├── 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 | GitHub 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 | GitHub 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 | GitHub 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 | ![Sequence Diagram](/images/contributing/vscode-setup.png) 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 | ![Sample Form](../images/simple-form.png) -------------------------------------------------------------------------------- /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 | ![Sonatype Central](https://maven-badges.sml.io/sonatype-central/dev.cheleb/zio-tapir-laminar_sjs1_3/badge.svg) 4 | ![GitHub Workflow Status](https://github.com/cheleb/laminar-form-derivation/actions/workflows/ci.yml/badge.svg) 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 | ![Import build](./docs/_assets/images/import-project.png) 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 | ![Tasks](./docs/_assets/images/dev-terminals.png) 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 | ![Form](./form.png) 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 | --------------------------------------------------------------------------------