├── docs
├── _assets
│ ├── .gitignore
│ └── images
│ │ ├── linkedin-day.png
│ │ ├── simple-form.png
│ │ ├── dev-terminals.png
│ │ ├── import-project.png
│ │ ├── linkedin-night.png
│ │ └── github-mark.svg
├── _layouts
│ └── main.html
├── sidebar.yml
└── _docs
│ ├── contributing
│ ├── vscode-setup.md
│ ├── index.md
│ └── setup.puml
│ ├── getting-started.md
│ ├── index.md
│ └── usage.md
├── project
├── build.properties
└── plugins.sbt
├── .scalafmt.conf
├── examples
├── client
│ ├── main.js
│ ├── .vscode
│ │ └── settings.json
│ ├── favicon.ico
│ ├── ui5-logo.png
│ ├── awesome.js
│ ├── scala-metadata.js
│ ├── src
│ │ └── main
│ │ │ └── scala
│ │ │ ├── facades
│ │ │ └── highlightjs
│ │ │ │ ├── HljsLanguage.scala
│ │ │ │ ├── hljsScala.scala
│ │ │ │ └── hljs.scala
│ │ │ ├── samples
│ │ │ ├── ListElement.scala
│ │ │ ├── OpaqueType.scala
│ │ │ ├── SimpleSample.scala
│ │ │ ├── EitherSample.scala
│ │ │ ├── package.scala
│ │ │ ├── Index.scala
│ │ │ ├── AdHoc.scala
│ │ │ ├── EnumSample.scala
│ │ │ ├── Validation.scala
│ │ │ ├── DemoDoreane.scala
│ │ │ ├── DemoNative.scala
│ │ │ ├── DemoNguyenyou.scala
│ │ │ ├── Sealed.scala
│ │ │ ├── DemoWebAwesome.scala
│ │ │ ├── Tree.scala
│ │ │ ├── Persons.scala
│ │ │ └── Conditional.scala
│ │ │ └── WebSocketDemo.scala
│ ├── package.json
│ ├── vite.config.js
│ ├── style.css
│ ├── index.html
│ └── awesome.html
├── server
│ └── src
│ │ └── main
│ │ ├── assets
│ │ ├── favicon.ico
│ │ ├── images
│ │ │ └── avatars
│ │ │ │ └── ono.png
│ │ └── style.css
│ │ └── scala
│ │ └── samples
│ │ ├── BaseController.scala
│ │ ├── HealthEndpoint.scala
│ │ ├── HttpApi.scala
│ │ ├── HealthController.scala
│ │ └── SampleServer.scala
└── generator
│ └── src
│ └── main
│ ├── twirl
│ └── index.scala.html
│ └── scala
│ └── samples
│ └── TwirlTemplate.scala
├── form.png
├── modules
├── ui5
│ ├── package.json
│ └── src
│ │ └── main
│ │ └── scala
│ │ └── dev
│ │ └── cheleb
│ │ └── scalamigen
│ │ └── ui5
│ │ └── UI5WidgetFactory.scala
├── core
│ └── src
│ │ └── main
│ │ └── scala
│ │ └── dev
│ │ └── cheleb
│ │ └── scalamigen
│ │ ├── ConditionalFor.scala
│ │ ├── NameUtils.scala
│ │ ├── config
│ │ └── PanelConfig.scala
│ │ ├── IronTypeValidator.scala
│ │ ├── ValidationEvent.scala
│ │ ├── Defaultable.scala
│ │ ├── Validator.scala
│ │ ├── LaminarWidgetFactory.scala
│ │ ├── WidgetFactory.scala
│ │ ├── package.scala
│ │ └── Form.scala
├── shared
│ └── src
│ │ └── main
│ │ └── scala
│ │ └── dev
│ │ └── cheleb
│ │ └── scalamigen
│ │ └── Annotations.scala
├── webawesome
│ └── src
│ │ └── main
│ │ └── scala
│ │ └── dev
│ │ └── cheleb
│ │ └── scalamigen
│ │ └── webawesome
│ │ └── WebAwesomeWidgetFactory.scala
└── ui5-nguyenyou
│ └── src
│ └── main
│ └── scala
│ └── dev
│ └── cheleb
│ └── scalamigen
│ └── ui5
│ └── UI5WidgetFactory.scala
├── .gitignore
├── renovate.json
├── scripts
├── gh-pages.sh
├── npmDev.sh
├── fastLink.sh
└── setup.sc
├── .github
├── workflows
│ ├── labeler.yml
│ ├── website.yml
│ ├── release.yml
│ └── ci.yml
└── labeler.yml
├── website.sbt
├── .vscode
└── tasks.json
└── README.md
/docs/_assets/.gitignore:
--------------------------------------------------------------------------------
1 | demo/
2 |
--------------------------------------------------------------------------------
/project/build.properties:
--------------------------------------------------------------------------------
1 | sbt.version=1.11.7
2 |
--------------------------------------------------------------------------------
/.scalafmt.conf:
--------------------------------------------------------------------------------
1 | version = "3.10.2"
2 | runner.dialect = scala3
--------------------------------------------------------------------------------
/examples/client/main.js:
--------------------------------------------------------------------------------
1 | import './style.css'
2 | import 'scalajs:main.js'
3 |
--------------------------------------------------------------------------------
/form.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cheleb/laminar-form-derivation/HEAD/form.png
--------------------------------------------------------------------------------
/modules/ui5/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "dependencies": {
3 | "jsdom": "^27.0.0"
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/examples/client/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "files.watcherExclude": {
3 | "**/target": true
4 | }
5 | }
--------------------------------------------------------------------------------
/examples/client/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cheleb/laminar-form-derivation/HEAD/examples/client/favicon.ico
--------------------------------------------------------------------------------
/examples/client/ui5-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cheleb/laminar-form-derivation/HEAD/examples/client/ui5-logo.png
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | target/
2 | .bsp/
3 | node_modules/
4 | dist/
5 | *.bak
6 | version.sbt
7 | package-lock.json
8 | scripts/.scala-build/
9 |
--------------------------------------------------------------------------------
/docs/_assets/images/linkedin-day.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cheleb/laminar-form-derivation/HEAD/docs/_assets/images/linkedin-day.png
--------------------------------------------------------------------------------
/docs/_assets/images/simple-form.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cheleb/laminar-form-derivation/HEAD/docs/_assets/images/simple-form.png
--------------------------------------------------------------------------------
/docs/_assets/images/dev-terminals.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cheleb/laminar-form-derivation/HEAD/docs/_assets/images/dev-terminals.png
--------------------------------------------------------------------------------
/docs/_assets/images/import-project.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cheleb/laminar-form-derivation/HEAD/docs/_assets/images/import-project.png
--------------------------------------------------------------------------------
/docs/_assets/images/linkedin-night.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cheleb/laminar-form-derivation/HEAD/docs/_assets/images/linkedin-night.png
--------------------------------------------------------------------------------
/examples/client/awesome.js:
--------------------------------------------------------------------------------
1 | import "@awesome.me/webawesome/dist/styles/webawesome.css";
2 | import './style.css'
3 | import 'scalajs:main.js'
4 |
--------------------------------------------------------------------------------
/examples/client/scala-metadata.js:
--------------------------------------------------------------------------------
1 |
2 | const scalaVersion = "3.4.1"
3 |
4 | exports.scalaMetadata = {
5 | scalaVersion: scalaVersion
6 | }
7 |
--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3 | "extends": [
4 | "config:base"
5 | ]
6 | }
7 |
--------------------------------------------------------------------------------
/examples/server/src/main/assets/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cheleb/laminar-form-derivation/HEAD/examples/server/src/main/assets/favicon.ico
--------------------------------------------------------------------------------
/modules/core/src/main/scala/dev/cheleb/scalamigen/ConditionalFor.scala:
--------------------------------------------------------------------------------
1 | package dev.cheleb.scalamigen
2 |
3 | trait ConditionalFor[C, A]:
4 | def check: C => Boolean
5 |
--------------------------------------------------------------------------------
/examples/server/src/main/assets/images/avatars/ono.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cheleb/laminar-form-derivation/HEAD/examples/server/src/main/assets/images/avatars/ono.png
--------------------------------------------------------------------------------
/docs/_layouts/main.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Laminar Form Derivation
5 |
6 |
7 |
8 | {{ content }}
9 |
10 |
11 |
--------------------------------------------------------------------------------
/examples/server/src/main/assets/style.css:
--------------------------------------------------------------------------------
1 | h1 {
2 | box-shadow: none;
3 | }
4 |
5 |
6 | .form>div {
7 | display: grid;
8 | width: 15rem;
9 | margin-bottom: 0.5rem;
10 | }
--------------------------------------------------------------------------------
/scripts/gh-pages.sh:
--------------------------------------------------------------------------------
1 | pushd examples/client
2 | rm -rf package-lock.json
3 | npm i
4 | npm run build
5 | popd
6 | export VERSION=`git describe --tags --abbrev=0 | sed "s/v//"`
7 | echo "Documentation version: $VERSION"
8 | sbt website
--------------------------------------------------------------------------------
/examples/server/src/main/scala/samples/BaseController.scala:
--------------------------------------------------------------------------------
1 | package samples
2 |
3 | import sttp.tapir.server.ServerEndpoint
4 | import zio.Task
5 |
6 | trait BaseController {
7 |
8 | val routes: List[ServerEndpoint[Any, Task]]
9 |
10 | }
11 |
--------------------------------------------------------------------------------
/scripts/npmDev.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | . ./scripts/target/build-env.sh
4 |
5 | echo "Starting npm dev server for client"
6 | echo " * SCALA_VERSION=$SCALA_VERSION"
7 | rm -f $MAIN_JS_PATH
8 | touch $NPM_DEV_PATH
9 |
10 | cd examples/client
11 | npm run dev
12 |
--------------------------------------------------------------------------------
/examples/client/src/main/scala/facades/highlightjs/HljsLanguage.scala:
--------------------------------------------------------------------------------
1 | package facades.highlightjs
2 |
3 | import scala.scalajs.js
4 |
5 | /** Marker trait for all js Object that represents languages to be registered &
6 | * Hljs
7 | */
8 | trait HljsLanguage extends js.Object
9 |
--------------------------------------------------------------------------------
/docs/sidebar.yml:
--------------------------------------------------------------------------------
1 | index: index.md
2 | subsection:
3 | - title: Getting Started
4 | page: getting-started.md
5 | - title: Usage
6 | page: usage.md
7 | - index: contributing/index.md
8 | subsection:
9 | - title: VSCode Setup
10 | page: contributing/vscode-setup.md
--------------------------------------------------------------------------------
/examples/client/src/main/scala/facades/highlightjs/hljsScala.scala:
--------------------------------------------------------------------------------
1 | package facades.highlightjs
2 |
3 | import scala.scalajs.js
4 | import scala.scalajs.js.annotation.JSImport
5 |
6 | @js.native
7 | @JSImport("highlight.js/lib/languages/scala", JSImport.Default)
8 | object hljsScala extends HljsLanguage
9 |
--------------------------------------------------------------------------------
/.github/workflows/labeler.yml:
--------------------------------------------------------------------------------
1 | name: "Pull Request Labeler"
2 | on:
3 | - pull_request
4 | - pull_request_target
5 |
6 | jobs:
7 | labeler:
8 | permissions:
9 | contents: read
10 | pull-requests: write
11 | runs-on: ubuntu-latest
12 | steps:
13 | - uses: actions/labeler@v6
--------------------------------------------------------------------------------
/examples/server/src/main/scala/samples/HealthEndpoint.scala:
--------------------------------------------------------------------------------
1 | package samples
2 |
3 | import sttp.tapir.*
4 | import zio.*
5 |
6 | trait HealthEndpoint {
7 | val healthEndpoint = endpoint
8 | .tag("health")
9 | .name("health")
10 | .get
11 | .in("health")
12 | .out(stringBody)
13 | .description("Health check")
14 |
15 | }
16 |
--------------------------------------------------------------------------------
/modules/core/src/main/scala/dev/cheleb/scalamigen/NameUtils.scala:
--------------------------------------------------------------------------------
1 | package dev.cheleb.scalamigen
2 |
3 | object NameUtils {
4 |
5 | /** someParameterName -> Some Parameter Name camelCase -> Title Case
6 | */
7 | def titleCase(string: String): String =
8 | string
9 | .filter(_.isLetter)
10 | .split("(?=[A-Z])")
11 | .map(_.capitalize)
12 | .mkString(" ")
13 | }
14 |
--------------------------------------------------------------------------------
/.github/labeler.yml:
--------------------------------------------------------------------------------
1 | ci:
2 | - changed-files:
3 | - any-glob-to-any-file:
4 | - .github/**
5 | documentation:
6 | - changed-files:
7 | - any-glob-to-any-file:
8 | - docs/*
9 | - guides/*
10 | - README.md
11 | enhancements:
12 | - changed-files:
13 | - any-glob-to-any-file:
14 | - modules/*
15 | build:
16 | - changed-files:
17 | - any-glob-to-any-file:
18 | - build.sbt
19 | - project/**
20 |
--------------------------------------------------------------------------------
/examples/client/src/main/scala/facades/highlightjs/hljs.scala:
--------------------------------------------------------------------------------
1 | package facades.highlightjs
2 |
3 | import org.scalajs.dom
4 |
5 | import scala.scalajs.js
6 | import scala.scalajs.js.annotation.JSImport
7 |
8 | @js.native
9 | @JSImport("highlight.js/lib/core", JSImport.Default)
10 | object hljs extends js.Object {
11 |
12 | def highlightElement(element: dom.HTMLElement): Unit = js.native
13 |
14 | def registerLanguage(name: String, language: HljsLanguage): Unit = js.native
15 |
16 | }
17 |
--------------------------------------------------------------------------------
/examples/server/src/main/scala/samples/HttpApi.scala:
--------------------------------------------------------------------------------
1 | package samples
2 |
3 | import zio.*
4 | import sttp.tapir.server.ServerEndpoint
5 |
6 | object HttpApi {
7 | def gatherRoutes(
8 | controllers: List[BaseController]
9 | ): List[ServerEndpoint[Any, Task]] =
10 | controllers.flatMap(_.routes)
11 |
12 | def makeControllers = for healthController <- HealthController.makeZIO
13 | yield List(healthController)
14 |
15 | val endpointsZIO = makeControllers.map(gatherRoutes)
16 | }
17 |
--------------------------------------------------------------------------------
/examples/server/src/main/scala/samples/HealthController.scala:
--------------------------------------------------------------------------------
1 | package samples
2 |
3 | import zio.*
4 | import sttp.tapir.*
5 | import sttp.tapir.server.ServerEndpoint
6 |
7 | class HealthController private extends BaseController with HealthEndpoint {
8 |
9 | val health = healthEndpoint
10 | .serverLogicSuccess[Task](_ => ZIO.succeed("OK"))
11 | override val routes: List[ServerEndpoint[Any, Task]] = List(health)
12 | }
13 |
14 | object HealthController {
15 | val makeZIO = ZIO.succeed(new HealthController)
16 | }
17 |
--------------------------------------------------------------------------------
/scripts/fastLink.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | . ./scripts/target/build-env.sh
4 |
5 | echo -n "Waiting for npm dev server to start."
6 |
7 | until [ -e $NPM_DEV_PATH ]; do
8 | echo -n "."
9 | sleep 2
10 | done
11 |
12 | echo " ✅"
13 | echo "NPM dev server started."
14 | echo "Waiting for client-fastopt/main.js to be generated."
15 |
16 | until [ -e $MAIN_JS_PATH ]; do
17 | echo -n "."
18 | sleep 2
19 | done
20 | echo " ✅"
21 | echo "⏱️ Watching client-fastopt/main.js for changes..."
22 |
23 |
24 | DEV=1 sbt '~client/fastLinkJS'
25 |
--------------------------------------------------------------------------------
/examples/generator/src/main/twirl/index.scala.html:
--------------------------------------------------------------------------------
1 | @(title: String)
2 |
3 |
4 |
5 |
6 |
7 | @title
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/modules/core/src/main/scala/dev/cheleb/scalamigen/config/PanelConfig.scala:
--------------------------------------------------------------------------------
1 | package dev.cheleb.scalamigen.config
2 |
3 | final case class PanelConfig(
4 | label: Option[String],
5 | asTable: Boolean,
6 | fieldCss: String = "srf-field",
7 | labelCss: String = "srf-label",
8 | panelCss: String = "srf-panel"
9 | ) {
10 | def withLabel(label: String): PanelConfig = copy(label = Some(label))
11 | def withAsTable(asTable: Boolean): PanelConfig = copy(asTable = asTable)
12 | def withFieldCss(fieldCss: String): PanelConfig = copy(fieldCss = fieldCss)
13 | def withLabelCss(labelCss: String): PanelConfig = copy(labelCss = labelCss)
14 | def withPanelCss(panelCss: String): PanelConfig = copy(panelCss = panelCss)
15 | }
16 |
--------------------------------------------------------------------------------
/.github/workflows/website.yml:
--------------------------------------------------------------------------------
1 | # Simple workflow for deploying static content to GitHub Pages
2 | name: Deploy static content to Pages
3 |
4 | on:
5 | # Runs on pushes targeting the default branch
6 | push:
7 | branches: ["master", "website"]
8 |
9 | # Allows you to run this workflow manually from the Actions tab
10 | workflow_dispatch:
11 |
12 | # Sets the GITHUB_TOKEN permissions to allow deployment to GitHub Pages
13 | permissions:
14 | contents: read
15 | pages: write
16 | id-token: write
17 |
18 |
19 |
20 | jobs:
21 | # Single deploy job since we're just deploying
22 | deploy:
23 |
24 | uses: WorldOfScala/actions/.github/workflows/gh-pages.yml@master
25 | with:
26 | script: ./scripts/gh-pages.sh
27 | secrets: inherit
28 |
--------------------------------------------------------------------------------
/examples/client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "laminar-form-derivation",
3 | "private": true,
4 | "version": "0.0.1",
5 | "main": "index.js",
6 | "type": "module",
7 | "scripts": {
8 | "dev": "vite",
9 | "build": "vite build && mv dist ../../docs/_assets/demo",
10 | "preview": "vite preview"
11 | },
12 | "license": "MIT",
13 | "dependencies": {
14 | "@awesome.me/webawesome": "^3.0.0",
15 | "@ui5/webcomponents": "2.1.0",
16 | "@ui5/webcomponents-fiori": "2.1.0",
17 | "@ui5/webcomponents-icons": "2.1.0",
18 | "highlight.js": "^11.11.0",
19 | "jsdom": "^25.0.1"
20 | },
21 | "devDependencies": {
22 | "@scala-js/vite-plugin-scalajs": "^1.1.0",
23 | "patch-package": "^8.0.1",
24 | "vite": "^7.2.2"
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/examples/server/src/main/scala/samples/SampleServer.scala:
--------------------------------------------------------------------------------
1 | package samples
2 |
3 | import zio.*
4 | import zio.http.*
5 | import sttp.tapir.files.*
6 | import sttp.tapir.*
7 | import sttp.tapir.server.ziohttp.*
8 |
9 | object SampleServer extends ZIOAppDefault {
10 |
11 | val webJarRoutes = staticResourcesGetServerEndpoint[Task]("public")(
12 | this.getClass.getClassLoader,
13 | "public"
14 | )
15 |
16 | val serrverProgram =
17 | for
18 | endpoints <- HttpApi.endpointsZIO
19 | _ <- Server.serve(
20 | ZioHttpInterpreter(ZioHttpServerOptions.default)
21 | .toHttp(webJarRoutes :: endpoints)
22 | )
23 | yield ()
24 |
25 | override def run =
26 | serrverProgram
27 | .provide(
28 | Server.default
29 | )
30 | }
31 |
--------------------------------------------------------------------------------
/modules/shared/src/main/scala/dev/cheleb/scalamigen/Annotations.scala:
--------------------------------------------------------------------------------
1 | package dev.cheleb.scalamigen
2 |
3 | import scala.annotation.StaticAnnotation
4 |
5 | /** Annotation for field names
6 | *
7 | * Use this annotation to specify field names for case class fields.
8 | */
9 | class FieldName(val value: String) extends StaticAnnotation
10 |
11 | /** @param name
12 | */
13 | case class Panel(
14 | name: String,
15 | asTable: Boolean = true,
16 | fieldCss: String = "srf-field",
17 | labelCss: String = "srf-label",
18 | panelCss: String = "srf-panel"
19 | ) extends StaticAnnotation
20 |
21 | /** */
22 | case class NoPanel(
23 | asTable: Boolean = true,
24 | fieldCss: String = "srf-field",
25 | labelCss: String = "srf-label",
26 | panelCss: String = "srf-panel"
27 | ) extends StaticAnnotation
28 |
--------------------------------------------------------------------------------
/examples/client/vite.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import scalaJSPlugin from "@scala-js/vite-plugin-scalajs";
3 |
4 | export default defineConfig({
5 | plugins: [scalaJSPlugin({
6 | // path to the directory containing the sbt build
7 | // default: '.'
8 | cwd: '../..',
9 |
10 | // sbt project ID from within the sbt build to get fast/fullLinkJS from
11 | // default: the root project of the sbt build
12 | projectID: 'client',
13 |
14 | // URI prefix of imports that this plugin catches (without the trailing ':')
15 | // default: 'scalajs' (so the plugin recognizes URIs starting with 'scalajs:')
16 | uriPrefix: 'scalajs',
17 | })],
18 | build: {
19 | sourcemap: true,
20 | },
21 | base: "/laminar-form-derivation/demo",
22 |
23 | });
24 |
25 |
--------------------------------------------------------------------------------
/examples/client/src/main/scala/samples/ListElement.scala:
--------------------------------------------------------------------------------
1 | package samples
2 |
3 | import com.raquo.laminar.api.L.*
4 | import dev.cheleb.scalamigen.*
5 |
6 | def list(using
7 | wf: WidgetFactory
8 | ): Sample = {
9 | case class Person2(id: Int, name: String, age: Int)
10 |
11 | case class ListElement(
12 | ints: List[Person2]
13 | )
14 |
15 | val listPersonVar = Var(
16 | ListElement(List(1, 2, 3).map(id => Person2(id, "Vlad", 20)))
17 | )
18 |
19 | Sample(
20 | "List",
21 | listPersonVar.asForm,
22 | div(child <-- listPersonVar.signal.map { item =>
23 | div(
24 | s"$item"
25 | )
26 | }),
27 | """|case class Person2(id: Int, name: String, age: Int)
28 | |
29 | |case class ListElement(
30 | | ints: List[Person2]
31 | |)
32 | |""".stripMargin
33 | )
34 |
35 | }
36 |
--------------------------------------------------------------------------------
/examples/client/style.css:
--------------------------------------------------------------------------------
1 | #root {
2 | font-family: Quicksand, Helvetica, Arial, sans-serif;
3 | -webkit-font-smoothing: antialiased;
4 | -moz-osx-font-smoothing: grayscale;
5 | }
6 |
7 | body {
8 | margin: 0;
9 | }
10 |
11 | .srf-form {
12 | border: 1px solid gray;
13 | }
14 |
15 | .srf-panel {
16 | border: 1px solid gray;
17 | }
18 |
19 | .srf-table {
20 | border: 1px solid gray;
21 | border-radius: 10px;
22 | }
23 |
24 | .srf-field {
25 | padding-left: 1em;
26 | padding-right: 1em;
27 | }
28 |
29 | /** Reducing the size of ui5 inputs of type number to only 100px */
30 | ui5-input[type="Number"] {
31 | width: 100px;
32 | min-width: 100px;
33 | }
34 |
35 | .srf-form:has(.srf-invalid) {
36 |
37 | border: 1px solid red;
38 |
39 | }
40 |
41 | .srf-valid {
42 | border: 1px solid transparent;
43 | }
44 |
45 | .srf-invalid {
46 | border: 1px solid red;
47 | }
--------------------------------------------------------------------------------
/docs/_assets/images/github-mark.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/examples/client/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
14 | Demo Laminar SAP UI5 bindings
15 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/examples/client/awesome.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
14 | Demo Laminar SAP UI5 bindings
15 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | push:
5 | tags:
6 | - "v*"
7 |
8 | jobs:
9 | publish:
10 | if: github.event.base_ref=='refs/heads/master'
11 | runs-on: ubuntu-22.04
12 | steps:
13 | - name: Checkout
14 | uses: actions/checkout@v6
15 | - name: Setup JVM
16 | uses: actions/setup-java@v5
17 | with:
18 | java-version: "22"
19 | distribution: "zulu"
20 | - name: Install sbt
21 | uses: sbt/setup-sbt@v1
22 | - name: Setup Node
23 | uses: actions/setup-node@v6
24 | with:
25 | node-version: 21
26 | - name: Release
27 | run: sbt ci-release
28 |
29 | env:
30 | PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }}
31 | PGP_SECRET: ${{ secrets.PGP_SECRET }}
32 | SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }}
33 | SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }}
34 | NODE_OPTIONS: "--openssl-legacy-provider"
35 |
--------------------------------------------------------------------------------
/docs/_docs/contributing/vscode-setup.md:
--------------------------------------------------------------------------------
1 | # VSCode development Setup
2 |
3 | This document explains the setup process when opening the project in VSCode.
4 |
5 |
6 |
7 | 
8 |
9 | The sequence diagram shows:
10 |
11 | When VSCode opens the project folder, it automatically triggers the "demo" task
12 |
13 |
14 | The "demo" task runs the "setup" task first, then the "runDemo" task
15 | * The "setup" task runs the Scala script that checks build environment and node modules
16 | * The "runDemo" task runs "fastLink" and "npmDev" tasks in parallel
17 | * "fastLink" waits for the npm dev server to start and then runs the Scala.js fastLink compilation
18 | * "npmDev" starts the npm development server for the client
19 |
20 | The key files involved are:
21 |
22 | * .vscode/tasks.json - Defines the task dependencies and execution order
23 | * scripts/setup.sc - Handles project setup and environment checks
24 | * scripts/fastLink.sh - Manages the Scala.js compilation process
25 | * scripts/npmDev.sh - Starts the npm development server
--------------------------------------------------------------------------------
/examples/client/src/main/scala/samples/OpaqueType.scala:
--------------------------------------------------------------------------------
1 | package samples
2 |
3 | import com.raquo.laminar.api.L.*
4 | import dev.cheleb.scalamigen.*
5 |
6 | def opaque(using
7 | wf: WidgetFactory
8 | ): Sample = {
9 |
10 | case class Person(
11 | name: String,
12 | password: Password
13 | )
14 |
15 | val simpleVar = Var(Person("Vlad", Password("123456")))
16 | Sample(
17 | "Opaque Type",
18 | simpleVar.asForm,
19 | div(
20 | child <-- simpleVar.signal.map { item =>
21 | div(
22 | s"$item"
23 | )
24 | }
25 | ),
26 | """
27 | |opaque type Password = String
28 | |object Password:
29 | | def apply(password: String): Password = password
30 | | given Form[Password] = secretForm(apply)
31 | |
32 | |// In another file...
33 | |
34 | |case class Person(
35 | | name: String,
36 | | password: Password
37 | | )
38 | |
39 | |val simpleVar = Var(Person("Vlad", Password("123456"))
40 | |
41 | |simpleVar.asForm
42 | """.stripMargin
43 | )
44 | }
45 |
--------------------------------------------------------------------------------
/modules/core/src/main/scala/dev/cheleb/scalamigen/IronTypeValidator.scala:
--------------------------------------------------------------------------------
1 | package dev.cheleb.scalamigen
2 |
3 | import io.github.iltotore.iron.*
4 |
5 | /** Type validator for
6 | * [IronType](https://iltotore.github.io/iron/docs/index.html).
7 | *
8 | * Iron is a library for compile-time or runtime type validation.
9 | */
10 | trait IronTypeValidator[T, C] {
11 |
12 | /** Validate a string against an IronType.
13 | */
14 | def validate(a: String): Either[String, IronType[T, C]]
15 | }
16 |
17 | object IronTypeValidator {
18 |
19 | /** Create an IronTypeValidator for a given IronType.
20 | *
21 | * @param baseValidator
22 | * @param constraint
23 | * @return
24 | */
25 | given [A, C](using
26 | baseValidator: Validator[A],
27 | constraint: RuntimeConstraint[A, C]
28 | ): IronTypeValidator[A, C] with
29 | def validate(a: String): Either[String, IronType[A, C]] =
30 | baseValidator.validate(a).flatMap { a =>
31 | if constraint.test(a) then Right(a.assume[C])
32 | else Left(constraint.message)
33 | }
34 |
35 | }
36 |
--------------------------------------------------------------------------------
/examples/client/src/main/scala/samples/SimpleSample.scala:
--------------------------------------------------------------------------------
1 | package samples
2 |
3 | import com.raquo.laminar.api.L.*
4 | import dev.cheleb.scalamigen.*
5 |
6 | import io.github.iltotore.iron.*
7 | import io.github.iltotore.iron.constraint.all.*
8 |
9 | def simple(using
10 | wf: WidgetFactory
11 | ): Sample = {
12 |
13 | case class Cat(
14 | name: String,
15 | weight: Int,
16 | hairsCount: BigInt :| GreaterEqual[100000],
17 | kind: Boolean = true
18 | )
19 |
20 | val simpleVar = Var(Cat("Scala le chat", 6, BigInt(100000).refineUnsafe))
21 | Sample(
22 | "Simple",
23 | simpleVar.asForm,
24 | div(
25 | child <-- simpleVar.signal.map { item =>
26 | div(
27 | s"$item"
28 | )
29 | }
30 | ),
31 | """
32 | |case class Cat(
33 | | name: String,
34 | | weight: Int,
35 | | hairsCount: BigInt :| GreaterEqual[100000],
36 | | kind: Boolean = true)
37 | |
38 | |val simpleVar = Var(Cat("Scala le chat", 6, BigInt(100000).refineUnsafe))
39 | |
40 | |simpleVar.asForm
41 | """.stripMargin
42 | )
43 | }
44 |
--------------------------------------------------------------------------------
/docs/_docs/contributing/index.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Contributing
3 | description: Guidelines for contributing to the project
4 | ---
5 | # Contributing to the project
6 |
7 | Thank you for your interest in contributing to the project! We welcome contributions from everyone.
8 |
9 | I made this document to help you set up your development environment.
10 |
11 | ## Setting up the development environment
12 |
13 | My favorite IDE is [Visual Studio Code](https://code.visualstudio.com/). But you can use any IDE you like.
14 |
15 | ### VSCode
16 |
17 | 1. Install [Visual Studio Code](https://code.visualstudio.com/).
18 | 2. Install the following extensions:
19 | - [Metals](https://marketplace.visualstudio.com/items?itemName=scalameta.metals)
20 |
21 |
22 | Clone the repository and open it in VSCode.
23 |
24 | Should be good to go, see [the magic](.vscode/tasks.json) explained [here](vscode-setup.md).
25 |
26 |
27 |
28 | ### IntelliJ IDEA
29 |
30 | TBD: Help me here.
31 |
32 | ## Prerequisites
33 |
34 | - [Node.js](https://nodejs.org/en/download/) (v23 or higher)
35 | - [Scala](https://www.scala-lang.org/download/) v3.3 or higher
36 | - [sbt](https://www.scala-sbt.org/download.html) (v1.11.x or higher)
--------------------------------------------------------------------------------
/docs/_docs/getting-started.md:
--------------------------------------------------------------------------------
1 | # Getting Started
2 |
3 | ## Installation
4 |
5 | ```sbt
6 | // With raw Laminar widgets (html only)
7 | libraryDependencies += "dev.cheleb" %%% "laminar-form-derivation-ui" % "{{ projectVersion }}"
8 | // With UI5 Web Components
9 | libraryDependencies += "dev.cheleb" %%% "laminar-form-derivation-ui5" % "{{ projectVersion}}"
10 | // With UI5 Web Components from nguyenyou
11 | libraryDependencies += "dev.cheleb" %%% "laminar-form-derivation-ui5-nguyenyou" % "{{ projectVersion}}"
12 | ```
13 |
14 | Annoations allow to customize the form rendering. They are part of the `laminar-form-derivation-shared` package.
15 |
16 | ```sbt
17 | libraryDependencies += "dev.cheleb" %%% "laminar-form-derivation-shared" % "{{ projectVersion }}"
18 | ```
19 |
20 | ## Sample
21 |
22 | ```scala sc:nocompile
23 | import com.raquo.laminar.api.L.*
24 | import dev.cheleb.scalamigen.*
25 |
26 | val eitherVar = Var(Cat("Scala le chat", 6))
27 | div(
28 | child <-- eitherVar.signal.map { item =>
29 | div(
30 | s"$item" // (1) debug case class
31 | )
32 | },
33 | eitherVar.asForm // (2) form rendering
34 | )
35 | ```
36 |
37 | Will be rendered as:
38 |
39 | 
--------------------------------------------------------------------------------
/examples/client/src/main/scala/samples/EitherSample.scala:
--------------------------------------------------------------------------------
1 | package samples
2 |
3 | import dev.cheleb.scalamigen.*
4 | import com.raquo.laminar.api.L.*
5 |
6 | def either(using
7 | wf: WidgetFactory
8 | ): Sample = {
9 |
10 | case class Cat(name: String, age: Int)
11 | case class Dog(name: String, age: Int)
12 | given Defaultable[Cat] with
13 | def default = Cat("", 0)
14 |
15 | given Defaultable[Dog] with
16 | def default = Dog("", 0)
17 |
18 | @Panel("Either", false)
19 | case class EitherSample(
20 | either: Either[Cat, Dog],
21 | // primitiveEither: Either[Cat, String],
22 | optionalInt: Option[Int]
23 | )
24 |
25 | val eitherVar = Var(
26 | EitherSample(
27 | Left(Cat("Scala le chat", 6)),
28 | // Right("Forty two"),
29 | Some(1)
30 | )
31 | )
32 | Sample(
33 | "Either",
34 | eitherVar.asForm,
35 | div(child <-- eitherVar.signal.map { item =>
36 | div(
37 | s"$item"
38 | )
39 | }),
40 | """|
41 | |@Panel("Either", false)
42 | |case class EitherSample(
43 | | either: Either[Cat, Dog],
44 | | primitiveEither: Either[Cat, String],
45 | | optionalInt: Option[Int]
46 | )""".stripMargin
47 | )
48 |
49 | }
50 |
--------------------------------------------------------------------------------
/examples/generator/src/main/scala/samples/TwirlTemplate.scala:
--------------------------------------------------------------------------------
1 | package samples
2 |
3 | import java.io.File
4 |
5 | import io.*
6 | import org.slf4j.LoggerFactory
7 |
8 | case class Config(
9 | title: String = "",
10 | resourceManaged: File = new File("target/")
11 | )
12 |
13 | @main
14 | def BuildIndex(args: String*): Unit = {
15 | val logger = LoggerFactory.getLogger("TwirlTemplate")
16 |
17 | val parser = new scopt.OptionParser[Config]("scopt") {
18 | head("scopt", "3.x")
19 | opt[String]("title")
20 | .action((title, config) =>
21 | config
22 | .copy(title = title)
23 | )
24 | .required()
25 | opt[File]("resource-managed")
26 | .action((srcManaged, config) =>
27 | config
28 | .copy(resourceManaged = srcManaged)
29 | )
30 | .required()
31 | help("help")
32 | }
33 |
34 | parser.parse(args, Config()) match {
35 | case None =>
36 | logger.error(parser.usage)
37 | case Some(config) =>
38 | config.resourceManaged.mkdirs()
39 | val indexFile = os.Path(config.resourceManaged) / "index.html"
40 | logger.info(s"Writing $indexFile")
41 | os.write.over(
42 | indexFile,
43 | html.index.apply(config.title).body
44 | )
45 |
46 | }
47 |
48 | }
49 |
--------------------------------------------------------------------------------
/examples/client/src/main/scala/samples/package.scala:
--------------------------------------------------------------------------------
1 | package samples
2 |
3 | import dev.cheleb.scalamigen.*
4 |
5 | opaque type CurrencyCode = String
6 |
7 | object CurrencyCode:
8 | def apply(code: String): CurrencyCode = code
9 |
10 | opaque type Password = String
11 | object Password:
12 | def apply(password: String): Password = password
13 | given Form[Password] = secretForm(apply)
14 |
15 | opaque type ExtraString = String
16 | object ExtraString:
17 | def apply(s: String): ExtraString = s
18 | // given Form[ExtraString] = stringForm(identity)
19 | given Form[ExtraString] = stringFormWithValidation(using
20 | new Validator[ExtraString] {
21 | override def validate(str: String): Either[String, ExtraString] =
22 | str.matches("^[a-fA-F0-9]+$") match
23 | case true => Right(str)
24 | case false => Left("expected hexadecimal string (just for demo)")
25 | }
26 | )
27 | given Defaultable[ExtraString] with
28 | def default: ExtraString = ""
29 |
30 | opaque type ExtraInt = Int
31 | object ExtraInt:
32 | def apply(i: Int): ExtraInt = i
33 | given Form[ExtraInt] = numericForm(_.toIntOption, 0)
34 | given Defaultable[ExtraInt] with
35 | def default: ExtraInt = 0
36 |
37 | //given WidgetFactory = if true then UI5WidgetFactory else LaminarWidgetFactory
38 |
--------------------------------------------------------------------------------
/docs/_docs/index.md:
--------------------------------------------------------------------------------
1 | ---
2 | layout: main
3 | ---
4 |
5 | ## Laminar Form Derivation
6 |
7 |
8 | Laminar Form Derivation is a library that allows you to derive HTML form from a case class.
9 |
10 |
11 |
12 | 🚀 Click me for a live demo
13 |
14 |
15 |
16 |
17 |
18 | It depends on:
19 | * [Scala 3](https://docs.scala-lang.org/scala3/) for compile-time metaprogramming and [ScalaJS](https://www.scala-js.org/) for client-side rendering.
20 | * [Laminar](https://laminar.dev)
21 | * [Magnolia](https://github.com/softwaremill/magnolia) by [SoftwareMill](https://softwaremill.com/)
22 | * originally [John Pretty](https://pretty.direct)
23 |
24 | Optionaly:
25 | * [UI5 Web Components](https://sap.github.io/ui5-webcomponents/) and [ScalaJS binding](https://github.com/sherpal/laminarsapui5bindings)
26 | * [UI5 Web Components from Nguyenyou](https://github.com/nguyenyou/ui5-webcomponents-laminar)
27 | * [WebAwesome aka ShoeLace 3](https://webawesome-laminar.vercel.app/docs/components/button)
28 |
29 | ## Credits
30 |
31 | Incredible thanks to incredible people who made this possible, authors and contributors of:
32 |
33 | * librairies this project depends on !
34 | * Strongly inspired by non less incredible [Kit](https://www.youtube.com/watch?v=JHriftPO62I)
35 |
36 |
--------------------------------------------------------------------------------
/docs/_docs/contributing/setup.puml:
--------------------------------------------------------------------------------
1 | @startuml vscode-setup
2 |
3 | !include
4 | title VSCode Startup Sequence for Laminar Form Derivation
5 |
6 | actor User
7 | participant "VSCode" as vscode
8 |
9 |
10 | control "Tasks" as demo
11 | participant "Scala CLI" as scalaCLI
12 | control "runDemo" as runDemo
13 | control "fastLink" as fastLink
14 | control "npmDev" as npmDev
15 | participant "sbt" as sbt
16 | participant "Vite" as Vite
17 | participant "<$firefox>" as firefox
18 |
19 | User -> vscode: Opens project folder
20 | activate vscode
21 |
22 | vscode -> demo: folderOpen
23 |
24 | demo -> scalaCLI: setup
25 | deactivate vscode
26 | activate scalaCLI
27 | 'scalaCLI -> scalaCLI: setup.sc
28 | note right: npm i / sbt
29 | scalaCLI -> demo
30 | deactivate scalaCLI
31 | demo -> runDemo
32 | activate runDemo
33 | runDemo -> npmDev
34 | activate npmDev
35 | runDemo -> fastLink
36 | deactivate runDemo
37 | activate fastLink
38 | note left: wait fastOptJS
39 | activate fastLink #LightBlue
40 | activate Vite
41 | npmDev -/ Vite
42 |
43 | activate sbt
44 |
45 | Vite -> sbt: fastOptJS
46 | sbt -/ fastLink
47 | deactivate fastLink
48 |
49 | loop
50 | fastLink -/ sbt: ~client/fastLinkJS
51 | sbt -> Vite: export ESModule
52 | Vite -> firefox
53 | end
54 | deactivate Vite
55 | deactivate sbt
56 | @enduml
57 |
--------------------------------------------------------------------------------
/project/plugins.sbt:
--------------------------------------------------------------------------------
1 | // scalafmt: { maxColumn = 120, style = defaultWithAlign }
2 |
3 | addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.20.1")
4 | addSbtPlugin("org.scala-js" % "sbt-jsdependencies" % "1.0.2")
5 |
6 | addSbtPlugin("ch.epfl.scala" % "sbt-scalajs-bundler" % "0.21.1")
7 | addSbtPlugin("ch.epfl.scala" % "sbt-web-scalajs-bundler" % "0.21.1")
8 |
9 | addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.6")
10 | addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "3.12.2")
11 | addSbtPlugin("com.github.sbt" % "sbt-ci-release" % "1.11.2")
12 | addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.3.1")
13 | addSbtPlugin("com.github.sbt" % "sbt-native-packager" % "1.11.4")
14 | //addSbtPlugin("com.typesafe.sbteclipse" % "sbteclipse-plugin" % "5.2.4")
15 | addSbtPlugin("org.playframework.twirl" % "sbt-twirl" % "2.0.9")
16 | addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.3.2")
17 | //addSbtPlugin("io.spray" % "sbt-revolver" % "0.10.0")
18 | addSbtPlugin("com.github.sbt" % "sbt-dynver" % "5.1.1")
19 | // Documentation plugins
20 | addSbtPlugin("com.github.sbt" % "sbt-unidoc" % "0.6.0")
21 | addSbtPlugin("com.github.sbt" % "sbt-ghpages" % "0.9.0")
22 | addSbtPlugin("org.scalameta" % "sbt-mdoc" % "2.8.1")
23 | addSbtPlugin("dev.cheleb" % "sbt-plantuml" % "0.2.0")
24 |
--------------------------------------------------------------------------------
/website.sbt:
--------------------------------------------------------------------------------
1 | addCommandAlias("website", "docs/mdoc; makeSite")
2 |
3 | lazy val currentYear: String =
4 | java.util.Calendar.getInstance().get(java.util.Calendar.YEAR).toString
5 |
6 | enablePlugins(
7 | SiteScaladocPlugin,
8 | SitePreviewPlugin,
9 | ScalaUnidocPlugin,
10 | GhpagesPlugin
11 | )
12 |
13 | ScalaUnidoc / siteSubdirName := ""
14 | addMappingsToSiteDir(
15 | ScalaUnidoc / packageDoc / mappings,
16 | ScalaUnidoc / siteSubdirName
17 | )
18 | git.remoteRepo := "git@github.com:cheleb/laminar-form-derivation.git"
19 | ghpagesNoJekyll := true
20 | Compile / doc / scalacOptions ++= Seq(
21 | "-siteroot",
22 | "laminar-form-derivation-docs/target/mdoc",
23 | "-project",
24 | "Laminar Form Derivation",
25 | "-groups",
26 | "-project-version",
27 | sys.env.getOrElse("VERSION", version.value),
28 | "-revision",
29 | version.value,
30 | // "-default-templates",
31 | // "static-site-main",
32 | "-project-footer",
33 | s"Copyright (c) 2022-$currentYear, Olivier NOUGUIER",
34 | // custom::https://www.linkedin.com/in/olivier-nouguier::linkedinday.png::linkedinnight.png
35 | "-social-links:github::https://github.com/cheleb/laminar-form-derivation,twitter::https://twitter.com/oNouguier",
36 | "-Ygenerate-inkuire",
37 | "-skip-by-regex:facades\\..*",
38 | "-skip-by-regex:samples\\..*",
39 | "-skip-by-regex:html\\..*",
40 | "-snippet-compiler:compile"
41 | )
42 |
--------------------------------------------------------------------------------
/modules/core/src/main/scala/dev/cheleb/scalamigen/ValidationEvent.scala:
--------------------------------------------------------------------------------
1 | package dev.cheleb.scalamigen
2 |
3 | /** A sealed trait representing the possible events that can be emitted by a
4 | * validation.
5 | *
6 | * Events are emitted by the validation of a field. They are used to update the
7 | * state.
8 | */
9 | trait ValidationEvent
10 |
11 | /** The event emitted when the validation is successful.
12 | *
13 | * It will clear any error message for a field.
14 | */
15 | case object ValidEvent extends ValidationEvent
16 |
17 | /** The event emitted when the validation is unsuccessful.
18 | *
19 | * @param errorMessage
20 | * The error message.
21 | */
22 | final case class InvalideEvent(errorMessage: String) extends ValidationEvent
23 |
24 | /** The event emitted when the field is hidden (when Option is set to None).
25 | *
26 | * It will then ignore any validation status.
27 | */
28 | case object HiddenEvent extends ValidationEvent
29 |
30 | /** The event emitted when the field is shown (when Option is set to Some).
31 | *
32 | * It will then redem any validation status.
33 | */
34 | case object ShownEvent extends ValidationEvent
35 |
36 | /** Validation status.
37 | *
38 | * It is used to determine the status of a field.
39 | */
40 | enum ValidationStatus:
41 | case Unknown
42 | case Valid
43 | case Invalid(message: String, visible: Boolean)
44 | case Hidden(first: Boolean)
45 | case Shown
46 |
--------------------------------------------------------------------------------
/docs/_docs/usage.md:
--------------------------------------------------------------------------------
1 | # Usage
2 |
3 | ## Basic usage
4 |
5 | ```scala sc:nocompile
6 | import dev.cheleb.scalamigen.*
7 | ```
8 | This import is necessary to bring the implicit conversions into scope.
9 |
10 | Now you can use the `asForm` method on any `Var` to render a form for the value it holds with double binding.
11 |
12 | ```scala sc:nocompile
13 |
14 | case class Cat(name: String, age: Int)
15 |
16 | val eitherVar = Var(Cat("Scala le chat", 6))
17 |
18 | eitherVar.asForm // (2) form rendering
19 |
20 | ```
21 |
22 | As long as the case class is built on elments that have a `Form[..]` instance, the form will be rendered correctly.
23 |
24 | ## Supported types
25 |
26 | * `String`, `Int`, `Long`, `Double`, `Boolean`
27 | * `LocalDate`
28 | * `Option[A]`
29 | * `List[A]`
30 | * `Either[A, B]`
31 | * `IronType[T, C]`
32 | * `Positive`
33 |
34 | ## Customizing the form rendering
35 |
36 | At anytime you can customize the form rendering by providing a `Form` instance for the type you want to render.
37 |
38 | * `opaque type`
39 | ```scala sc:nocompile
40 | opaque type Password = String
41 | object Password:
42 | def apply(password: String): Password = password
43 | given Form[Password] = secretForm(apply) // (1) form instance
44 |
45 | ```
46 | * `IronType`
47 |
48 | Iron type are supported as long as their base type is supported by the library. For example, `Positive` is a wrapper around `Int` and `Long` and can be used as such.
49 |
50 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | pull_request:
5 | branches: [master]
6 |
7 | jobs:
8 | CI:
9 | runs-on: ubuntu-22.04
10 | steps:
11 | - name: Checkout
12 | uses: actions/checkout@v6
13 | - name: Setup JVM
14 | uses: actions/setup-java@v5
15 | with:
16 | java-version: "23"
17 | distribution: "zulu"
18 | cache: "sbt"
19 | - name: Install sbt
20 | uses: sbt/setup-sbt@v1
21 | - name: Setup Node
22 | uses: actions/setup-node@v6
23 | with:
24 | node-version: 21
25 | - name: Cache SBT
26 | uses: actions/cache@v5
27 | with:
28 | path: |
29 | ~/.ivy2/cache
30 | ~/.ivy2/local
31 | **/target/**
32 | ~/.sbt
33 | key: ${{ runner.os }}-sbt-${{ hashFiles('**/build.sbt', 'project/*.sbt', 'project/*.scala') }}
34 | restore-keys: |
35 | ${{ runner.os }}-sbt-
36 | - name: Cache node
37 | uses: actions/cache@v5
38 | with:
39 | path: |
40 | examples/client/node_modules
41 | key: ${{ runner.os }}-node-${{ hashFiles('example/client/package.json') }}
42 |
43 | - name: Install dependencies
44 | run: |
45 | cd examples/client
46 | npm install
47 | # - name: Tests
48 | # run: sbt +test
49 | - name: server/dist
50 | run: DEV=prod sbt server/dist
51 | env:
52 | NODE_OPTIONS: "--openssl-legacy-provider"
53 |
--------------------------------------------------------------------------------
/examples/client/src/main/scala/WebSocketDemo.scala:
--------------------------------------------------------------------------------
1 | package samples
2 |
3 | //import com.raquo.laminar.api.L.*
4 | //import be.doeraene.webcomponents.ui5.*
5 | //import be.doeraene.webcomponents.ui5.configkeys.*
6 | //import io.laminext.websocket._
7 | //import org.scalajs.dom.KeyCode
8 |
9 | object WebSocketDemo {
10 | /*
11 | private def sherpal =
12 | img(src := "images/avatars/ono.png", alt := "Ono")
13 |
14 | val ws =
15 | WebSocket
16 | .url("ws://localhost:8080/subscriptions")
17 | .string
18 | .build(managed = false)
19 |
20 | val nameVar = Var("")
21 | val inputElement = input(
22 | cls("new-todo"),
23 | placeholder("What needs to be done?"),
24 | autoFocus(true),
25 | inContext { thisNode =>
26 | // Note: mapTo below accepts parameter by-name, evaluating it on every enter key press
27 | onKeyPress
28 | .filter(_.keyCode == KeyCode.Enter)
29 | .mapTo(thisNode.ref.value)
30 | .filter(_.nonEmpty) --> { text =>
31 | thisNode.ref.value = "" // clear input
32 | ws.sendOne(text)
33 | }
34 | }
35 | )
36 |
37 | val wsPanel = div(
38 | span(
39 | Avatar(sherpal),
40 | Button("Connect!", _.events.onClick --> ws.reconnect)
41 | ),
42 | Panel(
43 | width := "50%",
44 | _.headerText := "Both expandable and expanded",
45 | children.command <-- ws.received.map { msg =>
46 | CollectionCommand.Append(
47 | div(Label(_.wrappingType := WrappingType.Normal, msg))
48 | )
49 | }
50 | ),
51 | span(
52 | inputElement,
53 | button(
54 | "Sendeee"
55 | )
56 | )
57 | )
58 | */
59 | }
60 |
--------------------------------------------------------------------------------
/modules/core/src/main/scala/dev/cheleb/scalamigen/Defaultable.scala:
--------------------------------------------------------------------------------
1 | package dev.cheleb.scalamigen
2 |
3 | import io.github.iltotore.iron.*
4 | import io.github.iltotore.iron.constraint.all.*
5 |
6 | /** Typeclass for default values.
7 | *
8 | * This typeclass is used to provide default values for a given type. It is
9 | * used to provide default values for form fields when creating a new object,
10 | * for example.
11 | *
12 | * It is necessary to provide a default value for every type used in the
13 | * application and wrapped in a option.
14 | */
15 | trait Defaultable[A] {
16 |
17 | /** The default value for the type.
18 | */
19 | def default: A
20 |
21 | /** The label for the type.
22 | */
23 | def label: String =
24 | NameUtils.titleCase(default.getClass.getSimpleName)
25 | }
26 |
27 | object Defaultable {
28 |
29 | given Defaultable[Boolean] with
30 | def default = false
31 |
32 | /** Default value for Int is 0.
33 | */
34 | given Defaultable[Int] with
35 | def default = 0
36 |
37 | given Defaultable[Double] with
38 | def default = 0
39 |
40 | given Defaultable[Float] with
41 | def default = 0
42 |
43 | given Defaultable[BigDecimal] with
44 | def default = 0
45 | given Defaultable[BigInt] with
46 | def default = 0
47 |
48 | /** Default value for String is "".
49 | */
50 | given Defaultable[String] with
51 | def default = ""
52 |
53 | /** Default value for [Iron type Double
54 | * positive](https://iltotore.github.io/iron/io/github/iltotore/iron/constraint/numeric$.html#Positive-0)
55 | * is 0.0.
56 | */
57 | given Defaultable[IronType[Double, Positive]] with
58 | def default = 1.0.refineUnsafe[Positive]
59 |
60 | }
61 |
--------------------------------------------------------------------------------
/modules/core/src/main/scala/dev/cheleb/scalamigen/Validator.scala:
--------------------------------------------------------------------------------
1 | package dev.cheleb.scalamigen
2 |
3 | import scala.util.Try
4 |
5 | /** A trait representing a validator for a type A.
6 | *
7 | * A validator is a function that takes a string and returns either an error
8 | * message or a value of type A.
9 | */
10 | trait Validator[A] {
11 | def validate(str: String): Either[String, A]
12 | }
13 |
14 | /** Validators for common types. They are the base validation used by Iron
15 | * derivations.
16 | */
17 | object Validator {
18 |
19 | /** A validator for strings.
20 | */
21 | given Validator[String] with
22 | def validate(str: String): Either[String, String] =
23 | Right(str)
24 |
25 | /** A validator for doubles.
26 | */
27 | given Validator[Double] with
28 | def validate(str: String): Either[String, Double] =
29 | str.toDoubleOption.toRight("Not a double")
30 |
31 | /** A validator for integers.
32 | */
33 | given Validator[Int] with
34 | def validate(str: String): Either[String, Int] =
35 | str.toIntOption.toRight("Not a int")
36 |
37 | /** A validator for longs.
38 | */
39 | given Validator[Float] with
40 | def validate(str: String): Either[String, Float] =
41 | str.toFloatOption.toRight("Not a float")
42 |
43 | /** A validator for big integers.
44 | */
45 | given Validator[BigInt] with
46 | def validate(str: String): Either[String, BigInt] =
47 | Try(BigInt.apply(str)).toEither.left.map(_.getMessage)
48 |
49 | /** A validator for big decimals.
50 | */
51 | given Validator[BigDecimal] with
52 | def validate(str: String): Either[String, BigDecimal] =
53 | Try(BigDecimal.apply(str)).toEither.left.map(_.getMessage)
54 |
55 | }
56 |
--------------------------------------------------------------------------------
/.vscode/tasks.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "2.0.0",
3 | "tasks": [
4 | {
5 | "label": "demo",
6 | "runOptions": {
7 | "runOn": "folderOpen"
8 | },
9 | "dependsOrder": "sequence",
10 | "dependsOn": [
11 | "setup",
12 | "runDemo"
13 | ],
14 | "problemMatcher": [],
15 | "group": {
16 | "kind": "build"
17 | }
18 | },
19 | {
20 | "label": "setup",
21 | "type": "shell",
22 | "command": "./scripts/setup.sc",
23 | "presentation": {
24 | "panel": "dedicated",
25 | "group": "runDevCmd",
26 | "close": true
27 | },
28 | "group": "build"
29 | },
30 | {
31 | "label": "runDemo",
32 | "dependsOrder": "parallel",
33 | "dependsOn": [
34 | "fastLink",
35 | "npmDev"
36 | ],
37 | "problemMatcher": [],
38 | "group": {
39 | "kind": "build"
40 | }
41 | },
42 | {
43 | "label": "fastLink",
44 | "type": "shell",
45 | "command": "./scripts/fastLink.sh",
46 | "presentation": {
47 | "panel": "dedicated",
48 | "group": "runDevCmd"
49 | },
50 | "group": "build"
51 | },
52 | {
53 | "label": "npmDev",
54 | "type": "shell",
55 | "command": "./scripts/npmDev.sh",
56 | "presentation": {
57 | "panel": "dedicated",
58 | "group": "runDevCmd"
59 | },
60 | "group": "build"
61 | }
62 | ]
63 | }
--------------------------------------------------------------------------------
/examples/client/src/main/scala/samples/Index.scala:
--------------------------------------------------------------------------------
1 | package samples
2 |
3 | import org.scalajs.dom
4 | import com.raquo.laminar.api.L.*
5 | import io.github.nguyenyou.ui5.webcomponents.laminar.*
6 | import facades.highlightjs.{hljs, hljsScala}
7 | import dev.cheleb.scalamigen.WidgetFactory
8 |
9 | case class Sample(
10 | name: String,
11 | component: HtmlElement,
12 | debug: HtmlElement,
13 | source: String = "TODO"
14 | )
15 |
16 | object App extends App {
17 | hljs.registerLanguage("scala", hljsScala)
18 |
19 | def demos(using wf: WidgetFactory) = Seq(
20 | samples.simple,
21 | samples.opaque,
22 | samples.either,
23 | samples.validation,
24 | samples.conditional,
25 | samples.enums,
26 | samples.sealedClasses,
27 | samples.person,
28 | samples.list,
29 | // samples.tree,
30 | samples.adhoc
31 | )
32 |
33 | val demoVar = Var("ui5")
34 |
35 | val demo = div(
36 | SegmentedButton(
37 | _.accessibleName := "Map type",
38 | _.onSelectionChange --> { ev =>
39 | ev.detail.selectedItems.headOption
40 | .map(_.id)
41 | .foreach { id =>
42 | demoVar.set(id)
43 | }
44 | }
45 | )(
46 | SegmentedButtonItem(
47 | _.id := "native"
48 | )("Native"),
49 | SegmentedButtonItem(
50 | _.id := "ui5",
51 | _.selected := true
52 | )("UI5 Doreane"),
53 | SegmentedButtonItem(
54 | _.id := "ui5-nguyenyou"
55 | )("UI5 Nguyenyou"),
56 | SegmentedButtonItem(
57 | _.id := "webawesome"
58 | )("WebAwesome")
59 | ),
60 | child <-- demoVar.signal.map { id =>
61 | id match {
62 | case "native" =>
63 | div(
64 | h2("Native"),
65 | DemoNative()
66 | )
67 | case "ui5" =>
68 | div(
69 | h2("UI5 Doreane"),
70 | DemoDoreane()
71 | )
72 | case "ui5-nguyenyou" =>
73 | div(
74 | h2("UI5 Nguyenyou"),
75 | DemoNguyenyou()
76 | )
77 | case "webawesome" =>
78 | div(
79 | h2("WebAwesome"),
80 | DemoWebAwesome()
81 | )
82 | }
83 | }
84 | )
85 | val containerNode = dom.document.getElementById("app")
86 | render(containerNode, demo)
87 | }
88 |
--------------------------------------------------------------------------------
/modules/core/src/main/scala/dev/cheleb/scalamigen/LaminarWidgetFactory.scala:
--------------------------------------------------------------------------------
1 | package dev.cheleb.scalamigen
2 |
3 | import com.raquo.laminar.api.L.*
4 | import com.raquo.laminar.modifiers.EventListener
5 | import org.scalajs.dom.HTMLSelectElement
6 | import com.raquo.laminar.api.L
7 |
8 | /** This is raw laminar implementation of the widget factory.
9 | */
10 | object LaminarWidgetFactory extends WidgetFactory:
11 |
12 | override def renderCheckbox: L.HtmlElement = input(
13 | tpe := "checkbox"
14 | )
15 |
16 | override def renderDatePicker: HtmlElement = input(
17 | tpe := "date"
18 | )
19 |
20 | override def renderDialog(
21 | title: String,
22 | content: HtmlElement,
23 | openDialogBus: EventBus[Boolean]
24 | ): HtmlElement =
25 | div(
26 | className := "dialog",
27 | h2(title),
28 | content,
29 | button(
30 | "Close"
31 | // onClick --> closeObs
32 | )
33 | )
34 |
35 | override def renderSecret: HtmlElement = input(
36 | tpe := "password"
37 | )
38 |
39 | override def renderText: HtmlElement = input(
40 | tpe := "text"
41 | )
42 | override def renderLabel(required: Boolean, name: String): HtmlElement = span(
43 | name
44 | )
45 | override def renderNumeric: HtmlElement = input(
46 | tpe := "number"
47 | )
48 | override def renderButton: HtmlElement = button()
49 | override def renderLink(text: String, el: EventListener[?, ?]): HtmlElement =
50 | a(
51 | text,
52 | href := "#",
53 | el
54 | )
55 | override def renderUL(id: String): HtmlElement = ul(idAttr := id)
56 | override def renderPanel(headerText: Option[String]): HtmlElement =
57 | headerText match
58 | case None => div()
59 | case Some(headerText) =>
60 | div(
61 | headerText
62 | )
63 |
64 | override def renderSelect(
65 | selectedIndex: Int
66 | )(f: Int => Unit): HtmlElement =
67 | select(
68 | onChange.map(
69 | _.target.asInstanceOf[HTMLSelectElement].selectedIndex
70 | ) --> { ds =>
71 | f(ds)
72 | }
73 | )
74 |
75 | override def renderOption(
76 | label: String,
77 | idx: Int,
78 | isSelected: Boolean
79 | ): HtmlElement =
80 | option(
81 | label,
82 | value := s"$idx",
83 | selected := isSelected
84 | )
85 |
--------------------------------------------------------------------------------
/modules/core/src/main/scala/dev/cheleb/scalamigen/WidgetFactory.scala:
--------------------------------------------------------------------------------
1 | package dev.cheleb.scalamigen
2 |
3 | import com.raquo.laminar.api.L.HtmlElement
4 | import com.raquo.laminar.modifiers.EventListener
5 |
6 | import com.raquo.airstream.eventbus.EventBus
7 |
8 | /** This is a trait that defines the interface for the widget factory.
9 | */
10 | trait WidgetFactory:
11 |
12 | /** Render a checkbox.
13 | */
14 | def renderCheckbox: HtmlElement
15 |
16 | /** Render a date picker.
17 | */
18 | def renderDatePicker: HtmlElement
19 |
20 | def renderDialog(
21 | title: String,
22 | content: HtmlElement,
23 | openDialogBus: EventBus[Boolean]
24 | ): HtmlElement
25 |
26 | /** Render a text input, for strings.
27 | */
28 | def renderText: HtmlElement
29 |
30 | /** Render a password input, for secret strings.
31 | */
32 | def renderSecret: HtmlElement
33 |
34 | /** Render a label for a widget.
35 | */
36 | def renderLabel(required: Boolean, name: String): HtmlElement
37 |
38 | /** Render a numeric input, for numbers.
39 | */
40 | def renderNumeric: HtmlElement
41 |
42 | /** Render a button.
43 | */
44 | def renderButton: HtmlElement
45 |
46 | /** Render a link.
47 | */
48 | def renderLink(text: String, obs: EventListener[?, ?]): HtmlElement
49 |
50 | /** Render a panel. This is a container for other widgets derived from a case
51 | * class.
52 | */
53 | def renderPanel(headerText: Option[String]): HtmlElement
54 |
55 | /** Render an unordered list. This is a container for other widgets derived
56 | * from a case class.
57 | */
58 | def renderUL(id: String): HtmlElement
59 |
60 | /** Render a select.
61 | * @param selectedIndex
62 | * the index of the initially selected option
63 | * @param f
64 | * a callback function that is called when the selected option changes,
65 | * this function receives the new index as a parameter and should update
66 | * the model accordingly.
67 | */
68 | def renderSelect(selectedIndex: Int)(f: Int => Unit): HtmlElement
69 |
70 | /** Render an option.
71 | * @param label
72 | * the label of the option
73 | * @param idx
74 | * the index of the option
75 | * @param selected
76 | * whether the option is selected
77 | */
78 | def renderOption(label: String, idx: Int, selected: Boolean): HtmlElement
79 |
--------------------------------------------------------------------------------
/examples/client/src/main/scala/samples/AdHoc.scala:
--------------------------------------------------------------------------------
1 | package samples
2 |
3 | import com.raquo.laminar.api.L.*
4 | import dev.cheleb.scalamigen.*
5 |
6 | def adhoc(using
7 | wf: WidgetFactory
8 | ): Sample = {
9 |
10 | // outside your scope / in another library
11 | // => meaning '@FieldName' or '@NoPanel' annotations are not possible
12 | case class Cat(
13 | name: String,
14 | kind: Boolean = true,
15 | color: Color
16 | )
17 |
18 | enum Color(val code: String):
19 | case Black extends Color("000")
20 | case White extends Color("FFF")
21 | case Isabelle extends Color("???")
22 |
23 | // your library
24 | case class Basket(color: Color, cat: Cat)
25 |
26 | given colorForm: Form[Color] =
27 | selectForm(Color.values, labelMapper = c => s"$c ${c.code}")
28 | .withFieldName("Select color")
29 |
30 | given Form[Cat] =
31 | Form
32 | .autoDerived[Cat]
33 | .withFieldName("Your cat")
34 | .withPanelConfig(label = Some("What kind of cat ?"), asTable = true)
35 |
36 | val enumVar = Var(
37 | Basket(
38 | Color.Black,
39 | Cat(
40 | "Scala",
41 | true,
42 | Color.White
43 | )
44 | )
45 | )
46 |
47 | Sample(
48 | "Ad-Hoc",
49 | enumVar.asForm(enumVar.errorBus),
50 | div(
51 | child <-- enumVar.signal.map { item =>
52 | div(
53 | s"$item"
54 | )
55 | }
56 | ),
57 | """|
58 | |// outside your scope / in another library
59 | |// => meaning '@FieldName' or '@NoPanel' annotations are not possible
60 | |case class Cat(
61 | | name: String,
62 | | kind: Boolean = true,
63 | | color: Color,
64 | |)
65 | |
66 | |enum Color(val code: String):
67 | | case Black extends Color("000")
68 | | case White extends Color("FFF")
69 | | case Isabelle extends Color("???")
70 | |
71 | |// your library
72 | |case class Basket(color: Color, cat: Cat)
73 | |
74 | |given colorForm: Form[Color] =
75 | | selectForm(Color.values, labelMapper = c => s"$c ${c.code}")
76 | | .withFieldName("Select color")
77 | |
78 | |@annotation.nowarn
79 | |given Form[Cat] =
80 | | Form.autoDerived[Cat]
81 | | .withFieldName("Your cat")
82 | | .withPanelConfig(label = Some("What kind of cat ?"), asTable = true)
83 | |""".stripMargin
84 | )
85 | }
86 |
--------------------------------------------------------------------------------
/scripts/setup.sc:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env -S scala-cli -S 3
2 |
3 | //> using scala "3.8.0-RC3"
4 | //> using javaOptions "--sun-misc-unsafe-memory-access=allow" // Example option to set maximum heap size
5 | //> using dep "com.lihaoyi::os-lib:0.11.6"
6 |
7 | import os.*
8 | import scala.math.Ordered.orderingToOrdered
9 |
10 | val buildSbt = os.pwd / "build.sbt"
11 | val buildEnv = os.pwd / "scripts" / "target" / "build-env.sh"
12 |
13 | val exampleClient = os.pwd / "examples" / "client"
14 | val nodeModule = exampleClient / "node_modules" / ".package-lock.json"
15 | val packageJson = exampleClient / "package.json"
16 | val npmDevMarker = exampleClient / "target" / "npm-dev-server-running.marker"
17 |
18 | os.remove(npmDevMarker)
19 |
20 | if shouldImportProject then
21 | println(s"Importing project settings into build-env.sh ($buildEnv)...")
22 | os.proc("sbt", "projects")
23 | .call(
24 | cwd = os.pwd,
25 | env = Map("BUILD_ENV_SH_PATH" -> buildEnv.toString),
26 | stdout = os.ProcessOutput.Readlines(line => println(s" $line"))
27 | )
28 |
29 | if nodePackageMustInstalled then
30 | println("✨ Installing node modules...")
31 | os.proc("npm", "install").call(cwd = exampleClient)
32 | println("Node modules installation complete.")
33 |
34 | def shouldImportProject: Boolean = if (os.exists(buildEnv)) {
35 | if (os.stat(buildSbt).mtime > os.stat(buildEnv).mtime) {
36 | println(
37 | "⚠️ build.sbt has been modified since the last build-env.sh generation.\n\t - regenerating build-env.sh."
38 | )
39 | os.remove(buildEnv)
40 | true
41 | } else
42 | false
43 | } else {
44 | println("✨ Creating build-env.sh...")
45 | true
46 | }
47 |
48 | def nodePackageMustInstalled: Boolean = if (os.exists(nodeModule)) {
49 | print(s"\t- 🔎 Node modules already installed: ")
50 | if (os.stat(packageJson).mtime > os.stat(nodeModule).mtime) {
51 | println(
52 | "⚠️\n\t\t- package.json has been modified since the last installation."
53 | )
54 | true
55 | } else {
56 | os.stat(nodeModule).mtime match {
57 | case time
58 | if time.toMillis > System
59 | .currentTimeMillis() - 7 * 24 * 60 * 60 * 1000 =>
60 | // println("Node modules were installed within the last 7 days.")
61 | case _ =>
62 | println(
63 | "⚠️\n\t Node modules exist but were not installed recently ( > 7 days). Consider reinstalling if issues arise."
64 | )
65 | }
66 | println("✅ up-to-date.")
67 | false
68 | }
69 | } else
70 | true
71 |
--------------------------------------------------------------------------------
/modules/webawesome/src/main/scala/dev/cheleb/scalamigen/webawesome/WebAwesomeWidgetFactory.scala:
--------------------------------------------------------------------------------
1 | package dev.cheleb.scalamigen.webawesome
2 |
3 | import com.raquo.laminar.api.L.*
4 | import io.github.nguyenyou.webawesome.laminar.*
5 |
6 | import com.raquo.laminar.modifiers.EventListener
7 |
8 | import dev.cheleb.scalamigen.WidgetFactory
9 | import com.raquo.laminar.api.L
10 |
11 | //import io.github.nguyenyou.ui5.webcomponents.ui5Webcomponents.distTypesTitleLevelMod.TitleLevel
12 |
13 | /** UI5WidgetFactory is a factory for [SAP UI5
14 | * widgets](https://sap.github.io/ui5-webcomponents/).
15 | *
16 | * It relies on [Laminar UI5
17 | * bindings](https://github.com/sherpal/LaminarSAPUI5Bindings).
18 | */
19 | object WebAwesomeWidgetFactory extends WidgetFactory:
20 |
21 | override def renderCheckbox: L.HtmlElement = Checkbox()()
22 |
23 | override def renderDatePicker: L.HtmlElement = Input(
24 | _.tpe := "date"
25 | )()
26 |
27 | override def renderDialog(
28 | title: String,
29 | content: HtmlElement,
30 | openDialogBus: EventBus[Boolean]
31 | ): HtmlElement =
32 | Dialog(
33 | _.label := title
34 | // _.onClose --> { _ => }
35 | )().amend(content)
36 |
37 | override def renderSecret: L.HtmlElement = Input(
38 | _.tpe := "password"
39 | )()
40 |
41 | override def renderText: HtmlElement = Input(
42 | // _.showClearIcon := true,
43 | _.placeholder := "Enter text"
44 | )()
45 | override def renderLabel(required: Boolean, name: String): HtmlElement =
46 | span(name)
47 |
48 | override def renderNumeric: HtmlElement = Input(
49 | _.tpe := "number",
50 | _.placeholder := "Enter number"
51 | )()
52 | override def renderButton: HtmlElement = Button()()
53 | override def renderLink(text: String, el: EventListener[?, ?]): HtmlElement =
54 | a(
55 | text,
56 | href := "#",
57 | el
58 | )
59 | override def renderUL(id: String): HtmlElement = ul(idAttr := id)
60 |
61 | override def renderPanel(headerText: Option[String]): HtmlElement =
62 | headerText match
63 | case None => div()
64 | case Some(headerText) =>
65 | div(
66 | headerText
67 | )
68 |
69 | override def renderSelect(selectedIndex: Int)(
70 | f: Int => Unit
71 | ): HtmlElement =
72 | Select(
73 | _.onChange.mapToValue.map(_.toInt) --> { ds =>
74 | f(ds)
75 | },
76 | _.value := s"$selectedIndex"
77 | )()
78 |
79 | override def renderOption(
80 | label: String,
81 | idx: Int,
82 | isSelected: Boolean
83 | ): HtmlElement =
84 | UOption(
85 | _.label := label,
86 | _.value := s"$idx",
87 | _.selected := isSelected
88 | )(label)
89 |
--------------------------------------------------------------------------------
/examples/client/src/main/scala/samples/EnumSample.scala:
--------------------------------------------------------------------------------
1 | package samples
2 |
3 | import com.raquo.laminar.api.L.*
4 | import dev.cheleb.scalamigen.*
5 | import io.github.iltotore.iron.*
6 | import io.github.iltotore.iron.constraint.all.*
7 | import java.util.UUID
8 |
9 | def enums(using
10 | wf: WidgetFactory
11 | ): Sample = {
12 | enum Color(val code: String):
13 | case Black extends Color("000")
14 | case White extends Color("FFF")
15 | case Isabelle extends Color("???")
16 |
17 | given colorForm: Form[Color] =
18 | selectForm(Color.values, labelMapper = c => s"$c ${c.code}")
19 |
20 | case class Meal(id: UUID, name: String)
21 |
22 | val allMeals = List(
23 | Meal(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Pizza"),
24 | Meal(UUID.fromString("00000000-0000-0000-0000-000000000002"), "Pasta")
25 | )
26 |
27 | given mealForm: Form[UUID] =
28 | selectMappedForm(allMeals, mapper = m => m.id, labelMapper = _.name)
29 |
30 | case class Basket(color: Color, cat: Cat)
31 |
32 | case class Cat(
33 | name: String,
34 | weight: Int :| Positive,
35 | kind: Boolean = true,
36 | color: Color,
37 | mealId: UUID
38 | )
39 | // case class Dog(name: String, weight: Int)
40 |
41 | val enumVar = Var(
42 | Basket(
43 | Color.Black,
44 | Cat(
45 | "Scala",
46 | 10,
47 | true,
48 | Color.White,
49 | allMeals.head.id
50 | )
51 | )
52 | )
53 |
54 | Sample(
55 | "Enums",
56 | enumVar.asForm(enumVar.errorBus),
57 | div(
58 | child <-- enumVar.signal.map { item =>
59 | div(
60 | s"$item"
61 | )
62 | }
63 | ),
64 | """|
65 | |enum Color(val code: String):
66 | | case Black extends Color("000")
67 | | case White extends Color("FFF")
68 | | case Isabelle extends Color("???")
69 | |
70 | |given colorForm: Form[Color] =
71 | | selectForm(Color.values, labelMapper = c => s"$c ${c.code}")
72 | |
73 | |case class Meal(id: UUID, name: String)
74 | |
75 | |val allMeals = List(
76 | | Meal(UUID.fromString("00000000-0000-0000-0000-000000000001"), "Pizza"),
77 | | Meal(UUID.fromString("00000000-0000-0000-0000-000000000002"), "Pasta")
78 | |)
79 | |
80 | |given mealForm: Form[UUID] =
81 | | selectMappedForm(allMeals, mapper = m => m.id, labelMapper = _.name)
82 | |
83 | |case class Basket(color: Color, cat: Cat)
84 | |
85 | |case class Cat(
86 | | name: String,
87 | | weight: Int :| Positive,
88 | | kind: Boolean = true,
89 | | color: Color,
90 | | mealId: UUID
91 | |)
92 | |""".stripMargin
93 | )
94 | }
95 |
--------------------------------------------------------------------------------
/examples/client/src/main/scala/samples/Validation.scala:
--------------------------------------------------------------------------------
1 | package samples
2 |
3 | import dev.cheleb.scalamigen.*
4 |
5 | import com.raquo.laminar.api.L.*
6 |
7 | import io.github.iltotore.iron.*
8 | import io.github.iltotore.iron.constraint.all.*
9 |
10 | def validation(using
11 | wf: WidgetFactory
12 | ): Sample = {
13 |
14 | case class LatLon(lat: Double, lon: Double) {
15 | override def toString: String = s"$lat,$lon"
16 | }
17 |
18 | given Form[CurrencyCode] = stringForm(CurrencyCode(_))
19 |
20 | given Form[LatLon] = stringFormWithValidation(using
21 | new Validator[LatLon] {
22 | override def validate(value: String): Either[String, LatLon] = {
23 | value.split(",") match {
24 | case Array(lat, lon) =>
25 | (
26 | lat.toDoubleOption.toRight("Invalid latitude"),
27 | lon.toDoubleOption.toRight("Invalid longitude")
28 | ) match {
29 | case (Right(lat), Right(lon)) => Right(LatLon(lat, lon))
30 | case (Left(latError), Left(rightError)) =>
31 | Left(s"$latError and $rightError")
32 | case (Left(latError), _) => Left(latError)
33 | case (_, Left(lonError)) => Left(lonError)
34 | }
35 | case _ => Left("Invalid format")
36 | }
37 | }
38 | }
39 | )
40 |
41 | case class IronSample(
42 | curenncyCode: CurrencyCode,
43 | optional: Option[String],
44 | optionalInt: Option[Int],
45 | doubleGreaterThanEight: Double :| GreaterEqual[8.0],
46 | optionalDoublePositive: Option[Double :| Positive],
47 | latLong: LatLon
48 | )
49 |
50 | val ironSampleVar = Var(
51 | IronSample(
52 | CurrencyCode("Eur"),
53 | Some("name"),
54 | Some(1),
55 | 9.1,
56 | Some(1),
57 | LatLon(1, 2)
58 | )
59 | )
60 |
61 | Sample(
62 | "Validation",
63 | div(
64 | ironSampleVar.asForm
65 | ),
66 | div(
67 | child <-- ironSampleVar.signal.map { item =>
68 | div(
69 | s"$item"
70 | )
71 | }
72 | ),
73 | """|
74 | |given Form[CurrencyCode] = stringForm(CurrencyCode(_))
75 | |
76 | | case class IronSample(
77 | | curenncyCode: CurrencyCode,
78 | | optional: Option[String],
79 | | optionalInt: Option[Int],
80 | | doubleGreaterThanEight: Double :| GreaterEqual[8.0],
81 | | optionalDoublePositive: Option[Double :| Positive]
82 | | )
83 | |
84 | | given Defaultable[Double :| GreaterEqual[8.0]] with
85 | | def default: Double :| GreaterEqual[8.0] = 8.0
86 | |
87 | | val ironSampleVar = Var(
88 | | IronSample(CurrencyCode("Eur"), Some("name"), Some(1), 9.1, Some(1))
89 | | )
90 | """.stripMargin
91 | )
92 |
93 | }
94 |
--------------------------------------------------------------------------------
/examples/client/src/main/scala/samples/DemoDoreane.scala:
--------------------------------------------------------------------------------
1 | package samples
2 |
3 | import com.raquo.laminar.api.L.*
4 | import be.doeraene.webcomponents.ui5.*
5 | import facades.highlightjs.hljs
6 | import dev.cheleb.scalamigen.ui5.UI5WidgetFactory
7 | import dev.cheleb.scalamigen.WidgetFactory
8 |
9 | object DemoDoreane {
10 |
11 | def apply() =
12 |
13 | def item(name: String) = SideNavigation.item(
14 | _.text := name,
15 | dataAttr("component-name") := name
16 | )
17 |
18 | given WidgetFactory = UI5WidgetFactory
19 |
20 | val sampleVar = Var(samples.simple)
21 |
22 | val demos = App.demos
23 |
24 | div(
25 | display := "flex",
26 | div(
27 | paddingRight("2rem"),
28 | Title(
29 | "Demos",
30 | padding("0.5rem"),
31 | cursor := "pointer"
32 | ),
33 | SideNavigation(
34 | _.events.onSelectionChange
35 | .map(_.detail.item.dataset.get("componentName")) --> Observer[
36 | Option[String]
37 | ] { name =>
38 | name
39 | .flatMap(n => demos.find(_.name == n))
40 | .foreach(sampleVar.set)
41 |
42 | },
43 | demos.map(_.name).map(item)
44 | )
45 | ),
46 | div(
47 | height := "100vh",
48 | overflowY := "auto",
49 | display := "flex",
50 | flexGrow := 1,
51 | div(
52 | padding := "10px",
53 | minWidth := "40%",
54 | maxWidth := "calc(100% - 320px)",
55 | table(
56 | tr(
57 | td(
58 | div(
59 | child <-- sampleVar.signal.map(_.component)
60 | )
61 | ),
62 | td(
63 | div(
64 | marginTop := "1em",
65 | overflowX := "auto",
66 | border := "0.0625rem solid #C1C1C1",
67 | backgroundColor := "#f5f6fa",
68 | padding := "1rem",
69 | Title.h3("Code"),
70 | child <-- sampleVar.signal
71 | .map(_.source)
72 | .map(src =>
73 | pre(
74 | code(
75 | className := "language-scala",
76 | src,
77 | onMountCallback(ctx =>
78 | hljs.highlightElement(ctx.thisNode.ref)
79 | )
80 | )
81 | )
82 | )
83 | )
84 | )
85 | ),
86 | tr(
87 | td(
88 | colSpan := 2,
89 | child <-- sampleVar.signal.map(_.debug)
90 | )
91 | )
92 | )
93 | )
94 | )
95 | )
96 |
97 | }
98 |
--------------------------------------------------------------------------------
/examples/client/src/main/scala/samples/DemoNative.scala:
--------------------------------------------------------------------------------
1 | package samples
2 |
3 | import com.raquo.laminar.api.L.*
4 | import be.doeraene.webcomponents.ui5.*
5 | import facades.highlightjs.hljs
6 | import dev.cheleb.scalamigen.WidgetFactory
7 | import dev.cheleb.scalamigen.LaminarWidgetFactory
8 |
9 | object DemoNative {
10 |
11 | def apply() =
12 |
13 | def item(name: String) = SideNavigation.item(
14 | _.text := name,
15 | dataAttr("component-name") := name
16 | )
17 |
18 | given WidgetFactory = LaminarWidgetFactory
19 |
20 | val sampleVar = Var(samples.simple)
21 |
22 | val demos = App.demos
23 |
24 | div(
25 | display := "flex",
26 | div(
27 | paddingRight("2rem"),
28 | Title(
29 | "Demos",
30 | padding("0.5rem"),
31 | cursor := "pointer"
32 | ),
33 | SideNavigation(
34 | _.events.onSelectionChange
35 | .map(_.detail.item.dataset.get("componentName")) --> Observer[
36 | Option[String]
37 | ] { name =>
38 | name
39 | .flatMap(n => demos.find(_.name == n))
40 | .foreach(sampleVar.set)
41 |
42 | },
43 | demos.map(_.name).map(item)
44 | )
45 | ),
46 | div(
47 | height := "100vh",
48 | overflowY := "auto",
49 | display := "flex",
50 | flexGrow := 1,
51 | div(
52 | padding := "10px",
53 | minWidth := "40%",
54 | maxWidth := "calc(100% - 320px)",
55 | table(
56 | tr(
57 | td(
58 | div(
59 | child <-- sampleVar.signal.map(_.component)
60 | )
61 | ),
62 | td(
63 | div(
64 | marginTop := "1em",
65 | overflowX := "auto",
66 | border := "0.0625rem solid #C1C1C1",
67 | backgroundColor := "#f5f6fa",
68 | padding := "1rem",
69 | Title.h3("Code"),
70 | child <-- sampleVar.signal
71 | .map(_.source)
72 | .map(src =>
73 | pre(
74 | code(
75 | className := "language-scala",
76 | src,
77 | onMountCallback(ctx =>
78 | hljs.highlightElement(ctx.thisNode.ref)
79 | )
80 | )
81 | )
82 | )
83 | )
84 | )
85 | ),
86 | tr(
87 | td(
88 | colSpan := 2,
89 | child <-- sampleVar.signal.map(_.debug)
90 | )
91 | )
92 | )
93 | )
94 | )
95 | )
96 |
97 | }
98 |
--------------------------------------------------------------------------------
/modules/ui5-nguyenyou/src/main/scala/dev/cheleb/scalamigen/ui5/UI5WidgetFactory.scala:
--------------------------------------------------------------------------------
1 | package dev.cheleb.scalamigen.ui5nguyenyou
2 |
3 | import com.raquo.laminar.api.L.*
4 | import io.github.nguyenyou.ui5.webcomponents.laminar.*
5 |
6 | import com.raquo.laminar.modifiers.EventListener
7 |
8 | import dev.cheleb.scalamigen.WidgetFactory
9 | import com.raquo.laminar.api.L
10 |
11 | //import io.github.nguyenyou.ui5.webcomponents.ui5Webcomponents.distTypesTitleLevelMod.TitleLevel
12 |
13 | /** UI5WidgetFactory is a factory for [SAP UI5
14 | * widgets](https://sap.github.io/ui5-webcomponents/).
15 | *
16 | * It relies on [Laminar UI5
17 | * bindings](https://github.com/sherpal/LaminarSAPUI5Bindings).
18 | */
19 | object UI5WidgetFactory extends WidgetFactory:
20 |
21 | override def renderCheckbox: L.HtmlElement = CheckBox()()
22 |
23 | override def renderDatePicker: L.HtmlElement = DatePicker(
24 | _.formatPattern := "yyyy-MM-dd"
25 | )()
26 |
27 | override def renderDialog(
28 | title: String,
29 | content: HtmlElement,
30 | openDialogBus: EventBus[Boolean]
31 | ): HtmlElement =
32 | Dialog(
33 | _.headerText := title,
34 | _.onClose --> { _ => }
35 | )().amend(content)
36 |
37 | override def renderSecret: L.HtmlElement = Input(
38 | _.tpe := "Password"
39 | )()
40 |
41 | override def renderText: HtmlElement = Input(
42 | _.showClearIcon := true,
43 | _.placeholder := "Enter text"
44 | )()
45 | override def renderLabel(required: Boolean, name: String): HtmlElement =
46 | Label(
47 | _.required := required,
48 | _.showColon := false
49 | // _.text := name
50 | )(name)
51 |
52 | override def renderNumeric: HtmlElement = Input(
53 | _.tpe := "Number",
54 | _.placeholder := "Enter number"
55 | )()
56 | override def renderButton: HtmlElement = Button()()
57 | override def renderLink(text: String, el: EventListener[?, ?]): HtmlElement =
58 | Link()(text, el)
59 | override def renderUL(id: String): HtmlElement = ListItemGroup(
60 | _.id := id
61 | )()
62 | override def renderPanel(headerText: Option[String]): HtmlElement =
63 | headerText match
64 | case Some(headerText) =>
65 | Panel(
66 | _.headerText := headerText,
67 | _.headerLevel := "H3"
68 | )()
69 | case None =>
70 | div(cls := "srf-table")
71 |
72 | override def renderSelect(
73 | selectedIndex: Int
74 | )(f: Int => Unit): HtmlElement = Select(
75 | _.onChange
76 | .map(_.detail.selectedOption.value) --> { ds =>
77 | ds.foreach { case idx: String =>
78 | f(idx.toInt)
79 | }
80 |
81 | }
82 | )()
83 |
84 | override def renderOption(
85 | label: String,
86 | idx: Int,
87 | selected: Boolean
88 | ): HtmlElement =
89 | OptionCustom(
90 | _.value := s"$idx",
91 | _.selected := selected
92 | )(
93 | label
94 | )
95 |
--------------------------------------------------------------------------------
/examples/client/src/main/scala/samples/DemoNguyenyou.scala:
--------------------------------------------------------------------------------
1 | package samples
2 |
3 | import com.raquo.laminar.api.L.*
4 |
5 | import facades.highlightjs.{hljs, hljsScala}
6 |
7 | import io.github.nguyenyou.ui5.webcomponents.laminar.*
8 | import dev.cheleb.scalamigen.WidgetFactory
9 | import dev.cheleb.scalamigen.ui5nguyenyou.UI5WidgetFactory
10 |
11 | object DemoNguyenyou {
12 |
13 | def apply() =
14 |
15 | hljs.registerLanguage("scala", hljsScala)
16 |
17 | given wf: WidgetFactory = UI5WidgetFactory
18 |
19 | val sampleVar = Var(samples.simple)
20 |
21 | def item(name: String) = SideNavigationItem(
22 | _.text := name,
23 | _.id := name
24 | // dataAttr("component-name") := name
25 | )()
26 |
27 | val demos = App.demos
28 |
29 | val myApp =
30 | div(
31 | display := "flex",
32 | div(
33 | paddingRight("2rem"),
34 | Title(
35 | // _.padding("0.5rem")
36 | // _.cursor := "pointer"
37 | )("Demos"),
38 | SideNavigation(
39 | _.onSelectionChange --> { event =>
40 | val name = event.detail.item.id
41 | demos
42 | .find(_.name == name)
43 | .foreach(sampleVar.set)
44 | }
45 | )(demos.map(_.name).map(item))
46 | ),
47 | div(
48 | height := "100vh",
49 | overflowY := "auto",
50 | display := "flex",
51 | flexGrow := 1,
52 | div(
53 | padding := "10px",
54 | minWidth := "40%",
55 | maxWidth := "calc(100% - 320px)",
56 | table(
57 | tr(
58 | td(
59 | div(
60 | child <-- sampleVar.signal.map(_.component)
61 | )
62 | ),
63 | td(
64 | div(
65 | marginTop := "1em",
66 | overflowX := "auto",
67 | border := "0.0625rem solid #C1C1C1",
68 | backgroundColor := "#f5f6fa",
69 | padding := "1rem",
70 | Title(_.level := "H3")("Code"),
71 | child <-- sampleVar.signal
72 | .map(_.source)
73 | .map(src =>
74 | pre(
75 | code(
76 | className := "language-scala",
77 | src,
78 | onMountCallback(ctx =>
79 | hljs.highlightElement(ctx.thisNode.ref)
80 | )
81 | )
82 | )
83 | )
84 | )
85 | )
86 | ),
87 | tr(
88 | td(
89 | colSpan := 2,
90 | child <-- sampleVar.signal.map(_.debug)
91 | )
92 | )
93 | )
94 | )
95 | )
96 | )
97 |
98 | myApp
99 | }
100 |
--------------------------------------------------------------------------------
/examples/client/src/main/scala/samples/Sealed.scala:
--------------------------------------------------------------------------------
1 | package samples
2 |
3 | import dev.cheleb.scalamigen.*
4 |
5 | import com.raquo.laminar.api.L.*
6 |
7 | import com.raquo.laminar.nodes.ReactiveHtmlElement
8 |
9 | def sealedClasses(using
10 | wf: WidgetFactory
11 | ): Sample = {
12 |
13 | enum Color(val code: String):
14 | case Black extends Color("000")
15 | case White extends Color("FFF")
16 | case Isabelle extends Color("???")
17 |
18 | given colorForm: Form[Color] = selectForm(Color.values)
19 |
20 | sealed trait Animal
21 |
22 | case class Horse(name: String, age: Int, color: Color) extends Animal
23 |
24 | case class Lama(
25 | name: String,
26 | age: Int,
27 | splitDistance: Int,
28 | color: Color = Color.Isabelle
29 | ) extends Animal
30 |
31 | case class Otter(name: String, age: Int) extends Animal
32 |
33 | case class Owner(name: String, pet: Animal)
34 |
35 | val sealedVar = Var(Owner("Agnes", Horse("Niram <3", 6, Color.Isabelle)))
36 |
37 | case class Switcher(
38 | name: String,
39 | button: ReactiveHtmlElement[?]
40 | )
41 |
42 | object Switcher {
43 | def apply[A <: Animal](na: => A): Switcher =
44 | val a = na
45 | val name = a.getClass.getSimpleName
46 | Switcher(
47 | name,
48 | button(
49 | name.filter(_.isLetter),
50 | onClick.mapToUnit --> (_ => sealedVar.update(_.copy(pet = a)))
51 | )
52 | )
53 | }
54 |
55 | val switchers = List(
56 | Switcher(
57 | Horse("Niram", 13, Color.Black)
58 | ),
59 | Switcher(
60 | Lama("Lama", 3, 2)
61 | ),
62 | Switcher(
63 | Otter("Otter", 13)
64 | )
65 | )
66 |
67 | Sample(
68 | "Sealed",
69 | div(
70 | child <-- sealedVar.signal
71 | .distinctByFn((old, nw) => old.pet.getClass == nw.pet.getClass)
72 | .map { _ =>
73 | div(
74 | sealedVar.asForm,
75 | switchers
76 | .filterNot(_.name == sealedVar.now().pet.getClass.getSimpleName)
77 | .map(_.button)
78 | )
79 | }
80 | ),
81 | div(child <-- sealedVar.signal.map { item =>
82 | div(
83 | s"$item"
84 | )
85 | }),
86 | """| enum Color(val code: String):
87 | | case Black extends Color("000")
88 | | case White extends Color("FFF")
89 | | case Isabelle extends Color("???")
90 | |
91 | | given colorForm: Form[Color] = selectForm(Color.values)
92 | |
93 | | sealed trait Animal
94 | |
95 | | case class Horse(name: String, age: Int, color: Color) extends Animal
96 | |
97 | | case class Lama(
98 | | name: String,
99 | | age: Int,
100 | | splitDistance: Int,
101 | | color: Color = Color.Isabelle
102 | | ) extends Animal
103 | |
104 | | case class Otter(name: String, age: Int) extends Animal
105 | |
106 | | case class Owner(name: String, pet: Animal)
107 | |
108 | |""".stripMargin
109 | )
110 | }
111 |
--------------------------------------------------------------------------------
/modules/ui5/src/main/scala/dev/cheleb/scalamigen/ui5/UI5WidgetFactory.scala:
--------------------------------------------------------------------------------
1 | package dev.cheleb.scalamigen.ui5
2 |
3 | import com.raquo.laminar.api.L.*
4 | import be.doeraene.webcomponents.ui5.*
5 |
6 | import com.raquo.laminar.modifiers.EventListener
7 | import be.doeraene.webcomponents.ui5.configkeys.ListSeparator
8 | import be.doeraene.webcomponents.ui5.configkeys.TitleLevel
9 |
10 | import dev.cheleb.scalamigen.WidgetFactory
11 | import com.raquo.laminar.api.L
12 | import be.doeraene.webcomponents.ui5.configkeys.InputType.Password
13 |
14 | /** UI5WidgetFactory is a factory for [SAP UI5
15 | * widgets](https://sap.github.io/ui5-webcomponents/).
16 | *
17 | * It relies on [Laminar UI5
18 | * bindings](https://github.com/sherpal/LaminarSAPUI5Bindings).
19 | */
20 | object UI5WidgetFactory extends WidgetFactory:
21 |
22 | override def renderCheckbox: L.HtmlElement = CheckBox()
23 |
24 | override def renderDatePicker: L.HtmlElement = DatePicker(
25 | _.formatPattern := "yyyy-MM-dd"
26 | )
27 |
28 | override def renderDialog(
29 | title: String,
30 | content: HtmlElement,
31 | openDialogBus: EventBus[Boolean]
32 | ): HtmlElement =
33 | Dialog(
34 | _.headerText := title,
35 | _.showFromEvents(openDialogBus.events.filter(identity).mapTo(())),
36 | _.closeFromEvents(
37 | openDialogBus.events.map(!_).filter(identity).mapTo(())
38 | )
39 | ).amend(content)
40 |
41 | override def renderSecret: L.HtmlElement = Input(
42 | _.tpe := Password
43 | )
44 |
45 | override def renderText: HtmlElement = Input(
46 | _.showClearIcon := true,
47 | _.placeholder := "Enter text"
48 | )
49 | override def renderLabel(required: Boolean, name: String): HtmlElement =
50 | Label(
51 | _.required := required,
52 | _.showColon := false
53 | // _.text := name
54 | ).amend(name)
55 |
56 | override def renderNumeric: HtmlElement = Input(
57 | tpe := "number",
58 | _.placeholder := "Enter number"
59 | )
60 | override def renderButton: HtmlElement = Button()
61 | override def renderLink(text: String, el: EventListener[?, ?]): HtmlElement =
62 | Link(text, el)
63 | override def renderUL(id: String): HtmlElement = UList(
64 | _.id := id,
65 | width := "100%",
66 | _.noDataText := "No data",
67 | _.separators := ListSeparator.None
68 | )
69 | override def renderPanel(headerText: Option[String]): HtmlElement =
70 | headerText match
71 | case Some(headerText) =>
72 | Panel(
73 | _.headerText := headerText,
74 | _.headerLevel := TitleLevel.H3
75 | )
76 | case None =>
77 | div(cls := "srf-table")
78 |
79 | override def renderSelect(
80 | selectedIndex: Int
81 | )(f: Int => Unit): HtmlElement = Select(
82 | _.events.onChange
83 | .map(_.detail.selectedOption.dataset) --> { ds =>
84 | ds.get("idx").foreach(idx => f(idx.toInt))
85 |
86 | }
87 | )
88 |
89 | override def renderOption(
90 | label: String,
91 | idx: Int,
92 | selected: Boolean
93 | ): HtmlElement =
94 | Select.option(
95 | label,
96 | dataAttr("idx") := s"$idx",
97 | _.selected := selected
98 | )
99 |
--------------------------------------------------------------------------------
/examples/client/src/main/scala/samples/DemoWebAwesome.scala:
--------------------------------------------------------------------------------
1 | package samples
2 |
3 | import com.raquo.laminar.api.L.*
4 |
5 | import facades.highlightjs.{hljs, hljsScala}
6 |
7 | import io.github.nguyenyou.ui5.webcomponents.laminar.*
8 | import dev.cheleb.scalamigen.WidgetFactory
9 | import dev.cheleb.scalamigen.webawesome.WebAwesomeWidgetFactory
10 |
11 | object DemoWebAwesome {
12 |
13 | def apply() =
14 |
15 | hljs.registerLanguage("scala", hljsScala)
16 |
17 | given wf: WidgetFactory = WebAwesomeWidgetFactory
18 |
19 | val sampleVar = Var(samples.simple)
20 |
21 | def item(name: String) = SideNavigationItem(
22 | _.text := name,
23 | _.id := name
24 | // dataAttr("component-name") := name
25 | )()
26 |
27 | val demos = App.demos
28 |
29 | val myApp =
30 | div(
31 | display := "flex",
32 | div(
33 | paddingRight("2rem"),
34 | Title(
35 | // _.padding("0.5rem")
36 | // _.cursor := "pointer"
37 | )("Demos"),
38 | SideNavigation(
39 | _.onSelectionChange --> { event =>
40 | println(event.detail.item.id)
41 | val name = event.detail.item.id
42 | demos
43 | .find(_.name == name)
44 | .foreach(sampleVar.set)
45 | }
46 | )(demos.map(_.name).map(item))
47 | ),
48 | div(
49 | height := "100vh",
50 | overflowY := "auto",
51 | display := "flex",
52 | flexGrow := 1,
53 | div(
54 | padding := "10px",
55 | minWidth := "40%",
56 | maxWidth := "calc(100% - 320px)",
57 | table(
58 | tr(
59 | td(
60 | div(
61 | child <-- sampleVar.signal.map(_.component)
62 | )
63 | ),
64 | td(
65 | div(
66 | marginTop := "1em",
67 | overflowX := "auto",
68 | border := "0.0625rem solid #C1C1C1",
69 | backgroundColor := "#f5f6fa",
70 | padding := "1rem",
71 | Title(_.level := "H3")("Code"),
72 | child <-- sampleVar.signal
73 | .map(_.source)
74 | .map(src =>
75 | pre(
76 | code(
77 | className := "language-scala",
78 | src,
79 | onMountCallback(ctx =>
80 | hljs.highlightElement(ctx.thisNode.ref)
81 | )
82 | )
83 | )
84 | )
85 | )
86 | )
87 | ),
88 | tr(
89 | td(
90 | colSpan := 2,
91 | child <-- sampleVar.signal.map(_.debug)
92 | )
93 | )
94 | )
95 | )
96 | )
97 | )
98 |
99 | myApp
100 | }
101 |
--------------------------------------------------------------------------------
/examples/client/src/main/scala/samples/Tree.scala:
--------------------------------------------------------------------------------
1 | package samples
2 |
3 | import dev.cheleb.scalamigen.*
4 |
5 | import com.raquo.laminar.api.L.*
6 |
7 | /** Poc poc =D
8 | */
9 | def tree(using
10 | wf: WidgetFactory
11 | ): Sample = {
12 |
13 | enum Tree[+T]:
14 | case Empty extends Tree[Nothing]
15 | case Node(value: T, left: Tree[T], right: Tree[T])
16 | object Tree:
17 | def isSameStructure(tree1: Tree[?], tree2: Tree[?]): Boolean =
18 | (tree1, tree2) match
19 | case (Empty, Empty) => true
20 | case (Node(_, _, _), Empty) => false
21 | case (Empty, Node(_, _, _)) => false
22 | case (Node(_, left1, right1), Node(_, left2, right2)) =>
23 | isSameStructure(left1, left2) && isSameStructure(right1, right2)
24 |
25 | given treeInstance[A](using
26 | default: Defaultable[A]
27 | )(using Form[A]): Form[Tree[A]] = new Form[Tree[A]] { self =>
28 | override def render(
29 | path: List[Symbol],
30 | variable: Var[Tree[A]]
31 | )(using WidgetFactory, EventBus[(String, ValidationEvent)]): HtmlElement =
32 | variable.now() match
33 | case Tree.Empty =>
34 | button(
35 | "Add me",
36 | onClick.mapToUnit --> { _ =>
37 | variable.set(Tree.Node(default.default, Tree.Empty, Tree.Empty))
38 | }
39 | )
40 | case Tree.Node(value, left, right) =>
41 | div(
42 | button(
43 | "drop",
44 | onClick.mapToUnit --> { _ =>
45 | variable.set(Tree.Empty)
46 | }
47 | ), {
48 | val vVar = Var(value)
49 | val lVar = Var(left)
50 | val rVar = Var(right)
51 |
52 | Seq(
53 | Form.renderVar(
54 | path :+ Symbol("value"),
55 | vVar
56 | ),
57 | div(
58 | "left",
59 | Form.renderVar(
60 | path :+ Symbol("left"),
61 | lVar
62 | )
63 | ),
64 | div(
65 | "right",
66 | Form.renderVar(
67 | path :+ Symbol("right"),
68 | rVar
69 | )
70 | )
71 | )
72 | }
73 | )
74 | }
75 |
76 | import Tree.*
77 | case class Person(name: String, age: Int)
78 | object Person {
79 | given Defaultable[Person] with
80 | def default = Person("--", 0)
81 | }
82 | val treeVar2 = Var(
83 | Node(
84 | Person("agnes", 50),
85 | Node(Person("Zozo", 53), Empty, Empty),
86 | Empty
87 | )
88 | )
89 | Sample(
90 | "Tree", {
91 | div(
92 | child <-- treeVar2.signal
93 | .distinctByFn(Tree.isSameStructure)
94 | .map { _ =>
95 | val b = new EventBus[(String, ValidationEvent)]()
96 | treeVar2.asForm(b)
97 | }
98 | )
99 | },
100 | div(
101 | child <-- treeVar2.signal.map { item =>
102 | div(
103 | s"$item zozo"
104 | )
105 | }
106 | )
107 | )
108 | }
109 |
--------------------------------------------------------------------------------
/examples/client/src/main/scala/samples/Persons.scala:
--------------------------------------------------------------------------------
1 | package samples
2 |
3 | import com.raquo.laminar.api.L.*
4 | import dev.cheleb.scalamigen.*
5 | import java.time.LocalDate
6 |
7 | import io.github.iltotore.iron.*
8 | import io.github.iltotore.iron.constraint.all.*
9 |
10 | def person(using
11 | wf: WidgetFactory
12 | ): Sample = {
13 | // Define some models
14 |
15 | @NoPanel
16 | case class Person(
17 | @FieldName("First Name")
18 | name: String,
19 | password: Password,
20 | birthDate: LocalDate,
21 | fav: Pet,
22 | pet: Option[Pet],
23 | email: Option[String],
24 | age: BigInt,
25 | size: Double :| Positive
26 | )
27 | case class Pet(
28 | name: String,
29 | age: BigInt,
30 | House: House,
31 | size: Double :| Positive
32 | )
33 |
34 | case class House(capacity: Int)
35 |
36 | // Provide default for optional
37 | given Defaultable[Pet] with
38 | def default = Pet("No pet", 0, House(0), 1)
39 |
40 | // Instance your model
41 | val vlad =
42 | Person(
43 | "",
44 | Password("not a password"),
45 | LocalDate.of(1431, 11, 8),
46 | Pet("Batman", 666, House(2), 169),
47 | Some(Pet("Wolfy", 12, House(1), 42)),
48 | Some("vlad.dracul@gmail.com"),
49 | 48,
50 | 1.85
51 | )
52 |
53 | val personVar = Var(vlad)
54 |
55 | val errorBus = personVar.errorBus
56 |
57 | Sample(
58 | "Person",
59 | div(
60 | personVar.asForm(errorBus),
61 | div(
62 | child <-- errorBus.watch
63 | .map { errors =>
64 | div(
65 | errors.collect {
66 | case (field, ValidationStatus.Invalid(message, true)) =>
67 | div(
68 | s"$field: $message"
69 | )
70 | }.toSeq
71 | )
72 | }
73 | )
74 | ),
75 | div(child <-- personVar.signal.map { item =>
76 | div(
77 | s"$item"
78 | )
79 | }),
80 | """|
81 | | @NoPanel
82 | | case class Person(
83 | | @FieldName("First Name")
84 | | name: String,
85 | | password: Password,
86 | | birthDate: LocalDate,
87 | | fav: Pet,
88 | | pet: Option[Pet],
89 | | email: Option[String],
90 | | age: BigInt,
91 | | size: Double :| Positive
92 | | )
93 | | case class Pet(
94 | | name: String,
95 | | age: BigInt,
96 | | House: House,
97 | | size: Double :| Positive
98 | | )
99 | |
100 | | case class House(capacity: Int)
101 | |
102 | | // Provide default for optional
103 | | given Defaultable[Pet] with
104 | | def default = Pet("No pet", 0, House(0), 1)
105 | |
106 | | // Instance your model
107 | | val vlad =
108 | | Person(
109 | | "",
110 | | Password("not a password"),
111 | | LocalDate.of(1431, 11, 8),
112 | | Pet("Batman", 666, House(2), 169),
113 | | Some(Pet("Wolfy", 12, House(1), 42)),
114 | | Some("vlad.dracul@gmail.com"),
115 | | 48,
116 | | 1.85
117 | | )
118 | |
119 | |""".stripMargin
120 | )
121 | }
122 |
--------------------------------------------------------------------------------
/examples/client/src/main/scala/samples/Conditional.scala:
--------------------------------------------------------------------------------
1 | package samples
2 |
3 | import dev.cheleb.scalamigen.*
4 | import com.raquo.laminar.api.L.*
5 |
6 | case class Person(
7 | firstname: String,
8 | lastname: String,
9 | age: Int,
10 | extra_str: Option[ExtraString],
11 | extra_int: Option[ExtraInt]
12 | )
13 |
14 | object Person:
15 | val empty = Person("John", "Doe", 16, None, None)
16 |
17 | @Panel("Conditional", false)
18 | case class ConditionalSample(
19 | person: Person
20 | )
21 |
22 | given ConditionalFor[ConditionalSample, ExtraString] with
23 | def check = _.person.age >= 18
24 |
25 | given ConditionalFor[ConditionalSample, ExtraInt] with
26 | def check = _.person.age >= 18
27 |
28 | given Defaultable[Person] with
29 | def default = Person.empty
30 |
31 | val conditionalVar = Var(ConditionalSample(Person.empty))
32 |
33 | given formExtraString: Form[Option[ExtraString]] =
34 | Form.conditionalOn[ConditionalSample, ExtraString](conditionalVar)
35 |
36 | given formExtraInt: Form[Option[ExtraInt]] =
37 | Form.conditionalOn[ConditionalSample, ExtraInt](conditionalVar)
38 |
39 | def conditional(using
40 | wf: WidgetFactory
41 | ): Sample = {
42 |
43 | Sample(
44 | "Conditional",
45 | conditionalVar.asForm,
46 | div(child <-- conditionalVar.signal.map { item =>
47 | div(
48 | s"$item"
49 | )
50 | }),
51 | """|opaque type ExtraString = String
52 | |object ExtraString:
53 | | def apply(s: String): ExtraString = s
54 | | // given Form[ExtraString] = stringForm(identity)
55 | | given Form[ExtraString] = stringFormWithValidation(using
56 | | new Validator[ExtraString] {
57 | | override def validate(str: String): Either[String, ExtraString] =
58 | | str.matches("^[a-fA-F0-9]+$") match
59 | | case true => Right(str)
60 | | case false => Left("expected hexadecimal string (just for demo)")
61 | | }
62 | | )
63 | | given Defaultable[ExtraString] with
64 | | def default: ExtraString = ""
65 | |
66 | |opaque type ExtraInt = Int
67 | |object ExtraInt:
68 | | def apply(i: Int): ExtraInt = i
69 | | given Form[ExtraInt] = numericForm(_.toIntOption, 0)
70 | | given Defaultable[ExtraInt] with
71 | | def default: ExtraInt = 0
72 | |
73 | |// In another file
74 | |
75 | |case class Person(
76 | | firstname: String,
77 | | lastname: String,
78 | | age: Int,
79 | | extra_str: Option[ExtraString],
80 | | extra_int: Option[ExtraInt]
81 | | )
82 | |
83 | |object Person:
84 | | val empty = Person("John", "Doe", 16, None, None)
85 | |
86 | |@Panel("Conditional", false)
87 | |case class ConditionalSample(
88 | | person: Person,
89 | |)
90 | |
91 | |given ConditionalFor[ConditionalSample, ExtraString] with
92 | | def check = _.person.age >= 18
93 | |
94 | |given ConditionalFor[ConditionalSample, ExtraInt] with
95 | | def check = _.person.age >= 18
96 | |
97 | |given Defaultable[Person] with
98 | | def default = Person.empty
99 | |
100 | |val conditionalVar = Var(ConditionalSample(Person.empty))
101 | |
102 | |given formExtraString: Form[Option[ExtraString]] =
103 | | Form.conditionalOn[ConditionalSample, ExtraString](conditionalVar)
104 | |
105 | |given formExtraInt: Form[Option[ExtraInt]] =
106 | | Form.conditionalOn[ConditionalSample, ExtraInt](conditionalVar)
107 | |
108 | |conditionalVar.asForm
109 | |""".stripMargin
110 | )
111 |
112 | }
113 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # laminar-form-derivation
2 |
3 | 
4 | 
5 |
6 | This project derive UI Form for [laminar](https://laminar.dev/) with magnolia.
7 |
8 | ## Docs
9 |
10 | See [Documentation](https://cheleb.github.io/laminar-form-derivation/docs/index.html).
11 |
12 | ## Demo
13 |
14 | For the very impatient, here is a [live demo](https://cheleb.github.io/laminar-form-derivation/demo/index.html).
15 |
16 | ## Installation
17 |
18 | * Scala LTS: v0.24.3
19 | * Scala > 3.6.5: v1.0.0+
20 |
21 | ```sbt
22 | // With raw Laminar widgets (html only)
23 | libraryDependencies += "dev.cheleb" %%% "laminar-form-derivation-ui" % "0.11.0"
24 | // With UI5 Web Components
25 | libraryDependencies += "dev.cheleb" %%% "laminar-form-derivation-ui5" % "0.11.0"
26 | ```
27 |
28 | ## Run the example
29 |
30 | Client side is reloaded in dev mode with [vite](https://vitejs.dev/), server side is built with [sbt](https://www.scala-sbt.org/)
31 |
32 | Client code is in [example/client](./example/client/src/main/scala/HelloWorld.scala)
33 |
34 | ## Development
35 |
36 | ### VSCode with metals
37 |
38 | **Prerequisites**
39 |
40 | * [VSCode](https://code.visualstudio.com/)
41 | * [Metals](https://scalameta.org/metals/docs/editors/vscode.html)
42 | * [sbt](https://www.scala-sbt.org/)
43 | * [Node.js](https://nodejs.org/en/download/)
44 | * [Scala CLI](https://www.scala-lang.org/download/)
45 |
46 |
47 |
48 | Just open the project with vscode and enjoy [the magic](.vscode/tasks.json)
49 |
50 | ```bash
51 | code .
52 | ```
53 |
54 | As soon as you open the project, you will be prompted to import the build, click on the "Import build" button.
55 |
56 | 
57 |
58 | Then wait a few seconds for the build to import ...
59 |
60 | You will have the following tasks:
61 |
62 | * sbt fastLink client
63 | * vite dev hot reloading
64 |
65 | 
66 |
67 | ### Manual
68 |
69 | With vite hot reload
70 |
71 | * Teminal 1
72 |
73 | ```bash
74 | sbt clean
75 | DEV=1 sbt "~client/fastLinkJS"
76 | ```
77 |
78 | * Terminal 2
79 |
80 | ```bash
81 | cd example/client
82 | npm install
83 | npm run dev
84 | ```
85 |
86 | Open , changes are hot reloaded in the browser when you save the [HelloWorld.scala](./example/client/src/main/scala/HelloWorld.scala).
87 |
88 | ### Production mode
89 |
90 | With a ZIO http server (work in progress)
91 |
92 | ```bash
93 | sbt server/run
94 | ```
95 |
96 | Open
97 |
98 | With server restart on change
99 |
100 | ```bash
101 | sbt "~server/reStart"
102 | ```
103 |
104 | Reload the page to see the changes...
105 |
106 | ## Sample Usage
107 |
108 | ```scala
109 | package demo
110 |
111 | import dev.cheleb.scalamigen.*
112 | import dev.cheleb.scalamigen.Form.given
113 | import org.scalajs.dom
114 | import com.raquo.laminar.api.L.*
115 |
116 | // Define some models
117 | case class Person(
118 | name: String,
119 | fav: Pet,
120 | pet: Option[Pet],
121 | email: Option[String],
122 | age: Int
123 | )
124 | case class Pet(name: String, age: Int, House: House, size: Option[Int])
125 |
126 | case class House(capacity: Int)
127 |
128 | // Provide default for optional
129 | given Defaultable[Pet] with
130 | def default = Pet("No pet", 0, House(0), None)
131 |
132 | // Instance your model
133 | val agnes =
134 | Person(
135 | "Vlad",
136 | Pet("Batman", 666, House(2), Some(169)),
137 | Some(Pet("Wolfy", 12, House(1), Some(42))),
138 | Some("vlad.dracul@gmail.com"),
139 | 48
140 | )
141 |
142 | val itemVar = Var(agnes)
143 |
144 | object App extends App {
145 |
146 | val myApp =
147 | div(
148 | child <-- itemVar.signal.map { item =>
149 | div(
150 | s"$item"
151 | )
152 | },
153 | Form.renderVar(itemVar)
154 | )
155 |
156 | val containerNode = dom.document.getElementById("root")
157 | render(containerNode, myApp)
158 | }
159 |
160 | ```
161 |
162 | Renders the following form:
163 |
164 | 
165 |
166 | More info in the [example](./example/client/src/main/scala/HelloWorld.scala)
167 |
--------------------------------------------------------------------------------
/modules/core/src/main/scala/dev/cheleb/scalamigen/package.scala:
--------------------------------------------------------------------------------
1 | package dev.cheleb.scalamigen
2 |
3 | import com.raquo.airstream.state.Var
4 |
5 | import com.raquo.laminar.api.L.*
6 | import com.raquo.laminar.nodes.ReactiveHtmlElement
7 | import org.scalajs.dom.HTMLElement
8 |
9 | /** A form for a type A, no validation. Convenient to use for Opaque types. If
10 | * you need validation, use a Form with a ValidationEvent.
11 | */
12 | def stringForm[A](to: String => A) = new Form[A]:
13 |
14 | override def render(
15 | path: List[Symbol],
16 | variable: Var[A]
17 | )(using
18 | factory: WidgetFactory,
19 | errorBus: EventBus[(String, ValidationEvent)]
20 | ): HtmlElement =
21 | factory.renderText.amend(
22 | value <-- variable.signal.map(_.toString),
23 | onInput.mapToValue.map(to) --> { v =>
24 | variable.set(v)
25 | }
26 | )
27 |
28 | /** A form for a secret type.
29 | *
30 | * The secret type is a string that should not be displayed in clear text.
31 | *
32 | * In general it is used for passwords, api keys, etc...
33 | *
34 | * Hence this sensitive data should be declared as an opaque type.
35 | *
36 | * @param to
37 | * The function to convert the string to the secret type.
38 | * @return
39 | */
40 | def secretForm[A <: String](to: String => A) = new Form[A]:
41 |
42 | override def render(
43 | path: List[Symbol],
44 | variable: Var[A]
45 | )(using
46 | factory: WidgetFactory,
47 | errorBus: EventBus[(String, ValidationEvent)]
48 | ): HtmlElement =
49 | factory.renderSecret.amend(
50 | value <-- variable.signal,
51 | onInput.mapToValue.map(to) --> { v =>
52 | variable.set(v)
53 | }
54 | )
55 |
56 | /** Form for a numeric type.
57 | */
58 | def numericForm[A](f: String => Option[A], zero: A): Form[A] = new Form[A] {
59 | self =>
60 | override def fromString(s: String): Option[A] =
61 | f(s).orElse(Some(zero))
62 |
63 | override def render(
64 | path: List[Symbol],
65 | variable: Var[A]
66 | )(using
67 | factory: WidgetFactory,
68 | errorBus: EventBus[(String, ValidationEvent)]
69 | ): HtmlElement =
70 | factory.renderNumeric
71 | .amend(
72 | value <-- variable.signal.map { str =>
73 | str.toString()
74 | },
75 | onInput.mapToValue --> { v =>
76 | fromString(v).foreach(variable.set)
77 | }
78 | )
79 | }
80 |
81 | /** Render form as html select.
82 | *
83 | * @param elements
84 | * The elements to render.
85 | * @param labelMapper
86 | * The function to map the element to a label. Default is toString.
87 | * @return
88 | */
89 | def selectForm[A](
90 | elements: Array[A],
91 | labelMapper: A => String = (a: A) => a.toString
92 | ) =
93 | new Form[A] {
94 | override def render(
95 | path: List[Symbol],
96 | variable: Var[A]
97 | )(using
98 | factory: WidgetFactory,
99 | errorBus: EventBus[(String, ValidationEvent)]
100 | ): HtmlElement =
101 | val labels = elements.map(labelMapper)
102 | div(
103 | factory
104 | .renderSelect(elements.indexOf(variable.now())) { idx =>
105 | variable.set(elements(idx))
106 | }
107 | .amend(
108 | labels.map { label =>
109 | factory.renderOption(
110 | label,
111 | elements
112 | .map(labelMapper)
113 | .indexOf(label),
114 | label == labelMapper(variable.now())
115 | )
116 | }.toSeq
117 | )
118 | )
119 |
120 | }
121 |
122 | /** Render form as html select.
123 | *
124 | * @param elements
125 | * The elements to render.
126 | * @param mapper
127 | * The function to map the element to a value.
128 | * @param labelMapper
129 | * The function to map the element to a label. Default is toString.
130 | * @return
131 | */
132 | def selectMappedForm[A, B](
133 | elements: Seq[A],
134 | mapper: A => B,
135 | labelMapper: A => String = (a: A) => a.toString
136 | ) =
137 | new Form[B] {
138 |
139 | override def render(
140 | path: List[Symbol],
141 | variable: Var[B]
142 | )(using
143 | factory: WidgetFactory,
144 | errorBus: EventBus[(String, ValidationEvent)]
145 | ): HtmlElement =
146 | val labels = elements.map(labelMapper).zip(elements)
147 | div(
148 | factory
149 | .renderSelect(elements.map(mapper).indexOf(variable.now())) { idx =>
150 | variable.set(mapper(elements(idx)))
151 | }
152 | .amend(
153 | labels.map { (label, a) =>
154 | factory.renderOption(
155 | label,
156 | elements
157 | .map(labelMapper)
158 | .indexOf(label),
159 | a == variable.now()
160 | )
161 | }.toSeq
162 | )
163 | )
164 |
165 | }
166 |
167 | /** A form for a type A, with validation.
168 | *
169 | * @param validator
170 | * The validator for the type A.
171 | */
172 | def stringFormWithValidation[A](using
173 | validator: Validator[A]
174 | ) = new Form[A]:
175 | override def render(
176 | path: List[Symbol],
177 | variable: Var[A]
178 | )(using
179 | factory: WidgetFactory,
180 | errorBus: EventBus[(String, ValidationEvent)]
181 | ): HtmlElement =
182 | val state = Var("valid")
183 | factory.renderText.amend(
184 | value <-- variable.signal.map(_.toString),
185 | onInput.mapToValue.map(validator.validate) --> {
186 | case Right(v) =>
187 | variable.set(v)
188 | errorBus.emit(
189 | (path.key, ValidEvent)
190 | )
191 |
192 | case Left(err) =>
193 | errorBus.emit(
194 | (path.key, InvalideEvent(err))
195 | )
196 | },
197 | cls <-- errorBus.events
198 | .collect {
199 | case (field, InvalideEvent(_)) if field == path.key =>
200 | state.set("invalid")
201 | "srf-invalid"
202 | case (field, ShownEvent) if path.key.startsWith(field) =>
203 | s"srf-${state.now()}"
204 | case (field, HiddenEvent) if path.key.startsWith(field) =>
205 | s"srf-valid"
206 | case (field, ValidEvent) if field == path.key =>
207 | state.set("valid")
208 | "srf-valid"
209 | }
210 | )
211 |
212 | /** Extension methods for the Var class.
213 | */
214 | extension [A](va: Var[A])
215 | /** Render a form for the variable.
216 | *
217 | * @param wf
218 | * The widget factory.
219 | * @return
220 | */
221 | def asForm(using
222 | wf: WidgetFactory
223 | )(using
224 | Form[A]
225 | ): ReactiveHtmlElement[HTMLElement] = {
226 | val errorBus = new EventBus[(String, ValidationEvent)]()
227 | div(
228 | Form
229 | .renderVar(va)(using wf, errorBus)
230 | .amend(cls := "srf-form"),
231 | child <-- errorBus
232 | .errors((field, message) =>
233 | div(
234 | s"$field: $message"
235 | )
236 | )
237 | )
238 |
239 | }
240 |
241 | /** Render a form for the variable.
242 | *
243 | * @param errorBus
244 | * The error bus.
245 | * @param wf
246 | * The widget factory.
247 | * @return
248 | */
249 | def asForm(errorBus: EventBus[(String, ValidationEvent)])(using
250 | wf: WidgetFactory
251 | )(using Form[A]): ReactiveHtmlElement[HTMLElement] =
252 | Form
253 | .renderVar(va)(using wf, errorBus)
254 | .amend(cls := "srf-form")
255 |
256 | /** Buid an error bus for the variable that will be used to display errors.
257 | *
258 | * @return
259 | */
260 | def errorBus = new EventBus[(String, ValidationEvent)]
261 |
--------------------------------------------------------------------------------
/modules/core/src/main/scala/dev/cheleb/scalamigen/Form.scala:
--------------------------------------------------------------------------------
1 | package dev.cheleb.scalamigen
2 |
3 | import com.raquo.laminar.api.L.*
4 | import magnolia1.*
5 |
6 | import scala.util.*
7 | import com.raquo.airstream.state.Var
8 |
9 | import org.scalajs.dom.HTMLElement
10 | import com.raquo.laminar.nodes.ReactiveHtmlElement
11 | import magnolia1.SealedTrait.Subtype
12 | import java.time.LocalDate
13 | import io.github.iltotore.iron.*
14 |
15 | import config.PanelConfig
16 | import com.raquo.airstream.core.Signal
17 |
18 | /** Extension method for path: List[Symbol]
19 | */
20 | extension (path: List[Symbol])
21 | /** Get the key of the path.
22 | *
23 | * This key is used to identify the field in the error bus.
24 | */
25 | def key: String =
26 | path.map(_.name).mkString(".")
27 |
28 | extension (errorBus: EventBus[(String, ValidationEvent)])
29 | /** Watch the error bus and return a signal of the errors.
30 | *
31 | * @return
32 | */
33 | def watch: Signal[Map[String, ValidationStatus]] = errorBus.events
34 | .scanLeft(Map.empty[String, ValidationStatus]) {
35 | case (acc, (field, event)) =>
36 | event match
37 | case ValidEvent => acc - field
38 | case InvalideEvent(error) =>
39 | acc + (field -> ValidationStatus.Invalid(error, true))
40 | case HiddenEvent =>
41 | acc.map {
42 | case (f, ValidationStatus.Invalid(message, true))
43 | if f.startsWith(field) =>
44 | (f -> ValidationStatus.Invalid(message, false))
45 | case (f, v) => (f -> v)
46 | }
47 | case ShownEvent =>
48 | acc.map {
49 | case (f, ValidationStatus.Invalid(message, false))
50 | if f.startsWith(field) =>
51 | (f -> ValidationStatus.Invalid(message, true))
52 | case (f, v) => (f -> v)
53 | }
54 | }
55 |
56 | /** Render the errors.
57 | *
58 | * @param f
59 | * @return
60 | */
61 | def errors(f: (String, String) => HtmlElement) =
62 | errorBus.watch.map { errors =>
63 | div(
64 | errors.collect {
65 | case (field, ValidationStatus.Invalid(message, true)) =>
66 | f(field, message)
67 | }.toSeq
68 | )
69 | }
70 |
71 | /** A form for a type A.
72 | */
73 | trait Form[A] { self =>
74 |
75 | // def isAnyRef = false
76 |
77 | /** Parse a string and return an Option[A].
78 | *
79 | * @param s
80 | * @return
81 | */
82 | def fromString(s: String): Option[A] = None
83 |
84 | def toString(a: A) = a.toString
85 |
86 | protected var _fieldName: Option[String] = None
87 |
88 | def withFieldName(n: String) =
89 | _fieldName = Some(n)
90 | self
91 |
92 | protected var _panelConfig: Option[PanelConfig] = None
93 |
94 | def withPanelConfig(label: Option[String], asTable: Boolean) =
95 | _panelConfig = Some(PanelConfig(label, asTable))
96 | self
97 |
98 | /** Render a form for a variable.
99 | *
100 | * Sometimes the form is a part of a larger form and the parent form needs to
101 | * be updated when the variable changes. This is the purpose of the
102 | * syncParent function.
103 | *
104 | * @param variable
105 | * the variable to render
106 | * @param factory
107 | * the widget factory
108 | * @return
109 | */
110 | def render(
111 | path: List[Symbol],
112 | variable: Var[A]
113 | )(using
114 | factory: WidgetFactory,
115 | errorBus: EventBus[(String, ValidationEvent)]
116 | ): HtmlElement
117 |
118 | given Owner = unsafeWindowOwner
119 |
120 | /** Render the label form associated with a variable
121 | *
122 | * @param label
123 | * label of the variable
124 | * @param required
125 | * if the variable is mandatory or not
126 | * @param factory
127 | * the widget factory
128 | * @return
129 | */
130 | def renderLabel(
131 | label: String,
132 | required: Boolean
133 | )(using
134 | factory: WidgetFactory
135 | ): HtmlElement =
136 | factory.renderLabel(required, label)
137 |
138 | /*
139 |
140 |
141 |
142 |
Left
143 |
Right
144 |
145 |
146 |
147 | */
148 | def labelled(label: String, required: Boolean): Form[A] = new Form[A] {
149 | override def render(
150 | path: List[Symbol],
151 | variable: Var[A]
152 | )(using
153 | factory: WidgetFactory,
154 | errorBus: EventBus[(String, ValidationEvent)]
155 | ): HtmlElement =
156 | div(
157 | div(
158 | self.renderLabel(label, required)
159 | ),
160 | div(
161 | self.render(path, variable)
162 | )
163 | )
164 |
165 | }
166 | def xmap[B](to: (B, A) => B)(from: B => A): Form[B] = new Form[B] {
167 | override def render(
168 | path: List[Symbol],
169 | variable: Var[B]
170 | )(using
171 | factory: WidgetFactory,
172 | errorBus: EventBus[(String, ValidationEvent)]
173 | ): HtmlElement =
174 | self.render(path, variable.zoomLazy(from)(to))
175 | }
176 |
177 | }
178 |
179 | object Form extends AutoDerivation[Form] {
180 |
181 | type Typeclass[T] = Form[T]
182 |
183 | /** Render a variable with a form.
184 | *
185 | * @param v
186 | * the variable to render
187 | * @param syncParent
188 | * a function to sync the parent state
189 | * @param factory
190 | * the widget factory
191 | * @param fa
192 | * the form for the variable, either given or derived by magnolia <3
193 | * @tparam A
194 | * the type of the variable
195 | * @return
196 | */
197 | def renderVar[A](v: Var[A])(using
198 | WidgetFactory,
199 | EventBus[(String, ValidationEvent)]
200 | )(using
201 | fa: Form[A]
202 | ): ReactiveHtmlElement[HTMLElement] =
203 | fa.render(Nil, v)
204 |
205 | def renderVar[A](path: List[Symbol], v: Var[A])(using
206 | WidgetFactory,
207 | EventBus[(String, ValidationEvent)]
208 | )(using
209 | fa: Form[A]
210 | ): ReactiveHtmlElement[HTMLElement] =
211 | fa.render(path, v)
212 |
213 | /** Form for an Iron type. This is a form for a type that can be validated
214 | * with an Iron type.
215 | * @param validator
216 | * the Iron type validator
217 | * @param default
218 | * the default value for the Iron type
219 | * @param widgetFactory
220 | * the widget factory
221 | * @tparam T
222 | * the base type of the Iron type
223 | * @tparam C
224 | * the type of the Iron type contraint
225 | */
226 | given [T, C](using
227 | validator: IronTypeValidator[T, C]
228 | ): Form[IronType[T, C]] =
229 | new Form[IronType[T, C]] {
230 | override def render(
231 | path: List[Symbol],
232 | variable: Var[IronType[T, C]]
233 | )(using
234 | factory: WidgetFactory,
235 | errorBus: EventBus[(String, ValidationEvent)]
236 | ): HtmlElement = {
237 | val state = Var("valid")
238 | factory.renderText
239 | .amend(
240 | value := variable.now().toString,
241 | // value <-- variable.signal // FIXME should be able to sync mode.
242 | // .tapEach(_ => errorBus.emit(path.key -> ValidEvent))
243 | // .map(_.toString),
244 | onInput.mapToValue --> { str =>
245 | validator.validate(str) match
246 | case Left(error) =>
247 | errorBus.emit(path.key -> InvalideEvent(error))
248 | case Right(value) =>
249 | errorBus.emit(path.key -> ValidEvent)
250 | variable.set(value)
251 | },
252 | cls <-- errorBus.events
253 | .collect {
254 | case (field, InvalideEvent(_)) if field == path.key =>
255 | state.set("invalid")
256 | "srf-invalid"
257 | case (field, ShownEvent) if path.key.startsWith(field) =>
258 | s"srf-${state.now()}"
259 | case (field, HiddenEvent) if path.key.startsWith(field) =>
260 | s"srf-valid"
261 | case (field, ValidEvent) if field == path.key =>
262 | state.set("valid")
263 | "srf-valid"
264 | }
265 | )
266 | }
267 | }
268 |
269 | /** Form for to a string, aka without validation.
270 | */
271 | given Form[String] with
272 |
273 | override def render(
274 | path: List[Symbol],
275 | variable: Var[String]
276 | )(using
277 | factory: WidgetFactory,
278 | errorBus: EventBus[(String, ValidationEvent)]
279 | ): HtmlElement =
280 | factory.renderText
281 | .amend(
282 | value <-- variable.signal,
283 | onInput.mapToValue --> { v =>
284 | variable.set(v)
285 | }
286 | )
287 |
288 | /** Form for a Nothing, not sure it is still really needed :-/
289 | */
290 | given Form[Nothing] = new Form[Nothing] {
291 |
292 | override def render(
293 | path: List[Symbol],
294 | variable: Var[Nothing]
295 | )(using
296 | factory: WidgetFactory,
297 | errorBus: EventBus[(String, ValidationEvent)]
298 | ): HtmlElement =
299 | div()
300 | }
301 |
302 | /** Form for a Boolean.
303 | *
304 | * Basically a checkbox.
305 | */
306 | given Form[Boolean] = new Form[Boolean] {
307 |
308 | override def render(
309 | path: List[Symbol],
310 | variable: Var[Boolean]
311 | )(using
312 | factory: WidgetFactory,
313 | errorBus: EventBus[(String, ValidationEvent)]
314 | ): HtmlElement =
315 | div(
316 | factory.renderCheckbox
317 | .amend(
318 | checked <-- variable.signal,
319 | onChange.mapToChecked --> { v =>
320 | variable.set(v)
321 | }
322 | )
323 | )
324 | }
325 |
326 | /** Form for an Double.
327 | */
328 | given Form[Double] = numericForm(_.toDoubleOption, 0)
329 |
330 | /** Form for an Int.
331 | */
332 | given Form[Int] = numericForm(_.toIntOption, 0)
333 |
334 | /** Form for an Float.
335 | */
336 | given Form[Float] = numericForm(_.toFloatOption, 0)
337 |
338 | /** Form for an Long.
339 | */
340 | given Form[Long] = numericForm(_.toLongOption, 0)
341 |
342 | /** Form for a BigInt.
343 | */
344 | given Form[BigInt] =
345 | numericForm(str => Try(BigInt(str)).toOption, BigInt(0))
346 |
347 | /** Form for a BigDecimal.
348 | */
349 | given Form[BigDecimal] =
350 | numericForm(str => Try(BigDecimal(str)).toOption, BigDecimal(0))
351 |
352 | /** Form for a either of L or R
353 | *
354 | * @param lf
355 | * the left form for a L, given or derived by magnolia
356 | * @param rf
357 | * the right form for a R, given or derived by magnolia
358 | * @param ld
359 | * the default value for a L
360 | * @param rd
361 | * the default value for a R
362 | * @return
363 | */
364 | given eitherOf[L, R](using
365 | lf: Form[L],
366 | rf: Form[R],
367 | ld: Defaultable[L],
368 | rd: Defaultable[R]
369 | ): Form[Either[L, R]] =
370 | new Form[Either[L, R]] {
371 | override def render(
372 | path: List[Symbol],
373 | variable: Var[Either[L, R]]
374 | )(using
375 | factory: WidgetFactory,
376 | errorBus: EventBus[(String, ValidationEvent)]
377 | ): HtmlElement =
378 | div(
379 | span(
380 | factory.renderLink(
381 | "Left",
382 | onClick.mapTo(true) --> Observer[Boolean] { _ =>
383 | errorBus.emit(path.key -> ShownEvent)
384 | variable.set(Left(ld.default))
385 | }
386 | ),
387 | "---",
388 | factory.renderLink(
389 | "Right",
390 | onClick.mapTo(true) --> Observer[Boolean] { _ =>
391 | errorBus.emit(path.key -> ShownEvent)
392 | variable.set(Right(rd.default))
393 | }
394 | )
395 | ),
396 | div(
397 | lf.render(
398 | path,
399 | variable.zoom {
400 | case Right(_) => ld.default
401 | case Left(l) => l
402 | } { case (_, l) =>
403 | Left(l)
404 | }
405 | ),
406 | display <-- variable.signal.map {
407 | case Left(_) => "block"
408 | case _ => "none"
409 | }
410 | ),
411 | div(
412 | rf.render(
413 | path,
414 | variable.zoomLazy {
415 | case Right(r) => r
416 | case Left(_) => rd.default
417 | } { case (_, r) =>
418 | Right(r)
419 | }
420 | ),
421 | display <-- variable.signal.map {
422 | case Right(_) => "block"
423 | case _ => "none"
424 | }
425 | )
426 | )
427 |
428 | }
429 |
430 | /** Form for an Option[A]
431 | *
432 | * Render with clear button if the value is Some, else render with a set new
433 | * value button.
434 | * @param fa
435 | * the form for A
436 | * @param d
437 | * the default value for A
438 | * @return
439 | */
440 | given optionOfA[A](using
441 | fa: Form[A],
442 | d: Defaultable[A]
443 | ): Form[Option[A]] =
444 | new Form[Option[A]] {
445 | override def render(
446 | path: List[Symbol],
447 | variable: Var[Option[A]]
448 | )(using
449 | factory: WidgetFactory,
450 | errorBus: EventBus[(String, ValidationEvent)]
451 | ): HtmlElement = {
452 | val a = variable.zoomLazy {
453 | case Some(a) =>
454 | a
455 | case None => d.default
456 | } { case (_, a) =>
457 | Some(a)
458 | }
459 |
460 | a.now() match
461 | case null =>
462 | factory.renderButton.amend(
463 | "Set",
464 | onClick.mapTo(Some(d.default)) --> variable.writer
465 | )
466 | case _ =>
467 | div(
468 | div(
469 | display <-- variable.signal.map {
470 | case Some(_) => "block"
471 | case None => "none"
472 | },
473 | fa.render(path, a)
474 | ),
475 | div(
476 | factory.renderButton.amend(
477 | display <-- variable.signal.map {
478 | case Some(_) => "none"
479 | case None => "block"
480 | },
481 | "Set",
482 | onClick.mapTo(Some(d.default)) --> Observer[Option[A]] { sa =>
483 |
484 | errorBus.emit(path.key -> ShownEvent)
485 |
486 | variable.set(sa)
487 | }
488 | ),
489 | factory.renderButton.amend(
490 | display <-- variable.signal.map {
491 | case Some(_) => "block"
492 | case None => "none"
493 | },
494 | "Clear",
495 | onClick.mapTo(None) --> Observer[Option[A]] { _ =>
496 | errorBus.emit(path.key -> HiddenEvent)
497 |
498 | variable.set(None)
499 | }
500 | )
501 | )
502 | )
503 | }
504 | }
505 |
506 | /** Form for an Option[A]
507 | *
508 | * Rendered if some specific condition is met, hidden otherwise
509 | *
510 | * @param condVar
511 | * the variable on check the condition is to be checked
512 | * @param fa
513 | * the form for A
514 | * @param d
515 | * the default value for A
516 | * @param cond
517 | * the condition to be checked on C, related to show or hide the form for A
518 | * @return
519 | */
520 | def conditionalOn[C, A](
521 | condVar: Var[C]
522 | )(using
523 | fa: Form[A],
524 | d: Defaultable[A],
525 | cond: ConditionalFor[C, A]
526 | ): Form[Option[A]] =
527 | val displaySrc = condVar.signal
528 | .map(cond.check)
529 | .map:
530 | case true => "block"
531 | case false => "none"
532 |
533 | new Form[Option[A]] {
534 | override def renderLabel(
535 | label: String,
536 | required: Boolean
537 | )(using
538 | factory: WidgetFactory
539 | ): HtmlElement =
540 | super
541 | .renderLabel(label, true)
542 | .amend(
543 | display <-- displaySrc
544 | )
545 |
546 | override def render(
547 | path: List[Symbol],
548 | variable: Var[Option[A]]
549 | )(using
550 | factory: WidgetFactory,
551 | errorBus: EventBus[(String, ValidationEvent)]
552 | ): HtmlElement = {
553 |
554 | val varA = variable.zoomLazy {
555 | case Some(a) => a
556 | case None => d.default
557 | } { case (_, a) => Some(a) }
558 |
559 | fa.render(path, varA)
560 | .amend(
561 | display <-- displaySrc,
562 | condVar.signal.map { v =>
563 | val ev = if (cond.check(v)) {
564 | ShownEvent
565 | } else {
566 | if (variable.now().isDefined)
567 | variable.set(None)
568 | HiddenEvent
569 | }
570 | (path.key, ev)
571 | }
572 | --> errorBus.writer
573 | )
574 | }
575 | }
576 |
577 | /** Form for a List[A]
578 | * @param fa
579 | * the form for A
580 | * @return
581 | */
582 |
583 | given listOfA[A, K](using fa: Form[A]): Form[List[A]] =
584 | new Form[List[A]] {
585 |
586 | override def render(
587 | path: List[Symbol],
588 | variable: Var[List[A]]
589 | )(using
590 | factory: WidgetFactory,
591 | errorBus: EventBus[(String, ValidationEvent)]
592 | ): HtmlElement =
593 | div(
594 | children <-- variable.splitByIndex((id, _, aVar) => {
595 | div(
596 | idAttr := s"list-item-$id",
597 | div(
598 | fa.render(path, aVar)
599 | )
600 | )
601 | })
602 | )
603 | }
604 |
605 | /** Form for a LocalDate
606 | *
607 | * Render a date picker. // FIXME should be able to set the format
608 | */
609 | given Form[LocalDate] = new Form[LocalDate] {
610 |
611 | override def render(
612 | path: List[Symbol],
613 | variable: Var[LocalDate]
614 | )(using
615 | factory: WidgetFactory,
616 | errorBus: EventBus[(String, ValidationEvent)]
617 | ): HtmlElement =
618 | div(
619 | factory.renderDatePicker
620 | .amend(
621 | value <-- variable.signal.map(_.toString),
622 | onChange.mapToValue --> { v =>
623 | variable.set(LocalDate.parse(v))
624 | }
625 | )
626 | )
627 | }
628 |
629 | def join[A](
630 | caseClass: CaseClass[Typeclass, A]
631 | ): Form[A] = new Form[A] {
632 |
633 | private def fieldNameFromParam(param: CaseClass.Param[Form, A]): String =
634 | param.annotations
635 | .find(_.isInstanceOf[FieldName]) match
636 | case None =>
637 | param.typeclass._fieldName.getOrElse(NameUtils.titleCase(param.label))
638 | case Some(value) =>
639 | value.asInstanceOf[FieldName].value
640 |
641 | private def mkVariableForParam(
642 | variable: Var[A],
643 | param: CaseClass.Param[Form, A]
644 | ): Var[param.PType] =
645 | variable.zoomLazy { a =>
646 | Try(param.deref(a))
647 | .getOrElse(param.default)
648 | .asInstanceOf[param.PType]
649 | }((_, value) =>
650 | caseClass.construct { p =>
651 | if (p.label == param.label) value
652 | else {
653 | p.deref(variable.now())
654 | }
655 | }
656 | )
657 |
658 | override def render(
659 | path: List[Symbol],
660 | variable: Var[A]
661 | )(using
662 | factory: WidgetFactory,
663 | errorBus: EventBus[(String, ValidationEvent)]
664 | ): HtmlElement = {
665 | val panel = _panelConfig.getOrElse:
666 | caseClass.annotations.find(_.isInstanceOf[Panel]) match
667 | case None =>
668 | caseClass.annotations.find(_.isInstanceOf[NoPanel]) match
669 | case None =>
670 | PanelConfig(Some(caseClass.typeInfo.short), true)
671 | case Some(annot) =>
672 | val asTable = annot.asInstanceOf[NoPanel].asTable
673 | PanelConfig(None, asTable)
674 |
675 | case Some(value) =>
676 | val panel = value.asInstanceOf[Panel]
677 | PanelConfig(Option(panel.name), panel.asTable)
678 |
679 | def renderAsTable() =
680 | table(
681 | caseClass.params.map { param =>
682 |
683 | val isOption =
684 | param.deref(variable.now()).isInstanceOf[Option[?]]
685 |
686 | val fieldName = fieldNameFromParam(param)
687 | tr(
688 | td(
689 | param.typeclass.renderLabel(fieldName, !isOption)
690 | ).amend(
691 | className := panel.fieldCss
692 | ),
693 | td(
694 | param.typeclass
695 | .render(
696 | path :+ Symbol(fieldName),
697 | mkVariableForParam(variable, param)
698 | )
699 | .amend(
700 | idAttr := param.label
701 | )
702 | )
703 | )
704 | }.toSeq
705 | )
706 |
707 | def renderAsPanel() =
708 | caseClass.params.map { param =>
709 | val isOption = param.deref(variable.now()).isInstanceOf[Option[?]]
710 | val fieldName = fieldNameFromParam(param)
711 | param.typeclass
712 | .labelled(fieldName, !isOption)
713 | .render(
714 | path :+ Symbol(fieldName),
715 | mkVariableForParam(variable, param)
716 | )
717 | .amend(
718 | idAttr := param.label
719 | )
720 | }.toSeq
721 |
722 | factory
723 | .renderPanel(panel.label)
724 | .amend(
725 | className := panel.panelCss,
726 | // cls := "srf-form",
727 | if panel.asTable then renderAsTable()
728 | else renderAsPanel()
729 | )
730 | }
731 | }
732 |
733 | def split[A](sealedTrait: SealedTrait[Form, A]): Form[A] = new Form[A] {
734 |
735 | override def render(
736 | path: List[Symbol],
737 | variable: Var[A]
738 | )(using
739 | factory: WidgetFactory,
740 | errorBus: EventBus[(String, ValidationEvent)]
741 | ): HtmlElement =
742 | val a = variable.now()
743 | println(s"Rendering sealed trait: $a")
744 | sealedTrait.choose(a) { sub =>
745 |
746 | val va = variable.zoom { na =>
747 | try (sub.cast(na))
748 | catch {
749 | // Handle casting failure when user changes the subtype...
750 | case _: Throwable => sub.cast(a)
751 | }
752 | } { case (_, a2) =>
753 | a2
754 | }
755 |
756 | va.signal --> variable.writer
757 | sub.typeclass
758 | .render(
759 | path,
760 | va
761 | )
762 | .amend(
763 | idAttr := path.key
764 | )
765 | }
766 |
767 | }
768 |
769 | def getSubtypeLabel[T](sub: Subtype[Typeclass, T, ?]): String =
770 | sub.annotations
771 | .collectFirst { case label: FieldName => label.value }
772 | .getOrElse(NameUtils.titleCase(sub.typeInfo.short))
773 |
774 | }
775 |
--------------------------------------------------------------------------------