diff --git a/Readme.md b/Readme.md index 24fa6e4d..bc93c370 100644 --- a/Readme.md +++ b/Readme.md @@ -79,27 +79,28 @@ Let's create a simple hello world script in scala-cli that uses terminal21 serve ```scala import org.terminal21.client.* import org.terminal21.client.components.* +// std components like Paragraph, https://github.com/kostaskougios/terminal21-restapi/blob/main/terminal21-ui-std/src/main/scala/org/terminal21/client/components/StdElement.scala +import org.terminal21.client.components.std.* -Sessions.withNewSession("hello-world", "Hello World Example"): session => +Sessions + .withNewSession("hello-world", "Hello World Example") + .connect: session => given ConnectedSession = session - Seq( - Paragraph(text = "Hello World!") - ).render() - session.waitTillUserClosesSession() + + Controller.noModel(Paragraph(text = "Hello World!")).render() + // since this is a read-only UI, we can exit the app but leave the session open on the UI for the user to examine the data. + session.leaveSessionOpenAfterExiting() ``` If we run this, then we can point our browser to the server, and we will see this UI: ![hello world ui](docs/images/hello-world.png) -The script will wait until the user clicks the close button, which then will invalidate the -session it has with the server and terminate the app. - -![hello world ui](docs/images/hello-world-terminated.png) +The script will wait until the user clicks the close button and then the script will terminate. # Usecases -Due to it's client-server architecture, terminal21 gives a UI to scripts running i.e. on servers without a desktop environment and +Due to its client-server architecture, terminal21 gives a UI to scripts running i.e. on servers without a desktop environment and can be used for things like: - creating text file editors which run on desktop-less servers but still allows us to edit the text file on our browser, see [textedit.sc](example-scripts/textedit.sc) @@ -122,7 +123,7 @@ can be used for things like: # Available UI Components -Standard html elements +Standard html elements like paragraphs, headers, cookies etc [Std](docs/std.md) Generic components for buttons, menus, forms, text, grids, tables: @@ -139,6 +140,14 @@ Maths: Spark: [Spark](docs/spark.md) + +# Apps running on server + +User applications can run on the terminal21 server so that they are always available. The api is the same but a bit of extra wiring is required +for the terminal21 server to be able to use them. + +See [running apps on the server][docs/run-on-server.md] + # Architecture Terminal21 consist of : @@ -156,7 +165,7 @@ the state in the client scripts. terminal21 ui components are immutable from v0.20. Use `component.withX(...).renderChanges()` to modify a component and render it. Note that the original `component` is not changed. -Also when getting a value of i.e. an Input, use `myInput.current.value`. `current` makes sure we read the component with +Also, when getting a value of i.e. an Input, use `myInput.current.value`. `current` makes sure we read the component with all changes that may have occurred at the browser and all the changes we did on our script. # Need help? @@ -164,6 +173,17 @@ all changes that may have occurred at the browser and all the changes we did on Please use the [discussions](https://github.com/kostaskougios/terminal21-restapi/discussions) of the project to post any questions, comments or ideas. # Changelog + +## Version 0.30 + +- apps can now run on the server + server management bundled apps +- Cookie setter and reader. +- session builders refactoring for more flexible creation of sessions +- QuickTabs, QuickFormControl +- bug fix for old react state re-rendering on new session +- event iterators allows idiomatic handling of events and overhaul of the event handling for easier testing and easier development of larger apps +- MVC + ## Version 0.21 - more std and chakra components like Alert, Progress, Tooltip, Tabs. diff --git a/build.sbt b/build.sbt index 495aa8ae..a51dd017 100644 --- a/build.sbt +++ b/build.sbt @@ -1,11 +1,9 @@ -import sbt.librarymanagement.ModuleFilter - /** This build has different sections for each integration. I.e. an http4s section and a kafka section. These sections are not related to each other, please * examine the section you're interested in. */ -val scala3Version = "3.3.1" +val scala3Version = "3.3.3" -ThisBuild / version := "0.21" +ThisBuild / version := "0.30" ThisBuild / organization := "io.github.kostaskougios" name := "rest-api" ThisBuild / scalaVersion := scala3Version @@ -27,8 +25,9 @@ val FunctionsHelidonClient = "io.github.kostaskougios" %% "helidon-client" val FunctionsHelidonWsClient = "io.github.kostaskougios" %% "helidon-ws-client" % FunctionsVersion val FunctionsFibers = "io.github.kostaskougios" %% "fibers" % FunctionsVersion -val ScalaTest = "org.scalatest" %% "scalatest" % "3.2.15" % Test +val ScalaTest = "org.scalatest" %% "scalatest" % "3.2.18" % Test val Mockito = "org.mockito" % "mockito-all" % "2.0.2-beta" % Test +val Mockito510 = "org.scalatestplus" %% "mockito-5-10" % "3.2.18.0" % Test val Scala3Tasty = "org.scala-lang" %% "scala3-tasty-inspector" % scala3Version val CommonsText = "org.apache.commons" % "commons-text" % "1.10.0" val CommonsIO = "commons-io" % "commons-io" % "2.11.0" @@ -51,10 +50,10 @@ val HelidonServerLogging = "io.helidon.logging" % "helidon-logging-jul" val LogBack = "ch.qos.logback" % "logback-classic" % "1.4.14" val Slf4jApi = "org.slf4j" % "slf4j-api" % "2.0.9" -val SparkSql = ("org.apache.spark" %% "spark-sql" % "3.5.0" % "provided").cross(CrossVersion.for3Use2_13).exclude("org.scala-lang.modules", "scala-xml_2.13") +val SparkSql = ("org.apache.spark" %% "spark-sql" % "3.5.1" % "provided").cross(CrossVersion.for3Use2_13).exclude("org.scala-lang.modules", "scala-xml_2.13") val SparkScala3Fix = Seq( - "io.github.vincenzobaz" %% "spark-scala3-encoders" % "0.2.5", - "io.github.vincenzobaz" %% "spark-scala3-udf" % "0.2.5" + "io.github.vincenzobaz" %% "spark-scala3-encoders" % "0.2.6", + "io.github.vincenzobaz" %% "spark-scala3-udf" % "0.2.6" ).map(_.exclude("org.scala-lang.modules", "scala-xml_2.13")) // ----------------------------------------------------------------------------------------------- @@ -68,7 +67,7 @@ val commonSettings = Seq( lazy val `terminal21-server-client-common` = project .settings( commonSettings, - libraryDependencies ++= Seq( + libraryDependencies ++= Circe ++ Seq( ScalaTest, Slf4jApi, HelidonClientWebSocket, @@ -102,6 +101,19 @@ lazy val `terminal21-server` = project .dependsOn(`terminal21-ui-std-exports` % "compile->compile;test->test", `terminal21-server-client-common`) .enablePlugins(FunctionsRemotePlugin) +lazy val `terminal21-server-app` = project + .settings( + commonSettings, + libraryDependencies ++= Seq( + Mockito510 + ) + ) + .dependsOn( + `terminal21-server` % "compile->compile;test->test", + `terminal21-ui-std` % "compile->compile;test->test", + `terminal21-server-client-common` % "compile->compile;test->test" + ) + lazy val `terminal21-ui-std-exports` = project .settings( commonSettings, @@ -129,6 +141,7 @@ lazy val `terminal21-ui-std` = project libraryDependencies ++= Seq( ScalaTest, Mockito, + Mockito510, Slf4jApi, HelidonClient, FunctionsCaller, @@ -147,9 +160,10 @@ lazy val `terminal21-ui-std` = project lazy val `end-to-end-tests` = project .settings( commonSettings, + publish := {}, libraryDependencies ++= Seq(ScalaTest, LogBack) ) - .dependsOn(`terminal21-ui-std`, `terminal21-nivo`, `terminal21-mathjax`) + .dependsOn(`terminal21-ui-std` % "compile->compile;test->test", `terminal21-nivo`, `terminal21-mathjax`) lazy val `terminal21-nivo` = project .settings( @@ -185,6 +199,7 @@ lazy val `terminal21-mathjax` = project lazy val `terminal21-code-generation`: Project = project .settings( commonSettings, + publish := {}, libraryDependencies ++= Seq( ScalaTest, Scala3Tasty, diff --git a/docs/quick.md b/docs/quick.md index 3f1558d1..38e2a37d 100644 --- a/docs/quick.md +++ b/docs/quick.md @@ -5,6 +5,8 @@ simplify creation of this components. ## QuickTable +This class helps creating tables quickly. + ```scala val conversionTable = QuickTable().headers("To convert", "into", "multiply by") .caption("Imperial to metric conversion factors") @@ -14,3 +16,33 @@ val tableRows:Seq[Seq[String]] = Seq( ) conversionTable.rows(tableRows) ``` + +## QuickTabs + +This class simplifies the creation of tabs. + +```scala + +QuickTabs() + .withTabs("Tab 1", "Tab 2") + .withTabPanels( + Paragraph(text="Tab 1 content"), + Paragraph(text="Tab 2 content") + ) + +``` + +## QuickFormControl + +Simplifies creating forms. + +```scala +QuickFormControl() + .withLabel("Email address") + .withHelperText("We'll never share your email.") + .withInputGroup( + InputLeftAddon().withChildren(EmailIcon()), + emailInput, + InputRightAddon().withChildren(CheckCircleIcon()) + ) +``` \ No newline at end of file diff --git a/docs/run-on-server.md b/docs/run-on-server.md new file mode 100644 index 00000000..74e4a968 --- /dev/null +++ b/docs/run-on-server.md @@ -0,0 +1,32 @@ +# Running applications on the server + +To create an app that runs on the server, implement the `ServerSideApp` trait and then pass your implementation to the `start()` method of the server: + +```scala +class MyServerApp extends ServerSideApp: + override def name = "My Server App" + + override def description = "Some app that I want to be available when I start the server" + + override def createSession(serverSideSessions: ServerSideSessions, dependencies: Dependencies): Unit = + serverSideSessions + .withNewSession("my-server-app-session", name) + .connect: session => + given ConnectedSession = session + ... your app code ... +``` + +See for example the [default terminal21 apps](../terminal21-server-app/src/main/scala/org/terminal21/serverapp/bundled). + +Now make sure your app is included in the server's classpath and then pass it as an argument to `start()`, i.e. with this `scala-cli` script: + +```scala +//> ... +//> using dep MY_APP_DEP + +import org.terminal21.server.Terminal21Server + +Terminal21Server.start(apps = Seq(new MyServerApp)) +``` + +Now start the server and the app should be available in the app list of terminal21. diff --git a/docs/std.md b/docs/std.md index c08ba513..b0798a06 100644 --- a/docs/std.md +++ b/docs/std.md @@ -30,4 +30,19 @@ Header1(text = "Welcome to the std components demo/test") val output = Paragraph(text = "This will reflect what you type in the input") input.onChange: newValue => output.withText(newValue).renderChanges() +``` + +### Cookies + +Set a cookie: + +```scala +Cookie(name = "cookie-name", value = "cookie value") +``` + +Read a cookie: + +```scala +val cookieReader = CookieReader(key = "cookie-reader", name = "cookie-name") +val cookieValue = events.changedValue(cookieReader) ``` \ No newline at end of file diff --git a/docs/tutorial.md b/docs/tutorial.md index 7da7ad8d..3bebc76a 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -9,17 +9,31 @@ For a glimpse on what can be done with terminal21, please have a look at the [te Terminal21 is not meant as a way to create websites. It is rather meant to give UI's to the odd jobs that has to be performed by scripts and where it would require a lot of effort to create a dedicated web server with a UI. It is perfect for scripting for i.e. those internal odd tasks that have to be performed at your workplace or even for things you would -like to do on your box. And you won't have to write a single line of html or javascript. +like to do on your box or even maybe to present some code of yours running with a UI rather than a powerpoint +presentation. And you won't have to write a single line of html or javascript. This tutorial will use `scala-cli` but the same applies for `sbt` or `mill` projects that use the terminal21 libraries. If you have `scala-cli` installed on your box, you're good to go, there are no other requirements to run terminal21 scripts. Jdk and dependencies will be downloaded by `scala-cli` for us. -All example code is under `example-scripts` of this repo, feel free to check the repo and run them. +All example code is under `example-scripts` of this repo, feel free to checkout the repo and run them: + +```shell +git clone https://github.com/kostaskougios/terminal21-restapi.git +cd terminal21-restapi/example-scripts + +# start the server +./server.sc +# ... it will download dependencies & jdk and start the server. Point your browser to http://localhost:8080/ui/ + +# Open an other terminal window and +./hello-world.sc +# Have a look at your browser now. +``` ## Starting the terminal21 server -The easiest way to start the terminal21 server is to have a `scala-cli` script on the box where the server will run. +The easiest way to start the terminal21 server is to have a `scala-cli` script on the box where the server will run: [server.sc](../example-scripts/server.sc) @@ -28,6 +42,7 @@ The easiest way to start the terminal21 server is to have a `scala-cli` script o //> using jvm "21" //> using scala 3 +//> using javaOpt -Xmx128m //> using dep io.github.kostaskougios::terminal21-server:_VERSION_ import org.terminal21.server.Terminal21Server @@ -60,16 +75,24 @@ To do this we can create a [hello-world.sc](../example-scripts/hello-world.sc) i ```scala #!/usr/bin/env -S scala-cli project.scala +// ------------------------------------------------------------------------------ +// Hello world with terminal21. +// Run with ./hello-world.sc +// ------------------------------------------------------------------------------ import org.terminal21.client.* import org.terminal21.client.components.* +// std components like Paragraph, https://github.com/kostaskougios/terminal21-restapi/blob/main/terminal21-ui-std/src/main/scala/org/terminal21/client/components/StdElement.scala import org.terminal21.client.components.std.* -Sessions.withNewSession("hello-world", "Hello World Example"): session => - given ConnectedSession = session +Sessions + .withNewSession("hello-world", "Hello World Example") + .connect: session => + given ConnectedSession = session - Paragraph(text = "Hello World!").render() - session.leaveSessionOpenAfterExiting() + Controller.noModel(Paragraph(text = "Hello World!")).render() + // since this is a read-only UI, we can exit the app but leave the session open for the user to examine the page. + session.leaveSessionOpenAfterExiting() ``` The first line, `#!/usr/bin/env -S scala-cli project.scala`, makes our script runnable from the command line. @@ -89,21 +112,24 @@ Next it creates a session. Each session has a unique id (globally unique across title, "Hello World Example", that will be displayed on the browser. ```scala -Sessions.withNewSession("hello-world", "Hello World Example"): session => - ... +Sessions + .withNewSession("hello-world", "Hello World Example") + .connect: session => + ... ``` ![hello-world](images/hello-world.png) -Next is the actual user interface, in this example just a paragraph with a "Hello World!": +Next is the actual user interface, in this example just a paragraph with a "Hello World!". In order for it to be rendered, we +quickly construct a Controller (terminal21 uses an MVC architecture idiomatic to scala, more on this later on): ```scala -Paragraph(text = "Hello World!").render() +Controller.noModel(Paragraph(text = "Hello World!")).render() ``` -The `render()` method sends the UI to the server which in turn sends it to the terminal21 UI so that it is rendered. +The `render()` method sends the UI components to the server which in turn sends it to the terminal21 react frontend so that it is rendered. -Finally because this is just a presentation script (we don't expect any feedback from the user), we can terminate it but +Finally, because this is just a presentation script (we don't expect any feedback from the user), we can terminate it but inform terminal21 we want to leave the session open so that the user has a chance to see it. ```scala @@ -125,126 +151,215 @@ the progress bar and also give an informative message regarding which stage of t ```scala #!/usr/bin/env -S scala-cli project.scala -import org.terminal21.client.* +// ------------------------------------------------------------------------------ +// Universe creation progress bar demo +// Run with ./progress.sc +// ------------------------------------------------------------------------------ + +import org.terminal21.client.{*, given} import org.terminal21.client.components.* import org.terminal21.client.components.std.* import org.terminal21.client.components.chakra.* - -Sessions.withNewSession("universe-generation", "Universe Generation Progress"): session => - given ConnectedSession = session - - val msg = Paragraph(text = "Generating universe ...") - val progress = Progress(value = 1) - - Seq(msg, progress).render() - - for i <- 1 to 100 do - val p = progress.withValue(i) - val m = - if i < 10 then msg - else if i < 30 then msg.withText("Creating atoms") - else if i < 50 then msg.withText("Big bang!") - else if i < 80 then msg.withText("Inflating") - else msg.withText("Life evolution") - - Seq(p, m).renderChanges() - Thread.sleep(100) - - // clear UI - session.clear() - Paragraph(text = "Universe ready!").render() +import org.terminal21.model.{ClientEvent, SessionOptions} + +Sessions + .withNewSession("universe-generation", "Universe Generation Progress") + .connect: session => + given ConnectedSession = session + + def components(model: Int, events: Events): MV[Int] = + val status = + if model < 10 then "Generating universe ..." + else if model < 30 then "Creating atoms" + else if model < 50 then "Big bang!" + else if model < 80 then "Inflating" + else "Life evolution" + + val msg = Paragraph(text = status) + val progress = Progress(value = model) + + MV( + model + 1, + Seq(msg, progress) + ) + + // send a ticker to update the progress bar + object Ticker extends ClientEvent + fiberExecutor.submit: + for _ <- 1 to 100 do + Thread.sleep(200) + session.fireEvent(Ticker) + + Controller(components) + .render(1) + .iterator + .takeWhile(_.model < 100) // terminate when model == 100 + .foreach(_ => ()) // and run it + // clear UI + session.render(Seq(Paragraph(text = "Universe ready!"))) + session.leaveSessionOpenAfterExiting() ``` -Here we create a paragraph and a progress bar. - +We start by declaring our components into a function: ```scala - val msg = Paragraph(text = "Generating universe ...") - val progress = Progress(value = 1) +def components(model: Int, events: Events): MV[Int] ``` +This kind of function is the standard way to create reusable UI components in terminal21. It takes the model (the progress so far as an Int between 0 and 100), +`Events` which holds any event that was received and returns with a model-view class `MV[Int]` because our model is an `Int`. The top-level component of a page +must have this signature (there are variations but it has to return an `MV`) but sub-components can be any functions with any number of arguments or return type. More on that later. -Then we render them for the first time on screen. When we want to add a new element to the UI, we use the `render()` method. When -we want to update an existing element we use the `renderChanges()` method. +We then create a paragraph and a progress bar. ```scala - Seq(msg, progress).render() + val msg = Paragraph(text = status) + val progress = Progress(value = model) ``` -Then we have our main loop where the calculations occur. We just use a `Thread.sleep` to simulate that some important task is being calculated. And we -update the progress bar and the message in our paragraph. +Finally, we return the changed model and view: + ```scala -val p = progress.withValue(i) -val m = ... msg.withText("Creating atoms") ... + MV( + model + 1, + Seq(msg, progress) + ) ``` -Note the `e.withX()` methods. Those help us change a value on a UI element. We get a copy of the UI element which we can render as an update: +Ok we got our component, but how does it know when to update the progress and increase the model by 1? +For that we need to send it a custom event. The `components` function is called once when we call the +`render()` method on the controller and once for each event received. Since we don't have any UI component +that may send an event, we will send it ourselfs in a separate fiber: ```scala -Seq(p, m).renderChanges() +// send a ticker to update the progress bar +object Ticker extends ClientEvent +fiberExecutor.submit: + for _ <- 1 to 100 do + Thread.sleep(200) + session.fireEvent(Ticker) ``` -Finally, when the universe is ready, we just clear the UI and render a paragraph before we exit. +Remember the `events: Events` in our `components` function? This will contain the `Ticker` event, but it is of no use, so +the components function ignores it. + +Now we can create the `Controller` and iterate through all events: ```scala -session.clear() -Paragraph(text = "Universe ready!").render() +Controller(components) + .render(1) // render takes the initial model value, in this case our model is the progress as an Int between 0 and 100. We start with 1 and increment it in the components function + .iterator // this is a blocking iterator with events. If there is no event it will block. + .takeWhile(_.model < 100) // terminate when model == 100 + .foreach(_ => ()) // and run it ``` + +Thats it. We have a progress bar that displays different messages depending on the stage of our universe creation. And our code would +also be easily testable. More on tests later on. + ## Handling clicks -Some UI elements allow us to attach an `onClick` handler. When the user clicks the element, our scala code runs. +Some UI elements like `Button` are clickable. When the user clicks the element, our controller gets an OnClick event. -Let's see for example [on-click.sc](../example-scripts/on-click.sc). We will create a paragraph and a button. When the +Let's see for example [mvc-click-form.sc](../example-scripts/mvc-click-form.sc). We will create a paragraph and a button. When the user clicks the button, the paragraph text will change and the script will exit. +We will create the Page class we mentioned previously, makes it more structured and easier to test. + ```scala #!/usr/bin/env -S scala-cli project.scala +// ------------------------------------------------------------------------------ +// MVC demo that handles a button click +// Run with ./mvc-click-form.sc +// ------------------------------------------------------------------------------ + import org.terminal21.client.* import org.terminal21.client.components.* import org.terminal21.client.components.std.* import org.terminal21.client.components.chakra.* +import org.terminal21.model.SessionOptions + +Sessions + .withNewSession("mvc-click-form", "MVC form with a button") + .connect: session => + given ConnectedSession = session + new ClickPage(ClickForm(false)).run() match + case None => // the user closed the app + case Some(model) => println(s"model = $model") + + Thread.sleep(1000) // wait a bit so that the user can see the change in the UI + +/** Our model + * + * @param clicked + * will be set to true when the button is clicked + */ +case class ClickForm(clicked: Boolean) + +/** One nice way to structure the code (that simplifies testing too) is to create a class for every page in the user interface. In this instance, we create a + * page for the click form to be displayed. All components are in `components` method. The controller is in the `controller` method and we can run to get the + * result in the `run` method. We can use these methods in unit tests to test what is rendered and how events are processed respectively. + */ +class ClickPage(initialForm: ClickForm)(using ConnectedSession): + def run(): Option[ClickForm] = controller.render(initialForm).run() + + def components(form: ClickForm, events: Events): MV[ClickForm] = + val button = Button(key = "click-me", text = "Please click me") + val updatedForm = form.copy( + clicked = events.isClicked(button) + ) + val msg = Paragraph(text = if updatedForm.clicked then "Button clicked!" else "Waiting for user to click the button") + + MV( + updatedForm, + Seq(msg, button), + terminate = updatedForm.clicked // terminate the event iteration + ) + + def controller: Controller[ClickForm] = Controller(components) +``` -Sessions.withNewSession("on-click-example", "On Click Handler"): session => - given ConnectedSession = session +We create the paragraph and button. Components like the `Button` that receive events must have a unique key, so we set that to "click-me": - @volatile var exit = false - val msg = Paragraph(text = "Waiting for user to click the button") - val button = Button(text = "Please click me").onClick: () => - msg.withText("Button clicked.").renderChanges() - exit = true +```scala +val button = Button(key = "click-me", text = "Please click me") +val msg = Paragraph(text = if updatedForm.clicked then "Button clicked!" else "Waiting for user to click the button") +``` - Seq(msg, button).render() +If the button is clicked, we update our model accordingly: - session.waitTillUserClosesSessionOr(exit) +```scala +val updatedForm = form.copy( + clicked = events.isClicked(button) +) ``` -First we create the paragraph and button. We attach an `onClick` handler on the button: +Finally we return the `MV` with our model and view. Note that we inform the controller we want to terminate the event iteration when the button is clicked: ```scala - val button = Button(text = "Please click me").onClick: () => - msg.withText("Button clicked.").renderChanges() - exit = true +MV( + updatedForm, + Seq(msg, button), + terminate = updatedForm.clicked // terminate the event iteration +) ``` -Here we change the paragraph text and also update `exit` to `true`. - -Our script waits until var `exit` becomes true and then terminates. +We are good now to run our page: ```scala -session.waitTillUserClosesSessionOr(exit) + def run(): Option[ClickForm] = controller.render(initialForm).run() ``` -Now if we run it with `./on-click.sc` and click the button, the script will terminate. +The controller renders the form with an initial model of `initialForm`. This effectively just calls our `def components(form: ClickForm, events: Events)` with +an `InitialRender` event and form=initialForm. And then sends the resulting view to the terminal21 server. -## Reading updated values +Now if we run it with `./on-click.sc` and click the button, the script will terminate with an updated message in the paragraph. -Some UI element values, like input boxes, can be changed by the user. We can read the changed value at any point of our -code or install an onChange handler so that we read the value as soon as the user changes it. +## Reading updated values -Let's see how we can just read the value. The following script will create an email input box and a button. Whenever -the button is pressed, it will read the email and create a new paragraph with the email value. +Some UI element values, like input boxes, can be changed by the user. We can read the changed value and update our model accordingly. -[read-changed-value.sc](../example-scripts/read-changed-value.sc) +Lets create a form with an inputbox where the user can enter his/her email and a submit button. Lets follow our Page & Form class approach, it may make +our code a bit longer but also more structured and easier to test. -![read-value](images/tutorial/read-value.png) +[mvc-user-form.sc](../example-scripts/mvc-user-form.sc) ```scala #!/usr/bin/env -S scala-cli project.scala @@ -254,88 +369,144 @@ import org.terminal21.client.components.* import org.terminal21.client.components.std.Paragraph import org.terminal21.client.components.chakra.* -Sessions.withNewSession("read-changed-value-example", "Read Changed Value"): session => - given ConnectedSession = session - - val email = Input(`type` = "email", value = "my@email.com") - val output = Box() - - Seq( - FormControl().withChildren( - FormLabel(text = "Email address"), - InputGroup().withChildren( - InputLeftAddon().withChildren(EmailIcon()), - email +// ------------------------------------------------------------------------------ +// MVC demo with an email form +// Run with ./mvc-user-form.sc +// ------------------------------------------------------------------------------ + +Sessions + .withNewSession("mvc-user-form", "MVC example with a user form") + .connect: session => + given ConnectedSession = session + new UserPage(UserForm("my@email.com", false)).run match + case Some(submittedUser) => + println(s"Submitted: $submittedUser") + case None => + println("User closed session without submitting the form") + +/** Our model for the form */ +case class UserForm( + email: String, // the email + submitted: Boolean // true if user clicks the submit button, false otherwise +) + +/** One nice way to structure the code (that simplifies testing too) is to create a class for every page in the user interface. In this instance, we create a + * page for the user form to be displayed. All components are in `components` method. The controller is in the `controller` method and we can run to get the + * result in the `run` method. We can use these methods in unit tests to test what is rendered and how events are processed respectively. + */ +class UserPage(initialForm: UserForm)(using ConnectedSession): + + /** Runs the form and returns the results + * @return + * if None, the user didn't submit the form (i.e. closed the session), if Some(userForm) the user submitted the form. + */ + def run: Option[UserForm] = + controller.render(initialForm).run().filter(_.submitted) + + /** @return + * all the components that should be rendered for the page + */ + def components(form: UserForm, events: Events): MV[UserForm] = + val emailInput = Input(key = "email", `type` = "email", defaultValue = initialForm.email) + val submitButton = Button(key = "submit", text = "Submit") + + val updatedForm = form.copy( + email = events.changedValue(emailInput, form.email), + submitted = events.isClicked(submitButton) + ) + + val output = Paragraph(text = if events.isChangedValue(emailInput) then s"Email changed: ${updatedForm.email}" else "Please modify the email.") + + MV( + updatedForm, + Seq( + QuickFormControl() + .withLabel("Email address") + .withInputGroup( + InputLeftAddon().withChildren(EmailIcon()), + emailInput + ) + .withHelperText("We'll never share your email."), + submitButton, + output ), - FormHelperText(text = "We'll never share your email.") - ), - Button(text = "Read Value").onClick: () => - val value = email.current.value - output.current.addChildren(Paragraph(text = s"The value now is $value")).renderChanges() - , - output - ).render() - - session.waitTillUserClosesSession() + terminate = updatedForm.submitted // terminate the form when the submit button is clicked + ) + + def controller: Controller[UserForm] = Controller(components) ``` -The important bit is this: +The important bit is here: ```scala - Button(text = "Read Value").onClick: () => - val value = email.current.value - output.current.addChildren(Paragraph(text = s"The value now is $value")).renderChanges() +val emailInput = Input(key = "email", `type` = "email", defaultValue = initialForm.email) +val submitButton = Button(key = "submit", text = "Submit") + +val updatedForm = form.copy( + email = events.changedValue(emailInput, form.email), + submitted = events.isClicked(submitButton) +) + +val output = Paragraph(text = if events.isChangedValue(emailInput) then s"Email changed: ${updatedForm.email}" else "Please modify the email.") ``` -When the button is clicked, we get the current state of the `email` input box via `email.current`. And then get it's value, `email.current.value`. +When we update the model, we set `email = events.changedValue(emailInput, form.email)`. If the event was an `OnChange` event for our `emailInput`, this will set the email +to the changed value. If not it will revert back to the `form.email`, effectively leaving the email unchanged. -Also in order to append a new paragraph to the `output`, we get the current state of it (which includes any previous paragraphs we have added) and then -add a paragraph as a new child. Then we render the changes of `output` which includes the paragraphs. +## Creating reusable UI components. -We can now give it a try: `./read-changed-value.sc` +When we create user interfaces, often we want to reuse our own components. -We can also add an `onChange` event handler on our input box and get the value whenever the user changes it. +For instance we may want a component that asks the name of a `Person`. But we want to also be able +to add this component inside another component that is a table of `Seq[Person]` which lists all people and allows the user +to edit them. -[on-change.sc](../example-scripts/on-change.sc) +With terminal21, a component is just a function. It would normally take a model and `Events` but not necessarily, i.e. there can +be components that don't have to process events. Also the return +value is up to us, usually we would need to return at least a `UiElement` like `Paragraph` but many times return the updated model too. +The component that renders a page should return `MV[Model]` but the rest of the components can return what they see fit. -```scala -#!/usr/bin/env -S scala-cli project.scala +Let's see the `Person` example. Here we have 2 components, `personComponent` that asks for the name of a particular `Person` and +`peopleComponent` that renders a table with a Seq[Person], using the `personComponent`. -import org.terminal21.client.* -import org.terminal21.client.components.* -import org.terminal21.client.components.std.Paragraph -import org.terminal21.client.components.chakra.* +```scala +case class Person(id: Int, name: String) +def personComponent(person: Person, events: Events): MV[Person] = + val nameInput = Input(s"person-${person.id}", defaultValue = person.name) + val component = Box() + .withChildren( + Text(text = "Name"), + nameInput + ) + MV( + person.copy( + name = events.changedValue(nameInput, person.name) + ), + component + ) + +def peopleComponent(people: Seq[Person], events: Events): MV[Seq[Person]] = + val peopleComponents = people.map(p => personComponent(p, events)) + val component = QuickTable("people") + .withRows(peopleComponents.map(p => Seq(p.view))) + MV(peopleComponents.map(_.model), component) +``` -Sessions.withNewSession("on-change-example", "On Change event handler"): session => - given ConnectedSession = session +`personComponent` take a `Person` model, renders an input box for the person's name and also if there is a change event for this input it updates the model accordingly. +Now `peopleComponent` creates a table and each row contains the `personComponent`. The `Seq[Person]` model is updated accordingly depending on changes propagating from `personComponent`. - val output = Paragraph(text = "Please modify the email.") - val email = Input(`type` = "email", value = "my@email.com").onChange: v => - output.withText(s"Email value : $v").renderChanges() +## Testing - Seq( - FormControl().withChildren( - FormLabel(text = "Email address"), - InputGroup().withChildren( - InputLeftAddon().withChildren(EmailIcon()), - email - ), - FormHelperText(text = "We'll never share your email.") - ), - output - ).render() +So far we have seen that structuring our code to a `components`, `controller` and `run()` method allows us to test them easily. - session.waitTillUserClosesSession() -``` +The `components` is just a function that returns the model and the UI components, so we can easily assert what +is rendered based on the model value and if the model is updated correctly based on events. Terminal21's UI components +are just case classes that can easily be compared. -The important bit are these lines: +If you would like to find out more please see this 2 page app, a login and loggedin page, along with their tests: -```scala - val output = Paragraph(text = "Please modify the email.") - val email = Input(`type` = "email", value = "my@email.com").onChange: v => - output.withText(s"Email value : $v").renderChanges() -``` +[LoginPage & LoggedInPage](../end-to-end-tests/src/main/scala/tests/LoginPage.scala) -For the `Input` box, we add an `onChange` handler that gets the new value as `v`. We then use the value to update the paragraph. +[LoginPageTest](../end-to-end-tests/src/test/scala/tests/LoginPageTest.scala) -This script can be run as `./on-change.sc`. +[LoggedInTest](../end-to-end-tests/src/test/scala/tests/LoggedInTest.scala) diff --git a/end-to-end-tests/src/main/scala/tests/ChakraComponents.scala b/end-to-end-tests/src/main/scala/tests/ChakraComponents.scala index 328ed5b6..8599c988 100644 --- a/end-to-end-tests/src/main/scala/tests/ChakraComponents.scala +++ b/end-to-end-tests/src/main/scala/tests/ChakraComponents.scala @@ -2,39 +2,47 @@ package tests import org.terminal21.client.* import org.terminal21.client.components.chakra.* -import org.terminal21.client.components.render import org.terminal21.client.components.std.Paragraph import tests.chakra.* -import java.util.concurrent.CountDownLatch -import java.util.concurrent.atomic.AtomicBoolean - @main def chakraComponents(): Unit = - val keepRunning = new AtomicBoolean(true) - - while keepRunning.get() do + def loop(): Unit = println("Starting new session") - Sessions.withNewSession("chakra-components", "Chakra Components"): session => - keepRunning.set(false) - given ConnectedSession = session - - val latch = new CountDownLatch(1) - - // react tests reset the session to clear state - val krButton = Button(text = "Keep Running").onClick: () => - keepRunning.set(true) - latch.countDown() - - (Overlay.components ++ Forms.components ++ Editables.components ++ Stacks.components ++ Grids.components ++ Buttons.components( - latch - ) ++ Etc.components ++ MediaAndIcons.components ++ DataDisplay.components ++ Typography.components ++ Feedback.components ++ Disclosure.components ++ Navigation.components ++ Seq( - krButton - )) - .render() - - println("Waiting for button to be pressed for 1 hour") - session.waitTillUserClosesSessionOr(latch.getCount == 0) - if !session.isClosed then - session.clear() - Paragraph(text = "Terminated").render() - Thread.sleep(1000) + Sessions + .withNewSession("chakra-components", "Chakra Components") + .connect: session => + given ConnectedSession = session + + def components(m: ChakraModel, events: Events): MV[ChakraModel] = + // react tests reset the session to clear state + val krButton = Button("reset", text = "Reset state") + + val bcs = Buttons.components(m, events) + val elements = Overlay.components(events) ++ Forms.components( + m, + events + ) ++ Editables.components( + events + ) ++ Stacks.components ++ Grids.components ++ bcs.view ++ Etc.components ++ MediaAndIcons.components ++ DataDisplay.components ++ Typography.components ++ Feedback.components ++ Disclosure.components ++ + Navigation.components(events) ++ Seq( + krButton + ) + + val modifiedModel = bcs.model + val model = modifiedModel.copy( + rerun = events.isClicked(krButton) + ) + MV( + model, + elements, + model.rerun || model.terminate + ) + + Controller(components).render(ChakraModel()).iterator.lastOption.map(_.model) match + case Some(m) if m.rerun => + Controller.noModel(Seq(Paragraph(text = "chakra-session-reset"))).render(()) + Thread.sleep(500) + loop() + case _ => + + loop() diff --git a/end-to-end-tests/src/main/scala/tests/LoginPage.scala b/end-to-end-tests/src/main/scala/tests/LoginPage.scala new file mode 100644 index 00000000..2b9eaae6 --- /dev/null +++ b/end-to-end-tests/src/main/scala/tests/LoginPage.scala @@ -0,0 +1,114 @@ +package tests + +import org.terminal21.client.components.* +import org.terminal21.client.components.chakra.* +import org.terminal21.client.components.std.{NewLine, Paragraph} +import org.terminal21.client.* + +@main def loginFormApp(): Unit = + Sessions + .withNewSession("login-form", "Login Form") + .connect: session => + given ConnectedSession = session + val confirmed = for + login <- new LoginPage().run() + isYes <- new LoggedIn(login).run() + yield isYes + + if confirmed.getOrElse(false) then println("User confirmed the details") else println("Not confirmed") + +case class LoginForm(email: String = "my@email.com", pwd: String = "mysecret", submitted: Boolean = false, submittedInvalidEmail: Boolean = false): + def isValidEmail: Boolean = email.contains("@") + +/** The login form. Displays an email and password input and a submit button. When run() it will fill in the Login(email,pwd) model. + */ +class LoginPage(using session: ConnectedSession): + private val initialModel = LoginForm() + val okIcon = CheckCircleIcon(color = Some("green")) + val notOkIcon = WarningTwoIcon(color = Some("red")) + val emailInput = Input(key = "email", `type` = "email", defaultValue = initialModel.email) + + val submitButton = Button(key = "submit", text = "Submit") + + val passwordInput = Input(key = "password", `type` = "password", defaultValue = initialModel.pwd) + + val errorsBox = Box() + val errorMsgInvalidEmail = Paragraph(text = "Invalid Email", style = Map("color" -> "red")) + + def run(): Option[LoginForm] = + controller + .render(initialModel) + .iterator + .map(_.model) + .tapEach: form => + println(form) + .dropWhile(!_.submitted) + .nextOption() + + def components(form: LoginForm, events: Events): MV[LoginForm] = + println(events.event) + val isValidEmail = form.isValidEmail + val newForm = form.copy( + email = events.changedValue(emailInput, form.email), + pwd = events.changedValue(passwordInput, form.pwd), + submitted = events.isClicked(submitButton) && isValidEmail, + submittedInvalidEmail = events.isClicked(submitButton) && !isValidEmail + ) + val view = Seq( + QuickFormControl() + .withLabel("Email address") + .withHelperText("We'll never share your email.") + .withInputGroup( + InputLeftAddon().withChildren(EmailIcon()), + emailInput, + InputRightAddon().withChildren(if newForm.isValidEmail then okIcon else notOkIcon) + ), + QuickFormControl() + .withLabel("Password") + .withHelperText("Don't share with anyone") + .withInputGroup( + InputLeftAddon().withChildren(ViewOffIcon()), + passwordInput + ), + submitButton, + errorsBox.withChildren(if newForm.submittedInvalidEmail then errorMsgInvalidEmail else errorsBox) + ) + MV( + newForm, + view + ) + + def controller: Controller[LoginForm] = Controller(components) + +class LoggedIn(login: LoginForm)(using session: ConnectedSession): + val yesButton = Button(key = "yes-button", text = "Yes") + + val noButton = Button(key = "no-button", text = "No") + + val emailDetails = Text(text = s"email : ${login.email}") + val passwordDetails = Text(text = s"password : ${login.pwd}") + + def run(): Option[Boolean] = + controller.render(false).iterator.lastOption.map(_.model) + + def components(isYes: Boolean, events: Events): MV[Boolean] = + val view = Seq( + Paragraph().withChildren( + Text(text = "Are your details correct?"), + NewLine(), + emailDetails, + NewLine(), + passwordDetails + ), + HStack().withChildren(yesButton, noButton) + ) + MV( + events.isClicked(yesButton), + view, + events.isClicked(yesButton) || events.isClicked(noButton) + ) + + /** @return + * A controller with a boolean value, true if user clicked "Yes", false for "No" + */ + def controller: Controller[Boolean] = Controller(components) diff --git a/end-to-end-tests/src/main/scala/tests/MathJaxComponents.scala b/end-to-end-tests/src/main/scala/tests/MathJaxComponents.scala index 731e437a..254bc0e4 100644 --- a/end-to-end-tests/src/main/scala/tests/MathJaxComponents.scala +++ b/end-to-end-tests/src/main/scala/tests/MathJaxComponents.scala @@ -6,17 +6,22 @@ import org.terminal21.client.components.chakra.* import org.terminal21.client.components.mathjax.* @main def mathJaxComponents(): Unit = - Sessions.withNewSession("mathjax-components", "MathJax Components", MathJaxLib): session => - given ConnectedSession = session - Seq( - HStack().withChildren( - Text(text = "Lets write some math expressions that will wow everybody!"), - MathJax(expression = """\[\sum_{n = 200}^{1000}\left(\frac{20\sqrt{n}}{n}\right)\]""") - ), - MathJax(expression = """Everyone knows this one : \(ax^2 + bx + c = 0\). But how about this? \(\sum_{i=1}^n i^3 = ((n(n+1))/2)^2 \)"""), - MathJax( - expression = """Does it align correctly? \(ax^2 + bx + c = 0\) It does provided CHTML renderer is used.""", - style = Map("backgroundColor" -> "gray") + Sessions + .withNewSession("mathjax-components", "MathJax Components") + .andLibraries(MathJaxLib) + .connect: session => + given ConnectedSession = session + + val components = Seq( + HStack().withChildren( + Text(text = "Lets write some math expressions that will wow everybody!"), + MathJax(expression = """\[\sum_{n = 200}^{1000}\left(\frac{20\sqrt{n}}{n}\right)\]""") + ), + MathJax(expression = """Everyone knows this one : \(ax^2 + bx + c = 0\). But how about this? \(\sum_{i=1}^n i^3 = ((n(n+1))/2)^2 \)"""), + MathJax( + expression = """Does it align correctly? \(ax^2 + bx + c = 0\) It does provided CHTML renderer is used.""", + style = Map("backgroundColor" -> "gray") + ) ) - ).render() - session.leaveSessionOpenAfterExiting() + Controller.noModel(components).render() + session.leaveSessionOpenAfterExiting() diff --git a/end-to-end-tests/src/main/scala/tests/NivoComponents.scala b/end-to-end-tests/src/main/scala/tests/NivoComponents.scala index 2eb2aaa6..c1a5b689 100644 --- a/end-to-end-tests/src/main/scala/tests/NivoComponents.scala +++ b/end-to-end-tests/src/main/scala/tests/NivoComponents.scala @@ -1,12 +1,16 @@ package tests -import org.terminal21.client.components.nivo.* import org.terminal21.client.* import org.terminal21.client.components.* import tests.nivo.{ResponsiveBarChart, ResponsiveLineChart} @main def nivoComponents(): Unit = - Sessions.withNewSession("nivo-components", "Nivo Components", NivoLib): session => - given ConnectedSession = session - (ResponsiveBarChart() ++ ResponsiveLineChart()).render() - session.waitTillUserClosesSession() + Sessions + .withNewSession("nivo-components", "Nivo Components") + .andLibraries(NivoLib) + .connect: session => + given ConnectedSession = session + + val components = ResponsiveBarChart() ++ ResponsiveLineChart() + Controller.noModel(components).render() + session.leaveSessionOpenAfterExiting() diff --git a/end-to-end-tests/src/main/scala/tests/RunAll.scala b/end-to-end-tests/src/main/scala/tests/RunAll.scala new file mode 100644 index 00000000..fa0eabba --- /dev/null +++ b/end-to-end-tests/src/main/scala/tests/RunAll.scala @@ -0,0 +1,27 @@ +package tests + +import functions.fibers.Fiber +import org.terminal21.client.* + +@main def runAll(): Unit = + Seq( + submit: + chakraComponents() + , + submit: + stdComponents() + , + submit: + loginFormApp() + , + submit: + mathJaxComponents() + , + submit: + nivoComponents() + ).foreach(_.get()) + +private def submit(f: => Unit): Fiber[Unit] = + fiberExecutor.submit: + try f + catch case t: Throwable => t.printStackTrace() diff --git a/end-to-end-tests/src/main/scala/tests/StdComponents.scala b/end-to-end-tests/src/main/scala/tests/StdComponents.scala index a822856f..4f8b14f2 100644 --- a/end-to-end-tests/src/main/scala/tests/StdComponents.scala +++ b/end-to-end-tests/src/main/scala/tests/StdComponents.scala @@ -5,32 +5,40 @@ import org.terminal21.client.components.* import org.terminal21.client.components.std.* @main def stdComponents(): Unit = - Sessions.withNewSession("std-components", "Std Components"): session => - given ConnectedSession = session + Sessions + .withNewSession("std-components", "Std Components") + .connect: session => + given ConnectedSession = session - val input = Input(defaultValue = Some("Please enter your name")) - val output = Paragraph(text = "This will reflect what you type in the input") - input.onChange: newValue => - output.withText(newValue).renderChanges() + def components(events: Events) = + val input = Input(key = "name", defaultValue = "Please enter your name") + val cookieReader = CookieReader(key = "cookie-reader", name = "std-components-test-cookie") - Seq( - Header1(text = "header1 test"), - Header2(text = "header2 test"), - Header3(text = "header3 test"), - Header4(text = "header4 test"), - Header5(text = "header5 test"), - Header6(text = "header6 test"), - Paragraph(text = "Hello World!").withChildren( - NewLine(), - Span(text = "Some more text"), - Em(text = " emphasized!"), - NewLine(), - Span(text = "And the last line") - ), - Paragraph(text = "A Form ").withChildren( - input - ), - output - ).render() + val outputMsg = events.changedValue(input, "This will reflect what you type in the input") + val output = Paragraph(text = outputMsg) - session.waitTillUserClosesSession() + val cookieMsg = events.changedValue(cookieReader).map(newValue => s"Cookie value $newValue").getOrElse("This will display the value of the cookie") + val cookieValue = Paragraph(text = cookieMsg) + + Seq( + Header1(text = "header1 test"), + Header2(text = "header2 test"), + Header3(text = "header3 test"), + Header4(text = "header4 test"), + Header5(text = "header5 test"), + Header6(text = "header6 test"), + Paragraph(text = "Hello World!").withChildren( + NewLine(), + Span(text = "Some more text"), + Em(text = " emphasized!"), + NewLine(), + Span(text = "And the last line") + ), + Paragraph(text = "A Form").withChildren(input), + output, + Cookie(name = "std-components-test-cookie", value = "test-cookie-value"), + cookieReader, + cookieValue + ) + + Controller.noModel(components).render().iterator.lastOption diff --git a/end-to-end-tests/src/main/scala/tests/chakra/Buttons.scala b/end-to-end-tests/src/main/scala/tests/chakra/Buttons.scala index 7e3674e1..5d4c3759 100644 --- a/end-to-end-tests/src/main/scala/tests/chakra/Buttons.scala +++ b/end-to-end-tests/src/main/scala/tests/chakra/Buttons.scala @@ -1,23 +1,23 @@ package tests.chakra -import org.terminal21.client.ConnectedSession import org.terminal21.client.components.* import org.terminal21.client.components.chakra.* +import org.terminal21.client.* import tests.chakra.Common.* import java.util.concurrent.CountDownLatch object Buttons: - def components(latch: CountDownLatch)(using session: ConnectedSession): Seq[UiElement] = + def components(m: ChakraModel, events: Events): MV[ChakraModel] = val box1 = commonBox(text = "Buttons") - val exitButton = Button(text = "Click to exit program", colorScheme = Some("red")) - Seq( - box1, - exitButton.onClick: () => - Seq( - box1.withText("Exit Clicked!"), - exitButton.withText("Stopping...").withColorScheme(Some("green")) - ).renderChanges() - Thread.sleep(1000) - latch.countDown() + val exitButton = Button(key = "exit-button", text = "Click to exit program", colorScheme = Some("red")) + val model = m.copy( + terminate = events.isClicked(exitButton) + ) + MV( + model, + Seq( + box1, + exitButton + ) ) diff --git a/end-to-end-tests/src/main/scala/tests/chakra/ChakraModel.scala b/end-to-end-tests/src/main/scala/tests/chakra/ChakraModel.scala new file mode 100644 index 00000000..8d5182be --- /dev/null +++ b/end-to-end-tests/src/main/scala/tests/chakra/ChakraModel.scala @@ -0,0 +1,7 @@ +package tests.chakra + +case class ChakraModel( + rerun: Boolean = false, + email: String = "the-test-email@email.com", + terminate: Boolean = false +) diff --git a/end-to-end-tests/src/main/scala/tests/chakra/DataDisplay.scala b/end-to-end-tests/src/main/scala/tests/chakra/DataDisplay.scala index 6954315c..cec78561 100644 --- a/end-to-end-tests/src/main/scala/tests/chakra/DataDisplay.scala +++ b/end-to-end-tests/src/main/scala/tests/chakra/DataDisplay.scala @@ -1,22 +1,21 @@ package tests.chakra -import org.terminal21.client.ConnectedSession import org.terminal21.client.components.UiElement import org.terminal21.client.components.chakra.* import org.terminal21.client.components.std.NewLine import tests.chakra.Common.* object DataDisplay: - def components(using session: ConnectedSession): Seq[UiElement] = - val headAndFoot = Tr().withChildren( + def components: Seq[UiElement] = + def headAndFoot = Tr().withChildren( Th(text = "To convert"), Th(text = "into"), Th(text = "multiply by", isNumeric = true) ) val quickTable1 = QuickTable() - .headers("id", "name") + .withHeaders("id", "name") .caption("Quick Table Caption") - .rows( + .withRows( Seq( Seq(1, "Kostas"), Seq(2, "Andreas") @@ -30,7 +29,7 @@ object DataDisplay: Badge(text = "badge 3", size = "lg", colorScheme = Some("green")), Badge(text = "badge 4", variant = Some("outline"), colorScheme = Some("tomato")), Badge(text = "badge 4").withChildren( - Button(text = "test") + Button("test", text = "test") ) ), commonBox(text = "Quick Tables"), diff --git a/end-to-end-tests/src/main/scala/tests/chakra/Disclosure.scala b/end-to-end-tests/src/main/scala/tests/chakra/Disclosure.scala index a048f372..2f2c16d2 100644 --- a/end-to-end-tests/src/main/scala/tests/chakra/Disclosure.scala +++ b/end-to-end-tests/src/main/scala/tests/chakra/Disclosure.scala @@ -1,19 +1,18 @@ package tests.chakra -import org.terminal21.client.ConnectedSession import org.terminal21.client.components.UiElement import org.terminal21.client.components.chakra.* import org.terminal21.client.components.std.Paragraph import tests.chakra.Common.commonBox object Disclosure: - def components(using session: ConnectedSession): Seq[UiElement] = + def components: Seq[UiElement] = Seq( commonBox(text = "Tabs"), Tabs().withChildren( TabList().withChildren( - Tab(text = "tab-one", _selected = Map("color" -> "white", "bg" -> "blue.500")), - Tab(text = "tab-two", _selected = Map("color" -> "white", "bg" -> "green.400")), + Tab(text = "tab-one").withSelected(Map("color" -> "white", "bg" -> "blue.500")), + Tab(text = "tab-two").withSelected(Map("color" -> "white", "bg" -> "green.400")), Tab(text = "tab-three") ), TabPanels().withChildren( diff --git a/end-to-end-tests/src/main/scala/tests/chakra/Editables.scala b/end-to-end-tests/src/main/scala/tests/chakra/Editables.scala index 505f5edf..c0e3f5b8 100644 --- a/end-to-end-tests/src/main/scala/tests/chakra/Editables.scala +++ b/end-to-end-tests/src/main/scala/tests/chakra/Editables.scala @@ -1,28 +1,29 @@ package tests.chakra -import org.terminal21.client.ConnectedSession import org.terminal21.client.components.UiElement import org.terminal21.client.components.chakra.* +import org.terminal21.client.* import tests.chakra.Common.* object Editables: - def components(using session: ConnectedSession): Seq[UiElement] = - val status = Box(text = "This will reflect any changes in the form.") + def components(events: Events): Seq[UiElement] = + val editable1 = Editable(key = "editable1", defaultValue = "Please type here") + .withChildren( + EditablePreview(), + EditableInput() + ) - val editable1 = Editable(defaultValue = "Please type here").withChildren( - EditablePreview(), - EditableInput() - ) + val editable2 = Editable(key = "editable2", defaultValue = "For longer maybe-editable texts\nUse an EditableTextarea\nIt uses a textarea control.") + .withChildren( + EditablePreview(), + EditableTextarea() + ) - editable1.onChange: newValue => - status.withText(s"editable1 newValue = $newValue, verify editable1.value = ${editable1.current.value}").renderChanges() + val statusMsg = (events.changedValue(editable1).map(newValue => s"editable1 newValue = $newValue") ++ events + .changedValue(editable2) + .map(newValue => s"editable2 newValue = $newValue")).headOption.getOrElse("This will reflect any changes in the form.") - val editable2 = Editable(defaultValue = "For longer maybe-editable texts\nUse an EditableTextarea\nIt uses a textarea control.").withChildren( - EditablePreview(), - EditableTextarea() - ) - editable2.onChange: newValue => - status.withText(s"editable2 newValue = $newValue, verify editable2.value = ${editable2.current.value}").renderChanges() + val status = Box(text = statusMsg) Seq( commonBox(text = "Editables"), diff --git a/end-to-end-tests/src/main/scala/tests/chakra/Etc.scala b/end-to-end-tests/src/main/scala/tests/chakra/Etc.scala index 42b930c1..76b83037 100644 --- a/end-to-end-tests/src/main/scala/tests/chakra/Etc.scala +++ b/end-to-end-tests/src/main/scala/tests/chakra/Etc.scala @@ -1,12 +1,11 @@ package tests.chakra -import org.terminal21.client.ConnectedSession import org.terminal21.client.components.UiElement import org.terminal21.client.components.chakra.* import tests.chakra.Common.* object Etc: - def components(using session: ConnectedSession): Seq[UiElement] = + def components: Seq[UiElement] = Seq( commonBox(text = "Center"), Center(text = "Center demo, not styled"), diff --git a/end-to-end-tests/src/main/scala/tests/chakra/Feedback.scala b/end-to-end-tests/src/main/scala/tests/chakra/Feedback.scala index 72fd6f7f..ba9c2749 100644 --- a/end-to-end-tests/src/main/scala/tests/chakra/Feedback.scala +++ b/end-to-end-tests/src/main/scala/tests/chakra/Feedback.scala @@ -1,12 +1,11 @@ package tests.chakra -import org.terminal21.client.ConnectedSession import org.terminal21.client.components.UiElement import org.terminal21.client.components.chakra.* import tests.chakra.Common.commonBox object Feedback: - def components(using session: ConnectedSession): Seq[UiElement] = + def components: Seq[UiElement] = Seq( commonBox(text = "Alerts"), VStack().withChildren( diff --git a/end-to-end-tests/src/main/scala/tests/chakra/Forms.scala b/end-to-end-tests/src/main/scala/tests/chakra/Forms.scala index f693b29c..8e60e550 100644 --- a/end-to-end-tests/src/main/scala/tests/chakra/Forms.scala +++ b/end-to-end-tests/src/main/scala/tests/chakra/Forms.scala @@ -1,87 +1,82 @@ package tests.chakra -import org.terminal21.client.ConnectedSession +import org.terminal21.client.* import org.terminal21.client.components.* import org.terminal21.client.components.chakra.* import tests.chakra.Common.* object Forms: - def components(using session: ConnectedSession): Seq[UiElement] = - val status = Box(text = "This will reflect any changes in the form.") + def components(m: ChakraModel, events: Events): Seq[UiElement] = val okIcon = CheckCircleIcon(color = Some("green")) val notOkIcon = WarningTwoIcon(color = Some("red")) - val emailRightAddOn = InputRightAddon().withChildren(okIcon) - - val email = Input(`type` = "email", value = "my@email.com") - email.onChange: newValue => - Seq( - status.withText(s"email input new value = $newValue, verify email.value = ${email.current.value}"), - if newValue.contains("@") then emailRightAddOn.withChildren(okIcon) else emailRightAddOn.withChildren(notOkIcon) - ).renderChanges() - - val description = Textarea(placeholder = "Please enter a few things about you") - description.onChange: newValue => - status.withText(s"description input new value = $newValue, verify description.value = ${description.current.value}").renderChanges() - - val select1 = Select(placeholder = "Please choose").withChildren( - Option_(text = "Male", value = "male"), - Option_(text = "Female", value = "female") - ) - - select1.onChange: newValue => - status.withText(s"select1 input new value = $newValue, verify select1.value = ${select1.current.value}").renderChanges() - - val select2 = Select(value = "1", bg = Some("tomato"), color = Some("black"), borderColor = Some("yellow")).withChildren( - Option_(text = "First", value = "1"), - Option_(text = "Second", value = "2") - ) - - val password = Input(`type` = "password", value = "mysecret") - val dob = Input(`type` = "datetime-local") - dob.onChange: newValue => - status.withText(s"dob = $newValue , verify dob.value = ${dob.current.value}").renderChanges() - - val color = Input(`type` = "color") - - color.onChange: newValue => - status.withText(s"color = $newValue , verify color.value = ${color.current.value}").renderChanges() - - val checkbox2 = Checkbox(text = "Check 2", defaultChecked = true) - checkbox2.onChange: newValue => - status.withText(s"checkbox2 checked is $newValue , verify checkbox2.checked = ${checkbox2.current.checked}").renderChanges() - - val checkbox1 = Checkbox(text = "Check 1") - checkbox1.onChange: newValue => - Seq( - status.withText(s"checkbox1 checked is $newValue , verify checkbox1.checked = ${checkbox1.current.checked}"), - checkbox2.withIsDisabled(newValue) - ).renderChanges() - - val switch1 = Switch(text = "Switch 1") - val switch2 = Switch(text = "Switch 2", defaultChecked = true) - - switch1.onChange: newValue => - Seq( - status.withText(s"switch1 checked is $newValue , verify switch1.checked = ${switch1.current.checked}"), - switch2.withIsDisabled(newValue) - ).renderChanges() - - val radioGroup = RadioGroup(defaultValue = "2").withChildren( - HStack().withChildren( - Radio(value = "1", text = "first"), - Radio(value = "2", text = "second"), - Radio(value = "3", text = "third") + val email = Input(key = "email", `type` = "email", defaultValue = m.email) + val description = Textarea(key = "textarea", placeholder = "Please enter a few things about you", defaultValue = "desc") + val select1 = Select(key = "male/female", placeholder = "Please choose") + .withChildren( + Option_(text = "Male", value = "male"), + Option_(text = "Female", value = "female") + ) + val select2 = + Select(key = "select-first-second", defaultValue = "1", bg = Some("tomato"), color = Some("black"), borderColor = Some("yellow")).withChildren( + Option_(text = "First", value = "1"), + Option_(text = "Second", value = "2") ) + val password = Input(key = "password", `type` = "password", defaultValue = "mysecret") + val dob = Input(key = "dob", `type` = "datetime-local") + val color = Input(key = "color", `type` = "color") + val checkbox2 = Checkbox(key = "cb2", text = "Check 2", defaultChecked = true) + val checkbox1 = Checkbox(key = "cb1", text = "Check 1") + + val newM = m.copy( + email = events.changedValue(email).getOrElse(m.email) ) - - radioGroup.onChange: newValue => - status.withText(s"radioGroup newValue=$newValue , verify radioGroup.value=${radioGroup.current.value}").renderChanges() + val switch1 = Switch(key = "sw1", text = "Switch 1") + val switch2 = Switch(key = "sw2", text = "Switch 2", defaultChecked = true) + val radioGroup = RadioGroup(key = "radio", defaultValue = "2") + .withChildren( + HStack().withChildren( + Radio(value = "1", text = "first"), + Radio(value = "2", text = "second"), + Radio(value = "3", text = "third") + ) + ) + val saveButton = Button(key = "save-button", text = "Save", colorScheme = Some("red")) + val cancelButton = Button(key = "cancel-button", text = "Cancel") + val formStatus = + (events + .changedValue(email) + .map(v => s"email input new value = $v") + .toSeq ++ events + .changedValue(description) + .map(v => s"description input new value = $v") ++ events + .changedValue(select1) + .map(v => s"select1 input new value = $v") ++ events + .changedValue(dob) + .map(v => s"dob = $v") ++ events + .changedValue(color) + .map(v => s"color = $v") ++ events + .changedBooleanValue(checkbox2) + .map(v => s"checkbox2 checked is $v") ++ events + .changedBooleanValue(checkbox1) + .map(v => s"checkbox1 checked is $v") ++ events + .changedBooleanValue(switch1) + .map(v => s"switch1 checked is $v") ++ events + .changedValue(radioGroup) + .map(v => s"radioGroup newValue=$v") ++ events + .ifClicked(saveButton, "Saved clicked") ++ events + .ifClicked(cancelButton, "Cancel clicked")).headOption + .getOrElse("This will reflect any changes in the form.") + + val status = Box(text = formStatus) + + val emailRightAddOn = InputRightAddon() + .withChildren(if newM.email.contains("@") then okIcon else notOkIcon) Seq( commonBox(text = "Forms"), FormControl().withChildren( - FormLabel(text = "Email address"), + FormLabel(text = "Test-Email-Address"), InputGroup().withChildren( InputLeftAddon().withChildren(EmailIcon()), email, @@ -132,16 +127,8 @@ object Forms: switch2 ), ButtonGroup(variant = Some("outline"), spacing = Some("24")).withChildren( - Button(text = "Save", colorScheme = Some("red")) - .onClick: () => - status - .withText( - s"Saved clicked. Email = ${email.current.value}, password = ${password.current.value}, dob = ${dob.current.value}, check1 = ${checkbox1.current.checked}, check2 = ${checkbox2.current.checked}, radio = ${radioGroup.current.value}, switch1 = ${switch1.current.checked}, switch2 = ${switch2.current.checked}" - ) - .renderChanges(), - Button(text = "Cancel") - .onClick: () => - status.withText("Cancel clicked").renderChanges() + saveButton, + cancelButton ), radioGroup, status diff --git a/end-to-end-tests/src/main/scala/tests/chakra/Grids.scala b/end-to-end-tests/src/main/scala/tests/chakra/Grids.scala index cff12e77..0e7eadf7 100644 --- a/end-to-end-tests/src/main/scala/tests/chakra/Grids.scala +++ b/end-to-end-tests/src/main/scala/tests/chakra/Grids.scala @@ -1,12 +1,11 @@ package tests.chakra -import org.terminal21.client.ConnectedSession import org.terminal21.client.components.UiElement import org.terminal21.client.components.chakra.{Box, SimpleGrid} import tests.chakra.Common.* object Grids: - def components(using session: ConnectedSession): Seq[UiElement] = + def components: Seq[UiElement] = val box1 = commonBox(text = "Simple grid") Seq( box1, diff --git a/end-to-end-tests/src/main/scala/tests/chakra/MediaAndIcons.scala b/end-to-end-tests/src/main/scala/tests/chakra/MediaAndIcons.scala index 21bfb2ac..fff9fbf3 100644 --- a/end-to-end-tests/src/main/scala/tests/chakra/MediaAndIcons.scala +++ b/end-to-end-tests/src/main/scala/tests/chakra/MediaAndIcons.scala @@ -1,12 +1,11 @@ package tests.chakra -import org.terminal21.client.ConnectedSession import org.terminal21.client.components.UiElement import org.terminal21.client.components.chakra.* import tests.chakra.Common.commonBox object MediaAndIcons: - def components(using session: ConnectedSession): Seq[UiElement] = + def components: Seq[UiElement] = Seq( commonBox(text = "Icons"), HStack().withChildren( diff --git a/end-to-end-tests/src/main/scala/tests/chakra/Navigation.scala b/end-to-end-tests/src/main/scala/tests/chakra/Navigation.scala index afa7df70..4e155905 100644 --- a/end-to-end-tests/src/main/scala/tests/chakra/Navigation.scala +++ b/end-to-end-tests/src/main/scala/tests/chakra/Navigation.scala @@ -1,36 +1,38 @@ package tests.chakra -import org.terminal21.client.ConnectedSession +import org.terminal21.client.* import org.terminal21.client.components.UiElement import org.terminal21.client.components.chakra.* import org.terminal21.client.components.std.Paragraph import tests.chakra.Common.commonBox object Navigation: - def components(using session: ConnectedSession): Seq[UiElement] = - val clickedBreadcrumb = Paragraph(text = "no-breadcrumb-clicked") - def breadcrumbClicked(t: String): Unit = - clickedBreadcrumb.withText(s"breadcrumb-click: $t").renderChanges() + def components(events: Events): Seq[UiElement] = + val bcLinkHome = BreadcrumbLink("breadcrumb-home", text = "breadcrumb-home") + val bcLink1 = BreadcrumbLink("breadcrumb-link1", text = "breadcrumb1") + val bcCurrent = BreadcrumbItem(isCurrentPage = Some(true)) + val bcLink2 = BreadcrumbLink("breadcrumb-link2", text = "breadcrumb2") + val link = Link(key = "google-link", text = "link-external-google", href = "https://www.google.com/", isExternal = Some(true)) - val clickedLink = Paragraph(text = "no-link-clicked") + val bcStatus = + ( + events.ifClicked(bcLinkHome, "breadcrumb-click: breadcrumb-home").toSeq ++ + events.ifClicked(bcLink1, "breadcrumb-click: breadcrumb-link1") ++ + events.ifClicked(bcLink2, "breadcrumb-click: breadcrumb-link2") + ).headOption.getOrElse("no-breadcrumb-clicked") + + val clickedBreadcrumb = Paragraph(text = bcStatus) + val clickedLink = Paragraph(text = if events.isClicked(link) then "link-clicked" else "no-link-clicked") Seq( commonBox(text = "Breadcrumbs"), Breadcrumb().withChildren( - BreadcrumbItem().withChildren( - BreadcrumbLink(text = "breadcrumb-home").onClick(() => breadcrumbClicked("breadcrumb-home")) - ), - BreadcrumbItem().withChildren( - BreadcrumbLink(text = "breadcrumb-link1").onClick(() => breadcrumbClicked("breadcrumb-link1")) - ), - BreadcrumbItem(isCurrentPage = Some(true)).withChildren( - BreadcrumbLink(text = "breadcrumb-link2").onClick(() => breadcrumbClicked("breadcrumb-link2")) - ) + BreadcrumbItem().withChildren(bcLinkHome), + BreadcrumbItem().withChildren(bcLink1), + bcCurrent.withChildren(bcLink2) ), clickedBreadcrumb, commonBox(text = "Link"), - Link(text = "link-external-google", href = "https://www.google.com/", isExternal = Some(true)) - .onClick: () => - clickedLink.withText("link-clicked").renderChanges(), + link, clickedLink ) diff --git a/end-to-end-tests/src/main/scala/tests/chakra/Overlay.scala b/end-to-end-tests/src/main/scala/tests/chakra/Overlay.scala index bed13a35..2fc72ef9 100644 --- a/end-to-end-tests/src/main/scala/tests/chakra/Overlay.scala +++ b/end-to-end-tests/src/main/scala/tests/chakra/Overlay.scala @@ -1,31 +1,39 @@ package tests.chakra -import org.terminal21.client.ConnectedSession +import org.terminal21.client.* import org.terminal21.client.components.UiElement import org.terminal21.client.components.chakra.* import tests.chakra.Common.commonBox object Overlay: - def components(using session: ConnectedSession): Seq[UiElement] = - val box1 = Box(text = "Clicks will be reported here.") + def components(events: Events): Seq[UiElement] = + val mi1 = MenuItem(key = "download-menu", text = "Download menu-download") + val mi2 = MenuItem(key = "copy-menu", text = "Copy") + val mi3 = MenuItem(key = "paste-menu", text = "Paste") + val mi4 = MenuItem(key = "exit-menu", text = "Exit") + + val box1Msg = + if events.isClicked(mi1) then "'Download' clicked" + else if events.isClicked(mi2) then "'Copy' clicked" + else if events.isClicked(mi3) then "'Paste' clicked" + else if events.isClicked(mi4) then "'Exit' clicked" + else "Clicks will be reported here." + + val box1 = Box(text = box1Msg) + Seq( commonBox(text = "Menus box0001"), HStack().withChildren( - Menu().withChildren( + Menu(key = "menu1").withChildren( MenuButton(text = "Actions menu0001", size = Some("sm"), colorScheme = Some("teal")).withChildren( ChevronDownIcon() ), MenuList().withChildren( - MenuItem(text = "Download menu-download") - .onClick: () => - box1.withText("'Download' clicked").renderChanges(), - MenuItem(text = "Copy").onClick: () => - box1.withText("'Copy' clicked").renderChanges(), - MenuItem(text = "Paste").onClick: () => - box1.withText("'Paste' clicked").renderChanges(), + mi1, + mi2, + mi3, MenuDivider(), - MenuItem(text = "Exit").onClick: () => - box1.withText("'Exit' clicked").renderChanges() + mi4 ) ), box1 diff --git a/end-to-end-tests/src/main/scala/tests/chakra/Stacks.scala b/end-to-end-tests/src/main/scala/tests/chakra/Stacks.scala index ea1cab0c..4b980f36 100644 --- a/end-to-end-tests/src/main/scala/tests/chakra/Stacks.scala +++ b/end-to-end-tests/src/main/scala/tests/chakra/Stacks.scala @@ -1,12 +1,11 @@ package tests.chakra -import org.terminal21.client.ConnectedSession import org.terminal21.client.components.UiElement import org.terminal21.client.components.chakra.{Box, HStack, VStack} import tests.chakra.Common.* object Stacks: - def components(using session: ConnectedSession): Seq[UiElement] = + def components: Seq[UiElement] = Seq( commonBox(text = "VStack"), VStack(spacing = Some("24px"), align = Some("stretch")).withChildren( diff --git a/end-to-end-tests/src/test/scala/tests/LoggedInTest.scala b/end-to-end-tests/src/test/scala/tests/LoggedInTest.scala new file mode 100644 index 00000000..a8d21a86 --- /dev/null +++ b/end-to-end-tests/src/test/scala/tests/LoggedInTest.scala @@ -0,0 +1,33 @@ +package tests + +import org.scalatest.funsuite.AnyFunSuiteLike +import org.scalatest.matchers.should.Matchers.* +import org.terminal21.client.* +import org.terminal21.model.CommandEvent + +class LoggedInTest extends AnyFunSuiteLike: + class App: + val login = LoginForm() + given session: ConnectedSession = ConnectedSessionMock.newConnectedSessionMock + val form = new LoggedIn(login) + def allComponents = form.components(false, Events.Empty).view.flatMap(_.flat) + + test("renders email details"): + new App: + allComponents should contain(form.emailDetails) + + test("renders password details"): + new App: + allComponents should contain(form.passwordDetails) + + test("yes clicked"): + new App: + val eventsIt = form.controller.render(false).iterator + session.fireEvents(CommandEvent.onClick(form.yesButton), CommandEvent.sessionClosed) + eventsIt.lastOption.map(_.model) should be(Some(true)) + + test("no clicked"): + new App: + val eventsIt = form.controller.render(false).iterator + session.fireEvents(CommandEvent.onClick(form.noButton), CommandEvent.sessionClosed) + eventsIt.lastOption.map(_.model) should be(Some(false)) diff --git a/end-to-end-tests/src/test/scala/tests/LoginPageTest.scala b/end-to-end-tests/src/test/scala/tests/LoginPageTest.scala new file mode 100644 index 00000000..ff86244e --- /dev/null +++ b/end-to-end-tests/src/test/scala/tests/LoginPageTest.scala @@ -0,0 +1,39 @@ +package tests + +import org.scalatest.funsuite.AnyFunSuiteLike +import org.scalatest.matchers.should.Matchers.* +import org.terminal21.client.components.* +import org.terminal21.client.* +import org.terminal21.model.CommandEvent + +class LoginPageTest extends AnyFunSuiteLike: + + class App: + given session: ConnectedSession = ConnectedSessionMock.newConnectedSessionMock + val login = LoginForm() + val page = new LoginPage + def allComponents: Seq[UiElement] = page.components(login, Events.Empty).view.flatMap(_.flat) + + test("renders email input"): + new App: + allComponents should contain(page.emailInput) + + test("renders password input"): + new App: + allComponents should contain(page.passwordInput) + + test("renders submit button"): + new App: + allComponents should contain(page.submitButton) + + test("user submits validated data"): + new App: + val eventsIt = page.controller.render(login).iterator // get the iterator before we fire the events, otherwise the iterator will be empty + session.fireEvents( + CommandEvent.onChange(page.emailInput, "an@email.com"), + CommandEvent.onChange(page.passwordInput, "secret"), + CommandEvent.onClick(page.submitButton), + CommandEvent.sessionClosed // every test should close the session so that the iterator doesn't block if converted to a list. + ) + + eventsIt.lastOption.map(_.model) should be(Some(LoginForm("an@email.com", "secret", true))) diff --git a/example-scripts/bouncing-ball.sc b/example-scripts/bouncing-ball.sc index 11b491fb..441938cc 100755 --- a/example-scripts/bouncing-ball.sc +++ b/example-scripts/bouncing-ball.sc @@ -8,26 +8,47 @@ // always import these import org.terminal21.client.* import org.terminal21.client.components.* +import org.terminal21.model.* // use the chakra components for menus, forms etc, https://chakra-ui.com/docs/components // The scala case classes : https://github.com/kostaskougios/terminal21-restapi/blob/main/terminal21-ui-std/src/main/scala/org/terminal21/client/components/chakra/ChakraElement.scala import org.terminal21.client.components.chakra.* -import scala.annotation.tailrec +Sessions + .withNewSession("bouncing-ball", "C64 bouncing ball") + .connect: session => + given ConnectedSession = session -Sessions.withNewSession("bouncing-ball", "C64 bouncing ball"): session => - given ConnectedSession = session + // We'll do this with an MVC approach. This is our Model: + case class Ball(x: Int, y: Int, dx: Int, dy: Int): + def nextPosition: Ball = + val newDx = if x < 0 || x > 600 then -dx else dx + val newDy = if y < 0 || y > 500 then -dy else dy + Ball(x + newDx, y + newDy, newDx, newDy) - println( - "Files under ~/.terminal21/web will be served under /web . Please place a ball.png file under ~/.terminal21/web/images on the box where the server runs." - ) - val ball = Image(src = "/web/images/ball.png") - ball.render() + // In order to update the ball's position, we will be sending approx 60 Ticker events per second to our controller. + case object Ticker extends ClientEvent - @tailrec def animateBall(x: Int, y: Int, dx: Int, dy: Int): Unit = - ball.withStyle("position" -> "fixed", "left" -> (x + "px"), "top" -> (y + "px")).renderChanges() - Thread.sleep(1000 / 120) - val newDx = if x < 0 || x > 600 then -dx else dx - val newDy = if y < 0 || y > 500 then -dy else dy - if !session.isClosed then animateBall(x + newDx, y + newDy, newDx, newDy) + val initialModel = Ball(50, 50, 8, 8) - animateBall(50, 50, 8, 8) + println( + "Files under ~/.terminal21/web will be served under /web . Please place a ball.png file under ~/.terminal21/web/images on the box where the server runs." + ) + + // This is our controller implementation. It takes the model (ball) and events (in this case just the Ticker which we can otherwise ignore) + // and results in the next frame's state. + def components(ball: Ball, events: Events): MV[Ball] = + val b = ball.nextPosition + MV( + b, + Image(src = "/web/images/ball.png").withStyle("position" -> "fixed", "left" -> (b.x + "px"), "top" -> (b.y + "px")) + ) + // We'll be sending a Ticker 60 times per second + fiberExecutor.submit: + while !session.isClosed do + session.fireEvent(Ticker) + Thread.sleep(1000 / 60) + + // We are ready to create a controller instance with our components function. + Controller(components) + .render(initialModel) // and render it with our initial model (it will call the components function and render any resulting UI) + .run() // and run this until the user closes the session diff --git a/example-scripts/csv-editor.sc b/example-scripts/csv-editor.sc index 75abf9cb..91700aa1 100755 --- a/example-scripts/csv-editor.sc +++ b/example-scripts/csv-editor.sc @@ -2,20 +2,19 @@ // ------------------------------------------------------------------------------ // A quick and dirty csv file editor for small csv files. +// Run with : ./csv-editor -- csv-file-path // ------------------------------------------------------------------------------ -// always import these import org.terminal21.client.* - -import java.util.concurrent.atomic.AtomicBoolean import org.terminal21.client.components.* +import org.terminal21.collections.TypedMapKey +import org.terminal21.model.* // use the chakra components for menus, forms etc, https://chakra-ui.com/docs/components // The scala case classes : https://github.com/kostaskougios/terminal21-restapi/blob/main/terminal21-ui-std/src/main/scala/org/terminal21/client/components/chakra/ChakraElement.scala import org.terminal21.client.components.chakra.* import org.apache.commons.io.FileUtils import java.io.File -import scala.collection.concurrent.TrieMap if args.length != 1 then throw new IllegalArgumentException( @@ -26,83 +25,98 @@ val fileName = args(0) val file = new File(fileName) val contents = if file.exists() then FileUtils.readFileToString(file, "UTF-8") - else "type,damage points,hit points\nmage,10dp,20hp\nwarrior,20dp,30hp" - -val csv = contents.split("\n").map(_.split(",")) - -// store the csv data in a more usable Map -val initialCsvMap = csv.zipWithIndex - .flatMap: (row, y) => - row.zipWithIndex - .map: (col, x) => - ((x, y), col) - .toMap -val csvMap = TrieMap.empty[(Int, Int), String] ++ initialCsvMap - -// save the map back to the csv file -def saveCsvMap() = - val coords = csvMap.keySet - val maxX = coords.map(_._1).max - val maxY = coords.map(_._2).max - - val s = (0 to maxX) - .map: y => - (0 to maxY) - .map: x => - csvMap.getOrElse((x, y), "") - .mkString(",") - .mkString("\n") - FileUtils.writeStringToFile(file, s, "UTF-8") - - // this will be set to true when we have to exit -val exitFlag = new AtomicBoolean(false) - -Sessions.withNewSession(s"csv-editor-$fileName", s"CsvEdit: $fileName"): session => - given ConnectedSession = session - - val status = Box() - val saveAndExit = Button(text = "Save & Exit") - .onClick: () => - saveCsvMap() - status.withText("Csv file saved, exiting.").renderChanges() - exitFlag.set(true) - - val exit = Button(text = "Exit Without Saving") - .onClick: () => - exitFlag.set(true) - - def newEditable(x: Int, y: Int, value: String) = - Editable(defaultValue = value) + else "type,damage points,hit points\nmage,10dp,20hp\nwarrior,20dp,30hp" // a simple csv for demo purposes + +val csv = toCsvModel(contents.split("\n").map(_.split(",").toSeq).toSeq) + +Sessions + .withNewSession(s"csv-editor-$fileName", s"CsvEdit: $fileName") + .connect: session => + given ConnectedSession = session + println(s"Now open ${session.uiUrl} to view the UI") + val editor = new CsvEditorPage(csv) + editor.run() + +/** Our model. It stores the csv data as a Map of (x,y) coordinates -> value. + */ +case class CsvModel(save: Boolean, exitWithoutSave: Boolean, csv: Map[(Int, Int), String], maxX: Int, maxY: Int, status: String = "Please edit the file.") +def toCsvModel(csv: Seq[Seq[String]]) = + val maxX = csv.map(_.size).max + val maxY = csv.size + val m = csv.zipWithIndex + .flatMap: (row, y) => + row.zipWithIndex.map: (column, x) => + ((x, y), column) + .toMap + CsvModel(false, false, m, maxX, maxY) + +/** A nice approach to coding UI's is to create Page classes for every UI page. In this instance we need a page for our csv editor. The components function can + * be easily tested if we want to test what is rendered and how it changes the model when events occur. + */ +class CsvEditorPage(initModel: CsvModel)(using session: ConnectedSession): + + val saveAndExit = Button("save-exit", text = "Save & Exit") + val exit = Button("exit", text = "Exit Without Saving") + + def run(): Unit = + for mv <- controller.render(initModel).iterator.lastOption.filter(_.model.save) // only save if model.save is true + do save(mv.model) + + def editorComponent(model: CsvModel, events: Events): MV[CsvModel] = + val tableCells = + (0 until model.maxY).map: y => + (0 until model.maxX).map: x => + newEditable(x, y, model.csv(x, y)) + + val newModel = tableCells.flatten.find(events.isChangedValue) match + case Some(editable) => + val coords = editable.storedValue(CoordsKey) + val newValue = events.changedValue(editable, "error") + model.copy(csv = model.csv + (coords -> newValue), status = s"Changed value at $coords to $newValue") + case None => model + + val view = QuickTable("csv-editor", variant = "striped", colorScheme = "teal", size = "mg") + .withCaption("Please edit the csv contents above and click save to save and exit") + .withRows(tableCells) + + MV(newModel, view) + + def components(model: CsvModel, events: Events): MV[CsvModel] = + val cells = editorComponent(model, events) + val newModel = cells.model.copy( + save = events.isClicked(saveAndExit), + exitWithoutSave = events.isClicked(exit) + ) + val view = cells.view ++ Seq( + HStack().withChildren( + saveAndExit, + exit, + Box(text = newModel.status) + ) + ) + MV( + newModel, + view, + terminate = newModel.exitWithoutSave || newModel.save + ) + + def controller: Controller[CsvModel] = Controller(components) + + def save(model: CsvModel): Unit = + val data = (0 until model.maxY).map: y => + (0 until model.maxX).map: x => + model.csv((x, y)) + FileUtils.writeStringToFile(file, data.map(_.mkString(",")).mkString("\n"), "UTF-8") + println(s"Csv file saved to $file") + + object CoordsKey extends TypedMapKey[(Int, Int)] + private def newEditable(x: Int, y: Int, value: String): Editable = + Editable(s"cell-$x-$y", defaultValue = value) // note: anything receiving events should have a unique key, in this instance s"cell-$x-$y" .withChildren( EditablePreview(), EditableInput() ) - .onChange: newValue => - csvMap((x, y)) = newValue - status.withText(s"($x,$y) value changed to $newValue").renderChanges() - - Seq( - TableContainer().withChildren( - Table(variant = "striped", colorScheme = Some("teal"), size = "mg") - .withChildren( - TableCaption(text = "Please edit the csv contents above and click save to save and exit"), - Thead(), - Tbody( - children = csv.zipWithIndex.map: (row, y) => - Tr( - children = row.zipWithIndex.map: (column, x) => - Td().withChildren(newEditable(x, y, column)) - ) - ) - ) - ), - HStack().withChildren( - saveAndExit, - exit, - status - ) - ).render() - - println(s"Now open ${session.uiUrl} to view the UI") - // wait for one of the save/exit buttons to be pressed. - session.waitTillUserClosesSessionOr(exitFlag.get()) + .store( + CoordsKey, + (x, y) + ) // every UiElement has a store where we can store arbitrary data. Here we store the coordinates for the value this editable will edit diff --git a/example-scripts/csv-viewer.sc b/example-scripts/csv-viewer.sc index bf084500..fab68a7e 100755 --- a/example-scripts/csv-viewer.sc +++ b/example-scripts/csv-viewer.sc @@ -2,19 +2,17 @@ // ------------------------------------------------------------------------------ // A csv file viewer +// Run with: ./csv-viewer.sc -- csv-file // ------------------------------------------------------------------------------ -// always import these import org.terminal21.client.* import org.terminal21.client.components.* // use the chakra components for menus, forms etc, https://chakra-ui.com/docs/components // The scala case classes : https://github.com/kostaskougios/terminal21-restapi/blob/main/terminal21-ui-std/src/main/scala/org/terminal21/client/components/chakra/ChakraElement.scala -import org.apache.commons.io.FileUtils import org.terminal21.client.components.chakra.* import java.io.File -import java.util.concurrent.CountDownLatch -import scala.collection.concurrent.TrieMap +import org.apache.commons.io.FileUtils if args.length != 1 then throw new IllegalArgumentException( @@ -27,24 +25,29 @@ val contents = FileUtils.readFileToString(file, "UTF-8") val csv = contents.split("\n").map(_.split(",")) -Sessions.withNewSession(s"csv-viewer-$fileName", s"CsvView: $fileName"): session => - given ConnectedSession = session - - TableContainer() - .withChildren( - Table(variant = "striped", colorScheme = Some("teal"), size = "mg") - .withChildren( - TableCaption(text = "Csv file contents"), - Tbody( - children = csv.map: row => - Tr( - children = row.map: column => - Td(text = column) +Sessions + .withNewSession(s"csv-viewer-$fileName", s"CsvView: $fileName") + .connect: session => + given ConnectedSession = session + + Controller + .noModel( + TableContainer() // We could use the QuickTable component here, but lets do it a bit more low level with the Chakra components + .withChildren( + Table(variant = "striped", colorScheme = Some("teal"), size = "mg") + .withChildren( + TableCaption(text = "Csv file contents"), + Tbody( + children = csv.map: row => + Tr( + children = row.map: column => + Td(text = column) + ) + ) ) ) - ) - ) - .render() - println(s"Now open ${session.uiUrl} to view the UI.") - // since this is a read-only UI, we can exit the app but leave the session open on the UI for the user to examine the data. - session.leaveSessionOpenAfterExiting() + ) + .render() // we don't have to process any events here, just let the user view the csv file. + println(s"Now open ${session.uiUrl} to view the UI.") + // since this is a read-only UI, we can exit the app but leave the session open on the UI for the user to examine the data. + session.leaveSessionOpenAfterExiting() diff --git a/example-scripts/hello-world.sc b/example-scripts/hello-world.sc index 6057391d..b191660f 100755 --- a/example-scripts/hello-world.sc +++ b/example-scripts/hello-world.sc @@ -1,16 +1,19 @@ #!/usr/bin/env -S scala-cli project.scala // ------------------------------------------------------------------------------ // Hello world with terminal21. +// Run with ./hello-world.sc // ------------------------------------------------------------------------------ -// always import these import org.terminal21.client.* import org.terminal21.client.components.* -// std components, https://github.com/kostaskougios/terminal21-restapi/blob/main/terminal21-ui-std/src/main/scala/org/terminal21/client/components/StdElement.scala +// std components like Paragraph, https://github.com/kostaskougios/terminal21-restapi/blob/main/terminal21-ui-std/src/main/scala/org/terminal21/client/components/StdElement.scala import org.terminal21.client.components.std.* -Sessions.withNewSession("hello-world", "Hello World Example"): session => - given ConnectedSession = session +Sessions + .withNewSession("hello-world", "Hello World Example") + .connect: session => + given ConnectedSession = session - Paragraph(text = "Hello World!").render() - session.leaveSessionOpenAfterExiting() + Controller.noModel(Paragraph(text = "Hello World!")).render() + // since this is a read-only UI, we can exit the app but leave the session open for the user to examine the page. + session.leaveSessionOpenAfterExiting() diff --git a/example-scripts/mathjax.sc b/example-scripts/mathjax.sc index 30c2ea72..4a2343f5 100755 --- a/example-scripts/mathjax.sc +++ b/example-scripts/mathjax.sc @@ -1,17 +1,27 @@ #!/usr/bin/env -S scala-cli project.scala +// ------------------------------------------------------------------------------ +// Render some maths on screen for demo purposes. +// Run with ./mathjax.sc +// ------------------------------------------------------------------------------ + import org.terminal21.client.* import org.terminal21.client.components.* import org.terminal21.client.components.mathjax.* -Sessions.withNewSession("mathjax", "MathJax Example", MathJaxLib /* note we need to register the MathJaxLib in order to use it */ ): session => - given ConnectedSession = session - Seq( - MathJax( - expression = """When \(a \ne 0\), there are two solutions to \(ax^2 + bx + c = 0\) and they are $$x = {-b \pm \sqrt{b^2-4ac} \over 2a}.$$""" - ), - MathJax( - expression = """ +Sessions + .withNewSession("mathjax", "MathJax Example") + .andLibraries(MathJaxLib /* note we need to register the MathJaxLib in order to use it */ ) + .connect: session => + given ConnectedSession = session + Controller + .noModel( + Seq( + MathJax( + expression = """When \(a \ne 0\), there are two solutions to \(ax^2 + bx + c = 0\) and they are $$x = {-b \pm \sqrt{b^2-4ac} \over 2a}.$$""" + ), + MathJax( + expression = """ |when \(a \ne 0\), there are two solutions to \(x = {-b \pm \sqrt{b^2-4ac} \over 2a}.\) |Aenean vel velit a lacus lacinia pulvinar. Morbi eget ex et tellus aliquam molestie sit amet eu diam. |Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas tellus enim, tempor non efficitur et, rutrum efficitur metus. @@ -21,6 +31,9 @@ Sessions.withNewSession("mathjax", "MathJax Example", MathJaxLib /* note we need |Morbi ultrices sem quis nisl convallis, ac cursus nunc condimentum. Orci varius natoque penatibus et magnis dis parturient montes, |nascetur ridiculus mus. |""".stripMargin - ) - ).render() - session.leaveSessionOpenAfterExiting() + ) + ) + ) + .render() + // since this is a read-only UI, we can exit the app but leave the session open on the UI for the user to examine the data. + session.leaveSessionOpenAfterExiting() diff --git a/example-scripts/mvc-click-form.sc b/example-scripts/mvc-click-form.sc new file mode 100755 index 00000000..e014c354 --- /dev/null +++ b/example-scripts/mvc-click-form.sc @@ -0,0 +1,51 @@ +#!/usr/bin/env -S scala-cli project.scala + +// ------------------------------------------------------------------------------ +// MVC demo that handles a button click +// Run with ./mvc-click-form.sc +// ------------------------------------------------------------------------------ + +import org.terminal21.client.* +import org.terminal21.client.components.* +import org.terminal21.client.components.std.* +import org.terminal21.client.components.chakra.* +import org.terminal21.model.SessionOptions + +Sessions + .withNewSession("mvc-click-form", "MVC form with a button") + .connect: session => + given ConnectedSession = session + new ClickPage(ClickForm(false)).run() match + case None => // the user closed the app + case Some(model) => println(s"model = $model") + + Thread.sleep(1000) // wait a bit so that the user can see the change in the UI + +/** Our model + * + * @param clicked + * will be set to true when the button is clicked + */ +case class ClickForm(clicked: Boolean) + +/** One nice way to structure the code (that simplifies testing too) is to create a class for every page in the user interface. In this instance, we create a + * page for the click form to be displayed. All components are in `components` method. The controller is in the `controller` method and we can run to get the + * result in the `run` method. We can use these methods in unit tests to test what is rendered and how events are processed respectively. + */ +class ClickPage(initialForm: ClickForm)(using ConnectedSession): + def run(): Option[ClickForm] = controller.render(initialForm).run() + + def components(form: ClickForm, events: Events): MV[ClickForm] = + val button = Button(key = "click-me", text = "Please click me") + val updatedForm = form.copy( + clicked = events.isClicked(button) + ) + val msg = Paragraph(text = if updatedForm.clicked then "Button clicked!" else "Waiting for user to click the button") + + MV( + updatedForm, + Seq(msg, button), + terminate = updatedForm.clicked // terminate the event iteration + ) + + def controller: Controller[ClickForm] = Controller(components) diff --git a/example-scripts/mvc-user-form.sc b/example-scripts/mvc-user-form.sc new file mode 100755 index 00000000..d8b92d0f --- /dev/null +++ b/example-scripts/mvc-user-form.sc @@ -0,0 +1,72 @@ +#!/usr/bin/env -S scala-cli project.scala + +import org.terminal21.client.* +import org.terminal21.client.components.* +import org.terminal21.client.components.std.Paragraph +import org.terminal21.client.components.chakra.* + +// ------------------------------------------------------------------------------ +// MVC demo with an email form +// Run with ./mvc-user-form.sc +// ------------------------------------------------------------------------------ + +Sessions + .withNewSession("mvc-user-form", "MVC example with a user form") + .connect: session => + given ConnectedSession = session + new UserPage(UserForm("my@email.com", false)).run match + case Some(submittedUser) => + println(s"Submitted: $submittedUser") + case None => + println("User closed session without submitting the form") + +/** Our model for the form */ +case class UserForm( + email: String, // the email + submitted: Boolean // true if user clicks the submit button, false otherwise +) + +/** One nice way to structure the code (that simplifies testing too) is to create a class for every page in the user interface. In this instance, we create a + * page for the user form to be displayed. All components are in `components` method. The controller is in the `controller` method and we can run to get the + * result in the `run` method. We can use these methods in unit tests to test what is rendered and how events are processed respectively. + */ +class UserPage(initialForm: UserForm)(using ConnectedSession): + + /** Runs the form and returns the results + * @return + * if None, the user didn't submit the form (i.e. closed the session), if Some(userForm) the user submitted the form. + */ + def run: Option[UserForm] = + controller.render(initialForm).run().filter(_.submitted) + + /** @return + * all the components that should be rendered for the page + */ + def components(form: UserForm, events: Events): MV[UserForm] = + val emailInput = Input(key = "email", `type` = "email", defaultValue = initialForm.email) + val submitButton = Button(key = "submit", text = "Submit") + + val updatedForm = form.copy( + email = events.changedValue(emailInput, form.email), + submitted = events.isClicked(submitButton) + ) + + val output = Paragraph(text = if events.isChangedValue(emailInput) then s"Email changed: ${updatedForm.email}" else "Please modify the email.") + + MV( + updatedForm, + Seq( + QuickFormControl() + .withLabel("Email address") + .withInputGroup( + InputLeftAddon().withChildren(EmailIcon()), + emailInput + ) + .withHelperText("We'll never share your email."), + submitButton, + output + ), + terminate = updatedForm.submitted // terminate the form when the submit button is clicked + ) + + def controller: Controller[UserForm] = Controller(components) diff --git a/example-scripts/nivo-bar-chart.sc b/example-scripts/nivo-bar-chart.sc index 546d1205..d9b8ff01 100755 --- a/example-scripts/nivo-bar-chart.sc +++ b/example-scripts/nivo-bar-chart.sc @@ -1,5 +1,10 @@ #!/usr/bin/env -S scala-cli project.scala +// ------------------------------------------------------------------------------ +// Nivo bar chart demo, animated ! +// Run with ./nivo-bar-chart.sc +// ------------------------------------------------------------------------------ + import org.terminal21.client.* import org.terminal21.client.fiberExecutor import org.terminal21.client.components.* @@ -8,48 +13,58 @@ import org.terminal21.client.components.nivo.* import scala.util.Random import NivoBarChart.* +import org.terminal21.model.ClientEvent -Sessions.withNewSession("nivo-bar-chart", "Nivo Bar Chart", NivoLib /* note we need to register the NivoLib in order to use it */ ): session => - given ConnectedSession = session +Sessions + .withNewSession("nivo-bar-chart", "Nivo Bar Chart") + .andLibraries(NivoLib /* note we need to register the NivoLib in order to use it */ ) + .connect: session => + given ConnectedSession = session - val chart = ResponsiveBar( - data = createRandomData, - keys = Seq("hot dog", "burger", "sandwich", "kebab", "fries", "donut"), - indexBy = "country", - padding = 0.3, - defs = Seq( - Defs("dots", "patternDots", "inherit", "#38bcb2", size = Some(4), padding = Some(1), stagger = Some(true)), - Defs("lines", "patternLines", "inherit", "#eed312", rotation = Some(-45), lineWidth = Some(6), spacing = Some(10)) - ), - fill = Seq(Fill("dots", Match("fries")), Fill("lines", Match("sandwich"))), - axisLeft = Some(Axis(legend = "food", legendOffset = -40)), - axisBottom = Some(Axis(legend = "country", legendOffset = 32)), - valueScale = Scale(`type` = "linear"), - indexScale = Scale(`type` = "band", round = Some(true)), - legends = Seq( - Legend( - dataFrom = "keys", - translateX = 120, - itemsSpacing = 2, - itemWidth = 100, - itemHeight = 20, - symbolSize = 20, - symbolShape = "square" + def components(events: Events): Seq[UiElement] = + val data = createRandomData + val chart = ResponsiveBar( + data = data, + keys = Seq("hot dog", "burger", "sandwich", "kebab", "fries", "donut"), + indexBy = "country", + padding = 0.3, + defs = Seq( + Defs("dots", "patternDots", "inherit", "#38bcb2", size = Some(4), padding = Some(1), stagger = Some(true)), + Defs("lines", "patternLines", "inherit", "#eed312", rotation = Some(-45), lineWidth = Some(6), spacing = Some(10)) + ), + fill = Seq(Fill("dots", Match("fries")), Fill("lines", Match("sandwich"))), + axisLeft = Some(Axis(legend = "food", legendOffset = -40)), + axisBottom = Some(Axis(legend = "country", legendOffset = 32)), + valueScale = Scale(`type` = "linear"), + indexScale = Scale(`type` = "band", round = Some(true)), + legends = Seq( + Legend( + dataFrom = "keys", + translateX = 120, + itemsSpacing = 2, + itemWidth = 100, + itemHeight = 20, + symbolSize = 20, + symbolShape = "square" + ) + ) + ) + Seq( + Paragraph(text = "Various foods.", style = Map("margin" -> 20)), + chart ) - ) - ) - - Seq( - Paragraph(text = "Various foods.", style = Map("margin" -> 20)), - chart - ).render() - fiberExecutor.submit: - while !session.isClosed do - Thread.sleep(2000) - chart.withData(createRandomData).renderChanges() + // we'll send new data to our controller every 2 seconds via a custom event + case object Ticker extends ClientEvent + fiberExecutor.submit: + while !session.isClosed do + Thread.sleep(2000) + session.fireEvent(Ticker) - session.waitTillUserClosesSession() + Controller + .noModel(components) + .render() + .run() object NivoBarChart: def createRandomData: Seq[Seq[BarDatum]] = diff --git a/example-scripts/nivo-line-chart.sc b/example-scripts/nivo-line-chart.sc index 6f5843ad..92993718 100755 --- a/example-scripts/nivo-line-chart.sc +++ b/example-scripts/nivo-line-chart.sc @@ -1,5 +1,10 @@ #!/usr/bin/env -S scala-cli project.scala +// ------------------------------------------------------------------------------ +// Nivo line chart demo, animated ! +// Run with ./nivo-line-chart.sc +// ------------------------------------------------------------------------------ + import org.terminal21.client.* import org.terminal21.client.fiberExecutor import org.terminal21.client.components.* @@ -8,32 +13,40 @@ import org.terminal21.client.components.nivo.* import scala.util.Random import NivoLineChart.* +import org.terminal21.model.ClientEvent -Sessions.withNewSession("nivo-line-chart", "Nivo Line Chart", NivoLib /* note we need to register the NivoLib in order to use it */ ): session => - given ConnectedSession = session - - val chart = ResponsiveLine( - data = createRandomData, - yScale = Scale(stacked = Some(true)), - axisBottom = Some(Axis(legend = "transportation", legendOffset = 36)), - axisLeft = Some(Axis(legend = "count", legendOffset = -40)), - legends = Seq(Legend()) - ) - - Seq( - Paragraph(text = "Means of transportation for various countries", style = Map("margin" -> 20)), - chart - ).render() +Sessions + .withNewSession("nivo-line-chart", "Nivo Line Chart") + .andLibraries(NivoLib /* note we need to register the NivoLib in order to use it */ ) + .connect: session => + given ConnectedSession = session - fiberExecutor.submit: - while !session.isClosed do - Thread.sleep(2000) - chart.withData(createRandomData).renderChanges() + def components(events: Events): Seq[UiElement] = + val chart = ResponsiveLine( + data = createRandomData, + yScale = Scale(stacked = Some(true)), + axisBottom = Some(Axis(legend = "transportation", legendOffset = 36)), + axisLeft = Some(Axis(legend = "count", legendOffset = -40)), + legends = Seq(Legend()) + ) + Seq( + Paragraph(text = "Means of transportation for various countries", style = Map("margin" -> 20)), + chart + ) + // we'll send new data to our controller every 2 seconds via a custom event + case object Ticker extends ClientEvent + fiberExecutor.submit: + while !session.isClosed do + Thread.sleep(2000) + session.fireEvent(Ticker) - session.waitTillUserClosesSession() + Controller + .noModel(components) + .render() + .run() object NivoLineChart: - def createRandomData: Seq[Serie] = + def createRandomData: Seq[Serie] = Seq( dataFor("Japan"), dataFor("France"), diff --git a/example-scripts/on-change.sc b/example-scripts/on-change.sc deleted file mode 100755 index a55b92e9..00000000 --- a/example-scripts/on-change.sc +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env -S scala-cli project.scala - -import org.terminal21.client.* -import org.terminal21.client.components.* -import org.terminal21.client.components.std.Paragraph -import org.terminal21.client.components.chakra.* - -Sessions.withNewSession("on-change-example", "On Change event handler"): session => - given ConnectedSession = session - - val output = Paragraph(text = "Please modify the email.") - val email = Input(`type` = "email", value = "my@email.com").onChange: v => - output.withText(s"Email value : $v").renderChanges() - - Seq( - FormControl().withChildren( - FormLabel(text = "Email address"), - InputGroup().withChildren( - InputLeftAddon().withChildren(EmailIcon()), - email - ), - FormHelperText(text = "We'll never share your email.") - ), - output - ).render() - - session.waitTillUserClosesSession() diff --git a/example-scripts/on-click.sc b/example-scripts/on-click.sc deleted file mode 100755 index ef877a14..00000000 --- a/example-scripts/on-click.sc +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env -S scala-cli project.scala - -import org.terminal21.client.* -import org.terminal21.client.components.* -import org.terminal21.client.components.std.* -import org.terminal21.client.components.chakra.* - -Sessions.withNewSession("on-click-example", "On Click Handler"): session => - given ConnectedSession = session - - @volatile var exit = false - val msg = Paragraph(text = "Waiting for user to click the button") - val button = Button(text = "Please click me").onClick: () => - msg.withText("Button clicked.").renderChanges() - exit = true - - Seq(msg, button).render() - - session.waitTillUserClosesSessionOr(exit) diff --git a/example-scripts/postit.sc b/example-scripts/postit.sc index 4c2326a7..d515f6e0 100755 --- a/example-scripts/postit.sc +++ b/example-scripts/postit.sc @@ -1,47 +1,69 @@ #!/usr/bin/env -S scala-cli project.scala // ------------------------------------------------------------------------------ // A note poster, where anyone can write a note +// Run with ./postit.sc // ------------------------------------------------------------------------------ -// always import these import org.terminal21.client.* import org.terminal21.client.components.* -// std components, https://github.com/kostaskougios/terminal21-restapi/blob/main/terminal21-ui-std/src/main/scala/org/terminal21/client/components/StdElement.scala +// std components like Paragraph, https://github.com/kostaskougios/terminal21-restapi/blob/main/terminal21-ui-std/src/main/scala/org/terminal21/client/components/StdElement.scala import org.terminal21.client.components.std.* // use the chakra components for menus, forms etc, https://chakra-ui.com/docs/components // The scala case classes : https://github.com/kostaskougios/terminal21-restapi/blob/main/terminal21-ui-std/src/main/scala/org/terminal21/client/components/chakra/ChakraElement.scala import org.terminal21.client.components.chakra.* -Sessions.withNewSession("postit", "Post-It"): session => - given ConnectedSession = session +Sessions + .withNewSession("postit", "Post-It") + .connect: session => + given ConnectedSession = session + println(s"Now open ${session.uiUrl} to view the UI.") + new PostItPage().run() - val editor = Textarea(placeholder = "Please post your note by clicking here and editing the content") - val messages = VStack(align = Some("stretch")) - val add = Button(text = "Post It.").onClick: () => - // add the new msg. - // note: editor.value is automatically updated by terminal-ui - val currentMessages = messages.current - currentMessages - .addChildren( - HStack().withChildren( - Image( - src = "https://upload.wikimedia.org/wikipedia/commons/thumb/f/fa/Apple_Notes_icon.svg/2048px-Apple_Notes_icon.svg.png", - boxSize = Some("32px") +class PostItPage(using ConnectedSession): + case class PostIt(message: String = "", messages: List[String] = Nil) + def run(): Unit = controller.render(PostIt()).iterator.lastOption + + def components(model: PostIt, events: Events): MV[PostIt] = + val editor = Textarea("postit-message", placeholder = "Please post your note by clicking here and editing the content") + val addButton = Button("postit", text = "Post It.") + val clearButton = Button("clear-it", text = "Clear board.") + + val updatedMessages = if events.isClicked(clearButton) then Nil else model.messages ++ events.ifClicked(addButton, model.message) + val updatedModel = model.copy( + message = events.changedValue(editor, model.message), + messages = updatedMessages + ) + + val messagesVStack = VStack( + "the-board", + align = Some("stretch"), + children = updatedMessages.map: msg => + HStack() + .withSpacing("8px") + .withChildren( + Image( + src = "https://upload.wikimedia.org/wikipedia/commons/thumb/f/fa/Apple_Notes_icon.svg/2048px-Apple_Notes_icon.svg.png", + boxSize = Some("32px") + ), + Box(text = msg) + ) + ) + MV( + updatedModel, + Seq( + Paragraph(text = "Please type your note below and click 'Post It' to post it so that everyone can view it."), + InputGroup().withChildren( + InputLeftAddon().withChildren(EditIcon()), + editor + ), + HStack() + .withSpacing("8px") + .withChildren( + addButton, + clearButton ), - Box(text = editor.current.value) - ) + messagesVStack ) - .renderChanges() - - Seq( - Paragraph(text = "Please type your note below and click 'Post It' to post it so that everyone can view it."), - InputGroup().withChildren( - InputLeftAddon().withChildren(EditIcon()), - editor - ), - add, - messages - ).render() + ) - println(s"Now open ${session.uiUrl} to view the UI.") - session.waitTillUserClosesSession() + def controller = Controller(components) diff --git a/example-scripts/progress.sc b/example-scripts/progress.sc index 29782daa..aad7f8c5 100755 --- a/example-scripts/progress.sc +++ b/example-scripts/progress.sc @@ -1,30 +1,49 @@ #!/usr/bin/env -S scala-cli project.scala -import org.terminal21.client.* +// ------------------------------------------------------------------------------ +// Universe creation progress bar demo +// Run with ./progress.sc +// ------------------------------------------------------------------------------ + +import org.terminal21.client.{*, given} import org.terminal21.client.components.* import org.terminal21.client.components.std.* import org.terminal21.client.components.chakra.* +import org.terminal21.model.{ClientEvent, SessionOptions} -Sessions.withNewSession("universe-generation", "Universe Generation Progress"): session => - given ConnectedSession = session +Sessions + .withNewSession("universe-generation", "Universe Generation Progress") + .connect: session => + given ConnectedSession = session - val msg = Paragraph(text = "Generating universe ...") - val progress = Progress(value = 1) + def components(model: Int, events: Events): MV[Int] = + val status = + if model < 10 then "Generating universe ..." + else if model < 30 then "Creating atoms" + else if model < 50 then "Big bang!" + else if model < 80 then "Inflating" + else "Life evolution" - Seq(msg, progress).render() + val msg = Paragraph(text = status) + val progress = Progress(value = model) - for i <- 1 to 100 do - val p = progress.withValue(i) - val m = - if i < 10 then msg - else if i < 30 then msg.withText("Creating atoms") - else if i < 50 then msg.withText("Big bang!") - else if i < 80 then msg.withText("Inflating") - else msg.withText("Life evolution") + MV( + model + 1, + Seq(msg, progress) + ) - Seq(p, m).renderChanges() - Thread.sleep(100) + // send a ticker to update the progress bar + object Ticker extends ClientEvent + fiberExecutor.submit: + for _ <- 1 to 100 do + Thread.sleep(200) + session.fireEvent(Ticker) - // clear UI - session.clear() - Paragraph(text = "Universe ready!").render() + Controller(components) + .render(1) // render takes the initial model value, in this case our model is the progress as an Int between 0 and 100. We start with 1 and increment it in the components function + .iterator + .takeWhile(_.model < 100) // terminate when model == 100 + .foreach(_ => ()) // and run it + // clear UI + session.render(Seq(Paragraph(text = "Universe ready!"))) + session.leaveSessionOpenAfterExiting() diff --git a/example-scripts/project.scala b/example-scripts/project.scala index a8914413..4cd68731 100644 --- a/example-scripts/project.scala +++ b/example-scripts/project.scala @@ -1,8 +1,10 @@ //> using jvm "21" //> using scala 3 -//> using dep io.github.kostaskougios::terminal21-ui-std:0.21 -//> using dep io.github.kostaskougios::terminal21-nivo:0.21 -//> using dep io.github.kostaskougios::terminal21-mathjax:0.21 +//> using dep io.github.kostaskougios::terminal21-ui-std:0.30 +//> using dep io.github.kostaskougios::terminal21-nivo:0.30 +//> using dep io.github.kostaskougios::terminal21-mathjax:0.30 //> using dep commons-io:commons-io:2.15.1 + +//> using javaOpt -Xmx128m diff --git a/example-scripts/read-changed-value.sc b/example-scripts/read-changed-value.sc deleted file mode 100755 index 65f2693d..00000000 --- a/example-scripts/read-changed-value.sc +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env -S scala-cli project.scala - -import org.terminal21.client.* -import org.terminal21.client.components.* -import org.terminal21.client.components.std.Paragraph -import org.terminal21.client.components.chakra.* - -Sessions.withNewSession("read-changed-value-example", "Read Changed Value"): session => - given ConnectedSession = session - - val email = Input(`type` = "email", value = "my@email.com") - val output = Box() - - Seq( - FormControl().withChildren( - FormLabel(text = "Email address"), - InputGroup().withChildren( - InputLeftAddon().withChildren(EmailIcon()), - email - ), - FormHelperText(text = "We'll never share your email.") - ), - Button(text = "Read Value").onClick: () => - val value = email.current.value - output.current.addChildren(Paragraph(text = s"The value now is $value")).renderChanges() - , - output - ).render() - - session.waitTillUserClosesSession() diff --git a/example-scripts/server.sc b/example-scripts/server.sc index 08612e3d..7ba686b7 100755 --- a/example-scripts/server.sc +++ b/example-scripts/server.sc @@ -2,7 +2,8 @@ //> using jvm "21" //> using scala 3 -//> using dep io.github.kostaskougios::terminal21-server:0.21 +//> using javaOpt -Xmx128m +//> using dep io.github.kostaskougios::terminal21-server-app:0.30 import org.terminal21.server.Terminal21Server diff --git a/example-scripts/textedit.sc b/example-scripts/textedit.sc index 77fe5756..292c070c 100755 --- a/example-scripts/textedit.sc +++ b/example-scripts/textedit.sc @@ -2,23 +2,20 @@ // ------------------------------------------------------------------------------ // A text file editor for small files. +// run with ./textedit.sc -- text-file // ------------------------------------------------------------------------------ import org.apache.commons.io.FileUtils - import java.io.File -// always import these import org.terminal21.client.* import org.terminal21.client.components.* -// std components, https://github.com/kostaskougios/terminal21-restapi/blob/main/terminal21-ui-std/src/main/scala/org/terminal21/client/components/StdElement.scala +// std components like Paragraph, https://github.com/kostaskougios/terminal21-restapi/blob/main/terminal21-ui-std/src/main/scala/org/terminal21/client/components/StdElement.scala import org.terminal21.client.components.std.* // use the chakra components for menus, forms etc, https://chakra-ui.com/docs/components // The scala case classes : https://github.com/kostaskougios/terminal21-restapi/blob/main/terminal21-ui-std/src/main/scala/org/terminal21/client/components/chakra/ChakraElement.scala import org.terminal21.client.components.chakra.* -import java.util.concurrent.CountDownLatch - if args.length != 1 then throw new IllegalArgumentException( "Expecting 1 argument, the name of the file to edit" @@ -29,58 +26,60 @@ val file = new File(fileName) val contents = if file.exists() then FileUtils.readFileToString(file, "UTF-8") else "" -def saveFile(content: String) = FileUtils.writeStringToFile(file, content, "UTF-8") +def saveFile(content: String): Unit = + println(s"Saving file $fileName") + FileUtils.writeStringToFile(file, content, "UTF-8") -Sessions.withNewSession(s"textedit-$fileName", s"Edit: $fileName"): session => - given ConnectedSession = session - // we will wait till the user clicks the "Exit" menu, this latch makes sure the main thread of the app waits. - val exitLatch = new CountDownLatch(1) - // the main editor area. - val editor = Textarea(value = contents) - // This will display a "saved" badge for a second when the user saves the file - val status = Badge() - // This will display an asterisk when the contents of the file are changed in the editor - val modified = Badge(colorScheme = Some("red")) +Sessions + .withNewSession(s"textedit-$fileName", s"Edit: $fileName") + .connect: session => + given ConnectedSession = session - // when the user changes the textarea, we get the new text and we can compare it with the loaded value. - editor.onChange: newValue => - modified.withText(if newValue != contents then "*" else "").renderChanges() + // the model for our editor form + case class Edit(content: String, savedContent: String, save: Boolean) + // the main editor area. + def components(edit: Edit, events: Events): MV[Edit] = + val editorTextArea = Textarea("editor", defaultValue = edit.content) + val saveMenu = MenuItem("save-menu", text = "Save") + val exitMenu = MenuItem("exit-menu", text = "Exit") + val isSave = events.isClicked(saveMenu) + val updatedContent = events.changedValue(editorTextArea, edit.content) + val updatedEdit = edit.copy( + content = updatedContent, + save = isSave, + savedContent = if isSave then updatedContent else edit.savedContent + ) + val modified = Badge(colorScheme = Some("red"), text = if updatedEdit.content != updatedEdit.savedContent then "*" else "") + val status = Badge(text = if updatedEdit.save then "Saved" else "") - Seq( - HStack().withChildren( - Menu().withChildren( - MenuButton(text = "File").withChildren(ChevronDownIcon()), - MenuList().withChildren( - MenuItem(text = "Save") - .onClick: () => - saveFile(editor.current.value) - // we'll display a "Saved" badge for 1 second. - Seq( - status.withText("Saved"), - modified.withText("") - ).renderChanges() - // each event handler runs on a new fiber, it is ok to sleep here - Thread.sleep(1000) - status.withText("").renderChanges() - , - MenuItem(text = "Exit") - .onClick: () => - exitLatch.countDown() + val view = Seq( + HStack().withChildren( + Menu().withChildren( + MenuButton("file-menu", text = "File").withChildren(ChevronDownIcon()), + MenuList().withChildren( + saveMenu, + exitMenu + ) + ), + status, + modified + ), + FormControl().withChildren( + FormLabel(text = "Editor"), + InputGroup().withChildren( + InputLeftAddon().withChildren(EditIcon()), + editorTextArea + ) ) - ), - status, - modified - ), - FormControl().withChildren( - FormLabel(text = "Editor"), - InputGroup().withChildren( - InputLeftAddon().withChildren(EditIcon()), - editor ) - ) - ).render() - println(s"Now open ${session.uiUrl} to view the UI") - exitLatch.await() - session.clear() - Paragraph(text = "Terminated").render() + MV(updatedEdit, view, terminate = events.isClicked(exitMenu)) + + println(s"Now open ${session.uiUrl} to view the UI") + Controller(components) + .render(Edit(contents, contents, false)) + .iterator + .tapEach: mv => + if mv.model.save then saveFile(mv.model.content) + .foreach(_ => ()) + session.render(Seq(Paragraph(text = "Terminated"))) diff --git a/example-spark/project.scala b/example-spark/project.scala index 72a41970..c4788bc7 100644 --- a/example-spark/project.scala +++ b/example-spark/project.scala @@ -8,10 +8,10 @@ //> using javaOpt -Dlogback.configurationFile=file:etc/logback.xml // terminal21 dependencies -//> using dep io.github.kostaskougios::terminal21-ui-std:0.21 -//> using dep io.github.kostaskougios::terminal21-spark:0.21 -//> using dep io.github.kostaskougios::terminal21-nivo:0.21 -//> using dep io.github.kostaskougios::terminal21-mathjax:0.21 +//> using dep io.github.kostaskougios::terminal21-ui-std:0.30 +//> using dep io.github.kostaskougios::terminal21-spark:0.30 +//> using dep io.github.kostaskougios::terminal21-nivo:0.30 +//> using dep io.github.kostaskougios::terminal21-mathjax:0.30 //> using dep ch.qos.logback:logback-classic:1.4.14 diff --git a/example-spark/spark-notebook.sc b/example-spark/spark-notebook.sc index 02a6a39c..54e0ec03 100755 --- a/example-spark/spark-notebook.sc +++ b/example-spark/spark-notebook.sc @@ -1,86 +1,107 @@ #!/usr/bin/env -S scala-cli --restart project.scala -/** - * note we use the --restart param for scala-cli. This means every time we change this file, scala-cli will terminate - * and rerun it with the changes. This way we get the notebook feel when we use spark scripts. - * - * terminal21 spark lib caches datasets by storing them into disk. This way complex queries won't have to be re-evaluated - * on each restart of the script. We can force re-evaluation by clicking the "Recalculate" buttons in the UI. - */ +/** note we use the --restart param for scala-cli. This means every time we change this file, scala-cli will terminate and rerun it with the changes. This way + * we get the notebook feel when we use spark scripts. + * + * terminal21 spark lib caches datasets by storing them into disk. This way complex queries won't have to be re-evaluated on each restart of the script. We can + * force re-evaluation by clicking the "Recalculate" buttons in the UI. + */ -// We need these imports import org.apache.spark.sql.* import org.terminal21.client.components.* import org.terminal21.client.components.chakra.* import org.terminal21.client.components.nivo.* -import org.terminal21.client.{*, given} +import org.terminal21.client.* import org.terminal21.sparklib.* import java.util.concurrent.atomic.AtomicInteger -import scala.util.Random +import scala.util.{Random, Using} import SparkNotebook.* import org.terminal21.client.components.mathjax.{MathJax, MathJaxLib} +import org.terminal21.client.components.std.Paragraph -SparkSessions.newTerminal21WithSparkSession(SparkSessions.newSparkSession(/* configure your spark session here */), "spark-notebook", "Spark Notebook", NivoLib, MathJaxLib): (spark, session) => - given ConnectedSession = session - given SparkSession = spark - import scala3encoders.given - import spark.implicits.* +Using.resource(SparkSessions.newSparkSession( /* configure your spark session here */ )): spark => + Sessions + .withNewSession("spark-notebook", "Spark Notebook") + .andLibraries(NivoLib, MathJaxLib) + .connect: session => + given ConnectedSession = session + given SparkSession = spark + import scala3encoders.given + import spark.implicits.* - // lets get a Dataset, the data are random so that when we click refresh we can see the data actually - // been refreshed. - val peopleDS = createPeople + // lets get a Dataset, the data are random so that when we click refresh we can see the data actually + // been refreshed. + val peopleDS = createPeople + val peopleSample = Cached("People sample"): + peopleDS + .sort($"id") + .limit(5) + val peopleOrderedByAge = Cached("Oldest people"): + peopleDS + .orderBy($"age".desc) - // We will display the data in a table - val peopleTable = QuickTable().headers("Id", "Name", "Age").caption("People") + /** The calculation above uses a directory to store the dataset results. This way we can restart this script without loosing datasets that may take long + * to calculate, making our script behave more like a notebook. When we click "Recalculate" in the UI, the cache directory is deleted and the dataset is + * re-evaluated. If the Dataset schema changes, please click "Recalculate" or manually delete this folder. + * + * The key for the cache is "People sample" + */ + println(s"Cache path: ${peopleSample.cachePath}") - val peopleTableCalc = peopleDS.sort($"id").visualize("People sample", peopleTable): data => - peopleTable.rows(data.take(5).map(p => Seq(p.id, p.name, p.age))) + def components(events: Events) = + given Events = events + // We will display the data in a table + val peopleTable = QuickTable().withHeaders("Id", "Name", "Age").withCaption("People") - /** The calculation above uses a directory to store the dataset results. This way we can restart this script without loosing datasets that may take long to - * calculate, making our script behave more like a notebook. When we click "Recalculate" in the UI, the cache directory is deleted and the dataset is - * re-evaluated. If the Dataset schema changes, please click "Recalculate" or manually delete this folder. - * - * The key for the cache is "People sample" - */ - println(s"Cache path: ${peopleTableCalc.cachePath}") + val peopleTableCalc = peopleSample.visualize(peopleTable): data => + peopleTable.withRows(data.collect.toList.map(p => Seq(p.id, p.name, p.age))) - val oldestPeopleChart = ResponsiveLine( - axisBottom = Some(Axis(legend = "Person", legendOffset = 36)), - axisLeft = Some(Axis(legend = "Age", legendOffset = -40)), - legends = Seq(Legend()) - ) - - val oldestPeopleChartCalc = peopleDS - .orderBy($"age".desc) - .visualize("Oldest people", oldestPeopleChart): data => - oldestPeopleChart.withData(Seq( - Serie( - "Person", - data = data.take(5).map(person => Datum(person.name, person.age)) + val oldestPeopleChart = ResponsiveLine( + axisBottom = Some(Axis(legend = "Person", legendOffset = 36)), + axisLeft = Some(Axis(legend = "Age", legendOffset = -40)), + legends = Seq(Legend()) ) - )) - Seq( - // just make it look a bit more like a proper notebook by adding some fake maths - MathJax( - expression = """ - |The following is total nonsense but it simulates some explanation that would normally be here if we had - |a proper notebook. When \(a \ne 0\), there are two solutions to \(x = {-b \pm \sqrt{b^2-4ac} \over 2a}.\) - |Aenean vel velit a lacus lacinia pulvinar. Morbi eget ex et tellus aliquam molestie sit amet eu diam. - |Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas tellus enim, tempor non efficitur et, rutrum efficitur metus. - |Nulla scelerisque, mauris sit amet accumsan iaculis, elit ipsum suscipit lorem, sed fermentum nunc purus non tellus. - |Aenean congue accumsan tempor. \(x = {-b \pm \sqrt{b^2-4ac} \over 2a}.\) maecenas vitae commodo tortor. Aliquam erat volutpat. Etiam laoreet malesuada elit sed vestibulum. - |Etiam consequat congue fermentum. Vivamus dapibus scelerisque ipsum eu tempus. Integer non pulvinar nisi. - |Morbi ultrices sem quis nisl convallis, ac cursus nunc condimentum. Orci varius natoque penatibus et magnis dis parturient montes, - |nascetur ridiculus mus. - |""".stripMargin - ), - peopleTableCalc, - oldestPeopleChartCalc - ).render() + val oldestPeopleChartCalc = peopleOrderedByAge + .visualize(oldestPeopleChart): data => + oldestPeopleChart.withData( + Seq( + Serie( + "Person", + data = data.take(5).map(person => Datum(person.name, person.age)) + ) + ) + ) - session.waitTillUserClosesSession() + Seq( + Paragraph( + text = """ + |The spark notebooks can use the `visualise` extension method over a dataframe/dataset. It will cache the dataset by + |saving it as a file under /tmp. The `Recalculate` button refreshes the dataset (re-runs it). In this example, the + |data are random and so are different each time the `Recalculate` is pressed. + |""".stripMargin, + style = Map("margin" -> "32px") + ), + // just make it look a bit more like a proper notebook by adding some fake maths + MathJax( + expression = """ + |The following is total nonsense but it simulates some explanation that would normally be here if we had + |a proper notebook. When \(a \ne 0\), there are two solutions to \(x = {-b \pm \sqrt{b^2-4ac} \over 2a}.\) + |Aenean vel velit a lacus lacinia pulvinar. Morbi eget ex et tellus aliquam molestie sit amet eu diam. + |Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas tellus enim, tempor non efficitur et, rutrum efficitur metus. + |Nulla scelerisque, mauris sit amet accumsan iaculis, elit ipsum suscipit lorem, sed fermentum nunc purus non tellus. + |Aenean congue accumsan tempor. \(x = {-b \pm \sqrt{b^2-4ac} \over 2a}.\) maecenas vitae commodo tortor. Aliquam erat volutpat. Etiam laoreet malesuada elit sed vestibulum. + |Etiam consequat congue fermentum. Vivamus dapibus scelerisque ipsum eu tempus. Integer non pulvinar nisi. + |Morbi ultrices sem quis nisl convallis, ac cursus nunc condimentum. Orci varius natoque penatibus et magnis dis parturient montes, + |nascetur ridiculus mus. + |""".stripMargin, + style = Map("margin" -> "32px") + ), + peopleTableCalc, + oldestPeopleChartCalc + ) + Controller.noModel(components).render().run() object SparkNotebook: private val names = Array("Andy", "Kostas", "Alex", "Andreas", "George", "Jack") diff --git a/project/build.properties b/project/build.properties index abbbce5d..04267b14 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.9.8 +sbt.version=1.9.9 diff --git a/terminal21-mathjax/src/main/scala/org/terminal21/client/components/mathjax/MathJax.scala b/terminal21-mathjax/src/main/scala/org/terminal21/client/components/mathjax/MathJax.scala index 4deef557..c41159da 100644 --- a/terminal21-mathjax/src/main/scala/org/terminal21/client/components/mathjax/MathJax.scala +++ b/terminal21-mathjax/src/main/scala/org/terminal21/client/components/mathjax/MathJax.scala @@ -2,6 +2,7 @@ package org.terminal21.client.components.mathjax import org.terminal21.client.components.UiElement.HasStyle import org.terminal21.client.components.{Keys, UiElement} +import org.terminal21.collections.TypedMap sealed trait MathJaxElement extends UiElement @@ -11,9 +12,12 @@ case class MathJax( key: String = Keys.nextKey, // expression should be like """ text \( asciimath \) text""", i.e. """When \(a \ne 0\), there are two solutions to \(ax^2 + bx + c = 0\)""" expression: String = """fill in the expression as per https://asciimath.org/""", - style: Map[String, Any] = Map.empty // Note: some of the styles are ignored by mathjax lib + style: Map[String, Any] = Map.empty, // Note: some of the styles are ignored by mathjax lib + dataStore: TypedMap = TypedMap.Empty ) extends MathJaxElement - with HasStyle[MathJax]: + with HasStyle: + type This = MathJax override def withStyle(v: Map[String, Any]): MathJax = copy(style = v) def withKey(k: String) = copy(key = k) def withExpression(e: String) = copy(expression = e) + override def withDataStore(ds: TypedMap): MathJax = copy(dataStore = ds) diff --git a/terminal21-mathjax/src/main/scala/org/terminal21/client/components/mathjax/MathJaxLib.scala b/terminal21-mathjax/src/main/scala/org/terminal21/client/components/mathjax/MathJaxLib.scala index 5946f253..e9747117 100644 --- a/terminal21-mathjax/src/main/scala/org/terminal21/client/components/mathjax/MathJaxLib.scala +++ b/terminal21-mathjax/src/main/scala/org/terminal21/client/components/mathjax/MathJaxLib.scala @@ -6,6 +6,6 @@ import io.circe.* import org.terminal21.client.components.{ComponentLib, UiElement} object MathJaxLib extends ComponentLib: - import org.terminal21.client.components.StdElementEncoding.given + import org.terminal21.client.json.StdElementEncoding.given override def toJson(using Encoder[UiElement]): PartialFunction[UiElement, Json] = case n: MathJaxElement => n.asJson.mapObject(o => o.add("type", "MathJax".asJson)) diff --git a/terminal21-nivo/src/main/scala/org/terminal21/client/components/NivoLib.scala b/terminal21-nivo/src/main/scala/org/terminal21/client/components/NivoLib.scala index 4a68b032..97d595a5 100644 --- a/terminal21-nivo/src/main/scala/org/terminal21/client/components/NivoLib.scala +++ b/terminal21-nivo/src/main/scala/org/terminal21/client/components/NivoLib.scala @@ -1,12 +1,11 @@ package org.terminal21.client.components +import io.circe.* import io.circe.generic.auto.* import io.circe.syntax.* -import io.circe.* import org.terminal21.client.components.nivo.NEJson -import org.terminal21.client.components.{ComponentLib, UiElement} object NivoLib extends ComponentLib: - import org.terminal21.client.components.StdElementEncoding.given + import org.terminal21.client.json.StdElementEncoding.given override def toJson(using Encoder[UiElement]): PartialFunction[UiElement, Json] = case n: NEJson => n.asJson.mapObject(o => o.add("type", "Nivo".asJson)) diff --git a/terminal21-nivo/src/main/scala/org/terminal21/client/components/nivo/NivoElement.scala b/terminal21-nivo/src/main/scala/org/terminal21/client/components/nivo/NivoElement.scala index 8816f500..1782eec5 100644 --- a/terminal21-nivo/src/main/scala/org/terminal21/client/components/nivo/NivoElement.scala +++ b/terminal21-nivo/src/main/scala/org/terminal21/client/components/nivo/NivoElement.scala @@ -2,9 +2,10 @@ package org.terminal21.client.components.nivo import org.terminal21.client.components.UiElement.HasStyle import org.terminal21.client.components.{Keys, UiElement} +import org.terminal21.collections.TypedMap -sealed trait NEJson extends UiElement -sealed trait NivoElement[A <: UiElement] extends NEJson with HasStyle[A] +sealed trait NEJson extends UiElement +sealed trait NivoElement extends NEJson with HasStyle /** https://nivo.rocks/line/ */ @@ -28,11 +29,14 @@ case class ResponsiveLine( pointBorderColor: Map[String, String] = Map("from" -> "serieColor"), pointLabelYOffset: Int = -12, useMesh: Boolean = true, - legends: Seq[Legend] = Nil -) extends NivoElement[ResponsiveLine]: + legends: Seq[Legend] = Nil, + dataStore: TypedMap = TypedMap.Empty +) extends NivoElement: + type This = ResponsiveLine override def withStyle(v: Map[String, Any]): ResponsiveLine = copy(style = v) def withKey(v: String) = copy(key = v) def withData(data: Seq[Serie]) = copy(data = data) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** https://nivo.rocks/bar/ */ @@ -56,8 +60,11 @@ case class ResponsiveBar( axisBottom: Option[Axis] = Some(Axis(legend = "y", legendOffset = 36)), axisLeft: Option[Axis] = Some(Axis(legend = "x", legendOffset = -40)), legends: Seq[Legend] = Nil, - ariaLabel: String = "Chart Label" -) extends NivoElement[ResponsiveBar]: + ariaLabel: String = "Chart Label", + dataStore: TypedMap = TypedMap.Empty +) extends NivoElement: + type This = ResponsiveBar override def withStyle(v: Map[String, Any]): ResponsiveBar = copy(style = v) def withKey(v: String) = copy(key = v) def withData(data: Seq[Seq[BarDatum]]) = copy(data = data) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) diff --git a/terminal21-server-app/src/main/resources/logback.xml b/terminal21-server-app/src/main/resources/logback.xml new file mode 100644 index 00000000..17c2c6ad --- /dev/null +++ b/terminal21-server-app/src/main/resources/logback.xml @@ -0,0 +1,17 @@ + + + + + + + + %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + diff --git a/terminal21-server-app/src/main/scala/org/terminal21/server/Dependencies.scala b/terminal21-server-app/src/main/scala/org/terminal21/server/Dependencies.scala new file mode 100644 index 00000000..f550ca07 --- /dev/null +++ b/terminal21-server-app/src/main/scala/org/terminal21/server/Dependencies.scala @@ -0,0 +1,8 @@ +package org.terminal21.server + +import functions.fibers.FiberExecutor +import org.terminal21.serverapp.{ServerSideApp, ServerSideSessionsBeans} +import org.terminal21.serverapp.bundled.AppManagerBeans + +class Dependencies(val fiberExecutor: FiberExecutor, val apps: Seq[ServerSideApp]) extends ServerBeans with ServerSideSessionsBeans with AppManagerBeans: + override def dependencies: Dependencies = this diff --git a/terminal21-server/src/main/scala/org/terminal21/server/Terminal21Server.scala b/terminal21-server-app/src/main/scala/org/terminal21/server/Terminal21Server.scala similarity index 80% rename from terminal21-server/src/main/scala/org/terminal21/server/Terminal21Server.scala rename to terminal21-server-app/src/main/scala/org/terminal21/server/Terminal21Server.scala index 70e8be52..0d0e42a3 100644 --- a/terminal21-server/src/main/scala/org/terminal21/server/Terminal21Server.scala +++ b/terminal21-server-app/src/main/scala/org/terminal21/server/Terminal21Server.scala @@ -6,15 +6,17 @@ import io.helidon.webserver.WebServer import io.helidon.webserver.http.HttpRouting import org.slf4j.LoggerFactory import org.terminal21.config.Config +import org.terminal21.serverapp.ServerSideApp +import org.terminal21.serverapp.bundled.DefaultApps import java.net.InetAddress object Terminal21Server: - private val logger = LoggerFactory.getLogger(getClass) - def start(port: Option[Int] = None): Unit = + private val logger = LoggerFactory.getLogger(getClass) + def start(port: Option[Int] = None, apps: Seq[ServerSideApp] = Nil, defaultApps: Seq[ServerSideApp] = DefaultApps.All): Unit = FiberExecutor.withFiberExecutor: executor => val portV = port.getOrElse(Config.Default.port) - val dependencies = new Dependencies(executor) + val dependencies = new Dependencies(executor, apps ++ defaultApps) val routesBuilder = HttpRouting.builder() Routes.register(dependencies, routesBuilder) Routes.static(routesBuilder) @@ -27,6 +29,7 @@ object Terminal21Server: .build .start + dependencies.appManager.start() if !server.isRunning then throw new IllegalStateException("Server failed to start") try logger.info(s"Terminal 21 Server started. Please open http://localhost:$portV/ui for the user interface") diff --git a/terminal21-server-app/src/main/scala/org/terminal21/serverapp/ServerSideApp.scala b/terminal21-server-app/src/main/scala/org/terminal21/serverapp/ServerSideApp.scala new file mode 100644 index 00000000..e057414c --- /dev/null +++ b/terminal21-server-app/src/main/scala/org/terminal21/serverapp/ServerSideApp.scala @@ -0,0 +1,8 @@ +package org.terminal21.serverapp + +import org.terminal21.server.Dependencies + +trait ServerSideApp: + def name: String + def description: String + def createSession(serverSideSessions: ServerSideSessions, dependencies: Dependencies): Unit diff --git a/terminal21-server-app/src/main/scala/org/terminal21/serverapp/ServerSideSessions.scala b/terminal21-server-app/src/main/scala/org/terminal21/serverapp/ServerSideSessions.scala new file mode 100644 index 00000000..5f0ed820 --- /dev/null +++ b/terminal21-server-app/src/main/scala/org/terminal21/serverapp/ServerSideSessions.scala @@ -0,0 +1,49 @@ +package org.terminal21.serverapp + +import functions.fibers.FiberExecutor +import org.terminal21.client.ConnectedSession +import org.terminal21.client.components.ComponentLib +import org.terminal21.client.json.{StdElementEncoding, UiElementEncoding} +import org.terminal21.config.Config +import org.terminal21.model.SessionOptions +import org.terminal21.server.service.ServerSessionsService + +import java.util.concurrent.atomic.AtomicBoolean + +class ServerSideSessions(sessionsService: ServerSessionsService, executor: FiberExecutor): + case class ServerSideSessionBuilder( + id: String, + name: String, + componentLibs: Seq[ComponentLib] = Seq(StdElementEncoding), + sessionOptions: SessionOptions = SessionOptions.Defaults + ): + def andLibraries(libraries: ComponentLib*): ServerSideSessionBuilder = copy(componentLibs = componentLibs ++ libraries) + def andOptions(sessionOptions: SessionOptions) = copy(sessionOptions = sessionOptions) + + def connect[R](f: ConnectedSession => R): R = + val config = Config.Default + val serverUrl = s"http://${config.host}:${config.port}" + + val session = sessionsService.createSession(id, name, sessionOptions) + val encoding = new UiElementEncoding(Seq(StdElementEncoding) ++ componentLibs) + val isStopped = new AtomicBoolean(false) + + def terminate(): Unit = + isStopped.set(true) + + val connectedSession = ConnectedSession(session, encoding, serverUrl, sessionsService, terminate) + sessionsService.notifyMeOnSessionEvents(session): event => + executor.submit: + connectedSession.fireEvent(event) + true + try + f(connectedSession) + finally + if !isStopped.get() && !connectedSession.isLeaveSessionOpen then sessionsService.terminateSession(session) + + def withNewSession(id: String, name: String): ServerSideSessionBuilder = ServerSideSessionBuilder(id, name) + +trait ServerSideSessionsBeans: + def sessionsService: ServerSessionsService + def fiberExecutor: FiberExecutor + lazy val serverSideSessions = new ServerSideSessions(sessionsService, fiberExecutor) diff --git a/terminal21-server-app/src/main/scala/org/terminal21/serverapp/bundled/AppManager.scala b/terminal21-server-app/src/main/scala/org/terminal21/serverapp/bundled/AppManager.scala new file mode 100644 index 00000000..75ab80bf --- /dev/null +++ b/terminal21-server-app/src/main/scala/org/terminal21/serverapp/bundled/AppManager.scala @@ -0,0 +1,86 @@ +package org.terminal21.serverapp.bundled + +import functions.fibers.FiberExecutor +import org.terminal21.client.* +import org.terminal21.client.components.* +import org.terminal21.client.components.chakra.* +import org.terminal21.client.components.std.{Header1, Paragraph, Span} +import org.terminal21.model.SessionOptions +import org.terminal21.server.Dependencies +import org.terminal21.serverapp.{ServerSideApp, ServerSideSessions} + +class AppManager(serverSideSessions: ServerSideSessions, fiberExecutor: FiberExecutor, apps: Seq[ServerSideApp], dependencies: Dependencies): + def start(): Unit = + fiberExecutor.submit: + serverSideSessions + .withNewSession("app-manager", "Terminal 21") + .andOptions(SessionOptions(alwaysOpen = true)) + .connect: session => + given ConnectedSession = session + new AppManagerPage(apps, startApp).run() + + private def startApp(app: ServerSideApp): Unit = + fiberExecutor.submit: + app.createSession(serverSideSessions, dependencies) + +class AppManagerPage(apps: Seq[ServerSideApp], startApp: ServerSideApp => Unit)(using session: ConnectedSession): + case class ManagerModel(startApp: Option[ServerSideApp] = None) + + def run(): Unit = + eventsIterator.foreach(_ => ()) + + private case class TableView(clicked: Option[ServerSideApp], columns: Seq[UiElement]) + private def appRows(events: Events): Seq[TableView] = apps.map: app => + val link = Link(s"app-${app.name}", text = app.name) + TableView( + if events.isClicked(link) then Some(app) else None, + Seq( + link, + Text(text = app.description) + ) + ) + + def components(model: ManagerModel, events: Events): MV[ManagerModel] = + val appsMv = appRows(events) + val appsTable = QuickTable( + key = "apps-table", + caption = Some("Apps installed on the server, click one to run it."), + rows = appsMv.map(tv => tv.columns) + ).withHeaders("App Name", "Description") + val startApp = appsMv.map(_.clicked).find(_.nonEmpty).flatten + MV( + model.copy(startApp = startApp), + Seq( + Header1(text = "Terminal 21 Manager"), + Paragraph(text = "Here you can run all the installed apps on the server."), + appsTable, + Paragraph().withChildren( + Span(text = "Have a question? Please ask at "), + Link( + key = "discussion-board-link", + text = "terminal21's discussion board ", + href = "https://github.com/kostaskougios/terminal21-restapi/discussions", + color = Some("teal.500"), + isExternal = Some(true) + ).withChildren(ExternalLinkIcon(mx = Some("2px"))) + ) + ) + ) + + def controller: Controller[ManagerModel] = + Controller(components) + + def eventsIterator: Iterator[ManagerModel] = + controller + .render(ManagerModel()) + .iterator + .map(_.model) + .tapEach: m => + for app <- m.startApp do startApp(app) + +trait AppManagerBeans: + def serverSideSessions: ServerSideSessions + def fiberExecutor: FiberExecutor + def apps: Seq[ServerSideApp] + def dependencies: Dependencies + lazy val appManager = new AppManager(serverSideSessions, fiberExecutor, apps, dependencies) diff --git a/terminal21-server-app/src/main/scala/org/terminal21/serverapp/bundled/DefaultApps.scala b/terminal21-server-app/src/main/scala/org/terminal21/serverapp/bundled/DefaultApps.scala new file mode 100644 index 00000000..101c49ba --- /dev/null +++ b/terminal21-server-app/src/main/scala/org/terminal21/serverapp/bundled/DefaultApps.scala @@ -0,0 +1,7 @@ +package org.terminal21.serverapp.bundled + +object DefaultApps: + val All = Seq( + new ServerStatusApp, + new SettingsApp + ) diff --git a/terminal21-server-app/src/main/scala/org/terminal21/serverapp/bundled/ServerStatusApp.scala b/terminal21-server-app/src/main/scala/org/terminal21/serverapp/bundled/ServerStatusApp.scala new file mode 100644 index 00000000..89b2ab26 --- /dev/null +++ b/terminal21-server-app/src/main/scala/org/terminal21/serverapp/bundled/ServerStatusApp.scala @@ -0,0 +1,149 @@ +package org.terminal21.serverapp.bundled + +import functions.fibers.FiberExecutor +import io.circe.Json +import org.terminal21.client.* +import org.terminal21.client.components.* +import org.terminal21.client.components.chakra.* +import org.terminal21.client.components.std.Paragraph +import org.terminal21.model.{ClientEvent, Session} +import org.terminal21.server.Dependencies +import org.terminal21.server.model.SessionState +import org.terminal21.server.service.ServerSessionsService +import org.terminal21.serverapp.{ServerSideApp, ServerSideSessions} + +class ServerStatusApp extends ServerSideApp: + override def name: String = "Server Status" + override def description: String = "Status of the server." + + override def createSession(serverSideSessions: ServerSideSessions, dependencies: Dependencies): Unit = + serverSideSessions + .withNewSession("server-status", "Server Status") + .connect: session => + new ServerStatusPage(serverSideSessions, dependencies.sessionsService)(using session, dependencies.fiberExecutor).run() + +class ServerStatusPage( + serverSideSessions: ServerSideSessions, + sessionsService: ServerSessionsService +)(using appSession: ConnectedSession, fiberExecutor: FiberExecutor): + case class StatusModel(runtime: Runtime, sessions: Seq[Session]) + val initModel = StatusModel(Runtime.getRuntime, sessionsService.allSessions) + + case class Ticker(sessions: Seq[Session]) extends ClientEvent + + def run(): Unit = + fiberExecutor.submit: + while !appSession.isClosed do + Thread.sleep(2000) + appSession.fireEvents(Ticker(sessionsService.allSessions)) + + try controller.render(initModel).iterator.lastOption + catch case t: Throwable => t.printStackTrace() + + private def toMb(v: Long) = s"${v / (1024 * 1024)} MB" + private val xs = Some("2xs") + + def controller: Controller[StatusModel] = + Controller(components) + + def components(model: StatusModel, events: Events): MV[StatusModel] = + val newModel = events.event match + case Ticker(sessions) => model.copy(sessions = sessions) + case _ => model + + MV( + newModel, + Box().withChildren( + jvmTable(newModel.runtime, events), + sessionsTable(newModel.sessions, events) + ) + ) + + private val jvmTableE = QuickTable(key = "jvmTable", caption = Some("JVM")) + .withHeaders("Property", "Value", "Actions") + private val gcButton = Button(key = "gc-button", size = xs, text = "Run GC") + + def jvmTable(runtime: Runtime, events: Events): UiElement = + if events.isClicked(gcButton) then System.gc() + jvmTableE.withRows( + Seq( + Seq("Free Memory", toMb(runtime.freeMemory()), ""), + Seq("Max Memory", toMb(runtime.maxMemory()), ""), + Seq("Total Memory", toMb(runtime.totalMemory()), gcButton), + Seq("Available processors", runtime.availableProcessors(), "") + ) + ) + + private val sessionsTableE = + QuickTable( + key = "sessions-table", + caption = Some("All sessions") + ).withHeaders("Id", "Name", "Is Open", "Actions") + + def sessionsTable(sessions: Seq[Session], events: Events): UiElement = + sessionsTableE.withRows( + sessions.map: session => + Seq(Text(text = session.id), Text(text = session.name), if session.isOpen then CheckIcon() else NotAllowedIcon(), actionsFor(session, events)) + ) + + private def actionsFor(session: Session, events: Events): UiElement = + if session.isOpen then + val closeButton = Button(key = s"close-${session.id}", text = "Close", size = xs) + .withLeftIcon(SmallCloseIcon()) + if events.isClicked(closeButton) then sessionsService.terminateAndRemove(session) + val viewButton = Button(key = s"view-${session.id}", text = "View State", size = xs) + .withLeftIcon(ChatIcon()) + if events.isClicked(viewButton) then + serverSideSessions + .withNewSession(session.id + "-server-state", s"Server State:${session.id}") + .connect: sSession => + new ViewServerStatePage(using sSession).runFor(sessionsService.sessionStateOf(session)) + + Box().withChildren( + closeButton, + Text(text = " "), + viewButton + ) + else NotAllowedIcon() + +class ViewServerStatePage(using session: ConnectedSession): + + private val firstStyle = Map("margin" -> "24px") + def jsonToTable(depth: Int, j: Json): UiElement = + j.fold( + jsonNull = Text(text = "null"), + jsonBoolean = b => Text(text = b.toString), + jsonNumber = n => Text(text = n.toString), + jsonString = s => Text(text = s), + jsonArray = arr => if arr.isEmpty then Text(text = "") else Box().withChildren(arr.map(a => jsonToTable(depth + 1, a))*), + jsonObject = o => { + val keyValues = o.toList + if keyValues.isEmpty then Text(text = "") + else + Table(style = if depth == 1 then firstStyle else Map.empty).withChildren( + Tbody(children = keyValues.map: (k, v) => + Tr() + .withBg("blackAlpha.500") + .withChildren( + Td(text = k), + Td().withChildren(jsonToTable(depth + 1, v)) + )) + ) + } + ) + + def runFor(state: SessionState): Unit = + val sj = state.serverJson + + val components = Seq( + QuickTabs() + .withTabs("Hierarchy", "Json") + .withTabPanelsSimple( + Box().withChildren(sj.elements.map(j => jsonToTable(1, j))*), + Paragraph().withChildren( + sj.elements.map(e => Text(text = e.toString))* + ) + ) + ) + session.render(components) + session.leaveSessionOpenAfterExiting() diff --git a/terminal21-server-app/src/main/scala/org/terminal21/serverapp/bundled/SettingsApp.scala b/terminal21-server-app/src/main/scala/org/terminal21/serverapp/bundled/SettingsApp.scala new file mode 100644 index 00000000..124022c3 --- /dev/null +++ b/terminal21-server-app/src/main/scala/org/terminal21/serverapp/bundled/SettingsApp.scala @@ -0,0 +1,28 @@ +package org.terminal21.serverapp.bundled + +import org.terminal21.client.components.* +import org.terminal21.client.components.frontend.ThemeToggle +import org.terminal21.client.* +import org.terminal21.server.Dependencies +import org.terminal21.serverapp.{ServerSideApp, ServerSideSessions} + +class SettingsApp extends ServerSideApp: + override def name = "Settings" + + override def description = "Terminal21 Settings" + + override def createSession(serverSideSessions: ServerSideSessions, dependencies: Dependencies): Unit = + serverSideSessions + .withNewSession("frontend-settings", "Settings") + .connect: session => + given ConnectedSession = session + new SettingsPage().run() + +class SettingsPage(using session: ConnectedSession): + val themeToggle = ThemeToggle() + def run() = + controller.render(()).iterator.lastOption + + def components = Seq(themeToggle) + + def controller = Controller.noModel(components) diff --git a/terminal21-server-app/src/test/scala/org/terminal21/serverapp/ServerSideSessionsTest.scala b/terminal21-server-app/src/test/scala/org/terminal21/serverapp/ServerSideSessionsTest.scala new file mode 100644 index 00000000..5db136d1 --- /dev/null +++ b/terminal21-server-app/src/test/scala/org/terminal21/serverapp/ServerSideSessionsTest.scala @@ -0,0 +1,52 @@ +package org.terminal21.serverapp + +import functions.fibers.FiberExecutor +import org.mockito.Mockito +import org.mockito.Mockito.{verify, when} +import org.scalatest.BeforeAndAfterAll +import org.scalatest.funsuite.AnyFunSuiteLike +import org.scalatestplus.mockito.MockitoSugar.* +import org.terminal21.model.{CommonModelBuilders, SessionOptions} +import org.terminal21.model.CommonModelBuilders.session +import org.terminal21.server.service.ServerSessionsService + +class ServerSideSessionsTest extends AnyFunSuiteLike with BeforeAndAfterAll: + val executor = FiberExecutor() + override protected def afterAll(): Unit = executor.shutdown() + + test("creates session"): + new App: + val s = session() + when(sessionsService.createSession(s.id, s.name, SessionOptions.Defaults)).thenReturn(s) + serverSideSessions + .withNewSession(s.id, s.name) + .connect: session => + session.leaveSessionOpenAfterExiting() + + verify(sessionsService).createSession(s.id, s.name, SessionOptions.Defaults) + + test("terminates session before exiting"): + new App: + val s = session() + when(sessionsService.createSession(s.id, s.name, SessionOptions.Defaults)).thenReturn(s) + serverSideSessions + .withNewSession(s.id, s.name) + .connect: _ => + () + + verify(sessionsService).terminateSession(s) + + test("registers to receive events"): + new App: + val s = session() + when(sessionsService.createSession(s.id, s.name, SessionOptions.Defaults)).thenReturn(s) + serverSideSessions + .withNewSession(s.id, s.name) + .connect: _ => + () + + verify(sessionsService).notifyMeOnSessionEvents(s) + + class App: + val sessionsService = mock[ServerSessionsService] + val serverSideSessions = new ServerSideSessions(sessionsService, executor) diff --git a/terminal21-server-app/src/test/scala/org/terminal21/serverapp/bundled/AppManagerPageTest.scala b/terminal21-server-app/src/test/scala/org/terminal21/serverapp/bundled/AppManagerPageTest.scala new file mode 100644 index 00000000..70ea67bb --- /dev/null +++ b/terminal21-server-app/src/test/scala/org/terminal21/serverapp/bundled/AppManagerPageTest.scala @@ -0,0 +1,63 @@ +//package org.terminal21.serverapp.bundled +// +//import org.mockito.Mockito +//import org.mockito.Mockito.when +//import org.scalatest.funsuite.AnyFunSuiteLike +//import org.scalatestplus.mockito.MockitoSugar.mock +//import org.terminal21.client.components.* +//import org.terminal21.client.components.chakra.{Link, Text} +//import org.terminal21.client.{ConnectedSession, ConnectedSessionMock} +//import org.terminal21.serverapp.ServerSideApp +//import org.scalatest.matchers.should.Matchers.* +//import org.terminal21.model.CommandEvent +// +//class AppManagerPageTest extends AnyFunSuiteLike: +// def mockApp(name: String, description: String) = +// val app = mock[ServerSideApp] +// when(app.name).thenReturn(name) +// when(app.description).thenReturn(description) +// app +// +// class App(apps: ServerSideApp*): +// given session: ConnectedSession = ConnectedSessionMock.newConnectedSessionMock +// var startedApp: Option[ServerSideApp] = None +// val page = new AppManagerPage(apps, app => startedApp = Some(app)) +// val model = page.ManagerModel() +// def allComponents = page.components.flatMap(_.flat) +// +// test("renders app links"): +// new App(mockApp("app1", "the-app1-desc")): +// allComponents +// .collect: +// case l: Link if l.text == "app1" => l +// .size should be(1) +// +// test("renders app description"): +// new App(mockApp("app1", "the-app1-desc")): +// allComponents +// .collect: +// case t: Text if t.text == "the-app1-desc" => t +// .size should be(1) +// +// test("renders the discussions link"): +// new App(): +// allComponents +// .collect: +// case l: Link if l.href == "https://github.com/kostaskougios/terminal21-restapi/discussions" => l +// .size should be(1) +// +// test("starts app when app link is clicked"): +// val app = mockApp("app1", "the-app1-desc") +// new App(app): +// val eventsIt = page.eventsIterator +// session.fireEvents(CommandEvent.onClick(page.appRows.head.head), CommandEvent.sessionClosed) +// eventsIt.toList +// startedApp should be(Some(app)) +// +// test("resets startApp state on other events"): +// val app = mockApp("app1", "the-app1-desc") +// new App(app): +// val other = allComponents.find(_.key == "discussion-board-link").get +// val eventsIt = page.controller.render().handledEventsIterator +// session.fireEvents(CommandEvent.onClick(page.appRows.head.head), CommandEvent.onClick(other), CommandEvent.sessionClosed) +// eventsIt.toList.map(_.model).last.startApp should be(None) diff --git a/terminal21-server-app/src/test/scala/org/terminal21/serverapp/bundled/ServerStatusPageTest.scala b/terminal21-server-app/src/test/scala/org/terminal21/serverapp/bundled/ServerStatusPageTest.scala new file mode 100644 index 00000000..6a6bd0f3 --- /dev/null +++ b/terminal21-server-app/src/test/scala/org/terminal21/serverapp/bundled/ServerStatusPageTest.scala @@ -0,0 +1,69 @@ +//package org.terminal21.serverapp.bundled +// +//import org.mockito.Mockito.when +//import org.scalatest.funsuite.AnyFunSuiteLike +//import org.scalatest.matchers.should.Matchers.* +//import org.scalatestplus.mockito.MockitoSugar.mock +//import org.terminal21.client.components.chakra.{Button, CheckIcon, NotAllowedIcon} +//import org.terminal21.client.{ConnectedSession, ConnectedSessionMock, given} +//import org.terminal21.model.CommonModelBuilders.session +//import org.terminal21.model.{CommandEvent, CommonModelBuilders, Session} +//import org.terminal21.server.service.ServerSessionsService +//import org.terminal21.serverapp.ServerSideSessions +// +//class ServerStatusPageTest extends AnyFunSuiteLike: +// class App: +// given connectedSession: ConnectedSession = ConnectedSessionMock.newConnectedSessionMock +// val sessionsService = mock[ServerSessionsService] +// val serverSideSessions = mock[ServerSideSessions] +// val session1 = session(id = "session1") +// when(sessionsService.allSessions).thenReturn(Seq(session1)) +// val page = new ServerStatusPage(serverSideSessions, sessionsService) +// +// test("Close button for a session"): +// new App: +// page.sessionsTable.flat +// .collectFirst: +// case b: Button if b.text == "Close" => b +// .isEmpty should be(false) +// +// test("View state button for a session"): +// new App: +// page.sessionsTable.flat +// .collectFirst: +// case b: Button if b.text == "View State" => b +// .isEmpty should be(false) +// +// test("When session is open, a CheckIcon is displayed"): +// new App: +// page.sessionsTable.flat +// .collectFirst: +// case i: CheckIcon => i +// .isEmpty should be(false) +// +// test("When session is closed, a NotAllowedIcon is displayed"): +// new App: +// import page.given +// val table = page.sessionsTable +// val m = page.initModel.copy(sessions = Seq(session(isOpen = false))) +// table +// .fireModelChangeRender(m) +// .flat +// .collectFirst: +// case i: NotAllowedIcon => i +// .isEmpty should be(false) +// +// test("sessions are rendered when Ticker event is fired"): +// new App: +// val it = page.controller.render().handledEventsIterator +// private val sessions2 = Seq(session(id = "s2", name = "session 2")) +// private val sessions3 = Seq(session(id = "s3", name = "session 3")) +// connectedSession.fireEvents( +// page.Ticker(sessions2), +// page.Ticker(sessions3), +// CommandEvent.sessionClosed +// ) +// val handledEvents = it.toList +// handledEvents.head.model.sessions should be(Seq(session(id = "session1"))) +// handledEvents(1).model.sessions should be(sessions2) +// handledEvents(2).model.sessions should be(sessions3) diff --git a/terminal21-server-app/src/test/scala/org/terminal21/serverapp/bundled/SettingsPageTest.scala b/terminal21-server-app/src/test/scala/org/terminal21/serverapp/bundled/SettingsPageTest.scala new file mode 100644 index 00000000..5a88f916 --- /dev/null +++ b/terminal21-server-app/src/test/scala/org/terminal21/serverapp/bundled/SettingsPageTest.scala @@ -0,0 +1,14 @@ +package org.terminal21.serverapp.bundled + +import org.scalatest.funsuite.AnyFunSuiteLike +import org.scalatest.matchers.should.Matchers.* +import org.terminal21.client.{*, given} + +class SettingsPageTest extends AnyFunSuiteLike: + class App: + given session: ConnectedSession = ConnectedSessionMock.newConnectedSessionMock + val page = new SettingsPage + + test("Should render the ThemeToggle component"): + new App: + page.components should contain(page.themeToggle) diff --git a/terminal21-server-client-common/src/main/scala/org/terminal21/client/components/AnyElement.scala b/terminal21-server-client-common/src/main/scala/org/terminal21/client/components/AnyElement.scala new file mode 100644 index 00000000..3a1fe334 --- /dev/null +++ b/terminal21-server-client-common/src/main/scala/org/terminal21/client/components/AnyElement.scala @@ -0,0 +1,6 @@ +package org.terminal21.client.components + +/** Base trait for any renderable element that has a key + */ +trait AnyElement: + def key: String diff --git a/terminal21-server-client-common/src/main/scala/org/terminal21/collections/SEList.scala b/terminal21-server-client-common/src/main/scala/org/terminal21/collections/SEList.scala new file mode 100644 index 00000000..a3830e1e --- /dev/null +++ b/terminal21-server-client-common/src/main/scala/org/terminal21/collections/SEList.scala @@ -0,0 +1,76 @@ +package org.terminal21.collections + +import java.util.concurrent.CountDownLatch + +class SEList[A]: + @volatile private var currentNode: NormalNode[A] = NormalNode(None, EndNode) + + private val atLeastOneIterator = new CountDownLatch(1) + + /** @return + * A new iterator that only reads elements that are added before the iterator is created. + */ + def iterator: SEBlockingIterator[A] = + atLeastOneIterator.countDown() + new SEBlockingIterator(currentNode) + + def waitUntilAtLeast1IteratorWasCreated(): Unit = atLeastOneIterator.await() + + /** Add a poison pill to terminate all iterators. + */ + def poisonPill(): Unit = + synchronized: + currentNode.valueAndNext = (None, PoisonPillNode) + currentNode.latch.countDown() + + /** Adds an item that will be visible to all iterators that were created before this item was added. + * @param item + * the item + */ + def add(item: A): Unit = + val cn = synchronized: + val cn = currentNode + if cn.valueAndNext._2 == PoisonPillNode then throw new IllegalStateException("Can't add items when the list has been poisoned.") + val n = NormalNode(None, currentNode.valueAndNext._2) + currentNode.valueAndNext = (Some(item), n) + currentNode = n + cn + cn.latch.countDown() + +class SEBlockingIterator[A](@volatile var currentNode: NormalNode[A]) extends Iterator[A]: + /** @return + * true if hasNext & next() will return immediately with the next value. This won't block. + */ + def isNextAvailable: Boolean = currentNode.hasValue + + /** @return + * true if there is a next() but blocks otherwise till next() becomes available or we are at the end of the iterator. + */ + override def hasNext: Boolean = + currentNode.waitValue() + val v = currentNode.valueAndNext._2 + if v == PoisonPillNode then false else true + + /** @return + * the next element or blocks until the next element becomes available + */ + override def next(): A = + if hasNext then + val v = currentNode.value + currentNode = currentNode.next + v + else throw new NoSuchElementException("next() called but there is no next element. The SEList has been poisoned and we reached the PoisonPill") + +sealed trait Node[+A] +case object EndNode extends Node[Nothing] +case object PoisonPillNode extends Node[Nothing] + +case class NormalNode[A](@volatile var valueAndNext: (Option[A], Node[A])) extends Node[A]: + val latch = new CountDownLatch(1) + def waitValue(): Unit = latch.await() + + def hasValue: Boolean = valueAndNext._1.nonEmpty + def value: A = valueAndNext._1.get + def next: NormalNode[A] = valueAndNext._2 match + case nn: NormalNode[A] @unchecked => nn + case _ => throw new NoSuchElementException("next should be called only if hasValue is true") diff --git a/terminal21-server-client-common/src/main/scala/org/terminal21/collections/TypedMap.scala b/terminal21-server-client-common/src/main/scala/org/terminal21/collections/TypedMap.scala new file mode 100644 index 00000000..52f05908 --- /dev/null +++ b/terminal21-server-client-common/src/main/scala/org/terminal21/collections/TypedMap.scala @@ -0,0 +1,31 @@ +package org.terminal21.collections + +import scala.reflect.{ClassTag, classTag} + +type TMMap = Map[TypedMapKey[_], Any] + +class TypedMap(protected val m: TMMap): + def +[A](kv: (TypedMapKey[A], A)): TypedMap = new TypedMap(m + kv) + def apply[A](k: TypedMapKey[A]): A = m(k).asInstanceOf[A] + def get[A](k: TypedMapKey[A]): Option[A] = m.get(k).asInstanceOf[Option[A]] + def getOrElse[A](k: TypedMapKey[A], default: => A) = m.getOrElse(k, default).asInstanceOf[A] + def keys: Iterable[TypedMapKey[_]] = m.keys + + override def hashCode() = m.hashCode() + override def equals(obj: Any) = obj match + case tm: TypedMap => m == tm.m + case _ => false + + def contains[A](k: TypedMapKey[A]) = m.contains(k) + override def toString = s"TypedMap(${m.keys.mkString(", ")})" + +object TypedMap: + val Empty = new TypedMap(Map.empty) + def apply(kv: (TypedMapKey[_], Any)*) = + val m = Map(kv*) + new TypedMap(m) + +trait TypedMapKey[A: ClassTag]: + type Of = A + + override def toString = s"${getClass.getSimpleName}[${classTag[A].runtimeClass.getSimpleName}]" diff --git a/terminal21-server-client-common/src/main/scala/org/terminal21/model/CommandEvent.scala b/terminal21-server-client-common/src/main/scala/org/terminal21/model/CommandEvent.scala index 216d6fe2..55590a8f 100644 --- a/terminal21-server-client-common/src/main/scala/org/terminal21/model/CommandEvent.scala +++ b/terminal21-server-client-common/src/main/scala/org/terminal21/model/CommandEvent.scala @@ -1,9 +1,46 @@ package org.terminal21.model +import io.circe.* +import io.circe.generic.auto.* +import io.circe.syntax.* +import org.terminal21.client.components.AnyElement + +/** These are the events as they arrive from the server + */ sealed trait CommandEvent: def key: String + def isSessionClosed: Boolean + +object CommandEvent: + def onClick(receivedBy: AnyElement): OnClick = OnClick(receivedBy.key) + def onChange(receivedBy: AnyElement, value: String): OnChange = OnChange(receivedBy.key, value) + def onChange(receivedBy: AnyElement, value: Boolean): OnChange = OnChange(receivedBy.key, value.toString) + def sessionClosed: SessionClosed = SessionClosed("-") + + given Encoder[CommandEvent] = + case c: OnClick => c.asJson.mapObject(_.add("type", "OnClick".asJson)) + case c: OnChange => c.asJson.mapObject(_.add("type", "OnChange".asJson)) + case sc: SessionClosed => sc.asJson.mapObject(_.add("type", "SessionClosed".asJson)) + case x => throw new IllegalStateException(s"$x should never be send as json") + + given Decoder[CommandEvent] = o => + o.get[String]("type") match + case Right("OnClick") => o.as[OnClick] + case Right("OnChange") => o.as[OnChange] + case Right("SessionClosed") => o.as[SessionClosed] + case x => throw new IllegalStateException(s"got unexpected $x") + +case class OnClick(key: String) extends CommandEvent: + override def isSessionClosed: Boolean = false + +case class OnChange(key: String, value: String) extends CommandEvent: + override def isSessionClosed: Boolean = false -case class OnClick(key: String) extends CommandEvent -case class OnChange(key: String, value: String) extends CommandEvent +case class SessionClosed(key: String) extends CommandEvent: + override def isSessionClosed: Boolean = true -case class SessionClosed(key: String) extends CommandEvent +/** Extend this to send your own messages + */ +trait ClientEvent extends CommandEvent: + override def key = "client-event" + override def isSessionClosed: Boolean = false diff --git a/terminal21-server-client-common/src/main/scala/org/terminal21/model/Session.scala b/terminal21-server-client-common/src/main/scala/org/terminal21/model/Session.scala index 1165974e..dd05660d 100644 --- a/terminal21-server-client-common/src/main/scala/org/terminal21/model/Session.scala +++ b/terminal21-server-client-common/src/main/scala/org/terminal21/model/Session.scala @@ -1,5 +1,5 @@ package org.terminal21.model -case class Session(id: String, name: String, secret: String, isOpen: Boolean): +case class Session(id: String, name: String, secret: String, isOpen: Boolean, options: SessionOptions): def hideSecret: Session = copy(secret = "***") - def close: Session = copy(isOpen=false) + def close: Session = copy(isOpen = false) diff --git a/terminal21-server-client-common/src/main/scala/org/terminal21/model/SessionOptions.scala b/terminal21-server-client-common/src/main/scala/org/terminal21/model/SessionOptions.scala new file mode 100644 index 00000000..fdba9954 --- /dev/null +++ b/terminal21-server-client-common/src/main/scala/org/terminal21/model/SessionOptions.scala @@ -0,0 +1,7 @@ +package org.terminal21.model + +case class SessionOptions(closeTabWhenTerminated: Boolean = true, alwaysOpen: Boolean = false) + +object SessionOptions: + val Defaults = SessionOptions() + val LeaveOpenWhenTerminated = SessionOptions(closeTabWhenTerminated = false) diff --git a/terminal21-server-client-common/src/test/scala/org/terminal21/collections/SEListTest.scala b/terminal21-server-client-common/src/test/scala/org/terminal21/collections/SEListTest.scala new file mode 100644 index 00000000..85236f71 --- /dev/null +++ b/terminal21-server-client-common/src/test/scala/org/terminal21/collections/SEListTest.scala @@ -0,0 +1,91 @@ +package org.terminal21.collections + +import functions.fibers.FiberExecutor +import org.scalatest.funsuite.AnyFunSuiteLike +import org.scalatest.matchers.should.Matchers.* + +class SEListTest extends AnyFunSuiteLike: + val executor = FiberExecutor() + + test("when empty, it.hasNext should wait"): + val l = SEList[Int]() + val it = l.iterator + l.poisonPill() + it.toList should be(Nil) + + test("when empty with 2 iterators"): + val l = SEList[Int]() + val it1 = l.iterator + val it2 = l.iterator + l.poisonPill() + it1.toList should be(Nil) + it2.toList should be(Nil) + + test("with 1 item"): + val l = SEList[Int]() + val it = l.iterator + l.add(1) + l.poisonPill() + it.toList should be(List(1)) + + test("with 2 items"): + val l = SEList[Int]() + val it = l.iterator + l.add(1) + l.add(2) + l.poisonPill() + it.toList should be(List(1, 2)) + + test("with 2 items and 2 iterators"): + val l = SEList[Int]() + val it1 = l.iterator + val it2 = l.iterator + l.add(1) + l.add(2) + l.poisonPill() + it1.toList should be(List(1, 2)) + it2.toList should be(List(1, 2)) + + test("iterator after added items"): + val l = SEList[Int]() + l.add(1) + val it = l.iterator + l.add(2) + l.poisonPill() + it.toList should be(List(2)) + + test("hasNext & next()"): + val l = SEList[Int]() + val it = l.iterator + l.add(1) + l.add(2) + l.poisonPill() + it.hasNext should be(true) + it.next() should be(1) + it.hasNext should be(true) + it.next() should be(2) + it.hasNext should be(false) + an[NoSuchElementException] should be thrownBy (it.next()) + + test("it.isNextAvailable"): + val l = SEList[Int]() + val it = l.iterator + it.isNextAvailable should be(false) + l.add(1) + it.isNextAvailable should be(true) + + test("multiple iterators and multi threading"): + val l = SEList[Int]() + val iterators = for _ <- 1 to 1000 yield + val it = l.iterator + executor.submit: + it.toList + + for i <- 1 to 100 do + Thread.sleep(1) + l.add(i) + + l.poisonPill() + + val expected = (1 to 100).toList + for f <- iterators do f.get() should be(expected) diff --git a/terminal21-server-client-common/src/test/scala/org/terminal21/collections/TypedMapTest.scala b/terminal21-server-client-common/src/test/scala/org/terminal21/collections/TypedMapTest.scala new file mode 100644 index 00000000..504427c8 --- /dev/null +++ b/terminal21-server-client-common/src/test/scala/org/terminal21/collections/TypedMapTest.scala @@ -0,0 +1,62 @@ +package org.terminal21.collections + +import org.scalatest.funsuite.AnyFunSuiteLike +import org.scalatest.matchers.should.Matchers.* + +class TypedMapTest extends AnyFunSuiteLike: + object IntKey extends TypedMapKey[Int] + object StringKey extends TypedMapKey[String] + + test("apply"): + val m = TypedMap.Empty + (IntKey -> 5) + (StringKey -> "x") + m(IntKey) should be(5) + m(StringKey) should be("x") + + test("construct"): + val m = TypedMap(IntKey -> 5, StringKey -> "x") + m(IntKey) should be(5) + m(StringKey) should be("x") + + test("keys"): + val m = TypedMap(IntKey -> 5, StringKey -> "x") + m.keys.toSet should be(Set(IntKey, StringKey)) + + test("get"): + val m = TypedMap.Empty + (IntKey -> 5) + (StringKey -> "x") + m.get(IntKey) should be(Some(5)) + m.get(StringKey) should be(Some("x")) + + test("getOrElse when key not available"): + TypedMap.Empty.getOrElse(IntKey, 2) should be(2) + + test("getOrElse when key available"): + (TypedMap.Empty + (IntKey -> 5)).getOrElse(IntKey, 2) should be(5) + + test("contains key positive"): + (TypedMap.Empty + (IntKey -> 5)).contains(IntKey) should be(true) + + test("contains key negative"): + TypedMap.Empty.contains(IntKey) should be(false) + + test("get key negative"): + TypedMap.Empty.get(IntKey) should be(None) + + test("equals positive"): + val m1 = TypedMap.Empty + (IntKey -> 5) + val m2 = TypedMap.Empty + (IntKey -> 5) + m1 should be(m2) + + test("equals negative"): + val m1 = TypedMap.Empty + (IntKey -> 5) + val m2 = TypedMap.Empty + (IntKey -> 6) + m1 should not be m2 + + test("hashCode positive"): + val m1 = TypedMap.Empty + (IntKey -> 5) + val m2 = TypedMap.Empty + (IntKey -> 5) + m1.hashCode should be(m2.hashCode) + + test("hashCode negative"): + val m1 = TypedMap.Empty + (IntKey -> 5) + val m2 = TypedMap.Empty + (IntKey -> 6) + m1.hashCode should not be m2.hashCode diff --git a/terminal21-server-client-common/src/test/scala/org/terminal21/model/CommonModelBuilders.scala b/terminal21-server-client-common/src/test/scala/org/terminal21/model/CommonModelBuilders.scala index a78db392..c5cd69de 100644 --- a/terminal21-server-client-common/src/test/scala/org/terminal21/model/CommonModelBuilders.scala +++ b/terminal21-server-client-common/src/test/scala/org/terminal21/model/CommonModelBuilders.scala @@ -1,5 +1,11 @@ package org.terminal21.model object CommonModelBuilders: - def session(id: String = "session-id", name: String = "session-name", secret: String = "session-secret", isOpen: Boolean = true) = - Session(id, name, secret, isOpen) + def session( + id: String = "session-id", + name: String = "session-name", + secret: String = "session-secret", + isOpen: Boolean = true, + sessionOptions: SessionOptions = SessionOptions.Defaults + ) = + Session(id, name, secret, isOpen, sessionOptions) diff --git a/terminal21-server/src/main/scala/org/terminal21/server/Routes.scala b/terminal21-server/src/main/scala/org/terminal21/server/Routes.scala index f0168f76..544af7d6 100644 --- a/terminal21-server/src/main/scala/org/terminal21/server/Routes.scala +++ b/terminal21-server/src/main/scala/org/terminal21/server/Routes.scala @@ -12,7 +12,7 @@ import java.nio.file.Path object Routes: private val logger = LoggerFactory.getLogger(getClass) - def register(dependencies: Dependencies, rb: HttpRouting.Builder): Unit = + def register(dependencies: ServerBeans, rb: HttpRouting.Builder): Unit = import dependencies.* SessionsServiceReceiverFactory.newJsonSessionsServiceHelidonRoutes(sessionsService).routes(rb) @@ -34,7 +34,7 @@ object Routes: rb.register("/ui", staticContent) rb.register("/web", publicContent) - def ws(dependencies: Dependencies): WsRouting.Builder = + def ws(dependencies: ServerBeans): WsRouting.Builder = val b = WsRouting.builder b.endpoint("/ui/sessions", dependencies.sessionsWebSocket) .endpoint("/api/command-ws", dependencies.commandWebSocket.commandWebSocketListener.listener) diff --git a/terminal21-server/src/main/scala/org/terminal21/server/Dependencies.scala b/terminal21-server/src/main/scala/org/terminal21/server/ServerBeans.scala similarity index 59% rename from terminal21-server/src/main/scala/org/terminal21/server/Dependencies.scala rename to terminal21-server/src/main/scala/org/terminal21/server/ServerBeans.scala index c799e449..ddb34dda 100644 --- a/terminal21-server/src/main/scala/org/terminal21/server/Dependencies.scala +++ b/terminal21-server/src/main/scala/org/terminal21/server/ServerBeans.scala @@ -4,4 +4,4 @@ import functions.fibers.FiberExecutor import org.terminal21.server.service.{CommandWebSocketBeans, ServerSessionsServiceBeans} import org.terminal21.server.ui.SessionsWebSocketBeans -class Dependencies(val fiberExecutor: FiberExecutor) extends ServerSessionsServiceBeans with SessionsWebSocketBeans with CommandWebSocketBeans +trait ServerBeans extends ServerSessionsServiceBeans with SessionsWebSocketBeans with CommandWebSocketBeans diff --git a/terminal21-server/src/main/scala/org/terminal21/server/service/ServerSessionsService.scala b/terminal21-server/src/main/scala/org/terminal21/server/service/ServerSessionsService.scala index d8749662..f17e6137 100644 --- a/terminal21-server/src/main/scala/org/terminal21/server/service/ServerSessionsService.scala +++ b/terminal21-server/src/main/scala/org/terminal21/server/service/ServerSessionsService.scala @@ -31,13 +31,19 @@ class ServerSessionsService extends SessionsService: override def terminateSession(session: Session): Unit = val state = sessions.getOrElse(session, throw new IllegalArgumentException(s"Session ${session.id} doesn't exist")) + if session.options.alwaysOpen then throw new IllegalArgumentException("Can't terminate a session that should be always open") state.eventsNotificationRegistry.notifyAll(SessionClosed("-")) sessions -= session sessions += session.close -> state.close sessionChangeNotificationRegistry.notifyAll(allSessions) + if (session.options.closeTabWhenTerminated) removeSession(session.close) - override def createSession(id: String, name: String): Session = - val s = Session(id, name, UUID.randomUUID().toString, true) + def terminateAndRemove(session: Session): Unit = + terminateSession(session) + removeSession(session.close) + + override def createSession(id: String, name: String, sessionOptions: SessionOptions): Session = + val s = Session(id, name, UUID.randomUUID().toString, true, sessionOptions) logger.info(s"Creating session $s") sessions.keys.toList.foreach(s => if s.id == id then sessions.remove(s)) val state = SessionState(ServerJson.Empty, new NotificationRegistry) @@ -56,14 +62,15 @@ class ServerSessionsService extends SessionsService: val newV = oldV.withNewState(newStateJson) sessions += session -> newV sessionStateChangeNotificationRegistry.notifyAll((session, newV, None)) - logger.info(s"Session $session new state $newStateJson") + logger.debug(s"Session $session new state $newStateJson") override def changeSessionJsonState(session: Session, change: ServerJson): Unit = - val oldV = sessions(session) - val newV = oldV.withNewState(oldV.serverJson.include(change)) - sessions += session -> newV - sessionStateChangeNotificationRegistry.notifyAll((session, newV, Some(change))) - logger.info(s"Session $session change $change") + ??? +// val oldV = sessions(session) +// val newV = oldV.withNewState(oldV.serverJson.include(change)) +// sessions += session -> newV +// sessionStateChangeNotificationRegistry.notifyAll((session, newV, Some(change))) +// logger.debug(s"Session $session change $change") def triggerUiEvent(event: UiEvent): Unit = val e = event match @@ -79,4 +86,4 @@ class ServerSessionsService extends SessionsService: state.eventsNotificationRegistry.add(listener) trait ServerSessionsServiceBeans: - val sessionsService: ServerSessionsService = new ServerSessionsService + lazy val sessionsService: ServerSessionsService = new ServerSessionsService diff --git a/terminal21-server/src/main/scala/org/terminal21/server/ui/SessionsWebSocket.scala b/terminal21-server/src/main/scala/org/terminal21/server/ui/SessionsWebSocket.scala index e78a3544..0a7507dd 100644 --- a/terminal21-server/src/main/scala/org/terminal21/server/ui/SessionsWebSocket.scala +++ b/terminal21-server/src/main/scala/org/terminal21/server/ui/SessionsWebSocket.scala @@ -30,37 +30,37 @@ class SessionsWebSocket(sessionsService: ServerSessionsService) extends WsListen private def sendSessionState(wsSession: WsSession, session: Session, sessionState: SessionState): Unit = val response = StateWsResponse(session.hideSecret, sessionState.serverJson).asJson.noSpaces - logger.info(s"$wsSession: Sending session state response $response") + logger.debug(s"$wsSession: Sending session state response $response") wsSession.send(response, true) private def sendSessionStateChange(wsSession: WsSession, session: Session, change: ServerJson): Unit = val response = StateChangeWsResponse(session.hideSecret, change).asJson.noSpaces - logger.info(s"$wsSession: Sending session change state response $response") + logger.debug(s"$wsSession: Sending session change state response $response") wsSession.send(response, true) private def sendSessions(wsSession: WsSession, allSessions: Seq[Session]): Unit = val sessions = allSessions.map(_.hideSecret).sortBy(_.name) val json = SessionsWsResponse(sessions).asJson.noSpaces - logger.info(s"$wsSession: Sending sessions $json") + logger.debug(s"$wsSession: Sending sessions $json") wsSession.send(json, true) override def onMessage(wsSession: WsSession, text: String, last: Boolean): Unit = - logger.info(s"$wsSession: Received json: $text , last = $last") + logger.debug(s"$wsSession: Received json: $text , last = $last") errorLogger.logErrors: WsRequest.decoder(text) match case Right(WsRequest("sessions", None)) => continuouslyRespond(wsSession) - logger.info(s"$wsSession: sessions processed successfully") + logger.debug(s"$wsSession: sessions processed successfully") case Right(WsRequest("session-full-refresh", Some(SessionFullRefresh(sessionId)))) => logger.info(s"$wsSession: session-full-refresh requested, sending full session data for $sessionId") val session = sessionsService.sessionById(sessionId) val sessionState = sessionsService.sessionStateOf(session) sendSessionState(wsSession, session, sessionState) case Right(WsRequest(eventName, Some(event: UiEvent))) => - logger.info(s"$wsSession: Received event $eventName = $event") + logger.debug(s"$wsSession: Received event $eventName = $event") sessionsService.triggerUiEvent(event) case Right(WsRequest("ping", None)) => - logger.info(s"$wsSession: ping received") + logger.debug(s"$wsSession: ping received") case Right(WsRequest("close-session", Some(CloseSession(sessionId)))) => val session = sessionsService.sessionById(sessionId) sessionsService.terminateSession(session) diff --git a/terminal21-server/src/test/scala/org/terminal21/server/service/ServerSessionsServiceTest.scala b/terminal21-server/src/test/scala/org/terminal21/server/service/ServerSessionsServiceTest.scala index ddd96db5..201327c7 100644 --- a/terminal21-server/src/test/scala/org/terminal21/server/service/ServerSessionsServiceTest.scala +++ b/terminal21-server/src/test/scala/org/terminal21/server/service/ServerSessionsServiceTest.scala @@ -1,29 +1,28 @@ package org.terminal21.server.service -import io.circe.Json import org.scalatest.funsuite.AnyFunSuiteLike import org.scalatest.matchers.should.Matchers.* -import org.terminal21.model.{OnChange, OnClick, SessionClosed} +import org.terminal21.model.{OnChange, OnClick, SessionClosed, SessionOptions} import org.terminal21.server.json import org.terminal21.ui.std.StdExportsBuilders.serverJson class ServerSessionsServiceTest extends AnyFunSuiteLike: test("sessionById"): new App: - val session = createSession + val session = createSession() serverSessionsService.setSessionJsonState(session, serverJson()) serverSessionsService.sessionById(session.id) should be(session) test("sessionStateOf"): new App: - val session = createSession + val session = createSession() val sj = serverJson() serverSessionsService.setSessionJsonState(session, sj) serverSessionsService.sessionStateOf(session).serverJson should be(sj) test("removeSession"): new App: - val session = createSession + val session = createSession() serverSessionsService.setSessionJsonState(session, serverJson()) serverSessionsService.removeSession(session) an[IllegalArgumentException] should be thrownBy: @@ -31,7 +30,7 @@ class ServerSessionsServiceTest extends AnyFunSuiteLike: test("removeSession notifies listeners"): new App: - val session = createSession + val session = createSession() serverSessionsService.setSessionJsonState(session, serverJson()) var listenerCalled = 0 serverSessionsService.notifyMeWhenSessionsChange: sessions => @@ -45,14 +44,28 @@ class ServerSessionsServiceTest extends AnyFunSuiteLike: test("terminateSession marks session as closed"): new App: - val session = createSession + val session = createSession(SessionOptions.LeaveOpenWhenTerminated) serverSessionsService.setSessionJsonState(session, serverJson()) serverSessionsService.terminateSession(session) serverSessionsService.sessionById(session.id).isOpen should be(false) + test("terminateSession doesn't terminate a session that should always be open"): + new App: + val session = createSession(SessionOptions(alwaysOpen = true)) + serverSessionsService.setSessionJsonState(session, serverJson()) + an[IllegalArgumentException] should be thrownBy: + serverSessionsService.terminateSession(session) + + test("terminateSession removes session if marked to be deleted when terminated"): + new App: + val session = createSession(SessionOptions(closeTabWhenTerminated = true)) + serverSessionsService.setSessionJsonState(session, serverJson()) + serverSessionsService.terminateSession(session) + serverSessionsService.allSessions should be(Nil) + test("terminateSession notifies session listeners"): new App: - val session = createSession + val session = createSession() serverSessionsService.setSessionJsonState(session, serverJson()) var eventCalled = false serverSessionsService.notifyMeOnSessionEvents(session): event => @@ -64,17 +77,18 @@ class ServerSessionsServiceTest extends AnyFunSuiteLike: test("terminateSession notifies sessions listeners"): new App: - val session = createSession + val session = createSession() serverSessionsService.setSessionJsonState(session, serverJson()) var listenerCalled = 0 serverSessionsService.notifyMeWhenSessionsChange: sessions => listenerCalled match case 0 => sessions should be(Seq(session)) case 1 => sessions should be(Seq(session.close)) + case 2 => sessions should be(Nil) listenerCalled += 1 true serverSessionsService.terminateSession(session) - listenerCalled should be(2) + listenerCalled should be(3) test("createSession notifies listeners"): new App: @@ -86,44 +100,12 @@ class ServerSessionsServiceTest extends AnyFunSuiteLike: listenerCalled += 1 true - createSession + createSession() listenerCalled should be(2) - test("changeSessionJsonState changes session's state"): - new App: - val session = createSession - val sj1 = serverJson(elements = Map("e1" -> Json.fromString("e1v"))) - serverSessionsService.setSessionJsonState(session, sj1) - val sj2 = serverJson(elements = Map("e2" -> Json.fromString("e2v"))) - serverSessionsService.changeSessionJsonState(session, sj2) - serverSessionsService.sessionStateOf(session).serverJson should be( - sj1.include(sj2) - ) - - test("changeSessionJsonState notifies listeners"): - new App: - val session = createSession - val sj1 = serverJson(elements = Map("e1" -> Json.fromString("e1v"))) - serverSessionsService.setSessionJsonState(session, sj1) - val sj2 = serverJson(elements = Map("e2" -> Json.fromString("e2v"))) - var called = 0 - serverSessionsService.notifyMeWhenSessionChanges: (s, sessionState, sjOption) => - called match - case 0 => - s should be(session) - sjOption should be(None) - case 1 => - s should be(session) - sjOption should be(Some(sj2)) - - called += 1 - true - serverSessionsService.changeSessionJsonState(session, sj2) - called should be(2) - test("triggerUiEvent notifies listeners for clicks"): new App: - val session = createSession + val session = createSession() var called = false serverSessionsService.notifyMeOnSessionEvents(session): e => called = true @@ -135,7 +117,7 @@ class ServerSessionsServiceTest extends AnyFunSuiteLike: test("triggerUiEvent notifies listeners for change"): new App: - val session = createSession + val session = createSession() var called = false serverSessionsService.notifyMeOnSessionEvents(session): e => called = true @@ -146,5 +128,5 @@ class ServerSessionsServiceTest extends AnyFunSuiteLike: called should be(true) class App: - val serverSessionsService = new ServerSessionsService - def createSession = serverSessionsService.createSession("test", "Test") + val serverSessionsService = new ServerSessionsService + def createSession(options: SessionOptions = SessionOptions.Defaults) = serverSessionsService.createSession("test", "Test", options) diff --git a/terminal21-spark/src/main/scala/org/terminal21/sparklib/Cached.scala b/terminal21-spark/src/main/scala/org/terminal21/sparklib/Cached.scala new file mode 100644 index 00000000..ed6d2ce4 --- /dev/null +++ b/terminal21-spark/src/main/scala/org/terminal21/sparklib/Cached.scala @@ -0,0 +1,62 @@ +package org.terminal21.sparklib + +import org.apache.commons.io.FileUtils +import org.apache.spark.sql.SparkSession +import org.terminal21.client.* +import org.terminal21.client.components.UiElement +import org.terminal21.client.components.UiElement.HasStyle +import org.terminal21.sparklib.calculations.SparkCalculation.TriggerRedraw +import org.terminal21.sparklib.calculations.{ReadWriter, SparkCalculation} +import org.terminal21.sparklib.util.Environment + +import java.io.File + +class Cached[OUT: ReadWriter](val name: String, outF: => OUT)(using spark: SparkSession): + private val rw = implicitly[ReadWriter[OUT]] + private val rootFolder = s"${Environment.tmpDirectory}/spark-calculations" + private val targetDir = s"$rootFolder/$name" + + def isCached: Boolean = new File(targetDir).exists() + + def cachePath: String = targetDir + + private def cache[A](reader: => A, writer: => A): A = + if isCached then reader + else writer + + def invalidateCache(): Unit = + FileUtils.deleteDirectory(new File(targetDir)) + out = None + + private def calculateOnce: OUT = + cache( + rw.read(spark, targetDir), { + val ds = outF + rw.write(targetDir, ds) + ds + } + ) + + @volatile private var out = Option.empty[OUT] + private def startCalc(session: ConnectedSession): Unit = + fiberExecutor.submit: + out = Some(calculateOnce) + session.fireEvent(TriggerRedraw) + + def get: Option[OUT] = out + + def visualize(dataUi: UiElement & HasStyle)( + toUi: OUT => UiElement & HasStyle + )(using + SparkSession + )(using session: ConnectedSession, events: Events) = + val sc = new SparkCalculation[OUT](s"spark-calc-$name", dataUi, toUi, this) + + if events.isClicked(sc.recalc) then + invalidateCache() + startCalc(session) + else if events.isInitialRender then startCalc(session) + sc + +object Cached: + def apply[OUT: ReadWriter](name: String)(outF: => OUT)(using spark: SparkSession): Cached[OUT] = new Cached(name, outF) diff --git a/terminal21-spark/src/main/scala/org/terminal21/sparklib/CalculationsExtensions.scala b/terminal21-spark/src/main/scala/org/terminal21/sparklib/CalculationsExtensions.scala deleted file mode 100644 index 2ee3e933..00000000 --- a/terminal21-spark/src/main/scala/org/terminal21/sparklib/CalculationsExtensions.scala +++ /dev/null @@ -1,26 +0,0 @@ -package org.terminal21.sparklib - -import functions.fibers.FiberExecutor -import org.apache.spark.sql.SparkSession -import org.terminal21.client.ConnectedSession -import org.terminal21.client.components.UiElement.HasStyle -import org.terminal21.client.components.{Keys, UiElement} -import org.terminal21.sparklib.calculations.{ReadWriter, StdUiSparkCalculation} - -extension [OUT: ReadWriter](ds: OUT) - def visualize(name: String, dataUi: UiElement with HasStyle[_])( - toUi: OUT => UiElement & HasStyle[_] - )(using - session: ConnectedSession, - executor: FiberExecutor, - spark: SparkSession - ) = - val ui = new StdUiSparkCalculation[OUT](Keys.nextKey, name, dataUi): - override protected def whenResultsReady(results: OUT): Unit = - try updateUi(toUi(results)) - catch case t: Throwable => t.printStackTrace() - super.whenResultsReady(results) - override def nonCachedCalculation: OUT = ds - - ui.run() - ui diff --git a/terminal21-spark/src/main/scala/org/terminal21/sparklib/SparkSessions.scala b/terminal21-spark/src/main/scala/org/terminal21/sparklib/SparkSessions.scala index f7306ac1..26ee8ca3 100644 --- a/terminal21-spark/src/main/scala/org/terminal21/sparklib/SparkSessions.scala +++ b/terminal21-spark/src/main/scala/org/terminal21/sparklib/SparkSessions.scala @@ -1,10 +1,6 @@ package org.terminal21.sparklib import org.apache.spark.sql.SparkSession -import org.terminal21.client.components.ComponentLib -import org.terminal21.client.{ConnectedSession, Sessions} - -import scala.util.Using object SparkSessions: def newSparkSession( @@ -20,24 +16,3 @@ object SparkSessions: .config("spark.driver.bindAddress", bindAddress) .config("spark.ui.enabled", sparkUiEnabled) .getOrCreate() - - /** Will create a terminal21 session and use the provided spark session - * @param spark - * the spark session, will be closed before this call returns. Use #newSparkSession to quickly create one. - * @param id - * the id of the terminal21 session - * @param name - * the name of the terminal21 session - * @param f - * the code to run - * @tparam R - * if f returns some value, this will be returned by the method - * @return - * whatever f returns - */ - def newTerminal21WithSparkSession[R](spark: SparkSession, id: String, name: String, componentLibs: ComponentLib*)( - f: (SparkSession, ConnectedSession) => R - ): R = - Sessions.withNewSession(id, name, componentLibs: _*): terminal21Session => - Using.resource(spark): _ => - f(spark, terminal21Session) diff --git a/terminal21-spark/src/main/scala/org/terminal21/sparklib/calculations/SparkCalculation.scala b/terminal21-spark/src/main/scala/org/terminal21/sparklib/calculations/SparkCalculation.scala index fc7ed440..dcb9582b 100644 --- a/terminal21-spark/src/main/scala/org/terminal21/sparklib/calculations/SparkCalculation.scala +++ b/terminal21-spark/src/main/scala/org/terminal21/sparklib/calculations/SparkCalculation.scala @@ -1,14 +1,13 @@ package org.terminal21.sparklib.calculations -import functions.fibers.FiberExecutor -import org.apache.commons.io.FileUtils import org.apache.spark.sql.SparkSession -import org.terminal21.client.ConnectedSession +import org.terminal21.client.components.* import org.terminal21.client.components.UiElement.HasStyle -import org.terminal21.client.components.{CachedCalculation, StdUiCalculation, UiComponent, UiElement} -import org.terminal21.sparklib.util.Environment - -import java.io.File +import org.terminal21.client.components.chakra.* +import org.terminal21.client.* +import org.terminal21.collections.TypedMap +import org.terminal21.model.ClientEvent +import org.terminal21.sparklib.Cached /** A UI component that takes a spark calculation (i.e. a spark query) that results in a Dataset. It caches the results by storing them as parquet into the tmp * folder/spark-calculations/$name. Next time the calculation runs it reads the cache if available. A button should allow the user to clear the cache and rerun @@ -16,39 +15,45 @@ import java.io.File * * Because the cache is stored in the disk, it is available even if the jvm running the code restarts. This allows the user to run and rerun their code without * having to rerun the spark calculation. - * - * Subclass this to create your own UI for a spark calculation, see StdUiSparkCalculation below. */ -trait SparkCalculation[OUT: ReadWriter](name: String)(using executor: FiberExecutor, spark: SparkSession) extends CachedCalculation[OUT] with UiComponent: - private val rw = implicitly[ReadWriter[OUT]] - private val rootFolder = s"${Environment.tmpDirectory}/spark-calculations" - private val targetDir = s"$rootFolder/$name" - - def isCached: Boolean = new File(targetDir).exists() - def cachePath: String = targetDir - - private def cache[A](reader: => A, writer: => A): A = - if isCached then reader - else writer - - override def invalidateCache(): Unit = - FileUtils.deleteDirectory(new File(targetDir)) - - private def calculateOnce(f: => OUT): OUT = - cache( - rw.read(spark, targetDir), { - val ds = f - rw.write(targetDir, ds) - ds - } +case class SparkCalculation[OUT: ReadWriter]( + key: String, + dataUi: UiElement with HasStyle, + toUi: OUT => UiElement & HasStyle, + cached: Cached[OUT], + dataStore: TypedMap = TypedMap.Empty +)(using + spark: SparkSession, + session: ConnectedSession, + events: Events +) extends UiComponent: + def name = cached.name + override type This = SparkCalculation[OUT] + override def withKey(key: String): This = copy(key = key) + override def withDataStore(ds: TypedMap): This = copy(dataStore = ds) + + val recalc = Button(s"recalc-button-$name", text = "Recalculate", size = Some("sm"), leftIcon = Some(RepeatIcon())) + + override def rendered: Seq[UiElement] = + val header = Box( + s"recalc-box-$name", + bg = "green", + p = 4, + children = Seq( + HStack().withChildren( + Text(text = name), + if events.isClicked(recalc) then Badge(text = "Recalculating...") + else if events.isInitialRender then Badge(text = "Initializing...") + else recalc + ) + ) ) + val ui = cached.get + .map: ds => + toUi(ds) + .getOrElse(dataUi) - override protected def calculation(): OUT = calculateOnce(nonCachedCalculation) + Seq(header, ui) -abstract class StdUiSparkCalculation[OUT: ReadWriter]( - val key: String, - name: String, - dataUi: UiElement with HasStyle[_] -)(using session: ConnectedSession, executor: FiberExecutor, spark: SparkSession) - extends SparkCalculation[OUT](name) - with StdUiCalculation[OUT](name, dataUi) +object SparkCalculation: + object TriggerRedraw extends ClientEvent diff --git a/terminal21-spark/src/test/scala/org/terminal21/sparklib/calculations/StdUiSparkCalculationTest.scala b/terminal21-spark/src/test/scala/org/terminal21/sparklib/calculations/StdUiSparkCalculationTest.scala deleted file mode 100644 index 13c7aadd..00000000 --- a/terminal21-spark/src/test/scala/org/terminal21/sparklib/calculations/StdUiSparkCalculationTest.scala +++ /dev/null @@ -1,99 +0,0 @@ -package org.terminal21.sparklib.calculations - -import org.apache.spark.sql.{Dataset, Encoder, SparkSession} -import org.scalatest.concurrent.Eventually -import org.scalatest.funsuite.AnyFunSuiteLike -import org.scalatest.matchers.should.Matchers.* -import org.scalatest.time.{Millis, Span} -import org.terminal21.client.components.Keys -import org.terminal21.client.components.chakra.* -import org.terminal21.client.{ConnectedSession, ConnectedSessionMock, given} -import org.terminal21.sparklib.SparkSessions - -import java.util.concurrent.atomic.{AtomicBoolean, AtomicInteger} -import scala.util.Using - -class StdUiSparkCalculationTest extends AnyFunSuiteLike with Eventually: - given PatienceConfig = PatienceConfig(scaled(Span(3000, Millis))) - - test("calculates the correct result"): - Using.resource(SparkSessions.newSparkSession()): spark => - import spark.implicits.* - given ConnectedSession = ConnectedSessionMock.newConnectedSessionMock - given SparkSession = spark - val calc = new TestingCalculation - calc.run().get().collect().toList should be(List(2)) - - test("whenResultsNotReady"): - Using.resource(SparkSessions.newSparkSession()): spark => - import spark.implicits.* - given ConnectedSession = ConnectedSessionMock.newConnectedSessionMock - given SparkSession = spark - val called = new AtomicBoolean(false) - val calc = new TestingCalculation: - override protected def whenResultsNotReady(): Unit = - called.set(true) - calc.run().get() - called.get() should be(true) - - test("whenResultsReady"): - Using.resource(SparkSessions.newSparkSession()): spark => - import spark.implicits.* - given ConnectedSession = ConnectedSessionMock.newConnectedSessionMock - given SparkSession = spark - val called = new AtomicBoolean(false) - val calc = new TestingCalculation: - override protected def whenResultsReady(results: Dataset[Int]): Unit = - results.collect().toList should be(List(2)) - called.set(true) - - calc.run() - eventually: - called.get() should be(true) - - test("whenResultsReady called even when cached"): - Using.resource(SparkSessions.newSparkSession()): spark => - import spark.implicits.* - given ConnectedSession = ConnectedSessionMock.newConnectedSessionMock - given SparkSession = spark - val called = new AtomicInteger(0) - val calc = new TestingCalculation: - override protected def whenResultsReady(results: Dataset[Int]): Unit = - results.collect().toList should be(List(2)) - called.incrementAndGet() - - calc.run().get() - calc.run() - eventually: - called.get() should be(2) - - test("caches results"): - Using.resource(SparkSessions.newSparkSession()): spark => - import spark.implicits.* - given ConnectedSession = ConnectedSessionMock.newConnectedSessionMock - given SparkSession = spark - val calc = new TestingCalculation - calc.run().get().collect().toList should be(List(2)) - calc.run().get().collect().toList should be(List(2)) - calc.calcCalledTimes.get() should be(1) - - test("refresh button invalidates cache and runs calculations"): - Using.resource(SparkSessions.newSparkSession()): spark => - import spark.implicits.* - given session: ConnectedSession = ConnectedSessionMock.newConnectedSessionMock - given SparkSession = spark - val calc = new TestingCalculation - calc.run().get().collect().toList should be(List(2)) - session.click(calc.recalc) - eventually: - calc.calcCalledTimes.get() should be(2) - -class TestingCalculation(using session: ConnectedSession, spark: SparkSession, enc: Encoder[Int]) - extends StdUiSparkCalculation[Dataset[Int]](Keys.nextKey, "testing-calc", Box()): - val calcCalledTimes = new AtomicInteger(0) - invalidateCache() - - override def nonCachedCalculation: Dataset[Int] = - import spark.implicits.* - calcCalledTimes.incrementAndGet() - Seq(2).toDS diff --git a/terminal21-spark/src/test/scala/org/terminal21/sparklib/endtoend/SparkBasics.scala b/terminal21-spark/src/test/scala/org/terminal21/sparklib/endtoend/SparkBasics.scala index ec6d1a2a..db717804 100644 --- a/terminal21-spark/src/test/scala/org/terminal21/sparklib/endtoend/SparkBasics.scala +++ b/terminal21-spark/src/test/scala/org/terminal21/sparklib/endtoend/SparkBasics.scala @@ -5,68 +5,87 @@ import org.apache.spark.sql.{Dataset, SparkSession} import org.terminal21.client.components.* import org.terminal21.client.components.chakra.* import org.terminal21.client.components.nivo.* -import org.terminal21.client.{*, given} +import org.terminal21.client.* import org.terminal21.sparklib.* import org.terminal21.sparklib.endtoend.model.CodeFile import org.terminal21.sparklib.endtoend.model.CodeFile.scanSourceFiles +import scala.util.Using + @main def sparkBasics(): Unit = - SparkSessions.newTerminal21WithSparkSession(SparkSessions.newSparkSession(), "spark-basics", "Spark Basics", NivoLib): (spark, session) => - given ConnectedSession = session - given SparkSession = spark - import scala3encoders.given - import spark.implicits.* + Using.resource(SparkSessions.newSparkSession()): spark => + Sessions + .withNewSession("spark-basics", "Spark Basics") + .andLibraries(NivoLib) + .connect: session => + given ConnectedSession = session + given SparkSession = spark + import scala3encoders.given + import spark.implicits.* - val headers = Seq("id", "name", "path", "numOfLines", "numOfWords", "createdDate", "timestamp") + val sourceFileCached = Cached("Code files"): + sourceFiles().limit(3) + val sortedSourceFilesDS = Cached("Sorted files"): + sortedSourceFiles(sourceFiles()).limit(3) + val sortedSourceFilesDFCached = Cached("Sorted files DF"): + sourceFiles() + .sort($"createdDate".asc, $"numOfWords".asc) + .toDF() + .limit(4) - val sortedFilesTable = QuickTable().headers(headers: _*).caption("Files sorted by createdDate and numOfWords") - val codeFilesTable = QuickTable().headers(headers: _*).caption("Unsorted files") + val sourceFilesSortedByNumOfLinesCached = Cached("Biggest Code Files"): + sourceFiles() + .sort($"numOfLines".desc) - val sortedSourceFilesDS = sortedSourceFiles(sourceFiles()) - val sortedCalc = sortedSourceFilesDS.visualize("Sorted files", sortedFilesTable): results => - val tableRows = results.take(3).toList.map(_.toData) - sortedFilesTable.rows(tableRows) + println(s"Cached dir: ${sourceFileCached.cachePath}") + def components(events: Events) = + given Events = events - val codeFilesCalculation = sourceFiles().visualize("Code files", codeFilesTable): results => - val dt = results.take(3).toList - codeFilesTable.rows(dt.map(_.toData)) + val headers = Seq("id", "name", "path", "numOfLines", "numOfWords", "createdDate", "timestamp") + val sortedFilesTable = QuickTable().withHeaders(headers: _*).caption("Files sorted by createdDate and numOfWords") + val codeFilesTable = QuickTable().withHeaders(headers: _*).caption("Unsorted files") - val sortedFilesTableDF = QuickTable().headers(headers: _*).caption("Files sorted by createdDate and numOfWords ASC and as DF") - val sortedCalcAsDF = sourceFiles() - .sort($"createdDate".asc, $"numOfWords".asc) - .toDF() - .visualize("Sorted files DF", sortedFilesTableDF): results => - val tableRows = results.take(4).toList - sortedFilesTableDF.rows(tableRows.toUiTable) + val sortedCalc = sortedSourceFilesDS.visualize(sortedFilesTable): results => + val tableRows = results.collect().map(_.toData).toList + sortedFilesTable.withRows(tableRows) - val chart = ResponsiveLine( - data = Seq( - Serie( - "Scala", - data = Seq( - Datum("plane", 262), - Datum("helicopter", 26), - Datum("boat", 43) - ) - ) - ), - axisBottom = Some(Axis(legend = "Class", legendOffset = 36)), - axisLeft = Some(Axis(legend = "Count", legendOffset = -40)), - legends = Seq(Legend()) - ) + val codeFilesCalculation = sourceFileCached.visualize(codeFilesTable): results => + val dt = results.collect().toList + codeFilesTable.withRows(dt.map(_.toData)) - val sourceFileChart = sortedSourceFilesDS.visualize("Biggest Code Files", chart): results => - val data = results.take(10).map(cf => Datum(StringUtils.substringBeforeLast(cf.name, ".scala"), cf.numOfLines)).toList - chart.withData(Seq(Serie("Scala", data = data))) + val sortedFilesTableDF = QuickTable().withHeaders(headers: _*).caption("Files sorted by createdDate and numOfWords ASC and as DF") + val sortedCalcAsDF = sortedSourceFilesDFCached + .visualize(sortedFilesTableDF): results => + val tableRows = results.collect().toList + sortedFilesTableDF.withRows(tableRows.toUiTable) - Seq( - codeFilesCalculation, - sortedCalc, - sortedCalcAsDF, - sourceFileChart - ).render() + val chart = ResponsiveLine( + data = Seq( + Serie( + "Scala", + data = Nil + ) + ), + axisBottom = Some(Axis(legend = "Class", legendOffset = 36)), + axisLeft = Some(Axis(legend = "Number of Lines", legendOffset = -40)), + legends = Seq(Legend()) + ) + + val sourceFileChart = sourceFilesSortedByNumOfLinesCached + .visualize(chart): results => + val data = results.take(10).map(cf => Datum(StringUtils.substringBeforeLast(cf.name, ".scala"), cf.numOfLines)).toList + chart.withData(Seq(Serie("Scala", data = data))) + Seq( + codeFilesCalculation, + sortedCalc, + sortedCalcAsDF, + sourceFileChart + ) - session.waitTillUserClosesSession() + Controller + .noModel(components) + .render() + .run() def sourceFiles()(using spark: SparkSession) = import scala3encoders.given diff --git a/terminal21-ui-std-exports/src/main/scala/org/terminal21/ui/std/ServerJson.scala b/terminal21-ui-std-exports/src/main/scala/org/terminal21/ui/std/ServerJson.scala index 5ff4371b..2c672740 100644 --- a/terminal21-ui-std-exports/src/main/scala/org/terminal21/ui/std/ServerJson.scala +++ b/terminal21-ui-std-exports/src/main/scala/org/terminal21/ui/std/ServerJson.scala @@ -1,18 +1,11 @@ package org.terminal21.ui.std import io.circe.Json +import org.slf4j.LoggerFactory case class ServerJson( - rootKeys: Seq[String], - elements: Map[String, Json], - keyTree: Map[String, Seq[String]] -): - def include(j: ServerJson): ServerJson = - ServerJson( - rootKeys, - elements ++ j.elements, - keyTree ++ j.keyTree - ) + elements: Seq[Json] +) object ServerJson: - val Empty = ServerJson(Nil, Map.empty, Map.empty) + val Empty = ServerJson(Nil) diff --git a/terminal21-ui-std-exports/src/main/scala/org/terminal21/ui/std/SessionsService.scala b/terminal21-ui-std-exports/src/main/scala/org/terminal21/ui/std/SessionsService.scala index 8809d7da..98e09b85 100644 --- a/terminal21-ui-std-exports/src/main/scala/org/terminal21/ui/std/SessionsService.scala +++ b/terminal21-ui-std-exports/src/main/scala/org/terminal21/ui/std/SessionsService.scala @@ -1,11 +1,11 @@ package org.terminal21.ui.std -import org.terminal21.model.Session +import org.terminal21.model.{Session, SessionOptions} /** //> exported */ trait SessionsService: - def createSession(id: String, name: String): Session + def createSession(id: String, name: String, sessionOptions: SessionOptions): Session def terminateSession(session: Session): Unit def setSessionJsonState(session: Session, state: ServerJson): Unit diff --git a/terminal21-ui-std-exports/src/test/scala/org/terminal21/ui/std/ServerJsonTest.scala b/terminal21-ui-std-exports/src/test/scala/org/terminal21/ui/std/ServerJsonTest.scala deleted file mode 100644 index 4a2f7dbf..00000000 --- a/terminal21-ui-std-exports/src/test/scala/org/terminal21/ui/std/ServerJsonTest.scala +++ /dev/null @@ -1,17 +0,0 @@ -package org.terminal21.ui.std - -import io.circe.Json -import org.scalatest.funsuite.AnyFunSuiteLike -import org.scalatest.matchers.should.Matchers.* - -class ServerJsonTest extends AnyFunSuiteLike: - test("include"): - val j1 = ServerJson(Seq("k1"), Map("k1" -> Json.fromInt(1), "k2" -> Json.fromInt(2)), Map("k1" -> Seq("k2", "k3"))) - val j2 = ServerJson(Nil, Map("k2" -> Json.fromInt(3)), Map("k2" -> Seq("k4"))) - j1.include(j2) should be( - ServerJson( - Seq("k1"), - Map("k1" -> Json.fromInt(1), "k2" -> Json.fromInt(3)), - Map("k1" -> Seq("k2", "k3"), "k2" -> Seq("k4")) - ) - ) diff --git a/terminal21-ui-std-exports/src/test/scala/org/terminal21/ui/std/StdExportsBuilders.scala b/terminal21-ui-std-exports/src/test/scala/org/terminal21/ui/std/StdExportsBuilders.scala index 68f693e6..6daa5518 100644 --- a/terminal21-ui-std-exports/src/test/scala/org/terminal21/ui/std/StdExportsBuilders.scala +++ b/terminal21-ui-std-exports/src/test/scala/org/terminal21/ui/std/StdExportsBuilders.scala @@ -4,11 +4,5 @@ import io.circe.Json object StdExportsBuilders: def serverJson( - rootKeys: Seq[String] = Nil, - elements: Map[String, Json] = Map.empty, - keyTree: Map[String, Seq[String]] = Map.empty - ) = ServerJson( - rootKeys, - elements, - keyTree - ) + elements: Seq[Json] = Nil + ) = ServerJson(elements) diff --git a/terminal21-ui-std/src/main/scala/org/terminal21/client/ConnectedSession.scala b/terminal21-ui-std/src/main/scala/org/terminal21/client/ConnectedSession.scala index e693d5c5..f05d20cc 100644 --- a/terminal21-ui-std/src/main/scala/org/terminal21/client/ConnectedSession.scala +++ b/terminal21-ui-std/src/main/scala/org/terminal21/client/ConnectedSession.scala @@ -1,30 +1,41 @@ package org.terminal21.client -import io.circe.* -import io.circe.generic.auto.* -import org.slf4j.LoggerFactory -import org.terminal21.client.components.UiElement.{HasChildren, HasEventHandler, allDeep} -import org.terminal21.client.components.{UiComponent, UiElement, UiElementEncoding} -import org.terminal21.client.internal.EventHandlers +import io.circe.{Json, JsonNumber, JsonObject} +import org.terminal21.client.components.UiElement.HasChildren +import org.terminal21.client.components.chakra.Box +import org.terminal21.client.components.{UiComponent, UiElement} +import org.terminal21.client.json.UiElementEncoding +import org.terminal21.collections.SEList import org.terminal21.model.* import org.terminal21.ui.std.{ServerJson, SessionsService} import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.{CountDownLatch, TimeUnit} import scala.annotation.tailrec -import scala.collection.concurrent.TrieMap +/** A session connected to the terminal21 server. + * + * @param session + * the session + * @param encoding + * json encoder for UiElements + * @param serverUrl + * the url of the server + * @param sessionsService + * the service to talk to the server + * @param onCloseHandler + * gets notified when the user closes the session + */ class ConnectedSession(val session: Session, encoding: UiElementEncoding, val serverUrl: String, sessionsService: SessionsService, onCloseHandler: () => Unit): - private val logger = LoggerFactory.getLogger(getClass) - private val handlers = new EventHandlers(this) + @volatile private var events = SEList[CommandEvent]() def uiUrl: String = serverUrl + "/ui" - def clear(): Unit = - render() - handlers.clear() - modifiedElements.clear() - def addEventHandler(key: String, handler: EventHandler): Unit = handlers.addEventHandler(key, handler) + /** Clears all UI elements and event handlers. + */ + def clear(): Unit = + events.poisonPill() + events = SEList() private val exitLatch = new CountDownLatch(1) @@ -36,7 +47,8 @@ class ConnectedSession(val session: Session, encoding: UiElementEncoding, val se private val leaveSessionOpen = new AtomicBoolean(false) - /** Doesn't close the session upon exiting. In the UI the session seems active but events are not working because the event handlers are not available. + /** Doesn't close the session upon exiting. In the UI the session seems active but events are not working because the event handlers are not available. Useful + * when we need to let the user read through some data. But no interaction is possible anymore between the user and the code. */ def leaveSessionOpenAfterExiting(): Unit = leaveSessionOpen.set(true) @@ -56,62 +68,75 @@ class ConnectedSession(val session: Session, encoding: UiElementEncoding, val se */ def isClosed: Boolean = exitLatch.getCount == 0 - def click(e: UiElement): Unit = fireEvent(OnClick(e.key)) + def fireClickEvent(e: UiElement): Unit = fireEvent(CommandEvent.onClick(e)) + def fireChangeEvent(e: UiElement, newValue: String): Unit = fireEvent(CommandEvent.onChange(e, newValue)) + def fireSessionClosedEvent(): Unit = fireEvent(CommandEvent.sessionClosed) - private[client] def fireEvent(event: CommandEvent): Unit = + /** @return + * A new event iterator. There can be many event iterators on the same time and each of them iterates events only from after the time it was created. The + * iterator blocks while waiting to receive an event. + */ + def eventIterator: Iterator[CommandEvent] = events.iterator + + /** Waits until at least 1 event iterator was created for the current page. Useful for testing purposes if i.e. one thread runs the main loop and gets an + * eventIterator at some point and an other thread needs to fire events. + */ + def waitUntilAtLeast1EventIteratorWasCreated(): Unit = events.waitUntilAtLeast1IteratorWasCreated() + + def fireEvents(events: CommandEvent*): Unit = for e <- events do fireEvent(e) + + def fireEvent(event: CommandEvent): Unit = + events.add(event) event match case SessionClosed(_) => + events.poisonPill() exitLatch.countDown() onCloseHandler() case _ => - handlers.getEventHandler(event.key) match - case Some(handlers) => - for handler <- handlers do - (event, handler) match - case (_: OnClick, h: OnClickEventHandler) => h.onClick() - case (onChange: OnChange, h: OnChangeEventHandler) => h.onChange(onChange.value) - case (onChange: OnChange, h: OnChangeBooleanEventHandler) => h.onChange(onChange.value.toBoolean) - case x => logger.error(s"Unknown event handling combination : $x") - case None => - logger.warn(s"There is no event handler for event $event") - - def render(es: UiElement*): Unit = - handlers.registerEventHandlers(es) + + /** Normally this method shouldn't be called directly. Terminates any previous event iterators, clears the UI and renders the UiElements. + * @param es + * the UiElements to be rendered. + */ + def render(es: Seq[UiElement]): Unit = + clear() val j = toJson(es) sessionsService.setSessionJsonState(session, j) - def renderChanges(es: UiElement*): Unit = - for e <- es do modified(e) - val j = toJson(es) - sessionsService.changeSessionJsonState(session, j) - - private def toJson(elements: Seq[UiElement]): ServerJson = - val flat = elements.flatMap(_.flat) - val sj = ServerJson( - elements.map(_.key), - flat - .map: el => - ( - el.key, - el match - case e: UiComponent => encoding.uiElementEncoder(e).deepDropNullValues - case e: HasChildren[_] => encoding.uiElementEncoder(e.noChildren).deepDropNullValues - case e => encoding.uiElementEncoder(e).deepDropNullValues - ) - .toMap, - flat - .map: e => - ( - e.key, - e match - case e: UiComponent => e.rendered.map(_.key) - case e: HasChildren[_] => e.children.map(_.key) - case _ => Nil - ) - .toMap + /** Normally this method shouldn't be called directly. Renders updates to existing elements + * @param es + * a seq of updated elements, all these should already have been rendered before (but not necessarily their children) + */ + private[client] def renderChanges(es: Seq[UiElement]): Unit = + if !isClosed && es.nonEmpty then + val j = toJson(es) + sessionsService.setSessionJsonState(session, j) // TODO:changeSessionJsonState + + private def nullEmptyKeysAndDropNulls(j: Json): Json = + val folder = new Json.Folder[Json] { + def onNull: Json = Json.Null + def onBoolean(value: Boolean): Json = Json.fromBoolean(value) + def onNumber(value: JsonNumber): Json = Json.fromJsonNumber(value) + def onString(value: String): Json = Json.fromString(value) + def onArray(value: Vector[Json]): Json = + Json.fromValues(value.collect { + case v if !v.isNull => v.foldWith(this) + }) + def onObject(value: JsonObject): Json = + Json.fromJsonObject( + value + .filter: + case ("key", v) => !v.asString.contains("") + case (_, v) => !v.isNull + .mapValues(_.foldWith(this)) + ) + } + + j.foldWith(folder) + + private def toJson(elementsUn: Seq[UiElement]): ServerJson = + val elements = elementsUn.map(_.substituteComponents) + val sj = ServerJson( + elements.map(e => nullEmptyKeysAndDropNulls(encoding.uiElementEncoder(e))) ) sj - private val modifiedElements = TrieMap.empty[String, UiElement] - def modified(e: UiElement): Unit = - modifiedElements += e.key -> e - def currentState[A <: UiElement](e: A): A = modifiedElements.getOrElse(e.key, e).asInstanceOf[A] diff --git a/terminal21-ui-std/src/main/scala/org/terminal21/client/Controller.scala b/terminal21-ui-std/src/main/scala/org/terminal21/client/Controller.scala new file mode 100644 index 00000000..1c6e8d4d --- /dev/null +++ b/terminal21-ui-std/src/main/scala/org/terminal21/client/Controller.scala @@ -0,0 +1,217 @@ +package org.terminal21.client + +import org.terminal21.client.Events.InitialRender +import org.terminal21.client.collections.EventIterator +import org.terminal21.client.components.{OnChangeBooleanEventHandler, OnChangeEventHandler, UiElement} +import org.terminal21.client.components.OnClickEventHandler.CanHandleOnClickEvent +import org.terminal21.model.{ClientEvent, CommandEvent, OnChange, OnClick} + +/** The initial function passed on to a controller in order to create the MVC iterator. + * @tparam M + * the type of the model + */ +type ModelViewFunction[M] = (M, Events) => MV[M] + +/** Controller manages the changes in the model by receiving events. Also the rendering of the view (which is UiElements). + * + * @tparam M + * the type of the model + */ +class Controller[M]( + eventIteratorFactory: => Iterator[CommandEvent], + renderChanges: Seq[UiElement] => Unit, + modelViewFunction: ModelViewFunction[M] +): + /** Sends the initialModel along with an InitialRender event to the modelViewFunction and renders the resulting UI. + * @param initialModel + * the initial state of the model + * @return + * a RenderedController. Call run() or iterator on that. + */ + def render(initialModel: M): RenderedController[M] = + val mv = modelViewFunction(initialModel, Events.Empty) + renderChanges(mv.view) + new RenderedController(eventIteratorFactory, renderChanges, modelViewFunction, mv) + +trait NoModelController: + this: Controller[Unit] => + def render(): RenderedController[Unit] = render(()) + +object Controller: + /** Call this for a full blown model-view-controller + * @param modelViewFunction + * a function (M, Events) => MV[M] which should process the events and render Seq[UiElement] + * @param session + * the ConnectedSession + * @tparam M + * the type of the model + * @return + * Controller[M], call render(initialModel) and then iterator or run() + */ + def apply[M](modelViewFunction: ModelViewFunction[M])(using session: ConnectedSession): Controller[M] = + new Controller(session.eventIterator, session.renderChanges, modelViewFunction) + + /** Call this id you just want to render some information UI that won't receive events. + * @param component + * a single component (and it's children) to be rendered + * @param session + * ConnectedSession + * @return + * the controller. + */ + def noModel(component: UiElement)(using session: ConnectedSession): Controller[Unit] with NoModelController = noModel(Seq(component)) + + /** Call this id you just want to render some information UI that won't receive events. + * @param components + * components to be rendered + * @param session + * ConnectedSession + * @return + * the controller. + */ + def noModel(components: Seq[UiElement])(using session: ConnectedSession): Controller[Unit] with NoModelController = + new Controller[Unit](session.eventIterator, session.renderChanges, (_, _) => MV((), components)) with NoModelController + + /** Call this if you have no model but still want to receive events. I.e. a form with just an "Ok" button + * + * @param materializer + * a function that will be called initially to render the UI and whenever there is an event to render any changes to the UI + * @param session + * ConnectedSession + * @return + * the controller. + */ + def noModel(materializer: Events => Seq[UiElement])(using session: ConnectedSession): Controller[Unit] with NoModelController = + new Controller[Unit](session.eventIterator, session.renderChanges, (_, events) => MV((), materializer(events))) with NoModelController + +class RenderedController[M]( + eventIteratorFactory: => Iterator[CommandEvent], + renderChanges: Seq[UiElement] => Unit, + materializer: ModelViewFunction[M], + initialMv: MV[M] +): + /** @return + * A new event iterator. There can be many event iterators on the same time and each of them iterates events only from after the time it was created. The + * iterator blocks while waiting to receive an event. + * + * Normally a single iterator is required and most of the time it is better done by the #run() method below. + */ + def iterator: EventIterator[MV[M]] = new EventIterator[MV[M]]( + eventIteratorFactory + .takeWhile(!_.isSessionClosed) + .scanLeft(initialMv): (mv, e) => + val events = Events(e) + val newMv = materializer(mv.model, events) + if mv.view != newMv.view then renderChanges(newMv.view) + newMv + .flatMap: mv => + // make sure we read the last MV change when terminating + if mv.terminate then Seq(mv.copy(terminate = false), mv) else Seq(mv) + .takeWhile(!_.terminate) + ) + + /** Gets an iterator and run the event processing. + * @return + * The last value of the model or None if the user closed the session. + */ + def run(): Option[M] = iterator.lastOption.map(_.model) + +/** Wraps an event and has useful methods to process it. + * @param event + * CommandEvent (like clicks, changed values, ClientEvent etc) + */ +case class Events(event: CommandEvent): + def isClicked(e: UiElement): Boolean = event match + case OnClick(key) => key == e.key + case _ => false + + /** If an element is clicked this results in Some(value), otherwise None + * @param e + * the element + * @param value + * the value + * @tparam V + * value type + * @return + * Some(value) if e is clicked, None if not + */ + def ifClicked[V](e: UiElement & CanHandleOnClickEvent, value: => V): Option[V] = if isClicked(e) then Some(value) else None + + /** @param e + * an editable element (like input) + * @param default + * the default value + * @return + * the new value of the editable (as received by an OnChange event) or the default value if the element's value didn't change + */ + def changedValue(e: UiElement & OnChangeEventHandler.CanHandleOnChangeEvent, default: String): String = changedValue(e).getOrElse(default) + + /** @param e + * an editable element (like input) that can receive OnChange events + * @return + * Some(newValue) if the element received an OnChange event, None if not + */ + def changedValue(e: UiElement & OnChangeEventHandler.CanHandleOnChangeEvent): Option[String] = event match + case OnChange(key, value) if key == e.key => Some(value) + case _ => None + + /** @param e + * an editable element (like input) + * @return + * true if the value of the element has changed, false if not + */ + def isChangedValue(e: UiElement & OnChangeEventHandler.CanHandleOnChangeEvent): Boolean = + event match + case OnChange(key, _) => key == e.key + case _ => false + + /** @param e + * an editable element with boolean value (like checkbox) + * @param default + * the value to return if the element wasn't changed + * @return + * the element's changed value or the default if the element didn't change + */ + def changedBooleanValue(e: UiElement & OnChangeBooleanEventHandler.CanHandleOnChangeEvent, default: Boolean): Boolean = + changedBooleanValue(e).getOrElse(default) + + /** @param e + * an editable element with boolean value (like checkbox) + * @return + * Some(value) if the element changed or None if not + */ + def changedBooleanValue(e: UiElement & OnChangeBooleanEventHandler.CanHandleOnChangeEvent): Option[Boolean] = event match + case OnChange(key, value) if key == e.key => Some(value.toBoolean) + case _ => None + + /** @param e + * an editable element with boolean value (like checkbox) + * @return + * true if the element changed or false if not + */ + def isChangedBooleanValue(e: UiElement & OnChangeBooleanEventHandler.CanHandleOnChangeEvent): Boolean = + event match + case OnChange(key, _) => key == e.key + case _ => false + + def isInitialRender: Boolean = event == InitialRender + +object Events: + case object InitialRender extends ClientEvent + + val Empty = Events(InitialRender) + +/** The ModelViewFunction should return this, which contains the changes to the model, the changed view and if the event iteration should terminate. + * @param model + * the value of the model after processing the event + * @param view + * the value of the view after processing the event + * @param terminate + * if true, the event iteration will terminate + * @tparam M + * the type of the model + */ +case class MV[M](model: M, view: Seq[UiElement], terminate: Boolean = false) + +object MV: + def apply[M](model: M, view: UiElement): MV[M] = MV(model, Seq(view)) diff --git a/terminal21-ui-std/src/main/scala/org/terminal21/client/EventHandler.scala b/terminal21-ui-std/src/main/scala/org/terminal21/client/EventHandler.scala deleted file mode 100644 index 1705b951..00000000 --- a/terminal21-ui-std/src/main/scala/org/terminal21/client/EventHandler.scala +++ /dev/null @@ -1,35 +0,0 @@ -package org.terminal21.client - -import org.terminal21.client.components.UiElement - -trait EventHandler - -trait OnClickEventHandler extends EventHandler: - def onClick(): Unit - -object OnClickEventHandler: - trait CanHandleOnClickEvent[A <: UiElement]: - this: A => - def onClick(h: OnClickEventHandler)(using session: ConnectedSession): A = - session.addEventHandler(key, h) - this - -trait OnChangeEventHandler extends EventHandler: - def onChange(newValue: String): Unit - -object OnChangeEventHandler: - trait CanHandleOnChangeEvent[A <: UiElement]: - this: A => - def onChange(h: OnChangeEventHandler)(using session: ConnectedSession): A = - session.addEventHandler(key, h) - this - -trait OnChangeBooleanEventHandler extends EventHandler: - def onChange(newValue: Boolean): Unit - -object OnChangeBooleanEventHandler: - trait CanHandleOnChangeEvent[A <: UiElement]: - this: A => - def onChange(h: OnChangeBooleanEventHandler)(using session: ConnectedSession): A = - session.addEventHandler(key, h) - this diff --git a/terminal21-ui-std/src/main/scala/org/terminal21/client/Globals.scala b/terminal21-ui-std/src/main/scala/org/terminal21/client/Globals.scala index 74a3af9b..b1f69c85 100644 --- a/terminal21-ui-std/src/main/scala/org/terminal21/client/Globals.scala +++ b/terminal21-ui-std/src/main/scala/org/terminal21/client/Globals.scala @@ -2,4 +2,5 @@ package org.terminal21.client import functions.fibers.FiberExecutor -given fiberExecutor: FiberExecutor = FiberExecutor() +given FiberExecutor = FiberExecutor() +val fiberExecutor = implicitly[FiberExecutor] diff --git a/terminal21-ui-std/src/main/scala/org/terminal21/client/Sessions.scala b/terminal21-ui-std/src/main/scala/org/terminal21/client/Sessions.scala index 5b29b5fd..23d62381 100644 --- a/terminal21-ui-std/src/main/scala/org/terminal21/client/Sessions.scala +++ b/terminal21-ui-std/src/main/scala/org/terminal21/client/Sessions.scala @@ -4,38 +4,51 @@ import functions.fibers.FiberExecutor import functions.helidon.transport.HelidonTransport import io.helidon.webclient.api.WebClient import io.helidon.webclient.websocket.WsClient -import org.terminal21.client.components.{ComponentLib, StdElementEncoding, UiElementEncoding} +import org.terminal21.client.components.ComponentLib +import org.terminal21.client.json.{StdElementEncoding, UiElementEncoding} import org.terminal21.config.Config +import org.terminal21.model.SessionOptions import org.terminal21.ui.std.SessionsServiceCallerFactory import java.util.concurrent.atomic.AtomicBoolean object Sessions: - def withNewSession[R](id: String, name: String, componentLibs: ComponentLib*)(f: ConnectedSession => R): R = - val config = Config.Default - val serverUrl = s"http://${config.host}:${config.port}" - val client = WebClient.builder - .baseUri(serverUrl) - .build - val transport = new HelidonTransport(client) - val sessionsService = SessionsServiceCallerFactory.newHelidonJsonSessionsService(transport) - val session = sessionsService.createSession(id, name) - val wsClient = WsClient.builder - .baseUri(s"ws://${config.host}:${config.port}") - .build - - val isStopped = new AtomicBoolean(false) - def terminate(): Unit = - isStopped.set(true) - - val encoding = new UiElementEncoding(Seq(StdElementEncoding) ++ componentLibs) - val connectedSession = ConnectedSession(session, encoding, serverUrl, sessionsService, terminate) - FiberExecutor.withFiberExecutor: executor => - val listener = new ClientEventsWsListener(wsClient, connectedSession, executor) - listener.start() - - try { - f(connectedSession) - } finally - if !isStopped.get() && !connectedSession.isLeaveSessionOpen then sessionsService.terminateSession(session) - listener.close() + case class SessionBuilder( + id: String, + name: String, + componentLibs: Seq[ComponentLib] = Seq(StdElementEncoding), + sessionOptions: SessionOptions = SessionOptions.Defaults + ): + def andLibraries(libraries: ComponentLib*): SessionBuilder = copy(componentLibs = componentLibs ++ libraries) + def andOptions(sessionOptions: SessionOptions) = copy(sessionOptions = sessionOptions) + + def connect[R](f: ConnectedSession => R): R = + val config = Config.Default + val serverUrl = s"http://${config.host}:${config.port}" + val client = WebClient.builder + .baseUri(serverUrl) + .build + val transport = new HelidonTransport(client) + val sessionsService = SessionsServiceCallerFactory.newHelidonJsonSessionsService(transport) + val session = sessionsService.createSession(id, name, sessionOptions) + val wsClient = WsClient.builder + .baseUri(s"ws://${config.host}:${config.port}") + .build + + val isStopped = new AtomicBoolean(false) + + def terminate(): Unit = + isStopped.set(true) + + val encoding = new UiElementEncoding(Seq(StdElementEncoding) ++ componentLibs) + val connectedSession = ConnectedSession(session, encoding, serverUrl, sessionsService, terminate) + FiberExecutor.withFiberExecutor: executor => + val listener = new ClientEventsWsListener(wsClient, connectedSession, executor) + listener.start() + + try f(connectedSession) + finally + if !isStopped.get() && !connectedSession.isLeaveSessionOpen then sessionsService.terminateSession(session) + listener.close() + + def withNewSession(id: String, name: String): SessionBuilder = SessionBuilder(id, name) diff --git a/terminal21-ui-std/src/main/scala/org/terminal21/client/collections/EventIterator.scala b/terminal21-ui-std/src/main/scala/org/terminal21/client/collections/EventIterator.scala new file mode 100644 index 00000000..d8df35e0 --- /dev/null +++ b/terminal21-ui-std/src/main/scala/org/terminal21/client/collections/EventIterator.scala @@ -0,0 +1,21 @@ +package org.terminal21.client.collections + +import org.terminal21.client.ConnectedSession + +import scala.collection.AbstractIterator + +class EventIterator[A](it: Iterator[A]) extends AbstractIterator[A]: + override def hasNext: Boolean = it.hasNext + override def next(): A = it.next() + + def lastOption: Option[A] = + var last = Option.empty[A] + while hasNext do last = Some(next()) + last + + def lastOptionOrNoneIfSessionClosed(using session: ConnectedSession) = + val v = lastOption + if session.isClosed then None else v + +object EventIterator: + def apply[A](items: A*): EventIterator[A] = new EventIterator(Iterator(items*)) diff --git a/terminal21-ui-std/src/main/scala/org/terminal21/client/components/CachedCalculation.scala b/terminal21-ui-std/src/main/scala/org/terminal21/client/components/CachedCalculation.scala deleted file mode 100644 index 98a95d8d..00000000 --- a/terminal21-ui-std/src/main/scala/org/terminal21/client/components/CachedCalculation.scala +++ /dev/null @@ -1,11 +0,0 @@ -package org.terminal21.client.components - -import functions.fibers.{Fiber, FiberExecutor} - -abstract class CachedCalculation[OUT](using executor: FiberExecutor) extends Calculation[OUT]: - def isCached: Boolean - def invalidateCache(): Unit - def nonCachedCalculation: OUT - override def reCalculate(): Fiber[OUT] = - invalidateCache() - super.reCalculate() diff --git a/terminal21-ui-std/src/main/scala/org/terminal21/client/components/Calculation.scala b/terminal21-ui-std/src/main/scala/org/terminal21/client/components/Calculation.scala deleted file mode 100644 index 30c933e0..00000000 --- a/terminal21-ui-std/src/main/scala/org/terminal21/client/components/Calculation.scala +++ /dev/null @@ -1,33 +0,0 @@ -package org.terminal21.client.components - -import functions.fibers.{Fiber, FiberExecutor} - -import java.util.concurrent.CountDownLatch - -trait Calculation[OUT](using executor: FiberExecutor): - protected def calculation(): OUT - protected def whenResultsNotReady(): Unit = () - protected def whenResultsReady(results: OUT): Unit = () - - def reCalculate(): Fiber[OUT] = run() - - def onError(t: Throwable): Unit = - t.printStackTrace() - - def run(): Fiber[OUT] = - val refreshInOrder = new CountDownLatch(1) - executor.submit: - try - executor.submit: - try whenResultsNotReady() - finally refreshInOrder.countDown() - - val out = calculation() - refreshInOrder.await() - executor.submit: - whenResultsReady(out) - out - catch - case t: Throwable => - onError(t) - throw t diff --git a/terminal21-ui-std/src/main/scala/org/terminal21/client/components/EventHandler.scala b/terminal21-ui-std/src/main/scala/org/terminal21/client/components/EventHandler.scala new file mode 100644 index 00000000..9388995c --- /dev/null +++ b/terminal21-ui-std/src/main/scala/org/terminal21/client/components/EventHandler.scala @@ -0,0 +1,18 @@ +package org.terminal21.client.components + +trait EventHandler + +object OnClickEventHandler: + trait CanHandleOnClickEvent: + this: UiElement => + if key.isEmpty then throw new IllegalStateException(s"clickables must have a stable key. Error occurred on $this") + +object OnChangeEventHandler: + trait CanHandleOnChangeEvent: + this: UiElement => + if key.isEmpty then throw new IllegalStateException(s"changeable must have a stable key. Error occurred on $this") + +object OnChangeBooleanEventHandler: + trait CanHandleOnChangeEvent: + this: UiElement => + if key.isEmpty then throw new IllegalStateException(s"changeable must have a stable key. Error occurred on $this") diff --git a/terminal21-ui-std/src/main/scala/org/terminal21/client/components/Keys.scala b/terminal21-ui-std/src/main/scala/org/terminal21/client/components/Keys.scala index 4ef4ce23..97aa99c1 100644 --- a/terminal21-ui-std/src/main/scala/org/terminal21/client/components/Keys.scala +++ b/terminal21-ui-std/src/main/scala/org/terminal21/client/components/Keys.scala @@ -1,7 +1,8 @@ package org.terminal21.client.components +import org.terminal21.client.components.UiElement.HasChildren + import java.util.concurrent.atomic.AtomicInteger object Keys: - private val keyId = new AtomicInteger(0) - def nextKey: String = s"key${keyId.incrementAndGet()}" + def nextKey: String = "" diff --git a/terminal21-ui-std/src/main/scala/org/terminal21/client/components/StdUiCalculation.scala b/terminal21-ui-std/src/main/scala/org/terminal21/client/components/StdUiCalculation.scala deleted file mode 100644 index 44c7b279..00000000 --- a/terminal21-ui-std/src/main/scala/org/terminal21/client/components/StdUiCalculation.scala +++ /dev/null @@ -1,65 +0,0 @@ -package org.terminal21.client.components - -import functions.fibers.FiberExecutor -import org.terminal21.client.ConnectedSession -import org.terminal21.client.components.UiElement.HasStyle -import org.terminal21.client.components.chakra.* - -import java.util.concurrent.atomic.{AtomicBoolean, AtomicReference} - -/** Creates a standard UI for a calculation which may take time. While the calculation runs, the UI is grayed out, including the dataUi component. When the - * calculation completes, it allows for updating the dataUi component. - * @tparam OUT - * the return value of the calculation. - */ -trait StdUiCalculation[OUT]( - name: String, - dataUi: UiElement with HasStyle[_] -)(using session: ConnectedSession, executor: FiberExecutor) - extends Calculation[OUT] - with UiComponent: - private val running = new AtomicBoolean(false) - private val currentUi = new AtomicReference(dataUi) - - protected def updateUi(dataUi: UiElement & HasStyle[_]) = currentUi.set(dataUi) - - lazy val badge = Badge() - lazy val recalc = Button(text = "Recalculate", size = Some("sm"), leftIcon = Some(RepeatIcon())).onClick: () => - if running.compareAndSet(false, true) then - try - reCalculate() - finally running.set(false) - - override lazy val rendered: Seq[UiElement] = - val header = Box( - bg = "green", - p = 4, - children = Seq( - HStack(children = Seq(Text(text = name), badge, recalc)) - ) - ) - Seq(header, dataUi) - - override def onError(t: Throwable): Unit = - session.renderChanges( - badge.withText(s"Error: ${t.getMessage}").withColorScheme(Some("red")), - dataUi, - recalc.withIsDisabled(None) - ) - super.onError(t) - - override protected def whenResultsNotReady(): Unit = - session.renderChanges( - badge.withText("Calculating").withColorScheme(Some("purple")), - currentUi.get().withStyle(dataUi.style + ("filter" -> "grayscale(100%)")), - recalc.withIsDisabled(Some(true)) - ) - super.whenResultsNotReady() - - override protected def whenResultsReady(results: OUT): Unit = - val newDataUi = currentUi.get().withStyle(dataUi.style - "filter") - session.renderChanges( - badge.withText("Ready").withColorScheme(None), - newDataUi, - recalc.withIsDisabled(Some(false)) - ) diff --git a/terminal21-ui-std/src/main/scala/org/terminal21/client/components/UiComponent.scala b/terminal21-ui-std/src/main/scala/org/terminal21/client/components/UiComponent.scala index c1213662..473f992b 100644 --- a/terminal21-ui-std/src/main/scala/org/terminal21/client/components/UiComponent.scala +++ b/terminal21-ui-std/src/main/scala/org/terminal21/client/components/UiComponent.scala @@ -1,7 +1,5 @@ package org.terminal21.client.components -import org.terminal21.client.components.UiElement.HasChildren - /** A UiComponent is a UI element that is composed of a seq of other ui elements */ trait UiComponent extends UiElement: @@ -9,3 +7,5 @@ trait UiComponent extends UiElement: // keys of any sub-elements the component has. def rendered: Seq[UiElement] override def flat = Seq(this) ++ rendered.flatMap(_.flat) + + protected def subKey(suffix: String): String = key + "-" + suffix diff --git a/terminal21-ui-std/src/main/scala/org/terminal21/client/components/UiElement.scala b/terminal21-ui-std/src/main/scala/org/terminal21/client/components/UiElement.scala index 00bb95b0..468022a1 100644 --- a/terminal21-ui-std/src/main/scala/org/terminal21/client/components/UiElement.scala +++ b/terminal21-ui-std/src/main/scala/org/terminal21/client/components/UiElement.scala @@ -1,42 +1,45 @@ package org.terminal21.client.components -import org.terminal21.client.{ConnectedSession, EventHandler} +import org.terminal21.client.components.UiElement.HasChildren +import org.terminal21.client.components.chakra.Box +import org.terminal21.collections.{TypedMap, TypedMapKey} + +abstract class UiElement extends AnyElement: + type This <: UiElement -trait UiElement: def key: String - def flat: Seq[UiElement] = Seq(this) + def withKey(key: String): This + def findKey(key: String): UiElement = flat.find(_.key == key).get - def render()(using session: ConnectedSession): Unit = - session.render(this) + def dataStore: TypedMap + def withDataStore(ds: TypedMap): This + def store[V](key: TypedMapKey[V], value: V): This = withDataStore(dataStore + (key -> value)) + def storedValue[V](key: TypedMapKey[V]): V = dataStore(key) - /** Renders any changes for this element and it's children (if any). The element must previously have been added to the session. + /** @return + * this element along all it's children flattened */ - def renderChanges()(using session: ConnectedSession): Unit = - session.renderChanges(this) + def flat: Seq[UiElement] = Seq(this) -object UiElement: - def allDeep(elements: Seq[UiElement]): Seq[UiElement] = - elements ++ elements - .collect: - case hc: HasChildren[_] => allDeep(hc.children) - .flatten + def substituteComponents: UiElement = + this match + case c: UiComponent => Box(key = c.key, text = "", children = c.rendered.map(_.substituteComponents), dataStore = c.dataStore) + case ch: HasChildren => ch.withChildren(ch.children.map(_.substituteComponents)*) + case _ => this - trait Current[A <: UiElement]: - this: UiElement => - def current(using session: ConnectedSession): A = session.currentState(this.asInstanceOf[A]) + def toSimpleString: String = s"${getClass.getSimpleName}($key)" - trait HasChildren[A <: UiElement]: - this: A => +object UiElement: + trait HasChildren: + this: UiElement => def children: Seq[UiElement] - override def flat: Seq[UiElement] = Seq(this) ++ children.flatMap(_.flat) - def withChildren(cn: UiElement*): A - def noChildren: A = withChildren() - def addChildren(cn: UiElement*): A = withChildren(children ++ cn: _*) + override def flat: Seq[UiElement] = Seq(this) ++ children.flatMap(_.flat) + def withChildren(cn: UiElement*): This + def noChildren: This = withChildren() + def addChildren(cn: UiElement*): This = withChildren(children ++ cn: _*) - trait HasEventHandler: - def defaultEventHandler(session: ConnectedSession): EventHandler - - trait HasStyle[A <: UiElement]: + trait HasStyle: + this: UiElement => def style: Map[String, Any] - def withStyle(v: Map[String, Any]): A - def withStyle(vs: (String, Any)*): A = withStyle(vs.toMap) + def withStyle(v: Map[String, Any]): This + def withStyle(vs: (String, Any)*): This = withStyle(vs.toMap) diff --git a/terminal21-ui-std/src/main/scala/org/terminal21/client/components/chakra/ChakraElement.scala b/terminal21-ui-std/src/main/scala/org/terminal21/client/components/chakra/ChakraElement.scala index ef601436..6705d250 100644 --- a/terminal21-ui-std/src/main/scala/org/terminal21/client/components/chakra/ChakraElement.scala +++ b/terminal21-ui-std/src/main/scala/org/terminal21/client/components/chakra/ChakraElement.scala @@ -1,8 +1,8 @@ package org.terminal21.client.components.chakra -import org.terminal21.client.components.UiElement.{Current, HasChildren, HasEventHandler, HasStyle} -import org.terminal21.client.components.{Keys, UiElement} -import org.terminal21.client.{ConnectedSession, OnChangeBooleanEventHandler, OnChangeEventHandler, OnClickEventHandler} +import org.terminal21.client.components.* +import org.terminal21.client.components.UiElement.{HasChildren, HasStyle} +import org.terminal21.collections.TypedMap sealed trait CEJson extends UiElement @@ -10,7 +10,7 @@ sealed trait CEJson extends UiElement * https://github.com/kostaskougios/terminal21-restapi/blob/main/examples/src/main/scala/tests/ChakraComponents.scala and it's related scala files under * https://github.com/kostaskougios/terminal21-restapi/tree/main/examples/src/main/scala/tests/chakra */ -sealed trait ChakraElement[A <: ChakraElement[A]] extends CEJson with HasStyle[A] with Current[A] +sealed trait ChakraElement extends CEJson with HasStyle /** https://chakra-ui.com/docs/components/button */ @@ -27,9 +27,11 @@ case class Button( isDisabled: Option[Boolean] = None, isLoading: Option[Boolean] = None, isAttached: Option[Boolean] = None, - spacing: Option[String] = None -) extends ChakraElement[Button] - with OnClickEventHandler.CanHandleOnClickEvent[Button]: + spacing: Option[String] = None, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement + with OnClickEventHandler.CanHandleOnClickEvent: + type This = Button override def withStyle(v: Map[String, Any]): Button = copy(style = v) def withKey(v: String) = copy(key = v) def withText(v: String) = copy(text = v) @@ -37,12 +39,15 @@ case class Button( def withVariant(v: Option[String]) = copy(variant = v) def withColorScheme(v: Option[String]) = copy(colorScheme = v) def withLeftIcon(v: Option[UiElement]) = copy(leftIcon = v) + def withLeftIcon(v: UiElement) = copy(leftIcon = Some(v)) def withRightIcon(v: Option[UiElement]) = copy(rightIcon = v) + def withRightIcon(v: UiElement) = copy(rightIcon = Some(v)) def withIsActive(v: Option[Boolean]) = copy(isActive = v) def withIsDisabled(v: Option[Boolean]) = copy(isDisabled = v) def withIsLoading(v: Option[Boolean]) = copy(isLoading = v) def withIsAttached(v: Option[Boolean]) = copy(isAttached = v) def withSpacing(v: Option[String]) = copy(spacing = v) + override def withDataStore(ds: TypedMap): Button = copy(dataStore = ds) /** https://chakra-ui.com/docs/components/button */ @@ -56,9 +61,11 @@ case class ButtonGroup( border: Option[String] = None, borderColor: Option[String] = None, style: Map[String, Any] = Map.empty, - children: Seq[UiElement] = Nil -) extends ChakraElement[ButtonGroup] - with HasChildren[ButtonGroup]: + children: Seq[UiElement] = Nil, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement + with HasChildren: + type This = ButtonGroup override def withChildren(cn: UiElement*) = copy(children = cn) override def withStyle(v: Map[String, Any]): ButtonGroup = copy(style = v) def withKey(v: String) = copy(key = v) @@ -69,6 +76,7 @@ case class ButtonGroup( def withHeight(v: Option[String]) = copy(height = v) def withBorder(v: Option[String]) = copy(border = v) def withBorderColor(v: Option[String]) = copy(borderColor = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** https://chakra-ui.com/docs/components/box */ @@ -81,18 +89,21 @@ case class Box( color: String = "", style: Map[String, Any] = Map.empty, as: Option[String] = None, - children: Seq[UiElement] = Nil -) extends ChakraElement[Box] - with HasChildren[Box]: - override def withChildren(cn: UiElement*) = copy(children = cn) - override def withStyle(v: Map[String, Any]) = copy(style = v) - def withKey(v: String) = copy(key = v) - def withText(v: String) = copy(text = v) - def withBg(v: String) = copy(bg = v) - def withW(v: String) = copy(w = v) - def withP(v: Int) = copy(p = v) - def withColor(v: String) = copy(color = v) - def withAs(v: Option[String]) = copy(as = v) + children: Seq[UiElement] = Nil, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement + with HasChildren: + type This = Box + override def withChildren(cn: UiElement*): Box = copy(children = cn) + override def withStyle(v: Map[String, Any]): Box = copy(style = v) + def withKey(v: String): Box = copy(key = v) + def withText(v: String): Box = copy(text = v) + def withBg(v: String): Box = copy(bg = v) + def withW(v: String): Box = copy(w = v) + def withP(v: Int): Box = copy(p = v) + def withColor(v: String): Box = copy(color = v) + def withAs(v: Option[String]): Box = copy(as = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** https://chakra-ui.com/docs/components/stack */ @@ -101,28 +112,38 @@ case class HStack( spacing: Option[String] = None, align: Option[String] = None, style: Map[String, Any] = Map.empty, - children: Seq[UiElement] = Nil -) extends ChakraElement[HStack] - with HasChildren[HStack]: + children: Seq[UiElement] = Nil, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement + with HasChildren: + type This = HStack override def withChildren(cn: UiElement*) = copy(children = cn) override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withSpacing(v: Option[String]) = copy(spacing = v) + def withSpacing(v: String) = copy(spacing = Some(v)) def withAlign(v: Option[String]) = copy(align = v) + def withAlign(v: String) = copy(align = Some(v)) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) case class VStack( key: String = Keys.nextKey, spacing: Option[String] = None, align: Option[String] = None, style: Map[String, Any] = Map.empty, - children: Seq[UiElement] = Nil -) extends ChakraElement[VStack] - with HasChildren[VStack]: + children: Seq[UiElement] = Nil, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement + with HasChildren: + type This = VStack override def withChildren(cn: UiElement*) = copy(children = cn) override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withSpacing(v: Option[String]) = copy(spacing = v) + def withSpacing(v: String) = copy(spacing = Some(v)) def withAlign(v: Option[String]) = copy(align = v) + def withAlign(v: String) = copy(align = Some(v)) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) case class SimpleGrid( key: String = Keys.nextKey, @@ -131,9 +152,11 @@ case class SimpleGrid( spacingY: Option[String] = None, columns: Int = 2, children: Seq[UiElement] = Nil, - style: Map[String, Any] = Map.empty -) extends ChakraElement[SimpleGrid] - with HasChildren[SimpleGrid]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement + with HasChildren: + type This = SimpleGrid override def withChildren(cn: UiElement*) = copy(children = cn) override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) @@ -141,38 +164,43 @@ case class SimpleGrid( def withSpacingX(v: Option[String]) = copy(spacingX = v) def withSpacingY(v: Option[String]) = copy(spacingY = v) def withColumns(v: Int) = copy(columns = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** https://chakra-ui.com/docs/components/editable */ case class Editable( key: String = Keys.nextKey, defaultValue: String = "", - value: String = "", style: Map[String, Any] = Map.empty, - children: Seq[UiElement] = Nil -) extends ChakraElement[Editable] - with HasEventHandler - with HasChildren[Editable] - with OnChangeEventHandler.CanHandleOnChangeEvent[Editable]: - override def defaultEventHandler(session: ConnectedSession): OnChangeEventHandler = - newValue => session.modified(copy(value = newValue)) - override def withChildren(cn: UiElement*) = copy(children = cn) - override def withStyle(v: Map[String, Any]) = copy(style = v) - def withKey(v: String) = copy(key = v) - def withDefaultValue(v: String) = copy(defaultValue = v) - def withValue(v: String) = copy(value = v) + children: Seq[UiElement] = Nil, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement + with HasChildren + with OnChangeEventHandler.CanHandleOnChangeEvent: + type This = Editable + override def withChildren(cn: UiElement*) = copy(children = cn) + override def withStyle(v: Map[String, Any]) = copy(style = v) + def withKey(v: String) = copy(key = v) + def withDefaultValue(v: String) = copy(defaultValue = v) + override def withDataStore(ds: TypedMap): Editable = copy(dataStore = ds) -case class EditablePreview(key: String = Keys.nextKey, style: Map[String, Any] = Map.empty) extends ChakraElement[EditablePreview]: +case class EditablePreview(key: String = Keys.nextKey, style: Map[String, Any] = Map.empty, dataStore: TypedMap = TypedMap.Empty) extends ChakraElement: + type This = EditablePreview override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) -case class EditableInput(key: String = Keys.nextKey, style: Map[String, Any] = Map.empty) extends ChakraElement[EditableInput]: +case class EditableInput(key: String = Keys.nextKey, style: Map[String, Any] = Map.empty, dataStore: TypedMap = TypedMap.Empty) extends ChakraElement: + type This = EditableInput override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) -case class EditableTextarea(key: String = Keys.nextKey, style: Map[String, Any] = Map.empty) extends ChakraElement[EditableTextarea]: +case class EditableTextarea(key: String = Keys.nextKey, style: Map[String, Any] = Map.empty, dataStore: TypedMap = TypedMap.Empty) extends ChakraElement: + type This = EditableTextarea override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** https://chakra-ui.com/docs/components/form-control */ @@ -180,13 +208,16 @@ case class FormControl( key: String = Keys.nextKey, as: String = "", style: Map[String, Any] = Map.empty, - children: Seq[UiElement] = Nil -) extends ChakraElement[FormControl] - with HasChildren[FormControl]: + children: Seq[UiElement] = Nil, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement + with HasChildren: + type This = FormControl override def withChildren(cn: UiElement*) = copy(children = cn) override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withAs(v: String) = copy(as = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** https://chakra-ui.com/docs/components/form-control */ @@ -194,13 +225,16 @@ case class FormLabel( key: String = Keys.nextKey, text: String, style: Map[String, Any] = Map.empty, - children: Seq[UiElement] = Nil -) extends ChakraElement[FormLabel] - with HasChildren[FormLabel]: + children: Seq[UiElement] = Nil, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement + with HasChildren: + type This = FormLabel override def withChildren(cn: UiElement*) = copy(children = cn) override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withText(v: String) = copy(text = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** https://chakra-ui.com/docs/components/form-control */ @@ -208,13 +242,16 @@ case class FormHelperText( key: String = Keys.nextKey, text: String, style: Map[String, Any] = Map.empty, - children: Seq[UiElement] = Nil -) extends ChakraElement[FormHelperText] - with HasChildren[FormHelperText]: + children: Seq[UiElement] = Nil, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement + with HasChildren: + type This = FormHelperText override def withChildren(cn: UiElement*) = copy(children = cn) override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withText(v: String) = copy(text = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** https://chakra-ui.com/docs/components/input */ @@ -224,55 +261,65 @@ case class Input( placeholder: String = "", size: String = "md", variant: Option[String] = None, - value: String = "", - style: Map[String, Any] = Map.empty -) extends ChakraElement[Input] - with HasEventHandler - with OnChangeEventHandler.CanHandleOnChangeEvent[Input]: - override def defaultEventHandler(session: ConnectedSession): OnChangeEventHandler = newValue => session.modified(copy(value = newValue)) - override def withStyle(v: Map[String, Any]) = copy(style = v) - def withKey(v: String) = copy(key = v) - def withType(v: String) = copy(`type` = v) - def withPlaceholder(v: String) = copy(placeholder = v) - def withSize(v: String) = copy(size = v) - def withVariant(v: Option[String]) = copy(variant = v) - def withValue(v: String) = copy(value = v) + defaultValue: String = "", + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement + with OnChangeEventHandler.CanHandleOnChangeEvent: + type This = Input + override def withStyle(v: Map[String, Any]): Input = copy(style = v) + def withKey(v: String): Input = copy(key = v) + def withType(v: String): Input = copy(`type` = v) + def withPlaceholder(v: String): Input = copy(placeholder = v) + def withSize(v: String): Input = copy(size = v) + def withVariant(v: Option[String]): Input = copy(variant = v) + def withDefaultValue(v: String): Input = copy(defaultValue = v) + override def withDataStore(ds: TypedMap): Input = copy(dataStore = ds) case class InputGroup( key: String = Keys.nextKey, size: String = "md", style: Map[String, Any] = Map.empty, - children: Seq[UiElement] = Nil -) extends ChakraElement[InputGroup] - with HasChildren[InputGroup]: + children: Seq[UiElement] = Nil, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement + with HasChildren: + type This = InputGroup override def withChildren(cn: UiElement*) = copy(children = cn) override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withSize(v: String) = copy(size = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) case class InputLeftAddon( key: String = Keys.nextKey, text: String = "", style: Map[String, Any] = Map.empty, - children: Seq[UiElement] = Nil -) extends ChakraElement[InputLeftAddon] - with HasChildren[InputLeftAddon]: + children: Seq[UiElement] = Nil, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement + with HasChildren: + type This = InputLeftAddon override def withChildren(cn: UiElement*) = copy(children = cn) override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withText(v: String) = copy(text = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) case class InputRightAddon( key: String = Keys.nextKey, text: String = "", style: Map[String, Any] = Map.empty, - children: Seq[UiElement] = Nil -) extends ChakraElement[InputRightAddon] - with HasChildren[InputRightAddon]: + children: Seq[UiElement] = Nil, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement + with HasChildren: + type This = InputRightAddon override def withChildren(cn: UiElement*) = copy(children = cn) override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withText(v: String) = copy(text = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** https://chakra-ui.com/docs/components/checkbox */ @@ -282,17 +329,16 @@ case class Checkbox( defaultChecked: Boolean = false, isDisabled: Boolean = false, style: Map[String, Any] = Map.empty, - checkedV: Option[Boolean] = None -) extends ChakraElement[Checkbox] - with HasEventHandler - with OnChangeBooleanEventHandler.CanHandleOnChangeEvent[Checkbox]: - def checked: Boolean = checkedV.getOrElse(defaultChecked) - override def defaultEventHandler(session: ConnectedSession): OnChangeEventHandler = newValue => session.modified(copy(checkedV = Some(newValue.toBoolean))) - override def withStyle(v: Map[String, Any]) = copy(style = v) - def withKey(v: String) = copy(key = v) - def withText(v: String) = copy(text = v) - def withDefaultChecked(v: Boolean) = copy(defaultChecked = v) - def withIsDisabled(v: Boolean) = copy(isDisabled = v) + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement + with OnChangeBooleanEventHandler.CanHandleOnChangeEvent: + type This = Checkbox + override def withStyle(v: Map[String, Any]) = copy(style = v) + def withKey(v: String) = copy(key = v) + def withText(v: String) = copy(text = v) + def withDefaultChecked(v: Boolean) = copy(defaultChecked = v) + def withIsDisabled(v: Boolean) = copy(isDisabled = v) + override def withDataStore(ds: TypedMap): Checkbox = copy(dataStore = ds) /** https://chakra-ui.com/docs/components/radio */ @@ -301,30 +347,32 @@ case class Radio( value: String, text: String = "", colorScheme: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[Radio]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = Radio override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withValue(v: String) = copy(value = v) def withText(v: String) = copy(text = v) def withColorScheme(v: Option[String]) = copy(colorScheme = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) case class RadioGroup( key: String = Keys.nextKey, defaultValue: String = "", - valueV: Option[String] = None, - style: Map[String, Any] = Map.empty, - children: Seq[UiElement] = Nil -) extends ChakraElement[RadioGroup] - with HasEventHandler - with HasChildren[RadioGroup] - with OnChangeEventHandler.CanHandleOnChangeEvent[RadioGroup]: - override def defaultEventHandler(session: ConnectedSession): OnChangeEventHandler = newValue => session.modified(copy(valueV = Some(newValue))) - override def withChildren(cn: UiElement*) = copy(children = cn) - override def withStyle(v: Map[String, Any]) = copy(style = v) - def value: String = valueV.getOrElse(defaultValue) - def withKey(v: String) = copy(key = v) - def withDefaultValue(v: String) = copy(defaultValue = v) + style: Map[String, Any] = Map.empty, + children: Seq[UiElement] = Nil, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement + with HasChildren + with OnChangeEventHandler.CanHandleOnChangeEvent: + type This = RadioGroup + override def withChildren(cn: UiElement*) = copy(children = cn) + override def withStyle(v: Map[String, Any]) = copy(style = v) + def withKey(v: String) = copy(key = v) + def withDefaultValue(v: String) = copy(defaultValue = v) + override def withDataStore(ds: TypedMap): RadioGroup = copy(dataStore = ds) case class Center( key: String = Keys.nextKey, @@ -334,9 +382,11 @@ case class Center( w: Option[String] = None, h: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[Center] - with HasChildren[Center]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement + with HasChildren: + type This = Center override def withChildren(cn: UiElement*) = copy(children = cn) override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) @@ -345,6 +395,7 @@ case class Center( def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) case class Circle( key: String = Keys.nextKey, @@ -354,9 +405,11 @@ case class Circle( w: Option[String] = None, h: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[Circle] - with HasChildren[Circle]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement + with HasChildren: + type This = Circle override def withChildren(cn: UiElement*) = copy(children = cn) override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) @@ -365,6 +418,7 @@ case class Circle( def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) case class Square( key: String = Keys.nextKey, @@ -374,9 +428,11 @@ case class Square( w: Option[String] = None, h: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[Square] - with HasChildren[Square]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement + with HasChildren: + type This = Square override def withChildren(cn: UiElement*) = copy(children = cn) override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) @@ -385,6 +441,7 @@ case class Square( def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -394,14 +451,17 @@ case class AddIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[AddIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = AddIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -411,14 +471,17 @@ case class ArrowBackIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[ArrowBackIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = ArrowBackIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -428,14 +491,17 @@ case class ArrowDownIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[ArrowDownIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = ArrowDownIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -445,14 +511,17 @@ case class ArrowForwardIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[ArrowForwardIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = ArrowForwardIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -462,14 +531,17 @@ case class ArrowLeftIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[ArrowLeftIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = ArrowLeftIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -479,14 +551,17 @@ case class ArrowRightIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[ArrowRightIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = ArrowRightIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -496,14 +571,17 @@ case class ArrowUpIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[ArrowUpIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = ArrowUpIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -513,14 +591,17 @@ case class ArrowUpDownIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[ArrowUpDownIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = ArrowUpDownIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -530,14 +611,17 @@ case class AtSignIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[AtSignIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = AtSignIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -547,14 +631,17 @@ case class AttachmentIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[AttachmentIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = AttachmentIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -564,14 +651,17 @@ case class BellIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[BellIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = BellIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -581,14 +671,17 @@ case class CalendarIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[CalendarIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = CalendarIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -598,14 +691,17 @@ case class ChatIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[ChatIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = ChatIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -615,14 +711,17 @@ case class CheckIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[CheckIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = CheckIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -632,14 +731,17 @@ case class CheckCircleIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[CheckCircleIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = CheckCircleIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -649,14 +751,17 @@ case class ChevronDownIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[ChevronDownIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = ChevronDownIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -666,14 +771,17 @@ case class ChevronLeftIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[ChevronLeftIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = ChevronLeftIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -683,14 +791,17 @@ case class ChevronRightIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[ChevronRightIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = ChevronRightIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -700,14 +811,17 @@ case class ChevronUpIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[ChevronUpIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = ChevronUpIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -717,14 +831,17 @@ case class CloseIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[CloseIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = CloseIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -734,14 +851,17 @@ case class CopyIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[CopyIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = CopyIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -751,14 +871,17 @@ case class DeleteIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[DeleteIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = DeleteIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -768,14 +891,17 @@ case class DownloadIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[DownloadIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = DownloadIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -785,14 +911,17 @@ case class DragHandleIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[DragHandleIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = DragHandleIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -802,14 +931,17 @@ case class EditIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[EditIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = EditIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -819,14 +951,17 @@ case class EmailIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[EmailIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = EmailIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -834,16 +969,21 @@ case class ExternalLinkIcon( key: String = Keys.nextKey, w: Option[String] = None, h: Option[String] = None, + mx: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[ExternalLinkIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = ExternalLinkIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + def withMx(v: Option[String]) = copy(mx = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -853,14 +993,17 @@ case class HamburgerIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[HamburgerIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = HamburgerIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -870,14 +1013,17 @@ case class InfoIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[InfoIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = InfoIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -887,14 +1033,17 @@ case class InfoOutlineIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[InfoOutlineIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = InfoOutlineIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -904,14 +1053,17 @@ case class LinkIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[LinkIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = LinkIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -921,14 +1073,17 @@ case class LockIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[LockIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = LockIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -938,14 +1093,17 @@ case class MinusIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[MinusIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = MinusIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -955,14 +1113,17 @@ case class MoonIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[MoonIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = MoonIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -972,14 +1133,17 @@ case class NotAllowedIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[NotAllowedIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = NotAllowedIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -989,14 +1153,17 @@ case class PhoneIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[PhoneIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = PhoneIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -1006,14 +1173,17 @@ case class PlusSquareIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[PlusSquareIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = PlusSquareIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -1023,14 +1193,17 @@ case class QuestionIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[QuestionIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = QuestionIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -1040,14 +1213,17 @@ case class QuestionOutlineIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[QuestionOutlineIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = QuestionOutlineIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -1057,14 +1233,17 @@ case class RepeatIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[RepeatIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = RepeatIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -1074,14 +1253,17 @@ case class RepeatClockIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[RepeatClockIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = RepeatClockIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -1091,14 +1273,17 @@ case class SearchIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[SearchIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = SearchIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -1108,14 +1293,17 @@ case class Search2Icon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[Search2Icon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = Search2Icon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -1125,14 +1313,17 @@ case class SettingsIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[SettingsIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = SettingsIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -1142,14 +1333,17 @@ case class SmallAddIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[SmallAddIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = SmallAddIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -1159,14 +1353,17 @@ case class SmallCloseIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[SmallCloseIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = SmallCloseIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -1176,14 +1373,17 @@ case class SpinnerIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[SpinnerIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = SpinnerIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -1193,14 +1393,17 @@ case class StarIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[StarIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = StarIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -1210,14 +1413,17 @@ case class SunIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[SunIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = SunIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -1227,14 +1433,17 @@ case class TimeIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[TimeIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = TimeIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -1244,14 +1453,17 @@ case class TriangleDownIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[TriangleDownIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = TriangleDownIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -1261,14 +1473,17 @@ case class TriangleUpIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[TriangleUpIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = TriangleUpIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -1278,14 +1493,17 @@ case class UnlockIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[UnlockIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = UnlockIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -1295,14 +1513,17 @@ case class UpDownIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[UpDownIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = UpDownIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -1312,14 +1533,17 @@ case class ViewIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[ViewIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = ViewIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -1329,14 +1553,17 @@ case class ViewOffIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[ViewOffIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = ViewOffIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -1346,14 +1573,17 @@ case class WarningIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[WarningIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = WarningIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon */ @@ -1363,14 +1593,17 @@ case class WarningTwoIcon( h: Option[String] = None, boxSize: Option[String] = None, color: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[WarningTwoIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = WarningTwoIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withW(v: Option[String]) = copy(w = v) def withH(v: Option[String]) = copy(h = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** https://chakra-ui.com/docs/components/textarea */ @@ -1380,19 +1613,20 @@ case class Textarea( placeholder: String = "", size: String = "md", variant: Option[String] = None, - value: String = "", - style: Map[String, Any] = Map.empty -) extends ChakraElement[Textarea] - with HasEventHandler - with OnChangeEventHandler.CanHandleOnChangeEvent[Textarea]: - override def defaultEventHandler(session: ConnectedSession): OnChangeEventHandler = newValue => session.modified(copy(value = newValue)) - override def withStyle(v: Map[String, Any]) = copy(style = v) - def withKey(v: String) = copy(key = v) - def withType(v: String) = copy(`type` = v) - def withPlaceholder(v: String) = copy(placeholder = v) - def withSize(v: String) = copy(size = v) - def withVariant(v: Option[String]) = copy(variant = v) - def withValue(v: String) = copy(value = v) + defaultValue: String = "", + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement + with OnChangeEventHandler.CanHandleOnChangeEvent: + type This = Textarea + override def withStyle(v: Map[String, Any]) = copy(style = v) + def withKey(v: String) = copy(key = v) + def withType(v: String) = copy(`type` = v) + def withPlaceholder(v: String) = copy(placeholder = v) + def withSize(v: String) = copy(size = v) + def withVariant(v: Option[String]) = copy(variant = v) + def withDefaultValue(v: String) = copy(defaultValue = v) + override def withDataStore(ds: TypedMap): Textarea = copy(dataStore = ds) /** https://chakra-ui.com/docs/components/switch */ @@ -1402,59 +1636,63 @@ case class Switch( defaultChecked: Boolean = false, isDisabled: Boolean = false, style: Map[String, Any] = Map.empty, - checkedV: Option[Boolean] = None -) extends ChakraElement[Switch] - with HasEventHandler - with OnChangeBooleanEventHandler.CanHandleOnChangeEvent[Switch]: - def checked: Boolean = checkedV.getOrElse(defaultChecked) - override def defaultEventHandler(session: ConnectedSession): OnChangeEventHandler = newValue => session.modified(copy(checkedV = Some(newValue.toBoolean))) - override def withStyle(v: Map[String, Any]) = copy(style = v) - def withKey(v: String) = copy(key = v) - def withText(v: String) = copy(text = v) - def withDefaultChecked(v: Boolean) = copy(defaultChecked = v) - def withIsDisabled(v: Boolean) = copy(isDisabled = v) + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement + with OnChangeBooleanEventHandler.CanHandleOnChangeEvent: + type This = Switch + override def withStyle(v: Map[String, Any]) = copy(style = v) + def withKey(v: String) = copy(key = v) + def withText(v: String) = copy(text = v) + def withDefaultChecked(v: Boolean) = copy(defaultChecked = v) + def withIsDisabled(v: Boolean) = copy(isDisabled = v) + override def withDataStore(ds: TypedMap): Switch = copy(dataStore = ds) /** https://chakra-ui.com/docs/components/select */ case class Select( key: String = Keys.nextKey, placeholder: String = "", - value: String = "", + defaultValue: String = "", bg: Option[String] = None, color: Option[String] = None, borderColor: Option[String] = None, style: Map[String, Any] = Map.empty, - children: Seq[UiElement] = Nil -) extends ChakraElement[Select] - with HasEventHandler - with HasChildren[Select] - with OnChangeEventHandler.CanHandleOnChangeEvent[Select]: - override def defaultEventHandler(session: ConnectedSession): OnChangeEventHandler = newValue => session.modified(copy(value = newValue)) - override def withStyle(v: Map[String, Any]) = copy(style = v) - override def withChildren(cn: UiElement*) = copy(children = cn) - def withKey(v: String) = copy(key = v) - def withPlaceholder(v: String) = copy(placeholder = v) - def withValue(v: String) = copy(value = v) - def withBg(v: Option[String]) = copy(bg = v) - def withColor(v: Option[String]) = copy(color = v) - def withBorderColor(v: Option[String]) = copy(borderColor = v) + children: Seq[UiElement] = Nil, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement + with HasChildren + with OnChangeEventHandler.CanHandleOnChangeEvent: + type This = Select + override def withStyle(v: Map[String, Any]) = copy(style = v) + override def withChildren(cn: UiElement*) = copy(children = cn) + def withKey(v: String) = copy(key = v) + def withPlaceholder(v: String) = copy(placeholder = v) + def withDefaultValue(v: String) = copy(defaultValue = v) + def withBg(v: Option[String]) = copy(bg = v) + def withColor(v: Option[String]) = copy(color = v) + def withBorderColor(v: Option[String]) = copy(borderColor = v) + override def withDataStore(ds: TypedMap): Select = copy(dataStore = ds) case class Option_( key: String = Keys.nextKey, value: String, text: String = "", - style: Map[String, Any] = Map.empty -) extends ChakraElement[Option_]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = Option_ override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withValue(v: String) = copy(value = v) def withText(v: String) = copy(text = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** https://chakra-ui.com/docs/components/table/usage */ -case class TableContainer(key: String = Keys.nextKey, children: Seq[UiElement] = Nil, style: Map[String, Any] = Map.empty) - extends ChakraElement[TableContainer] - with HasChildren[TableContainer]: +case class TableContainer(key: String = Keys.nextKey, children: Seq[UiElement] = Nil, style: Map[String, Any] = Map.empty, dataStore: TypedMap = TypedMap.Empty) + extends ChakraElement + with HasChildren: + type This = TableContainer override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withRowStringData(data: Seq[Seq[String]]): TableContainer = withRowData(data.map(_.map(c => Text(text = c)))) @@ -1474,6 +1712,7 @@ case class TableContainer(key: String = Keys.nextKey, children: Seq[UiElement] = this override def withChildren(cn: UiElement*) = copy(children = cn) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) case class Table( key: String = Keys.nextKey, @@ -1481,88 +1720,114 @@ case class Table( size: String = "md", colorScheme: Option[String] = None, style: Map[String, Any] = Map.empty, - children: Seq[UiElement] = Nil -) extends ChakraElement[Table] - with HasChildren[Table]: + children: Seq[UiElement] = Nil, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement + with HasChildren: + type This = Table override def withChildren(cn: UiElement*) = copy(children = cn) override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withVariant(v: String) = copy(variant = v) def withSize(v: String) = copy(size = v) def withColorScheme(v: Option[String]) = copy(colorScheme = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) -case class TableCaption(key: String = Keys.nextKey, text: String = "", style: Map[String, Any] = Map.empty) extends ChakraElement[TableCaption]: +case class TableCaption(key: String = Keys.nextKey, text: String = "", style: Map[String, Any] = Map.empty, dataStore: TypedMap = TypedMap.Empty) + extends ChakraElement: + type This = TableCaption override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withText(v: String) = copy(text = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) -case class Thead(key: String = Keys.nextKey, children: Seq[UiElement] = Nil, style: Map[String, Any] = Map.empty) - extends ChakraElement[Thead] - with HasChildren[Thead]: +case class Thead(key: String = Keys.nextKey, children: Seq[UiElement] = Nil, style: Map[String, Any] = Map.empty, dataStore: TypedMap = TypedMap.Empty) + extends ChakraElement + with HasChildren: + type This = Thead override def withChildren(cn: UiElement*) = copy(children = cn) override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) -case class Tbody(key: String = Keys.nextKey, children: Seq[UiElement] = Nil, style: Map[String, Any] = Map.empty) - extends ChakraElement[Tbody] - with HasChildren[Tbody]: +case class Tbody(key: String = Keys.nextKey, children: Seq[UiElement] = Nil, style: Map[String, Any] = Map.empty, dataStore: TypedMap = TypedMap.Empty) + extends ChakraElement + with HasChildren: + type This = Tbody override def withChildren(cn: UiElement*) = copy(children = cn) override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) -case class Tfoot(key: String = Keys.nextKey, children: Seq[UiElement] = Nil, style: Map[String, Any] = Map.empty) - extends ChakraElement[Tfoot] - with HasChildren[Tfoot]: +case class Tfoot(key: String = Keys.nextKey, children: Seq[UiElement] = Nil, style: Map[String, Any] = Map.empty, dataStore: TypedMap = TypedMap.Empty) + extends ChakraElement + with HasChildren: + type This = Tfoot override def withChildren(cn: UiElement*) = copy(children = cn) override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) case class Tr( key: String = Keys.nextKey, children: Seq[UiElement] = Nil, - style: Map[String, Any] = Map.empty -) extends ChakraElement[Tr] - with HasChildren[Tr]: + bg: Option[String] = None, + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement + with HasChildren: + type This = Tr override def withChildren(cn: UiElement*) = copy(children = cn) override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) + def withBg(v: Option[String]) = copy(bg = v) + def withBg(v: String) = copy(bg = Some(v)) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) case class Th( key: String = Keys.nextKey, text: String = "", isNumeric: Boolean = false, children: Seq[UiElement] = Nil, - style: Map[String, Any] = Map.empty -) extends ChakraElement[Th] - with HasChildren[Th]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement + with HasChildren: + type This = Th override def withChildren(cn: UiElement*) = copy(children = cn) override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withText(v: String) = copy(text = v) def withIsNumeric(v: Boolean) = copy(isNumeric = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) case class Td( key: String = Keys.nextKey, text: String = "", isNumeric: Boolean = false, style: Map[String, Any] = Map.empty, - children: Seq[UiElement] = Nil -) extends ChakraElement[Td] - with HasChildren[Td]: + children: Seq[UiElement] = Nil, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement + with HasChildren: + type This = Td override def withChildren(cn: UiElement*) = copy(children = cn) override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withText(v: String) = copy(text = v) def withIsNumeric(v: Boolean) = copy(isNumeric = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** https://chakra-ui.com/docs/components/menu/usage */ -case class Menu(key: String = Keys.nextKey, style: Map[String, Any] = Map.empty, children: Seq[UiElement] = Nil) - extends ChakraElement[Menu] - with HasChildren[Menu]: +case class Menu(key: String = Keys.nextKey, style: Map[String, Any] = Map.empty, children: Seq[UiElement] = Nil, dataStore: TypedMap = TypedMap.Empty) + extends ChakraElement + with HasChildren: + type This = Menu override def withChildren(cn: UiElement*) = copy(children = cn) override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) case class MenuButton( key: String = Keys.nextKey, @@ -1570,39 +1835,49 @@ case class MenuButton( size: Option[String] = None, colorScheme: Option[String] = None, style: Map[String, Any] = Map.empty, - children: Seq[UiElement] = Nil -) extends ChakraElement[MenuButton] - with HasChildren[MenuButton]: + children: Seq[UiElement] = Nil, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement + with HasChildren: + type This = MenuButton override def withChildren(cn: UiElement*) = copy(children = cn) override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withText(v: String) = copy(text = v) def withSize(v: Option[String]) = copy(size = v) def withColorScheme(v: Option[String]) = copy(colorScheme = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) -case class MenuList(key: String = Keys.nextKey, style: Map[String, Any] = Map.empty, children: Seq[UiElement] = Nil) - extends ChakraElement[MenuList] - with HasChildren[MenuList]: +case class MenuList(key: String = Keys.nextKey, style: Map[String, Any] = Map.empty, children: Seq[UiElement] = Nil, dataStore: TypedMap = TypedMap.Empty) + extends ChakraElement + with HasChildren: + type This = MenuList override def withChildren(cn: UiElement*) = copy(children = cn) override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) case class MenuItem( key: String = Keys.nextKey, style: Map[String, Any] = Map.empty, text: String = "", - children: Seq[UiElement] = Nil -) extends ChakraElement[MenuItem] - with HasChildren[MenuItem] - with OnClickEventHandler.CanHandleOnClickEvent[MenuItem]: - override def withChildren(cn: UiElement*) = copy(children = cn) - override def withStyle(v: Map[String, Any]) = copy(style = v) - def withKey(v: String) = copy(key = v) - def withText(v: String) = copy(text = v) + children: Seq[UiElement] = Nil, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement + with HasChildren + with OnClickEventHandler.CanHandleOnClickEvent: + type This = MenuItem + override def withChildren(cn: UiElement*) = copy(children = cn) + override def withStyle(v: Map[String, Any]) = copy(style = v) + def withKey(v: String) = copy(key = v) + def withText(v: String) = copy(text = v) + override def withDataStore(ds: TypedMap): MenuItem = copy(dataStore = ds) -case class MenuDivider(key: String = Keys.nextKey, style: Map[String, Any] = Map.empty) extends ChakraElement[MenuDivider]: +case class MenuDivider(key: String = Keys.nextKey, style: Map[String, Any] = Map.empty, dataStore: TypedMap = TypedMap.Empty) extends ChakraElement: + type This = MenuDivider override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) case class Badge( key: String = Keys.nextKey, @@ -1611,9 +1886,11 @@ case class Badge( variant: Option[String] = None, size: String = "md", children: Seq[UiElement] = Nil, - style: Map[String, Any] = Map.empty -) extends ChakraElement[Badge] - with HasChildren[Badge]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement + with HasChildren: + type This = Badge override def withChildren(cn: UiElement*) = copy(children = cn) override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) @@ -1621,6 +1898,7 @@ case class Badge( def withColorScheme(v: Option[String]) = copy(colorScheme = v) def withVariant(v: Option[String]) = copy(variant = v) def withSize(v: String) = copy(size = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** https://chakra-ui.com/docs/components/image/usage * @@ -1634,14 +1912,17 @@ case class Image( alt: String = "", boxSize: Option[String] = None, borderRadius: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[Image]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = Image override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withSrc(v: String) = copy(src = v) def withAlt(v: String) = copy(alt = v) def withBoxSize(v: Option[String]) = copy(boxSize = v) def withBorderRadius(v: Option[String]) = copy(borderRadius = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** https://chakra-ui.com/docs/components/text */ @@ -1655,8 +1936,10 @@ case class Text( align: Option[String] = None, casing: Option[String] = None, decoration: Option[String] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[Text]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = Text override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withText(v: String) = copy(text = v) @@ -1667,93 +1950,118 @@ case class Text( def withAlign(v: Option[String]) = copy(align = v) def withCasing(v: Option[String]) = copy(casing = v) def withDecoration(v: Option[String]) = copy(decoration = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) case class Code( key: String = Keys.nextKey, text: String = "", colorScheme: Option[String] = None, style: Map[String, Any] = Map.empty, - children: Seq[UiElement] = Nil -) extends ChakraElement[Code] - with HasChildren[Code]: + children: Seq[UiElement] = Nil, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement + with HasChildren: + type This = Code override def withChildren(cn: UiElement*) = copy(children = cn) override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withText(v: String) = copy(text = v) def withColorScheme(v: Option[String]) = copy(colorScheme = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) case class UnorderedList( key: String = Keys.nextKey, spacing: Option[String] = None, style: Map[String, Any] = Map.empty, - children: Seq[UiElement] = Nil -) extends ChakraElement[UnorderedList] - with HasChildren[UnorderedList]: + children: Seq[UiElement] = Nil, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement + with HasChildren: + type This = UnorderedList override def withChildren(cn: UiElement*) = copy(children = cn) override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withSpacing(v: Option[String]) = copy(spacing = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) case class OrderedList( key: String = Keys.nextKey, spacing: Option[String] = None, style: Map[String, Any] = Map.empty, - children: Seq[UiElement] = Nil -) extends ChakraElement[OrderedList] - with HasChildren[OrderedList]: + children: Seq[UiElement] = Nil, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement + with HasChildren: + type This = OrderedList override def withChildren(cn: UiElement*) = copy(children = cn) override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withSpacing(v: Option[String]) = copy(spacing = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) case class ListItem( key: String = Keys.nextKey, text: String = "", style: Map[String, Any] = Map.empty, - children: Seq[UiElement] = Nil -) extends ChakraElement[ListItem] - with HasChildren[ListItem]: + children: Seq[UiElement] = Nil, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement + with HasChildren: + type This = ListItem def withText(v: String) = copy(text = v) override def withChildren(cn: UiElement*) = copy(children = cn) override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) case class Alert( key: String = Keys.nextKey, status: String = "error", // error, success, warning, info style: Map[String, Any] = Map.empty, - children: Seq[UiElement] = Nil -) extends ChakraElement[Alert] - with HasChildren[Alert]: + children: Seq[UiElement] = Nil, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement + with HasChildren: + type This = Alert override def withChildren(cn: UiElement*) = copy(children = cn) override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withStatus(v: String) = copy(status = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) case class AlertIcon( key: String = Keys.nextKey, - style: Map[String, Any] = Map.empty -) extends ChakraElement[AlertIcon]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = AlertIcon override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) case class AlertTitle( key: String = Keys.nextKey, text: String = "Alert!", - style: Map[String, Any] = Map.empty -) extends ChakraElement[AlertTitle]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = AlertTitle override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withText(v: String) = copy(text = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) case class AlertDescription( key: String = Keys.nextKey, text: String = "Something happened!", - style: Map[String, Any] = Map.empty -) extends ChakraElement[AlertDescription]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = AlertDescription override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withText(v: String) = copy(text = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** https://chakra-ui.com/docs/components/progress */ @@ -1764,8 +2072,10 @@ case class Progress( size: Option[String] = None, hasStripe: Option[Boolean] = None, isIndeterminate: Option[Boolean] = None, - style: Map[String, Any] = Map.empty -) extends ChakraElement[Progress]: + style: Map[String, Any] = Map.empty, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement: + type This = Progress override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withColorScheme(v: Option[String]) = copy(colorScheme = v) @@ -1773,6 +2083,7 @@ case class Progress( def withValue(v: Int) = copy(value = v) def withHasStripe(v: Option[Boolean]) = copy(hasStripe = v) def withIsIndeterminate(v: Option[Boolean]) = copy(isIndeterminate = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) case class Tooltip( key: String = Keys.nextKey, @@ -1782,9 +2093,11 @@ case class Tooltip( hasArrow: Option[Boolean] = None, fontSize: Option[String] = None, style: Map[String, Any] = Map.empty, - children: Seq[UiElement] = Seq(Text("use tooltip.withContent() to set this")) -) extends ChakraElement[Tooltip] - with HasChildren[Tooltip]: + children: Seq[UiElement] = Seq(Text("use tooltip.withContent() to set this")), + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement + with HasChildren: + type This = Tooltip override def withStyle(v: Map[String, Any]) = copy(style = v) def withContent(cn: UiElement) = withChildren(cn) def withKey(v: String) = copy(key = v) @@ -1795,6 +2108,7 @@ case class Tooltip( override def noChildren = copy(children = Nil) override def withChildren(cn: UiElement*): Tooltip = if cn.size != 1 then throw new IllegalArgumentException("tooltip takes 1 only child") else copy(children = cn) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** see https://chakra-ui.com/docs/components/tabs */ @@ -1807,9 +2121,11 @@ case class Tabs( size: Option[String] = None, isFitted: Option[Boolean] = None, style: Map[String, Any] = Map.empty, - children: Seq[UiElement] = Nil -) extends ChakraElement[Tabs] - with HasChildren[Tabs]: + children: Seq[UiElement] = Nil, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement + with HasChildren: + type This = Tabs def withKey(v: String) = copy(key = v) override def withChildren(cn: UiElement*) = copy(children = cn) override def withStyle(v: Map[String, Any]) = copy(style = v) @@ -1818,18 +2134,22 @@ case class Tabs( def withSize(v: Option[String]) = copy(size = v) def withAlign(v: Option[String]) = copy(align = v) def withIsFitted(v: Option[Boolean]) = copy(isFitted = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** see https://chakra-ui.com/docs/components/tabs */ case class TabList( key: String = Keys.nextKey, style: Map[String, Any] = Map.empty, - children: Seq[UiElement] = Nil -) extends ChakraElement[TabList] - with HasChildren[TabList]: + children: Seq[UiElement] = Nil, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement + with HasChildren: + type This = TabList def withKey(v: String) = copy(key = v) override def withChildren(cn: UiElement*) = copy(children = cn) override def withStyle(v: Map[String, Any]) = copy(style = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** see https://chakra-ui.com/docs/components/tabs */ @@ -1837,45 +2157,57 @@ case class Tab( key: String = Keys.nextKey, text: String = "tab.text", isDisabled: Option[Boolean] = None, - _selected: Map[String, Any] = Map.empty, - _hover: Map[String, Any] = Map.empty, - _active: Map[String, Any] = Map.empty, + _selected: Option[Map[String, Any]] = None, + _hover: Option[Map[String, Any]] = None, + _active: Option[Map[String, Any]] = None, style: Map[String, Any] = Map.empty, - children: Seq[UiElement] = Nil -) extends ChakraElement[Tab] - with HasChildren[Tab]: - def withKey(v: String) = copy(key = v) - def withText(v: String) = copy(text = v) - override def withChildren(cn: UiElement*) = copy(children = cn) - override def withStyle(v: Map[String, Any]) = copy(style = v) - def withIsDisabled(v: Option[Boolean]) = copy(isDisabled = v) - def withSelected(v: Map[String, Any]) = copy(_selected = v) - def withHover(v: Map[String, Any]) = copy(_hover = v) - def withActive(v: Map[String, Any]) = copy(_active = v) + children: Seq[UiElement] = Nil, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement + with HasChildren: + type This = Tab + def withKey(v: String) = copy(key = v) + def withText(v: String) = copy(text = v) + override def withChildren(cn: UiElement*) = copy(children = cn) + override def withStyle(v: Map[String, Any]) = copy(style = v) + def withIsDisabled(v: Option[Boolean]) = copy(isDisabled = v) + def withSelected(v: Map[String, Any]) = copy(_selected = Some(v)) + def withSelected(v: Option[Map[String, Any]]) = copy(_selected = v) + def withHover(v: Map[String, Any]) = copy(_hover = Some(v)) + def withHover(v: Option[Map[String, Any]]) = copy(_hover = v) + def withActive(v: Map[String, Any]) = copy(_active = Some(v)) + def withActive(v: Option[Map[String, Any]]) = copy(_active = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** see https://chakra-ui.com/docs/components/tabs */ case class TabPanels( key: String = Keys.nextKey, style: Map[String, Any] = Map.empty, - children: Seq[UiElement] = Nil -) extends ChakraElement[TabPanels] - with HasChildren[TabPanels]: + children: Seq[UiElement] = Nil, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement + with HasChildren: + type This = TabPanels def withKey(v: String) = copy(key = v) override def withChildren(cn: UiElement*) = copy(children = cn) override def withStyle(v: Map[String, Any]) = copy(style = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** see https://chakra-ui.com/docs/components/tabs */ case class TabPanel( key: String = Keys.nextKey, style: Map[String, Any] = Map.empty, - children: Seq[UiElement] = Nil -) extends ChakraElement[TabPanel] - with HasChildren[TabPanel]: + children: Seq[UiElement] = Nil, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement + with HasChildren: + type This = TabPanel def withKey(v: String) = copy(key = v) override def withChildren(cn: UiElement*) = copy(children = cn) override def withStyle(v: Map[String, Any]) = copy(style = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** https://chakra-ui.com/docs/components/breadcrumb */ @@ -1887,9 +2219,11 @@ case class Breadcrumb( fontSize: Option[String] = None, pt: Option[Int] = None, style: Map[String, Any] = Map.empty, - children: Seq[UiElement] = Nil -) extends ChakraElement[Breadcrumb] - with HasChildren[Breadcrumb]: + children: Seq[UiElement] = Nil, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement + with HasChildren: + type This = Breadcrumb def withKey(v: String) = copy(key = v) override def withChildren(cn: UiElement*) = copy(children = cn) override def withStyle(v: Map[String, Any]) = copy(style = v) @@ -1898,6 +2232,7 @@ case class Breadcrumb( def withFontWeight(v: Option[String]) = copy(fontWeight = v) def withFontSize(v: Option[String]) = copy(fontSize = v) def withPt(v: Option[Int]) = copy(pt = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** https://chakra-ui.com/docs/components/breadcrumb */ @@ -1905,13 +2240,16 @@ case class BreadcrumbItem( key: String = Keys.nextKey, isCurrentPage: Option[Boolean] = None, style: Map[String, Any] = Map.empty, - children: Seq[UiElement] = Nil -) extends ChakraElement[BreadcrumbItem] - with HasChildren[BreadcrumbItem]: + children: Seq[UiElement] = Nil, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement + with HasChildren: + type This = BreadcrumbItem def withKey(v: String) = copy(key = v) override def withChildren(cn: UiElement*) = copy(children = cn) override def withStyle(v: Map[String, Any]) = copy(style = v) def withIsCurrentPage(v: Option[Boolean]) = copy(isCurrentPage = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) /** https://chakra-ui.com/docs/components/breadcrumb */ @@ -1920,29 +2258,39 @@ case class BreadcrumbLink( text: String = "breadcrumblink.text", href: Option[String] = None, style: Map[String, Any] = Map.empty, - children: Seq[UiElement] = Nil -) extends ChakraElement[BreadcrumbLink] - with HasChildren[BreadcrumbLink] - with OnClickEventHandler.CanHandleOnClickEvent[BreadcrumbLink]: - def withKey(v: String) = copy(key = v) - override def withChildren(cn: UiElement*) = copy(children = cn) - override def withStyle(v: Map[String, Any]) = copy(style = v) - def withHref(v: Option[String]) = copy(href = v) - def withText(v: String) = copy(text = v) + children: Seq[UiElement] = Nil, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement + with HasChildren + with OnClickEventHandler.CanHandleOnClickEvent: + type This = BreadcrumbLink + def withKey(v: String) = copy(key = v) + override def withChildren(cn: UiElement*) = copy(children = cn) + override def withStyle(v: Map[String, Any]) = copy(style = v) + def withHref(v: Option[String]) = copy(href = v) + def withText(v: String) = copy(text = v) + override def withDataStore(ds: TypedMap): BreadcrumbLink = copy(dataStore = ds) case class Link( key: String = Keys.nextKey, text: String = "link.text", href: String = "#", isExternal: Option[Boolean] = None, + color: Option[String] = None, style: Map[String, Any] = Map.empty, - children: Seq[UiElement] = Nil -) extends ChakraElement[Link] - with HasChildren[Link] - with OnClickEventHandler.CanHandleOnClickEvent[Link]: - def withKey(v: String) = copy(key = v) - override def withChildren(cn: UiElement*) = copy(children = cn) - override def withStyle(v: Map[String, Any]) = copy(style = v) - def withIsExternal(v: Option[Boolean]) = copy(isExternal = v) - def withHref(v: String) = copy(href = v) - def withText(v: String) = copy(text = v) + children: Seq[UiElement] = Nil, + dataStore: TypedMap = TypedMap.Empty +) extends ChakraElement + with HasChildren + with OnClickEventHandler.CanHandleOnClickEvent: + type This = Link + def withKey(v: String) = copy(key = v) + override def withChildren(cn: UiElement*) = copy(children = cn) + override def withStyle(v: Map[String, Any]) = copy(style = v) + def withIsExternal(v: Option[Boolean]) = copy(isExternal = v) + def withIsExternal(v: Boolean) = copy(isExternal = Some(v)) + def withHref(v: String) = copy(href = v) + def withText(v: String) = copy(text = v) + def withColor(v: String) = copy(color = Some(v)) + def withColor(v: Option[String]) = copy(color = v) + override def withDataStore(ds: TypedMap): Link = copy(dataStore = ds) diff --git a/terminal21-ui-std/src/main/scala/org/terminal21/client/components/chakra/QuickFormControl.scala b/terminal21-ui-std/src/main/scala/org/terminal21/client/components/chakra/QuickFormControl.scala new file mode 100644 index 00000000..2bb9508e --- /dev/null +++ b/terminal21-ui-std/src/main/scala/org/terminal21/client/components/chakra/QuickFormControl.scala @@ -0,0 +1,30 @@ +package org.terminal21.client.components.chakra + +import org.terminal21.client.components.UiElement.HasStyle +import org.terminal21.client.components.{Keys, UiComponent, UiElement} +import org.terminal21.collections.TypedMap + +case class QuickFormControl( + key: String = Keys.nextKey, + style: Map[String, Any] = Map.empty, + label: Option[String] = None, + inputGroup: Seq[UiElement] = Nil, + helperText: Option[String] = None, + dataStore: TypedMap = TypedMap.Empty +) extends UiComponent + with HasStyle: + type This = QuickFormControl + lazy val rendered: Seq[UiElement] = + val ch: Seq[UiElement] = + label.map(l => FormLabel(key = subKey("label"), text = l)).toSeq ++ + Seq(InputGroup(key = subKey("ig")).withChildren(inputGroup*)) ++ + helperText.map(h => FormHelperText(key = subKey("helper"), text = h)) + Seq(FormControl(key = subKey("fc"), style = style).withChildren(ch: _*)) + + def withLabel(label: String): QuickFormControl = copy(label = Some(label)) + def withInputGroup(ig: UiElement*): QuickFormControl = copy(inputGroup = ig) + def withHelperText(text: String): QuickFormControl = copy(helperText = Some(text)) + + override def withStyle(v: Map[String, Any]): QuickFormControl = copy(style = v) + override def withKey(key: String): QuickFormControl = copy(key = key) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) diff --git a/terminal21-ui-std/src/main/scala/org/terminal21/client/components/chakra/QuickTable.scala b/terminal21-ui-std/src/main/scala/org/terminal21/client/components/chakra/QuickTable.scala index a3394b89..04072176 100644 --- a/terminal21-ui-std/src/main/scala/org/terminal21/client/components/chakra/QuickTable.scala +++ b/terminal21-ui-std/src/main/scala/org/terminal21/client/components/chakra/QuickTable.scala @@ -2,6 +2,7 @@ package org.terminal21.client.components.chakra import org.terminal21.client.components.UiElement.HasStyle import org.terminal21.client.components.{Keys, UiComponent, UiElement} +import org.terminal21.collections.TypedMap case class QuickTable( key: String = Keys.nextKey, @@ -10,39 +11,75 @@ case class QuickTable( size: String = "mg", style: Map[String, Any] = Map.empty, caption: Option[String] = None, - headers: Seq[UiElement] = Nil, - rows: Seq[Seq[UiElement]] = Nil + headers: Seq[Any] = Nil, + rows: Seq[Seq[Any]] = Nil, + dataStore: TypedMap = TypedMap.Empty ) extends UiComponent - with HasStyle[QuickTable]: - def withKey(v: String) = copy(key = v) - def withVariant(v: String) = copy(variant = v) - def withColorScheme(v: String) = copy(colorScheme = v) - def withSize(v: String) = copy(size = v) - def withCaption(v: Option[String]) = copy(caption = v) - def withHeaders(v: Seq[UiElement]) = copy(headers = v) - def withRows(v: Seq[Seq[UiElement]]) = copy(rows = v) + with HasStyle: + type This = QuickTable + def withKey(v: String) = copy(key = v) + def withVariant(v: String) = copy(variant = v) + def withColorScheme(v: String) = copy(colorScheme = v) + def withSize(v: String) = copy(size = v) + def withCaption(v: Option[String]) = copy(caption = v) + def withCaption(v: String) = copy(caption = Some(v)) override lazy val rendered: Seq[UiElement] = - val head = Thead(key = key + "-th", children = Seq(Tr(children = headers.map(h => Th(children = Seq(h)))))) + val head = Thead( + key = subKey("thead"), + children = Seq( + Tr( + key = subKey("thead-tr"), + children = headers.zipWithIndex.map: (h, i) => + Th( + key = subKey(s"thead-tr-th-$i"), + children = Seq( + h match + case u: UiElement => u + case c => Text(text = c.toString) + ) + ) + ) + ) + ) val body = Tbody( - key = key + "-tb", - children = rows.map: row => - Tr(children = row.map(c => Td(children = Seq(c)))) + key = subKey("tb"), + children = rows.zipWithIndex.map: (row, i) => + Tr( + key = subKey(s"tb-tr-$i"), + children = row.zipWithIndex.map: (c, i) => + Td( + key = subKey(s"tb-th-$i"), + children = Seq( + c match + case u: UiElement => u + case c => Text(text = c.toString) + ) + ) + ) ) val table = Table( - key = key + "-t", + key = subKey("t"), variant = variant, colorScheme = Some(colorScheme), size = size, children = caption.map(text => TableCaption(text = text)).toSeq ++ Seq(head, body) ) - val tableContainer = TableContainer(key = key + "-tc", style = style, children = Seq(table)) + val tableContainer = TableContainer(key = subKey("tc"), style = style, children = Seq(table)) Seq(tableContainer) - def headers(headers: String*): QuickTable = copy(headers = headers.map(h => Text(text = h))) - def headersElements(headers: UiElement*): QuickTable = copy(headers = headers) - def rows(data: Seq[Seq[Any]]): QuickTable = copy(rows = data.map(_.map(c => Text(text = c.toString)))) - def rowsElements(data: Seq[Seq[UiElement]]): QuickTable = copy(rows = data) + def withHeaders(headers: String*): QuickTable = copy(headers = headers.map(h => Text(text = h))) + def withHeadersElements(headers: UiElement*): QuickTable = copy(headers = headers) + + /** @param data + * A mix of plain types or UiElement. If it is a UiElement, it will be rendered otherwise if it is anything else the `.toString` method will be used to + * render it. + * @return + * QuickTable + */ + def withRows(data: Seq[Seq[Any]]): QuickTable = copy(rows = data) + def withRowsElements(data: Seq[Seq[UiElement]]): QuickTable = copy(rows = data) def caption(text: String): QuickTable = copy(caption = Some(text)) override def withStyle(v: Map[String, Any]): QuickTable = copy(style = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) diff --git a/terminal21-ui-std/src/main/scala/org/terminal21/client/components/chakra/QuickTabs.scala b/terminal21-ui-std/src/main/scala/org/terminal21/client/components/chakra/QuickTabs.scala new file mode 100644 index 00000000..90c4f963 --- /dev/null +++ b/terminal21-ui-std/src/main/scala/org/terminal21/client/components/chakra/QuickTabs.scala @@ -0,0 +1,40 @@ +package org.terminal21.client.components.chakra + +import org.terminal21.client.components.UiElement.HasStyle +import org.terminal21.client.components.{Keys, UiComponent, UiElement} +import org.terminal21.collections.TypedMap + +case class QuickTabs( + key: String = Keys.nextKey, + style: Map[String, Any] = Map.empty, + tabs: Seq[String | Seq[UiElement]] = Nil, + tabPanels: Seq[Seq[UiElement]] = Nil, + dataStore: TypedMap = TypedMap.Empty +) extends UiComponent + with HasStyle: + type This = QuickTabs + + def withTabs(tabs: String | Seq[UiElement]*): QuickTabs = copy(tabs = tabs) + def withTabPanels(tabPanels: Seq[UiElement]*): QuickTabs = copy(tabPanels = tabPanels) + def withTabPanelsSimple(tabPanels: UiElement*): QuickTabs = copy(tabPanels = tabPanels.map(e => Seq(e))) + + override lazy val rendered = + Seq( + Tabs(key = subKey("tabs"), style = style).withChildren( + TabList( + key = subKey("tab-list"), + children = tabs.zipWithIndex.map: + case (name: String, idx) => Tab(key = s"$key-tab-$idx", text = name) + case (elements: Seq[UiElement], idx) => Tab(key = s"$key-tab-$idx", children = elements) + ), + TabPanels( + key = subKey("panels"), + children = tabPanels.zipWithIndex.map: (elements, idx) => + TabPanel(key = s"$key-panel-$idx", children = elements) + ) + ) + ) + + override def withStyle(v: Map[String, Any]): QuickTabs = copy(style = v) + override def withKey(key: String): QuickTabs = copy(key = key) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) diff --git a/terminal21-ui-std/src/main/scala/org/terminal21/client/components/extensions.scala b/terminal21-ui-std/src/main/scala/org/terminal21/client/components/extensions.scala deleted file mode 100644 index c0159356..00000000 --- a/terminal21-ui-std/src/main/scala/org/terminal21/client/components/extensions.scala +++ /dev/null @@ -1,10 +0,0 @@ -package org.terminal21.client.components - -import org.terminal21.client.ConnectedSession - -extension (s: Seq[UiElement]) - def render()(using session: ConnectedSession): Unit = - session.render(s: _*) - - def renderChanges()(using session: ConnectedSession): Unit = - session.renderChanges(s: _*) diff --git a/terminal21-ui-std/src/main/scala/org/terminal21/client/components/frontend/FrontEndElement.scala b/terminal21-ui-std/src/main/scala/org/terminal21/client/components/frontend/FrontEndElement.scala new file mode 100644 index 00000000..f4422ebf --- /dev/null +++ b/terminal21-ui-std/src/main/scala/org/terminal21/client/components/frontend/FrontEndElement.scala @@ -0,0 +1,11 @@ +package org.terminal21.client.components.frontend + +import org.terminal21.client.components.{Keys, UiElement} +import org.terminal21.collections.TypedMap + +sealed trait FrontEndElement extends UiElement + +case class ThemeToggle(key: String = Keys.nextKey, dataStore: TypedMap = TypedMap.Empty) extends FrontEndElement: + override type This = ThemeToggle + override def withKey(key: String): ThemeToggle = copy(key = key) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) diff --git a/terminal21-ui-std/src/main/scala/org/terminal21/client/components/std/StdElement.scala b/terminal21-ui-std/src/main/scala/org/terminal21/client/components/std/StdElement.scala index bf1b967c..1bfb5d3e 100644 --- a/terminal21-ui-std/src/main/scala/org/terminal21/client/components/std/StdElement.scala +++ b/terminal21-ui-std/src/main/scala/org/terminal21/client/components/std/StdElement.scala @@ -1,83 +1,101 @@ package org.terminal21.client.components.std -import org.terminal21.client.components.UiElement.{Current, HasChildren, HasEventHandler, HasStyle} -import org.terminal21.client.components.{Keys, UiElement} -import org.terminal21.client.{ConnectedSession, OnChangeEventHandler} +import org.terminal21.client.components.OnChangeEventHandler.CanHandleOnChangeEvent +import org.terminal21.client.components.UiElement.{HasChildren, HasStyle} +import org.terminal21.client.components.{Keys, OnChangeEventHandler, UiElement} +import org.terminal21.collections.TypedMap -sealed trait StdEJson extends UiElement -sealed trait StdElement[A <: UiElement] extends StdEJson with HasStyle[A] with Current[A] +sealed trait StdEJson extends UiElement +sealed trait StdElement extends StdEJson with HasStyle -case class Span(key: String = Keys.nextKey, text: String, style: Map[String, Any] = Map.empty) extends StdElement[Span]: +case class Span(key: String = Keys.nextKey, text: String, style: Map[String, Any] = Map.empty, dataStore: TypedMap = TypedMap.Empty) extends StdElement: + type This = Span override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withText(v: String) = copy(text = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) -case class NewLine(key: String = Keys.nextKey, style: Map[String, Any] = Map.empty) extends StdElement[NewLine]: +case class NewLine(key: String = Keys.nextKey, style: Map[String, Any] = Map.empty, dataStore: TypedMap = TypedMap.Empty) extends StdElement: + type This = NewLine override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) -case class Em(key: String = Keys.nextKey, text: String, style: Map[String, Any] = Map.empty) extends StdElement[Em]: +case class Em(key: String = Keys.nextKey, text: String, style: Map[String, Any] = Map.empty, dataStore: TypedMap = TypedMap.Empty) extends StdElement: + type This = Em override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withText(v: String) = copy(text = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) -case class Header1(key: String = Keys.nextKey, text: String, style: Map[String, Any] = Map.empty) extends StdElement[Header1]: +case class Header1(key: String = Keys.nextKey, text: String, style: Map[String, Any] = Map.empty, dataStore: TypedMap = TypedMap.Empty) extends StdElement: + type This = Header1 override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withText(v: String) = copy(text = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) -case class Header2(key: String = Keys.nextKey, text: String, style: Map[String, Any] = Map.empty) extends StdElement[Header2]: +case class Header2(key: String = Keys.nextKey, text: String, style: Map[String, Any] = Map.empty, dataStore: TypedMap = TypedMap.Empty) extends StdElement: + type This = Header2 override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withText(v: String) = copy(text = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) -case class Header3(key: String = Keys.nextKey, text: String, style: Map[String, Any] = Map.empty) extends StdElement[Header3]: +case class Header3(key: String = Keys.nextKey, text: String, style: Map[String, Any] = Map.empty, dataStore: TypedMap = TypedMap.Empty) extends StdElement: + type This = Header3 override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withText(v: String) = copy(text = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) -case class Header4(key: String = Keys.nextKey, text: String, style: Map[String, Any] = Map.empty) extends StdElement[Header4]: +case class Header4(key: String = Keys.nextKey, text: String, style: Map[String, Any] = Map.empty, dataStore: TypedMap = TypedMap.Empty) extends StdElement: + type This = Header4 override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withText(v: String) = copy(text = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) -case class Header5(key: String = Keys.nextKey, text: String, style: Map[String, Any] = Map.empty) extends StdElement[Header5]: +case class Header5(key: String = Keys.nextKey, text: String, style: Map[String, Any] = Map.empty, dataStore: TypedMap = TypedMap.Empty) extends StdElement: + type This = Header5 override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withText(v: String) = copy(text = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) -case class Header6(key: String = Keys.nextKey, text: String, style: Map[String, Any] = Map.empty) extends StdElement[Header6]: +case class Header6(key: String = Keys.nextKey, text: String, style: Map[String, Any] = Map.empty, dataStore: TypedMap = TypedMap.Empty) extends StdElement: + type This = Header6 override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withText(v: String) = copy(text = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) case class Paragraph( key: String = Keys.nextKey, - text: String = "paragraph.text", + text: String = "", style: Map[String, Any] = Map.empty, - children: Seq[UiElement] = Nil -) extends StdElement[Paragraph] - with HasChildren[Paragraph]: + children: Seq[UiElement] = Nil, + dataStore: TypedMap = TypedMap.Empty +) extends StdElement + with HasChildren: + type This = Paragraph override def withChildren(cn: UiElement*) = copy(children = cn) override def withStyle(v: Map[String, Any]) = copy(style = v) def withKey(v: String) = copy(key = v) def withText(v: String) = copy(text = v) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) case class Input( key: String = Keys.nextKey, `type`: String = "text", - defaultValue: Option[String] = None, + defaultValue: String = "", style: Map[String, Any] = Map.empty, - value: Option[String] = None -) extends StdElement[Input] - with HasEventHandler: - override def defaultEventHandler(session: ConnectedSession): OnChangeEventHandler = newValue => session.modified(copy(value = Some(newValue))) - override def withStyle(v: Map[String, Any]) = copy(style = v) - def withKey(v: String) = copy(key = v) - def withType(v: String) = copy(`type` = v) - def withDefaultValue(v: Option[String]) = copy(defaultValue = v) - def withValue(v: Option[String]) = copy(value = v) - - def onChange(h: OnChangeEventHandler)(using session: ConnectedSession): Input = - session.addEventHandler(key, h) - this + dataStore: TypedMap = TypedMap.Empty +) extends StdElement + with CanHandleOnChangeEvent: + type This = Input + override def withStyle(v: Map[String, Any]) = copy(style = v) + def withKey(v: String) = copy(key = v) + def withType(v: String) = copy(`type` = v) + def withDefaultValue(v: String) = copy(defaultValue = v) + override def withDataStore(ds: TypedMap): Input = copy(dataStore = ds) diff --git a/terminal21-ui-std/src/main/scala/org/terminal21/client/components/std/StdHttp.scala b/terminal21-ui-std/src/main/scala/org/terminal21/client/components/std/StdHttp.scala new file mode 100644 index 00000000..18ed771f --- /dev/null +++ b/terminal21-ui-std/src/main/scala/org/terminal21/client/components/std/StdHttp.scala @@ -0,0 +1,49 @@ +package org.terminal21.client.components.std + +import org.terminal21.client.components.OnChangeEventHandler.CanHandleOnChangeEvent +import org.terminal21.client.components.{Keys, OnChangeEventHandler, UiElement} +import org.terminal21.collections.TypedMap + +/** Elements mapping to Http functionality + */ +sealed trait StdHttp extends UiElement: + /** Each requestId will be processed only once per browser. + * + * I.e. lets say we have the Cookie(). If we add a cookie, we send it to the UI which in turn checks if it already set the cookie via the requestId. If it + * did, it skips it, if it didn't it sets the cookie. + * + * @return + * Should always be TransientRequest.newRequestId() + */ + def requestId: String + +/** On the browser, https://github.com/js-cookie/js-cookie is used. + * + * Set a cookie on the browser. + */ +case class Cookie( + key: String = Keys.nextKey, + name: String = "cookie.name", + value: String = "cookie.value", + path: Option[String] = None, + expireDays: Option[Int] = None, + requestId: String = "cookie-set-req", + dataStore: TypedMap = TypedMap.Empty +) extends StdHttp: + override type This = Cookie + override def withKey(key: String): Cookie = copy(key = key) + override def withDataStore(ds: TypedMap) = copy(dataStore = ds) + +/** Read a cookie value. The value, when read from the ui, it will reflect in `value` assuming the UI had the time to send the value back. Also the onChange + * handler will be called once with the value. + */ +case class CookieReader( + key: String = Keys.nextKey, + name: String = "cookie.name", + requestId: String = "cookie-read-req", + dataStore: TypedMap = TypedMap.Empty +) extends StdHttp + with CanHandleOnChangeEvent: + type This = CookieReader + override def withDataStore(ds: TypedMap): CookieReader = copy(dataStore = ds) + override def withKey(key: String): CookieReader = copy(key = key) diff --git a/terminal21-ui-std/src/main/scala/org/terminal21/client/internal/EventHandlers.scala b/terminal21-ui-std/src/main/scala/org/terminal21/client/internal/EventHandlers.scala deleted file mode 100644 index 39ba715e..00000000 --- a/terminal21-ui-std/src/main/scala/org/terminal21/client/internal/EventHandlers.scala +++ /dev/null @@ -1,28 +0,0 @@ -package org.terminal21.client.internal - -import org.terminal21.client.{ConnectedSession, EventHandler} -import org.terminal21.client.components.UiElement -import org.terminal21.client.components.UiElement.{HasEventHandler, allDeep} - -class EventHandlers(session: ConnectedSession): - private val eventHandlers = collection.concurrent.TrieMap.empty[String, List[EventHandler]] - - def registerEventHandlers(es: Seq[UiElement]): Unit = synchronized: - val all = allDeep(es) - val withEvents = all.collect: - case h: HasEventHandler => h - - for e <- withEvents do addEventHandlerAtTheTop(e.key, e.defaultEventHandler(session)) - - def addEventHandler(key: String, handler: EventHandler): Unit = - val handlers = eventHandlers.getOrElse(key, Nil) - eventHandlers += key -> (handlers :+ handler) - - def getEventHandler(key: String): Option[List[EventHandler]] = eventHandlers.get(key) - - private def addEventHandlerAtTheTop(key: String, handler: EventHandler): Unit = - val handlers = eventHandlers.getOrElse(key, Nil) - eventHandlers += key -> (handler :: handlers) - - def clear(): Unit = synchronized: - eventHandlers.clear() diff --git a/terminal21-ui-std/src/main/scala/org/terminal21/client/components/UiElementEncoding.scala b/terminal21-ui-std/src/main/scala/org/terminal21/client/json/UiElementEncoding.scala similarity index 61% rename from terminal21-ui-std/src/main/scala/org/terminal21/client/components/UiElementEncoding.scala rename to terminal21-ui-std/src/main/scala/org/terminal21/client/json/UiElementEncoding.scala index 04993b10..2a6ff9a0 100644 --- a/terminal21-ui-std/src/main/scala/org/terminal21/client/components/UiElementEncoding.scala +++ b/terminal21-ui-std/src/main/scala/org/terminal21/client/json/UiElementEncoding.scala @@ -1,10 +1,13 @@ -package org.terminal21.client.components +package org.terminal21.client.json import io.circe.* import io.circe.generic.auto.* import io.circe.syntax.* import org.terminal21.client.components.chakra.{Box, CEJson, ChakraElement} -import org.terminal21.client.components.std.{StdEJson, StdElement} +import org.terminal21.client.components.frontend.FrontEndElement +import org.terminal21.client.components.std.{StdEJson, StdElement, StdHttp} +import org.terminal21.client.components.{ComponentLib, UiComponent, UiElement} +import org.terminal21.collections.TypedMap class UiElementEncoding(libs: Seq[ComponentLib]): given uiElementEncoder: Encoder[UiElement] = @@ -29,9 +32,12 @@ object StdElementEncoding extends ComponentLib: ) Json.obj(vs: _*) + given Encoder[TypedMap] = _ => Json.Null + override def toJson(using Encoder[UiElement]): PartialFunction[UiElement, Json] = - case std: StdEJson => std.asJson.mapObject(o => o.add("type", "Std".asJson)) - case c: CEJson => c.asJson.mapObject(o => o.add("type", "Chakra".asJson)) - case c: UiComponent => - val b: ChakraElement[Box] = Box(key = c.key, text = "") - b.asJson.mapObject(o => o.add("type", "Chakra".asJson)) + case std: StdEJson => std.asJson.mapObject(o => o.add("type", "Std".asJson)) + case c: CEJson => c.asJson.mapObject(o => o.add("type", "Chakra".asJson)) + case std: StdHttp => std.asJson.mapObject(o => o.add("type", "Std".asJson)) + case fe: FrontEndElement => fe.asJson.mapObject(o => o.add("type", "FrontEnd".asJson)) + case _: UiComponent => + throw new IllegalStateException("substitute all components before serializing") diff --git a/terminal21-ui-std/src/test/scala/generator/GenerateIconsCode.scala b/terminal21-ui-std/src/test/scala/generator/GenerateIconsCode.scala deleted file mode 100644 index 5d6da43c..00000000 --- a/terminal21-ui-std/src/test/scala/generator/GenerateIconsCode.scala +++ /dev/null @@ -1,82 +0,0 @@ -package generator - -@main def generateIconsCode() = - val icons = Seq( - "AddIcon", - "ArrowBackIcon", - "ArrowDownIcon", - "ArrowForwardIcon", - "ArrowLeftIcon", - "ArrowRightIcon", - "ArrowUpIcon", - "ArrowUpDownIcon", - "AtSignIcon", - "AttachmentIcon", - "BellIcon", - "CalendarIcon", - "ChatIcon", - "CheckIcon", - "CheckCircleIcon", - "ChevronDownIcon", - "ChevronLeftIcon", - "ChevronRightIcon", - "ChevronUpIcon", - "CloseIcon", - "CopyIcon", - "DeleteIcon", - "DownloadIcon", - "DragHandleIcon", - "EditIcon", - "EmailIcon", - "ExternalLinkIcon", - "HamburgerIcon", - "InfoIcon", - "InfoOutlineIcon", - "LinkIcon", - "LockIcon", - "MinusIcon", - "MoonIcon", - "NotAllowedIcon", - "PhoneIcon", - "PlusSquareIcon", - "QuestionIcon", - "QuestionOutlineIcon", - "RepeatIcon", - "RepeatClockIcon", - "SearchIcon", - "Search2Icon", - "SettingsIcon", - "SmallAddIcon", - "SmallCloseIcon", - "SpinnerIcon", - "StarIcon", - "SunIcon", - "TimeIcon", - "TriangleDownIcon", - "TriangleUpIcon", - "UnlockIcon", - "UpDownIcon", - "ViewIcon", - "ViewOffIcon", - "WarningIcon", - "WarningTwoIcon" - ) - - println("// ------------------------ SCALA CODE -------------------------------------") - for clz <- icons do println(s""" - |/** generated by generateIconsCode() , https://chakra-ui.com/docs/components/icon - | */ - |case class $clz( - | key: String = Keys.nextKey, - | @volatile var w: Option[String] = None, - | @volatile var h: Option[String] = None, - | @volatile var boxSize: Option[String] = None, - | @volatile var color: Option[String] = None - |) extends ChakraElement - | - |""".stripMargin) - println("// ------------------------ SCALA USING CODE -------------------------------------") - println(icons.map(i => s"$i()").mkString(",")) - println("// ------------------------ TSX CODE -------------------------------------") - println(s"import { ${icons.mkString(",")} } from '@chakra-ui/icons';") - for clz <- icons do println(s"$clz: (b:any) => (<$clz {...b}/>),") diff --git a/terminal21-ui-std/src/test/scala/org/terminal21/client/CalculationTest.scala b/terminal21-ui-std/src/test/scala/org/terminal21/client/CalculationTest.scala deleted file mode 100644 index 9fea22ac..00000000 --- a/terminal21-ui-std/src/test/scala/org/terminal21/client/CalculationTest.scala +++ /dev/null @@ -1,37 +0,0 @@ -package org.terminal21.client - -import functions.fibers.FiberExecutor -import org.scalatest.funsuite.AnyFunSuiteLike -import org.scalatest.matchers.should.Matchers.* - -import java.util.concurrent.atomic.{AtomicBoolean, AtomicInteger} -import org.scalatest.concurrent.Eventually.* -import org.terminal21.client.components.Calculation - -class CalculationTest extends AnyFunSuiteLike: - given executor: FiberExecutor = FiberExecutor() - def testCalc(i: Int) = i + 1 - def testCalcString(i: Int): String = (i + 10).toString - - class Calc extends Calculation[Int]: - val whenResultsNotReadyCalled = new AtomicBoolean(false) - val whenResultsReadyValue = new AtomicInteger(-1) - override protected def whenResultsNotReady(): Unit = whenResultsNotReadyCalled.set(true) - override protected def whenResultsReady(results: Int): Unit = whenResultsReadyValue.set(results) - override protected def calculation() = 2 - - test("calculates"): - val calc = new Calc - calc.run().get() should be(2) - - test("calls whenResultsNotReady"): - val calc = new Calc - calc.run() - eventually: - calc.whenResultsNotReadyCalled.get() should be(true) - - test("calls whenResultsReady"): - val calc = new Calc - calc.run() - eventually: - calc.whenResultsReadyValue.get() should be(2) diff --git a/terminal21-ui-std/src/test/scala/org/terminal21/client/ConnectedSessionMock.scala b/terminal21-ui-std/src/test/scala/org/terminal21/client/ConnectedSessionMock.scala index 6323b0c7..49bd33d6 100644 --- a/terminal21-ui-std/src/test/scala/org/terminal21/client/ConnectedSessionMock.scala +++ b/terminal21-ui-std/src/test/scala/org/terminal21/client/ConnectedSessionMock.scala @@ -1,7 +1,7 @@ package org.terminal21.client import org.mockito.Mockito.mock -import org.terminal21.client.components.{StdElementEncoding, UiElementEncoding} +import org.terminal21.client.json.{StdElementEncoding, UiElementEncoding} import org.terminal21.model.CommonModelBuilders.session import org.terminal21.ui.std.SessionsService diff --git a/terminal21-ui-std/src/test/scala/org/terminal21/client/ConnectedSessionTest.scala b/terminal21-ui-std/src/test/scala/org/terminal21/client/ConnectedSessionTest.scala index 5b26595c..3eabb616 100644 --- a/terminal21-ui-std/src/test/scala/org/terminal21/client/ConnectedSessionTest.scala +++ b/terminal21-ui-std/src/test/scala/org/terminal21/client/ConnectedSessionTest.scala @@ -12,52 +12,30 @@ import org.terminal21.ui.std.ServerJson class ConnectedSessionTest extends AnyFunSuiteLike: - test("default event handlers are invoked before user handlers"): + test("event iterator"): given connectedSession: ConnectedSession = ConnectedSessionMock.newConnectedSessionMock - val editable = Editable() - editable.onChange: newValue => - editable.current.value should be(newValue) - - connectedSession.render(editable) - connectedSession.fireEvent(OnChange(editable.key, "new value")) - - test("to server json"): - val (sessionService, connectedSession) = ConnectedSessionMock.newConnectedSessionAndSessionServiceMock - - val p1 = Paragraph(text = "p1") - val span1 = Span(text = "span1") - connectedSession.render(p1.withChildren(span1)) - connectedSession.render() - verify(sessionService).setSessionJsonState( - connectedSession.session, - ServerJson( - Seq(p1.key), - Map(p1.key -> encoder(p1.withChildren()), span1.key -> encoder(span1)), - Map(p1.key -> Seq(span1.key), span1.key -> Nil) + val editable = Editable(key = "ed") + val it = connectedSession.eventIterator + val event1 = OnChange(editable.key, "v1") + val event2 = OnChange(editable.key, "v2") + connectedSession.fireEvent(event1) + connectedSession.fireEvent(event2) + connectedSession.clear() + it.toList should be( + List( + event1, + event2 ) ) - test("renderChanges changes state on server"): + test("to server json"): val (sessionService, connectedSession) = ConnectedSessionMock.newConnectedSessionAndSessionServiceMock - - val p1 = Paragraph(text = "p1") - val span1 = Span(text = "span1") - connectedSession.render(p1) - connectedSession.renderChanges(p1.withChildren(span1)) - verify(sessionService).changeSessionJsonState( + val span1 = Span("sk", text = "span1") + val p1 = Paragraph("pk", text = "p1").withChildren(span1) + connectedSession.render(Seq(p1.withChildren(span1))) + verify(sessionService).setSessionJsonState( connectedSession.session, ServerJson( - Seq(p1.key), - Map(p1.key -> encoder(p1.withChildren()), span1.key -> encoder(span1)), - Map(p1.key -> Seq(span1.key), span1.key -> Nil) + Seq(encoder(p1).deepDropNullValues) ) ) - - test("renderChanges updates current version of component"): - given connectedSession: ConnectedSession = ConnectedSessionMock.newConnectedSessionMock - - val p1 = Paragraph(text = "p1") - val span1 = Span(text = "span1") - connectedSession.render(p1) - connectedSession.renderChanges(p1.withChildren(span1)) - p1.current.children should be(Seq(span1)) diff --git a/terminal21-ui-std/src/test/scala/org/terminal21/client/ControllerTest.scala b/terminal21-ui-std/src/test/scala/org/terminal21/client/ControllerTest.scala new file mode 100644 index 00000000..36f71d0c --- /dev/null +++ b/terminal21-ui-std/src/test/scala/org/terminal21/client/ControllerTest.scala @@ -0,0 +1,128 @@ +package org.terminal21.client + +import org.scalatest.funsuite.AnyFunSuiteLike +import org.scalatest.matchers.should.Matchers.* +import org.terminal21.client.components.UiElement +import org.terminal21.client.components.chakra.* +import org.terminal21.client.components.std.Input +import org.terminal21.collections.SEList +import org.terminal21.model.{CommandEvent, OnChange, OnClick} + +class ControllerTest extends AnyFunSuiteLike: + val button = Button("b1") + val buttonClick = OnClick(button.key) + val input = Input("i1") + val inputChange = OnChange(input.key, "new-value") + val checkbox = Checkbox("c1") + val checkBoxChange = OnChange(checkbox.key, "true") + given ConnectedSession = ConnectedSessionMock.newConnectedSessionMock + + def newController[M]( + events: Seq[CommandEvent], + mvFunction: ModelViewFunction[M], + renderChanges: Seq[UiElement] => Unit = _ => () + ): Controller[M] = + val seList = SEList[CommandEvent]() + val it = seList.iterator + events.foreach(e => seList.add(e)) + seList.add(CommandEvent.sessionClosed) + new Controller(it, renderChanges, mvFunction) + + test("model updated"): + def components(m: Int, events: Events) = MV(m + 1, Box()) + newController(Seq(buttonClick), components).render(0).iterator.map(_.model).toList should be(List(1, 2)) + + test("model when terminated"): + def components(m: Int, events: Events) = + MV(100, Seq(Box()), terminate = true) + newController(Seq(buttonClick), components).render(0).iterator.map(_.model).toList should be(List(100)) + + test("view updated"): + def components(m: Int, events: Events) = MV(m + 1, Box(text = m.toString)) + newController(Seq(buttonClick), components).render(0).iterator.map(_.view).toList should be(Seq(Seq(Box(text = "0")), Seq(Box(text = "1")))) + + test("renderChanges() not invoked if no UI changed"): + def components(m: Int, events: Events) = + MV( + m + 1, + Box().withChildren(button, input, checkbox) + ) + + var rendered = List.empty[Seq[UiElement]] + def renderChanges(es: Seq[UiElement]) = + rendered = rendered :+ es + + newController(Seq(buttonClick, checkBoxChange, inputChange), components, renderChanges).render(0).iterator.map(_.model).toList should be(List(1, 2, 3, 4)) + rendered.size should be(1) + + test("renderChanges() invoked if UI changed"): + + def components(m: Int, events: Events) = + MV( + m + 1, + Box(text = s"m=$m").withChildren(button, input, checkbox) + ) + + var rendered = List.empty[Seq[UiElement]] + + def renderChanges(es: Seq[UiElement]) = + rendered = rendered :+ es + + newController(Seq(buttonClick, checkBoxChange), components, renderChanges).render(0).iterator.map(_.model).toList should be(List(1, 2, 3)) + rendered.size should be(3) + + test("events isClicked positive"): + def components(m: Boolean, events: Events) = MV(events.isClicked(button), button) + newController(Seq(buttonClick), components).render(false).iterator.lastOption.map(_.model) should be(Some(true)) + + test("events isClicked negative"): + def components(m: Boolean, events: Events) = MV(events.isClicked(button), button) + newController(Seq(checkBoxChange), components).render(false).iterator.lastOption.map(_.model) should be(Some(false)) + + test("events changedValue positive"): + def components(m: String, events: Events) = MV(events.changedValue(input, "x"), button) + newController(Seq(inputChange), components).render("").iterator.lastOption.map(_.model) should be(Some("new-value")) + + test("events changedValue negative"): + def components(m: String, events: Events) = MV(events.changedValue(input, "x"), button) + newController(Seq(buttonClick), components).render("").iterator.lastOption.map(_.model) should be(Some("x")) + + test("events changedBooleanValue positive"): + def components(m: Boolean, events: Events) = MV(events.changedBooleanValue(checkbox, false), button) + newController(Seq(checkBoxChange), components).render(false).iterator.lastOption.map(_.model) should be(Some(true)) + + test("events changedBooleanValue negative"): + def components(m: Boolean, events: Events) = MV(events.changedBooleanValue(checkbox, false), button) + newController(Seq(buttonClick), components).render(false).iterator.lastOption.map(_.model) should be(Some(false)) + + test("poc"): + case class Person(id: Int, name: String) + def personComponent(person: Person, events: Events): MV[Person] = + val nameInput = Input(s"person-${person.id}", defaultValue = person.name) + val component = Box() + .withChildren( + Text(text = "Name"), + nameInput + ) + MV( + person.copy( + name = events.changedValue(nameInput, person.name) + ), + component + ) + + def peopleComponent(people: Seq[Person], events: Events): MV[Seq[Person]] = + val peopleComponents = people.map(p => personComponent(p, events)) + val component = QuickTable("people") + .withRows(peopleComponents.map(p => Seq(p.view))) + MV(peopleComponents.map(_.model), component) + + val p1 = Person(10, "person 1") + val p2 = Person(20, "person 2") + val people = Seq(p1, p2) + val mv = newController(Seq(OnChange("person-10", "changed p10")), peopleComponent) + .render(people) + .iterator + .lastOption + .get + mv.model should be(Seq(p1.copy(name = "changed p10"), p2)) diff --git a/terminal21-ui-std/src/test/scala/org/terminal21/client/collections/EventIteratorTest.scala b/terminal21-ui-std/src/test/scala/org/terminal21/client/collections/EventIteratorTest.scala new file mode 100644 index 00000000..53c5891f --- /dev/null +++ b/terminal21-ui-std/src/test/scala/org/terminal21/client/collections/EventIteratorTest.scala @@ -0,0 +1,28 @@ +package org.terminal21.client.collections + +import org.scalatest.funsuite.AnyFunSuiteLike +import org.scalatest.matchers.should.Matchers.* +import org.terminal21.client.ConnectedSessionMock +import org.terminal21.model.{CommandEvent, SessionClosed} + +class EventIteratorTest extends AnyFunSuiteLike: + test("works as normal iterator"): + EventIterator(1, 2, 3).toList should be(List(1, 2, 3)) + + test("works as normal iterator when empty"): + EventIterator().toList should be(Nil) + + test("lastOption when available"): + EventIterator(1, 2, 3).lastOption should be(Some(3)) + + test("lastOption when not available"): + EventIterator().lastOption should be(None) + + test("lastOptionOrNoneIfSessionClosed when session open"): + val session = ConnectedSessionMock.newConnectedSessionMock + EventIterator(1, 2).lastOptionOrNoneIfSessionClosed(using session) should be(Some(2)) + + test("lastOptionOrNoneIfSessionClosed when session closed"): + val session = ConnectedSessionMock.newConnectedSessionMock + session.fireEvent(CommandEvent.sessionClosed) + EventIterator(1, 2).lastOptionOrNoneIfSessionClosed(using session) should be(None) diff --git a/terminal21-ui-std/src/test/scala/org/terminal21/client/components/UiElementTest.scala b/terminal21-ui-std/src/test/scala/org/terminal21/client/components/UiElementTest.scala new file mode 100644 index 00000000..eaa94f86 --- /dev/null +++ b/terminal21-ui-std/src/test/scala/org/terminal21/client/components/UiElementTest.scala @@ -0,0 +1,28 @@ +package org.terminal21.client.components + +import org.scalatest.funsuite.AnyFunSuiteLike +import org.scalatest.matchers.should.Matchers.* +import org.terminal21.client.components.chakra.{Box, QuickTable, Text} +import org.terminal21.client.components.std.Paragraph + +class UiElementTest extends AnyFunSuiteLike: + test("flat"): + val box = Box(key = "k1").withChildren(Text(key = "k2"), Text(key = "k3")) + box.flat should be( + Seq(box, Text(key = "k2"), Text(key = "k3")) + ) + test("findKey"): + Box(key = "k1").withChildren(Text(key = "k2"), Text(key = "k3")).findKey("k3") should be(Text(key = "k3")) + + test("substituteComponents when not component"): + val e = Text() + e.substituteComponents should be(e) + + test("substituteComponents when component"): + val e = QuickTable(key = "k1") + e.substituteComponents should be(Box("k1", children = e.rendered)) + + test("substituteComponents when children are component"): + val t = QuickTable(key = "k1") + val e = Paragraph(key = "p1").withChildren(t) + e.substituteComponents should be(Paragraph(key = "p1").withChildren(Box("k1", children = t.rendered))) diff --git a/terminal21-ui-std/src/test/scala/org/terminal21/client/json/UiElementEncodingTest.scala b/terminal21-ui-std/src/test/scala/org/terminal21/client/json/UiElementEncodingTest.scala new file mode 100644 index 00000000..594c22ec --- /dev/null +++ b/terminal21-ui-std/src/test/scala/org/terminal21/client/json/UiElementEncodingTest.scala @@ -0,0 +1,12 @@ +package org.terminal21.client.json + +import org.scalatest.funsuite.AnyFunSuiteLike +import org.terminal21.client.components.chakra.Button +import org.scalatest.matchers.should.Matchers.* + +class UiElementEncodingTest extends AnyFunSuiteLike: + val encoding = new UiElementEncoding(Seq(StdElementEncoding)) + test("dataStore"): + val b = Button(key = "b") + val j = encoding.uiElementEncoder(b).deepDropNullValues + j.hcursor.downField("Button").downField("dataStore").failed should be(true)