From 8fc084aab93b9bb3575420e0382031e4dfa34d0f Mon Sep 17 00:00:00 2001 From: Andrea Minetti Date: Mon, 13 Oct 2025 13:55:29 +0200 Subject: [PATCH 01/42] Vite draft --- build.sbt | 139 +++++++----------- client/index.html | 16 ++ client/javascript.svg | 1 + client/main.js | 3 + client/package.json | 59 ++++++++ client/public/vite.svg | 1 + .../main/scala/ch/wsl/box/client/Main.scala | 4 +- client/style.scss | 1 + client/vite.config.js | 35 +++++ project/plugins.sbt | 4 +- .../metadata/InMemoryMetadataFactory.scala | 52 +++++++ .../scala/ch/wsl/box/rest/routes/UI.scala | 14 +- stats.json | 1 + 13 files changed, 231 insertions(+), 99 deletions(-) create mode 100644 client/index.html create mode 100644 client/javascript.svg create mode 100644 client/main.js create mode 100644 client/package.json create mode 100644 client/public/vite.svg create mode 100644 client/style.scss create mode 100644 client/vite.config.js create mode 100644 server/src/main/scala/ch/wsl/box/rest/metadata/InMemoryMetadataFactory.scala create mode 100644 stats.json diff --git a/build.sbt b/build.sbt index da2999c3..f35710d4 100755 --- a/build.sbt +++ b/build.sbt @@ -2,7 +2,7 @@ import com.jsuereth.sbtpgp.PgpKeys.publishSigned //import xerial.sbt.Sonatype.sonatypeCentralHost import locales.LocalesFilter import org.scalajs.jsenv.Input.Script -import scalajsbundler.util.JSON +import org.scalajs.linker.interface.ModuleSplitStyle val publishSettings = List( Global / scalaJSStage := FullOptStage, @@ -81,24 +81,6 @@ lazy val server: Project = project buildInfoKeys := Seq[BuildInfoKey](version), buildInfoPackage := "boxInfo", buildInfoObject := "BoxBuildInfo", - Compile / compile := ((Compile / compile) dependsOn scalaJSPipeline).value, - Runtime / managedClasspath += (Assets / packageBin).value, - Assets / WebKeys.packagePrefix := "public/", - //Comment this to avoid errors in importing project, i.e. when changing libraries - Assets / pipelineStages := Seq(scalaJSPipeline), - Assets / scalaJSStage := FullOptStage, - scalaJSProjects := { - if (sys.env.contains("DEV_SERVER") || sys.env.contains("RUNNING_TEST")) Seq() else Seq(client) - }, -// scalaJSProjects := Seq(client), - webpackBundlingMode := BundlingMode.Application, - Seq("jquery","ol","bootstrap","flatpickr","quill","@fontsource/open-sans","@fortawesome/fontawesome-free","choices.js","gridstack","jspreadsheet-ce","jsuites","toolcool-range-slider","@electric-sql/pglite","@electric-sql/pglite-repl").map{ p => - if (!sys.env.contains("RUNNING_TEST")) - npmAssets ++= NpmAssets.ofProject(client) { nodeModules => - (nodeModules / p).allPaths - }.value - else npmAssets := Seq() - }, Test / testOptions ++= Seq( Tests.Argument(TestFrameworks.ScalaTest, "-u", "target/test-reports"), Tests.Argument(TestFrameworks.ScalaTest, "-h", "target/test-reports"), @@ -109,7 +91,6 @@ lazy val server: Project = project .enablePlugins( GitVersioning, BuildInfoPlugin, - WebScalaJSBundlerPlugin, SbtTwirl ) .dependsOn(sharedJVM) @@ -134,54 +115,56 @@ lazy val client: Project = (project in file("client")) scalacOptions ++= Settings.scalacOptions, resolvers += Resolver.jcenterRepo, libraryDependencies ++= Settings.scalajsDependencies.value, - // yes, we want to package JS dependencies - packageJSDependencies / skip := false, + // use Scala.js provided launcher code to start the client app scalaJSUseMainModuleInitializer := true, scalaJSStage := FullOptStage, - Compile / npmDependencies ++= Seq( - "ol" -> "8.1.0", - "proj4" -> "2.9.1", - "@types/proj4" -> "2.5.3", - "ol-ext" -> "4.0.11", - //"@siedlerchr/types-ol-ext" -> "3.2.4", - "jsts" -> "2.7.1", - "@types/jsts" -> "0.17.13", - "jquery" -> "3.4.1", - "@types/jquery" -> "3.5.6", - "popper.js" -> "1.16.1", - "bootstrap" -> "4.1.3", - "@types/bootstrap" -> "4.1.3", - "@fortawesome/fontawesome-free" -> "5.15.4", - "flatpickr" -> "4.6.3", - "monaco-editor" -> "0.34.0", - "quill" -> "1.3.7", - "@types/quill" -> "1.3.10", - "@fontsource/open-sans" -> "5.0.15", - "file-saver" -> "2.0.5", - "@types/file-saver" -> "2.0.1", - "js-md5" -> "0.7.3", - "@types/js-md5" -> "0.4.2", - "striptags" -> "3.2.0", - "toolcool-range-slider" -> "4.0.28", - "hotkeys-js" -> "3.10.0", - "crypto-browserify" -> "3.12.0", - "buffer" -> "6.0.3", - "stream-browserify" -> "3.0.0", - "choices.js" -> "11.1.0", - "autocompleter" -> "7.0.1", - "xlsx-js-style" -> "1.2.0", - "jspdf" -> "2.5.1", - "jspdf-autotable" -> "3.5.28", - "gridstack" -> "12.2.2", - "jspreadsheet-ce" -> "git://github.com/jspreadsheet/ce.git#2e7389f8f6a84d260603bbac06f00bb404e1ba49", //v5.0.0 - "jsuites" -> "5.9.1", - "@electric-sql/pglite" -> "0.2.17", - "@electric-sql/pglite-repl" -> "0.2.17", - "compressorjs" -> "1.2.1", - "shapefile" -> "0.6.6", - "@types/shapefile" -> "0.6.4", - ), + externalNpm := baseDirectory.value, //.map(f => new File(f.getPath + "/client")).value, +// Compile / npmDependencies ++= Seq( +// "ol" -> "8.1.0", +// "proj4" -> "2.9.1", +// "@types/proj4" -> "2.5.3", +// "ol-ext" -> "4.0.11", +// //"@siedlerchr/types-ol-ext" -> "3.2.4", +// "jsts" -> "2.7.1", +// "@types/jsts" -> "0.17.13", +// "jquery" -> "3.4.1", +// "@types/jquery" -> "3.5.6", +// "popper.js" -> "1.16.1", +// "bootstrap" -> "4.1.3", +// "@types/bootstrap" -> "4.1.3", +// "@fortawesome/fontawesome-free" -> "5.15.4", +// "flatpickr" -> "4.6.3", +// "monaco-editor" -> "0.34.0", +// "quill" -> "1.3.7", +// "@types/quill" -> "1.3.10", +// "@fontsource/open-sans" -> "5.0.15", +// "file-saver" -> "2.0.5", +// "@types/file-saver" -> "2.0.1", +// "js-md5" -> "0.7.3", +// "@types/js-md5" -> "0.4.2", +// "striptags" -> "3.2.0", +// "toolcool-range-slider" -> "4.0.28", +// "hotkeys-js" -> "3.10.0", +// "crypto-browserify" -> "3.12.0", +// "buffer" -> "6.0.3", +// "stream-browserify" -> "3.0.0", +// "choices.js" -> "11.1.0", +// "autocompleter" -> "7.0.1", +// "xlsx-js-style" -> "1.2.0", +// "jspdf" -> "2.5.1", +// "jspdf-autotable" -> "3.5.28", +// "gridstack" -> "12.2.2", +// "jspreadsheet-ce" -> "git://github.com/jspreadsheet/ce.git#2e7389f8f6a84d260603bbac06f00bb404e1ba49", //v5.0.0 +// "jsuites" -> "5.9.1", +// "@electric-sql/pglite" -> "0.2.17", +// "@electric-sql/pglite-repl" -> "0.2.17", +// "compressorjs" -> "1.2.1", +// "shapefile" -> "0.6.6", +// "@types/shapefile" -> "0.6.4", +// ), + stIncludeDev := false, + stStdlib := List("es6", "es2018.asyncgenerator"), stIgnore += "@fontsource/open-sans", stIgnore += "redux", stIgnore += "node", @@ -190,29 +173,9 @@ lazy val client: Project = (project in file("client")) stIgnore += "@fortawesome/fontawesome-free", stIgnore += "stream-browserify", stIgnore += "toolcool-range-slider", - stTypescriptVersion := "4.2.4", stOutputPackage := "ch.wsl.typings", - // Use library mode for fastOptJS - //Compile / additionalNpmConfig := Map("sideEffects" -> JSON.bool(false)), - fastOptJS / webpackBundlingMode := BundlingMode.Application, - fastOptJS / webpackConfigFile := Some(baseDirectory.value / ".." / "dev.config.js"), - // Use application model mode for fullOptJS - fullOptJS / webpackBundlingMode := BundlingMode.Application, - fullOptJS / webpackConfigFile := Some(baseDirectory.value / ".." / "prod.config.js"), - Test / webpackConfigFile := Some(baseDirectory.value / ".." / "test.config.js"), - Compile / npmDevDependencies ++= Seq( - "html-webpack-plugin" -> "5.5.0", - "webpack-merge" -> "5.8.0", - "style-loader" -> "3.3.1", - "css-loader" -> "6.7.1", - "mini-css-extract-plugin" -> "2.6.1", - "monaco-editor-webpack-plugin" -> "7.0.1", - "file-loader" -> "6.2.0", - ), + scalaJSLinkerConfig ~= (_.withModuleKind(ModuleKind.ESModule).withModuleSplitStyle(ModuleSplitStyle.SmallModulesFor(List("ch.wsl")))), - webpack / version := "5.89.0", - webpackCliVersion := "5.1.4", - installJsdom / version := "20.0.0", //To use jsdom headless browser uncomment the following lines Test / requireJsDomEnv := true, @@ -249,7 +212,7 @@ lazy val client: Project = (project in file("client")) .settings(publishSettings) .enablePlugins( ScalaJSPlugin, - ScalablyTypedConverterGenSourcePlugin, + ScalablyTypedConverterExternalNpmPlugin, LocalesPlugin ) .dependsOn(sharedJS) @@ -368,7 +331,7 @@ lazy val publishAllTask = { lazy val publishAllLocal = taskKey[Unit]("Publish all modules") lazy val publishAllLocalTask = { Def.sequential( - (client / Compile / fullOptJS / webpack), + (client / Compile / fullOptJS), (codegen / Compile / compile), (sharedJVM / publishLocal), (codegen / publishLocal), diff --git a/client/index.html b/client/index.html new file mode 100644 index 00000000..31f3bda2 --- /dev/null +++ b/client/index.html @@ -0,0 +1,16 @@ + + + + + + + Vite App + + +
+ + + + diff --git a/client/javascript.svg b/client/javascript.svg new file mode 100644 index 00000000..f9abb2b7 --- /dev/null +++ b/client/javascript.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/main.js b/client/main.js new file mode 100644 index 00000000..3e02138e --- /dev/null +++ b/client/main.js @@ -0,0 +1,3 @@ +import 'scalajs:main.js' +import './style.scss' +import * as bootstrap from 'bootstrap' \ No newline at end of file diff --git a/client/package.json b/client/package.json new file mode 100644 index 00000000..2569c7c5 --- /dev/null +++ b/client/package.json @@ -0,0 +1,59 @@ +{ + "name": "box-client", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@electric-sql/pglite": "0.2.17", + "@electric-sql/pglite-repl": "0.2.17", + "@fontsource/open-sans": "5.0.15", + "@fortawesome/fontawesome-free": "5.15.4", + "@types/bootstrap": "4.1.3", + "@types/file-saver": "2.0.1", + "@types/jquery": "3.5.6", + "@types/js-md5": "0.4.2", + "@types/jsts": "0.17.13", + "@types/proj4": "2.5.3", + "@types/quill": "1.3.10", + "@types/shapefile": "0.6.4", + "autocompleter": "7.0.1", + "bootstrap": "4.1.3", + "buffer": "6.0.3", + "choices.js": "11.1.0", + "compressorjs": "1.2.1", + "crypto-browserify": "3.12.0", + "file-saver": "2.0.5", + "flatpickr": "4.6.3", + "gridstack": "12.2.2", + "hotkeys-js": "3.10.0", + "jquery": "3.4.1", + "js-md5": "0.7.3", + "jspdf": "2.5.1", + "jspdf-autotable": "3.5.28", + "jspreadsheet-ce": "git://github.com/jspreadsheet/ce.git#2e7389f8f6a84d260603bbac06f00bb404e1ba49", + "jsts": "2.7.1", + "jsuites": "5.9.1", + "monaco-editor": "0.34.0", + "ol": "8.1.0", + "ol-ext": "4.0.11", + "popper.js": "1.16.1", + "proj4": "2.9.1", + "quill": "1.3.7", + "shapefile": "0.6.6", + "stream-browserify": "3.0.0", + "striptags": "3.2.0", + "toolcool-range-slider": "4.0.28", + "xlsx-js-style": "1.2.0" + }, + "devDependencies": { + "@scala-js/vite-plugin-scalajs": "^1.1.0", + "sass-embedded": "^1.93.2", + "typescript": "^4.1.6", + "vite": "^7.1.9" + } +} diff --git a/client/public/vite.svg b/client/public/vite.svg new file mode 100644 index 00000000..e7b8dfb1 --- /dev/null +++ b/client/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/main/scala/ch/wsl/box/client/Main.scala b/client/src/main/scala/ch/wsl/box/client/Main.scala index 75225b62..a54bec59 100644 --- a/client/src/main/scala/ch/wsl/box/client/Main.scala +++ b/client/src/main/scala/ch/wsl/box/client/Main.scala @@ -70,7 +70,6 @@ object Main extends Logging { println(s"Setting logger level to ${ClientConf.loggerLevel}") //loads datetime picker - ch.wsl.typings.bootstrap.bootstrapRequire //ch.wsl.typings.toolcoolRangeSlider.toolcoolRangeSliderRequire @@ -130,10 +129,9 @@ object Main extends Logging { document.body.appendChild(autocompleteStyle) document.body.appendChild(stepperStyle) - val app = document.createElement("div") + val app = document.getElementById("app") BrowserConsole.log(app) - document.body.appendChild(app) BrowserConsole.log(document.body) applicationInstance.run(app) diff --git a/client/style.scss b/client/style.scss new file mode 100644 index 00000000..93c4b1c7 --- /dev/null +++ b/client/style.scss @@ -0,0 +1 @@ +@import "~bootstrap/scss/bootstrap"; \ No newline at end of file diff --git a/client/vite.config.js b/client/vite.config.js new file mode 100644 index 00000000..48a8dc5a --- /dev/null +++ b/client/vite.config.js @@ -0,0 +1,35 @@ +import { defineConfig } from "vite"; +import scalaJSPlugin from "@scala-js/vite-plugin-scalajs"; + + +export default defineConfig({ + server: { + proxy: { + '/api': 'http://localhost:8080', + }, + }, + optimizeDeps: { + exclude: ['@electric-sql/pglite'], + }, + worker: { + format: 'es', + }, + resolve: { + alias: { + '~bootstrap': './node_modules/bootstrap', + } + }, + plugins: [scalaJSPlugin({ + // path to the directory containing the sbt build + // default: '.' + cwd: '..', + + // sbt project ID from within the sbt build to get fast/fullLinkJS from + // default: the root project of the sbt build + projectID: 'client', + + // URI prefix of imports that this plugin catches (without the trailing ':') + // default: 'scalajs' (so the plugin recognizes URIs starting with 'scalajs:') + uriPrefix: 'scalajs', + })], +}); \ No newline at end of file diff --git a/project/plugins.sbt b/project/plugins.sbt index b7ab820c..3d464345 100755 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -2,12 +2,10 @@ addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.19.0") -addSbtPlugin("com.earldouglas" % "xsbt-web-plugin" % "4.0.2") addSbtPlugin("io.github.cquiroz" % "sbt-locales" % "4.2.0") addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.9.0") -addSbtPlugin("io.spray" % "sbt-revolver" % "0.9.1") addSbtPlugin("com.typesafe.play" % "sbt-twirl" % "1.5.2") addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.0.0") @@ -16,7 +14,7 @@ addSbtPlugin("com.github.sbt" % "sbt-pgp" % "2.1.2") addSbtPlugin("com.typesafe.sbt" % "sbt-git" % "1.0.0") addSbtPlugin("org.scalablytyped.converter" % "sbt-converter" % "1.0.0-beta44") -addSbtPlugin("ch.epfl.scala" % "sbt-web-scalajs-bundler" % "0.21.0") + addDependencyTreePlugin diff --git a/server/src/main/scala/ch/wsl/box/rest/metadata/InMemoryMetadataFactory.scala b/server/src/main/scala/ch/wsl/box/rest/metadata/InMemoryMetadataFactory.scala new file mode 100644 index 00000000..54fa1f4c --- /dev/null +++ b/server/src/main/scala/ch/wsl/box/rest/metadata/InMemoryMetadataFactory.scala @@ -0,0 +1,52 @@ +//package ch.wsl.box.rest.metadata +// +//import ch.wsl.box.model.shared.{CurrentUser, JSONField, JSONMetadata} +//import ch.wsl.box.services.Services +//import slick.dbio.DBIO +// +//import java.util.UUID +//import scala.collection.mutable.HashMap +//import scala.concurrent.ExecutionContext +// +//class InMemoryMetadataFactory(mf:MetadataFactory) { +// +// private val metadataByName:HashMap[(String,String),JSONMetadata] = HashMap() +// private val metadataByUUID:HashMap[(UUID,String),JSONMetadata] = HashMap() +// +// def load()(implicit ec: ExecutionContext, services: Services):DBIO[Boolean] = ??? +// +// private def filterRoles(user:CurrentUser)(metadata:DBIO[JSONMetadata])(implicit ec:ExecutionContext):DBIO[JSONMetadata] = metadata.map{ m => +// +// def filterRole(field:JSONField):Boolean = field.roles.isEmpty || field.roles.intersect(user.roles ++ Seq(user.username)).nonEmpty +// +// m.copy(fields = m.fields.filter(filterRole)) +// } +// +// def of(name: String, lang: String, user: CurrentUser)(implicit ec: ExecutionContext): DBIO[JSONMetadata] = filterRoles(user){ +// metadataByName.get((name, lang)) match { +// case Some(value) => DBIO.successful(value) +// case None => DBIO.failed(new Exception("Metadata not found")) +// } +// } +// +// def of(id: UUID, lang: String, user: CurrentUser)(implicit ec: ExecutionContext, services: Services): DBIO[JSONMetadata] = filterRoles(user){ +// metadataByUUID.get((id, lang)) match { +// case Some(value) => DBIO.successful(value) +// case None => DBIO.failed(new Exception("Metadata not found")) +// } +// } +// +// def children(form:JSONMetadata, user: CurrentUser,ignoreChilds:Seq[UUID] = Seq())(implicit ec:ExecutionContext,services:Services):DBIO[Seq[JSONMetadata]] = { +// val childIds: Seq[UUID] = form.fields.flatMap(_.child).map(_.objId) +// +// for{ +// firstLevel <- DBIO.sequence{childIds.map{ objId => of(objId,form.lang,user)}} +// secondLevel <- DBIO.sequence(firstLevel.map(y => children(y,user,ignoreChilds ++ childIds))) +// } yield { +// firstLevel ++ secondLevel.flatten +// } +// +// } +// +// def list(implicit ec: ExecutionContext, services: Services): DBIO[Seq[String]] = ??? +//} diff --git a/server/src/main/scala/ch/wsl/box/rest/routes/UI.scala b/server/src/main/scala/ch/wsl/box/rest/routes/UI.scala index 1ab97485..05e62263 100755 --- a/server/src/main/scala/ch/wsl/box/rest/routes/UI.scala +++ b/server/src/main/scala/ch/wsl/box/rest/routes/UI.scala @@ -56,8 +56,8 @@ object UI { |console.log('Worker started'); | |try { - | const { PGlite } = await import('./assets/@electric-sql/pglite/dist/index.js') - | const { worker } = await import('./assets/@electric-sql/pglite/dist/worker/index.js') + | const { PGlite } = await import('./@electric-sql/pglite/dist/index.js') + | const { worker } = await import('./@electric-sql/pglite/dist/worker/index.js') | worker({ | async init() { | // Create and return a PGlite instance @@ -89,7 +89,12 @@ object UI { pathPrefix("dev") { getFromBrowseableDirectories("./client/target/scala-2.13/scalajs-bundler/main") } ~ - postgresWorker ~ + pathPrefix("db") { + postgresWorker ~ + postgresWasm ~ + postgresData ~ + WebJarsSupport.webJars + } ~ pathPrefix("icon") { pathPrefix("icon.png") { get{ @@ -153,8 +158,7 @@ object UI { } } ~ pathPrefix("assets") { - postgresWasm ~ - postgresData ~ + WebJarsSupport.webJars } ~ pathPrefix("bundle") { diff --git a/stats.json b/stats.json new file mode 100644 index 00000000..f1f7d3b0 --- /dev/null +++ b/stats.json @@ -0,0 +1 @@ +{"hash":"e9b959a72cbce9303134","version":"5.89.0","time":193,"builtAt":1756460502186,"publicPath":"auto","outputPath":"/home/minettiandrea/Dev/Box/box/dist","assetsByChunkName":{"main":["main.js"]},"assets":[{"type":"asset","name":"main.js","size":0,"emitted":false,"comparedForEmit":false,"cached":true,"info":{"javascriptModule":false,"minimized":true},"chunkNames":["main"],"chunkIdHints":[],"auxiliaryChunkNames":[],"auxiliaryChunkIdHints":[],"related":{},"chunks":[179],"auxiliaryChunks":[],"isOverSizeLimit":false}],"chunks":[{"rendered":true,"initial":true,"entry":true,"recorded":false,"size":0,"sizes":{},"names":["main"],"idHints":[],"runtime":["main"],"files":["main.js"],"auxiliaryFiles":[],"hash":"9a09e9618fcdd19494c3","childrenByOrder":{},"id":179,"siblings":[],"parents":[],"children":[],"modules":[],"origins":[{"module":"","moduleIdentifier":"","moduleName":"","loc":"main","request":"./src"}]}],"modules":[],"entrypoints":{"main":{"name":"main","chunks":[179],"assets":[{"name":"main.js"}],"filteredAssets":0,"assetsSize":null,"auxiliaryAssets":[],"filteredAuxiliaryAssets":0,"auxiliaryAssetsSize":0,"children":{},"childAssets":{},"isOverSizeLimit":false}},"namedChunkGroups":{"main":{"name":"main","chunks":[179],"assets":[{"name":"main.js"}],"filteredAssets":0,"assetsSize":null,"auxiliaryAssets":[],"filteredAuxiliaryAssets":0,"auxiliaryAssetsSize":0,"children":{},"childAssets":{},"isOverSizeLimit":false}},"errors":[{"loc":"main","message":"Module not found: Error: Can't resolve './src' in '/home/minettiandrea/Dev/Box/box'","details":"resolve './src' in '/home/minettiandrea/Dev/Box/box'\n using description file: /home/minettiandrea/Dev/Box/box/package.json (relative path: .)\n Field 'browser' doesn't contain a valid alias configuration\n using description file: /home/minettiandrea/Dev/Box/box/package.json (relative path: ./src)\n no extension\n Field 'browser' doesn't contain a valid alias configuration\n /home/minettiandrea/Dev/Box/box/src doesn't exist\n .js\n Field 'browser' doesn't contain a valid alias configuration\n /home/minettiandrea/Dev/Box/box/src.js doesn't exist\n .json\n Field 'browser' doesn't contain a valid alias configuration\n /home/minettiandrea/Dev/Box/box/src.json doesn't exist\n .wasm\n Field 'browser' doesn't contain a valid alias configuration\n /home/minettiandrea/Dev/Box/box/src.wasm doesn't exist\n as directory\n /home/minettiandrea/Dev/Box/box/src doesn't exist","stack":"ModuleNotFoundError: Module not found: Error: Can't resolve './src' in '/home/minettiandrea/Dev/Box/box'\n at /home/minettiandrea/Dev/Box/box/node_modules/webpack/lib/Compilation.js:2022:28\n at /home/minettiandrea/Dev/Box/box/node_modules/webpack/lib/NormalModuleFactory.js:817:13\n at eval (eval at create (/home/minettiandrea/Dev/Box/box/node_modules/tapable/lib/HookCodeFactory.js:33:10), :10:1)\n at /home/minettiandrea/Dev/Box/box/node_modules/webpack/lib/NormalModuleFactory.js:275:22\n at eval (eval at create (/home/minettiandrea/Dev/Box/box/node_modules/tapable/lib/HookCodeFactory.js:33:10), :9:1)\n at /home/minettiandrea/Dev/Box/box/node_modules/webpack/lib/NormalModuleFactory.js:448:22\n at /home/minettiandrea/Dev/Box/box/node_modules/webpack/lib/NormalModuleFactory.js:118:11\n at /home/minettiandrea/Dev/Box/box/node_modules/webpack/lib/NormalModuleFactory.js:689:25\n at /home/minettiandrea/Dev/Box/box/node_modules/webpack/lib/NormalModuleFactory.js:893:8\n at /home/minettiandrea/Dev/Box/box/node_modules/webpack/lib/NormalModuleFactory.js:1013:5"}],"errorsCount":1,"warnings":[{"message":"configuration\nThe 'mode' option has not been set, webpack will fallback to 'production' for this value.\nSet 'mode' option to 'development' or 'production' to enable defaults for each environment.\nYou can also set it to 'none' to disable any default behavior. Learn more: https://webpack.js.org/configuration/mode/","stack":"NoModeWarning: configuration\nThe 'mode' option has not been set, webpack will fallback to 'production' for this value.\nSet 'mode' option to 'development' or 'production' to enable defaults for each environment.\nYou can also set it to 'none' to disable any default behavior. Learn more: https://webpack.js.org/configuration/mode/\n at /home/minettiandrea/Dev/Box/box/node_modules/webpack/lib/WarnNoModeSetPlugin.js:20:30\n at Hook.eval [as call] (eval at create (/home/minettiandrea/Dev/Box/box/node_modules/tapable/lib/HookCodeFactory.js:19:10), :21:1)\n at Hook.CALL_DELEGATE [as _call] (/home/minettiandrea/Dev/Box/box/node_modules/tapable/lib/Hook.js:14:14)\n at Compiler.newCompilation (/home/minettiandrea/Dev/Box/box/node_modules/webpack/lib/Compiler.js:1125:30)\n at /home/minettiandrea/Dev/Box/box/node_modules/webpack/lib/Compiler.js:1170:29\n at Hook.eval [as callAsync] (eval at create (/home/minettiandrea/Dev/Box/box/node_modules/tapable/lib/HookCodeFactory.js:33:10), :6:1)\n at Hook.CALL_ASYNC_DELEGATE [as _callAsync] (/home/minettiandrea/Dev/Box/box/node_modules/tapable/lib/Hook.js:18:14)\n at Compiler.compile (/home/minettiandrea/Dev/Box/box/node_modules/webpack/lib/Compiler.js:1165:28)\n at /home/minettiandrea/Dev/Box/box/node_modules/webpack/lib/Compiler.js:524:12\n at Compiler.readRecords (/home/minettiandrea/Dev/Box/box/node_modules/webpack/lib/Compiler.js:989:5)"}],"warningsCount":1,"children":[]} \ No newline at end of file From 903c9292ffeb85898487e5f62475cdce8c2d2c5a Mon Sep 17 00:00:00 2001 From: Andrea Minetti Date: Mon, 13 Oct 2025 13:55:29 +0200 Subject: [PATCH 02/42] Vite draft --- build.sbt | 139 +++++++----------- client/index.html | 16 ++ client/javascript.svg | 1 + client/main.js | 3 + client/package.json | 59 ++++++++ client/postgres.worker.js | 16 ++ client/public/vite.svg | 1 + .../main/scala/ch/wsl/box/client/Main.scala | 4 +- client/style.scss | 1 + client/vite.config.js | 35 +++++ project/plugins.sbt | 4 +- .../metadata/InMemoryMetadataFactory.scala | 52 +++++++ .../scala/ch/wsl/box/rest/routes/UI.scala | 14 +- stats.json | 1 + 14 files changed, 247 insertions(+), 99 deletions(-) create mode 100644 client/index.html create mode 100644 client/javascript.svg create mode 100644 client/main.js create mode 100644 client/package.json create mode 100644 client/postgres.worker.js create mode 100644 client/public/vite.svg create mode 100644 client/style.scss create mode 100644 client/vite.config.js create mode 100644 server/src/main/scala/ch/wsl/box/rest/metadata/InMemoryMetadataFactory.scala create mode 100644 stats.json diff --git a/build.sbt b/build.sbt index da2999c3..f35710d4 100755 --- a/build.sbt +++ b/build.sbt @@ -2,7 +2,7 @@ import com.jsuereth.sbtpgp.PgpKeys.publishSigned //import xerial.sbt.Sonatype.sonatypeCentralHost import locales.LocalesFilter import org.scalajs.jsenv.Input.Script -import scalajsbundler.util.JSON +import org.scalajs.linker.interface.ModuleSplitStyle val publishSettings = List( Global / scalaJSStage := FullOptStage, @@ -81,24 +81,6 @@ lazy val server: Project = project buildInfoKeys := Seq[BuildInfoKey](version), buildInfoPackage := "boxInfo", buildInfoObject := "BoxBuildInfo", - Compile / compile := ((Compile / compile) dependsOn scalaJSPipeline).value, - Runtime / managedClasspath += (Assets / packageBin).value, - Assets / WebKeys.packagePrefix := "public/", - //Comment this to avoid errors in importing project, i.e. when changing libraries - Assets / pipelineStages := Seq(scalaJSPipeline), - Assets / scalaJSStage := FullOptStage, - scalaJSProjects := { - if (sys.env.contains("DEV_SERVER") || sys.env.contains("RUNNING_TEST")) Seq() else Seq(client) - }, -// scalaJSProjects := Seq(client), - webpackBundlingMode := BundlingMode.Application, - Seq("jquery","ol","bootstrap","flatpickr","quill","@fontsource/open-sans","@fortawesome/fontawesome-free","choices.js","gridstack","jspreadsheet-ce","jsuites","toolcool-range-slider","@electric-sql/pglite","@electric-sql/pglite-repl").map{ p => - if (!sys.env.contains("RUNNING_TEST")) - npmAssets ++= NpmAssets.ofProject(client) { nodeModules => - (nodeModules / p).allPaths - }.value - else npmAssets := Seq() - }, Test / testOptions ++= Seq( Tests.Argument(TestFrameworks.ScalaTest, "-u", "target/test-reports"), Tests.Argument(TestFrameworks.ScalaTest, "-h", "target/test-reports"), @@ -109,7 +91,6 @@ lazy val server: Project = project .enablePlugins( GitVersioning, BuildInfoPlugin, - WebScalaJSBundlerPlugin, SbtTwirl ) .dependsOn(sharedJVM) @@ -134,54 +115,56 @@ lazy val client: Project = (project in file("client")) scalacOptions ++= Settings.scalacOptions, resolvers += Resolver.jcenterRepo, libraryDependencies ++= Settings.scalajsDependencies.value, - // yes, we want to package JS dependencies - packageJSDependencies / skip := false, + // use Scala.js provided launcher code to start the client app scalaJSUseMainModuleInitializer := true, scalaJSStage := FullOptStage, - Compile / npmDependencies ++= Seq( - "ol" -> "8.1.0", - "proj4" -> "2.9.1", - "@types/proj4" -> "2.5.3", - "ol-ext" -> "4.0.11", - //"@siedlerchr/types-ol-ext" -> "3.2.4", - "jsts" -> "2.7.1", - "@types/jsts" -> "0.17.13", - "jquery" -> "3.4.1", - "@types/jquery" -> "3.5.6", - "popper.js" -> "1.16.1", - "bootstrap" -> "4.1.3", - "@types/bootstrap" -> "4.1.3", - "@fortawesome/fontawesome-free" -> "5.15.4", - "flatpickr" -> "4.6.3", - "monaco-editor" -> "0.34.0", - "quill" -> "1.3.7", - "@types/quill" -> "1.3.10", - "@fontsource/open-sans" -> "5.0.15", - "file-saver" -> "2.0.5", - "@types/file-saver" -> "2.0.1", - "js-md5" -> "0.7.3", - "@types/js-md5" -> "0.4.2", - "striptags" -> "3.2.0", - "toolcool-range-slider" -> "4.0.28", - "hotkeys-js" -> "3.10.0", - "crypto-browserify" -> "3.12.0", - "buffer" -> "6.0.3", - "stream-browserify" -> "3.0.0", - "choices.js" -> "11.1.0", - "autocompleter" -> "7.0.1", - "xlsx-js-style" -> "1.2.0", - "jspdf" -> "2.5.1", - "jspdf-autotable" -> "3.5.28", - "gridstack" -> "12.2.2", - "jspreadsheet-ce" -> "git://github.com/jspreadsheet/ce.git#2e7389f8f6a84d260603bbac06f00bb404e1ba49", //v5.0.0 - "jsuites" -> "5.9.1", - "@electric-sql/pglite" -> "0.2.17", - "@electric-sql/pglite-repl" -> "0.2.17", - "compressorjs" -> "1.2.1", - "shapefile" -> "0.6.6", - "@types/shapefile" -> "0.6.4", - ), + externalNpm := baseDirectory.value, //.map(f => new File(f.getPath + "/client")).value, +// Compile / npmDependencies ++= Seq( +// "ol" -> "8.1.0", +// "proj4" -> "2.9.1", +// "@types/proj4" -> "2.5.3", +// "ol-ext" -> "4.0.11", +// //"@siedlerchr/types-ol-ext" -> "3.2.4", +// "jsts" -> "2.7.1", +// "@types/jsts" -> "0.17.13", +// "jquery" -> "3.4.1", +// "@types/jquery" -> "3.5.6", +// "popper.js" -> "1.16.1", +// "bootstrap" -> "4.1.3", +// "@types/bootstrap" -> "4.1.3", +// "@fortawesome/fontawesome-free" -> "5.15.4", +// "flatpickr" -> "4.6.3", +// "monaco-editor" -> "0.34.0", +// "quill" -> "1.3.7", +// "@types/quill" -> "1.3.10", +// "@fontsource/open-sans" -> "5.0.15", +// "file-saver" -> "2.0.5", +// "@types/file-saver" -> "2.0.1", +// "js-md5" -> "0.7.3", +// "@types/js-md5" -> "0.4.2", +// "striptags" -> "3.2.0", +// "toolcool-range-slider" -> "4.0.28", +// "hotkeys-js" -> "3.10.0", +// "crypto-browserify" -> "3.12.0", +// "buffer" -> "6.0.3", +// "stream-browserify" -> "3.0.0", +// "choices.js" -> "11.1.0", +// "autocompleter" -> "7.0.1", +// "xlsx-js-style" -> "1.2.0", +// "jspdf" -> "2.5.1", +// "jspdf-autotable" -> "3.5.28", +// "gridstack" -> "12.2.2", +// "jspreadsheet-ce" -> "git://github.com/jspreadsheet/ce.git#2e7389f8f6a84d260603bbac06f00bb404e1ba49", //v5.0.0 +// "jsuites" -> "5.9.1", +// "@electric-sql/pglite" -> "0.2.17", +// "@electric-sql/pglite-repl" -> "0.2.17", +// "compressorjs" -> "1.2.1", +// "shapefile" -> "0.6.6", +// "@types/shapefile" -> "0.6.4", +// ), + stIncludeDev := false, + stStdlib := List("es6", "es2018.asyncgenerator"), stIgnore += "@fontsource/open-sans", stIgnore += "redux", stIgnore += "node", @@ -190,29 +173,9 @@ lazy val client: Project = (project in file("client")) stIgnore += "@fortawesome/fontawesome-free", stIgnore += "stream-browserify", stIgnore += "toolcool-range-slider", - stTypescriptVersion := "4.2.4", stOutputPackage := "ch.wsl.typings", - // Use library mode for fastOptJS - //Compile / additionalNpmConfig := Map("sideEffects" -> JSON.bool(false)), - fastOptJS / webpackBundlingMode := BundlingMode.Application, - fastOptJS / webpackConfigFile := Some(baseDirectory.value / ".." / "dev.config.js"), - // Use application model mode for fullOptJS - fullOptJS / webpackBundlingMode := BundlingMode.Application, - fullOptJS / webpackConfigFile := Some(baseDirectory.value / ".." / "prod.config.js"), - Test / webpackConfigFile := Some(baseDirectory.value / ".." / "test.config.js"), - Compile / npmDevDependencies ++= Seq( - "html-webpack-plugin" -> "5.5.0", - "webpack-merge" -> "5.8.0", - "style-loader" -> "3.3.1", - "css-loader" -> "6.7.1", - "mini-css-extract-plugin" -> "2.6.1", - "monaco-editor-webpack-plugin" -> "7.0.1", - "file-loader" -> "6.2.0", - ), + scalaJSLinkerConfig ~= (_.withModuleKind(ModuleKind.ESModule).withModuleSplitStyle(ModuleSplitStyle.SmallModulesFor(List("ch.wsl")))), - webpack / version := "5.89.0", - webpackCliVersion := "5.1.4", - installJsdom / version := "20.0.0", //To use jsdom headless browser uncomment the following lines Test / requireJsDomEnv := true, @@ -249,7 +212,7 @@ lazy val client: Project = (project in file("client")) .settings(publishSettings) .enablePlugins( ScalaJSPlugin, - ScalablyTypedConverterGenSourcePlugin, + ScalablyTypedConverterExternalNpmPlugin, LocalesPlugin ) .dependsOn(sharedJS) @@ -368,7 +331,7 @@ lazy val publishAllTask = { lazy val publishAllLocal = taskKey[Unit]("Publish all modules") lazy val publishAllLocalTask = { Def.sequential( - (client / Compile / fullOptJS / webpack), + (client / Compile / fullOptJS), (codegen / Compile / compile), (sharedJVM / publishLocal), (codegen / publishLocal), diff --git a/client/index.html b/client/index.html new file mode 100644 index 00000000..31f3bda2 --- /dev/null +++ b/client/index.html @@ -0,0 +1,16 @@ + + + + + + + Vite App + + +
+ + + + diff --git a/client/javascript.svg b/client/javascript.svg new file mode 100644 index 00000000..f9abb2b7 --- /dev/null +++ b/client/javascript.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/main.js b/client/main.js new file mode 100644 index 00000000..3e02138e --- /dev/null +++ b/client/main.js @@ -0,0 +1,3 @@ +import 'scalajs:main.js' +import './style.scss' +import * as bootstrap from 'bootstrap' \ No newline at end of file diff --git a/client/package.json b/client/package.json new file mode 100644 index 00000000..2569c7c5 --- /dev/null +++ b/client/package.json @@ -0,0 +1,59 @@ +{ + "name": "box-client", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@electric-sql/pglite": "0.2.17", + "@electric-sql/pglite-repl": "0.2.17", + "@fontsource/open-sans": "5.0.15", + "@fortawesome/fontawesome-free": "5.15.4", + "@types/bootstrap": "4.1.3", + "@types/file-saver": "2.0.1", + "@types/jquery": "3.5.6", + "@types/js-md5": "0.4.2", + "@types/jsts": "0.17.13", + "@types/proj4": "2.5.3", + "@types/quill": "1.3.10", + "@types/shapefile": "0.6.4", + "autocompleter": "7.0.1", + "bootstrap": "4.1.3", + "buffer": "6.0.3", + "choices.js": "11.1.0", + "compressorjs": "1.2.1", + "crypto-browserify": "3.12.0", + "file-saver": "2.0.5", + "flatpickr": "4.6.3", + "gridstack": "12.2.2", + "hotkeys-js": "3.10.0", + "jquery": "3.4.1", + "js-md5": "0.7.3", + "jspdf": "2.5.1", + "jspdf-autotable": "3.5.28", + "jspreadsheet-ce": "git://github.com/jspreadsheet/ce.git#2e7389f8f6a84d260603bbac06f00bb404e1ba49", + "jsts": "2.7.1", + "jsuites": "5.9.1", + "monaco-editor": "0.34.0", + "ol": "8.1.0", + "ol-ext": "4.0.11", + "popper.js": "1.16.1", + "proj4": "2.9.1", + "quill": "1.3.7", + "shapefile": "0.6.6", + "stream-browserify": "3.0.0", + "striptags": "3.2.0", + "toolcool-range-slider": "4.0.28", + "xlsx-js-style": "1.2.0" + }, + "devDependencies": { + "@scala-js/vite-plugin-scalajs": "^1.1.0", + "sass-embedded": "^1.93.2", + "typescript": "^4.1.6", + "vite": "^7.1.9" + } +} diff --git a/client/postgres.worker.js b/client/postgres.worker.js new file mode 100644 index 00000000..61ffcf72 --- /dev/null +++ b/client/postgres.worker.js @@ -0,0 +1,16 @@ +console.log('Worker started'); +try { + import { PGlite } from '@electric-sql/pglite' + import { worker } from '@electric-sql/pglite/worker' + worker({ + async init() { + // Create and return a PGlite instance + return new PGlite('idb://box-pgdata') + }, + }) +} catch (error) { + console.error('Error starting worker:', error); +} +self.onmessage = function(event) { + console.log('Message received in worker:', event.data); +}; \ No newline at end of file diff --git a/client/public/vite.svg b/client/public/vite.svg new file mode 100644 index 00000000..e7b8dfb1 --- /dev/null +++ b/client/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/main/scala/ch/wsl/box/client/Main.scala b/client/src/main/scala/ch/wsl/box/client/Main.scala index 75225b62..a54bec59 100644 --- a/client/src/main/scala/ch/wsl/box/client/Main.scala +++ b/client/src/main/scala/ch/wsl/box/client/Main.scala @@ -70,7 +70,6 @@ object Main extends Logging { println(s"Setting logger level to ${ClientConf.loggerLevel}") //loads datetime picker - ch.wsl.typings.bootstrap.bootstrapRequire //ch.wsl.typings.toolcoolRangeSlider.toolcoolRangeSliderRequire @@ -130,10 +129,9 @@ object Main extends Logging { document.body.appendChild(autocompleteStyle) document.body.appendChild(stepperStyle) - val app = document.createElement("div") + val app = document.getElementById("app") BrowserConsole.log(app) - document.body.appendChild(app) BrowserConsole.log(document.body) applicationInstance.run(app) diff --git a/client/style.scss b/client/style.scss new file mode 100644 index 00000000..93c4b1c7 --- /dev/null +++ b/client/style.scss @@ -0,0 +1 @@ +@import "~bootstrap/scss/bootstrap"; \ No newline at end of file diff --git a/client/vite.config.js b/client/vite.config.js new file mode 100644 index 00000000..48a8dc5a --- /dev/null +++ b/client/vite.config.js @@ -0,0 +1,35 @@ +import { defineConfig } from "vite"; +import scalaJSPlugin from "@scala-js/vite-plugin-scalajs"; + + +export default defineConfig({ + server: { + proxy: { + '/api': 'http://localhost:8080', + }, + }, + optimizeDeps: { + exclude: ['@electric-sql/pglite'], + }, + worker: { + format: 'es', + }, + resolve: { + alias: { + '~bootstrap': './node_modules/bootstrap', + } + }, + plugins: [scalaJSPlugin({ + // path to the directory containing the sbt build + // default: '.' + cwd: '..', + + // sbt project ID from within the sbt build to get fast/fullLinkJS from + // default: the root project of the sbt build + projectID: 'client', + + // URI prefix of imports that this plugin catches (without the trailing ':') + // default: 'scalajs' (so the plugin recognizes URIs starting with 'scalajs:') + uriPrefix: 'scalajs', + })], +}); \ No newline at end of file diff --git a/project/plugins.sbt b/project/plugins.sbt index b7ab820c..3d464345 100755 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -2,12 +2,10 @@ addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.19.0") -addSbtPlugin("com.earldouglas" % "xsbt-web-plugin" % "4.0.2") addSbtPlugin("io.github.cquiroz" % "sbt-locales" % "4.2.0") addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.9.0") -addSbtPlugin("io.spray" % "sbt-revolver" % "0.9.1") addSbtPlugin("com.typesafe.play" % "sbt-twirl" % "1.5.2") addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.0.0") @@ -16,7 +14,7 @@ addSbtPlugin("com.github.sbt" % "sbt-pgp" % "2.1.2") addSbtPlugin("com.typesafe.sbt" % "sbt-git" % "1.0.0") addSbtPlugin("org.scalablytyped.converter" % "sbt-converter" % "1.0.0-beta44") -addSbtPlugin("ch.epfl.scala" % "sbt-web-scalajs-bundler" % "0.21.0") + addDependencyTreePlugin diff --git a/server/src/main/scala/ch/wsl/box/rest/metadata/InMemoryMetadataFactory.scala b/server/src/main/scala/ch/wsl/box/rest/metadata/InMemoryMetadataFactory.scala new file mode 100644 index 00000000..54fa1f4c --- /dev/null +++ b/server/src/main/scala/ch/wsl/box/rest/metadata/InMemoryMetadataFactory.scala @@ -0,0 +1,52 @@ +//package ch.wsl.box.rest.metadata +// +//import ch.wsl.box.model.shared.{CurrentUser, JSONField, JSONMetadata} +//import ch.wsl.box.services.Services +//import slick.dbio.DBIO +// +//import java.util.UUID +//import scala.collection.mutable.HashMap +//import scala.concurrent.ExecutionContext +// +//class InMemoryMetadataFactory(mf:MetadataFactory) { +// +// private val metadataByName:HashMap[(String,String),JSONMetadata] = HashMap() +// private val metadataByUUID:HashMap[(UUID,String),JSONMetadata] = HashMap() +// +// def load()(implicit ec: ExecutionContext, services: Services):DBIO[Boolean] = ??? +// +// private def filterRoles(user:CurrentUser)(metadata:DBIO[JSONMetadata])(implicit ec:ExecutionContext):DBIO[JSONMetadata] = metadata.map{ m => +// +// def filterRole(field:JSONField):Boolean = field.roles.isEmpty || field.roles.intersect(user.roles ++ Seq(user.username)).nonEmpty +// +// m.copy(fields = m.fields.filter(filterRole)) +// } +// +// def of(name: String, lang: String, user: CurrentUser)(implicit ec: ExecutionContext): DBIO[JSONMetadata] = filterRoles(user){ +// metadataByName.get((name, lang)) match { +// case Some(value) => DBIO.successful(value) +// case None => DBIO.failed(new Exception("Metadata not found")) +// } +// } +// +// def of(id: UUID, lang: String, user: CurrentUser)(implicit ec: ExecutionContext, services: Services): DBIO[JSONMetadata] = filterRoles(user){ +// metadataByUUID.get((id, lang)) match { +// case Some(value) => DBIO.successful(value) +// case None => DBIO.failed(new Exception("Metadata not found")) +// } +// } +// +// def children(form:JSONMetadata, user: CurrentUser,ignoreChilds:Seq[UUID] = Seq())(implicit ec:ExecutionContext,services:Services):DBIO[Seq[JSONMetadata]] = { +// val childIds: Seq[UUID] = form.fields.flatMap(_.child).map(_.objId) +// +// for{ +// firstLevel <- DBIO.sequence{childIds.map{ objId => of(objId,form.lang,user)}} +// secondLevel <- DBIO.sequence(firstLevel.map(y => children(y,user,ignoreChilds ++ childIds))) +// } yield { +// firstLevel ++ secondLevel.flatten +// } +// +// } +// +// def list(implicit ec: ExecutionContext, services: Services): DBIO[Seq[String]] = ??? +//} diff --git a/server/src/main/scala/ch/wsl/box/rest/routes/UI.scala b/server/src/main/scala/ch/wsl/box/rest/routes/UI.scala index 1ab97485..05e62263 100755 --- a/server/src/main/scala/ch/wsl/box/rest/routes/UI.scala +++ b/server/src/main/scala/ch/wsl/box/rest/routes/UI.scala @@ -56,8 +56,8 @@ object UI { |console.log('Worker started'); | |try { - | const { PGlite } = await import('./assets/@electric-sql/pglite/dist/index.js') - | const { worker } = await import('./assets/@electric-sql/pglite/dist/worker/index.js') + | const { PGlite } = await import('./@electric-sql/pglite/dist/index.js') + | const { worker } = await import('./@electric-sql/pglite/dist/worker/index.js') | worker({ | async init() { | // Create and return a PGlite instance @@ -89,7 +89,12 @@ object UI { pathPrefix("dev") { getFromBrowseableDirectories("./client/target/scala-2.13/scalajs-bundler/main") } ~ - postgresWorker ~ + pathPrefix("db") { + postgresWorker ~ + postgresWasm ~ + postgresData ~ + WebJarsSupport.webJars + } ~ pathPrefix("icon") { pathPrefix("icon.png") { get{ @@ -153,8 +158,7 @@ object UI { } } ~ pathPrefix("assets") { - postgresWasm ~ - postgresData ~ + WebJarsSupport.webJars } ~ pathPrefix("bundle") { diff --git a/stats.json b/stats.json new file mode 100644 index 00000000..f1f7d3b0 --- /dev/null +++ b/stats.json @@ -0,0 +1 @@ +{"hash":"e9b959a72cbce9303134","version":"5.89.0","time":193,"builtAt":1756460502186,"publicPath":"auto","outputPath":"/home/minettiandrea/Dev/Box/box/dist","assetsByChunkName":{"main":["main.js"]},"assets":[{"type":"asset","name":"main.js","size":0,"emitted":false,"comparedForEmit":false,"cached":true,"info":{"javascriptModule":false,"minimized":true},"chunkNames":["main"],"chunkIdHints":[],"auxiliaryChunkNames":[],"auxiliaryChunkIdHints":[],"related":{},"chunks":[179],"auxiliaryChunks":[],"isOverSizeLimit":false}],"chunks":[{"rendered":true,"initial":true,"entry":true,"recorded":false,"size":0,"sizes":{},"names":["main"],"idHints":[],"runtime":["main"],"files":["main.js"],"auxiliaryFiles":[],"hash":"9a09e9618fcdd19494c3","childrenByOrder":{},"id":179,"siblings":[],"parents":[],"children":[],"modules":[],"origins":[{"module":"","moduleIdentifier":"","moduleName":"","loc":"main","request":"./src"}]}],"modules":[],"entrypoints":{"main":{"name":"main","chunks":[179],"assets":[{"name":"main.js"}],"filteredAssets":0,"assetsSize":null,"auxiliaryAssets":[],"filteredAuxiliaryAssets":0,"auxiliaryAssetsSize":0,"children":{},"childAssets":{},"isOverSizeLimit":false}},"namedChunkGroups":{"main":{"name":"main","chunks":[179],"assets":[{"name":"main.js"}],"filteredAssets":0,"assetsSize":null,"auxiliaryAssets":[],"filteredAuxiliaryAssets":0,"auxiliaryAssetsSize":0,"children":{},"childAssets":{},"isOverSizeLimit":false}},"errors":[{"loc":"main","message":"Module not found: Error: Can't resolve './src' in '/home/minettiandrea/Dev/Box/box'","details":"resolve './src' in '/home/minettiandrea/Dev/Box/box'\n using description file: /home/minettiandrea/Dev/Box/box/package.json (relative path: .)\n Field 'browser' doesn't contain a valid alias configuration\n using description file: /home/minettiandrea/Dev/Box/box/package.json (relative path: ./src)\n no extension\n Field 'browser' doesn't contain a valid alias configuration\n /home/minettiandrea/Dev/Box/box/src doesn't exist\n .js\n Field 'browser' doesn't contain a valid alias configuration\n /home/minettiandrea/Dev/Box/box/src.js doesn't exist\n .json\n Field 'browser' doesn't contain a valid alias configuration\n /home/minettiandrea/Dev/Box/box/src.json doesn't exist\n .wasm\n Field 'browser' doesn't contain a valid alias configuration\n /home/minettiandrea/Dev/Box/box/src.wasm doesn't exist\n as directory\n /home/minettiandrea/Dev/Box/box/src doesn't exist","stack":"ModuleNotFoundError: Module not found: Error: Can't resolve './src' in '/home/minettiandrea/Dev/Box/box'\n at /home/minettiandrea/Dev/Box/box/node_modules/webpack/lib/Compilation.js:2022:28\n at /home/minettiandrea/Dev/Box/box/node_modules/webpack/lib/NormalModuleFactory.js:817:13\n at eval (eval at create (/home/minettiandrea/Dev/Box/box/node_modules/tapable/lib/HookCodeFactory.js:33:10), :10:1)\n at /home/minettiandrea/Dev/Box/box/node_modules/webpack/lib/NormalModuleFactory.js:275:22\n at eval (eval at create (/home/minettiandrea/Dev/Box/box/node_modules/tapable/lib/HookCodeFactory.js:33:10), :9:1)\n at /home/minettiandrea/Dev/Box/box/node_modules/webpack/lib/NormalModuleFactory.js:448:22\n at /home/minettiandrea/Dev/Box/box/node_modules/webpack/lib/NormalModuleFactory.js:118:11\n at /home/minettiandrea/Dev/Box/box/node_modules/webpack/lib/NormalModuleFactory.js:689:25\n at /home/minettiandrea/Dev/Box/box/node_modules/webpack/lib/NormalModuleFactory.js:893:8\n at /home/minettiandrea/Dev/Box/box/node_modules/webpack/lib/NormalModuleFactory.js:1013:5"}],"errorsCount":1,"warnings":[{"message":"configuration\nThe 'mode' option has not been set, webpack will fallback to 'production' for this value.\nSet 'mode' option to 'development' or 'production' to enable defaults for each environment.\nYou can also set it to 'none' to disable any default behavior. Learn more: https://webpack.js.org/configuration/mode/","stack":"NoModeWarning: configuration\nThe 'mode' option has not been set, webpack will fallback to 'production' for this value.\nSet 'mode' option to 'development' or 'production' to enable defaults for each environment.\nYou can also set it to 'none' to disable any default behavior. Learn more: https://webpack.js.org/configuration/mode/\n at /home/minettiandrea/Dev/Box/box/node_modules/webpack/lib/WarnNoModeSetPlugin.js:20:30\n at Hook.eval [as call] (eval at create (/home/minettiandrea/Dev/Box/box/node_modules/tapable/lib/HookCodeFactory.js:19:10), :21:1)\n at Hook.CALL_DELEGATE [as _call] (/home/minettiandrea/Dev/Box/box/node_modules/tapable/lib/Hook.js:14:14)\n at Compiler.newCompilation (/home/minettiandrea/Dev/Box/box/node_modules/webpack/lib/Compiler.js:1125:30)\n at /home/minettiandrea/Dev/Box/box/node_modules/webpack/lib/Compiler.js:1170:29\n at Hook.eval [as callAsync] (eval at create (/home/minettiandrea/Dev/Box/box/node_modules/tapable/lib/HookCodeFactory.js:33:10), :6:1)\n at Hook.CALL_ASYNC_DELEGATE [as _callAsync] (/home/minettiandrea/Dev/Box/box/node_modules/tapable/lib/Hook.js:18:14)\n at Compiler.compile (/home/minettiandrea/Dev/Box/box/node_modules/webpack/lib/Compiler.js:1165:28)\n at /home/minettiandrea/Dev/Box/box/node_modules/webpack/lib/Compiler.js:524:12\n at Compiler.readRecords (/home/minettiandrea/Dev/Box/box/node_modules/webpack/lib/Compiler.js:989:5)"}],"warningsCount":1,"children":[]} \ No newline at end of file From 2e6ffcab7dd8be6b38fe6d1fa4f5afa0f8e9594a Mon Sep 17 00:00:00 2001 From: Andrea Minetti Date: Tue, 2 Dec 2025 13:42:41 +0100 Subject: [PATCH 03/42] Fixed date filter in table --- .../box/client/views/EntityTableView.scala | 2 +- .../widget/lookup/PopupSelectWidget.scala | 83 ++++++++++++++++--- db/box_migrations/BOX_R__v_jsonschema.sql | 58 +++++++++++++ .../scala/ch/wsl/box/model/UpdateTable.scala | 2 +- .../wsl/box/model/shared/JSONMetadata.scala | 4 + 5 files changed, 135 insertions(+), 14 deletions(-) create mode 100644 db/box_migrations/BOX_R__v_jsonschema.sql diff --git a/client/src/main/scala/ch/wsl/box/client/views/EntityTableView.scala b/client/src/main/scala/ch/wsl/box/client/views/EntityTableView.scala index 56a32b98..70e33f66 100755 --- a/client/src/main/scala/ch/wsl/box/client/views/EntityTableView.scala +++ b/client/src/main/scala/ch/wsl/box/client/views/EntityTableView.scala @@ -221,7 +221,7 @@ case class EntityTablePresenter(model:ModelProperty[EntityTableModel], onSelect: geoms = Seq(), extent = None, public = state.public, - selectedColumns = form.table + selectedColumns = form.preselectedTable ) //saveIds(IDs(true,1,Seq(),0),query) diff --git a/client/src/main/scala/ch/wsl/box/client/views/components/widget/lookup/PopupSelectWidget.scala b/client/src/main/scala/ch/wsl/box/client/views/components/widget/lookup/PopupSelectWidget.scala index ee7bcdac..923e4467 100644 --- a/client/src/main/scala/ch/wsl/box/client/views/components/widget/lookup/PopupSelectWidget.scala +++ b/client/src/main/scala/ch/wsl/box/client/views/components/widget/lookup/PopupSelectWidget.scala @@ -1,9 +1,11 @@ package ch.wsl.box.client.views.components.widget.lookup +import ch.wsl.box.client.Context.services import ch.wsl.box.client.services.{BrowserConsole, ClientConf, Labels} -import ch.wsl.box.client.styles.BootstrapCol +import ch.wsl.box.client.styles.{BootstrapCol, Icons} import ch.wsl.box.client.utils.TestHooks -import ch.wsl.box.client.views.components.widget.{ComponentWidgetFactory, Widget, WidgetParams, WidgetUtils} +import ch.wsl.box.client.views.components.JSONMetadataRenderer +import ch.wsl.box.client.views.components.widget.{ComponentWidgetFactory, Widget, WidgetCallbackActions, WidgetParams, WidgetUtils} import ch.wsl.box.model.shared._ import ch.wsl.box.shared.utils.JSONUtils.EnhancedJson import io.circe._ @@ -15,6 +17,7 @@ import io.udash.bootstrap.button.UdashButton import io.udash.bootstrap.modal.UdashModal import io.udash.bootstrap.modal.UdashModal.ModalEvent import io.udash.bootstrap.utils.BootstrapStyles.Size +import io.udash.properties.seq import io.udash.properties.single.Property import org.scalajs.dom.{Event, HTMLInputElement, document} import scalatags.JsDom @@ -23,6 +26,9 @@ import scribe.Logging import scala.concurrent.duration._ +sealed trait Mode +case object Edit extends Mode +case object Search extends Mode object PopupSelectWidget extends ComponentWidgetFactory { @@ -53,6 +59,12 @@ object PopupSelectWidget extends ComponentWidgetFactory { val Open = "open" } + import ch.wsl.box.client.Context.Implicits._ + + + val mode:Property[Mode] = Property(Search) + val lookupData = Property(Json.obj()) + def popupEdit(nested:Binding.NestedInterceptor)(mainRenderer:(UdashModal,Property[String]) => Modifier) = { val searchId = TestHooks.popupSearch(field.name,metadata.objId) @@ -64,23 +76,64 @@ object PopupSelectWidget extends ComponentWidgetFactory { def optionList(nested:NestedInterceptor):Modifier = div( label(Labels.popup.search),br, TextInput(searchProp,500.milliseconds)(width := 100.pct, id := searchId),br,br, + button(`type` := "button", Icons.plus, " ", Labels.entities.`new`,ClientConf.style.boxButtonImportant,onclick :+= ((e:Event) => { + lookupData.set(Json.obj()) + mode.set(Edit) + })),br,br, nested(showIf(modalStatus.transform(_ == Status.Open)) { div(ClientConf.style.popupEntiresList,nested(produce(searchProp) { searchTerm => div( div( nested(repeat(lookup.filter(opt => searchTerm == "" || opt.value.toLowerCase.contains(searchTerm.toLowerCase))) { x => - div(a(bind(x.transform(_.value)), onclick :+= ((e: Event) => { - modalStatus.set(Status.Closed) - model.set(Some(x.get)) - e.preventDefault() - }))).render + div( + a(Icons.pencil_square,onclick :+= ((e: Event) => { + val lookup = fieldLookup.asInstanceOf[JSONFieldLookupRemote] + val keys = Seq((lookup.map.foreign.keyColumns.head,x.get.id)) // single lookup supported + //val keys = lookup.map.localKeysColumn.zip(lookup.map.foreign.keyColumns).map{ case (local,remote) => remote -> x.get.id} + services.rest.get(EntityKind.ENTITY.kind,services.clientSession.lang(),lookup.lookupEntity,JSONID.fromMap(keys),public).map { record => + lookupData.set(record) + mode.set(Edit) + } + e.preventDefault() + })), " ", + a(bind(x.transform(_.value)), onclick :+= ((e: Event) => { + modalStatus.set(Status.Closed) + model.set(Some(x.get)) + e.preventDefault() + }) + )).render } ) ) ).render })).render } - )) + )) + + def editEntity(entity:String,nested:NestedInterceptor):Modifier = { + + + + val metadata:Property[Option[JSONMetadata]] = Property(None) + + services.rest.metadata(EntityKind.ENTITY.kind,services.clientSession.lang(),entity,public).foreach(m => metadata.set(Some(m))) + + nested(produceWithNested(metadata) { (metadata,nested) => + div(metadata.map { metadata => + val id = JSONID.fromData(lookupData.get, metadata) + val action = WidgetCallbackActions.noAction + JSONMetadataRenderer(metadata, lookupData, Seq(), Property(id.map(_.asString)), action, Property(false), public).edit(nested) + }).render + }) + } + + def editEntry(nested:NestedInterceptor):Modifier = { + fieldLookup match { + case JSONFieldLookupRemote(lookupEntity, map, lookupQuery) => editEntity(lookupEntity,nested) + case JSONFieldLookupExtractor(extractor) => ??? + case JSONFieldLookupData(data) => ??? + } + } var modal:UdashModal = null @@ -94,9 +147,12 @@ object PopupSelectWidget extends ComponentWidgetFactory { ).render val body = (x:NestedInterceptor) => div( - div( - optionList(x) - ) + x(produceWithNested(mode) { (m,nested) => + m match { + case Edit => div(editEntry(nested)).render + case Search => div(optionList(nested)).render + } + }) ).render val footer = (nested:NestedInterceptor) => div( @@ -124,7 +180,10 @@ object PopupSelectWidget extends ComponentWidgetFactory { modal.listen { case ev:ModalEvent => ev.tpe match { case ModalEvent.EventType.Hide | ModalEvent.EventType.Hidden => modalStatus.set(Status.Closed) - case ModalEvent.EventType.Shown => document.getElementById(searchId).asInstanceOf[HTMLInputElement].focus() + case ModalEvent.EventType.Shown => { + mode.set(Search) + document.getElementById(searchId).asInstanceOf[HTMLInputElement].focus() + } case _ => {} } } diff --git a/db/box_migrations/BOX_R__v_jsonschema.sql b/db/box_migrations/BOX_R__v_jsonschema.sql new file mode 100644 index 00000000..00995e79 --- /dev/null +++ b/db/box_migrations/BOX_R__v_jsonschema.sql @@ -0,0 +1,58 @@ +drop view if exists v_jsonschema; +create or replace view v_jsonschema as +WITH cols AS not materialized ( + SELECT + column_name, + data_type, + is_nullable, + character_maximum_length, + numeric_precision, + numeric_scale, + column_default, + table_schema, + table_name, + jsonb_strip_nulls(jsonb_build_object( + 'type', CASE + WHEN data_type IN ('integer','bigint','smallint') THEN '"integer"'::jsonb + WHEN data_type IN ('numeric','decimal','real','double precision') THEN '"number"'::jsonb + WHEN data_type = 'boolean' THEN '"boolean"'::jsonb + WHEN data_type LIKE '%char%' OR data_type = 'text' THEN '"string"'::jsonb + WHEN data_type = 'date' THEN '"string"'::jsonb + WHEN data_type LIKE 'timestamp%' THEN '"string"'::jsonb + WHEN data_type IN ('json','jsonb') THEN '"object"'::jsonb + ELSE '"string"'::jsonb END, + 'format', case + WHEN data_type = 'date' THEN '"date"'::jsonb + WHEN data_type LIKE 'timestamp%' THEN '"date-time"'::jsonb + else null::jsonb end, + 'nullable', (is_nullable = 'YES'), + 'maxLength', CASE + WHEN character_maximum_length IS NOT NULL THEN to_jsonb(character_maximum_length) + ELSE NULL + END, + 'precision', CASE + WHEN numeric_precision IS NOT NULL THEN to_jsonb(numeric_precision) + ELSE NULL + END, + 'scale', CASE + WHEN numeric_scale IS NOT NULL THEN to_jsonb(numeric_scale) + ELSE NULL + END, + 'default', to_jsonb(column_default) + )) as column_jsonschema + FROM information_schema.columns +) +SELECT table_schema::text,table_name::text,jsonb_build_object( + '$schema', 'https://json-schema.org/draft/2020-12/schema', + '$id', to_jsonb('urn:postgres:' || table_schema || ':' || table_name), + 'title', to_jsonb(table_name), + 'type', 'object', + 'properties', jsonb_object_agg(column_name,column_jsonschema), + 'required', (SELECT jsonb_agg(column_name) + FROM cols c + WHERE c.is_nullable = 'NO' and c.table_schema = cols.table_schema and c.table_name=cols.table_name) + )::jsonb AS json_schema +FROM cols +group by table_schema, table_name; + +grant select on v_jsonschema to box_user; diff --git a/server/src/main/scala/ch/wsl/box/model/UpdateTable.scala b/server/src/main/scala/ch/wsl/box/model/UpdateTable.scala index 9c205fc5..e65629e4 100644 --- a/server/src/main/scala/ch/wsl/box/model/UpdateTable.scala +++ b/server/src/main/scala/ch/wsl/box/model/UpdateTable.scala @@ -389,7 +389,7 @@ trait UpdateTable[T] extends BoxTable[T] with Logging { t:Table[T] => case ("org.locationtech.jts.geom.Geometry",_) => filter[Geometry](col.nullable,Geo.fromEWKT(v)) case ("java.util.UUID",_) => filter[java.util.UUID](col.nullable,Try(UUID.fromString(v)).toOption) case ("Boolean",_) => filter[Boolean](col.nullable,Some(v == "true")) - case t => throw new Exception(s"$t is not supported for simple query") + case t => throw new Exception(s"$t is not supported for simple query. On table ${table.tableName} $jsonQuery") } } diff --git a/shared/src/main/scala/ch/wsl/box/model/shared/JSONMetadata.scala b/shared/src/main/scala/ch/wsl/box/model/shared/JSONMetadata.scala index 440b358d..bc1ea7e1 100755 --- a/shared/src/main/scala/ch/wsl/box/model/shared/JSONMetadata.scala +++ b/shared/src/main/scala/ch/wsl/box/model/shared/JSONMetadata.scala @@ -51,6 +51,10 @@ case class JSONMetadata( // }.dropWhile(_ == 0).headOption.getOrElse(0) // } def table:Seq[JSONField] = tabularFields.flatMap(tf => fields.find(_.name == tf)) + def preselectedTable:Seq[JSONField] = params.flatMap(_.jsOpt("preselectedFields").flatMap(_.as[Seq[String]].toOption)) match { + case Some(preselected) => preselected.flatMap(tf => fields.find(_.name == tf)) + case None => table + } def tableLookupFields = table.filter(_.lookup.isDefined) lazy val keyFields:Seq[JSONField] = keys.flatMap(k => fields.find(_.name == k)) From 00d97de8376da146f8846d7eff58a55a18042dbc Mon Sep 17 00:00:00 2001 From: Andrea Minetti Date: Mon, 19 Jan 2026 14:32:12 +0100 Subject: [PATCH 04/42] Update vite configs --- .gitignore | 3 ++- build.sbt | 5 +++-- client/index.html | 1 + client/main.js | 2 +- client/package.json | 6 ++++-- client/src/main/scala/ch/wsl/box/client/Main.scala | 14 ++++++++------ .../scala/ch/wsl/box/client/routes/Routes.scala | 14 ++------------ .../ch/wsl/box/client/services/ClientConf.scala | 4 +++- .../ch/wsl/box/client/services/Notification.scala | 7 +++++-- .../box/client/services/impl/HttpClientImpl.scala | 2 +- .../scala/ch/wsl/box/client/views/LoginView.scala | 3 ++- .../components/widget/lookup/LookupWidget.scala | 6 +----- .../io/udash/routing/BoxUrlChangeProvider.scala | 4 ++-- client/style.scss | 4 +++- client/vite.config.js | 5 ++++- project/Settings.scala | 6 +++--- project/plugins.sbt | 2 +- 17 files changed, 46 insertions(+), 42 deletions(-) diff --git a/.gitignore b/.gitignore index 9a929979..985082ab 100755 --- a/.gitignore +++ b/.gitignore @@ -74,4 +74,5 @@ node_modules LOCAL_README.md #let sbt handle the depenencies -client/package-lock.json \ No newline at end of file +client/package-lock.json +client/dist \ No newline at end of file diff --git a/build.sbt b/build.sbt index f35710d4..95ec34f7 100755 --- a/build.sbt +++ b/build.sbt @@ -110,7 +110,7 @@ lazy val serverCacheRedis = (project in file("server-cache-redis")).settings( lazy val client: Project = (project in file("client")) .settings( - name := "client", + name := "box-client", scalaVersion := Settings.versions.scala213, scalacOptions ++= Settings.scalacOptions, resolvers += Resolver.jcenterRepo, @@ -173,8 +173,9 @@ lazy val client: Project = (project in file("client")) stIgnore += "@fortawesome/fontawesome-free", stIgnore += "stream-browserify", stIgnore += "toolcool-range-slider", + stIgnore += "@tailwindcss/vite", stOutputPackage := "ch.wsl.typings", - scalaJSLinkerConfig ~= (_.withModuleKind(ModuleKind.ESModule).withModuleSplitStyle(ModuleSplitStyle.SmallModulesFor(List("ch.wsl")))), + scalaJSLinkerConfig ~= (_.withModuleKind(ModuleKind.ESModule).withModuleSplitStyle(ModuleSplitStyle.FewestModules)), //To use jsdom headless browser uncomment the following lines diff --git a/client/index.html b/client/index.html index 31f3bda2..4167466a 100644 --- a/client/index.html +++ b/client/index.html @@ -10,6 +10,7 @@
diff --git a/client/main.js b/client/main.js index 3e02138e..cd7c9b32 100644 --- a/client/main.js +++ b/client/main.js @@ -1,3 +1,3 @@ import 'scalajs:main.js' import './style.scss' -import * as bootstrap from 'bootstrap' \ No newline at end of file +import * as bootstrap from 'bootstrap' diff --git a/client/package.json b/client/package.json index 2569c7c5..2a71912d 100644 --- a/client/package.json +++ b/client/package.json @@ -13,10 +13,11 @@ "@electric-sql/pglite-repl": "0.2.17", "@fontsource/open-sans": "5.0.15", "@fortawesome/fontawesome-free": "5.15.4", + "@tailwindcss/vite": "^4.1.18", "@types/bootstrap": "4.1.3", "@types/file-saver": "2.0.1", "@types/jquery": "3.5.6", - "@types/js-md5": "0.4.2", + "@types/js-md5": "0.8.0", "@types/jsts": "0.17.13", "@types/proj4": "2.5.3", "@types/quill": "1.3.10", @@ -32,7 +33,7 @@ "gridstack": "12.2.2", "hotkeys-js": "3.10.0", "jquery": "3.4.1", - "js-md5": "0.7.3", + "js-md5": "0.8.3", "jspdf": "2.5.1", "jspdf-autotable": "3.5.28", "jspreadsheet-ce": "git://github.com/jspreadsheet/ce.git#2e7389f8f6a84d260603bbac06f00bb404e1ba49", @@ -47,6 +48,7 @@ "shapefile": "0.6.6", "stream-browserify": "3.0.0", "striptags": "3.2.0", + "tailwindcss": "^4.1.18", "toolcool-range-slider": "4.0.28", "xlsx-js-style": "1.2.0" }, diff --git a/client/src/main/scala/ch/wsl/box/client/Main.scala b/client/src/main/scala/ch/wsl/box/client/Main.scala index a54bec59..a8bd36c0 100644 --- a/client/src/main/scala/ch/wsl/box/client/Main.scala +++ b/client/src/main/scala/ch/wsl/box/client/Main.scala @@ -9,6 +9,7 @@ import io.udash.wrappers.jquery._ import org.scalajs.dom import org.scalajs.dom.{Element, WebSocket, document, window} import scribe.{Level, Logger, Logging} +import wvlet.airframe.Design import scala.concurrent.Future import scala.scalajs.js @@ -21,7 +22,13 @@ object Main extends Logging { def main(args: Array[String]): Unit = { val moduleName = window.asInstanceOf[js.Dynamic].boxUiModule.asInstanceOf[String] - Context.init(Module.byName(moduleName)) + val design = Module.byName(moduleName) + boot(design) + } + + def boot(module:Design) = { + + Context.init(module) println( s""" @@ -48,12 +55,9 @@ object Main extends Logging { }) //window.onerror = ErrorHandler.onError - } def setupUI(): Future[Unit] = { - - for { _ <- services.clientSession.refreshSession() appVersion <- services.rest.appVersion() @@ -73,8 +77,6 @@ object Main extends Logging { //ch.wsl.typings.toolcoolRangeSlider.toolcoolRangeSliderRequire - - Labels.load(labels) UI.load(uiConf) diff --git a/client/src/main/scala/ch/wsl/box/client/routes/Routes.scala b/client/src/main/scala/ch/wsl/box/client/routes/Routes.scala index 68f1764e..80bc4997 100755 --- a/client/src/main/scala/ch/wsl/box/client/routes/Routes.scala +++ b/client/src/main/scala/ch/wsl/box/client/routes/Routes.scala @@ -38,17 +38,7 @@ object Routes extends Logging { def fullUrl = dom.document.asInstanceOf[js.Dynamic].baseURI.asInstanceOf[String] def originUrl = dom.window.location.origin - def baseUri = { - val bu = fullUrl - if(bu.contains("//")) { - bu.split("/").drop(3).toList match { - case Nil => "/" - case x => x.mkString("/","/","/") - } - } else { - bu - } - } + def baseUri = ClientConf.frontendUrl def removeBase(s:String):String = { val result = s.stripPrefix(baseUri.stripSuffix("/")) @@ -62,7 +52,7 @@ object Routes extends Logging { } def wsV1(topic:String):String = { - fullUrl.replace("http","ws") + "api/v1/notifications/"+topic + originUrl.replace("http","ws") + "/api/v1/notifications/"+topic } def apply(kind:String, entityName:String,public:Boolean) = new Routes{ diff --git a/client/src/main/scala/ch/wsl/box/client/services/ClientConf.scala b/client/src/main/scala/ch/wsl/box/client/services/ClientConf.scala index b1f18f09..5ad1200e 100644 --- a/client/src/main/scala/ch/wsl/box/client/services/ClientConf.scala +++ b/client/src/main/scala/ch/wsl/box/client/services/ClientConf.scala @@ -10,8 +10,10 @@ import ch.wsl.box.model.shared.oidc.OIDCFrontendConf import io.circe._ import io.circe.parser._ import io.circe.generic.auto._ +import org.scalajs.dom import scribe.Level +import scala.scalajs.js import scala.util.Try /** @@ -59,7 +61,7 @@ object ClientConf { def labelAlign: String = Try(conf("label.align")).getOrElse("left") def menuSeparator: String = Try(conf("menu.separator")).getOrElse(" ") - def frontendUrl: String = Try(conf("frontendUrl")).getOrElse("http://localhost:8080") + def frontendUrl: String = dom.window.asInstanceOf[js.Dynamic].boxFrontendUrl.asInstanceOf[String] def colorMain: String = Try(conf("color.main")).getOrElse("#006268") def colorMainText: String = Try(conf("color.main.text")).getOrElse("#ffffff") diff --git a/client/src/main/scala/ch/wsl/box/client/services/Notification.scala b/client/src/main/scala/ch/wsl/box/client/services/Notification.scala index b78705da..84bc5372 100755 --- a/client/src/main/scala/ch/wsl/box/client/services/Notification.scala +++ b/client/src/main/scala/ch/wsl/box/client/services/Notification.scala @@ -6,6 +6,7 @@ import io.circe.parser._ import io.circe.generic.auto._ import io.udash._ import org.scalajs.dom.WebSocket +import scribe.Logging import scala.concurrent.duration._ import scala.scalajs.js.timers.setTimeout @@ -35,7 +36,7 @@ class NoNotification() extends NotificationChannel { override def close(): Unit = {} } -class NotificationWebSocket() extends NotificationChannel { +class NotificationWebSocket() extends NotificationChannel with Logging { private var socket:WebSocket = null @@ -45,7 +46,9 @@ class NotificationWebSocket() extends NotificationChannel { socket.close() } - socket = new WebSocket(Routes.wsV1("box-client")) + val url = Routes.wsV1("box-client") + logger.info(s"Opening websocket on $url") + socket = new WebSocket(url) socket.onmessage = (msg => { diff --git a/client/src/main/scala/ch/wsl/box/client/services/impl/HttpClientImpl.scala b/client/src/main/scala/ch/wsl/box/client/services/impl/HttpClientImpl.scala index e6f57605..f06fd344 100644 --- a/client/src/main/scala/ch/wsl/box/client/services/impl/HttpClientImpl.scala +++ b/client/src/main/scala/ch/wsl/box/client/services/impl/HttpClientImpl.scala @@ -3,7 +3,7 @@ package ch.wsl.box.client.services.impl import ch.wsl.box.client.services.HttpClient.Response import ch.wsl.box.client.services.{BrowserConsole, HttpClient, Labels, Notification, RunNowExecutionContext} import ch.wsl.box.model.shared.errors.{ExceptionReport, GenericExceptionReport, JsonDecoderExceptionReport, SQLExceptionReport} -import io.circe.Decoder +import io.circe.{Decoder, Json} import org.scalajs.dom import org.scalajs.dom.{File, FormData, XMLHttpRequest} import scribe.Logging diff --git a/client/src/main/scala/ch/wsl/box/client/views/LoginView.scala b/client/src/main/scala/ch/wsl/box/client/views/LoginView.scala index 972901e6..455b63bc 100755 --- a/client/src/main/scala/ch/wsl/box/client/views/LoginView.scala +++ b/client/src/main/scala/ch/wsl/box/client/views/LoginView.scala @@ -29,7 +29,8 @@ case class LoginPresenter() extends Presenter[LoginStateAbstract] { override def handleState(state: LoginStateAbstract): Unit = {} def login(model:ModelProperty[LoginData]) = { - services.clientSession.login(model.get.username,model.get.password).recover { case _ => + services.clientSession.login(model.get.username,model.get.password).recover { case t => + t.printStackTrace() model.subProp(_.message).set(Labels.login.failed) } } diff --git a/client/src/main/scala/ch/wsl/box/client/views/components/widget/lookup/LookupWidget.scala b/client/src/main/scala/ch/wsl/box/client/views/components/widget/lookup/LookupWidget.scala index 7e997299..21d6d5f6 100644 --- a/client/src/main/scala/ch/wsl/box/client/views/components/widget/lookup/LookupWidget.scala +++ b/client/src/main/scala/ch/wsl/box/client/views/components/widget/lookup/LookupWidget.scala @@ -97,16 +97,12 @@ trait LookupWidget extends Widget with HasData { dataSyncRegistration.foreach(_.cancel()) logger.debug(s"Fetching remote lookup $q") - val cacheKey = metadata.name + ch.wsl.typings.jsMd5.mod.^(fieldLookup.lookupEntity + fieldLookup.map + q.toString) - - - + val cacheKey = metadata.name + ch.wsl.typings.jsMd5.mod.hex(fieldLookup.lookupEntity + fieldLookup.map + q.toString) LookupWidget.remoteLookup.get(cacheKey) match { case Some(value) => value.map(setNewLookup) case _ => { _lookup.set(Seq(), true) //reset lookup state - val request = for{ lookups <- services.rest.lookup(metadata.kind, services.clientSession.lang(), metadata.name, field.name, q, public) singleLookup <- if(data.get != Json.Null && !lookups.exists(_.id == data.get)) { diff --git a/client/src/main/scala/io/udash/routing/BoxUrlChangeProvider.scala b/client/src/main/scala/io/udash/routing/BoxUrlChangeProvider.scala index 1fba3788..ec0b6060 100644 --- a/client/src/main/scala/io/udash/routing/BoxUrlChangeProvider.scala +++ b/client/src/main/scala/io/udash/routing/BoxUrlChangeProvider.scala @@ -1,7 +1,7 @@ package io.udash.routing import ch.wsl.box.client.Context -import ch.wsl.box.client.services.{BrowserConsole, Labels, Navigate} +import ch.wsl.box.client.services.{BrowserConsole, ClientConf, Labels, Navigate} import com.avsystem.commons._ import io.udash.core.Url import io.udash.properties.MutableSetRegistration @@ -92,7 +92,7 @@ final class BoxUrlChangeProvider extends UrlChangeProvider with Logging { } private def baseUri = { - val bu = dom.document.baseURI + val bu = ClientConf.frontendUrl logger.debug(s"Base URI with dom.document.baseURI: $bu") if(bu.startsWith("file:///")) { bu.split("html").headOption.map( x => s"${x}html").getOrElse(bu) diff --git a/client/style.scss b/client/style.scss index 93c4b1c7..a7443ebf 100644 --- a/client/style.scss +++ b/client/style.scss @@ -1 +1,3 @@ -@import "~bootstrap/scss/bootstrap"; \ No newline at end of file +@import "~bootstrap/scss/bootstrap"; + +@import "choices.js/src/styles/choices"; \ No newline at end of file diff --git a/client/vite.config.js b/client/vite.config.js index 48a8dc5a..d111500d 100644 --- a/client/vite.config.js +++ b/client/vite.config.js @@ -2,11 +2,13 @@ import { defineConfig } from "vite"; import scalaJSPlugin from "@scala-js/vite-plugin-scalajs"; + export default defineConfig({ server: { proxy: { '/api': 'http://localhost:8080', }, + cors: true }, optimizeDeps: { exclude: ['@electric-sql/pglite'], @@ -19,7 +21,8 @@ export default defineConfig({ '~bootstrap': './node_modules/bootstrap', } }, - plugins: [scalaJSPlugin({ + plugins: [ + scalaJSPlugin({ // path to the directory containing the sbt build // default: '.' cwd: '..', diff --git a/project/Settings.scala b/project/Settings.scala index 83e93235..917a1902 100755 --- a/project/Settings.scala +++ b/project/Settings.scala @@ -32,7 +32,7 @@ object Settings { object versions { //General - val scala213 = "2.13.16" + val scala213 = "2.13.18" val ficus = "1.5.2" val macWire = "2.3.7" @@ -52,7 +52,7 @@ object Settings { //json parsers - val circe = "0.14.3" + val circe = "0.14.15" //database val postgres = "42.2.20" @@ -88,7 +88,7 @@ object Settings { "io.circe" %%% "circe-core" % versions.circe, "io.circe" %%% "circe-generic" % versions.circe, "io.circe" %%% "circe-parser" % versions.circe, - "io.circe" %%% "circe-generic-extras" % versions.circe, + "io.circe" %%% "circe-generic-extras" % "0.14.4", // not same versioning of circe https://github.com/circe/circe-generic-extras?tab=readme-ov-file#versioning "com.outr" %%% "scribe" % versions.scribe, "com.nrinaudo" %%% "kantan.csv" % versions.kantan, "com.github.eikek" %%% "yamusca-core" % "0.8.0", diff --git a/project/plugins.sbt b/project/plugins.sbt index 3d464345..3f230dfb 100755 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,6 +1,6 @@ //addSbtPlugin("com.vmunier" % "sbt-web-scalajs" % "1.1.0") -addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.19.0") +addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.20.2") addSbtPlugin("io.github.cquiroz" % "sbt-locales" % "4.2.0") From 8100c78bbcf2d2b15dccb41ccb2d6dfffa6b8c5f Mon Sep 17 00:00:00 2001 From: Andrea Minetti Date: Mon, 19 Jan 2026 16:39:17 +0100 Subject: [PATCH 05/42] Modular style --- client/index.html | 1 + .../main/scala/ch/wsl/box/client/Module.scala | 3 + .../wsl/box/client/services/ClientConf.scala | 7 +- .../box/client/services/ServiceModule.scala | 2 + .../wsl/box/client/styles/GlobalStyles.scala | 3032 +++++++++-------- .../wsl/box/client/views/EntitiesView.scala | 1 - .../widget/admin/LayoutWidget.scala | 1 - .../widget/array/TwoListWidget.scala | 1 - 8 files changed, 1529 insertions(+), 1519 deletions(-) diff --git a/client/index.html b/client/index.html index 4167466a..7911e30d 100644 --- a/client/index.html +++ b/client/index.html @@ -3,6 +3,7 @@ + Vite App diff --git a/client/src/main/scala/ch/wsl/box/client/Module.scala b/client/src/main/scala/ch/wsl/box/client/Module.scala index 18969de8..3186f7e3 100644 --- a/client/src/main/scala/ch/wsl/box/client/Module.scala +++ b/client/src/main/scala/ch/wsl/box/client/Module.scala @@ -1,7 +1,9 @@ package ch.wsl.box.client import ch.wsl.box.client.services.{ClientSession, DataAccessObject, HttpClient, Navigator, Notification, NotificationChannel, NotificationWebSocket, REST} import ch.wsl.box.client.services.impl.{DaoLocalDbImpl, DaoPassthroughImpl, HttpClientImpl, RestImpl} +import ch.wsl.box.client.styles.{BoxStyle, BoxStyleFactory, GlobalStyleFactory, TestStyleFactory} import ch.wsl.box.model.shared.AvailableUIModule +import ch.wsl.typings.std.WebAssembly.Global import wvlet.airframe._ object Module { @@ -18,6 +20,7 @@ object Module { .bind[ClientSession].toEagerSingleton .bind[Navigator].toEagerSingleton .bind[NotificationChannel].to[NotificationWebSocket] + .bind[BoxStyleFactory].to[GlobalStyleFactory] val prod = newDesign .bind[HttpClient].to[HttpClientImpl] diff --git a/client/src/main/scala/ch/wsl/box/client/services/ClientConf.scala b/client/src/main/scala/ch/wsl/box/client/services/ClientConf.scala index 5ad1200e..5fb63e3e 100644 --- a/client/src/main/scala/ch/wsl/box/client/services/ClientConf.scala +++ b/client/src/main/scala/ch/wsl/box/client/services/ClientConf.scala @@ -1,10 +1,12 @@ package ch.wsl.box.client.services +import ch.wsl.box.client.Context.services + import java.sql.Timestamp import java.time.temporal.ChronoUnit import ch.wsl.box.client.styles.constants.StyleConstants import ch.wsl.box.client.styles.constants.StyleConstants.{ChildProperties, Colors} -import ch.wsl.box.client.styles.{GlobalStyleFactory, StyleConf} +import ch.wsl.box.client.styles.{GlobalStyleFactory, StyleConf, TestStyle} import ch.wsl.box.model.shared.JSONFieldTypes import ch.wsl.box.model.shared.oidc.OIDCFrontendConf import io.circe._ @@ -93,7 +95,8 @@ object ClientConf { inputWidth ) - lazy val style = GlobalStyleFactory.GlobalStyles(styleConf) + //lazy val style = new GlobalStyleFactory.GlobalStyles(styleConf) + lazy val style = services.style.build(scalacss.devOrProdDefaults) def filterPrecisionDatetime: String = Try(conf("filter.precision.datetime").toUpperCase).toOption match { case Some("DATE") => JSONFieldTypes.DATE diff --git a/client/src/main/scala/ch/wsl/box/client/services/ServiceModule.scala b/client/src/main/scala/ch/wsl/box/client/services/ServiceModule.scala index 1935ca98..c6f0cbdf 100644 --- a/client/src/main/scala/ch/wsl/box/client/services/ServiceModule.scala +++ b/client/src/main/scala/ch/wsl/box/client/services/ServiceModule.scala @@ -1,5 +1,6 @@ package ch.wsl.box.client.services +import ch.wsl.box.client.styles.BoxStyleFactory import wvlet.airframe._ trait ServiceModule { @@ -9,4 +10,5 @@ trait ServiceModule { val clientSession = bind[ClientSession] val navigator = bind[Navigator] val notification = bind[NotificationChannel] + val style = bind[BoxStyleFactory] } diff --git a/client/src/main/scala/ch/wsl/box/client/styles/GlobalStyles.scala b/client/src/main/scala/ch/wsl/box/client/styles/GlobalStyles.scala index 789e4c89..9b809ed9 100755 --- a/client/src/main/scala/ch/wsl/box/client/styles/GlobalStyles.scala +++ b/client/src/main/scala/ch/wsl/box/client/styles/GlobalStyles.scala @@ -2,16 +2,19 @@ package ch.wsl.box.client.styles +import ch.wsl.box.client.services.ClientConf import ch.wsl.box.client.styles.constants.StyleConstants import ch.wsl.box.client.styles.constants.StyleConstants.{ChildProperties, Colors} import ch.wsl.box.client.styles.fonts.Font import ch.wsl.box.client.styles.utils.{ColorUtils, MediaQueries, StyleUtils} +import scalacss.StyleSheet import scala.language.postfixOps import scalacss.internal.{AV, CanIUse, FontFace} import scalacss.internal.CanIUse.{Agent, boxshadow} import scalacss.internal.DslBase.ToStyle import scalacss.internal.LengthUnit.px +import scalacss.internal.mutable.{Register, Settings} import scalatags.JsDom import scalatags.generic.Attr @@ -20,1647 +23,1649 @@ import scala.concurrent.duration.DurationInt case class StyleConf(colors:Colors, smallCellsSize:Int, childProps: ChildProperties, requiredFontSize:Int, paddingBlocks: Int, inputPercentage:Double) -object GlobalStyleFactory{ - val CssSettings = scalacss.devOrProdDefaults; import CssSettings._ +class GlobalStyleFactory extends BoxStyleFactory { + override def build(settings:Settings): BoxStyle = new GlobalStyles(settings,ClientConf.styleConf) +} - case class GlobalStyles(conf:StyleConf) extends StyleSheet.Inline { +class GlobalStyles(settings:Settings,conf:StyleConf) extends StyleSheet.Inline()(settings.cssRegister) with BoxStyle { + import settings._ - import dsl._ + import dsl._ - val inputDefaultWidth = width(conf.inputPercentage %%) + private val inputDefaultWidth = width(conf.inputPercentage %%) - val inputHighlight = style( - borderWidth(0 px,0 px,1 px,0 px), - borderColor(conf.colors.main), - backgroundColor(c"#f5f5f5"), - outlineWidth.`0` - ) + override val inputHighlight = style( + borderWidth(0 px,0 px,1 px,0 px), + borderColor(conf.colors.main), + backgroundColor(c"#f5f5f5"), + outlineWidth.`0` + ) - val inputInvalid = style( - borderColor(conf.colors.danger), - backgroundColor(ColorUtils.RGB.fromHex(conf.colors.dangerColor).withTrasparency(0.3)) - ) + override val inputInvalid = style( + borderColor(conf.colors.danger), + backgroundColor(ColorUtils.RGB.fromHex(conf.colors.dangerColor).withTrasparency(0.3)) + ) - val global = style( + override val global = style( - unsafeRoot(".col, .col-1, .col-10, .col-11, .col-12, .col-2, .col-3, .col-4, .col-5, .col-6, .col-7, .col-8, .col-9, .col-auto, .col-lg, .col-lg-1, .col-lg-10, .col-lg-11, .col-lg-12, .col-lg-2, .col-lg-3, .col-lg-4, .col-lg-5, .col-lg-6, .col-lg-7, .col-lg-8, .col-lg-9, .col-lg-auto, .col-md, .col-md-1, .col-md-10, .col-md-11, .col-md-12, .col-md-2, .col-md-3, .col-md-4, .col-md-5, .col-md-6, .col-md-7, .col-md-8, .col-md-9, .col-md-auto, .col-sm, .col-sm-1, .col-sm-10, .col-sm-11, .col-sm-12, .col-sm-2, .col-sm-3, .col-sm-4, .col-sm-5, .col-sm-6, .col-sm-7, .col-sm-8, .col-sm-9, .col-sm-auto, .col-xl, .col-xl-1, .col-xl-10, .col-xl-11, .col-xl-12, .col-xl-2, .col-xl-3, .col-xl-4, .col-xl-5, .col-xl-6, .col-xl-7, .col-xl-8, .col-xl-9, .col-xl-auto") ( - padding.`0` - ), + unsafeRoot(".col, .col-1, .col-10, .col-11, .col-12, .col-2, .col-3, .col-4, .col-5, .col-6, .col-7, .col-8, .col-9, .col-auto, .col-lg, .col-lg-1, .col-lg-10, .col-lg-11, .col-lg-12, .col-lg-2, .col-lg-3, .col-lg-4, .col-lg-5, .col-lg-6, .col-lg-7, .col-lg-8, .col-lg-9, .col-lg-auto, .col-md, .col-md-1, .col-md-10, .col-md-11, .col-md-12, .col-md-2, .col-md-3, .col-md-4, .col-md-5, .col-md-6, .col-md-7, .col-md-8, .col-md-9, .col-md-auto, .col-sm, .col-sm-1, .col-sm-10, .col-sm-11, .col-sm-12, .col-sm-2, .col-sm-3, .col-sm-4, .col-sm-5, .col-sm-6, .col-sm-7, .col-sm-8, .col-sm-9, .col-sm-auto, .col-xl, .col-xl-1, .col-xl-10, .col-xl-11, .col-xl-12, .col-xl-2, .col-xl-3, .col-xl-4, .col-xl-5, .col-xl-6, .col-xl-7, .col-xl-8, .col-xl-9, .col-xl-auto") ( + padding.`0` + ), - unsafeRoot(".row")( - margin.`0` - ), + unsafeRoot(".row")( + margin.`0` + ), - unsafeRoot("b") ( - Font.bold - ), + unsafeRoot("b") ( + Font.bold + ), - unsafeRoot("h4")( - marginTop(20 px) - ), + unsafeRoot("h4")( + marginTop(20 px) + ), - unsafeRoot(".block-el-0 h4")( - marginTop.`0`.important - ), + unsafeRoot(".block-el-0 h4")( + marginTop.`0`.important + ), - unsafeRoot("h5")( - Font.bold, - fontSize(14 px), - media.maxWidth(600 px)( //disable autozoom - fontSize(16 px) - ) - ), + unsafeRoot("h5")( + Font.bold, + fontSize(14 px), + media.maxWidth(600 px)( //disable autozoom + fontSize(16 px) + ) + ), - unsafeRoot("body") ( - StyleConstants.defaultFontSize, - backgroundColor.white, - Font.regular - ), + unsafeRoot("body") ( + StyleConstants.defaultFontSize, + backgroundColor.white, + Font.regular + ), - unsafeRoot("html, body") ( - touchAction := "pan-x pan-y" - ), + unsafeRoot("html, body") ( + touchAction := "pan-x pan-y" + ), - unsafeRoot("h3") ( - marginTop(18 px) - ), + unsafeRoot("h3") ( + marginTop(18 px) + ), - unsafeRoot("*:focus")( - outline.none - ), + unsafeRoot("*:focus")( + outline.none + ), - unsafeRoot("select")( - inputDefaultWidth, - media.maxWidth(600 px)( - width(100 %%) - ), - borderStyle.solid, - borderWidth(0 px,0 px,1 px,0 px), - borderRadius.`0`, - borderColor(Colors.GreySemi), - height(23 px), - backgroundColor.transparent, - Font.regular, - &.focus( - inputHighlight - ), - &.hover( - inputHighlight - ), - &.invalid( - inputInvalid - ), - media.maxWidth(600 px)( //disable autozoom - height(26 px), - fontSize(16 px) - ) + unsafeRoot("select")( + inputDefaultWidth, + media.maxWidth(600 px)( + width(100 %%) ), - - unsafeRoot("input")( - inputDefaultWidth, - media.maxWidth(600 px)( - width(100 %%) - ), - borderStyle.solid, - borderWidth(0 px,0 px,1 px,0 px), - borderRadius.`0`, - backgroundColor.white, - borderColor(Colors.GreySemi), - height(23 px), - Font.regular, - paddingLeft(5 px), - paddingRight(5 px), - backgroundColor.transparent, - &.focus( - inputHighlight - ), - &.hover( - inputHighlight - ), - &.invalid( - inputInvalid - ), - media.maxWidth(600 px)( //disable autozoom - height(26 px), - fontSize(16 px) - ) + borderStyle.solid, + borderWidth(0 px,0 px,1 px,0 px), + borderRadius.`0`, + borderColor(Colors.GreySemi), + height(23 px), + backgroundColor.transparent, + Font.regular, + &.focus( + inputHighlight ), - - - unsafeRoot("label")( - Font.bold + &.hover( + inputHighlight ), - - unsafeRoot("input[type='checkbox']")( - width.auto, - height.auto + &.invalid( + inputInvalid ), + media.maxWidth(600 px)( //disable autozoom + height(26 px), + fontSize(16 px) + ) + ), - unsafeRoot("input[type='number']")( - textAlign.right + unsafeRoot("input")( + inputDefaultWidth, + media.maxWidth(600 px)( + width(100 %%) ), - - unsafeRoot(".flatpickr-time input[type='number']")( - textAlign.center + borderStyle.solid, + borderWidth(0 px,0 px,1 px,0 px), + borderRadius.`0`, + backgroundColor.white, + borderColor(Colors.GreySemi), + height(23 px), + Font.regular, + paddingLeft(5 px), + paddingRight(5 px), + backgroundColor.transparent, + &.focus( + inputHighlight ), - - unsafeRoot("input[type='file']")( - width(100 %%), - height.auto, - borderWidth(0 px), - backgroundColor.transparent + &.hover( + inputHighlight ), - - unsafeRoot("textarea")( - width(100 %%), - borderStyle.solid, - borderWidth(1 px), - borderRadius.`0`, - borderLeft.`0`, - borderRight.`0`, - borderTop.`0`, - backgroundColor.transparent, - borderColor(Colors.GreySemi), - resize.vertical, - overflowY.auto, - wordWrap.breakWord, - minHeight(30 px), - maxHeight(200 px), - media.maxWidth(600 px)( //disable autozoom - fontSize(16 px) - ), - transition := "all .1s linear;", - &.focus( - inputHighlight - ), - &.hover( - inputHighlight - ), + &.invalid( + inputInvalid ), + media.maxWidth(600 px)( //disable autozoom + height(26 px), + fontSize(16 px) + ) + ), - unsafeRoot("option")( - direction.ltr - ), + unsafeRoot("label")( + Font.bold + ), - unsafeRoot("header")( - clear.both, - height(50 px), - padding(10 px, 20 px, 10 px, 20 px), - media.minWidth(600 px)( - paddingLeft(50 px) - ), - lineHeight(29 px), - border.`0`, - color(conf.colors.mainText), - backgroundColor(conf.colors.main), - fontSize(0.8.rem), - unsafeChild("ul") ( - display.inline, - margin(10 px) - ), - unsafeChild("li") ( - display.inline, - margin(10 px) - ), - position.sticky, - zIndex(100), - boxShadow := "0px 0px 3px #555" - ), + unsafeRoot("input[type='checkbox']")( + width.auto, + height.auto + ), - unsafeRoot("footer")( - borderTop(conf.colors.main,5 px,solid), - backgroundColor.white, - overflow.hidden, - fontSize(11 px), - color.darkgray, - padding(15 px), - height(55 px) - ), + unsafeRoot("input[type='number']")( + textAlign.right + ), - unsafeRoot(".form-control")( // this controls the datetime input - paddingTop(1 px), - paddingBottom(1 px), - paddingRight(5 px), - textAlign.left, - lineHeight(14 px), - height(21 px), - borderStyle.solid, - borderWidth(1 px), - borderRadius.`0`, - backgroundColor.white, - borderColor(Colors.GreySemi) - ), + unsafeRoot(".flatpickr-time input[type='number']")( + textAlign.center + ), - unsafeRoot(".modal") ( - backgroundColor.rgba(0,0,0,0.5), - ), + unsafeRoot("input[type='file']")( + width(100 %%), + height.auto, + borderWidth(0 px), + backgroundColor.transparent + ), - unsafeRoot(".modal-sm") ( - maxWidth(800 px).important + unsafeRoot("textarea")( + width(100 %%), + borderStyle.solid, + borderWidth(1 px), + borderRadius.`0`, + borderLeft.`0`, + borderRight.`0`, + borderTop.`0`, + backgroundColor.transparent, + borderColor(Colors.GreySemi), + resize.vertical, + overflowY.auto, + wordWrap.breakWord, + minHeight(30 px), + maxHeight(200 px), + media.maxWidth(600 px)( //disable autozoom + fontSize(16 px) ), - unsafeRoot(".modal-lg") ( - maxWidth(90 %%).important, - media.maxWidth(600 px)( - maxWidth(100 %%).important, - ), + transition := "all .1s linear;", + &.focus( + inputHighlight ), + &.hover( + inputHighlight + ), + ), - unsafeRoot(".rotate-right") ( - transform := "rotate(90deg)" - ), + unsafeRoot("option")( + direction.ltr + ), - unsafeRoot(".rotate-left") ( - transform := "rotate(-90deg)" + unsafeRoot("header")( + clear.both, + height(50 px), + padding(10 px, 20 px, 10 px, 20 px), + media.minWidth(600 px)( + paddingLeft(50 px) ), - - unsafeRoot(".container-fluid")( - padding.`0` + lineHeight(29 px), + border.`0`, + color(conf.colors.mainText), + backgroundColor(conf.colors.main), + fontSize(0.8.rem), + unsafeChild("ul") ( + display.inline, + margin(10 px) + ), + unsafeChild("li") ( + display.inline, + margin(10 px) ), + position.sticky, + zIndex(100), + boxShadow := "0px 0px 3px #555" + ), - unsafeRoot("main")( -// media.minWidth(600 px)( -// paddingLeft(50 px), -// paddingRight(50 px) -// ), - backgroundColor.white + unsafeRoot("footer")( + borderTop(conf.colors.main,5 px,solid), + backgroundColor.white, + overflow.hidden, + fontSize(11 px), + color.darkgray, + padding(15 px), + height(55 px) + ), + + unsafeRoot(".form-control")( // this controls the datetime input + paddingTop(1 px), + paddingBottom(1 px), + paddingRight(5 px), + textAlign.left, + lineHeight(14 px), + height(21 px), + borderStyle.solid, + borderWidth(1 px), + borderRadius.`0`, + backgroundColor.white, + borderColor(Colors.GreySemi) + ), + + unsafeRoot(".modal") ( + backgroundColor.rgba(0,0,0,0.5), + ), + + unsafeRoot(".modal-sm") ( + maxWidth(800 px).important + ), + unsafeRoot(".modal-lg") ( + maxWidth(90 %%).important, + media.maxWidth(600 px)( + maxWidth(100 %%).important, ), + ), - unsafeRoot("a")( - &.hover( - textDecorationLine.underline, - color(conf.colors.mainLink) - ), - color(conf.colors.mainLink), - cursor.pointer, - ), + unsafeRoot(".rotate-right") ( + transform := "rotate(90deg)" + ), - unsafeRoot("main a") ( - media.maxWidth(600 px)( - display.inlineBlock, - margin.vertical(5 px) - ), - ), + unsafeRoot(".rotate-left") ( + transform := "rotate(-90deg)" + ), + + unsafeRoot(".container-fluid")( + padding.`0` + ), + unsafeRoot("main")( + // media.minWidth(600 px)( + // paddingLeft(50 px), + // paddingRight(50 px) + // ), + backgroundColor.white + ), - unsafeRoot("#box-table table")( - backgroundColor.white + unsafeRoot("a")( + &.hover( + textDecorationLine.underline, + color(conf.colors.mainLink) ), + color(conf.colors.mainLink), + cursor.pointer, + ), - //hide up/down arrow for input - unsafeRoot("input[type=\"number\"]::-webkit-outer-spin-button,\n input[type=\"number\"]::-webkit-inner-spin-button")( - StyleUtils.unsafeProp("-webkit-appearance","none"), - margin.`0` + unsafeRoot("main a") ( + media.maxWidth(600 px)( + display.inlineBlock, + margin.vertical(5 px) ), + ), - unsafeRoot("input[type=\"number\"]")( - StyleUtils.unsafeProp("-moz-appearance","textfield") - ), + unsafeRoot("#box-table table")( + backgroundColor.white + ), - unsafeRoot(".ql-editor")( - fontWeight.normal - ), - unsafeRoot(".ql-editor p")( - marginBottom.`0` - ), + //hide up/down arrow for input + unsafeRoot("input[type=\"number\"]::-webkit-outer-spin-button,\n input[type=\"number\"]::-webkit-inner-spin-button")( + StyleUtils.unsafeProp("-webkit-appearance","none"), + margin.`0` + ), - unsafeRoot (".field")( - width(100 %%) - ), + unsafeRoot("input[type=\"number\"]")( + StyleUtils.unsafeProp("-moz-appearance","textfield") + ), - unsafeRoot(".grid-stack > .grid-stack-item.grid-stack-sub-grid > .grid-stack-item-content") ( - backgroundColor.rgba(0,0,0,0.1), - marginRight(2.px) - ) + unsafeRoot(".ql-editor")( + fontWeight.normal + ), - ) + unsafeRoot(".ql-editor p")( + marginBottom.`0` + ), - val spaceBetween = style( - display.flex, - flexDirection.column, - media.minWidth(600 px)( - flexDirection.row, - ), - justifyContent.spaceBetween, - alignItems.center, - alignContent.center + unsafeRoot (".field")( + width(100 %%) + ), + + unsafeRoot(".grid-stack > .grid-stack-item.grid-stack-sub-grid > .grid-stack-item-content") ( + backgroundColor.rgba(0,0,0,0.1), + marginRight(2.px) ) - val flexContainer = style( - display.flex, + + ) + + override val spaceBetween = style( + display.flex, + flexDirection.column, + media.minWidth(600 px)( flexDirection.row, - height(100 %%), + ), + justifyContent.spaceBetween, + alignItems.center, + alignContent.center + ) + + override val flexContainer = style( + display.flex, + flexDirection.row, + height(100 %%), + width(100 %%), + + ) + + override val sidebarRightContent = style( + flex := "1 0 auto", + width(100 %%), + transitionDuration(300 millis), + unsafeExt(_ + ".showSidebar")( + media.minWidth(600 px)( + width :=! "calc(100% - 250px)", + transitionDuration(300 millis), + ) + ), + ) + + override val sidebar = style( + marginLeft(-250 px), + height :=! "calc(100vh - 250px)", + overflowX.hidden, + transitionDuration(300 millis), + width(250 px), + paddingRight(80 px), + unsafeExt(_ + ".showSidebar")( + marginLeft.`0`, + paddingRight(30 px), + transitionDuration(300 millis), + ), + unsafeChild("input") ( width(100 %%), + ), + unsafeChild("li") ( + lineHeight(22 px) + ), + unsafeChild("ul") ( + marginTop(20 px) + ), + media.maxWidth(600 px)( + display.none, + ), + ) + + + + override val spaceAfter = style( + display.flex, + flexDirection.column, + media.minWidth(600 px)( + flexDirection.row, + ), + justifyContent.start, + alignItems.center, + alignContent.center + ) + + override val navigationBlock = style( + display.flex, + flexDirection.row, + justifyContent.spaceBetween, + alignItems.center, + alignContent.center, + media.maxWidth(600 px)( + width(90 %%) + ) + ) + + override val textNoWrap = style( + whiteSpace.nowrap + ) + + override val dataChanged = style( + color(conf.colors.danger), + fontSize(14 px) + ) + + override val formTitle = style( + fontWeight.bold, + fontSize(18 px), + padding(0.75 rem) + ) + + override val formTitleLight = style( + fontWeight._300, + fontSize(14 px) + ) + + override val chipLink = style( + borderColor.gray, + padding(5 px, 10 px), + marginLeft(10 px), + fontSize(10 px), + backgroundColor(Color("#eee")), + borderRadius(20 px), + &.hover( + color(conf.colors.main).important, + backgroundColor(ColorUtils.RGB.fromHex(conf.colors.mainColor).withTrasparency(0.4)) + ) + ) + + override val chip = style( + color(conf.colors.main).important, + backgroundColor(ColorUtils.RGB.fromHex(conf.colors.mainColor).withTrasparency(0.4)), + padding(5 px, 10 px), + marginLeft(10 px), + fontSize(10 px), + borderRadius(20 px), + textNoWrap + ) + + override val checkboxWidget = style( + float.none.important, + marginRight(5.px) + ) + + override val jsonMetadataRendered = style( + unsafeChild("input") ( + float.right + ), + unsafeChild("textarea") ( + float.right + ), + unsafeChild(".jexcel input") ( + float.none ) + ) - val sidebarRightContent = style( - flex := "1 0 auto", - width(100 %%), - transitionDuration(300 millis), - unsafeExt(_ + ".showSidebar")( - media.minWidth(600 px)( - width :=! "calc(100% - 250px)", - transitionDuration(300 millis), + override val preformatted = style( + whiteSpace.preLine + ) + + override val formHeader = style( + margin(`0`, 10 px) + ) + + override val dateTimePicker = style( + inputDefaultWidth, + media.maxWidth(600 px)( + width(100 %%) + ), + textAlign.right, + float.right + ) + + override val dateTimePickerFullWidth = style( + width(100 %%), + textAlign.right, + float.right + ) + + override val tableHeaderFixed = style( + unsafeChild("table")( + verticalAlign.middle, + height.fitContent + ), + unsafeChild("thead") ( + position.sticky, + top.`0`, + backgroundColor.white, + unsafeChild("td") ( + border.`0`, + media.maxWidth(600 px)( + padding.vertical(5 px) ) - ), + ) ) + ) - val sidebar = style( - marginLeft(-250 px), - height :=! "calc(100vh - 250px)", - overflowX.hidden, - transitionDuration(300 millis), - width(250 px), - paddingRight(80 px), - unsafeExt(_ + ".showSidebar")( - marginLeft.`0`, - paddingRight(30 px), - transitionDuration(300 millis), + override val smallCells = style( + padding.horizontal(3 px), + padding.vertical(15 px), + verticalAlign.middle.important, + fontSize(conf.smallCellsSize px), + unsafeRoot("input") ( + media.maxWidth(600 px)( + height(22 px), + fontSize(14 px) + ), + ), + unsafeRoot("p")( + margin(0 px) + ) + ) + + override val tableCellActions = style( + height(100 %%), + minWidth(80 px), + fontSize(15 px), + display.flex, + alignItems.center, + justifyContent.spaceAround, + unsafeChild("a.action.primary") ( + &.hover( + color(conf.colors.main) + ) + ), + unsafeChild("a.action.danger") ( + &.hover( + color(conf.colors.danger) + ) + ), + // unsafeChild("svg") ( + // transform := "scale(1.5)" + // ) + ) + + override val rowStyle = style( + height(100 %%), + unsafeChild("a.action") ( + color(Color("#ccc")), + fontSize(11 px) + ), + borderLeft(4 px,solid,transparent), + + unsafeExt(_ + ".selected") ( + unsafeChild("a.action") ( + color(Color("#333")), ), - unsafeChild("input") ( - width(100 %%), + backgroundColor(ColorUtils.RGB.fromHex(conf.colors.mainColor).withTrasparency(0.2)) + ), + &.hover( + backgroundColor(rgba(250,248,250,1)), + borderLeft(4 px,solid,black), + unsafeChild("a.action") ( + color(Color("#333")), ), - unsafeChild("li") ( - lineHeight(22 px) + unsafeExt(_ + ".selected") ( + backgroundColor(ColorUtils.RGB.fromHex(conf.colors.mainColor).withTrasparency(0.2)) ), - unsafeChild("ul") ( - marginTop(20 px) + ), + unsafeExt(_ + "." + StyleConstants.mapHoverClass)( + backgroundColor(ColorUtils.RGB.fromHex(conf.colors.mainColor).lighten(0.8).color) + ) + ) + + override val tableHeader = style( + fontSize(14 px), + media.maxWidth(600 px)( + fontSize(12 px), + ), + Font.bold + ) + + + + override val numberCells = style( + textAlign.right, + paddingRight(3 px) + ) + + override val textCells = style( + textAlign.left, + paddingLeft(3 px) + ) + + override val lookupCells = style( + textAlign.center + ) + + override val dateCells = style( + textAlign.center + ) + + override val noPadding = style( padding.`0` ) + override val smallBottomMargin = style( marginBottom(5 px) ) + override val mediumBottomMargin = style( marginBottom(15 px) ) + + override val subBlock = style( + padding(conf.paddingBlocks px), + minHeight.`0` + ) + + override val block = style( + paddingTop.`0`, + paddingBottom.`0`, + minHeight.`0` + ) + + override val innerBlock = style( + margin.`0` + ) + + override val withBorder = style( + borderBottom(conf.childProps.borderSize px,solid, conf.childProps.borderColor), + paddingBottom(5 px), + paddingTop(5 px) + ) + + override val removeFlexChild = style( + borderLeftColor.transparent, + borderLeftStyle.solid, + borderLeftWidth(3 px), + &.hover( + borderLeftColor(conf.colors.main) + ) + ) + + override val tableContainer = style( + paddingLeft(10.px), + paddingRight(10.px), + paddingBottom(20.px) + ) + + override val table = style( + borderColor(Colors.GreySemi), + borderCollapse.collapse, + unsafeChild("th") ( + Font.bold, + //borderColor(conf.colors.main), + ), + unsafeChild("td") ( + //borderColor(conf.colors.main), + //borderStyle.solid, + //borderWidth(1 px), + paddingLeft(10 px), + paddingRight(10 px), + paddingTop(10 px), + paddingBottom(10 px), + media.maxWidth(600 px)( + verticalAlign.textTop ), + ), + unsafeChild("label") ( + marginBottom.`0`, media.maxWidth(600 px)( - display.none, + width(100 %%), + justifyContent.spaceEvenly ), + ), + width(100 %%) + ) + override val field = style( + paddingRight(10 px), + paddingLeft(10 px), + minHeight.`0`, + media.maxWidth(600 px)( //disable autozoom + paddingTop(10 px) ) + ) - - - val spaceAfter = style( - display.flex, - flexDirection.column, - media.minWidth(600 px)( - flexDirection.row, - ), - justifyContent.start, - alignItems.center, - alignContent.center + override val fieldHighlight = style( + unsafeExt(_ + ":focus-within")( + unsafeChild("label")( + color(conf.colors.main), + ) ) - - val navigationBlock = style( - display.flex, - flexDirection.row, - justifyContent.spaceBetween, - alignItems.center, - alignContent.center, - media.maxWidth(600 px)( - width(90 %%) + ) + + override val removeFieldMargin = style( + marginRight(-10 px), + marginLeft(-10 px), + minHeight.`0` + ) + + override val removeFieldAndBlockMargin = style( + marginRight((-10-conf.paddingBlocks) px), + marginLeft((-10-conf.paddingBlocks) px), + minHeight.`0` + ) + + override val distributionContrainer = style( + display.flex, + flexDirection.row, + flexWrap.wrap, + justifyContent.start, + alignItems.center, + alignContent.spaceAround + ) + + override val distributionChild = style( + padding(10.px), + margin(5.px) + ) + + override val boxedLink = style( + Font.bold, + width(120 px), + height(120 px), + padding(20 px), + margin(20 px) + ) + + + override val notificationArea = style( + position.fixed, + top(40 px), + right(40 px), + zIndex(2000) + ) + + override val notification = style( + padding(20 px), + border(1 px,solid,red), + backgroundColor.white + ) + + override val headerLogo = style( + height(40 px), + maxWidth( 100 %%), + marginTop(-10 px), + marginBottom(-5 px), + marginLeft(0 px), + marginRight(10 px) + ) + + override val headerTitle = style( + fontSize(20 px), + //color.rgba(255,255,255,0.8), + //text-shadow: 0px 3px 3px #006268, 0 0px 0px #fff, 0px 3px 3px #006268; + ) + + override val linkHeaderFooter = style( + textDecorationLine.none.important, + unsafeChild("span") ( + textDecorationLine.none.important, + &.hover( + textDecorationLine.none.important, + color(conf.colors.link) ) + ), + &.hover( + textDecorationLine.none.important, + color(conf.colors.link) + ), + color(conf.colors.link), + textTransform.uppercase, + Font.bold, + cursor.pointer, + padding.horizontal(2.px) + ) + + + + override val fullHeightMax = style( + height :=! "calc(100vh - 150px)", + media.maxWidth(600 px)( + height :=! "calc(100vh - 110px)", + //paddingBottom(70 px) + ), + overflow.auto + ) + + override val fullHeight = style( + height :=! "calc(100vh - 105px)", + media.maxWidth(600 px)( + height :=! "calc(100vh - 53px)", + ), + overflow.auto, + width(100.%%) + ) + + override val loading = style( + position.fixed, + top.`0`, + left.`0`, + width(100.%%), + height(100.%%), + backgroundColor(rgba(0,0,0,0.5)), + paddingTop(200 px), + textAlign.center, + color.white, + fontSize(20 px), + zIndex(9999) + ) + + override val noMargin = style( + margin.`0` + ) + + override val subform = style( + marginTop(conf.childProps.marginTopSize px), + padding(conf.childProps.paddingSize px), + border(conf.childProps.borderSize px,solid, conf.childProps.borderColor), + backgroundColor(conf.childProps.backgroundColor), + overflow.hidden + ) + + override val childTable = style( + backgroundColor(conf.childProps.backgroundColor), + border(conf.childProps.borderSize px,solid, conf.childProps.borderColor), + width(100.%%), + overflow.hidden + ) + + override val childTableTr = style( + border(conf.childProps.borderSize px,solid, conf.childProps.borderColor), + borderBottom.`0` + ) + + override val childTableTd = style( + fontSize(conf.smallCellsSize px), + padding(5.px) + ) + + override val childTableHeader = style( + Font.bold + ) + + override val childTableAction = style( + width(10.px) + ) + + override val childFormTableTr = style( + borderTop.`0` + ) + + override val childFormTableTd = style( + StyleConstants.defaultFontSize + ) + + override val boxIconButton = style( + Font.regular, + whiteSpace.nowrap, + padding(3 px, 10 px), + fontSize(16 px), + margin(3 px, 0 px), + border(0 px), + color(conf.colors.mainLink), + backgroundColor.transparent, + unsafeChild("svg") ( + transform := "scale(1.5)" + ), + &.hover( + color(white), + backgroundColor(conf.colors.main) + ), + &.attrExists("disabled") ( + backgroundColor(lightgray), + color(gray), + borderColor(gray) + ) + ) + + override val boxIconButtonDanger = style( + Font.regular, + whiteSpace.nowrap, + padding(7 px, 15 px), + fontSize(16 px), + margin(3 px, 0 px), + border(0 px), + color(conf.colors.danger), + backgroundColor.transparent, + unsafeChild("svg") ( + transform := "scale(1.5)" + ), + &.hover( + color(white), + backgroundColor(conf.colors.danger) + ), + &.attrExists("disabled") ( + backgroundColor(lightgray), + color(gray), + borderColor(gray) + ) + ) + + + override val boxButton = style( + Font.regular, + whiteSpace.nowrap, + height.auto, + padding(7 px, 15 px), + fontSize(14 px), + minWidth(25 px), + textAlign.center, + lineHeight(16 px), + margin(9 px, 5 px, 9 px, 0 px), + border.`0`, + color(conf.colors.mainLink), + backgroundColor(white), + cursor.pointer, + &.hover( + color(white), + backgroundColor(conf.colors.main), + opacity(0.5), + ), + &.attrExists("disabled") ( + backgroundColor.transparent, + color(gray), + cursor.default + //borderColor(gray) ) + ) - val textNoWrap = style( - whiteSpace.nowrap - ) + override val boxNavigationLabel = style( + textAlign.center, + lineHeight(26 px), + fontSize(12 px) + ) - val dataChanged = style( - color(conf.colors.danger), - fontSize(14 px) + override val spacedList = style( + unsafeChild("li") ( + marginTop(10 px), + marginBottom(10 px) ) + ) - val formTitle = style( - fontWeight.bold, - fontSize(18 px), - padding(0.75 rem) - ) - val formTitleLight = style( - fontWeight._300, - fontSize(14 px) - ) - val chipLink = style( - borderColor.gray, - padding(5 px, 10 px), - marginLeft(10 px), - fontSize(10 px), - backgroundColor(Color("#eee")), - borderRadius(20 px), - &.hover( - color(conf.colors.main).important, - backgroundColor(ColorUtils.RGB.fromHex(conf.colors.mainColor).withTrasparency(0.4)) - ) + override val boxButtonImportant = style( + Font.regular, + whiteSpace.nowrap, + height.auto, + padding(7 px, 15 px), + fontSize(14 px), + lineHeight(16 px), + margin(9 px, 5 px, 9 px, 0 px), + border.`0`, + boxShadow := "0px 0px 2px #555", + backgroundColor(conf.colors.main), + color(conf.colors.mainText), + &.hover( + backgroundColor.white, + color(conf.colors.mainLink), + borderColor(conf.colors.mainLink), + boxShadow := "0px 0px 5px #555" + ) + ) + + override val boxButtonDanger = style( + Font.regular, + whiteSpace.nowrap, + height.auto, + padding(7 px, 15 px), + fontSize(14 px), + lineHeight(16 px), + margin(9 px, 5 px, 9 px, 0 px), + border.`0`, + boxShadow := "0px 0px 2px #555", + backgroundColor(conf.colors.danger), + color.white, + &.hover( + backgroundColor.white, + color(conf.colors.danger) ) + ) - val chip = style( - color(conf.colors.main).important, - backgroundColor(ColorUtils.RGB.fromHex(conf.colors.mainColor).withTrasparency(0.4)), - padding(5 px, 10 px), - marginLeft(10 px), - fontSize(10 px), - borderRadius(20 px), - textNoWrap - ) + override val popupButton = style( + inputDefaultWidth, + media.maxWidth(600 px)( + width(100 %%) + ), + borderStyle.solid, + borderWidth(0 px,0 px,1 px,0 px), + borderRadius.`0`, + borderColor(Colors.GreySemi), + minHeight(23 px), + backgroundColor.transparent, + cursor.pointer, + &.focus( + inputHighlight + ), + &.hover( + inputHighlight + ) + ) + + override val popupEntiresList = style( + overflowY.auto, + maxHeight(70.vh) + ) + + override val fullWidth = style( + width(100 %%).important + ) + + override val maxFullWidth = style( + maxWidth(100 %%).important + ) + + override val filterTableSelect = style( + border.`0`, + media.maxWidth(600 px)( + height(20 px), + fontSize(11 px) + ), + ) - val checkboxWidget = style( - float.none.important, - marginRight(5.px) - ) - val jsonMetadataRendered = style( - unsafeChild("input") ( - float.right - ), - unsafeChild("textarea") ( - float.right - ), - unsafeChild(".jexcel input") ( - float.none - ) - ) - val preformatted = style( - whiteSpace.preLine - ) + override val imageThumb = style( + height.auto, + maxWidth(100 %%) + ) - val formHeader = style( - margin(`0`, 10 px) - ) + override val noBullet = style( + listStyleType := "none", + paddingLeft.`0`, + fontSize(12 px) + ) - val dateTimePicker = style( - inputDefaultWidth, - media.maxWidth(600 px)( - width(100 %%) - ), - textAlign.right, - float.right - ) + val navigationArea = style( + paddingLeft(20 px), + paddingRight(20 px), + paddingTop(5 px), + paddingBottom(5 px) + ) - val dateTimePickerFullWidth = style( - width(100 %%), - textAlign.right, - float.right - ) + val navigatorArea = style( + width(260 px), + textAlign.right + ) - val tableHeaderFixed = style( - unsafeChild("table")( - verticalAlign.middle, - height.fitContent - ), - unsafeChild("thead") ( - position.sticky, - top.`0`, - backgroundColor.white, - unsafeChild("td") ( - border.`0`, - media.maxWidth(600 px)( - padding.vertical(5 px) - ) - ) - ) + val noMobile = style( + media.maxWidth(600 px)( + display.none ) + ) - val smallCells = style( - padding.horizontal(3 px), - padding.vertical(15 px), - verticalAlign.middle.important, - fontSize(conf.smallCellsSize px), - unsafeRoot("input") ( - media.maxWidth(600 px)( - height(22 px), - fontSize(14 px) - ), - ), - unsafeRoot("p")( - margin(0 px) - ) + val showFooterActionOnMobile = style( + unsafeChild("#footerActions")( + display.block.important ) + ) - val tableCellActions = style( - height(100 %%), - minWidth(80 px), - fontSize(15 px), - display.flex, - alignItems.center, - justifyContent.spaceAround, - unsafeChild("a.action.primary") ( - &.hover( - color(conf.colors.main) - ) - ), - unsafeChild("a.action.danger") ( - &.hover( - color(conf.colors.danger) - ) - ), -// unsafeChild("svg") ( -// transform := "scale(1.5)" -// ) + val mobileOnly = style( + display.none, + media.maxWidth(600 px)( + display.block ) + ) - val rowStyle = style( - height(100 %%), - unsafeChild("a.action") ( - color(Color("#ccc")), - fontSize(11 px) - ), - borderLeft(4 px,solid,transparent), - - unsafeExt(_ + ".selected") ( - unsafeChild("a.action") ( - color(Color("#333")), - ), - backgroundColor(ColorUtils.RGB.fromHex(conf.colors.mainColor).withTrasparency(0.2)) - ), - &.hover( - backgroundColor(rgba(250,248,250,1)), - borderLeft(4 px,solid,black), - unsafeChild("a.action") ( - color(Color("#333")), - ), - unsafeExt(_ + ".selected") ( - backgroundColor(ColorUtils.RGB.fromHex(conf.colors.mainColor).withTrasparency(0.2)) - ), - ), - unsafeExt(_ + "." + StyleConstants.mapHoverClass)( - backgroundColor(ColorUtils.RGB.fromHex(conf.colors.mainColor).lighten(0.8).color) + val mobileFooter = style( + media.maxWidth(600 px)( + unsafeChild("button")( + (width :=! "calc(100vw - 20px)").important, + height(35 px).important ) ) - - val tableHeader = style( - fontSize(14 px), - media.maxWidth(600 px)( - fontSize(12 px), - ), - Font.bold - ) - - - - val numberCells = style( - textAlign.right, - paddingRight(3 px) - ) - - val textCells = style( - textAlign.left, - paddingLeft(3 px) - ) - - val lookupCells = style( - textAlign.center - ) - - val dateCells = style( - textAlign.center - ) - - val noPadding = style( padding.`0` ) - val smallBottomMargin = style( marginBottom(5 px) ) - val mediumBottomMargin = style( marginBottom(15 px) ) - - val subBlock = style( - padding(conf.paddingBlocks px), - minHeight.`0` - ) - - val block = style( - paddingTop.`0`, - paddingBottom.`0`, - minHeight.`0` - ) - - val innerBlock = style( - margin.`0` - ) - - val withBorder = style( - borderBottom(conf.childProps.borderSize px,solid, conf.childProps.borderColor), - paddingBottom(5 px), - paddingTop(5 px) - ) - - val removeFlexChild = style( - borderLeftColor.transparent, - borderLeftStyle.solid, - borderLeftWidth(3 px), - &.hover( - borderLeftColor(conf.colors.main) - ) - ) - - val tableContainer = style( - paddingLeft(10.px), - paddingRight(10.px), - paddingBottom(20.px) - ) - - val table = style( - borderColor(Colors.GreySemi), - borderCollapse.collapse, - unsafeChild("th") ( - Font.bold, - //borderColor(conf.colors.main), - ), - unsafeChild("td") ( - //borderColor(conf.colors.main), - //borderStyle.solid, - //borderWidth(1 px), - paddingLeft(10 px), - paddingRight(10 px), - paddingTop(10 px), - paddingBottom(10 px), - media.maxWidth(600 px)( - verticalAlign.textTop - ), - ), - unsafeChild("label") ( - marginBottom.`0`, - media.maxWidth(600 px)( - width(100 %%), - justifyContent.spaceEvenly - ), - ), - width(100 %%) - ) - - val field = style( - paddingRight(10 px), - paddingLeft(10 px), - minHeight.`0`, - media.maxWidth(600 px)( //disable autozoom - paddingTop(10 px) - ) - ) - - val fieldHighlight = style( - unsafeExt(_ + ":focus-within")( - unsafeChild("label")( - color(conf.colors.main), - ) - ) - ) - - val removeFieldMargin = style( - marginRight(-10 px), - marginLeft(-10 px), - minHeight.`0` - ) - - val removeFieldAndBlockMargin = style( - marginRight((-10-conf.paddingBlocks) px), - marginLeft((-10-conf.paddingBlocks) px), - minHeight.`0` - ) - - val distributionContrainer = style( - display.flex, - flexDirection.row, - flexWrap.wrap, - justifyContent.start, - alignItems.center, - alignContent.spaceAround - ) - - val distributionChild = style( - padding(10.px), - margin(5.px) - ) - - val boxedLink = style( - Font.bold, - width(120 px), - height(120 px), - padding(20 px), - margin(20 px) - ) - - - val notificationArea = style( - position.fixed, - top(40 px), - right(40 px), - zIndex(2000) - ) - - val notification = style( - padding(20 px), - border(1 px,solid,red), - backgroundColor.white - ) - - val headerLogo = style( - height(40 px), - maxWidth( 100 %%), - marginTop(-10 px), - marginBottom(-5 px), - marginLeft(0 px), - marginRight(10 px) - ) - - val headerTitle = style( - fontSize(20 px), - //color.rgba(255,255,255,0.8), - //text-shadow: 0px 3px 3px #006268, 0 0px 0px #fff, 0px 3px 3px #006268; - ) - - val linkHeaderFooter = style( - textDecorationLine.none.important, - unsafeChild("span") ( - textDecorationLine.none.important, - &.hover( - textDecorationLine.none.important, - color(conf.colors.link) - ) - ), - &.hover( - textDecorationLine.none.important, - color(conf.colors.link) - ), - color(conf.colors.link), - textTransform.uppercase, - Font.bold, - cursor.pointer, - padding.horizontal(2.px) - ) - - - - val fullHeightMax = style( - height :=! "calc(100vh - 150px)", - media.maxWidth(600 px)( - height :=! "calc(100vh - 110px)", - //paddingBottom(70 px) - ), - overflow.auto - ) - - val fullHeight = style( - height :=! "calc(100vh - 105px)", - media.maxWidth(600 px)( - height :=! "calc(100vh - 53px)", - ), - overflow.auto, - width(100.%%) - ) - - val loading = style( - position.fixed, - top.`0`, - left.`0`, - width(100.%%), - height(100.%%), - backgroundColor(rgba(0,0,0,0.5)), - paddingTop(200 px), - textAlign.center, - color.white, - fontSize(20 px), - zIndex(9999) - ) - - val noMargin = style( - margin.`0` - ) - - val subform = style( - marginTop(conf.childProps.marginTopSize px), - padding(conf.childProps.paddingSize px), - border(conf.childProps.borderSize px,solid, conf.childProps.borderColor), - backgroundColor(conf.childProps.backgroundColor), - overflow.hidden - ) - - val childTable = style( - backgroundColor(conf.childProps.backgroundColor), - border(conf.childProps.borderSize px,solid, conf.childProps.borderColor), - width(100.%%), - overflow.hidden - ) - - val childTableTr = style( - border(conf.childProps.borderSize px,solid, conf.childProps.borderColor), - borderBottom.`0` - ) - - val childTableTd = style( - fontSize(conf.smallCellsSize px), - padding(5.px) - ) - - val childTableHeader = style( - Font.bold - ) - - val childTableAction = style( - width(10.px) - ) - - val childFormTableTr = style( - borderTop.`0` - ) - - val childFormTableTd = style( - StyleConstants.defaultFontSize - ) - - val boxIconButton = style( - Font.regular, - whiteSpace.nowrap, - padding(3 px, 10 px), - fontSize(16 px), - margin(3 px, 0 px), - border(0 px), - color(conf.colors.mainLink), - backgroundColor.transparent, - unsafeChild("svg") ( - transform := "scale(1.5)" - ), - &.hover( - color(white), - backgroundColor(conf.colors.main) - ), - &.attrExists("disabled") ( - backgroundColor(lightgray), - color(gray), - borderColor(gray) - ) - ) - - val boxIconButtonDanger = style( - Font.regular, - whiteSpace.nowrap, - padding(7 px, 15 px), - fontSize(16 px), - margin(3 px, 0 px), - border(0 px), - color(conf.colors.danger), - backgroundColor.transparent, - unsafeChild("svg") ( - transform := "scale(1.5)" - ), - &.hover( - color(white), - backgroundColor(conf.colors.danger) - ), - &.attrExists("disabled") ( - backgroundColor(lightgray), - color(gray), - borderColor(gray) - ) - ) - - - val boxButton = style( - Font.regular, - whiteSpace.nowrap, - height.auto, - padding(7 px, 15 px), - fontSize(14 px), - minWidth(25 px), - textAlign.center, - lineHeight(16 px), - margin(9 px, 5 px, 9 px, 0 px), - border.`0`, - color(conf.colors.mainLink), - backgroundColor(white), - cursor.pointer, - &.hover( - color(white), - backgroundColor(conf.colors.main), - opacity(0.5), - ), - &.attrExists("disabled") ( - backgroundColor.transparent, - color(gray), - cursor.default - //borderColor(gray) - ) - ) - - val boxNavigationLabel = style( - textAlign.center, - lineHeight(26 px), - fontSize(12 px) - ) - - val spacedList = style( - unsafeChild("li") ( - marginTop(10 px), - marginBottom(10 px) - ) - ) - - - - val boxButtonImportant = style( - Font.regular, - whiteSpace.nowrap, - height.auto, - padding(7 px, 15 px), - fontSize(14 px), - lineHeight(16 px), - margin(9 px, 5 px, 9 px, 0 px), - border.`0`, - boxShadow := "0px 0px 2px #555", + ) + + val mobileBoxAction = style( + mobileOnly, + boxShadow := "0px 0px 2px #555", + backgroundColor(conf.colors.main), + color(conf.colors.mainText), + borderRadius(50 px), + position.fixed, + right(20 px), + bottom(20 px), + height(50 px), + width(50 px), + border.`0`, + fontSize(20 px), + zIndex(5) + ) + + val adminFormEditAction = style( + color(conf.colors.main), + backgroundColor.transparent, + opacity(0.6), + &.hover( backgroundColor(conf.colors.main), color(conf.colors.mainText), - &.hover( - backgroundColor.white, - color(conf.colors.mainLink), - borderColor(conf.colors.mainLink), - boxShadow := "0px 0px 5px #555" - ) - ) - - val boxButtonDanger = style( - Font.regular, - whiteSpace.nowrap, - height.auto, - padding(7 px, 15 px), - fontSize(14 px), - lineHeight(16 px), - margin(9 px, 5 px, 9 px, 0 px), - border.`0`, - boxShadow := "0px 0px 2px #555", - backgroundColor(conf.colors.danger), - color.white, - &.hover( - backgroundColor.white, - color(conf.colors.danger) - ) - ) - - val popupButton = style( - inputDefaultWidth, - media.maxWidth(600 px)( - width(100 %%) - ), - borderStyle.solid, - borderWidth(0 px,0 px,1 px,0 px), - borderRadius.`0`, - borderColor(Colors.GreySemi), - minHeight(23 px), - backgroundColor.transparent, - cursor.pointer, - &.focus( - inputHighlight - ), - &.hover( - inputHighlight - ) - ) - - val popupEntiresList = style( - overflowY.auto, - maxHeight(70.vh) - ) - - val fullWidth = style( - width(100 %%).important - ) - - val maxFullWidth = style( - maxWidth(100 %%).important - ) - - val filterTableSelect = style( - border.`0`, - media.maxWidth(600 px)( - height(20 px), - fontSize(11 px) - ), - ) - - - - val imageThumb = style( - height.auto, - maxWidth(100 %%) - ) - - val noBullet = style( - listStyleType := "none", - paddingLeft.`0`, - fontSize(12 px) - ) - - val navigationArea = style( - paddingLeft(20 px), - paddingRight(20 px), - paddingTop(5 px), - paddingBottom(5 px) - ) - - val navigatorArea = style( - width(260 px), - textAlign.right - ) - - val noMobile = style( - media.maxWidth(600 px)( - display.none - ) - ) - - val showFooterActionOnMobile = style( - unsafeChild("#footerActions")( - display.block.important - ) - ) - - val mobileOnly = style( + opacity(1), + ), + borderRadius(50 px), + position.absolute, + right(10 px), + marginTop(-35 px), + height(30 px), + width(30 px), + border.`0`, + fontSize(15 px), + zIndex(5) + ) + + val showHide = style( + overflow.hidden, + opacity(0), + transition := "opacity 200ms ease-in-out", + unsafeExt(_ + ".hide") ( display.none, - media.maxWidth(600 px)( - display.block - ) - ) - - val mobileFooter = style( - media.maxWidth(600 px)( - unsafeChild("button")( - (width :=! "calc(100vw - 20px)").important, - height(35 px).important - ) - ) - ) - - val mobileBoxAction = style( - mobileOnly, - boxShadow := "0px 0px 2px #555", - backgroundColor(conf.colors.main), - color(conf.colors.mainText), - borderRadius(50 px), - position.fixed, - right(20 px), - bottom(20 px), - height(50 px), - width(50 px), - border.`0`, - fontSize(20 px), - zIndex(5) - ) - - val adminFormEditAction = style( - color(conf.colors.main), - backgroundColor.transparent, - opacity(0.6), - &.hover( - backgroundColor(conf.colors.main), - color(conf.colors.mainText), - opacity(1), - ), - borderRadius(50 px), - position.absolute, - right(10 px), - marginTop(-35 px), - height(30 px), - width(30 px), - border.`0`, - fontSize(15 px), - zIndex(5) - ) - - val showHide = style( - overflow.hidden, - opacity(0), + transition := "opacity 0ms", + ), + unsafeExt(_ + ".show") ( + opacity(1), transition := "opacity 200ms ease-in-out", - unsafeExt(_ + ".hide") ( - display.none, - transition := "opacity 0ms", - ), - unsafeExt(_ + ".show") ( - opacity(1), - transition := "opacity 200ms ease-in-out", - ), - unsafeExt(_ + ".close") ( - display.none - ) - ) - - val mobileBoxActionPanel = style( - boxShadow := "0px 0px 2px #555", - backgroundColor(conf.colors.main), - color(conf.colors.mainText), - position.fixed, - left(0 px), - bottom(0 px), - height.auto, - width(100 %%), - border.`0`, - fontSize(20 px), - zIndex(21), - padding(20 px), - unsafeChild("button") ( - backgroundColor(conf.colors.main).important, - color(conf.colors.mainText).important, - textTransform.uppercase - ) - ) - - val mobileMenu = style( - position.absolute, - padding(10 px), - top(49 px), - left.`0`, - width(100 %%), - backgroundColor(conf.colors.main), - zIndex(10), - textAlign.right, - unsafeChild("div") ( - margin.vertical(10 px) - ), - unsafeChild("a") ( - fontSize(18 px), - color(conf.colors.link) - ), - unsafeChild("hr") ( - marginTop(2 rem), - border.`0` - ), - boxShadow := "0px 4px 4px #bbb" - ) - - val hrThin = style( - marginTop(15 px), - marginBottom(10 px), + ), + unsafeExt(_ + ".close") ( + display.none + ) + ) + + val mobileBoxActionPanel = style( + boxShadow := "0px 0px 2px #555", + backgroundColor(conf.colors.main), + color(conf.colors.mainText), + position.fixed, + left(0 px), + bottom(0 px), + height.auto, + width(100 %%), + border.`0`, + fontSize(20 px), + zIndex(21), + padding(20 px), + unsafeChild("button") ( + backgroundColor(conf.colors.main).important, + color(conf.colors.mainText).important, + textTransform.uppercase + ) + ) + + val mobileMenu = style( + position.absolute, + padding(10 px), + top(49 px), + left.`0`, + width(100 %%), + backgroundColor(conf.colors.main), + zIndex(10), + textAlign.right, + unsafeChild("div") ( + margin.vertical(10 px) + ), + unsafeChild("a") ( + fontSize(18 px), + color(conf.colors.link) + ), + unsafeChild("hr") ( + marginTop(2 rem), border.`0` - ) - - val labelRequired = style( - Font.bold - ) - - val notNullable = style( - // borderColor(StyleConstants.Colors.orange), - // borderStyle.solid, - // borderWidth(2 px) - ) - - val smallLabelRequired = style( - fontSize(conf.requiredFontSize.px), - color(conf.colors.danger) - ) - - val labelNonRequred = style( - Font.bold - ) - - val editor = style( - inputDefaultWidth, - media.maxWidth(600 px)( - width(100 %%) - ), - float.right, - borderStyle.solid, - borderWidth(1 px), - borderRadius.`0`, - borderColor(Colors.GreySemi), - marginBottom(10 px) - ) - - val controlButtons = style( - display.flex, - flexWrap.wrap, - alignItems.center, - width(100.%%), - backgroundColor(Colors.GreyExtra), - unsafeRoot("svg") ( - marginTop(-2.px) - ), - ) - - val controlButtonsBottom = style( - borderBottomStyle.solid, - borderBottomWidth(1 px), - borderBottomColor(conf.colors.main) - ) - - val controlInputs = style( - display.flex, - flexWrap.wrap, - alignItems.center, - width(100.%%), - backgroundColor(Colors.GreyExtra), - unsafeChild("input") ( - margin.horizontal(5 px), - width.auto, - flexGrow(1), - alignItems.center - ), - unsafeChild("select") ( - margin.horizontal(5 px), - flexGrow(1), - alignItems.center - ), - ) - - val mapPopup = style( - backgroundColor.white, - borderStyle.solid, - borderWidth(1 px), - borderColor(conf.colors.main), - padding.vertical(5 px), - padding.horizontal(10 px), - marginLeft(5 px), - marginTop(5 px), - unsafeChild("ul") ( - paddingLeft.`0`, - marginBottom.`0` - ), - unsafeChild("li") ( - listStyle := "none", - padding(5 px), - &.hover( - color.white, - backgroundColor(conf.colors.main) - ) - ) - ) - - val mapSearch = style( - display.flex, - backgroundColor(Colors.GreyExtra), - unsafeChild("input") ( - width(100.%%), - margin(5 px), - backgroundColor.white - ), - height(33 px), - marginBottom(-33 px), - zIndex(2), - position.relative - ) - - val mapInfo = style( - color(Colors.Grey), - padding.horizontal(10 px), - padding.vertical(5 px), - backgroundColor(Colors.GreyExtra), - ) - - val mapInfoChild = style( - display.flex, - unsafeChild("input") ( - width(100.%%), - margin(5 px), - backgroundColor.white - ) - ) - - val mapGeomAction = style( - display.flex, - flexDirection.column - ) - - val mapButton = style( - color(conf.colors.main), - backgroundColor(Colors.GreyExtra), - padding(5 px,10 px), - border.`0`, - unsafeRoot(".active")( - backgroundColor(conf.colors.main), - color(conf.colors.mainText) - ), - &.attrExists("disabled") ( - color(gray), - backgroundColor(Colors.GreyExtra), - ) - ) - - val mapLayerSelect = style( - marginLeft(10 px), - backgroundColor.transparent, + ), + boxShadow := "0px 4px 4px #bbb" + ) + + val hrThin = style( + marginTop(15 px), + marginBottom(10 px), + border.`0` + ) + + val labelRequired = style( + Font.bold + ) + + val notNullable = style( +// borderColor(StyleConstants.Colors.orange), +// borderStyle.solid, +// borderWidth(2 px) + ) + + val smallLabelRequired = style( + fontSize(conf.requiredFontSize.px), + color(conf.colors.danger) + ) + + val labelNonRequred = style( + Font.bold + ) + + val editor = style( + inputDefaultWidth, + media.maxWidth(600 px)( + width(100 %%) + ), + float.right, + borderStyle.solid, + borderWidth(1 px), + borderRadius.`0`, + borderColor(Colors.GreySemi), + marginBottom(10 px) + ) + + val controlButtons = style( + display.flex, + flexWrap.wrap, + alignItems.center, + width(100.%%), + backgroundColor(Colors.GreyExtra), + unsafeRoot("svg") ( + marginTop(-2.px) + ), + ) + + val controlButtonsBottom = style( + borderBottomStyle.solid, + borderBottomWidth(1 px), + borderBottomColor(conf.colors.main) + ) + + val controlInputs = style( + display.flex, + flexWrap.wrap, + alignItems.center, + width(100.%%), + backgroundColor(Colors.GreyExtra), + unsafeChild("input") ( + margin.horizontal(5 px), + width.auto, + flexGrow(1), + alignItems.center + ), + unsafeChild("select") ( + margin.horizontal(5 px), + flexGrow(1), + alignItems.center + ), + ) + + val mapPopup = style( + backgroundColor.white, + borderStyle.solid, + borderWidth(1 px), + borderColor(conf.colors.main), + padding.vertical(5 px), + padding.horizontal(10 px), + marginLeft(5 px), + marginTop(5 px), + unsafeChild("ul") ( + paddingLeft.`0`, + marginBottom.`0` + ), + unsafeChild("li") ( + listStyle := "none", + padding(5 px), &.hover( - backgroundColor.transparent - ), - &.focus( - backgroundColor.transparent - ), - unsafeChild("option") ( - color.black - ) - ) - - val mapLayerSelectFullscreen = style( - position.absolute, - right.`0`, - bottom(36 px), - backgroundColor.rgba(255,255,255,0.8), - padding.horizontal(10 px), - padding.vertical(5 px), - unsafeChild("select") { - width(90 %%) - }, - zIndex(1), - fontSize(11 px), - lineHeight(22 px), - media.maxWidth(600 px)( - bottom(100 px) + color.white, + backgroundColor(conf.colors.main) ) ) + ) - val mapFullscreen = style( - position.fixed, - top(50 px), - left.`0`, - width(100 %%), - backgroundColor.white, - zIndex(10), - height :=! "calc(100vh - 105px)" - ) - - val simpleInputBottomBorder = style( - margin.`0`, - padding.`0`, + val mapSearch = style( + display.flex, + backgroundColor(Colors.GreyExtra), + unsafeChild("input") ( width(100.%%), - height(100.%%), - float.none.important, - ) - - val simpleInput = style( - margin.`0`, - padding.`0`, + margin(5 px), + backgroundColor.white + ), + height(33 px), + marginBottom(-33 px), + zIndex(2), + position.relative + ) + + val mapInfo = style( + color(Colors.Grey), + padding.horizontal(10 px), + padding.vertical(5 px), + backgroundColor(Colors.GreyExtra), + ) + + val mapInfoChild = style( + display.flex, + unsafeChild("input") ( width(100.%%), - height(100.%%), - float.none.important, - borderWidth(0 px,0 px,1 px,0 px) - ) - - val simpleCheckbox = style( - width(13 px), - height(13 px), - float.none.important, - borderWidth.`0`, - border.`0`, - margin.auto, - display.flex, - alignSelf.center - ) - - val centredContent = style( - maxWidth(900.px), - marginTop(20 px), - marginLeft.auto, - marginTop(20 px), - marginRight.auto, + margin(5 px), + backgroundColor.white ) + ) + val mapGeomAction = style( + display.flex, + flexDirection.column + ) - val margin0Auto = style( - margin(`0`,auto) + val mapButton = style( + color(conf.colors.main), + backgroundColor(Colors.GreyExtra), + padding(5 px,10 px), + border.`0`, + unsafeRoot(".active")( + backgroundColor(conf.colors.main), + color(conf.colors.mainText) + ), + &.attrExists("disabled") ( + color(gray), + backgroundColor(Colors.GreyExtra), ) - - - val tristateCheckBox = style( - backgroundColor(c"#fff"), - borderStyle.solid, - borderWidth(1 px), - borderColor(Colors.GreySemi), - cursor.pointer, - display.inlineBlock, + ) + + val mapLayerSelect = style( + marginLeft(10 px), + backgroundColor.transparent, + &.hover( + backgroundColor.transparent + ), + &.focus( + backgroundColor.transparent + ), + unsafeChild("option") ( + color.black + ) + ) + + val mapLayerSelectFullscreen = style( + position.absolute, + right.`0`, + bottom(36 px), + backgroundColor.rgba(255,255,255,0.8), + padding.horizontal(10 px), + padding.vertical(5 px), + unsafeChild("select") { + width(90 %%) + }, + zIndex(1), + fontSize(11 px), + lineHeight(22 px), + media.maxWidth(600 px)( + bottom(100 px) + ) + ) + + val mapFullscreen = style( + position.fixed, + top(50 px), + left.`0`, + width(100 %%), + backgroundColor.white, + zIndex(10), + height :=! "calc(100vh - 105px)" + ) + + val simpleInputBottomBorder = style( + margin.`0`, + padding.`0`, + width(100.%%), + height(100.%%), + float.none.important, + ) + + val simpleInput = style( + margin.`0`, + padding.`0`, + width(100.%%), + height(100.%%), + float.none.important, + borderWidth(0 px,0 px,1 px,0 px) + ) + + val simpleCheckbox = style( + width(13 px), + height(13 px), + float.none.important, + borderWidth.`0`, + border.`0`, + margin.auto, + display.flex, + alignSelf.center + ) + + val centredContent = style( + maxWidth(900.px), + marginTop(20 px), + marginLeft.auto, + marginTop(20 px), + marginRight.auto, + ) + + + val margin0Auto = style( + margin(`0`,auto) + ) + + + val tristateCheckBox = style( + backgroundColor(c"#fff"), + borderStyle.solid, + borderWidth(1 px), + borderColor(Colors.GreySemi), + cursor.pointer, + display.inlineBlock, + height(20 px), + width(20 px), + marginLeft(10 px), + textAlign.center, + verticalAlign.middle, + unsafeChild("svg")( + marginTop(-4 px), + svgStroke(c"#fff") + ) + ) + + val tristatePositive = style( + backgroundColor(c"#4aca65"), + borderColor(c"#43b45b") + ) + + val tristateNegative = style( + backgroundColor(c"#dc4e4e"), + borderColor(c"#c74545") + ) + + val label50 = style( + width((100-conf.inputPercentage) %%), + display.inlineBlock + ) + + val inputRightLabel = style( + width((100-conf.inputPercentage) %%), + padding.horizontal(10 px), + textAlign.right + ) + + val childAddButton = style( + lineHeight(40 px), + paddingLeft(10 px), + fontSize(14 px), + display.inlineBlock, + unsafeChild("svg")( + color(conf.colors.main), height(20 px), width(20 px), - marginLeft(10 px), - textAlign.center, - verticalAlign.middle, - unsafeChild("svg")( - marginTop(-4 px), - svgStroke(c"#fff") - ) - ) - - val tristatePositive = style( - backgroundColor(c"#4aca65"), - borderColor(c"#43b45b") - ) - - val tristateNegative = style( - backgroundColor(c"#dc4e4e"), - borderColor(c"#c74545") - ) - - val label50 = style( - width((100-conf.inputPercentage) %%), - display.inlineBlock - ) - - val inputRightLabel = style( - width((100-conf.inputPercentage) %%), - padding.horizontal(10 px), - textAlign.right - ) - - val childAddButton = style( - lineHeight(40 px), - paddingLeft(10 px), - fontSize(14 px), - display.inlineBlock, - unsafeChild("svg")( - color(conf.colors.main), - height(20 px), - width(20 px), - marginRight(5 px) - ) - ) - - val twoListContainer = style( - backgroundColor(Colors.GreyExtra), - padding(10 px), - margin(5 px), - overflow.auto, - maxHeight(500 px) - ) - - val twoListLeft = style( - unsafeChild(".right") ( - display.none - ) + marginRight(5 px) + ) + ) + + val twoListContainer = style( + backgroundColor(Colors.GreyExtra), + padding(10 px), + margin(5 px), + overflow.auto, + maxHeight(500 px) + ) + + val twoListLeft = style( + unsafeChild(".right") ( + display.none + ) + ) + + val twoListRight = style( + unsafeChild(".left") ( + display.none + ) + ) + + val twoListElement = style( + lineHeight(31 px), + border.solid, + borderColor(conf.colors.main), + borderWidth(2 px), + borderRadius(5 px), + textAlign.center, + fontWeight.bold + ) + + val twoListButton = style( + lineHeight(21 px), + padding(3 px,10 px, 7 px,15 px), + fontSize(14 px), + display.inlineBlock, + border.`0`, + color(conf.colors.main), + &.hover( + backgroundColor(conf.colors.main), + color.white ) + ) - val twoListRight = style( - unsafeChild(".left") ( - display.none - ) - ) + val childAddButtonBoxed = style( + width(100 %%), + border(conf.childProps.borderSize px,solid, conf.childProps.borderColor), + backgroundColor(conf.childProps.backgroundColor), + marginBottom(20 px) + ) - val twoListElement = style( - lineHeight(31 px), - border.solid, - borderColor(conf.colors.main), - borderWidth(2 px), - borderRadius(5 px), - textAlign.center, - fontWeight.bold + val childRemoveButton = style( + lineHeight(32 px), + fontSize(14 px), + unsafeChild("svg")( + color(conf.colors.danger), + height(20 px), + width(20 px), + marginRight(5 px) ) + ) - val twoListButton = style( - lineHeight(21 px), - padding(3 px,10 px, 7 px,15 px), - fontSize(14 px), - display.inlineBlock, - border.`0`, + val childMoveButton = style( + lineHeight(32 px), + fontSize(18 px), + unsafeChild("svg")( color(conf.colors.main), - &.hover( - backgroundColor(conf.colors.main), - color.white - ) - ) - - val childAddButtonBoxed = style( - width(100 %%), - border(conf.childProps.borderSize px,solid, conf.childProps.borderColor), - backgroundColor(conf.childProps.backgroundColor), - marginBottom(20 px) - ) - - val childRemoveButton = style( - lineHeight(32 px), - fontSize(14 px), - unsafeChild("svg")( - color(conf.colors.danger), - height(20 px), - width(20 px), - marginRight(5 px) - ) - ) - - val childMoveButton = style( - lineHeight(32 px), - fontSize(18 px), - unsafeChild("svg")( - color(conf.colors.main), - height(20 px), - width(20 px), - marginRight(5 px) - ) - ) - - val childDuplicateButton = style( - lineHeight(32 px), - fontSize(14 px), - unsafeChild("svg")( - color(conf.colors.main), - height(20 px), - width(20 px), - marginRight(5 px) - ) + height(20 px), + width(20 px), + marginRight(5 px) ) + ) - val dropFileZone = style( - minHeight(50 px), - width(100 %%), - borderColor(Colors.Trasparent), - borderStyle.solid, + val childDuplicateButton = style( + lineHeight(32 px), + fontSize(14 px), + unsafeChild("svg")( + color(conf.colors.main), + height(20 px), + width(20 px), + marginRight(5 px) + ) + ) + + val dropFileZone = style( + minHeight(50 px), + width(100 %%), + borderColor(Colors.Trasparent), + borderStyle.solid, + borderWidth(1 px), + &.hover( + borderStyle.dashed, + borderColor(Colors.Grey), borderWidth(1 px), - &.hover( - borderStyle.dashed, - borderColor(Colors.Grey), - borderWidth(1 px), - ), - display.flex, - flexDirection.column, - justifyContent.center, - alignItems.center, - margin.vertical(10 px), - unsafeChild("p")( - color(Colors.Grey), - fontSize(11 px), - Font.bold - ) - ) - - val dropFileZoneDropping = style( - borderColor(conf.colors.main), - backgroundColor.rgba(0,0,0,0.3), - unsafeChild("p")( - color(conf.colors.main) - ) - ) - - val sidebarButton = style( - position.fixed, - top(5 px), - left(5 px), - zIndex(101), - media.maxWidth(600 px)( - display.none - ) - - ) - - val editableTableEditButton = style( - fontSize(12 px), - marginLeft(2 px), - unsafeChild("svg") ( - color.gray, - ), - &.hover( - unsafeChild("svg") ( - color(conf.colors.main) - ) - ) - ) - - val editableTableMulti = style( - minHeight(20 px), - padding.`0` + ), + display.flex, + flexDirection.column, + justifyContent.center, + alignItems.center, + margin.vertical(10 px), + unsafeChild("p")( + color(Colors.Grey), + fontSize(11 px), + Font.bold ) + ) - val xyButtonOnTable = style( - media.maxWidth(600 px)( - width(100.px) - ) + val dropFileZoneDropping = style( + borderColor(conf.colors.main), + backgroundColor.rgba(0,0,0,0.3), + unsafeChild("p")( + color(conf.colors.main) ) + ) - val queryBuilderContainer = style( - unsafeChild("input")( - float.none, - width(200 px) - ), - unsafeChild("select")( - float.none, - width(200 px) - ) - + val sidebarButton = style( + position.fixed, + top(5 px), + left(5 px), + zIndex(101), + media.maxWidth(600 px)( + display.none ) - val adminConditionBlock = style( - margin(20 px), - display.flex, - justifyContent.spaceBetween, - alignItems.center, - unsafeChild("select")( - width(300 px) - ), - unsafeChild("input")( - width(300 px) - ) - ) + ) - val adminCreateForm = style( - display.flex, - width(400 px), - justifyContent.spaceBetween, - alignItems.baseline, - unsafeChild("select")( - width(300 px) + val editableTableEditButton = style( + fontSize(12 px), + marginLeft(2 px), + unsafeChild("svg") ( + color.gray, + ), + &.hover( + unsafeChild("svg") ( + color(conf.colors.main) ) ) - - val thOver = style( - outlineStyle.dashed, - outlineWidth(1 px), - outlineOffset(-1 px), - outlineColor.lightgray, - backgroundColor.rgba(0,0,0,0.1) - ) + ) + + val editableTableMulti = style( + minHeight(20 px), + padding.`0` + ) + + val xyButtonOnTable = style( + media.maxWidth(600 px)( + width(100.px) + ) + ) + + val queryBuilderContainer = style( + unsafeChild("input")( + float.none, + width(200 px) + ), + unsafeChild("select")( + float.none, + width(200 px) + ) + + ) + + val adminConditionBlock = style( + margin(20 px), + display.flex, + justifyContent.spaceBetween, + alignItems.center, + unsafeChild("select")( + width(300 px) + ), + unsafeChild("input")( + width(300 px) + ) + ) + + val adminCreateForm = style( + display.flex, + width(400 px), + justifyContent.spaceBetween, + alignItems.baseline, + unsafeChild("select")( + width(300 px) + ) + ) + + val thOver = style( + outlineStyle.dashed, + outlineWidth(1 px), + outlineOffset(-1 px), + outlineColor.lightgray, + backgroundColor.rgba(0,0,0,0.1) + ) // // val mapPopup = style( @@ -1671,19 +1676,18 @@ object GlobalStyleFactory{ // ) // - // val fixedHeader = style( - // unsafeRoot("tbody")( - //// display.block, - // overflow.auto, - // maxHeight :=! "calc(100vh - 330px)", - //// height(200 px), - //// width(100 %%) - // ), - // unsafeRoot("thead")( - //// display.block - // ) - // ) - - - } -} \ No newline at end of file +// val fixedHeader = style( +// unsafeRoot("tbody")( +//// display.block, +// overflow.auto, +// maxHeight :=! "calc(100vh - 330px)", +//// height(200 px), +//// width(100 %%) +// ), +// unsafeRoot("thead")( +//// display.block +// ) +// ) + + +} diff --git a/client/src/main/scala/ch/wsl/box/client/views/EntitiesView.scala b/client/src/main/scala/ch/wsl/box/client/views/EntitiesView.scala index 410be939..063d922f 100755 --- a/client/src/main/scala/ch/wsl/box/client/views/EntitiesView.scala +++ b/client/src/main/scala/ch/wsl/box/client/views/EntitiesView.scala @@ -7,7 +7,6 @@ package ch.wsl.box.client.views import ch.wsl.box.client.routes.Routes import ch.wsl.box.client.services.{BrowserConsole, ClientConf, Labels, Navigate, REST, UI} import ch.wsl.box.client.styles.BootstrapCol -import ch.wsl.box.client.styles.GlobalStyleFactory.GlobalStyles import ch.wsl.box.client.{EntitiesState, EntityFormState, EntityTableState} import ch.wsl.box.model.shared.EntityKind import io.udash._ diff --git a/client/src/main/scala/ch/wsl/box/client/views/components/widget/admin/LayoutWidget.scala b/client/src/main/scala/ch/wsl/box/client/views/components/widget/admin/LayoutWidget.scala index 45ecac7f..00d5faba 100644 --- a/client/src/main/scala/ch/wsl/box/client/views/components/widget/admin/LayoutWidget.scala +++ b/client/src/main/scala/ch/wsl/box/client/views/components/widget/admin/LayoutWidget.scala @@ -2,7 +2,6 @@ package ch.wsl.box.client.views.components.widget.admin import ch.wsl.box.client.services.{BrowserConsole, ClientConf} import ch.wsl.box.client.styles.BootstrapCol -import ch.wsl.box.client.styles.GlobalStyleFactory.GlobalStyles import ch.wsl.box.client.styles.constants.StyleConstants.Colors import ch.wsl.box.client.views.components.widget.{ComponentWidgetFactory, Widget, WidgetParams} import ch.wsl.box.model.shared.{DistributedLayout, JSONField, JSONFieldTypes, JSONQuery, Layout, LayoutBlock, StackedLayout, WidgetsNames} diff --git a/client/src/main/scala/ch/wsl/box/client/views/components/widget/array/TwoListWidget.scala b/client/src/main/scala/ch/wsl/box/client/views/components/widget/array/TwoListWidget.scala index 63738c35..6becb324 100644 --- a/client/src/main/scala/ch/wsl/box/client/views/components/widget/array/TwoListWidget.scala +++ b/client/src/main/scala/ch/wsl/box/client/views/components/widget/array/TwoListWidget.scala @@ -2,7 +2,6 @@ package ch.wsl.box.client.views.components.widget.array import ch.wsl.box.client.services.{BrowserConsole, ClientConf} import ch.wsl.box.client.styles.{BootstrapCol, Icons} -import ch.wsl.box.client.styles.GlobalStyleFactory.GlobalStyles import ch.wsl.box.client.styles.constants.StyleConstants.Colors import ch.wsl.box.client.views.components.widget.lookup.LookupWidget import ch.wsl.box.client.views.components.widget.{ComponentWidgetFactory, Widget, WidgetParams} From a34b6064572d16d33b183b0642c67a6c79abe1b6 Mon Sep 17 00:00:00 2001 From: Andrea Minetti Date: Tue, 20 Jan 2026 15:08:07 +0100 Subject: [PATCH 06/42] Style can be costumized in external projects --- .../main/scala/ch/wsl/box/client/Main.scala | 3 +- .../main/scala/ch/wsl/box/client/Module.scala | 4 +- .../wsl/box/client/services/ClientConf.scala | 2 - .../client/services/DataAccessObject.scala | 3 + .../box/client/services/ServiceModule.scala | 2 + .../client/services/impl/DaoLocalDbImpl.scala | 6 + .../services/impl/DaoPassthroughImpl.scala | 6 + .../ch/wsl/box/client/styles/BoxStyle.scala | 150 ++++++++++++++++++ .../wsl/box/client/styles/GlobalStyles.scala | 4 +- .../ch/wsl/box/client/views/RootView.scala | 16 +- .../views/components/BoxMainLayout.scala | 29 ++++ .../client/views/components/MainLayout.scala | 8 + 12 files changed, 213 insertions(+), 20 deletions(-) create mode 100644 client/src/main/scala/ch/wsl/box/client/styles/BoxStyle.scala create mode 100644 client/src/main/scala/ch/wsl/box/client/views/components/BoxMainLayout.scala create mode 100644 client/src/main/scala/ch/wsl/box/client/views/components/MainLayout.scala diff --git a/client/src/main/scala/ch/wsl/box/client/Main.scala b/client/src/main/scala/ch/wsl/box/client/Main.scala index a8bd36c0..c1f6b329 100644 --- a/client/src/main/scala/ch/wsl/box/client/Main.scala +++ b/client/src/main/scala/ch/wsl/box/client/Main.scala @@ -1,6 +1,7 @@ package ch.wsl.box.client import ch.wsl.box.client.db.DB +import ch.wsl.box.client.services.impl.DaoLocalDbImpl import ch.wsl.box.client.services.{BrowserConsole, ClientConf, Labels, Notification, REST, UI} import ch.wsl.box.client.styles.{AutocompleteStyles, ChoicesStyles, OpenLayersStyles} import ch.wsl.box.client.utils._ @@ -67,7 +68,7 @@ object Main extends Logging { } uiConf <- services.rest.ui() labels <- services.rest.labels(services.clientSession.lang()) - _ <- if(ClientConf.localDb) DB.init() else Future.successful() + _ <- if(services.data.name == DaoLocalDbImpl.name) DB.init() else Future.successful() } yield { Logger.root.clearHandlers().clearModifiers().withHandler(minimumLevel = Some(ClientConf.loggerLevel)).replace() diff --git a/client/src/main/scala/ch/wsl/box/client/Module.scala b/client/src/main/scala/ch/wsl/box/client/Module.scala index 3186f7e3..c15d27db 100644 --- a/client/src/main/scala/ch/wsl/box/client/Module.scala +++ b/client/src/main/scala/ch/wsl/box/client/Module.scala @@ -2,6 +2,7 @@ package ch.wsl.box.client import ch.wsl.box.client.services.{ClientSession, DataAccessObject, HttpClient, Navigator, Notification, NotificationChannel, NotificationWebSocket, REST} import ch.wsl.box.client.services.impl.{DaoLocalDbImpl, DaoPassthroughImpl, HttpClientImpl, RestImpl} import ch.wsl.box.client.styles.{BoxStyle, BoxStyleFactory, GlobalStyleFactory, TestStyleFactory} +import ch.wsl.box.client.views.components.{BoxMainLayout, MainLayout} import ch.wsl.box.model.shared.AvailableUIModule import ch.wsl.typings.std.WebAssembly.Global import wvlet.airframe._ @@ -21,6 +22,7 @@ object Module { .bind[Navigator].toEagerSingleton .bind[NotificationChannel].to[NotificationWebSocket] .bind[BoxStyleFactory].to[GlobalStyleFactory] + .bind[MainLayout].to[BoxMainLayout] val prod = newDesign .bind[HttpClient].to[HttpClientImpl] @@ -29,5 +31,5 @@ object Module { .bind[ClientSession].toEagerSingleton .bind[Navigator].toEagerSingleton .bind[NotificationChannel].to[NotificationWebSocket] - + .bind[MainLayout].to[BoxMainLayout] } diff --git a/client/src/main/scala/ch/wsl/box/client/services/ClientConf.scala b/client/src/main/scala/ch/wsl/box/client/services/ClientConf.scala index 5fb63e3e..bfa0cd66 100644 --- a/client/src/main/scala/ch/wsl/box/client/services/ClientConf.scala +++ b/client/src/main/scala/ch/wsl/box/client/services/ClientConf.scala @@ -44,8 +44,6 @@ object ClientConf { case _ => Level.Warn } - def localDb:Boolean = Try(conf("local.db").toBoolean).getOrElse(true) - def version: String = _version def appVersion: String = _appVersion diff --git a/client/src/main/scala/ch/wsl/box/client/services/DataAccessObject.scala b/client/src/main/scala/ch/wsl/box/client/services/DataAccessObject.scala index 51213655..8fdba8fc 100644 --- a/client/src/main/scala/ch/wsl/box/client/services/DataAccessObject.scala +++ b/client/src/main/scala/ch/wsl/box/client/services/DataAccessObject.scala @@ -9,6 +9,9 @@ import scala.concurrent.{ExecutionContext, Future} case class Record(data:Json,local_version:Boolean) trait DataAccessObject { + + def name:String + def get(kind:String, lang:String, entity:String, id:JSONID,public:Boolean = false)(implicit ec:ExecutionContext):Future[Record] def insert(kind:String, lang:String, entity:String, data:Json, public:Boolean)(implicit ec:ExecutionContext): Future[Json] def update(kind:String, lang:String, entity:String, id:JSONID, data:Json,public:Boolean = false)(implicit ec:ExecutionContext):Future[Json] diff --git a/client/src/main/scala/ch/wsl/box/client/services/ServiceModule.scala b/client/src/main/scala/ch/wsl/box/client/services/ServiceModule.scala index c6f0cbdf..78d1ccf7 100644 --- a/client/src/main/scala/ch/wsl/box/client/services/ServiceModule.scala +++ b/client/src/main/scala/ch/wsl/box/client/services/ServiceModule.scala @@ -1,6 +1,7 @@ package ch.wsl.box.client.services import ch.wsl.box.client.styles.BoxStyleFactory +import ch.wsl.box.client.views.components.MainLayout import wvlet.airframe._ trait ServiceModule { @@ -11,4 +12,5 @@ trait ServiceModule { val navigator = bind[Navigator] val notification = bind[NotificationChannel] val style = bind[BoxStyleFactory] + val layout = bind[MainLayout] } diff --git a/client/src/main/scala/ch/wsl/box/client/services/impl/DaoLocalDbImpl.scala b/client/src/main/scala/ch/wsl/box/client/services/impl/DaoLocalDbImpl.scala index 73e5d612..a11388b8 100644 --- a/client/src/main/scala/ch/wsl/box/client/services/impl/DaoLocalDbImpl.scala +++ b/client/src/main/scala/ch/wsl/box/client/services/impl/DaoLocalDbImpl.scala @@ -9,8 +9,14 @@ import io.circe.Json import scala.concurrent.{ExecutionContext, Future} +object DaoLocalDbImpl { + val name = "postgres-local" +} + class DaoLocalDbImpl(rest:REST, clientSession: ClientSession) extends DataAccessObject { + override def name: String = DaoLocalDbImpl.name + override def get(kind: String, lang: String, entity: String, id: JSONID, public: Boolean)(implicit ec: ExecutionContext): Future[Record] = { DB.localRecord.get(LocalRecordKey(id.asString,kind,entity)).flatMap { case Some(value) => Future.successful(Record(value.data,true)) diff --git a/client/src/main/scala/ch/wsl/box/client/services/impl/DaoPassthroughImpl.scala b/client/src/main/scala/ch/wsl/box/client/services/impl/DaoPassthroughImpl.scala index 99c96db5..5db0bd23 100644 --- a/client/src/main/scala/ch/wsl/box/client/services/impl/DaoPassthroughImpl.scala +++ b/client/src/main/scala/ch/wsl/box/client/services/impl/DaoPassthroughImpl.scala @@ -10,8 +10,14 @@ import io.circe.Json import scala.concurrent.{ExecutionContext, Future} +object DaoPassthroughImpl { + val name = "online" +} + class DaoPassthroughImpl(rest:REST, clientSession: ClientSession) extends DataAccessObject { + override def name: String = DaoPassthroughImpl.name + override def get(kind: String, lang: String, entity: String, id: JSONID, public: Boolean)(implicit ec: ExecutionContext): Future[Record] = { rest.get(kind, lang, entity, id, public).map(x => Record(x,false)) } diff --git a/client/src/main/scala/ch/wsl/box/client/styles/BoxStyle.scala b/client/src/main/scala/ch/wsl/box/client/styles/BoxStyle.scala new file mode 100644 index 00000000..d8dd087d --- /dev/null +++ b/client/src/main/scala/ch/wsl/box/client/styles/BoxStyle.scala @@ -0,0 +1,150 @@ +package ch.wsl.box.client.styles + +import scalacss.internal.{Env, Renderer} +import scalacss.internal.mutable.{Register, Settings} +import scalacss.{StyleA, StyleSheet} + +trait BoxStyleFactory { + def build(settings: Settings):BoxStyle +} + +trait BoxStyle { + + val inputHighlight: StyleA + val inputInvalid: StyleA + def global: StyleA + val spaceBetween: StyleA + val flexContainer: StyleA + val sidebarRightContent: StyleA + val sidebar: StyleA + val spaceAfter: StyleA + val navigationBlock: StyleA + val textNoWrap: StyleA + val dataChanged: StyleA + val formTitle: StyleA + val formTitleLight: StyleA + val chipLink: StyleA + val chip: StyleA + val checkboxWidget: StyleA + val jsonMetadataRendered: StyleA + val preformatted: StyleA + val formHeader: StyleA + val dateTimePicker: StyleA + val dateTimePickerFullWidth: StyleA + val tableHeaderFixed: StyleA + val smallCells: StyleA + val tableCellActions: StyleA + val rowStyle: StyleA + val tableHeader: StyleA + val numberCells: StyleA + val textCells: StyleA + val lookupCells: StyleA + val dateCells: StyleA + val noPadding: StyleA + val smallBottomMargin: StyleA + val mediumBottomMargin: StyleA + val subBlock: StyleA + val block: StyleA + val innerBlock: StyleA + val withBorder: StyleA + val removeFlexChild: StyleA + val tableContainer: StyleA + val table: StyleA + val field: StyleA + val fieldHighlight: StyleA + val removeFieldMargin: StyleA + val removeFieldAndBlockMargin: StyleA + val distributionContrainer: StyleA + val distributionChild: StyleA + val boxedLink: StyleA + val notificationArea: StyleA + val notification: StyleA + val headerLogo: StyleA + val headerTitle: StyleA + val linkHeaderFooter: StyleA + val fullHeightMax: StyleA + val fullHeight: StyleA + val loading: StyleA + val noMargin: StyleA + val subform: StyleA + val childTable: StyleA + val childTableTr: StyleA + val childTableTd: StyleA + val childTableHeader: StyleA + val childTableAction: StyleA + val childFormTableTr: StyleA + val childFormTableTd: StyleA + val boxIconButton: StyleA + val boxIconButtonDanger: StyleA + val boxButton: StyleA + val boxNavigationLabel: StyleA + val spacedList: StyleA + val boxButtonImportant: StyleA + val boxButtonDanger: StyleA + val popupButton: StyleA + val popupEntiresList: StyleA + val fullWidth: StyleA + val maxFullWidth: StyleA + val filterTableSelect: StyleA + val imageThumb: StyleA + val noBullet: StyleA + val noMobile:StyleA + val mobileBoxAction:StyleA + val mobileMenu:StyleA + val mobileOnly:StyleA + val showFooterActionOnMobile:StyleA + val adminCreateForm:StyleA + val adminFormEditAction:StyleA + val mapPopup:StyleA + val mapSearch:StyleA + val mapFullscreen:StyleA + val mapLayerSelect:StyleA + val mapLayerSelectFullscreen:StyleA + val mapInfo:StyleA + val mapInfoChild:StyleA + val mapGeomAction:StyleA + val mapButton:StyleA + val controlButtons:StyleA + val controlInputs:StyleA + val controlButtonsBottom:StyleA + val xyButtonOnTable:StyleA + val simpleCheckbox:StyleA + val dropFileZone:StyleA + val dropFileZoneDropping:StyleA + val editableTableEditButton:StyleA + val simpleInputBottomBorder:StyleA + val simpleInput:StyleA + val editor:StyleA + val tristateCheckBox:StyleA + val tristatePositive:StyleA + val tristateNegative:StyleA + val label50:StyleA + val inputRightLabel:StyleA + val notNullable:StyleA + val thOver:StyleA + val childDuplicateButton:StyleA + val childAddButtonBoxed:StyleA + val childAddButton:StyleA + val childRemoveButton:StyleA + val childMoveButton:StyleA + val twoListRight:StyleA + val twoListLeft:StyleA + val twoListContainer:StyleA + val twoListButton:StyleA + val twoListElement:StyleA + val editableTableMulti:StyleA + val queryBuilderContainer:StyleA + val adminConditionBlock:StyleA + val centredContent:StyleA + val smallLabelRequired:StyleA + val labelRequired:StyleA + val labelNonRequred:StyleA + val margin0Auto:StyleA + val mobileFooter:StyleA + val hrThin:StyleA + val mobileBoxActionPanel:StyleA + val sidebarButton:StyleA + val showHide:StyleA + + def render[Out](implicit r: Renderer[Out], env: Env):Out +} diff --git a/client/src/main/scala/ch/wsl/box/client/styles/GlobalStyles.scala b/client/src/main/scala/ch/wsl/box/client/styles/GlobalStyles.scala index 9b809ed9..35c1a17c 100755 --- a/client/src/main/scala/ch/wsl/box/client/styles/GlobalStyles.scala +++ b/client/src/main/scala/ch/wsl/box/client/styles/GlobalStyles.scala @@ -49,7 +49,7 @@ class GlobalStyles(settings:Settings,conf:StyleConf) extends StyleSheet.Inline() backgroundColor(ColorUtils.RGB.fromHex(conf.colors.dangerColor).withTrasparency(0.3)) ) - override val global = style( + override def global = style( unsafeRoot(".col, .col-1, .col-10, .col-11, .col-12, .col-2, .col-3, .col-4, .col-5, .col-6, .col-7, .col-8, .col-9, .col-auto, .col-lg, .col-lg-1, .col-lg-10, .col-lg-11, .col-lg-12, .col-lg-2, .col-lg-3, .col-lg-4, .col-lg-5, .col-lg-6, .col-lg-7, .col-lg-8, .col-lg-9, .col-lg-auto, .col-md, .col-md-1, .col-md-10, .col-md-11, .col-md-12, .col-md-2, .col-md-3, .col-md-4, .col-md-5, .col-md-6, .col-md-7, .col-md-8, .col-md-9, .col-md-auto, .col-sm, .col-sm-1, .col-sm-10, .col-sm-11, .col-sm-12, .col-sm-2, .col-sm-3, .col-sm-4, .col-sm-5, .col-sm-6, .col-sm-7, .col-sm-8, .col-sm-9, .col-sm-auto, .col-xl, .col-xl-1, .col-xl-10, .col-xl-11, .col-xl-12, .col-xl-2, .col-xl-3, .col-xl-4, .col-xl-5, .col-xl-6, .col-xl-7, .col-xl-8, .col-xl-9, .col-xl-auto") ( padding.`0` @@ -1329,7 +1329,7 @@ class GlobalStyles(settings:Settings,conf:StyleConf) extends StyleSheet.Inline() backgroundColor(Colors.GreyExtra), padding(5 px,10 px), border.`0`, - unsafeRoot(".active")( + unsafeExt(_ + ".active")( backgroundColor(conf.colors.main), color(conf.colors.mainText) ), diff --git a/client/src/main/scala/ch/wsl/box/client/views/RootView.scala b/client/src/main/scala/ch/wsl/box/client/views/RootView.scala index 37383fa8..fc5498e5 100755 --- a/client/src/main/scala/ch/wsl/box/client/views/RootView.scala +++ b/client/src/main/scala/ch/wsl/box/client/views/RootView.scala @@ -119,21 +119,8 @@ class RootView(viewModel:ModelProperty[RootViewModel]) extends ContainerView { private def content = produce(viewModel.subProp(_.layout)) { - case Layouts.std => { - div(BootstrapStyles.containerFluid)( - Header.navbar(UI.title), - notifications, - main(ClientConf.style.fullHeight)( - div()( - child - ) - ), - Footer.template(UI.logo), - LoginPopup.render - ).render - } + case Layouts.std => services.layout.container(child) case Layouts.blank => div(BootstrapStyles.containerFluid,overflowX.hidden,ClientConf.style.showFooterActionOnMobile)( - notifications, child ).render } @@ -142,6 +129,7 @@ class RootView(viewModel:ModelProperty[RootViewModel]) extends ContainerView { override def getTemplate: Modifier = div( loading, + notifications, content ) diff --git a/client/src/main/scala/ch/wsl/box/client/views/components/BoxMainLayout.scala b/client/src/main/scala/ch/wsl/box/client/views/components/BoxMainLayout.scala new file mode 100644 index 00000000..ca272420 --- /dev/null +++ b/client/src/main/scala/ch/wsl/box/client/views/components/BoxMainLayout.scala @@ -0,0 +1,29 @@ +package ch.wsl.box.client.views.components + +import ch.wsl.box.client.services.{ClientConf, LoginPopup, UI} +import io.udash.bootstrap.BootstrapStyles +import scalatags.JsDom.all._ +import scalatags.JsDom.tags2.main +import ch.wsl.box.client.views.components._ +import ch.wsl.typings.std +import io.udash.core.Presenter +import org.scalajs.dom.Element +import scalacss.ScalatagsCss._ +import scalatags.JsDom.all._ +import io.udash.css.CssView._ + +class BoxMainLayout extends MainLayout { + + override def container(content: Element): Element = { + div(BootstrapStyles.containerFluid)( + Header.navbar(UI.title), + main(ClientConf.style.fullHeight)( + div()( + content + ) + ), + Footer.template(UI.logo), + LoginPopup.render + ).render + } +} diff --git a/client/src/main/scala/ch/wsl/box/client/views/components/MainLayout.scala b/client/src/main/scala/ch/wsl/box/client/views/components/MainLayout.scala new file mode 100644 index 00000000..9fc27e7f --- /dev/null +++ b/client/src/main/scala/ch/wsl/box/client/views/components/MainLayout.scala @@ -0,0 +1,8 @@ +package ch.wsl.box.client.views.components + +import org.scalajs.dom.Element + + +trait MainLayout { + def container(content:Element):Element +} From 15e63a68029f2d0b25ba7c799887f4a93d592a86 Mon Sep 17 00:00:00 2001 From: Andrea Minetti Date: Fri, 30 Jan 2026 10:14:24 +0100 Subject: [PATCH 07/42] Fixed Shorten util --- build.sbt | 1 + client/libraries.css | 12 +++++++++++ client/main.js | 1 + client/package.json | 2 +- .../main/scala/ch/wsl/box/client/Main.scala | 2 ++ .../main/scala/ch/wsl/box/client/Module.scala | 2 +- .../wsl/box/client/services/ClientConf.scala | 2 +- .../ch/wsl/box/client/styles/BoxStyle.scala | 1 + .../wsl/box/client/styles/GlobalStyles.scala | 13 +++++++++++- .../ch/wsl/box/client/utils/Shorten.scala | 8 ++++++- .../ch/wsl/box/client/utils/StripHtml.scala | 21 +++++++++++++++++++ .../box/client/views/EntityTableView.scala | 6 +----- .../components/JSONMetadataRenderer.scala | 5 +++++ .../components/TableFieldsRenderer.scala | 3 ++- .../widget/lookup/PopupSelectWidget.scala | 3 ++- client/style.scss | 3 --- 16 files changed, 70 insertions(+), 15 deletions(-) create mode 100644 client/libraries.css create mode 100644 client/src/main/scala/ch/wsl/box/client/utils/StripHtml.scala diff --git a/build.sbt b/build.sbt index 95ec34f7..5139b906 100755 --- a/build.sbt +++ b/build.sbt @@ -174,6 +174,7 @@ lazy val client: Project = (project in file("client")) stIgnore += "stream-browserify", stIgnore += "toolcool-range-slider", stIgnore += "@tailwindcss/vite", + stIgnore += "string-strip-html", stOutputPackage := "ch.wsl.typings", scalaJSLinkerConfig ~= (_.withModuleKind(ModuleKind.ESModule).withModuleSplitStyle(ModuleSplitStyle.FewestModules)), diff --git a/client/libraries.css b/client/libraries.css new file mode 100644 index 00000000..e407c2e9 --- /dev/null +++ b/client/libraries.css @@ -0,0 +1,12 @@ +@import "bootstrap"; + +@import "choices.js/src/styles/choices"; + +@import "flatpickr/dist/flatpickr.min.css"; +@import "flatpickr/dist/themes/dark.css"; + +@import "quill/dist/quill.snow.css"; + +@import "ol/ol.css"; + +@import "gridstack/dist/gridstack.min.css"; diff --git a/client/main.js b/client/main.js index cd7c9b32..509f16c0 100644 --- a/client/main.js +++ b/client/main.js @@ -1,3 +1,4 @@ import 'scalajs:main.js' +import './libraries.css'; import './style.scss' import * as bootstrap from 'bootstrap' diff --git a/client/package.json b/client/package.json index 2a71912d..272a0390 100644 --- a/client/package.json +++ b/client/package.json @@ -47,7 +47,7 @@ "quill": "1.3.7", "shapefile": "0.6.6", "stream-browserify": "3.0.0", - "striptags": "3.2.0", + "string-strip-html": "13.5.3", "tailwindcss": "^4.1.18", "toolcool-range-slider": "4.0.28", "xlsx-js-style": "1.2.0" diff --git a/client/src/main/scala/ch/wsl/box/client/Main.scala b/client/src/main/scala/ch/wsl/box/client/Main.scala index c1f6b329..5d01d445 100644 --- a/client/src/main/scala/ch/wsl/box/client/Main.scala +++ b/client/src/main/scala/ch/wsl/box/client/Main.scala @@ -87,7 +87,9 @@ object Main extends Logging { val CssSettings = scalacss.devOrProdDefaults import CssSettings._ + val mainStyle = document.createElement("style") + ClientConf.style.global //load unsaferoot global styles mainStyle.innerText = ClientConf.style.render(cssStringRenderer,cssEnv) val olStyle = document.createElement("style") diff --git a/client/src/main/scala/ch/wsl/box/client/Module.scala b/client/src/main/scala/ch/wsl/box/client/Module.scala index c15d27db..ba0f8821 100644 --- a/client/src/main/scala/ch/wsl/box/client/Module.scala +++ b/client/src/main/scala/ch/wsl/box/client/Module.scala @@ -1,7 +1,7 @@ package ch.wsl.box.client import ch.wsl.box.client.services.{ClientSession, DataAccessObject, HttpClient, Navigator, Notification, NotificationChannel, NotificationWebSocket, REST} import ch.wsl.box.client.services.impl.{DaoLocalDbImpl, DaoPassthroughImpl, HttpClientImpl, RestImpl} -import ch.wsl.box.client.styles.{BoxStyle, BoxStyleFactory, GlobalStyleFactory, TestStyleFactory} +import ch.wsl.box.client.styles.{BoxStyle, BoxStyleFactory, GlobalStyleFactory} import ch.wsl.box.client.views.components.{BoxMainLayout, MainLayout} import ch.wsl.box.model.shared.AvailableUIModule import ch.wsl.typings.std.WebAssembly.Global diff --git a/client/src/main/scala/ch/wsl/box/client/services/ClientConf.scala b/client/src/main/scala/ch/wsl/box/client/services/ClientConf.scala index bfa0cd66..dc76b365 100644 --- a/client/src/main/scala/ch/wsl/box/client/services/ClientConf.scala +++ b/client/src/main/scala/ch/wsl/box/client/services/ClientConf.scala @@ -6,7 +6,7 @@ import java.sql.Timestamp import java.time.temporal.ChronoUnit import ch.wsl.box.client.styles.constants.StyleConstants import ch.wsl.box.client.styles.constants.StyleConstants.{ChildProperties, Colors} -import ch.wsl.box.client.styles.{GlobalStyleFactory, StyleConf, TestStyle} +import ch.wsl.box.client.styles.{GlobalStyleFactory, StyleConf} import ch.wsl.box.model.shared.JSONFieldTypes import ch.wsl.box.model.shared.oidc.OIDCFrontendConf import io.circe._ diff --git a/client/src/main/scala/ch/wsl/box/client/styles/BoxStyle.scala b/client/src/main/scala/ch/wsl/box/client/styles/BoxStyle.scala index d8dd087d..a0f44e0c 100644 --- a/client/src/main/scala/ch/wsl/box/client/styles/BoxStyle.scala +++ b/client/src/main/scala/ch/wsl/box/client/styles/BoxStyle.scala @@ -104,6 +104,7 @@ trait BoxStyle { val mapInfoChild:StyleA val mapGeomAction:StyleA val mapButton:StyleA + val mapTable:StyleA val controlButtons:StyleA val controlInputs:StyleA val controlButtonsBottom:StyleA diff --git a/client/src/main/scala/ch/wsl/box/client/styles/GlobalStyles.scala b/client/src/main/scala/ch/wsl/box/client/styles/GlobalStyles.scala index 35c1a17c..ebd1fe41 100755 --- a/client/src/main/scala/ch/wsl/box/client/styles/GlobalStyles.scala +++ b/client/src/main/scala/ch/wsl/box/client/styles/GlobalStyles.scala @@ -35,7 +35,7 @@ class GlobalStyles(settings:Settings,conf:StyleConf) extends StyleSheet.Inline() import dsl._ - private val inputDefaultWidth = width(conf.inputPercentage %%) + protected val inputDefaultWidth = width(conf.inputPercentage %%) override val inputHighlight = style( borderWidth(0 px,0 px,1 px,0 px), @@ -1381,6 +1381,17 @@ class GlobalStyles(settings:Settings,conf:StyleConf) extends StyleSheet.Inline() height :=! "calc(100vh - 105px)" ) + + override val mapTable = style( + height :=! "calc(100vh - 105px)", + media.maxWidth(600 px)( + height :=! "calc(100vh - 50px)", + ), + position.sticky, + top.`0` + ) + + val simpleInputBottomBorder = style( margin.`0`, padding.`0`, diff --git a/client/src/main/scala/ch/wsl/box/client/utils/Shorten.scala b/client/src/main/scala/ch/wsl/box/client/utils/Shorten.scala index 602b9a98..ab1aeca0 100644 --- a/client/src/main/scala/ch/wsl/box/client/utils/Shorten.scala +++ b/client/src/main/scala/ch/wsl/box/client/utils/Shorten.scala @@ -1,8 +1,14 @@ package ch.wsl.box.client.utils + + object Shorten { + + + + def apply(str:String,maxLength:Int = 40):String = { - val noHTML = ch.wsl.typings.striptags.mod.apply(str) + val noHTML = StripHtml(str) if(noHTML.length > maxLength) { str.take(maxLength-3) + "..." } else str diff --git a/client/src/main/scala/ch/wsl/box/client/utils/StripHtml.scala b/client/src/main/scala/ch/wsl/box/client/utils/StripHtml.scala new file mode 100644 index 00000000..9d985826 --- /dev/null +++ b/client/src/main/scala/ch/wsl/box/client/utils/StripHtml.scala @@ -0,0 +1,21 @@ +package ch.wsl.box.client.utils + + +import scala.scalajs.js +import scala.scalajs.js.annotation.JSImport + + + +object StripHtml { + + @js.native + @JSImport("string-strip-html","stripHtml") + private object StringStripHtml extends js.Object { + def apply(input: String): js.Dynamic = js.native + } + + def apply(s:String):String = { + StringStripHtml(s).result.asInstanceOf[String] + } + +} diff --git a/client/src/main/scala/ch/wsl/box/client/views/EntityTableView.scala b/client/src/main/scala/ch/wsl/box/client/views/EntityTableView.scala index e529f4a0..441baebc 100755 --- a/client/src/main/scala/ch/wsl/box/client/views/EntityTableView.scala +++ b/client/src/main/scala/ch/wsl/box/client/views/EntityTableView.scala @@ -772,11 +772,7 @@ case class EntityTableView(model:ModelProperty[EntityTableModel], presenter:Enti if(model.subProp(_.geoms).get.isEmpty) presenter.loadGeoms(model.subProp(_.extent).get) - if(window.innerWidth < 600) { // is mobile - map = Some(div(height := (window.innerHeight - 50).px).render) - } else { - map = Some(div(height := (window.innerHeight - 105).px).render) - } + map = Some(div(ClientConf.style.mapTable).render) val observer = new MutationObserver({ (mutations, observer) => diff --git a/client/src/main/scala/ch/wsl/box/client/views/components/JSONMetadataRenderer.scala b/client/src/main/scala/ch/wsl/box/client/views/components/JSONMetadataRenderer.scala index 2936112f..5165b035 100755 --- a/client/src/main/scala/ch/wsl/box/client/views/components/JSONMetadataRenderer.scala +++ b/client/src/main/scala/ch/wsl/box/client/views/components/JSONMetadataRenderer.scala @@ -200,7 +200,12 @@ case class JSONMetadataRenderer(metadata: JSONMetadata, data: Property[Json], ch nested(produce(selectedTab) { tabName => val block = blks.filter(_.block.tab == tabName) window.setTimeout(() => block.map(x => x.widget.afterRender()),0) + // make tab fill the space + val fullwidth = block.map(b => b.copy(b.block.copy(width = 12/block.length))) + renderBlocks(fullwidth).render + renderBlocks(block).render + }) ) } diff --git a/client/src/main/scala/ch/wsl/box/client/views/components/TableFieldsRenderer.scala b/client/src/main/scala/ch/wsl/box/client/views/components/TableFieldsRenderer.scala index 8693f554..0f76a1b1 100755 --- a/client/src/main/scala/ch/wsl/box/client/views/components/TableFieldsRenderer.scala +++ b/client/src/main/scala/ch/wsl/box/client/views/components/TableFieldsRenderer.scala @@ -2,6 +2,7 @@ package ch.wsl.box.client.views.components import ch.wsl.box.client.routes.Routes import ch.wsl.box.client.services.{ClientConf, Labels} +import ch.wsl.box.client.utils.StripHtml import ch.wsl.box.client.{EntityFormState, EntityTableState} import ch.wsl.box.model.shared.GeoJson.Geometry import ch.wsl.box.model.shared.{JSONField, JSONFieldTypes, JSONID, JSONLookup, JSONLookups, WidgetsNames} @@ -33,7 +34,7 @@ object TableFieldsRenderer extends Logging{ def renderLongText(string: String):Modifier = { val length = ClientConf.tableMaxTextLength - val noHTML = ch.wsl.typings.striptags.mod.apply(string) + val noHTML = StripHtml(string) if(noHTML.length <= length) { p(noHTML) } else { diff --git a/client/src/main/scala/ch/wsl/box/client/views/components/widget/lookup/PopupSelectWidget.scala b/client/src/main/scala/ch/wsl/box/client/views/components/widget/lookup/PopupSelectWidget.scala index 923e4467..2896f124 100644 --- a/client/src/main/scala/ch/wsl/box/client/views/components/widget/lookup/PopupSelectWidget.scala +++ b/client/src/main/scala/ch/wsl/box/client/views/components/widget/lookup/PopupSelectWidget.scala @@ -116,7 +116,8 @@ object PopupSelectWidget extends ComponentWidgetFactory { val metadata:Property[Option[JSONMetadata]] = Property(None) - services.rest.metadata(EntityKind.ENTITY.kind,services.clientSession.lang(),entity,public).foreach(m => metadata.set(Some(m))) + logger.debug("Loading child metadata") + //services.rest.metadata(EntityKind.ENTITY.kind,services.clientSession.lang(),entity,public).foreach(m => metadata.set(Some(m))) nested(produceWithNested(metadata) { (metadata,nested) => div(metadata.map { metadata => diff --git a/client/style.scss b/client/style.scss index a7443ebf..e69de29b 100644 --- a/client/style.scss +++ b/client/style.scss @@ -1,3 +0,0 @@ -@import "~bootstrap/scss/bootstrap"; - -@import "choices.js/src/styles/choices"; \ No newline at end of file From d091e909485527ab1fd214ea361f87d11a80424a Mon Sep 17 00:00:00 2001 From: Andrea Minetti Date: Tue, 3 Feb 2026 16:14:33 +0100 Subject: [PATCH 08/42] Improved external templating --- build.sbt | 1 + client/index.html | 2 +- client/main.js | 1 + .../scala/ch/wsl/box/client/styles/BoxStyle.scala | 1 + .../ch/wsl/box/client/styles/GlobalStyles.scala | 4 ++++ .../ch/wsl/box/client/views/EntityTableView.scala | 5 ++++- .../scala/ch/wsl/box/client/views/LoginView.scala | 2 +- .../views/components/JSONMetadataRenderer.scala | 2 -- .../client/views/components/ui/Autocomplete.scala | 15 ++++++++++----- .../views/components/ui/TwoPanelResize.scala | 6 +++--- .../views/components/widget/PopupWidget.scala | 5 ++--- .../views/components/widget/geo/OlMapWidget.scala | 2 +- .../widget/lookup/PopupSelectWidget.scala | 5 ++--- 13 files changed, 31 insertions(+), 20 deletions(-) diff --git a/build.sbt b/build.sbt index 5139b906..cce51497 100755 --- a/build.sbt +++ b/build.sbt @@ -175,6 +175,7 @@ lazy val client: Project = (project in file("client")) stIgnore += "toolcool-range-slider", stIgnore += "@tailwindcss/vite", stIgnore += "string-strip-html", + stIgnore += "autocompleter", stOutputPackage := "ch.wsl.typings", scalaJSLinkerConfig ~= (_.withModuleKind(ModuleKind.ESModule).withModuleSplitStyle(ModuleSplitStyle.FewestModules)), diff --git a/client/index.html b/client/index.html index 7911e30d..091419ac 100644 --- a/client/index.html +++ b/client/index.html @@ -11,7 +11,7 @@
diff --git a/client/main.js b/client/main.js index 509f16c0..b3b2879b 100644 --- a/client/main.js +++ b/client/main.js @@ -2,3 +2,4 @@ import 'scalajs:main.js' import './libraries.css'; import './style.scss' import * as bootstrap from 'bootstrap' +import '@fortawesome/fontawesome-free/js/all.min.js' diff --git a/client/src/main/scala/ch/wsl/box/client/styles/BoxStyle.scala b/client/src/main/scala/ch/wsl/box/client/styles/BoxStyle.scala index a0f44e0c..7a27cb3e 100644 --- a/client/src/main/scala/ch/wsl/box/client/styles/BoxStyle.scala +++ b/client/src/main/scala/ch/wsl/box/client/styles/BoxStyle.scala @@ -63,6 +63,7 @@ trait BoxStyle { val headerTitle: StyleA val linkHeaderFooter: StyleA val fullHeightMax: StyleA + val tableWrapper: StyleA val fullHeight: StyleA val loading: StyleA val noMargin: StyleA diff --git a/client/src/main/scala/ch/wsl/box/client/styles/GlobalStyles.scala b/client/src/main/scala/ch/wsl/box/client/styles/GlobalStyles.scala index ebd1fe41..a851978c 100755 --- a/client/src/main/scala/ch/wsl/box/client/styles/GlobalStyles.scala +++ b/client/src/main/scala/ch/wsl/box/client/styles/GlobalStyles.scala @@ -807,6 +807,10 @@ class GlobalStyles(settings:Settings,conf:StyleConf) extends StyleSheet.Inline() overflow.auto ) + override val tableWrapper = style( + addClassName("box-table-wrapper") + ) + override val fullHeight = style( height :=! "calc(100vh - 105px)", media.maxWidth(600 px)( diff --git a/client/src/main/scala/ch/wsl/box/client/views/EntityTableView.scala b/client/src/main/scala/ch/wsl/box/client/views/EntityTableView.scala index 441baebc..6cd7669d 100755 --- a/client/src/main/scala/ch/wsl/box/client/views/EntityTableView.scala +++ b/client/src/main/scala/ch/wsl/box/client/views/EntityTableView.scala @@ -1138,7 +1138,10 @@ case class EntityTableView(model:ModelProperty[EntityTableModel], presenter:Enti ), div(id := "box-table", ClientConf.style.fullHeightMax,ClientConf.style.tableHeaderFixed, - tableContent(metadata), + div( + ClientConf.style.fullHeightMax, + tableContent(metadata) + ), button(`type` := "button", onclick :+= presenter.downloadCSV, ClientConf.style.boxButton, Labels.entity.csv), button(`type` := "button", onclick :+= presenter.downloadXLS, ClientConf.style.boxButton, Labels.entity.xls), button(`type` := "button", onclick :+= presenter.importXLS, ClientConf.style.boxButton, Labels.entity.importxls), diff --git a/client/src/main/scala/ch/wsl/box/client/views/LoginView.scala b/client/src/main/scala/ch/wsl/box/client/views/LoginView.scala index 455b63bc..984c5397 100755 --- a/client/src/main/scala/ch/wsl/box/client/views/LoginView.scala +++ b/client/src/main/scala/ch/wsl/box/client/views/LoginView.scala @@ -45,7 +45,7 @@ case class LoginView(presenter:LoginPresenter) extends View { override def getTemplate = div( div(BootstrapStyles.container)(raw(UI.loginTopHtml)), - div(BootstrapStyles.container, height := 400.px)( + div(BootstrapStyles.container, `class` := "login-container", height := 400.px)( div(BootstrapStyles.Grid.row, BootstrapStyles.Flex.justifyContent(BootstrapStyles.FlexContentJustification.Center), BootstrapStyles.Flex.alignItems(BootstrapStyles.FlexAlign.Center), diff --git a/client/src/main/scala/ch/wsl/box/client/views/components/JSONMetadataRenderer.scala b/client/src/main/scala/ch/wsl/box/client/views/components/JSONMetadataRenderer.scala index 5165b035..8a904a5f 100755 --- a/client/src/main/scala/ch/wsl/box/client/views/components/JSONMetadataRenderer.scala +++ b/client/src/main/scala/ch/wsl/box/client/views/components/JSONMetadataRenderer.scala @@ -204,8 +204,6 @@ case class JSONMetadataRenderer(metadata: JSONMetadata, data: Property[Json], ch val fullwidth = block.map(b => b.copy(b.block.copy(width = 12/block.length))) renderBlocks(fullwidth).render - renderBlocks(block).render - }) ) } diff --git a/client/src/main/scala/ch/wsl/box/client/views/components/ui/Autocomplete.scala b/client/src/main/scala/ch/wsl/box/client/views/components/ui/Autocomplete.scala index b8704970..8c9d4966 100644 --- a/client/src/main/scala/ch/wsl/box/client/views/components/ui/Autocomplete.scala +++ b/client/src/main/scala/ch/wsl/box/client/views/components/ui/Autocomplete.scala @@ -12,16 +12,21 @@ import org.scalajs.dom.html.Div import org.scalajs.dom.{Event, HTMLDivElement, HTMLInputElement, MutationObserver, MutationObserverInit, document} import scalatags.JsDom.all._ import scribe.Logging -import ch.wsl.typings.autocompleter -import ch.wsl.typings.autocompleter.{autocompleterBooleans, mod} import scala.concurrent.{ExecutionContext, Future} import scala.scalajs.js -import scala.scalajs.js.annotation.JSName +import scala.scalajs.js.annotation.{JSImport, JSName} import scala.scalajs.js.{JSON, UndefOr, |} object Autocomplete extends Logging { + + @js.native + @JSImport("autocompleter", JSImport.Default) + private object autocomplete extends js.Object { + def apply(opt:js.Object):Unit = js.native + } + def apply[T](prop: Property[Option[T]], fetch: js.Function1[String,Future[Seq[T]]], toLabel: js.Function1[Option[T],String], toSuggestion: js.Function1[T,HTMLDivElement])(modifier: Modifier*)(implicit ec: ExecutionContext, enc:Encoder[T], dec:Decoder[T]): HTMLInputElement = { val el: HTMLInputElement = input(modifier).render @@ -41,7 +46,7 @@ object Autocomplete extends Logging { val preventSubmit = true @JSName("fetch") - def fetch_false(text: String, update: js.Function1[js.Array[String] | autocompleterBooleans.`false`, Unit], trigger: mod.EventTrigger, cursorPos: Double): Unit = { + def fetch_false(text: String, update: js.Function1[js.Array[String], Unit], trigger: js.Any, cursorPos: Double): Unit = { logger.info(s"Fetching $text") fetch(text).map { data => val dataJS: js.Array[String] = data.map(x => x.asJson.noSpaces).toJSArray @@ -78,7 +83,7 @@ object Autocomplete extends Logging { } } } - autocompleter.mod.^.asInstanceOf[js.Dynamic](options) + autocomplete(options) } }) diff --git a/client/src/main/scala/ch/wsl/box/client/views/components/ui/TwoPanelResize.scala b/client/src/main/scala/ch/wsl/box/client/views/components/ui/TwoPanelResize.scala index 201fdbc3..5af2aed9 100644 --- a/client/src/main/scala/ch/wsl/box/client/views/components/ui/TwoPanelResize.scala +++ b/client/src/main/scala/ch/wsl/box/client/views/components/ui/TwoPanelResize.scala @@ -16,7 +16,7 @@ import scala.util.Random // Ref https://phuoc.ng/collection/html-dom/create-resizable-split-views/ class TwoPanelResize(defaultClose:Boolean) { - val leftDefaultWidth = 40 + val leftDefaultWidth = 500 case class Style(conf:StyleConf) extends StyleSheet.Inline{ import dsl._ @@ -29,7 +29,7 @@ class TwoPanelResize(defaultClose:Boolean) { ) val containerLeft = style( - width(leftDefaultWidth %%), + width(leftDefaultWidth px), if(defaultClose) width.`0` else { media.maxWidth(600 px)( width.`0` //:= "calc(100% - 15px)" )}, @@ -129,7 +129,7 @@ class TwoPanelResize(defaultClose:Boolean) { leftSide.style.width = "100%" rightSide.style.display = "none" } else { - leftSide.style.width = leftDefaultWidth + "%" + leftSide.style.width = leftDefaultWidth + "px" } document.getElementById(openId).classList.add(style.hide.htmlClass) diff --git a/client/src/main/scala/ch/wsl/box/client/views/components/widget/PopupWidget.scala b/client/src/main/scala/ch/wsl/box/client/views/components/widget/PopupWidget.scala index a9522be1..71afa0f1 100644 --- a/client/src/main/scala/ch/wsl/box/client/views/components/widget/PopupWidget.scala +++ b/client/src/main/scala/ch/wsl/box/client/views/components/widget/PopupWidget.scala @@ -159,11 +159,10 @@ object PopupWidget extends ComponentWidgetFactory { private def _render(write:Boolean,nested:Binding.NestedInterceptor) = popup(nested,write,(modal,modalStatus) => { val tooltip = WidgetUtils.addTooltip(field.tooltip) _ - div(BootstrapCol.md(12),ClientConf.style.noPadding, ClientConf.style.smallBottomMargin, - BootstrapStyles.Display.flex(),BootstrapStyles.Flex.justifyContent(BootstrapStyles.FlexContentJustification.Between))( + div(BootstrapCol.md(12),ClientConf.style.noPadding, ClientConf.style.smallBottomMargin)( WidgetUtils.toLabel(field,WidgetUtils.LabelRight), TextInput(params.prop.bitransform(_.string)(x => params.prop.get))(width := 1.px, height := 1.px, padding := 0, border := 0, float.left,WidgetUtils.toNullable(field.nullable)), //in order to use HTML5 validation we insert an hidden field - tooltip(button(ClientConf.style.popupButton, onclick :+= ((e:Event) => { + tooltip(button(ClientConf.style.popupButton,BootstrapStyles.Float.right(), onclick :+= ((e:Event) => { modalStatus.set(Status.Open) e.preventDefault() }),produceLabel).render)._1, diff --git a/client/src/main/scala/ch/wsl/box/client/views/components/widget/geo/OlMapWidget.scala b/client/src/main/scala/ch/wsl/box/client/views/components/widget/geo/OlMapWidget.scala index 7f324785..fd6e998d 100644 --- a/client/src/main/scala/ch/wsl/box/client/views/components/widget/geo/OlMapWidget.scala +++ b/client/src/main/scala/ch/wsl/box/client/views/components/widget/geo/OlMapWidget.scala @@ -332,7 +332,7 @@ class OlMapWidget(val id: ReadableProperty[Option[String]], val field: JSONField def requiredCheckField = TextInput( _data.bitransform(_.map(_.toString(1).take(5)).getOrElse("")) // check on the actual data is is non empty (x => _data.get) // the text input will never been changed - )(width := 1.px, height := 1.px, padding := 0, border := 0, float.left,WidgetUtils.toNullable(field.nullable)) //in order to use HTML5 validation we insert an hidden field + )(width := 1.px, height := 1.px, minHeight := 1.px, padding := 0, border := 0, float.left,WidgetUtils.toNullable(field.nullable)) //in order to use HTML5 validation we insert an hidden field override protected def edit(nested:Binding.NestedInterceptor): JsDom.all.Modifier = { diff --git a/client/src/main/scala/ch/wsl/box/client/views/components/widget/lookup/PopupSelectWidget.scala b/client/src/main/scala/ch/wsl/box/client/views/components/widget/lookup/PopupSelectWidget.scala index 2896f124..722b1d56 100644 --- a/client/src/main/scala/ch/wsl/box/client/views/components/widget/lookup/PopupSelectWidget.scala +++ b/client/src/main/scala/ch/wsl/box/client/views/components/widget/lookup/PopupSelectWidget.scala @@ -217,11 +217,10 @@ object PopupSelectWidget extends ComponentWidgetFactory { override def edit(nested:Binding.NestedInterceptor): JsDom.all.Modifier = popupEdit(nested)((modal,modalStatus) => { val tooltip = WidgetUtils.addTooltip(field.tooltip) _ - div(BootstrapCol.md(12),ClientConf.style.noPadding, ClientConf.style.smallBottomMargin, - BootstrapStyles.Display.flex(),BootstrapStyles.Flex.justifyContent(BootstrapStyles.FlexContentJustification.Between))( + div(BootstrapCol.md(12),ClientConf.style.noPadding, ClientConf.style.smallBottomMargin,BootstrapStyles.Float.right())( WidgetUtils.toLabel(field,WidgetUtils.LabelRight), TextInput(data.bitransform(_.string)(x => data.get))(width := 1.px, height := 1.px, padding := 0, border := 0, float.left,WidgetUtils.toNullable(field.nullable)), //in order to use HTML5 validation we insert an hidden field - tooltip(button(ClientConf.style.popupButton, onclick :+= ((e:Event) => { + tooltip(button(ClientConf.style.popupButton,BootstrapStyles.Float.right(), onclick :+= ((e:Event) => { modalStatus.set(Status.Open) e.preventDefault() }),nested(bind(model.transform(_.map(_.value).getOrElse(""))))).render)._1, From 895cdf5d5ef059f793c7ed1924403365dc0f63c6 Mon Sep 17 00:00:00 2001 From: Andrea Minetti Date: Mon, 9 Feb 2026 11:49:06 +0100 Subject: [PATCH 09/42] Fixed geopackage export --- .../box/rest/io/geotools/GeoPackageWriter.scala | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/server/src/main/scala/ch/wsl/box/rest/io/geotools/GeoPackageWriter.scala b/server/src/main/scala/ch/wsl/box/rest/io/geotools/GeoPackageWriter.scala index 86fc7806..3a74fb25 100644 --- a/server/src/main/scala/ch/wsl/box/rest/io/geotools/GeoPackageWriter.scala +++ b/server/src/main/scala/ch/wsl/box/rest/io/geotools/GeoPackageWriter.scala @@ -18,7 +18,7 @@ import scala.concurrent.{ExecutionContext, Future} object GeoPackageWriter { - def geomSchema(builder:SimpleFeatureTypeBuilder,name:String,geo:Seq[Option[GeoJson.Geometry]]) = { + def geomSchema(builder:SimpleFeatureTypeBuilder,name:String,geo:Seq[Option[GeoJson.Geometry]],srid:Int) = { geo.toList.flatten.filterNot(_ == GeoJson.Empty).headOption match { case Some(value) => { value match { @@ -33,7 +33,7 @@ object GeoPackageWriter { case GeoJson.GeometryCollection(_, crs) => builder.add(name, classOf[Geometry], crs.srid) } } - case None => builder.add(name, classOf[Geometry]) + case None => builder.add(name, classOf[Geometry],srid) } } @@ -70,10 +70,14 @@ object GeoPackageWriter { geopkg.init() - - data.geometry.foreach { case (geomName, values) => + val srid = values.flatten + .groupBy(_.crs.srid) // group the entrys by their SRID + .maxByOption(_._2.length) //take the srid with more entries + .map(_._1) // take the SRID + .getOrElse(0) //dafault SRID 0 -> no information + val builder: SimpleFeatureTypeBuilder = new SimpleFeatureTypeBuilder builder.setName(s"${name}_$geomName") //builder.setCRS(org.geotools.referencing.CRS.decode(crs.name)) @@ -83,7 +87,7 @@ object GeoPackageWriter { t.typ match { case JSONFieldTypes.INTEGER => builder.add(t.name, classOf[Integer]) case JSONFieldTypes.NUMBER => builder.add(t.name, classOf[Double]) - case JSONFieldTypes.GEOMETRY if t.name == geomName => geomSchema(builder, t.name, values) + case JSONFieldTypes.GEOMETRY if t.name == geomName => geomSchema(builder, t.name, values,srid) case JSONFieldTypes.GEOMETRY => () case _ => builder.add(t.name, classOf[String]) } From d33246fe0cb0dd76ff806d2d913b6d363a8d3c16 Mon Sep 17 00:00:00 2001 From: Andrea Minetti Date: Mon, 9 Feb 2026 11:49:55 +0100 Subject: [PATCH 10/42] Fixed table column dragging when style capitalize the header --- .../main/scala/ch/wsl/box/client/views/EntityTableView.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/main/scala/ch/wsl/box/client/views/EntityTableView.scala b/client/src/main/scala/ch/wsl/box/client/views/EntityTableView.scala index 6cd7669d..c529255b 100755 --- a/client/src/main/scala/ch/wsl/box/client/views/EntityTableView.scala +++ b/client/src/main/scala/ch/wsl/box/client/views/EntityTableView.scala @@ -1020,7 +1020,7 @@ case class EntityTableView(model:ModelProperty[EntityTableModel], presenter:Enti } else { el.querySelector(ClientConf.style.tableHeader.selector) } - head.innerText + head.innerHTML } new TableColumnDrag(table, labelExtractor,e => { From 3fa51389ecf7faf7ab5aa52deeb93a878414315f Mon Sep 17 00:00:00 2001 From: Andrea Minetti Date: Mon, 9 Feb 2026 14:39:06 +0100 Subject: [PATCH 11/42] Popup editing ok --- .../ch/wsl/box/client/services/Labels.scala | 1 + .../ch/wsl/box/client/styles/BoxStyle.scala | 1 + .../wsl/box/client/styles/GlobalStyles.scala | 5 + .../widget/lookup/LookupWidget.scala | 14 +-- .../widget/lookup/PopupSelectWidget.scala | 103 +++++++++++++----- .../wsl/box/model/shared/SharedLabels.scala | 4 +- 6 files changed, 95 insertions(+), 33 deletions(-) diff --git a/client/src/main/scala/ch/wsl/box/client/services/Labels.scala b/client/src/main/scala/ch/wsl/box/client/services/Labels.scala index b96549d5..bf1b9a35 100644 --- a/client/src/main/scala/ch/wsl/box/client/services/Labels.scala +++ b/client/src/main/scala/ch/wsl/box/client/services/Labels.scala @@ -164,6 +164,7 @@ object Labels { def search = get(SharedLabels.popup.search) def close = get(SharedLabels.popup.close) def remove = get(SharedLabels.popup.remove) + def back = get(SharedLabels.popup.back) } object home { diff --git a/client/src/main/scala/ch/wsl/box/client/styles/BoxStyle.scala b/client/src/main/scala/ch/wsl/box/client/styles/BoxStyle.scala index 7a27cb3e..072462e8 100644 --- a/client/src/main/scala/ch/wsl/box/client/styles/BoxStyle.scala +++ b/client/src/main/scala/ch/wsl/box/client/styles/BoxStyle.scala @@ -84,6 +84,7 @@ trait BoxStyle { val boxButtonDanger: StyleA val popupButton: StyleA val popupEntiresList: StyleA + val popupEntiresItem: StyleA val fullWidth: StyleA val maxFullWidth: StyleA val filterTableSelect: StyleA diff --git a/client/src/main/scala/ch/wsl/box/client/styles/GlobalStyles.scala b/client/src/main/scala/ch/wsl/box/client/styles/GlobalStyles.scala index a851978c..3052289d 100755 --- a/client/src/main/scala/ch/wsl/box/client/styles/GlobalStyles.scala +++ b/client/src/main/scala/ch/wsl/box/client/styles/GlobalStyles.scala @@ -1030,6 +1030,11 @@ class GlobalStyles(settings:Settings,conf:StyleConf) extends StyleSheet.Inline() overflowY.auto, maxHeight(70.vh) ) + override val popupEntiresItem = style( + display.flex, + justifyContent.spaceBetween, + marginBottom(10 px) + ) override val fullWidth = style( width(100 %%).important diff --git a/client/src/main/scala/ch/wsl/box/client/views/components/widget/lookup/LookupWidget.scala b/client/src/main/scala/ch/wsl/box/client/views/components/widget/lookup/LookupWidget.scala index 21d6d5f6..2e8fa364 100644 --- a/client/src/main/scala/ch/wsl/box/client/views/components/widget/lookup/LookupWidget.scala +++ b/client/src/main/scala/ch/wsl/box/client/views/components/widget/lookup/LookupWidget.scala @@ -93,14 +93,14 @@ trait LookupWidget extends Widget with HasData { } - private def fetchRemoteLookup(fieldLookup:JSONFieldLookupRemote)(q: JSONQuery)(implicit ec: ExecutionContext):Future[Seq[JSONLookup]] = { + private def fetchRemoteLookup(fieldLookup:JSONFieldLookupRemote,force:Boolean)(q: JSONQuery)(implicit ec: ExecutionContext):Future[Seq[JSONLookup]] = { dataSyncRegistration.foreach(_.cancel()) logger.debug(s"Fetching remote lookup $q") val cacheKey = metadata.name + ch.wsl.typings.jsMd5.mod.hex(fieldLookup.lookupEntity + fieldLookup.map + q.toString) LookupWidget.remoteLookup.get(cacheKey) match { - case Some(value) => value.map(setNewLookup) + case Some(value) if !force => value.map(setNewLookup) case _ => { _lookup.set(Seq(), true) //reset lookup state val request = for{ @@ -136,11 +136,11 @@ trait LookupWidget extends Widget with HasData { case Some(query) => { autoRelease(allData.listen({ allJs => val newQuery = query.withData(allJs,services.clientSession.lang()) - fetchRemoteLookup(fieldLookup)(newQuery) + fetchRemoteLookup(fieldLookup,force = false)(newQuery) }, true)) } - case None => fetchRemoteLookup(fieldLookup)(JSONQuery.empty.limit(1000)) + case None => fetchRemoteLookup(fieldLookup,force = false)(JSONQuery.empty.limit(1000)) } @@ -197,16 +197,16 @@ trait LookupWidget extends Widget with HasData { } - private def fetchLookups()(implicit ec: ExecutionContext):Future[Seq[JSONLookup]] = { + protected def fetchLookups(force:Boolean = false)(implicit ec: ExecutionContext):Future[Seq[JSONLookup]] = { fieldLookup match { case r:JSONFieldLookupRemote => { r.lookupQuery.flatMap(JSONQuery.fromJson) match { case Some(query) => { val newQuery = query.withData(allData.get,services.clientSession.lang()) - fetchRemoteLookup(r)(newQuery)(ec) + fetchRemoteLookup(r,force)(newQuery)(ec) } - case None => fetchRemoteLookup(r)(JSONQuery.empty.limit(1000))(ec) + case None => fetchRemoteLookup(r,force)(JSONQuery.empty.limit(1000))(ec) } } case JSONFieldLookupExtractor(extractor) => Future.successful( diff --git a/client/src/main/scala/ch/wsl/box/client/views/components/widget/lookup/PopupSelectWidget.scala b/client/src/main/scala/ch/wsl/box/client/views/components/widget/lookup/PopupSelectWidget.scala index 722b1d56..f20d659f 100644 --- a/client/src/main/scala/ch/wsl/box/client/views/components/widget/lookup/PopupSelectWidget.scala +++ b/client/src/main/scala/ch/wsl/box/client/views/components/widget/lookup/PopupSelectWidget.scala @@ -63,8 +63,11 @@ object PopupSelectWidget extends ComponentWidgetFactory { val mode:Property[Mode] = Property(Search) + val childMetadata:Property[Option[JSONMetadata]] = Property(None) val lookupData = Property(Json.obj()) + val editForm:Option[String] = field.params.flatMap(_.getOpt("editForm")) + def popupEdit(nested:Binding.NestedInterceptor)(mainRenderer:(UdashModal,Property[String]) => Modifier) = { val searchId = TestHooks.popupSearch(field.name,metadata.objId) @@ -85,23 +88,29 @@ object PopupSelectWidget extends ComponentWidgetFactory { div( div( nested(repeat(lookup.filter(opt => searchTerm == "" || opt.value.toLowerCase.contains(searchTerm.toLowerCase))) { x => - div( - a(Icons.pencil_square,onclick :+= ((e: Event) => { + div(ClientConf.style.popupEntiresItem, + a(bind(x.transform(_.value)), onclick :+= ((e: Event) => { + modalStatus.set(Status.Closed) + model.set(Some(x.get)) + e.preventDefault() + })), + a(marginRight := 15.px, Icons.pencil_square,onclick :+= ((e: Event) => { val lookup = fieldLookup.asInstanceOf[JSONFieldLookupRemote] val keys = Seq((lookup.map.foreign.keyColumns.head,x.get.id)) // single lookup supported //val keys = lookup.map.localKeysColumn.zip(lookup.map.foreign.keyColumns).map{ case (local,remote) => remote -> x.get.id} - services.rest.get(EntityKind.ENTITY.kind,services.clientSession.lang(),lookup.lookupEntity,JSONID.fromMap(keys),public).map { record => + + val data = editForm match { + case Some(value) => services.rest.get(EntityKind.FORM.kind,services.clientSession.lang(),value,JSONID.fromMap(keys),public) + case None => services.rest.get(EntityKind.ENTITY.kind,services.clientSession.lang(),lookup.lookupEntity,JSONID.fromMap(keys),public) + } + + data.map { record => lookupData.set(record) mode.set(Edit) } e.preventDefault() - })), " ", - a(bind(x.transform(_.value)), onclick :+= ((e: Event) => { - modalStatus.set(Status.Closed) - model.set(Some(x.get)) - e.preventDefault() - }) - )).render + })) + ).render } ) ) @@ -114,12 +123,15 @@ object PopupSelectWidget extends ComponentWidgetFactory { - val metadata:Property[Option[JSONMetadata]] = Property(None) + logger.debug("Loading child metadata") - //services.rest.metadata(EntityKind.ENTITY.kind,services.clientSession.lang(),entity,public).foreach(m => metadata.set(Some(m))) + editForm match { + case Some(value) => services.rest.metadata(EntityKind.FORM.kind,services.clientSession.lang(),value,public).foreach(m => childMetadata.set(Some(m))) + case None => services.rest.metadata(EntityKind.ENTITY.kind,services.clientSession.lang(),entity,public).foreach(m => childMetadata.set(Some(m))) + } - nested(produceWithNested(metadata) { (metadata,nested) => + nested(produceWithNested(childMetadata) { (metadata,nested) => div(metadata.map { metadata => val id = JSONID.fromData(lookupData.get, metadata) val action = WidgetCallbackActions.noAction @@ -156,18 +168,59 @@ object PopupSelectWidget extends ComponentWidgetFactory { }) ).render - val footer = (nested:NestedInterceptor) => div( - nested(showIf(model.transform(_.isDefined)) { - button(onclick :+= ((e: Event) => { - model.set(None) - modal.hide() - e.preventDefault() - }), Labels.popup.remove, ClientConf.style.boxButtonDanger).render - }), - button(onclick :+= ((e:Event) => { - modal.hide() - e.preventDefault() - }), Labels.popup.close,ClientConf.style.boxButton) + val footer = (n:NestedInterceptor) => div( + n( + produceWithNested(mode) { (m,nested) => + m match { + case Edit => div( + button(onclick :+= ((e:Event) => { + mode.set(Search) + e.preventDefault() + }), Labels.popup.back,ClientConf.style.boxButton), + button(onclick :+= ((e:Event) => { + val id = JSONID.fromData(lookupData.get,childMetadata.get.get).get + val f = editForm match { + case Some(value) => services.rest.update(EntityKind.FORM.kind,services.clientSession.lang(),value,id,lookupData.get,public) + case None => services.rest.update(EntityKind.ENTITY.kind,services.clientSession.lang(),childMetadata.get.get.entity,id,lookupData.get,public) + } + + f.foreach{ _ => + fieldLookup match { + case JSONFieldLookupRemote(lookupEntity, map, lookupQuery) => { + val id = lookupData.get.js(map.foreign.keyColumns.head) + val values = map.foreign.labelColumns.flatMap(x => lookupData.get.getOpt(x)) + model.set(Some(JSONLookup(id,values))) + modal.hide() + mode.set(Search) + fetchLookups(true) + } + case JSONFieldLookupExtractor(extractor) => ??? + case JSONFieldLookupData(data) => ??? + } + + } + + e.preventDefault() + }), Labels.form.save,ClientConf.style.boxButton) + ).render + case Search => div( + nested(showIf(model.transform(_.isDefined)) { + button(onclick :+= ((e: Event) => { + model.set(None) + modal.hide() + e.preventDefault() + }), Labels.popup.remove, ClientConf.style.boxButtonDanger).render + }), + button(onclick :+= ((e:Event) => { + modal.hide() + e.preventDefault() + }), Labels.popup.close,ClientConf.style.boxButton) + ).render + + } + } + ), + ).render modal = nested(UdashModal(modalSize = Some(Size.Small).toProperty)( diff --git a/shared/src/main/scala/ch/wsl/box/model/shared/SharedLabels.scala b/shared/src/main/scala/ch/wsl/box/model/shared/SharedLabels.scala index d802218d..4fb17ba8 100644 --- a/shared/src/main/scala/ch/wsl/box/model/shared/SharedLabels.scala +++ b/shared/src/main/scala/ch/wsl/box/model/shared/SharedLabels.scala @@ -272,10 +272,12 @@ object SharedLabels extends LabelsCollection { def search = "popup.search" def close = "popup.close" def remove = "popup.remove" + def back = "popup.back" def all = Seq( search, close, - remove + remove, + back ) } From 13dfe11f41bf13e554f6c4183f7a8ae3ce0f6b63 Mon Sep 17 00:00:00 2001 From: Andrea Minetti Date: Tue, 17 Feb 2026 08:37:53 +0100 Subject: [PATCH 12/42] publishAllLocal only for server --- build.sbt | 2 -- 1 file changed, 2 deletions(-) diff --git a/build.sbt b/build.sbt index cce51497..eecb06aa 100755 --- a/build.sbt +++ b/build.sbt @@ -334,8 +334,6 @@ lazy val publishAllTask = { lazy val publishAllLocal = taskKey[Unit]("Publish all modules") lazy val publishAllLocalTask = { Def.sequential( - (client / Compile / fullOptJS), - (codegen / Compile / compile), (sharedJVM / publishLocal), (codegen / publishLocal), (server / publishLocal), From 5d7c937241639c9338c221e85fa43a0c11fe0de7 Mon Sep 17 00:00:00 2001 From: Andrea Minetti Date: Mon, 2 Mar 2026 11:15:19 +0100 Subject: [PATCH 13/42] Fixed geometry collections geopackage --- .../widget/lookup/PopupSelectWidget.scala | 45 +++++++++------- .../src/main/scala/ch/wsl/box/rest/Boot.scala | 2 +- .../rest/io/geotools/GeoPackageWriter.scala | 28 ++++++++-- .../ch/wsl/box/model/shared/GeoJson.scala | 53 ++++++++++++++----- 4 files changed, 88 insertions(+), 40 deletions(-) diff --git a/client/src/main/scala/ch/wsl/box/client/views/components/widget/lookup/PopupSelectWidget.scala b/client/src/main/scala/ch/wsl/box/client/views/components/widget/lookup/PopupSelectWidget.scala index f20d659f..c8cb0967 100644 --- a/client/src/main/scala/ch/wsl/box/client/views/components/widget/lookup/PopupSelectWidget.scala +++ b/client/src/main/scala/ch/wsl/box/client/views/components/widget/lookup/PopupSelectWidget.scala @@ -63,6 +63,7 @@ object PopupSelectWidget extends ComponentWidgetFactory { val mode:Property[Mode] = Property(Search) + val enableEdit = field.params.exists(_.jsOpt("enableEdit").exists(_ == Json.True)) val childMetadata:Property[Option[JSONMetadata]] = Property(None) val lookupData = Property(Json.obj()) @@ -79,10 +80,12 @@ object PopupSelectWidget extends ComponentWidgetFactory { def optionList(nested:NestedInterceptor):Modifier = div( label(Labels.popup.search),br, TextInput(searchProp,500.milliseconds)(width := 100.pct, id := searchId),br,br, - button(`type` := "button", Icons.plus, " ", Labels.entities.`new`,ClientConf.style.boxButtonImportant,onclick :+= ((e:Event) => { - lookupData.set(Json.obj()) - mode.set(Edit) - })),br,br, + if(enableEdit) { + button(`type` := "button", Icons.plus, " ", Labels.entities.`new`, ClientConf.style.boxButtonImportant, onclick :+= ((e: Event) => { + lookupData.set(Json.obj()) + mode.set(Edit) + })) + } else Seq[Modifier](),br,br, nested(showIf(modalStatus.transform(_ == Status.Open)) { div(ClientConf.style.popupEntiresList,nested(produce(searchProp) { searchTerm => div( @@ -94,22 +97,24 @@ object PopupSelectWidget extends ComponentWidgetFactory { model.set(Some(x.get)) e.preventDefault() })), - a(marginRight := 15.px, Icons.pencil_square,onclick :+= ((e: Event) => { - val lookup = fieldLookup.asInstanceOf[JSONFieldLookupRemote] - val keys = Seq((lookup.map.foreign.keyColumns.head,x.get.id)) // single lookup supported - //val keys = lookup.map.localKeysColumn.zip(lookup.map.foreign.keyColumns).map{ case (local,remote) => remote -> x.get.id} - - val data = editForm match { - case Some(value) => services.rest.get(EntityKind.FORM.kind,services.clientSession.lang(),value,JSONID.fromMap(keys),public) - case None => services.rest.get(EntityKind.ENTITY.kind,services.clientSession.lang(),lookup.lookupEntity,JSONID.fromMap(keys),public) - } - - data.map { record => - lookupData.set(record) - mode.set(Edit) - } - e.preventDefault() - })) + if(enableEdit) { + a(marginRight := 15.px, Icons.pencil_square, onclick :+= ((e: Event) => { + val lookup = fieldLookup.asInstanceOf[JSONFieldLookupRemote] + val keys = Seq((lookup.map.foreign.keyColumns.head, x.get.id)) // single lookup supported + //val keys = lookup.map.localKeysColumn.zip(lookup.map.foreign.keyColumns).map{ case (local,remote) => remote -> x.get.id} + + val data = editForm match { + case Some(value) => services.rest.get(EntityKind.FORM.kind, services.clientSession.lang(), value, JSONID.fromMap(keys), public) + case None => services.rest.get(EntityKind.ENTITY.kind, services.clientSession.lang(), lookup.lookupEntity, JSONID.fromMap(keys), public) + } + + data.map { record => + lookupData.set(record) + mode.set(Edit) + } + e.preventDefault() + })) + } else Seq[Modifier]() ).render } ) diff --git a/server/src/main/scala/ch/wsl/box/rest/Boot.scala b/server/src/main/scala/ch/wsl/box/rest/Boot.scala index dee115f9..2b6896f3 100755 --- a/server/src/main/scala/ch/wsl/box/rest/Boot.scala +++ b/server/src/main/scala/ch/wsl/box/rest/Boot.scala @@ -114,7 +114,7 @@ object Boot extends App { val (name,app_version,https) = args.length match { case 3 => (args(0),args(1),args(2).toBoolean) case 2 => (args(0),args(1),false) - case _ => ("Standalone","DEV",true) + case _ => ("Standalone","DEV",false) } def run(name:String,app_version:String,module:Design) { diff --git a/server/src/main/scala/ch/wsl/box/rest/io/geotools/GeoPackageWriter.scala b/server/src/main/scala/ch/wsl/box/rest/io/geotools/GeoPackageWriter.scala index 3a74fb25..dc76b9a3 100644 --- a/server/src/main/scala/ch/wsl/box/rest/io/geotools/GeoPackageWriter.scala +++ b/server/src/main/scala/ch/wsl/box/rest/io/geotools/GeoPackageWriter.scala @@ -69,8 +69,16 @@ object GeoPackageWriter { val geopkg = new GeoPackage(File.createTempFile("geopkg", "db")) geopkg.init() + val geometry_by_type: Map[String, Seq[Option[GeoJson.Geometry]]] = data.geometry.flatMap { case (geomName, values) => + val kinds = values.groupBy(_.map(_.geomName)) + kinds.map{ case (geomType, values) => + Seq(geomName,geomType.getOrElse("no_geometry").toLowerCase).mkString("_") -> values + } + } + + geometry_by_type.foreach { case (geomName, values) => - data.geometry.foreach { case (geomName, values) => + val geomFieldName = data.types.find(c => c.typ == JSONFieldTypes.GEOMETRY && geomName.startsWith(c.name)).map(_.name).getOrElse("no-field") val srid = values.flatten .groupBy(_.crs.srid) // group the entrys by their SRID @@ -80,14 +88,14 @@ object GeoPackageWriter { val builder: SimpleFeatureTypeBuilder = new SimpleFeatureTypeBuilder builder.setName(s"${name}_$geomName") - //builder.setCRS(org.geotools.referencing.CRS.decode(crs.name)) + //builder.setCRS(org.geotools.referencing.CRS.decode(s"EPSG:$srid")) data.types.foreach { t => t.typ match { case JSONFieldTypes.INTEGER => builder.add(t.name, classOf[Integer]) case JSONFieldTypes.NUMBER => builder.add(t.name, classOf[Double]) - case JSONFieldTypes.GEOMETRY if t.name == geomName => geomSchema(builder, t.name, values,srid) + case JSONFieldTypes.GEOMETRY if geomFieldName == t.name => geomSchema(builder, geomName, values,srid) case JSONFieldTypes.GEOMETRY => () case _ => builder.add(t.name, classOf[String]) } @@ -99,8 +107,17 @@ object GeoPackageWriter { val featureBuilder = new SimpleFeatureBuilder(schema) - for (r <- data.toMap) yield { - data.types.filter(tt => tt.typ != JSONFieldTypes.GEOMETRY || tt.name == geomName).foreach { c => + + def filterSameGeom(r:Map[String,Json]):Boolean = { + for{ + js <- r.get(geomFieldName) + obj <- js.as[GeoJson.Geometry].toOption + value <- values.find(_.nonEmpty).flatten + } yield value.geomName == obj.geomName + }.getOrElse(false) + + for (r <- data.toMap if filterSameGeom(r) ) yield { + data.types.filter(tt => tt.typ != JSONFieldTypes.GEOMETRY || geomName.startsWith(tt.name)).foreach { c => fieldWriter(r.getOrElse(c.name, Json.Null), c.typ, featureBuilder) } collection.add(featureBuilder.buildFeature(null)) @@ -117,6 +134,7 @@ object GeoPackageWriter { entry.setTableName(name) //entry.setDescription("Cities of the world") + geopkg.addCRS(srid) geopkg.add(entry, collection) //geopkg.createSpatialIndex(entry) diff --git a/shared/src/main/scala/ch/wsl/box/model/shared/GeoJson.scala b/shared/src/main/scala/ch/wsl/box/model/shared/GeoJson.scala index 6143bb93..01eb3fa7 100644 --- a/shared/src/main/scala/ch/wsl/box/model/shared/GeoJson.scala +++ b/shared/src/main/scala/ch/wsl/box/model/shared/GeoJson.scala @@ -74,6 +74,10 @@ object GeoJson { override def removeSimple(toDelete: SingleGeometry): Option[Geometry] = if(toDelete == this) None else Some(this) } + sealed trait GeometryObject { + def name:String + } + // geojson geometry from postgis // {"type":"Point","crs":{"type":"name","properties":{"name":"EPSG:21781"}},"coordinates":[720000,112000]} sealed trait Geometry { @@ -116,33 +120,41 @@ object GeoJson { } - case object Empty extends SingleGeometry { + case object Empty extends SingleGeometry with GeometryObject { + + override val name: String = "EMPTY" override def crs: CRS = CRS("EPSG:0") - override def geomName: String = "EMPTY" + override def geomName: String = name override def toString(precision: Double): String = "Empty" override def convert(f: Coordinates => Coordinates, crs: CRS): Geometry = this } + object Point extends GeometryObject { + override val name: String = "POINT" + } case class Point(coordinates: Coordinates, crs:CRS) extends SingleGeometry { override def allCoordinates: Seq[Coordinates] = Seq(coordinates) - override def geomName: String = "POINT" + override def geomName: String = Point.name override def toString(precision:Double): String = s"$geomName(${coordinates.toString(precision)})" override def convert(f: Coordinates => Coordinates,crs:CRS): Point = Point(f(coordinates),crs) } + object LineString extends GeometryObject { + override val name: String = "LINESTRING" + } case class LineString(coordinates: Seq[Coordinates], crs:CRS) extends SingleGeometry { override def allCoordinates: Seq[Coordinates] = coordinates - override def geomName: String = "LINESTRING" + override def geomName: String = LineString.name override def toString(precision:Double): String = s"$geomName(${coordinates.map(_.toString(precision)).mkString(",")})" @@ -151,11 +163,12 @@ object GeoJson { override def convert(f: Coordinates => Coordinates,crs:CRS): LineString = LineString(coordinates.map(f),crs) } + case class MultiPoint(coordinates: Seq[Coordinates], crs:CRS) extends Geometry { override def allCoordinates: Seq[Coordinates] = coordinates - override def geomName: String = "MULTIPOINT" + override def geomName: String = MultiPoint.name override def toString(precision:Double): String = s"$geomName(${coordinates.map(_.toString(precision)).mkString("(","),(",")")})" @@ -169,7 +182,9 @@ object GeoJson { } - object MultiPoint { + object MultiPoint extends GeometryObject { + override val name: String = "MULTIPOINT" + def fromPoints(points:Seq[Point]) = { val crs = points.map(_.crs).distinct if (crs.length != 1) throw new Exception("Can't handle different CRS in the same geometry") @@ -181,7 +196,7 @@ object GeoJson { override def allCoordinates: Seq[Coordinates] = coordinates.flatten - override def geomName: String = "MULTILINESTRING" + override def geomName: String = MultiLineString.name override def toString(precision:Double): String = s"$geomName(${coordinates.map(_.map(_.toString(precision)).mkString(",")).mkString("(","),(",")")})" @@ -195,7 +210,10 @@ object GeoJson { } - object MultiLineString { + object MultiLineString extends GeometryObject { + + override val name: String = "MULTILINESTRING" + def fromLines(lines:Seq[LineString]) = { val crs = lines.map(_.crs).distinct if(crs.length != 1) throw new Exception("Can't handle different CRS in the same geometry") @@ -203,11 +221,15 @@ object GeoJson { } } + object Polygon extends GeometryObject { + override val name: String = "POLYGON" + } + case class Polygon(coordinates: Seq[Seq[Coordinates]], crs:CRS) extends SingleGeometry { override def allCoordinates: Seq[Coordinates] = coordinates.flatten - override def geomName: String = "POLYGON" + override def geomName: String = Polygon.name override def toString(precision:Double): String = s"$geomName (${coordinates.map(_.map(_.toString(precision)).mkString(",")).mkString("(","),(",")")})" @@ -220,7 +242,7 @@ object GeoJson { override def allCoordinates: Seq[Coordinates] = coordinates.flatMap(_.flatten) - override def geomName: String = "MULTIPOLYGON" + override def geomName: String = MultiPolygon.name override def toString(precision:Double): String = s"$geomName (${coordinates.map(_.map(_.map(_.toString(precision)).mkString(",")).mkString("(","),(",")")).mkString("(","),(",")")})" @@ -234,7 +256,10 @@ object GeoJson { } - object MultiPolygon { + object MultiPolygon extends GeometryObject { + + override val name: String = "MULTIPOLYGON" + def fromPolygons(polygons: Seq[Polygon]) = { val crs = polygons.map(_.crs).distinct if (crs.length != 1) throw new Exception("Can't handle different CRS in the same geometry") @@ -298,9 +323,9 @@ object GeoJson { } geoms.filterNot(_.geomName == Empty.geomName).map(_.geomName).distinct.toList match { - case "POINT" :: Nil => MultiPoint.fromPoints(geoms.map{ case p:Point => p}) - case "LINESTRING" :: Nil => MultiLineString.fromLines(geoms.map{ case p:LineString => p}) - case "POLYGON" :: Nil => MultiPolygon.fromPolygons(geoms.map{ case p:Polygon => p}) + case Point.name :: Nil => MultiPoint.fromPoints(geoms.map{ case p:Point => p}) + case LineString.name :: Nil => MultiLineString.fromLines(geoms.map{ case p:LineString => p}) + case Polygon.name :: Nil => MultiPolygon.fromPolygons(geoms.map{ case p:Polygon => p}) case _ => GeometryCollection(geoms,crs) } } From 3d89fe42070ce1f40fb2e234af7286b224a341f6 Mon Sep 17 00:00:00 2001 From: Andrea Minetti Date: Tue, 3 Mar 2026 10:16:38 +0100 Subject: [PATCH 14/42] Building client using vite --- .github/workflows/release.yml | 19 +++- build.sbt | 1 + client/index.html | 1 - client/package.json | 4 +- client/public/vite.svg | 1 - .../main/scala/ch/wsl/box/client/Main.scala | 2 +- .../main/scala/ch/wsl/box/client/Module.scala | 1 + .../main/scala/ch/wsl/box/client/db/DB.scala | 15 +-- client/{vite.config.js => vite.config.app.js} | 16 +++- client/vite.config.worker.js | 25 +++++ client/{ => workers}/postgres.worker.js | 7 +- {resources => client/workers}/sw.js | 0 .../scala/ch/wsl/box/rest/routes/UI.scala | 95 ++----------------- .../box/services/config/ConfFileAndDb.scala | 7 -- .../box/services/config/DummyFullConfig.scala | 6 -- .../wsl/box/services/config/FullConfig.scala | 3 - .../config/FullConfigFileOnlyImpl.scala | 6 -- .../ch/wsl/box/templates/index.scala.html | 77 +++------------ 18 files changed, 91 insertions(+), 195 deletions(-) delete mode 100644 client/public/vite.svg rename client/{vite.config.js => vite.config.app.js} (68%) create mode 100644 client/vite.config.worker.js rename client/{ => workers}/postgres.worker.js (76%) rename {resources => client/workers}/sw.js (100%) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3c0856c5..130e1d27 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,10 +11,10 @@ jobs: - uses: actions/checkout@v2.3.4 with: fetch-depth: 0 - - name: Node 16.x + - name: Node 22.x uses: actions/setup-node@v3 with: - node-version: 16 + node-version: 22 - name: Setup JDK uses: actions/setup-java@v4 with: @@ -29,8 +29,21 @@ jobs: passphrase: ${{ secrets.PGP_PASSPHRASE }} - name: List keys run: gpg -K + - id: tag + run: | + if [[ "${GITHUB_REF#refs/tags/}" != "$GITHUB_REF" ]]; then + echo "tag=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" + else + echo "tag=$(git tag --points-at $GITHUB_SHA | head -n1)" >> "$GITHUB_OUTPUT" + fi + - name: Export VITE_BOX_VERSION + run: echo "VITE_BOX_VERSION=${{ steps.tag.outputs.tag }}" >> $GITHUB_ENV - name: Build bundle - run: sbt -J-Xmx4G -J-XX:MaxMetaspaceSize=1G -J-Xss10m client/fullOptJS::webpack + run: | + cd client + npm install + npm run build + cd .. - name: Version info run: | sbt server/version diff --git a/build.sbt b/build.sbt index eecb06aa..d42ad64e 100755 --- a/build.sbt +++ b/build.sbt @@ -74,6 +74,7 @@ lazy val server: Project = project Compile / packageBin / mainClass := Some("ch.wsl.box.rest.Boot"), Compile / run / mainClass := Some("ch.wsl.box.rest.Boot"), Compile / resourceDirectory := baseDirectory.value / "../resources", + Compile / resourceDirectory := baseDirectory.value / "../client/dist", Test / unmanagedResourceDirectories += baseDirectory.value / "../db", Test / unmanagedSourceDirectories += baseDirectory.value / "../db", Test / fork := true, diff --git a/client/index.html b/client/index.html index 091419ac..5ccf2e50 100644 --- a/client/index.html +++ b/client/index.html @@ -3,7 +3,6 @@ - Vite App diff --git a/client/package.json b/client/package.json index 272a0390..13ab0c10 100644 --- a/client/package.json +++ b/client/package.json @@ -5,7 +5,9 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vite build", + "build:app": "vite build --config vite.config.app.js", + "build:worker": "vite build --config vite.config.worker.js", + "build": "npm run build:worker && npm run build:app", "preview": "vite preview" }, "dependencies": { diff --git a/client/public/vite.svg b/client/public/vite.svg deleted file mode 100644 index e7b8dfb1..00000000 --- a/client/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/client/src/main/scala/ch/wsl/box/client/Main.scala b/client/src/main/scala/ch/wsl/box/client/Main.scala index 5d01d445..1f27c4ae 100644 --- a/client/src/main/scala/ch/wsl/box/client/Main.scala +++ b/client/src/main/scala/ch/wsl/box/client/Main.scala @@ -68,7 +68,7 @@ object Main extends Logging { } uiConf <- services.rest.ui() labels <- services.rest.labels(services.clientSession.lang()) - _ <- if(services.data.name == DaoLocalDbImpl.name) DB.init() else Future.successful() + _ <- if(services.data.name == DaoLocalDbImpl.name) DB.init(version) else Future.successful() } yield { Logger.root.clearHandlers().clearModifiers().withHandler(minimumLevel = Some(ClientConf.loggerLevel)).replace() diff --git a/client/src/main/scala/ch/wsl/box/client/Module.scala b/client/src/main/scala/ch/wsl/box/client/Module.scala index ba0f8821..15fe2eb1 100644 --- a/client/src/main/scala/ch/wsl/box/client/Module.scala +++ b/client/src/main/scala/ch/wsl/box/client/Module.scala @@ -31,5 +31,6 @@ object Module { .bind[ClientSession].toEagerSingleton .bind[Navigator].toEagerSingleton .bind[NotificationChannel].to[NotificationWebSocket] + .bind[BoxStyleFactory].to[GlobalStyleFactory] .bind[MainLayout].to[BoxMainLayout] } diff --git a/client/src/main/scala/ch/wsl/box/client/db/DB.scala b/client/src/main/scala/ch/wsl/box/client/db/DB.scala index c3603daf..165bd2a9 100644 --- a/client/src/main/scala/ch/wsl/box/client/db/DB.scala +++ b/client/src/main/scala/ch/wsl/box/client/db/DB.scala @@ -12,18 +12,19 @@ import scala.scalajs.js object DB { - private val worker_options = new WorkerOptions {} - worker_options.`type` = WorkerType.module - //val worker_url = new URL("./postgres.worker.js",js.`import`.meta.asInstanceOf[String]).asInstanceOf[String] - private val worker = new Worker("./postgres.worker.js",worker_options) + var connection:PGliteWorker = null + var localRecord: LocalRecordDAO = null - val connection = new PGliteWorker(worker) + def init(version:String)(implicit ex:ExecutionContext) = { - val localRecord = new LocalRecordDAO(connection) + val worker_options = new WorkerOptions {} + worker_options.`type` = WorkerType.module - def init()(implicit ex:ExecutionContext) = { + val worker = new Worker(s"./ui/postgres.worker.${version}.js",worker_options) + connection = new PGliteWorker(worker) + localRecord = new LocalRecordDAO(connection) for { result <- localRecord.init() diff --git a/client/vite.config.js b/client/vite.config.app.js similarity index 68% rename from client/vite.config.js rename to client/vite.config.app.js index d111500d..070a416a 100644 --- a/client/vite.config.js +++ b/client/vite.config.app.js @@ -1,7 +1,8 @@ import { defineConfig } from "vite"; import scalaJSPlugin from "@scala-js/vite-plugin-scalajs"; +import { loadEnv } from 'vite'; - +const env = loadEnv(process.env.NODE_ENV, process.cwd()); export default defineConfig({ server: { @@ -13,9 +14,6 @@ export default defineConfig({ optimizeDeps: { exclude: ['@electric-sql/pglite'], }, - worker: { - format: 'es', - }, resolve: { alias: { '~bootstrap': './node_modules/bootstrap', @@ -35,4 +33,14 @@ export default defineConfig({ // default: 'scalajs' (so the plugin recognizes URIs starting with 'scalajs:') uriPrefix: 'scalajs', })], + build: { + emptyOutDir: false, + rollupOptions: { + output: { + entryFileNames: `ui/[name].${env.VITE_BOX_VERSION}.js`, + chunkFileNames: `ui/[name].${env.VITE_BOX_VERSION}.js`, + assetFileNames: `ui/[name].${env.VITE_BOX_VERSION}.[ext]`, + }, + }, + }, }); \ No newline at end of file diff --git a/client/vite.config.worker.js b/client/vite.config.worker.js new file mode 100644 index 00000000..18424f27 --- /dev/null +++ b/client/vite.config.worker.js @@ -0,0 +1,25 @@ +import { defineConfig } from 'vite' +import { loadEnv } from 'vite'; + +const env = loadEnv(process.env.NODE_ENV, process.cwd()); + +export default defineConfig({ + optimizeDeps: { + exclude: ['@electric-sql/pglite'], + }, + build: { + outDir: './dist', + emptyOutDir: false, + rollupOptions: { + input: ['./workers/postgres.worker.js','./workers/sw.js'], + // externalize the package so Rollup doesn't bundle its JS/WASM + output: { + // keep emitted asset names predictable + entryFileNames: `ui/[name].${env.VITE_BOX_VERSION}.js`, + chunkFileNames: `ui/[name].${env.VITE_BOX_VERSION}.js`, + assetFileNames: `ui/[name].${env.VITE_BOX_VERSION}.[ext]`, + }, + } + + }, +}) diff --git a/client/postgres.worker.js b/client/workers/postgres.worker.js similarity index 76% rename from client/postgres.worker.js rename to client/workers/postgres.worker.js index 61ffcf72..d1686e9a 100644 --- a/client/postgres.worker.js +++ b/client/workers/postgres.worker.js @@ -1,7 +1,8 @@ +import { PGlite } from '@electric-sql/pglite' +import { worker } from '@electric-sql/pglite/worker' + console.log('Worker started'); try { - import { PGlite } from '@electric-sql/pglite' - import { worker } from '@electric-sql/pglite/worker' worker({ async init() { // Create and return a PGlite instance @@ -13,4 +14,4 @@ try { } self.onmessage = function(event) { console.log('Message received in worker:', event.data); -}; \ No newline at end of file +}; diff --git a/resources/sw.js b/client/workers/sw.js similarity index 100% rename from resources/sw.js rename to client/workers/sw.js diff --git a/server/src/main/scala/ch/wsl/box/rest/routes/UI.scala b/server/src/main/scala/ch/wsl/box/rest/routes/UI.scala index 05e62263..3d98beb6 100755 --- a/server/src/main/scala/ch/wsl/box/rest/routes/UI.scala +++ b/server/src/main/scala/ch/wsl/box/rest/routes/UI.scala @@ -24,77 +24,8 @@ object UI { import Directives._ - val postgresWorker = path("postgres.worker.js") { - get{ - val worker = s""" - |try { - | import { PGlite } from '@electric-sql/pglite' - |} catch (error) { - | console.error('Error importing PGlite:', error); - |} - | - |try { - | import { worker } from '@electric-sql/pglite/worker' - |} catch (error) { - | console.error('Error importing worker:', error); - |} - | - |try{ - | worker({ - | async init() { - | // Create and return a PGlite instance - | return new PGlite('idb://box-pgdata') - | }, - | }) - |} catch (error) { - | console.error('Error starting worker:', error); - |} - |""".stripMargin - - val simpleWorker = - s""" - |console.log('Worker started'); - | - |try { - | const { PGlite } = await import('./@electric-sql/pglite/dist/index.js') - | const { worker } = await import('./@electric-sql/pglite/dist/worker/index.js') - | worker({ - | async init() { - | // Create and return a PGlite instance - | return new PGlite('idb://box-pgdata') - | }, - | }) - |} catch (error) { - | console.error('Error starting worker:', error); - |} - | - |self.onmessage = function(event) { - | console.log('Message received in worker:', event.data); - |}; - |""".stripMargin - - complete(HttpEntity(MediaTypes.`application/javascript`.toContentType(HttpCharsets.`UTF-8`) ,simpleWorker)) - } - } - - val postgresWasm = path("postgres.wasm") { - WebJarsSupport.fullPath("@electric-sql/pglite/dist/postgres.wasm",ContentType.apply(MediaType.applicationBinary("wasm",MediaType.Compressible))) - } - val postgresData = path("postgres.data") { - WebJarsSupport.fullPath("@electric-sql/pglite/dist/postgres.data",ContentTypes.`text/plain(UTF-8)`) - } - def clientFiles(implicit system:ActorSystem,services:Services):Route = { - pathPrefix("dev") { - getFromBrowseableDirectories("./client/target/scala-2.13/scalajs-bundler/main") - } ~ - pathPrefix("db") { - postgresWorker ~ - postgresWasm ~ - postgresData ~ - WebJarsSupport.webJars - } ~ pathPrefix("icon") { pathPrefix("icon.png") { get{ @@ -117,9 +48,6 @@ object UI { File.completeFile(BoxFile(Some(IconGenerator.withName(services.config.initials,services.config.mainColor,16,8)),Some("image/png"),"favicon-16x16.png")) } } ~ - pathPrefix("sw.js") { - getFromResource("sw.js") - } ~ pathPrefix("pdf") { extractUnmatchedPath { e => @@ -157,27 +85,20 @@ object UI { complete(HttpEntity(ContentType(MediaType.applicationWithOpenCharset("manifest+json","webmanifest"),HttpCharsets.`UTF-8`) ,manifest)) } } ~ - pathPrefix("assets") { + pathPrefix("ui") { + extractUnmatchedPath { path => + val assetName = path.toString().substring(1) + path.endsWith("wasm") match { + case false => getFromResource(s"ui/$assetName") + case true => getFromResource(s"ui/$assetName", contentType = ContentType.apply(MediaType.applicationBinary("wasm",MediaType.Compressible))) + } - WebJarsSupport.webJars - } ~ - pathPrefix("bundle") { - WebJarsSupport.bundle - } ~ - pathPrefix("redactor.js") { - get{ - complete(HttpEntity(ContentType(MediaTypes.`application/javascript`,HttpCharsets.`UTF-8`) ,services.config.redactorJs)) - } - }~ - pathPrefix("redactor.css") { - get{ - complete(HttpEntity(ContentType(MediaTypes.`text/css`,HttpCharsets.`UTF-8`) ,services.config.redactorCSS)) } } ~ get { complete { val module = if(services.config.localDb) AvailableUIModule.prod else AvailableUIModule.prodNoLocalDb - ch.wsl.box.templates.html.index.render(BoxBuildInfo.version,module,services.config.enableRedactor,services.config.devServer,services.config.basePath,services.config.mainColor,services.config.matomo) + ch.wsl.box.templates.html.index.render(BoxBuildInfo.version,module,services.config.basePath,services.config.mainColor,services.config.matomo) } } } diff --git a/server/src/main/scala/ch/wsl/box/services/config/ConfFileAndDb.scala b/server/src/main/scala/ch/wsl/box/services/config/ConfFileAndDb.scala index b91497e1..b3a728cd 100644 --- a/server/src/main/scala/ch/wsl/box/services/config/ConfFileAndDb.scala +++ b/server/src/main/scala/ch/wsl/box/services/config/ConfFileAndDb.scala @@ -109,13 +109,6 @@ class ConfFileAndDb(connection:Connection)(implicit ec:ExecutionContext) extends result } - def enableRedactor:Boolean = { - Try(_conf("redactor.js")).toOption.exists(_.nonEmpty) && - Try(_conf("redactor.css")).toOption.exists(_.nonEmpty) - } - - def redactorJs = Try(_conf("redactor.js")).getOrElse("") - def redactorCSS = Try(_conf("redactor.css")).getOrElse("") def filterPrecisionDatetime = Try(_conf("filter.precision.datetime").toUpperCase).toOption match { case Some("DATE") => JSONFieldTypes.DATE diff --git a/server/src/main/scala/ch/wsl/box/services/config/DummyFullConfig.scala b/server/src/main/scala/ch/wsl/box/services/config/DummyFullConfig.scala index 24c9a42d..8b1c0335 100644 --- a/server/src/main/scala/ch/wsl/box/services/config/DummyFullConfig.scala +++ b/server/src/main/scala/ch/wsl/box/services/config/DummyFullConfig.scala @@ -34,12 +34,6 @@ class DummyFullConfig extends DummyConfigImpl with FullConfig { override def fksLookupRowsLimit: Int = 50 - override def enableRedactor: Boolean = false - - override def redactorJs: String = "" - - override def redactorCSS: String = "" - override def devServer: Boolean = false override def clientConf: Map[String, String] = Map() diff --git a/server/src/main/scala/ch/wsl/box/services/config/FullConfig.scala b/server/src/main/scala/ch/wsl/box/services/config/FullConfig.scala index eb8fd3a4..9d4b55f0 100644 --- a/server/src/main/scala/ch/wsl/box/services/config/FullConfig.scala +++ b/server/src/main/scala/ch/wsl/box/services/config/FullConfig.scala @@ -18,9 +18,6 @@ trait FullConfig extends Config { def enableCache:Boolean def fksLookupLabels:com.typesafe.config.Config def fksLookupRowsLimit:Int - def enableRedactor:Boolean - def redactorJs:String - def redactorCSS:String def devServer:Boolean def frontendUrl:String diff --git a/server/src/main/scala/ch/wsl/box/services/config/FullConfigFileOnlyImpl.scala b/server/src/main/scala/ch/wsl/box/services/config/FullConfigFileOnlyImpl.scala index 010fcb17..0acf8ae4 100644 --- a/server/src/main/scala/ch/wsl/box/services/config/FullConfigFileOnlyImpl.scala +++ b/server/src/main/scala/ch/wsl/box/services/config/FullConfigFileOnlyImpl.scala @@ -34,12 +34,6 @@ class FullConfigFileOnlyImpl extends ConfigFileImpl with FullConfig { override def fksLookupRowsLimit: Int = throw new ConfigNotAvailableException - override def enableRedactor: Boolean = throw new ConfigNotAvailableException - - override def redactorJs: String = throw new ConfigNotAvailableException - - override def redactorCSS: String = throw new ConfigNotAvailableException - override def devServer: Boolean = throw new ConfigNotAvailableException override def clientConf: Map[String, String] = throw new ConfigNotAvailableException diff --git a/server/src/main/twirl/ch/wsl/box/templates/index.scala.html b/server/src/main/twirl/ch/wsl/box/templates/index.scala.html index 20f83990..117c624f 100644 --- a/server/src/main/twirl/ch/wsl/box/templates/index.scala.html +++ b/server/src/main/twirl/ch/wsl/box/templates/index.scala.html @@ -1,35 +1,23 @@ -@import org.webjars.WebJarAssetLocator -@import scala.util.Try -@import scala.util.Random @import ch.wsl.box.viewmodel.MatomoConfig -@(version:String,uiModule:String,enableRedactor:Boolean,devServer:Boolean,basePath:String,color:String,matomo:Option[MatomoConfig]) - +@(version:String,uiModule:String,basePath:String,color:String,matomo:Option[MatomoConfig]) - + + + - - - - + + - -@* NOT READY @if(!devServer) {*@ -@* *@ -@* }*@ - - - - - - - - - - - - - - - - @for(group <- Seq("latin","latin-ext")) { - @for( width <- Seq("300","400","600","700")) { - - } - } - - - - - - - - @if(enableRedactor) { - - - } - - - - @if(devServer) { - - - } else { - - } - - - - + - + + @matomo.map{ m => @@ -109,8 +55,9 @@ } - +
+ From 9723a19b13ae6cb2921eb5c14f08d2542b076e57 Mon Sep 17 00:00:00 2001 From: Andrea Minetti Date: Tue, 3 Mar 2026 10:39:55 +0100 Subject: [PATCH 15/42] Set increase memory for SBT --- .github/workflows/release.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 130e1d27..35857a61 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,6 +39,8 @@ jobs: - name: Export VITE_BOX_VERSION run: echo "VITE_BOX_VERSION=${{ steps.tag.outputs.tag }}" >> $GITHUB_ENV - name: Build bundle + env: + SBT_OPTS: -XX:+UseG1GC -Xmx4G -Xss10M -XX:MaxInlineLevel=20 run: | cd client npm install From 0b26fe244d7111787911914f7cad6077ea9eae31 Mon Sep 17 00:00:00 2001 From: Andrea Minetti Date: Wed, 4 Mar 2026 15:46:49 +0100 Subject: [PATCH 16/42] removed webpack form sbt --- build.sbt | 48 +----------------------------------------------- 1 file changed, 1 insertion(+), 47 deletions(-) diff --git a/build.sbt b/build.sbt index d42ad64e..0be17fe5 100755 --- a/build.sbt +++ b/build.sbt @@ -120,50 +120,7 @@ lazy val client: Project = (project in file("client")) // use Scala.js provided launcher code to start the client app scalaJSUseMainModuleInitializer := true, scalaJSStage := FullOptStage, - externalNpm := baseDirectory.value, //.map(f => new File(f.getPath + "/client")).value, -// Compile / npmDependencies ++= Seq( -// "ol" -> "8.1.0", -// "proj4" -> "2.9.1", -// "@types/proj4" -> "2.5.3", -// "ol-ext" -> "4.0.11", -// //"@siedlerchr/types-ol-ext" -> "3.2.4", -// "jsts" -> "2.7.1", -// "@types/jsts" -> "0.17.13", -// "jquery" -> "3.4.1", -// "@types/jquery" -> "3.5.6", -// "popper.js" -> "1.16.1", -// "bootstrap" -> "4.1.3", -// "@types/bootstrap" -> "4.1.3", -// "@fortawesome/fontawesome-free" -> "5.15.4", -// "flatpickr" -> "4.6.3", -// "monaco-editor" -> "0.34.0", -// "quill" -> "1.3.7", -// "@types/quill" -> "1.3.10", -// "@fontsource/open-sans" -> "5.0.15", -// "file-saver" -> "2.0.5", -// "@types/file-saver" -> "2.0.1", -// "js-md5" -> "0.7.3", -// "@types/js-md5" -> "0.4.2", -// "striptags" -> "3.2.0", -// "toolcool-range-slider" -> "4.0.28", -// "hotkeys-js" -> "3.10.0", -// "crypto-browserify" -> "3.12.0", -// "buffer" -> "6.0.3", -// "stream-browserify" -> "3.0.0", -// "choices.js" -> "11.1.0", -// "autocompleter" -> "7.0.1", -// "xlsx-js-style" -> "1.2.0", -// "jspdf" -> "2.5.1", -// "jspdf-autotable" -> "3.5.28", -// "gridstack" -> "12.2.2", -// "jspreadsheet-ce" -> "git://github.com/jspreadsheet/ce.git#2e7389f8f6a84d260603bbac06f00bb404e1ba49", //v5.0.0 -// "jsuites" -> "5.9.1", -// "@electric-sql/pglite" -> "0.2.17", -// "@electric-sql/pglite-repl" -> "0.2.17", -// "compressorjs" -> "1.2.1", -// "shapefile" -> "0.6.6", -// "@types/shapefile" -> "0.6.4", -// ), + externalNpm := baseDirectory.value, stIncludeDev := false, stStdlib := List("es6", "es2018.asyncgenerator"), stIgnore += "@fontsource/open-sans", @@ -315,13 +272,10 @@ lazy val box = (project in file(".")) lazy val publishAll = taskKey[Unit]("Publish all modules") lazy val publishAllTask = { Def.sequential( - (client / clean), (server / clean), (serverCacheRedis / clean), (serverServices / clean), (codegen / clean), - (client / Compile / fullOptJS / webpack), - (client / Compile / fullOptJS), (codegen / Compile / compile), (sharedJVM / publishSigned), (codegen / publishSigned), From 133d2ecb8c405c450cbdb868df06dfd328f45c84 Mon Sep 17 00:00:00 2001 From: Andrea Minetti Date: Thu, 12 Mar 2026 10:17:20 +0100 Subject: [PATCH 17/42] Fixed (again) geopackage exporter --- .../rest/io/geotools/GeoPackageWriter.scala | 70 +++++++++++-------- 1 file changed, 42 insertions(+), 28 deletions(-) diff --git a/server/src/main/scala/ch/wsl/box/rest/io/geotools/GeoPackageWriter.scala b/server/src/main/scala/ch/wsl/box/rest/io/geotools/GeoPackageWriter.scala index dc76b9a3..522c0f79 100644 --- a/server/src/main/scala/ch/wsl/box/rest/io/geotools/GeoPackageWriter.scala +++ b/server/src/main/scala/ch/wsl/box/rest/io/geotools/GeoPackageWriter.scala @@ -60,6 +60,7 @@ object GeoPackageWriter { } + def geomNameTrim(name:String) = if(name.trim.isEmpty) "geom" else name.trim def write(name:String, data: DataResultTable)(implicit ex:ExecutionContext) = Future{ @@ -69,19 +70,27 @@ object GeoPackageWriter { val geopkg = new GeoPackage(File.createTempFile("geopkg", "db")) geopkg.init() - val geometry_by_type: Map[String, Seq[Option[GeoJson.Geometry]]] = data.geometry.flatMap { case (geomName, values) => - val kinds = values.groupBy(_.map(_.geomName)) - kinds.map{ case (geomType, values) => - Seq(geomName,geomType.getOrElse("no_geometry").toLowerCase).mkString("_") -> values - } - } + val geometry_by_type: Map[String, Seq[(GeoJson.Geometry,Map[String,Json])]] = data.types.filter(_.typ == JSONFieldTypes.GEOMETRY).flatMap { case geom => + val values: Seq[(GeoJson.Geometry, Map[String, Json])] = data.toMap + .flatMap(r => r.get(geom.name) + .flatMap(_.as[GeoJson.Geometry].toOption + .map(x => (x,r)) + ) + ) + val kinds = values.groupBy(_._1.geomName) + val r: Seq[(String, Seq[(GeoJson.Geometry, Map[String, Json])])] = kinds.map{ case (geomType, values) => + val name = geomNameTrim(geom.name) + Seq(name,geomType.toLowerCase).mkString("_") -> values + }.toSeq + r + }.toMap geometry_by_type.foreach { case (geomName, values) => - val geomFieldName = data.types.find(c => c.typ == JSONFieldTypes.GEOMETRY && geomName.startsWith(c.name)).map(_.name).getOrElse("no-field") + val geomFieldName = data.types.find(c => c.typ == JSONFieldTypes.GEOMETRY && geomName.startsWith(geomNameTrim(c.name))).map(_.name).getOrElse("no-field") - val srid = values.flatten - .groupBy(_.crs.srid) // group the entrys by their SRID + val srid = values + .groupBy(_._1.crs.srid) // group the entrys by their SRID .maxByOption(_._2.length) //take the srid with more entries .map(_._1) // take the SRID .getOrElse(0) //dafault SRID 0 -> no information @@ -91,14 +100,26 @@ object GeoPackageWriter { //builder.setCRS(org.geotools.referencing.CRS.decode(s"EPSG:$srid")) - data.types.foreach { t => - t.typ match { - case JSONFieldTypes.INTEGER => builder.add(t.name, classOf[Integer]) - case JSONFieldTypes.NUMBER => builder.add(t.name, classOf[Double]) - case JSONFieldTypes.GEOMETRY if geomFieldName == t.name => geomSchema(builder, geomName, values,srid) - case JSONFieldTypes.GEOMETRY => () - case _ => builder.add(t.name, classOf[String]) - } + + + data.types.zipWithIndex.foreach { case (t,i) => + + // to avoid double name of columns group data by name and in case add suffix + val sameName = data.types.map(_.name.trim).zipWithIndex.filter(_._1 == t.name.trim) + val n = if(sameName.length == 1) t.name.trim else { + val suffix = sameName.indexWhere{case (_,j) => i == j} + 1 + t.name.trim + "_" + suffix + } + + + t.typ match { + case JSONFieldTypes.INTEGER => builder.add(n, classOf[Integer]) + case JSONFieldTypes.NUMBER => builder.add(n, classOf[Double]) + case JSONFieldTypes.GEOMETRY if geomNameTrim(geomFieldName) == geomNameTrim(n) => geomSchema(builder, geomName, values.map(x => Some(x._1)), srid) + case JSONFieldTypes.GEOMETRY => () + case _ => builder.add(n, classOf[String]) + } + } val schema = builder.buildFeatureType() @@ -108,16 +129,8 @@ object GeoPackageWriter { val featureBuilder = new SimpleFeatureBuilder(schema) - def filterSameGeom(r:Map[String,Json]):Boolean = { - for{ - js <- r.get(geomFieldName) - obj <- js.as[GeoJson.Geometry].toOption - value <- values.find(_.nonEmpty).flatten - } yield value.geomName == obj.geomName - }.getOrElse(false) - - for (r <- data.toMap if filterSameGeom(r) ) yield { - data.types.filter(tt => tt.typ != JSONFieldTypes.GEOMETRY || geomName.startsWith(tt.name)).foreach { c => + for (r <- values.map(_._2)) yield { + data.types.filter(tt => tt.typ != JSONFieldTypes.GEOMETRY || geomName.startsWith(geomNameTrim(tt.name))).foreach { c => fieldWriter(r.getOrElse(c.name, Json.Null), c.typ, featureBuilder) } collection.add(featureBuilder.buildFeature(null)) @@ -132,7 +145,8 @@ object GeoPackageWriter { val entry = new FeatureEntry() - entry.setTableName(name) + entry.setTableName(name+"_"+geomName) + entry.setSrid(srid) //entry.setDescription("Cities of the world") geopkg.addCRS(srid) geopkg.add(entry, collection) From c6416f555d08c2691b424986234916e2fb665103 Mon Sep 17 00:00:00 2001 From: Andrea Minetti Date: Thu, 12 Mar 2026 14:14:40 +0100 Subject: [PATCH 18/42] Fixed exported client --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 35857a61..927ba495 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,7 +34,7 @@ jobs: if [[ "${GITHUB_REF#refs/tags/}" != "$GITHUB_REF" ]]; then echo "tag=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" else - echo "tag=$(git tag --points-at $GITHUB_SHA | head -n1)" >> "$GITHUB_OUTPUT" + echo "tag=$(git tag --points-at $GITHUB_SHA | head -n1 | cut -c2- )" >> "$GITHUB_OUTPUT" fi - name: Export VITE_BOX_VERSION run: echo "VITE_BOX_VERSION=${{ steps.tag.outputs.tag }}" >> $GITHUB_ENV From 0454d2835f9797810dd259575427e057c30bf8cb Mon Sep 17 00:00:00 2001 From: Andrea Minetti Date: Thu, 12 Mar 2026 15:12:11 +0100 Subject: [PATCH 19/42] Fixed exported client --- .github/workflows/release.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 927ba495..27392623 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,10 +32,14 @@ jobs: - id: tag run: | if [[ "${GITHUB_REF#refs/tags/}" != "$GITHUB_REF" ]]; then - echo "tag=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" + tag=${GITHUB_REF#refs/tags/} else - echo "tag=$(git tag --points-at $GITHUB_SHA | head -n1 | cut -c2- )" >> "$GITHUB_OUTPUT" + tag=$(git tag --points-at "$GITHUB_SHA" | head -n1) fi + # strip a single leading v if present + tag=${tag#v} + echo $tag + echo "tag=$tag" >> "$GITHUB_OUTPUT" - name: Export VITE_BOX_VERSION run: echo "VITE_BOX_VERSION=${{ steps.tag.outputs.tag }}" >> $GITHUB_ENV - name: Build bundle From 56109f5a51bd658d4ff21b408aee2ac8947e0ef0 Mon Sep 17 00:00:00 2001 From: Andrea Minetti Date: Tue, 17 Mar 2026 12:43:37 +0100 Subject: [PATCH 20/42] Translate draft --- client/package.json | 2 +- .../wsl/box/client/styles/GlobalStyles.scala | 8 ++ .../ch/wsl/box/client/mocks/RestMock.scala | 2 +- .../scala/ch/wsl/box/model/Translations.scala | 26 +++++- .../main/scala/ch/wsl/box/rest/Module.scala | 5 +- .../ch/wsl/box/rest/routes/v1/Admin.scala | 49 ++++++++++- .../scala/ch/wsl/box/services/Services.scala | 2 + .../box/services/config/ConfFileAndDb.scala | 10 ++- .../wsl/box/services/config/FullConfig.scala | 7 +- .../translation/DeepLTranslateService.scala | 82 +++++++++++++++++++ .../translation/TranslateService.scala | 8 ++ .../ch/wsl/box/rest/BoxFormAdminSpec.scala | 2 +- .../box/rest/FormMetadataFactorySpec.scala | 4 +- 13 files changed, 194 insertions(+), 13 deletions(-) create mode 100644 server/src/main/scala/ch/wsl/box/services/translation/DeepLTranslateService.scala create mode 100644 server/src/main/scala/ch/wsl/box/services/translation/TranslateService.scala diff --git a/client/package.json b/client/package.json index 13ab0c10..9da70688 100644 --- a/client/package.json +++ b/client/package.json @@ -4,7 +4,7 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "vite", + "dev": "vite --config vite.config.app.js", "build:app": "vite build --config vite.config.app.js", "build:worker": "vite build --config vite.config.worker.js", "build": "npm run build:worker && npm run build:app", diff --git a/client/src/main/scala/ch/wsl/box/client/styles/GlobalStyles.scala b/client/src/main/scala/ch/wsl/box/client/styles/GlobalStyles.scala index 3052289d..5be48787 100755 --- a/client/src/main/scala/ch/wsl/box/client/styles/GlobalStyles.scala +++ b/client/src/main/scala/ch/wsl/box/client/styles/GlobalStyles.scala @@ -246,6 +246,14 @@ class GlobalStyles(settings:Settings,conf:StyleConf) extends StyleSheet.Inline() height(55 px) ), + unsafeRoot(".action") ( + cursor.pointer + ), + + unsafeRoot(".nav-item") ( + cursor.pointer + ), + unsafeRoot(".form-control")( // this controls the datetime input paddingTop(1 px), paddingBottom(1 px), diff --git a/client/src/test/scala/ch/wsl/box/client/mocks/RestMock.scala b/client/src/test/scala/ch/wsl/box/client/mocks/RestMock.scala index fa5b5baf..50920108 100644 --- a/client/src/test/scala/ch/wsl/box/client/mocks/RestMock.scala +++ b/client/src/test/scala/ch/wsl/box/client/mocks/RestMock.scala @@ -142,7 +142,7 @@ class RestMock(values:Values) extends REST with Logging { override def importXLS(kind: String, lang: String, entity: String, data: File)(implicit ec:ExecutionContext): Future[Int] = ??? - val userInfo = UserInfo("t","t",None,Seq(),Json.Null) + val userInfo = UserInfo("t","t","t",None,Seq(),Json.Null) override def login(request: LoginRequest)(implicit ec:ExecutionContext): Future[UserInfo] = Future.successful{ userInfo } diff --git a/server/src/main/scala/ch/wsl/box/model/Translations.scala b/server/src/main/scala/ch/wsl/box/model/Translations.scala index 4dd3699f..f960c1d3 100644 --- a/server/src/main/scala/ch/wsl/box/model/Translations.scala +++ b/server/src/main/scala/ch/wsl/box/model/Translations.scala @@ -3,7 +3,7 @@ package ch.wsl.box.model import ch.wsl.box.jdbc.UserDatabase import ch.wsl.box.model.boxentities.{BoxField, BoxForm, BoxLabels} import ch.wsl.box.jdbc.PostgresProfile.api._ -import ch.wsl.box.model.shared.{BoxTranslationsFields, Field} +import ch.wsl.box.model.shared.{BoxTranslationField, BoxTranslationsFields, Field} import ch.wsl.box.services.Services import java.util.UUID @@ -184,4 +184,28 @@ object Translations { }.recover{ case t => t.printStackTrace(); throw t } } + + def autoTranslate(from:String, to:String, force:Boolean = false)(implicit services: Services, ec:ExecutionContext): Future[Int] = { + val c = services.connection.adminDB + for { + source <- exportFields(from,c) + dest <- exportFields(from,c) + sourceToTranslate = source.filter(f => force || !dest.exists(d => f.uuid == d.uuid && d.label != "")) + translated <- services.translation.translateAll(from,to,sourceToTranslate.map(_.label)) + result <- updateFields(BoxTranslationsFields(from,to, + sourceToTranslate.zip(translated).map{case (src,translated) => + BoxTranslationField(src,src.copy( + label = translated, + lookup_columns = src.lookup_columns.map{ + case l if l == from => to + case l if l.endsWith(s"_$from") => l.substring(0,l.length - (from.length + 1)) + "_" + to + case l => l + } + )) + } + ),c) + } yield result + } + + } diff --git a/server/src/main/scala/ch/wsl/box/rest/Module.scala b/server/src/main/scala/ch/wsl/box/rest/Module.scala index 410584a7..e0c9ba12 100644 --- a/server/src/main/scala/ch/wsl/box/rest/Module.scala +++ b/server/src/main/scala/ch/wsl/box/rest/Module.scala @@ -5,11 +5,12 @@ import ch.wsl.box.jdbc.{Connection, ConnectionConfImpl} import ch.wsl.box.rest.routes.v1.{NotificationChannels, NotificationChannelsImpl} import ch.wsl.box.rest.utils.BoxSession import ch.wsl.box.services.{Services, ServicesWithoutGeneration} -import ch.wsl.box.services.config.{ConfFileAndDb, Config, ConfigFileImpl, FullConfig, FullConfigFileOnlyImpl} +import ch.wsl.box.services.config.{ConfFileAndDb, Config, ConfigFileImpl, DeepLConfig, FullConfig, FullConfigFileOnlyImpl} import ch.wsl.box.services.file.ImageCacheStorage import ch.wsl.box.services.files.{InMemoryImageCacheStorage, PgImageCacheStorage} import ch.wsl.box.services.mail.{MailService, MailServiceCourier, MailServiceDummy} import ch.wsl.box.services.mail_dispatcher.{MailDispatcherService, SingleHostMailDispatcherService} +import ch.wsl.box.services.translation.{DeepLTranslateService, TranslateService} import com.softwaremill.session.{InMemoryRefreshTokenStorage, RefreshTokenStorage} import scribe.Logging import wvlet.airframe._ @@ -48,6 +49,8 @@ object DefaultModule extends Module { .bind[NotificationChannels].to[NotificationChannelsImpl] .bind[FullConfig].to[ConfFileAndDb] .bind[MailDispatcherService].to[SingleHostMailDispatcherService] + .bind[DeepLConfig].to[ConfFileAndDb] + .bind[TranslateService].to[DeepLTranslateService] .bind[RefreshTokenStorage[BoxSession]].toInstance(new InMemoryRefreshTokenStorage[BoxSession] with Logging { override def log(msg: String): Unit = logger.info(msg) }) diff --git a/server/src/main/scala/ch/wsl/box/rest/routes/v1/Admin.scala b/server/src/main/scala/ch/wsl/box/rest/routes/v1/Admin.scala index f499569a..eb99366f 100644 --- a/server/src/main/scala/ch/wsl/box/rest/routes/v1/Admin.scala +++ b/server/src/main/scala/ch/wsl/box/rest/routes/v1/Admin.scala @@ -7,7 +7,7 @@ import akka.http.scaladsl.server.Directives.{complete, get, path, pathPrefix} import akka.stream.Materializer import ch.wsl.box.information_schema.PgInformationSchema import ch.wsl.box.model.shared.admin.FormCreationRequest -import ch.wsl.box.model.{BoxDefinition, BoxDefinitionMerge, InformationSchema} +import ch.wsl.box.model.{BoxDefinition, BoxDefinitionMerge, InformationSchema, Translations} import ch.wsl.box.model.shared.{BoxTranslationsFields, EntityKind} import ch.wsl.box.rest.metadata.{BoxFormMetadataFactory, FormCreationHandler, StubMetadataFactory} import ch.wsl.box.rest.routes.{Form, Table} @@ -19,8 +19,9 @@ import io.circe._ import io.circe.generic.auto._ import io.circe.syntax._ import ch.wsl.box.jdbc.PostgresProfile.api._ +import ch.wsl.box.rest.logic.LangHelper -import scala.concurrent.ExecutionContext +import scala.concurrent.{ExecutionContext, Future} case class Admin(session:BoxSession)(implicit ec:ExecutionContext, userProfile: UserProfile, mat:Materializer, system:ActorSystem, services: Services) { @@ -117,6 +118,45 @@ case class Admin(session:BoxSession)(implicit ec:ExecutionContext, userProfile: } + def translate =pathPrefix("translate") { + pathPrefix(Segment) { from => + path(Segment) { to => + post { + entity(as[String]) { body => + complete { + services.translation.translate(from, to, body) + } + } + } + } + } + } + + def translateAll = pathPrefix("translate-all") { + pathPrefix(Segment) { from => + pathPrefix(Segment) { to => + get{ + complete{ + Translations.autoTranslate(from,to) + } + } + } + } + } + + def translateAllForce = pathPrefix("translate-all-force") { + pathPrefix(Segment) { from => + pathPrefix(Segment) { to => + get{ + complete{ + Translations.autoTranslate(from,to,true) + } + } + } + } + } + + val route = Auth.onlyAdminstrator(session) { //need to be at the end or non administrator request are not resolved //access to box tables for administrator form(session) ~ @@ -128,6 +168,9 @@ case class Admin(session:BoxSession)(implicit ec:ExecutionContext, userProfile: boxDefinition ~ childCandidates ~ roles ~ - createForm + createForm ~ + translate ~ + translateAll ~ + translateAllForce } } diff --git a/server/src/main/scala/ch/wsl/box/services/Services.scala b/server/src/main/scala/ch/wsl/box/services/Services.scala index b6ce9c34..1f7cf02c 100644 --- a/server/src/main/scala/ch/wsl/box/services/Services.scala +++ b/server/src/main/scala/ch/wsl/box/services/Services.scala @@ -8,6 +8,7 @@ import ch.wsl.box.services.config.{Config, FullConfig} import ch.wsl.box.services.files.ImageCache import ch.wsl.box.services.mail.MailService import ch.wsl.box.services.mail_dispatcher.MailDispatcherService +import ch.wsl.box.services.translation.TranslateService import com.softwaremill.session.RefreshTokenStorage import wvlet.airframe._ @@ -20,4 +21,5 @@ trait Services extends ServicesWithoutGeneration { val mailDispatcher = bind[MailDispatcherService] val notificationChannels = bind[NotificationChannels] val refreshTokenStorage = bind[RefreshTokenStorage[BoxSession]] + val translation = bind[TranslateService] } diff --git a/server/src/main/scala/ch/wsl/box/services/config/ConfFileAndDb.scala b/server/src/main/scala/ch/wsl/box/services/config/ConfFileAndDb.scala index b3a728cd..d2693199 100644 --- a/server/src/main/scala/ch/wsl/box/services/config/ConfFileAndDb.scala +++ b/server/src/main/scala/ch/wsl/box/services/config/ConfFileAndDb.scala @@ -20,7 +20,7 @@ import io.circe.syntax._ import io.circe.generic.auto._ class ConfFileAndDb(connection:Connection)(implicit ec:ExecutionContext) extends ConfigFileImpl with FullConfig with Logging { - private var _conf: Map[String, String] = Map() + var _conf: Map[String, String] = Map() def load() = { @@ -57,7 +57,8 @@ class ConfFileAndDb(connection:Connection)(implicit ec:ExecutionContext) extends "fks.lookup.labels", "fks.lookup.rowsLimit", "redactor.js", - "redactor.css" + "redactor.css", + "deeplKey" ).contains(k)} @@ -141,4 +142,9 @@ class ConfFileAndDb(connection:Connection)(implicit ec:ExecutionContext) extends override def localDb: Boolean = Try(_conf("local.db").toBoolean).getOrElse(true) override def singleUser: Boolean = conf.as[Option[Boolean]]("singleUser").getOrElse(false) + + override def deeplKey: Option[String] = _conf.get("deeplKey") + + override def deeplEndpoint: Option[String] = _conf.get("deeplEndpoint") + } diff --git a/server/src/main/scala/ch/wsl/box/services/config/FullConfig.scala b/server/src/main/scala/ch/wsl/box/services/config/FullConfig.scala index 9d4b55f0..38f61994 100644 --- a/server/src/main/scala/ch/wsl/box/services/config/FullConfig.scala +++ b/server/src/main/scala/ch/wsl/box/services/config/FullConfig.scala @@ -5,7 +5,12 @@ import ch.wsl.box.viewmodel.MatomoConfig import java.time.{LocalDateTime, OffsetDateTime} -trait FullConfig extends Config { +trait DeepLConfig { + def deeplKey:Option[String] = None + def deeplEndpoint:Option[String] = None +} + +trait FullConfig extends Config with DeepLConfig { def akkaHttpSession:com.typesafe.config.Config def host:String def port:Int diff --git a/server/src/main/scala/ch/wsl/box/services/translation/DeepLTranslateService.scala b/server/src/main/scala/ch/wsl/box/services/translation/DeepLTranslateService.scala new file mode 100644 index 00000000..2acde251 --- /dev/null +++ b/server/src/main/scala/ch/wsl/box/services/translation/DeepLTranslateService.scala @@ -0,0 +1,82 @@ +package ch.wsl.box.services.translation + +import ch.wsl.box.rest.auth.oidc.AuthFlow.OpenIDToken +import ch.wsl.box.services.config.{DeepLConfig, FullConfig} +import sttp.client4._ +import sttp.client4.circe.asJson +import sttp.client4.{DefaultFutureBackend, basicRequest} +import io.circe.generic.semiauto._ + +import scala.concurrent.{ExecutionContext, Future} + + + +class DeepLTranslateService(conf: DeepLConfig)(implicit ex:ExecutionContext) extends TranslateService { + + private val apiKey = conf.deeplKey + private val apiEndpoint = conf.deeplEndpoint + + private val backend = DefaultFutureBackend() + + + private case class DeepLAPIRequest( + text:Seq[String], + target_lang: String, + source_lang: String, + context:Option[String] + ) + + + + private case class DeepLAPITranslations( + text:String + ) + + private case class DeepLAPIResponse( + translations: Seq[DeepLAPITranslations] + ) + + implicit private val reqEncoder = deriveEncoder[DeepLAPIRequest] + implicit private val resTDecoder = deriveDecoder[DeepLAPITranslations] + implicit private val resDecoder = deriveDecoder[DeepLAPIResponse] + + override def translate(from: String, to: String,text: String): Future[String] = translateAll(from,to,Seq(text)).map(_.head) + + override def translateAll(from: String, to: String,texts: Seq[String]): Future[Seq[String]] = { + + val translator = translatorBulk(from,to) _ + + val blocks = texts.grouped(50) + + blocks.foldLeft(Future.successful(Seq[String]())) { (accF, text) => + for { + acc <- accF + res <- translator(text) + throttle <- Future{ Thread.sleep(300) } + } yield acc ++ res + } + } + + private def translatorBulk(from: String, to: String)(texts: Seq[String]): Future[Seq[String]] = { + (apiKey,apiEndpoint) match { + case (Some(key),Some(endpoint)) => { + val r = basicRequest + .post(uri"${endpoint}/v2/translate") + .header("Authorization",s"DeepL-Auth-Key $key") + .body(asJson(DeepLAPIRequest(texts,to.toUpperCase,from.toUpperCase,Some("Text is used in a web application that expose database tables on the web.")))) + .response(asJson[DeepLAPIResponse]) + + print(r.toCurl) + + r.send(backend).map(_.body match { + case Right(value) => value.translations.map(_.text) + case Left(value) => throw value + }) + } + case (None,Some(_)) => throw new Exception("DeepL API Key not found") + case (Some(_),None) => throw new Exception("DeepL Endpoint not found") + case (None,None) => throw new Exception("DeepL API Key and endpoint not found") + } + } + +} diff --git a/server/src/main/scala/ch/wsl/box/services/translation/TranslateService.scala b/server/src/main/scala/ch/wsl/box/services/translation/TranslateService.scala new file mode 100644 index 00000000..f182eac8 --- /dev/null +++ b/server/src/main/scala/ch/wsl/box/services/translation/TranslateService.scala @@ -0,0 +1,8 @@ +package ch.wsl.box.services.translation + +import scala.concurrent.Future + +trait TranslateService { + def translate(from:String,to:String,text:String):Future[String] + def translateAll(from:String,to:String,texts:Seq[String]):Future[Seq[String]] +} diff --git a/server/src/test/scala/ch/wsl/box/rest/BoxFormAdminSpec.scala b/server/src/test/scala/ch/wsl/box/rest/BoxFormAdminSpec.scala index 136a7b14..91c878f5 100644 --- a/server/src/test/scala/ch/wsl/box/rest/BoxFormAdminSpec.scala +++ b/server/src/test/scala/ch/wsl/box/rest/BoxFormAdminSpec.scala @@ -21,7 +21,7 @@ class BoxFormAdminSpec extends BaseSpec { "Admin Box schema form" should "handled" in withServices { implicit services => - implicit val session = BoxSession(CurrentUser(DbInfo(services.connection.adminUser,services.connection.adminUser,Seq()),UserInfo(services.connection.adminUser,services.connection.adminUser,None,Seq(),Json.Null))) + implicit val session = BoxSession(CurrentUser(DbInfo(services.connection.adminUser,services.connection.adminUser,Seq()),UserInfo(services.connection.adminUser,services.connection.adminUser,services.connection.adminUser,None,Seq(),Json.Null))) implicit val bdb = FullDatabase(services.connection.adminDB,services.connection.adminDB) for{ diff --git a/server/src/test/scala/ch/wsl/box/rest/FormMetadataFactorySpec.scala b/server/src/test/scala/ch/wsl/box/rest/FormMetadataFactorySpec.scala index 2f5af2e7..297da177 100644 --- a/server/src/test/scala/ch/wsl/box/rest/FormMetadataFactorySpec.scala +++ b/server/src/test/scala/ch/wsl/box/rest/FormMetadataFactorySpec.scala @@ -24,8 +24,8 @@ class FormMetadataFactorySpec extends BaseSpec { "Form metadata" should "not include roles specific fields" in withServices[Assertion] { implicit services => - val userWithRoles = CurrentUser(DbInfo("test","test",Seq("testRole")),UserInfo("test","test",None,Seq("testRole"),Json.Null)) - val userWithoutRoles = CurrentUser(DbInfo("test2","test2",Seq()),UserInfo("test2","test2",None,Seq(),Json.Null)) + val userWithRoles = CurrentUser(DbInfo("test","test",Seq("testRole")),UserInfo("test","test","test",None,Seq("testRole"),Json.Null)) + val userWithoutRoles = CurrentUser(DbInfo("test2","test2",Seq()),UserInfo("test2","test2","test2",None,Seq(),Json.Null)) implicit val up = UserProfile(services.connection.adminUser,services.connection.adminUser) From f12092b57cfc039045e133fadfed3c99ecc828be Mon Sep 17 00:00:00 2001 From: Andrea Minetti Date: Tue, 17 Mar 2026 14:52:00 +0100 Subject: [PATCH 21/42] Using skunk for db LISTEN and multiplexing messages on single channel --- .../scala/ch/wsl/box/jdbc/Connection.scala | 18 ++- .../ch/wsl/box/jdbc/PostgresConfig.scala | 31 +++++ .../functions/BOX_R__box_notify.sql | 18 +++ .../functions/BOX_R__mail_notifications.sql | 54 +++++++++ .../functions/BOX_R__ui_notification.sql | 28 +++++ project/Settings.scala | 1 + .../src/main/scala/ch/wsl/box/rest/Boot.scala | 4 +- .../main/scala/ch/wsl/box/rest/Module.scala | 2 + .../rest/logic/notification/DbNotify.scala | 66 +++++++++++ .../rest/logic/notification/MailHandler.scala | 30 ++--- .../notification/NotificationsHandler.scala | 110 ------------------ .../routes/v1/WebsocketNotifications.scala | 27 ++--- .../scala/ch/wsl/box/services/Services.scala | 3 + 13 files changed, 240 insertions(+), 152 deletions(-) create mode 100644 codegen/src/main/scala/ch/wsl/box/jdbc/PostgresConfig.scala create mode 100644 db/box_migrations/functions/BOX_R__box_notify.sql create mode 100644 db/box_migrations/functions/BOX_R__mail_notifications.sql create mode 100644 db/box_migrations/functions/BOX_R__ui_notification.sql create mode 100644 server/src/main/scala/ch/wsl/box/rest/logic/notification/DbNotify.scala delete mode 100644 server/src/main/scala/ch/wsl/box/rest/logic/notification/NotificationsHandler.scala diff --git a/codegen/src/main/scala/ch/wsl/box/jdbc/Connection.scala b/codegen/src/main/scala/ch/wsl/box/jdbc/Connection.scala index d0d7c7a2..b4129019 100644 --- a/codegen/src/main/scala/ch/wsl/box/jdbc/Connection.scala +++ b/codegen/src/main/scala/ch/wsl/box/jdbc/Connection.scala @@ -20,6 +20,8 @@ import org.postgresql.ds.PGSimpleDataSource import java.util.UUID import scala.collection.JavaConverters._ import scala.concurrent.{Await, ExecutionContext} +import cats.effect._ +import skunk.{Session, _} /** * Created by andreaminetti on 16/02/16. @@ -37,6 +39,8 @@ trait Connection extends Logging { //val executor = AsyncExecutor("public-executor",50,50,10000,50) + def singleAdminSession():Resource[IO, skunk.Session[IO]] + def adminDB = dbForUser(adminUser,"box_admin",adminDbConnection) @@ -93,8 +97,17 @@ class ConnectionConfImpl extends Connection { ConfigValueFactory.fromAnyRef("disabled") } + import natchez.Trace.Implicits.noop + private val pgConf = JdbcParser.parse(dbPath).get + override def singleAdminSession(): Resource[IO, Session[IO]] = Session.single[IO]( + host=pgConf.host, + port= pgConf.port, + user=adminUser, + database = pgConf.database, + password = Some(dbPassword) + ) println(s"DB: $dbPath") @@ -169,10 +182,9 @@ class ConnectionTestContainerImpl(container: PostgreSQLContainer,schema:String) val idleTimeout = 300000 + override def singleAdminSession(): Resource[IO, Session[IO]] = ??? - - - override def dataSource(name:String,schema:String): DataSource = { + override def dataSource(name:String, schema:String): DataSource = { val ds = new PGSimpleDataSource() ds.setUrl(dbPath) ds.setUser(adminUser) diff --git a/codegen/src/main/scala/ch/wsl/box/jdbc/PostgresConfig.scala b/codegen/src/main/scala/ch/wsl/box/jdbc/PostgresConfig.scala new file mode 100644 index 00000000..3bb5b96e --- /dev/null +++ b/codegen/src/main/scala/ch/wsl/box/jdbc/PostgresConfig.scala @@ -0,0 +1,31 @@ +package ch.wsl.box.jdbc + + +case class PostgresConfig( + host: String, + port: Int, + database: String, + params: Map[String, String] + ) + +object JdbcParser { + // Regex for jdbc:postgresql://[host][:port]/[database][?params] + private val JdbcRegex = """jdbc:postgresql://([^:/]+)(?::(\d+))?/([^?]+)(?:\?(.*))?""".r + + def parse(url: String): Option[PostgresConfig] = url match { + case JdbcRegex(host, port, db, params) => + val portNum = Option(port).map(_.toInt).getOrElse(5432) + val paramMap = Option(params).toSeq + .flatMap(_.split("&")) + .flatMap { pair => + pair.split("=", 2) match { + case Array(k, v) => Some(k -> v) + case _ => None + } + }.toMap + + Some(PostgresConfig(host, portNum, db, paramMap)) + + case _ => None + } +} \ No newline at end of file diff --git a/db/box_migrations/functions/BOX_R__box_notify.sql b/db/box_migrations/functions/BOX_R__box_notify.sql new file mode 100644 index 00000000..75610aac --- /dev/null +++ b/db/box_migrations/functions/BOX_R__box_notify.sql @@ -0,0 +1,18 @@ +create or replace function box_notify( + channel text, + payload json +) + returns void set search_path from current as $$ +declare + output text := ''; +begin + + output := row_to_json( + (SELECT ColumnName FROM (SELECT channel,payload) AS ColumnName (channel,payload)) + )::text; + + -- subtracting the amount from the sender's account + PERFORM pg_notify('box_' || current_schema || '_channel',output); +end; +$$ LANGUAGE plpgsql; + diff --git a/db/box_migrations/functions/BOX_R__mail_notifications.sql b/db/box_migrations/functions/BOX_R__mail_notifications.sql new file mode 100644 index 00000000..991a3fff --- /dev/null +++ b/db/box_migrations/functions/BOX_R__mail_notifications.sql @@ -0,0 +1,54 @@ +create or replace function mail_notification(_mail_from email, _mail_reply_to email, _mail_to email[], _mail_cc email[], _mail_bcc email[], _subject text, _text text, _html text, _params jsonb) returns uuid + SET search_path from current + language plpgsql +as +$$ +declare + _mail_id uuid; +begin + + insert into mails (send_at,wished_send_at,mail_from,mail_to,mail_cc,mail_bcc,subject,text,html,params,created,reply_to) values + (now(),now(),_mail_from, _mail_to, _mail_cc,_mail_bcc, _subject, _text, _html,_params, now(),_mail_reply_to) returning id into _mail_id; + + -- subtracting the amount from the sender's account + PERFORM box_notify('mail_feedback_channel','{"sendMail": true}'); + + return _mail_id; +end; +$$; + + +create or replace function mail_notification(_mail_from email, _mail_to email[], _mail_cc email[], _mail_bcc email[], _subject text, _text text, _html text, _params jsonb) returns uuid + SET search_path from current + language sql +as +$$ + +select mail_notification(_mail_from, null::email, _mail_to, _mail_cc, _mail_bcc, _subject, _text, _html, _params); + +$$; + + + + + +create or replace function mail_notification(_mail_from email, _mail_to email[], _subject text, _text text, _html text, _params jsonb) returns uuid + language sql + set search_path from current +as +$$ +select mail_notification(_mail_from,_mail_to,array[]::email[],array[]::email[],_subject,_text,_html,_params); +$$; + + +create or replace function mail_notification(_mail_from email, _mail_to email[], _subject text, _text text, _html text) returns uuid + language sql + set search_path from current +as +$$ +select mail_notification(_mail_from,_mail_to,_subject,_text,_html,'{}'::jsonb); +$$; + + + + diff --git a/db/box_migrations/functions/BOX_R__ui_notification.sql b/db/box_migrations/functions/BOX_R__ui_notification.sql new file mode 100644 index 00000000..79b63e3f --- /dev/null +++ b/db/box_migrations/functions/BOX_R__ui_notification.sql @@ -0,0 +1,28 @@ +create or replace function ui_notification(topic text, users text[], payload json) returns void + language plpgsql + set search_path from current +as +$$ +declare + output text := ''; +begin + + output := row_to_json( + (SELECT ColumnName FROM (SELECT topic,users,payload) AS ColumnName (topic,allowed_users,payload)) + )::text; + + -- subtracting the amount from the sender's account + PERFORM box_notify('ui_feedback_channel',output); +end; +$$; + + +create or replace function ui_notification_forall(topic text, payload json) returns void + language plpgsql + set search_path from current +as +$$ +begin + PERFORM ui_notification(topic,'{"ALL_USERS"}'::text[],payload); +end; +$$; \ No newline at end of file diff --git a/project/Settings.scala b/project/Settings.scala index 4ff1a943..ff500959 100755 --- a/project/Settings.scala +++ b/project/Settings.scala @@ -110,6 +110,7 @@ object Settings { "org.flywaydb" % "flyway-database-postgresql" % versions.flyway, "com.outr" %% "scribe" % versions.scribe, "com.outr" %% "scribe-slf4j18" % versions.scribe, + "org.tpolecat" %% "skunk-core" % "0.6.5" )) val codegenDependecies = Def.setting(sharedJVMCodegenDependencies.value ++ Seq( diff --git a/server/src/main/scala/ch/wsl/box/rest/Boot.scala b/server/src/main/scala/ch/wsl/box/rest/Boot.scala index 2b6896f3..b94597d3 100755 --- a/server/src/main/scala/ch/wsl/box/rest/Boot.scala +++ b/server/src/main/scala/ch/wsl/box/rest/Boot.scala @@ -16,7 +16,7 @@ import scribe.writer.ConsoleWriter import wvlet.airframe.Design import ch.wsl.box.model.Migrate import ch.wsl.box.rest.logic.cron.{BoxCronLoader, CronScheduler} -import ch.wsl.box.rest.logic.notification.{MailHandler, NotificationsHandler} +import ch.wsl.box.rest.logic.notification.{MailHandler} import ch.wsl.box.rest.utils.CertificateUtils import scala.concurrent.{Await, ExecutionContext, Future, Promise} @@ -58,7 +58,7 @@ class Box(name:String,version:String,https:Boolean)(implicit services: Services) //Registring handlers - new MailHandler(services.mailDispatcher).listen() + new MailHandler(services.dbNotify,services.mailDispatcher).listen() val scheduler = new CronScheduler(system) new BoxCronLoader(scheduler).load() diff --git a/server/src/main/scala/ch/wsl/box/rest/Module.scala b/server/src/main/scala/ch/wsl/box/rest/Module.scala index e0c9ba12..59b1bda7 100644 --- a/server/src/main/scala/ch/wsl/box/rest/Module.scala +++ b/server/src/main/scala/ch/wsl/box/rest/Module.scala @@ -2,6 +2,7 @@ package ch.wsl.box.rest import akka.actor.ActorSystem import ch.wsl.box.jdbc.{Connection, ConnectionConfImpl} +import ch.wsl.box.rest.logic.notification.{DbNotify, DbNotifyHandlerImpl} import ch.wsl.box.rest.routes.v1.{NotificationChannels, NotificationChannelsImpl} import ch.wsl.box.rest.utils.BoxSession import ch.wsl.box.services.{Services, ServicesWithoutGeneration} @@ -46,6 +47,7 @@ object DefaultModule extends Module { .bind[ImageCacheStorage].to[PgImageCacheStorage] .bind[MailService].to[MailServiceCourier] .bind[Connection].to[ConnectionConfImpl] + .bind[DbNotify].to[DbNotifyHandlerImpl] .bind[NotificationChannels].to[NotificationChannelsImpl] .bind[FullConfig].to[ConfFileAndDb] .bind[MailDispatcherService].to[SingleHostMailDispatcherService] diff --git a/server/src/main/scala/ch/wsl/box/rest/logic/notification/DbNotify.scala b/server/src/main/scala/ch/wsl/box/rest/logic/notification/DbNotify.scala new file mode 100644 index 00000000..530dff7d --- /dev/null +++ b/server/src/main/scala/ch/wsl/box/rest/logic/notification/DbNotify.scala @@ -0,0 +1,66 @@ +package ch.wsl.box.rest.logic.notification + + +import ch.wsl.box.jdbc.Connection +import scribe.Logging +import cats.effect._ +import skunk._ +import skunk.implicits._ +import skunk.codec.all._ +import cats.effect.syntax.all._ +import natchez.Trace.Implicits.noop +import skunk.data.Identifier +import cats.effect.unsafe.implicits.global +import ch.wsl.box.services.config.{Config, FullConfig} +import io.circe.Json + +import scala.concurrent.{ExecutionContext, Future} + + +trait DbNotify { + def listen(channel:String,callback: Json => Future[Boolean]) +} + + +class DbNotifyHandlerImpl(connection:Connection,conf:FullConfig) extends DbNotify with Logging { + + case class BoxChannel(channel:String,payload:Json) + + implicit val dec = io.circe.generic.semiauto.deriveDecoder[BoxChannel] + + def handleMessage(str:String):Option[BoxChannel] = io.circe.parser.parse(str).flatMap(_.as[BoxChannel]) match { + case Left(value) => { + logger.warn(s"Message $str not parsable") + None + } + case Right(value) => { + logger.debug(s"Recived message on channel ${value.channel}: ${value.payload}") + Some(value) + } + } + + val callbacks = scala.collection.mutable.Map[String,(Json) => Future[Boolean]]() + + def listen(channel:String,callback: Json => Future[Boolean]) = callbacks.put(channel,callback) + + def listenAll() = { + logger.info(s"Setting up DB Listen") + val io = connection.singleAdminSession().use{ s => + val channelName = s"box_${conf.boxSchemaName}_channel" + logger.info(s"Listening on $channelName") + val ch = s.channel(Identifier.fromString(channelName).toOption.get) + val lst = ch.listen(1024) + lst.evalMap { n => + handleMessage(n.value).flatMap(m => callbacks.get(m.channel).map(c => (m.payload,c))) match { + case Some((m,callback)) => IO.fromFuture(IO(callback(m))) + case None => IO() + } + }.compile.drain + + } + io.unsafeRunAndForget() + } + + listenAll() + +} diff --git a/server/src/main/scala/ch/wsl/box/rest/logic/notification/MailHandler.scala b/server/src/main/scala/ch/wsl/box/rest/logic/notification/MailHandler.scala index e7b9fd92..d91ad205 100644 --- a/server/src/main/scala/ch/wsl/box/rest/logic/notification/MailHandler.scala +++ b/server/src/main/scala/ch/wsl/box/rest/logic/notification/MailHandler.scala @@ -1,31 +1,17 @@ package ch.wsl.box.rest.logic.notification -import ch.wsl.box.services.Services -import ch.wsl.box.services.mail.{Mail, MailService} + import ch.wsl.box.services.mail_dispatcher.MailDispatcherService -import io.circe._ -import io.circe.syntax._ -import io.circe.parser._ -import io.circe.generic.auto._ import scribe.Logging +import scala.concurrent.Future -import scala.concurrent.{ExecutionContext, Future} - -class MailHandler(mailDispatcherService: MailDispatcherService) extends Logging { - - def listen()(implicit ex:ExecutionContext,services: Services):Unit = { - - def handleNotification(str:String): Future[Boolean] = { - parse(str) match { - case Left(err) => Future.failed(new Exception(err.message)) - case Right(_) => { - mailDispatcherService.dispatchNow() - Future.successful(true) - } - } - } +class MailHandler(dbNotify: DbNotify, mailDispatcherService: MailDispatcherService) extends Logging { - NotificationsHandler.create("mail_feedback_channel",services.connection, handleNotification) + def listen():Unit = { + dbNotify.listen("mail_feedback_channel",_ => Future.successful{ + mailDispatcherService.dispatchNow() + true + }) } } diff --git a/server/src/main/scala/ch/wsl/box/rest/logic/notification/NotificationsHandler.scala b/server/src/main/scala/ch/wsl/box/rest/logic/notification/NotificationsHandler.scala deleted file mode 100644 index 75bb3318..00000000 --- a/server/src/main/scala/ch/wsl/box/rest/logic/notification/NotificationsHandler.scala +++ /dev/null @@ -1,110 +0,0 @@ -package ch.wsl.box.rest.logic.notification - -import java.util.Date - -import ch.wsl.box.jdbc.Connection -import ch.wsl.box.services.Services -import org.postgresql.PGConnection -import scribe.Logging - -import scala.concurrent.{ExecutionContext, Future} -import scala.util.{Failure, Success, Try} - -trait PgNotifier{ - def stop() -} - -object NotificationsHandler { - - def create(channel:String,connection:Connection,callback: (String) => Future[Boolean])(implicit ec:ExecutionContext):PgNotifier = new PgNotifier { - val listener = new Listener(connection,channel,callback) - listener.start() - override def stop(): Unit = listener.stopRunning() - } - -} - -import java.sql.SQLException - -class Listener(connection:Connection,channel:String,callback: (String) => Future[Boolean])(implicit ec:ExecutionContext) extends Thread with Logging { - - - val user = connection.adminUser - var pgconn: PGConnection = null - var conn:java.sql.Connection = null - - private var running = true - def stopRunning() = { - running = false - } - - private def reloadConnection() = { - conn = connection.dataSource(s"Notification $channel",connection.dbSchema).getConnection() - val stmt = conn.createStatement - val listenQuery = s"""SET ROLE "$user"; LISTEN $channel""" - logger.info(listenQuery) - stmt.execute(listenQuery) - stmt.close - pgconn = conn.asInstanceOf[PGConnection] - } - - def select1() = { - val stmt = conn.createStatement - val rs = stmt.executeQuery(s"SELECT 1") - rs.close() - stmt.close() - } - - reloadConnection() - - - override def run(): Unit = { - while ( running ) { - Thread.sleep(500) - try { - - // issue a dummy query to contact the backend - // and receive any pending notifications. - Try(select1()) match { - case Success(value) => value - case Failure(exception) => { - Thread.sleep(1000) - Try(conn.close()) - reloadConnection() - select1() - } - } - - - val notifications = pgconn.getNotifications(1000) - if(notifications != null) { - notifications.foreach{ n => - logger.info(s""" - |Recived notification: - |timestamp: ${new Date().toString} - |name: ${n.getName} - |parameter: ${n.getParameter} - |""".stripMargin) - callback(n.getParameter).onComplete { - case Success(ok) => true - case Failure(exception) => { - exception.printStackTrace() - logger.error(exception.getMessage) - false - } - } - } - } - // wait a while before checking again for new - // notifications - //Thread.sleep(1000) - } - catch { - case sqle: SQLException => - sqle.printStackTrace() - case ie: InterruptedException => - ie.printStackTrace() - } - } - } -} \ No newline at end of file diff --git a/server/src/main/scala/ch/wsl/box/rest/routes/v1/WebsocketNotifications.scala b/server/src/main/scala/ch/wsl/box/rest/routes/v1/WebsocketNotifications.scala index 6fe0cf05..0e3406b6 100644 --- a/server/src/main/scala/ch/wsl/box/rest/routes/v1/WebsocketNotifications.scala +++ b/server/src/main/scala/ch/wsl/box/rest/routes/v1/WebsocketNotifications.scala @@ -6,7 +6,7 @@ import akka.http.scaladsl.server.Directives import akka.stream.{CompletionStrategy, Materializer, OverflowStrategy} import akka.stream.scaladsl.{Flow, Sink, Source} import ch.wsl.box.jdbc.Connection -import ch.wsl.box.rest.logic.notification.NotificationsHandler +import ch.wsl.box.rest.logic.notification.{DbNotify} import ch.wsl.box.rest.utils.UserProfile import ch.wsl.box.services.Services import io.circe._ @@ -62,7 +62,7 @@ trait NotificationChannels { def start() } -class NotificationChannelsImpl(connection:Connection) extends NotificationChannels with Logging { +class NotificationChannelsImpl(connection:Connection,dbNotify: DbNotify) extends NotificationChannels with Logging { private var notificationChannels: ListBuffer[NotificationChannel] = ListBuffer.empty[NotificationChannel] def add(user:String,topic: String)(implicit mat: Materializer) = { @@ -74,19 +74,16 @@ class NotificationChannelsImpl(connection:Connection) extends NotificationChanne final val ALL_USERS = "ALL_USERS" - private def handleNotification(str:String): Future[Boolean] = { - parse(str) match { - case Left(err) => Future.failed(new Exception(err.message)) - case Right(js) => js.as[UiNotification] match { - case Left(err) => Future.failed(new Exception(err.message + err.history)) - case Right(notification) => { - logger.info(s"Send notification: $notification") - notification.allowed_users.contains(ALL_USERS) match { - case true => notificationChannels.foreach(_.sendBroadcast(notification)) - case false => notificationChannels.foreach(_.sendNotification(notification)) - } - Future.successful(true) + private def handleNotification(js:Json): Future[Boolean] = { + js.as[UiNotification] match { + case Left(err) => Future.failed(new Exception(err.message + err.history)) + case Right(notification) => { + logger.info(s"Send notification: $notification") + notification.allowed_users.contains(ALL_USERS) match { + case true => notificationChannels.foreach(_.sendBroadcast(notification)) + case false => notificationChannels.foreach(_.sendNotification(notification)) } + Future.successful(true) } } } @@ -95,5 +92,5 @@ class NotificationChannelsImpl(connection:Connection) extends NotificationChanne - override def start(): Unit = NotificationsHandler.create("ui_feedback_channel",connection,handleNotification) + override def start(): Unit = dbNotify.listen("ui_feedback_channel",handleNotification) } diff --git a/server/src/main/scala/ch/wsl/box/services/Services.scala b/server/src/main/scala/ch/wsl/box/services/Services.scala index 1f7cf02c..5a0605ae 100644 --- a/server/src/main/scala/ch/wsl/box/services/Services.scala +++ b/server/src/main/scala/ch/wsl/box/services/Services.scala @@ -2,6 +2,7 @@ package ch.wsl.box.services import akka.actor.ActorSystem import ch.wsl.box.jdbc.Connection +import ch.wsl.box.rest.logic.notification.{DbNotify} import ch.wsl.box.rest.routes.v1.NotificationChannels import ch.wsl.box.rest.utils.BoxSession import ch.wsl.box.services.config.{Config, FullConfig} @@ -18,8 +19,10 @@ trait Services extends ServicesWithoutGeneration { val actorSystem = bind[ActorSystem] val imageCacher = bind[ImageCache] val mail = bind[MailService] + val dbNotify = bind[DbNotify] val mailDispatcher = bind[MailDispatcherService] val notificationChannels = bind[NotificationChannels] val refreshTokenStorage = bind[RefreshTokenStorage[BoxSession]] val translation = bind[TranslateService] + } From a05220d08ec3b2e01389fd226c2b52aa5960972a Mon Sep 17 00:00:00 2001 From: Andrea Minetti Date: Wed, 18 Mar 2026 16:57:36 +0100 Subject: [PATCH 22/42] Translate column function implementation --- .../scala/ch/wsl/box/jdbc/Connection.scala | 18 +++++- .../functions/BOX_R__box_notify.sql | 8 +-- .../functions/BOX_R__mail_notifications.sql | 2 +- .../functions/BOX_R__translate_column.sql | 16 ++++++ .../functions/BOX_R__ui_notification.sql | 2 +- .../main/scala/ch/wsl/box/rest/Module.scala | 3 +- .../rest/logic/notification/DbNotify.scala | 2 +- .../translation/ColumnTranslate.scala | 57 +++++++++++++++++++ 8 files changed, 97 insertions(+), 11 deletions(-) create mode 100644 db/box_migrations/functions/BOX_R__translate_column.sql create mode 100644 server/src/main/scala/ch/wsl/box/services/translation/ColumnTranslate.scala diff --git a/codegen/src/main/scala/ch/wsl/box/jdbc/Connection.scala b/codegen/src/main/scala/ch/wsl/box/jdbc/Connection.scala index b4129019..a3411c4d 100644 --- a/codegen/src/main/scala/ch/wsl/box/jdbc/Connection.scala +++ b/codegen/src/main/scala/ch/wsl/box/jdbc/Connection.scala @@ -39,7 +39,8 @@ trait Connection extends Logging { //val executor = AsyncExecutor("public-executor",50,50,10000,50) - def singleAdminSession():Resource[IO, skunk.Session[IO]] + def notificationSession():Resource[IO, skunk.Session[IO]] + def pooledAdminSession(): Resource[IO, Resource[IO, Session[IO]]] def adminDB = dbForUser(adminUser,"box_admin",adminDbConnection) @@ -101,7 +102,7 @@ class ConnectionConfImpl extends Connection { private val pgConf = JdbcParser.parse(dbPath).get - override def singleAdminSession(): Resource[IO, Session[IO]] = Session.single[IO]( + override def notificationSession(): Resource[IO, Session[IO]] = Session.single[IO]( host=pgConf.host, port= pgConf.port, user=adminUser, @@ -109,6 +110,15 @@ class ConnectionConfImpl extends Connection { password = Some(dbPassword) ) + override def pooledAdminSession(): Resource[IO, Resource[IO, Session[IO]]] = Session.pooled[IO]( + host=pgConf.host, + port= pgConf.port, + user=adminUser, + database = pgConf.database, + password = Some(dbPassword), + max = 2 + ) + println(s"DB: $dbPath") override def dataSource(name:String,schema:String): DataSource = { @@ -182,7 +192,9 @@ class ConnectionTestContainerImpl(container: PostgreSQLContainer,schema:String) val idleTimeout = 300000 - override def singleAdminSession(): Resource[IO, Session[IO]] = ??? + override def notificationSession(): Resource[IO, Session[IO]] = ??? + + override def pooledAdminSession(): Resource[IO, Resource[IO, Session[IO]]] = ??? override def dataSource(name:String, schema:String): DataSource = { val ds = new PGSimpleDataSource() diff --git a/db/box_migrations/functions/BOX_R__box_notify.sql b/db/box_migrations/functions/BOX_R__box_notify.sql index 75610aac..cf18979f 100644 --- a/db/box_migrations/functions/BOX_R__box_notify.sql +++ b/db/box_migrations/functions/BOX_R__box_notify.sql @@ -1,15 +1,15 @@ create or replace function box_notify( channel text, - payload json + payload jsonb ) returns void set search_path from current as $$ declare output text := ''; begin - output := row_to_json( - (SELECT ColumnName FROM (SELECT channel,payload) AS ColumnName (channel,payload)) - )::text; + output := to_jsonb( + (SELECT t FROM (SELECT channel,payload) AS t (channel,payload)) + )::text; -- subtracting the amount from the sender's account PERFORM pg_notify('box_' || current_schema || '_channel',output); diff --git a/db/box_migrations/functions/BOX_R__mail_notifications.sql b/db/box_migrations/functions/BOX_R__mail_notifications.sql index 991a3fff..6d30eea8 100644 --- a/db/box_migrations/functions/BOX_R__mail_notifications.sql +++ b/db/box_migrations/functions/BOX_R__mail_notifications.sql @@ -11,7 +11,7 @@ begin (now(),now(),_mail_from, _mail_to, _mail_cc,_mail_bcc, _subject, _text, _html,_params, now(),_mail_reply_to) returning id into _mail_id; -- subtracting the amount from the sender's account - PERFORM box_notify('mail_feedback_channel','{"sendMail": true}'); + PERFORM box_notify('mail_feedback_channel','{"sendMail": true}'::jsonb); return _mail_id; end; diff --git a/db/box_migrations/functions/BOX_R__translate_column.sql b/db/box_migrations/functions/BOX_R__translate_column.sql new file mode 100644 index 00000000..2a6faacb --- /dev/null +++ b/db/box_migrations/functions/BOX_R__translate_column.sql @@ -0,0 +1,16 @@ +create or replace function translate_column(schema text, _table text, from_lang text, to_lang text, from_column text, to_column text) returns boolean + SET search_path from current + language plpgsql as $$ +begin + perform box_notify('translate_column', + jsonb_build_object( + 'schema',schema, + 'table', _table, + 'from_lang', from_lang, + 'to_lang',to_lang, + 'from_column', from_column, + 'to_column',to_column + )); + return true; +end; +$$; \ No newline at end of file diff --git a/db/box_migrations/functions/BOX_R__ui_notification.sql b/db/box_migrations/functions/BOX_R__ui_notification.sql index 79b63e3f..3d939133 100644 --- a/db/box_migrations/functions/BOX_R__ui_notification.sql +++ b/db/box_migrations/functions/BOX_R__ui_notification.sql @@ -7,7 +7,7 @@ declare output text := ''; begin - output := row_to_json( + output := row_to_jsonb( (SELECT ColumnName FROM (SELECT topic,users,payload) AS ColumnName (topic,allowed_users,payload)) )::text; diff --git a/server/src/main/scala/ch/wsl/box/rest/Module.scala b/server/src/main/scala/ch/wsl/box/rest/Module.scala index 59b1bda7..cb683104 100644 --- a/server/src/main/scala/ch/wsl/box/rest/Module.scala +++ b/server/src/main/scala/ch/wsl/box/rest/Module.scala @@ -11,7 +11,7 @@ import ch.wsl.box.services.file.ImageCacheStorage import ch.wsl.box.services.files.{InMemoryImageCacheStorage, PgImageCacheStorage} import ch.wsl.box.services.mail.{MailService, MailServiceCourier, MailServiceDummy} import ch.wsl.box.services.mail_dispatcher.{MailDispatcherService, SingleHostMailDispatcherService} -import ch.wsl.box.services.translation.{DeepLTranslateService, TranslateService} +import ch.wsl.box.services.translation.{ColumnTranslate, DeepLTranslateService, TranslateService} import com.softwaremill.session.{InMemoryRefreshTokenStorage, RefreshTokenStorage} import scribe.Logging import wvlet.airframe._ @@ -62,6 +62,7 @@ object DefaultModule extends Module { s.connection.dbConnection.close() s.connection.adminDbConnection.close() } + .bind[ColumnTranslate].toEagerSingleton } diff --git a/server/src/main/scala/ch/wsl/box/rest/logic/notification/DbNotify.scala b/server/src/main/scala/ch/wsl/box/rest/logic/notification/DbNotify.scala index 530dff7d..eb7e01a7 100644 --- a/server/src/main/scala/ch/wsl/box/rest/logic/notification/DbNotify.scala +++ b/server/src/main/scala/ch/wsl/box/rest/logic/notification/DbNotify.scala @@ -45,7 +45,7 @@ class DbNotifyHandlerImpl(connection:Connection,conf:FullConfig) extends DbNotif def listenAll() = { logger.info(s"Setting up DB Listen") - val io = connection.singleAdminSession().use{ s => + val io = connection.notificationSession().use{ s => val channelName = s"box_${conf.boxSchemaName}_channel" logger.info(s"Listening on $channelName") val ch = s.channel(Identifier.fromString(channelName).toOption.get) diff --git a/server/src/main/scala/ch/wsl/box/services/translation/ColumnTranslate.scala b/server/src/main/scala/ch/wsl/box/services/translation/ColumnTranslate.scala new file mode 100644 index 00000000..df3a29c4 --- /dev/null +++ b/server/src/main/scala/ch/wsl/box/services/translation/ColumnTranslate.scala @@ -0,0 +1,57 @@ +package ch.wsl.box.services.translation + +import ch.wsl.box.jdbc.{Connection, FullDatabase} +import ch.wsl.box.model.shared.{JSONDiff, JSONDiffField, JSONDiffModel, JSONID, JSONQuery} +import ch.wsl.box.rest.logic.notification.DbNotify +import ch.wsl.box.rest.metadata.EntityMetadataFactory +import ch.wsl.box.rest.runtime.Registry +import ch.wsl.box.rest.utils.UserProfile +import ch.wsl.box.services.Services +import ch.wsl.box.shared.utils.JSONUtils.EnhancedJson +import io.circe.Json +import scribe.{Logger, Logging} +import slick.dbio.DBIO + +import scala.concurrent.{ExecutionContext, Future} + +case class ColumnTranslateRequest(schema:String,table:String,from_lang:String,to_lang:String,from_column:String,to_column:String) + +class ColumnTranslate(dbNotify: DbNotify,translateService: TranslateService, connection: Connection)(implicit ec:ExecutionContext,services:Services) extends Logging { + + implicit val dec = io.circe.generic.semiauto.deriveDecoder[ColumnTranslateRequest] + + def translate(tr:ColumnTranslateRequest) = { + + val actions = Registry().actions(tr.table) + + implicit val fd = FullDatabase(connection.adminDB,connection.adminDB) + implicit val up = UserProfile(connection.adminUser,connection.adminUser) + + for{ + metadata <- EntityMetadataFactory.of(tr.table,Registry()) + from <- connection.adminDB.run{ + actions.fetchFields(metadata.keys ++ Seq(tr.from_column,tr.to_column),JSONQuery.limit(100000)) + } + translated <- translateService.translateAll(tr.from_lang,tr.to_lang,from.map(_.get(tr.from_column))) + _ <- connection.adminDB.run { + DBIO.sequence(from.zip(translated).map { case (row, translated) => + val diff = JSONDiff(Seq( + JSONDiffModel(tr.table, JSONID.fromData(row, metadata), Seq(JSONDiffField(tr.to_column, row.jsOpt(tr.to_column), Some(Json.fromString(translated))))) + )) + actions.updateDiff(diff) + }) + } + } yield true + + } + + dbNotify.listen("translate_column", js => { + js.as[ColumnTranslateRequest] match { + case Left(value) => { + logger.warn(s"Channel translate_column, can't decode $js") + Future.successful(false) + } + case Right(value) => translate(value) + } + }) +} From 83eac43ea94d60b9aba9be7ccc66798c2f22441a Mon Sep 17 00:00:00 2001 From: Andrea Minetti Date: Fri, 20 Mar 2026 14:05:30 +0100 Subject: [PATCH 23/42] Export PDF --- client/index.html | 2 +- client/package.json | 7 +- .../box/client/services/BoxFileReader.scala | 2 +- .../wsl/box/client/services/HttpClient.scala | 3 +- .../ch/wsl/box/client/services/Labels.scala | 1 + .../ch/wsl/box/client/services/PDF.scala | 97 +++++++++++++++++++ .../client/services/impl/HttpClientImpl.scala | 12 ++- .../ch/wsl/box/client/utils/ImageUtils.scala | 55 +++++++++++ .../wsl/box/client/vendors/CompressorJS.scala | 12 +++ .../box/client/views/EntityTableView.scala | 44 +++++---- .../box/client/views/components/Footer.scala | 2 +- .../wsl/box/client/mocks/HttpClientMock.scala | 4 +- .../ch/wsl/box/rest/routes/Exporters.scala | 22 +++-- .../scala/ch/wsl/box/rest/routes/File.scala | 8 +- .../scala/ch/wsl/box/rest/routes/Form.scala | 28 ++---- .../scala/ch/wsl/box/rest/routes/Table.scala | 10 +- .../wsl/box/model/shared/JSONMetadata.scala | 4 + .../wsl/box/model/shared/SharedLabels.scala | 2 + 18 files changed, 258 insertions(+), 57 deletions(-) create mode 100644 client/src/main/scala/ch/wsl/box/client/services/PDF.scala create mode 100644 client/src/main/scala/ch/wsl/box/client/utils/ImageUtils.scala create mode 100644 client/src/main/scala/ch/wsl/box/client/vendors/CompressorJS.scala diff --git a/client/index.html b/client/index.html index 5ccf2e50..4167466a 100644 --- a/client/index.html +++ b/client/index.html @@ -10,7 +10,7 @@
diff --git a/client/package.json b/client/package.json index 9da70688..2a518ea7 100644 --- a/client/package.json +++ b/client/package.json @@ -36,8 +36,8 @@ "hotkeys-js": "3.10.0", "jquery": "3.4.1", "js-md5": "0.8.3", - "jspdf": "2.5.1", - "jspdf-autotable": "3.5.28", + "jspdf": "4.2.1", + "jspdf-autotable": "5.0.7", "jspreadsheet-ce": "git://github.com/jspreadsheet/ce.git#2e7389f8f6a84d260603bbac06f00bb404e1ba49", "jsts": "2.7.1", "jsuites": "5.9.1", @@ -52,7 +52,8 @@ "string-strip-html": "13.5.3", "tailwindcss": "^4.1.18", "toolcool-range-slider": "4.0.28", - "xlsx-js-style": "1.2.0" + "xlsx-js-style": "1.2.0", + "is-blob": "3.0.0" }, "devDependencies": { "@scala-js/vite-plugin-scalajs": "^1.1.0", diff --git a/client/src/main/scala/ch/wsl/box/client/services/BoxFileReader.scala b/client/src/main/scala/ch/wsl/box/client/services/BoxFileReader.scala index 824d853c..322b9c34 100644 --- a/client/src/main/scala/ch/wsl/box/client/services/BoxFileReader.scala +++ b/client/src/main/scala/ch/wsl/box/client/services/BoxFileReader.scala @@ -28,7 +28,7 @@ object BoxFileReader { } - def readAsDataURL(blob:Blob)(implicit ex:ExecutionContext):Future[js.Any] = _read(_.readAsDataURL(blob)) + def readAsDataURL(blob:Blob)(implicit ex:ExecutionContext):Future[String] = _read(_.readAsDataURL(blob)).map(_.toString) def readAsArrayBuffer(blob:Blob)(implicit ex:ExecutionContext):Future[js.Any] = _read(_.readAsArrayBuffer(blob)) def readAsText(blob:Blob)(implicit ex:ExecutionContext):Future[String] = _read(_.readAsText(blob)).map(_.toString) diff --git a/client/src/main/scala/ch/wsl/box/client/services/HttpClient.scala b/client/src/main/scala/ch/wsl/box/client/services/HttpClient.scala index 0a131f19..b5d31cd3 100755 --- a/client/src/main/scala/ch/wsl/box/client/services/HttpClient.scala +++ b/client/src/main/scala/ch/wsl/box/client/services/HttpClient.scala @@ -4,7 +4,7 @@ import java.io.ByteArrayInputStream import ch.wsl.box.client.{Context, IndexState} import ch.wsl.box.model.shared.errors.{ExceptionReport, GenericExceptionReport, JsonDecoderExceptionReport, SQLExceptionReport} import org.scalajs.dom -import org.scalajs.dom.{File, FormData, XMLHttpRequest} +import org.scalajs.dom.{Blob, File, FormData, XMLHttpRequest} import scribe.Logging import scala.concurrent.{ExecutionContext, Future, Promise} @@ -28,6 +28,7 @@ trait HttpClient{ def sendFile[T](url: String, file: File)(implicit decoder: io.circe.Decoder[T], ex:ExecutionContext):Future[T] def sendRaw[T](url: String, data: js.Any)(implicit decoder: io.circe.Decoder[T], ex:ExecutionContext):Future[T] def setHandleAuthFailure(f:() => Unit) + def getBlob(url:String)(implicit ex:ExecutionContext):Future[Blob] } diff --git a/client/src/main/scala/ch/wsl/box/client/services/Labels.scala b/client/src/main/scala/ch/wsl/box/client/services/Labels.scala index bf1b9a35..15ddb4dd 100644 --- a/client/src/main/scala/ch/wsl/box/client/services/Labels.scala +++ b/client/src/main/scala/ch/wsl/box/client/services/Labels.scala @@ -144,6 +144,7 @@ object Labels { def confirmRevert = get(SharedLabels.entity.confirmRevert) def csv = get(SharedLabels.entity.csv) def xls = get(SharedLabels.entity.xls) + def pdf = get(SharedLabels.entity.pdf) def importxls = get(SharedLabels.entity.importxls) def shp = get(SharedLabels.entity.shp) def geoPackage = get(SharedLabels.entity.geopackage) diff --git a/client/src/main/scala/ch/wsl/box/client/services/PDF.scala b/client/src/main/scala/ch/wsl/box/client/services/PDF.scala new file mode 100644 index 00000000..407a7372 --- /dev/null +++ b/client/src/main/scala/ch/wsl/box/client/services/PDF.scala @@ -0,0 +1,97 @@ +package ch.wsl.box.client.services + +import ch.wsl.box.client.Context.services +import ch.wsl.box.client.routes.Routes +import ch.wsl.box.model.shared.{ExportMode, JSONQuery} +import io.circe.syntax.EncoderOps +import io.circe.generic.auto._ + +import scala.scalajs.js.URIUtils +import JSONQuery._ +import ch.wsl.box.client.utils.ImageUtils +import ch.wsl.typings.jspdfAutotable.anon.PartialStyles +import ch.wsl.typings.jspdfAutotable.mod.{HorizontalPageBreakBehaviourType, RowInput, UserOptions} +import kantan.csv._ +import kantan.csv.ops._ + +import scala.concurrent.ExecutionContext +import scala.scalajs.js.JSConverters._ +import scala.scalajs.js +import scalatags.JsDom.all._ +import ch.wsl.typings.jspdf.mod.jsPDF +import com.avsystem.commons.Future + +object PDF { + + import ch.wsl.box.client.Context._ + + + def renderTable(title:String,header:Seq[String],body:Seq[Seq[String]])(implicit ec:ExecutionContext) = { + + val logo = UI.logo match { + case Some(l) => for{ + img <- services.httpClient.getBlob(ClientConf.frontendUrl + l) + resized <- ImageUtils.setHeight(img,60) + base64 <- BoxFileReader.readAsDataURL(resized) + dim <- ImageUtils.dimensions(base64) + } yield { + println(dim) + Some((base64,dim)) + } + case None => Future.successful(None) + } + + logo.foreach { logo => + + val doc = new jsPDF(ch.wsl.typings.jspdf.jspdfStrings.landscape,ch.wsl.typings.jspdf.jspdfStrings.mm) + + val data = body.map(_.toJSArray).toJSArray.asInstanceOf[js.Array[RowInput]] + + ch.wsl.typings.jspdfAutotable.mod.default(doc, UserOptions() + .setHead(js.Array(header.toJSArray).asInstanceOf[js.Array[RowInput]]) + .setBody(data) + .setWillDrawPage(data => { + doc.setFontSize(12) + doc.setTextColor(40) + logo.foreach { case (i,d) => + println(d) + doc.addImage(i, "JPEG", data.settings.margin.left, 6, d.scaled_w(10),10) + } + doc.text(s"${UI.title.getOrElse("Box Framework")} - $title", data.settings.margin.left + 10 + logo.map(_._2.scaled_w(10)).getOrElse(0), 12) + () + }) + .setMargin(js.Array[Double](20, 10, 10, 10)) + .setStyles(PartialStyles().setCellPadding(0.5).setFontSize(9)) + .setHeadStyles(PartialStyles().setFillColor(ClientConf.colorMain).setTextColor(ClientConf.colorMainText)) + .setHorizontalPageBreak(true) + .setHorizontalPageBreakRepeat(0) + .setHorizontalPageBreakBehaviour(HorizontalPageBreakBehaviourType.immediately) + ) + + val pages: Int = doc.internal.asInstanceOf[js.Dynamic].getNumberOfPages().asInstanceOf[Int] + for (i <- 1 to pages) { + doc.setFontSize(8) + val h = doc.internal.pageSize.getHeight() + val w = doc.internal.pageSize.getWidth() + doc.setPage(i) + doc.text(s"$i/$pages", w - 30, h - 10) + } + + doc.save(s"$title.pdf") + } + } + + def table(kind:String,modelName:String,fields:Seq[String],query:JSONQuery)(implicit ec:ExecutionContext) = { + val csv = Routes.apiV1( + s"/$kind/${services.clientSession.lang()}/$modelName/csv?fk=${ExportMode.RESOLVE_FK}&fields=${fields.mkString(",")}&q=${URIUtils.encodeURI(query.asJson.noSpaces)}".replaceAll("\n","") + ) + services.httpClient.get[String](csv).map{ result => + result.asUnsafeCsvReader[Seq[String]](rfc).toList match { + case header :: body => renderTable(modelName,header,body) + } + + + } + + } +} diff --git a/client/src/main/scala/ch/wsl/box/client/services/impl/HttpClientImpl.scala b/client/src/main/scala/ch/wsl/box/client/services/impl/HttpClientImpl.scala index f06fd344..0b1941ee 100644 --- a/client/src/main/scala/ch/wsl/box/client/services/impl/HttpClientImpl.scala +++ b/client/src/main/scala/ch/wsl/box/client/services/impl/HttpClientImpl.scala @@ -5,7 +5,7 @@ import ch.wsl.box.client.services.{BrowserConsole, HttpClient, Labels, Notificat import ch.wsl.box.model.shared.errors.{ExceptionReport, GenericExceptionReport, JsonDecoderExceptionReport, SQLExceptionReport} import io.circe.{Decoder, Json} import org.scalajs.dom -import org.scalajs.dom.{File, FormData, XMLHttpRequest} +import org.scalajs.dom.{Blob, File, FileReader, FormData, HttpMethod, RequestInit, XMLHttpRequest} import scribe.Logging import scala.concurrent.{ExecutionContext, ExecutionContextExecutor, Future, Promise} @@ -195,4 +195,14 @@ class HttpClientImpl extends HttpClient with Logging { override def setHandleAuthFailure(f: () => Unit): Unit = { handleAuthFailure = f } + + override def getBlob(url: String)(implicit ex:ExecutionContext): Future[Blob] = { + for{ + req <- dom.fetch(url,new RequestInit { + method = HttpMethod.GET + }).toFuture + blob <- req.blob().toFuture + } yield blob + + } } \ No newline at end of file diff --git a/client/src/main/scala/ch/wsl/box/client/utils/ImageUtils.scala b/client/src/main/scala/ch/wsl/box/client/utils/ImageUtils.scala new file mode 100644 index 00000000..c2850925 --- /dev/null +++ b/client/src/main/scala/ch/wsl/box/client/utils/ImageUtils.scala @@ -0,0 +1,55 @@ +package ch.wsl.box.client.utils + +import ch.wsl.box.client.services.BrowserConsole +import ch.wsl.box.client.vendors.CompressorJS +import ch.wsl.typings.compressorjs +import org.scalajs.dom +import org.scalajs.dom.{Blob, Image} +import scribe.Logging + +import scala.concurrent.{Future, Promise} +import scala.scalajs.js + +case class ImageDimensions(h:Int,w:Int) { + def scaled_w(new_h:Int):Int = (new_h.toDouble / h * w).toInt +} + +object ImageUtils extends Logging { + def dimensions(image:String): Future[ImageDimensions] = { + val p = Promise[ImageDimensions]() + val img = new Image() + img.src = image + img.onload = _ => { + p.success(ImageDimensions(img.naturalHeight,img.naturalWidth)) + } + p.future + } + + + def setHeight(img:Blob,height:Int) = { + + val p = Promise[Blob]() + BrowserConsole.log(img) + BrowserConsole.log(img.toString) + println(ch.wsl.typings.isBlob.mod.default(img)) + + dom.window.asInstanceOf[js.Dynamic].tmpBlob = img + + val options = compressorjs.Compressor.Options() + .setHeight(height) + .setSuccess{ b => + p.success(b.asInstanceOf[Blob]) + } + .setError{ e => + logger.warn(e.message) + p.failure(new Exception(e.message)) + } + new CompressorJS(img,options) + + + + p.future + + } + +} diff --git a/client/src/main/scala/ch/wsl/box/client/vendors/CompressorJS.scala b/client/src/main/scala/ch/wsl/box/client/vendors/CompressorJS.scala new file mode 100644 index 00000000..6610b0e6 --- /dev/null +++ b/client/src/main/scala/ch/wsl/box/client/vendors/CompressorJS.scala @@ -0,0 +1,12 @@ +package ch.wsl.box.client.vendors + +import org.scalajs.dom.Blob + +import scala.scalajs.js +import scala.scalajs.js.annotation.JSImport + +@JSImport("compressorjs", JSImport.Default) +@js.native +class CompressorJS(blob:Blob,options:js.Any) extends js.Object { + +} diff --git a/client/src/main/scala/ch/wsl/box/client/views/EntityTableView.scala b/client/src/main/scala/ch/wsl/box/client/views/EntityTableView.scala index de9f89b1..fe84a424 100755 --- a/client/src/main/scala/ch/wsl/box/client/views/EntityTableView.scala +++ b/client/src/main/scala/ch/wsl/box/client/views/EntityTableView.scala @@ -4,7 +4,7 @@ import ch.wsl.box.client.Context.services import ch.wsl.box.client.db.{DB, LocalRecord} import ch.wsl.box.client.routes.Routes import ch.wsl.box.client.{Context, EntityFormState, EntityTableState, FormPageState} -import ch.wsl.box.client.services.{BrowserConsole, ClientConf, Labels, Navigate, Navigation, Notification, UI} +import ch.wsl.box.client.services.{BrowserConsole, ClientConf, Labels, Navigate, Navigation, Notification, PDF, UI} import ch.wsl.box.client.styles.Icons.Icon import ch.wsl.box.client.styles.{BootstrapCol, Icons} import ch.wsl.box.client.utils.{ElementId, TestHooks, URLQuery} @@ -160,7 +160,7 @@ case class EntityTablePresenter(model:ModelProperty[EntityTableModel], onSelect: } } - private def _handleState(state: EntityTableState,emptyFieldsForm:JSONMetadata): Unit = { + private def _handleState(state: EntityTableState,metadata:JSONMetadata): Unit = { services.clientSession.loading.set(true) logger.info(s"handling Entity table state name=${state.entity}, kind=${state.kind} and query=${state.query}") @@ -169,9 +169,6 @@ case class EntityTablePresenter(model:ModelProperty[EntityTableModel], onSelect: val urlQuery:Option[JSONQuery] = URLQuery.fromQueryParameters(Routes.urlParams.get("q")).orElse(stateQuery) services.clientSession.setURLQuery(urlQuery.getOrElse(JSONQuery.empty)) - val fields = emptyFieldsForm.fields.filter(field => emptyFieldsForm.tabularFields.contains(field.name)) - val form = emptyFieldsForm.copy(fields = fields) - val defaultQuery:JSONQuery = JSONQuery.empty.limit(ClientConf.pageLength) val queryWithGeom:JSONQuery = services.clientSession.getQueryFor(state.kind,state.entity,urlQuery) match { @@ -179,12 +176,12 @@ case class EntityTablePresenter(model:ModelProperty[EntityTableModel], onSelect: case _ => urlQuery.getOrElse(defaultQuery) } - val geomFields = emptyFieldsForm.geomFields.map(_.name) + val geomFields = metadata.geomFields.map(_.name) val query = queryWithGeom.copy(filter = queryWithGeom.filter.filterNot(f => geomFields.contains(f.column))) {for{ access <- { if(!state.public) - services.rest.tableAccess(form.entity,state.kind) + services.rest.tableAccess(metadata.entity,state.kind) else Future.successful(TableAccess(false,false,false)) } specificKind <- { if(!state.public) @@ -199,7 +196,7 @@ case class EntityTablePresenter(model:ModelProperty[EntityTableModel], onSelect: kind = specificKind, urlQuery = urlQuery, rows = Seq(), - fieldQueries = form.table.map{ field => + fieldQueries = metadata.table.map{ field => val operator = query.filter.find(_.column == field.name).flatMap(_.operator).getOrElse(Filter.default(field)) val rawValue = query.filter.find(_.column == field.name).flatMap(_.value).getOrElse("") @@ -211,7 +208,7 @@ case class EntityTablePresenter(model:ModelProperty[EntityTableModel], onSelect: filterOperator = operator ) }, - metadata = Some(form), + metadata = Some(metadata), selectedRow = Seq(), ids = IDsVMFactory.empty, pages = Navigation.pageCount(0), @@ -221,7 +218,7 @@ case class EntityTablePresenter(model:ModelProperty[EntityTableModel], onSelect: geoms = Seq(), extent = None, public = state.public, - selectedColumns = form.preselectedTable + selectedColumns = metadata.preselectedTable ) //saveIds(IDs(true,1,Seq(),0),query) @@ -551,6 +548,11 @@ case class EntityTablePresenter(model:ModelProperty[EntityTableModel], onSelect: e.preventDefault() } + val downloadPdf = (e: Event) => { + download("pdf") + e.preventDefault() + } + val downloadXLS = (e:Event) => { download("xlsx") e.preventDefault() @@ -583,18 +585,21 @@ case class EntityTablePresenter(model:ModelProperty[EntityTableModel], onSelect: val kind = EntityKind(model.subProp(_.kind).get).entityOrForm val modelName = model.subProp(_.name).get - val exportFields = model.get.metadata.map(_.exportFields).getOrElse(Seq()) - val fields = model.get.metadata.map(_.fields).getOrElse(Seq()) + val fields = model.get.selectedColumns.map(_.name) - val queryNoLimits = query(None).copy(paging = None) + val queryNoLimits = query(None).copy(paging = None) - val url = Routes.apiV1( - s"/$kind/${services.clientSession.lang()}/$modelName/$format?fk=${ExportMode.RESOLVE_FK}&fields=${exportFields.mkString(",")}&q=${URIUtils.encodeURI(queryNoLimits.asJson.noSpaces)}".replaceAll("\n","") - ) - logger.info(s"downloading: $url") - dom.window.open(url) + if(format == "pdf") { + PDF.table(kind,modelName,fields,queryNoLimits) + } else { + val url = Routes.apiV1( + s"/$kind/${services.clientSession.lang()}/$modelName/$format?fk=${ExportMode.RESOLVE_FK}&fields=${fields.mkString(",")}&q=${URIUtils.encodeURI(queryNoLimits.asJson.noSpaces)}".replaceAll("\n", "") + ) + logger.info(s"downloading: $url") + dom.window.open(url) + } } def getObj(id:JSONID):Future[Json] = { @@ -1054,7 +1059,7 @@ case class EntityTableView(model:ModelProperty[EntityTableModel], presenter:Enti headerFactory = Some(_ => div(Labels.table.column_selection).render), bodyFactory = Some { nested => div( - metadata.toSeq.flatMap(_.table).filterNot(_.`type` == JSONFieldTypes.GEOMETRY).map{ c => + metadata.toSeq.flatMap(_.fields).filterNot(_.`type` == JSONFieldTypes.GEOMETRY).map{ c => div( Checkbox(localModel.bitransform(_.contains(c)){ case true => localModel.get ++ Seq(c) @@ -1146,6 +1151,7 @@ case class EntityTableView(model:ModelProperty[EntityTableModel], presenter:Enti ), button(`type` := "button", onclick :+= presenter.downloadCSV, ClientConf.style.boxButton, Labels.entity.csv), button(`type` := "button", onclick :+= presenter.downloadXLS, ClientConf.style.boxButton, Labels.entity.xls), + button(`type` := "button", onclick :+= presenter.downloadPdf, ClientConf.style.boxButton, Labels.entity.pdf), button(`type` := "button", onclick :+= presenter.importXLS, ClientConf.style.boxButton, Labels.entity.importxls), if (presenter.hasGeometry()) { Seq( diff --git a/client/src/main/scala/ch/wsl/box/client/views/components/Footer.scala b/client/src/main/scala/ch/wsl/box/client/views/components/Footer.scala index 10aac483..ef434fe4 100755 --- a/client/src/main/scala/ch/wsl/box/client/views/components/Footer.scala +++ b/client/src/main/scala/ch/wsl/box/client/views/components/Footer.scala @@ -11,6 +11,6 @@ object Footer { def template(logo:Option[String]) = footer(ClientConf.style.noMobile, div(BootstrapStyles.Float.left())(copyright), - div(BootstrapStyles.Float.right())( logo.map(x => img(ClientConf.style.headerLogo,src := x))) + div(BootstrapStyles.Float.right())( logo.map(x => img(ClientConf.style.headerLogo,src := ClientConf.frontendUrl + x))) ).render } \ No newline at end of file diff --git a/client/src/test/scala/ch/wsl/box/client/mocks/HttpClientMock.scala b/client/src/test/scala/ch/wsl/box/client/mocks/HttpClientMock.scala index d52a21ce..7da3efea 100644 --- a/client/src/test/scala/ch/wsl/box/client/mocks/HttpClientMock.scala +++ b/client/src/test/scala/ch/wsl/box/client/mocks/HttpClientMock.scala @@ -2,7 +2,7 @@ package ch.wsl.box.client.mocks import ch.wsl.box.client.services.HttpClient import io.circe.{Decoder, Encoder} -import org.scalajs.dom.File +import org.scalajs.dom.{Blob, File} import scala.concurrent.{ExecutionContext, Future} import scala.scalajs.js @@ -25,4 +25,6 @@ class HttpClientMock extends HttpClient { override def sendRaw[T](url: String, data: js.Any)(implicit decoder: Decoder[T], ex: ExecutionContext): Future[T] = ??? override def setHandleAuthFailure(f: () => Unit): Unit = {} + + override def getBlob(url: String)(implicit ex: ExecutionContext): Future[Blob] = ??? } diff --git a/server/src/main/scala/ch/wsl/box/rest/routes/Exporters.scala b/server/src/main/scala/ch/wsl/box/rest/routes/Exporters.scala index 72428d8b..4f83362d 100644 --- a/server/src/main/scala/ch/wsl/box/rest/routes/Exporters.scala +++ b/server/src/main/scala/ch/wsl/box/rest/routes/Exporters.scala @@ -5,7 +5,7 @@ import akka.http.scaladsl.model.headers.{ContentDispositionTypes, `Content-Dispo import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server.Route import akka.stream.Materializer -import ch.wsl.box.model.shared.{CSVTable, JSONFieldLookupData, JSONFieldLookupExtractor, JSONFieldLookupRemote, JSONMetadata, JSONQuery, XLSTable} +import ch.wsl.box.model.shared.{CSVTable, JSONField, JSONFieldLookupData, JSONFieldLookupExtractor, JSONFieldLookupRemote, JSONMetadata, JSONQuery, XLSTable} import ch.wsl.box.rest.io.xls.XLS import ch.wsl.box.rest.logic.{FormActions, Lookup} import io.circe.parser.parse @@ -37,7 +37,7 @@ trait Exporters { val registry: RegistryInstance val name:String val metadataFactory: MetadataFactory - def tabularMetadata(fields:Option[Seq[String]] = None): DBIO[JSONMetadata] + def tabularMetadata(): DBIO[JSONMetadata] def actions:FormActions def mergeWithForeignKeys(extractFk: Boolean,data:Seq[Json],fk: Map[String,Seq[Json]],metadata:JSONMetadata):Seq[Json] = { @@ -88,19 +88,29 @@ trait Exporters { } } - def exportCsv(q:String,fk:Option[String])(implicit session:BoxSession, db:FullDatabase, mat:Materializer, ec:ExecutionContext, services:Services) = { + def exportCsv(q:String,fk:Option[String],fields:Option[String])(implicit session:BoxSession, db:FullDatabase, mat:Materializer, ec:ExecutionContext, services:Services) = { val extractFk = fk.forall(_ == "resolve_fk") val query = parse(q).right.get.as[JSONQuery].right.get + + + def selectedFields(mf:Seq[JSONField]):Seq[JSONField] = { + fields.map(_.split(",").toSeq) match { + case Some(value) => value.flatMap(f => mf.find(_.name == f)) + case None => mf + } + } + val io = for { metadata <- DBIO.from(boxDb.adminDb.run(tabularMetadata())) formActions = FormActions(metadata, registry, metadataFactory) fkValues <- Lookup.valuesForEntity(metadata) - data <- formActions.list(query, true, _.exportFieldsNoGeom.map(_.name)) + fields = selectedFields(metadata.exportFieldsNoGeom) + data <- formActions.list(query, true, _ => fields.map(_.name)) csvTable = CSVTable( title = name, - header = metadata.exportFieldsNoGeom.map(_.title), - rows = mergeWithForeignKeys(extractFk,data,fkValues,metadata).map(row => metadata.exportFieldsNoGeom.map(cell => row.get(cell.name))) + header = fields.map(_.title), + rows = mergeWithForeignKeys(extractFk,data,fkValues,metadata).map(row => fields.map(cell => row.get(cell.name))) ) } yield { CSV.download(csvTable) diff --git a/server/src/main/scala/ch/wsl/box/rest/routes/File.scala b/server/src/main/scala/ch/wsl/box/rest/routes/File.scala index 8917bd86..6b91056b 100755 --- a/server/src/main/scala/ch/wsl/box/rest/routes/File.scala +++ b/server/src/main/scala/ch/wsl/box/rest/routes/File.scala @@ -24,6 +24,7 @@ import ch.wsl.box.rest.runtime.Registry import ch.wsl.box.rest.utils.UserProfile import ch.wsl.box.services.Services import ch.wsl.box.services.file.FileId +import org.apache.tika.Tika import scala.concurrent.duration.DurationInt import scala.concurrent.{Await, ExecutionContext, Future} @@ -41,10 +42,15 @@ object File{ def completeFile(f:BoxFile) = complete { + val file = f.file.getOrElse(Array()) val contentType = f.mime.flatMap{ mime => ContentType.parse(mime).right.toOption + }.orElse{ + val tika = new Tika() + val mime = tika.detect(file.take(4096)) + ContentType.parse(mime).right.toOption }.getOrElse(ContentTypes.`application/octet-stream`) - val file = f.file.getOrElse(Array()) + val name = f.name val entity = HttpEntity(contentType,file) diff --git a/server/src/main/scala/ch/wsl/box/rest/routes/Form.scala b/server/src/main/scala/ch/wsl/box/rest/routes/Form.scala index fe61a4fb..bce9d76e 100755 --- a/server/src/main/scala/ch/wsl/box/rest/routes/Form.scala +++ b/server/src/main/scala/ch/wsl/box/rest/routes/Form.scala @@ -60,32 +60,22 @@ case class Form( def metadata: JSONMetadata = Await.result(boxDb.adminDb.run(metadataFactory.of(name,lang,session.user)),10.seconds) def actions:FormActions = FormActions(metadata,registry,metadataFactory) - private def _tabMetadata(fields:Option[Seq[String]] = None,m:JSONMetadata): Seq[JSONField] = { - fields match { - case Some(fields) => m.fields.filter(field => fields.contains(field.name)) - case None => m.fields.filter(field => m.tabularFields.contains(field.name) || m.exportFields.contains(field.name)) - } - } - private def viewTableMetadata(fields:Seq[String],tableMetadata:JSONMetadata,viewMetadata:JSONMetadata): Seq[JSONField] = { - val tableFields = _tabMetadata(Some(fields),tableMetadata) - val viewFields = _tabMetadata(Some(fields),viewMetadata) - fields.flatMap{ field => - tableFields.find(_.name == field).orElse(viewFields.find(_.name == field)) - } + private def viewTableMetadata(formMetadata:JSONMetadata,viewMetadata:JSONMetadata): JSONMetadata = { + + val allFields = formMetadata.fields ++ viewMetadata.fields.filter(f => !formMetadata.fields.map(_.name).contains(f.name)) + formMetadata.copy(fields = allFields) } - def tabularMetadata(fields:Option[Seq[String]] = None): DBIO[JSONMetadata] = { - val filteredFields = metadata.view match { - case None => DBIO.successful(_tabMetadata(fields,metadata)) + def tabularMetadata(): DBIO[JSONMetadata] = { + metadata.view match { + case None => DBIO.successful(metadata) case Some(view) => DBIO.from(EntityMetadataFactory.of(view,registry).map{ vm => - viewTableMetadata(fields.getOrElse(metadata.tabularFields),metadata,vm) + viewTableMetadata(metadata,vm) }) } - filteredFields.map( ff => metadata.copy(fields = ff )) - } def privateOnly(r: => Route):Route = { @@ -116,7 +106,7 @@ case class Form( } ~ get { privateOnly { parameters('q, 'fk.?, 'fields.?) { (q, fk, fields) => - exportCsv(q,fk) + exportCsv(q,fk,fields) } } } diff --git a/server/src/main/scala/ch/wsl/box/rest/routes/Table.scala b/server/src/main/scala/ch/wsl/box/rest/routes/Table.scala index 40504101..13480c42 100755 --- a/server/src/main/scala/ch/wsl/box/rest/routes/Table.scala +++ b/server/src/main/scala/ch/wsl/box/rest/routes/Table.scala @@ -218,15 +218,19 @@ case class Table[T <: ch.wsl.box.jdbc.PostgresProfile.api.Table[M] with UpdateTa } ~ respondWithHeader(`Content-Disposition`(ContentDispositionTypes.attachment,Map("filename" -> s"$name.csv"))) { get { - parameters('q) { q => + parameters('q,'fields.?) { (q,fields) => import kantan.csv._ import kantan.csv.ops._ val query = parse(q).right.get.as[JSONQuery].right.get val csvString = for{ metadata <- EntityMetadataFactory.of(name, registry) - data <- db.run(dbActions.findSimple(query)) - } yield (Seq(metadata.fields.map(_.name)) ++ data.map(_.values())).asCsv(rfc) + selectedFields = fields.map(_.split(",").toSeq) match { + case Some(value) => value.flatMap(f => metadata.fields.find(_.name == f.name)).map(_.name) + case None => metadata.fields.map(_.name) + } + data <- db.run(dbActions.fetchFields(selectedFields,query)) + } yield (Seq(selectedFields) ++ data.map(r => selectedFields.map(f => r.get(f)))).asCsv(rfc) onComplete(csvString) { case Success(csv) => complete(csv) diff --git a/shared/src/main/scala/ch/wsl/box/model/shared/JSONMetadata.scala b/shared/src/main/scala/ch/wsl/box/model/shared/JSONMetadata.scala index bc1ea7e1..ee15e5ee 100755 --- a/shared/src/main/scala/ch/wsl/box/model/shared/JSONMetadata.scala +++ b/shared/src/main/scala/ch/wsl/box/model/shared/JSONMetadata.scala @@ -51,6 +51,10 @@ case class JSONMetadata( // }.dropWhile(_ == 0).headOption.getOrElse(0) // } def table:Seq[JSONField] = tabularFields.flatMap(tf => fields.find(_.name == tf)) + def exportTable:Seq[JSONField] = { + val r = exportFields.flatMap(tf => fields.find(_.name == tf)) + r + } def preselectedTable:Seq[JSONField] = params.flatMap(_.jsOpt("preselectedFields").flatMap(_.as[Seq[String]].toOption)) match { case Some(preselected) => preselected.flatMap(tf => fields.find(_.name == tf)) case None => table diff --git a/shared/src/main/scala/ch/wsl/box/model/shared/SharedLabels.scala b/shared/src/main/scala/ch/wsl/box/model/shared/SharedLabels.scala index 4fb17ba8..1992168d 100644 --- a/shared/src/main/scala/ch/wsl/box/model/shared/SharedLabels.scala +++ b/shared/src/main/scala/ch/wsl/box/model/shared/SharedLabels.scala @@ -226,6 +226,7 @@ object SharedLabels extends LabelsCollection { def confirmRevert = "table.confirmRevert" def csv = "table.csv" def xls = "table.xls" + def pdf = "table.pdf" def importxls = "table.importxls" def shp = "table.shp" def geopackage = "table.geopackage" @@ -241,6 +242,7 @@ object SharedLabels extends LabelsCollection { confirmRevert, csv, xls, + pdf, importxls, shp, geopackage From bfdad481cb0550eb86222308c81d739e700e63a0 Mon Sep 17 00:00:00 2001 From: Andrea Minetti Date: Mon, 23 Mar 2026 11:39:51 +0100 Subject: [PATCH 24/42] Fixed CSV export --- .../ch/wsl/box/rest/logic/DbActions.scala | 5 ++- .../ch/wsl/box/rest/logic/FormActions.scala | 24 +++------- .../rest/metadata/EntityMetadataFactory.scala | 10 +++-- .../ch/wsl/box/rest/routes/Exporters.scala | 44 ++++++++++--------- .../scala/ch/wsl/box/rest/routes/Form.scala | 2 +- .../ch/wsl/box/model/shared/JSONQuery.scala | 3 +- 6 files changed, 45 insertions(+), 43 deletions(-) diff --git a/server/src/main/scala/ch/wsl/box/rest/logic/DbActions.scala b/server/src/main/scala/ch/wsl/box/rest/logic/DbActions.scala index 3252cf18..74218204 100755 --- a/server/src/main/scala/ch/wsl/box/rest/logic/DbActions.scala +++ b/server/src/main/scala/ch/wsl/box/rest/logic/DbActions.scala @@ -128,7 +128,10 @@ class DbActions[T <: ch.wsl.box.jdbc.PostgresProfile.api.Table[M] with UpdateTab } - def keys(): DBIOAction[Seq[String], NoStream, Effect] = DBIO.from(services.connection.adminDB.run(EntityMetadataFactory.keysOf(entity.baseTableRow.schemaName.getOrElse("public"),entity.baseTableRow.tableName))) + def keys(): DBIOAction[Seq[String], NoStream, Effect] = EntityMetadataFactory.cachedKeyOf(entity.baseTableRow.schemaName.getOrElse("public"),entity.baseTableRow.tableName) match { + case Some(k) => DBIO.successful(k) + case None => DBIO.from(services.connection.adminDB.run(EntityMetadataFactory.keysOf(entity.baseTableRow.schemaName.getOrElse("public"),entity.baseTableRow.tableName))) + } override def ids(query: JSONQuery,keys:Seq[String]): DBIO[IDs] = { diff --git a/server/src/main/scala/ch/wsl/box/rest/logic/FormActions.scala b/server/src/main/scala/ch/wsl/box/rest/logic/FormActions.scala index 4b0d3d79..dd663f69 100755 --- a/server/src/main/scala/ch/wsl/box/rest/logic/FormActions.scala +++ b/server/src/main/scala/ch/wsl/box/rest/logic/FormActions.scala @@ -49,7 +49,7 @@ case class FormActions(metadata:JSONMetadata, jsonAction.getById(id).flatMap{ row => - DBIO.sequenceOption(row.map(expandJson)) + DBIO.sequenceOption(row.map(expandJson(_,metadata.fields.map(_.name)))) } } @@ -58,6 +58,7 @@ case class FormActions(metadata:JSONMetadata, private def queryForm(query: JSONQuery):DBIO[JSONQuery] = { val base = metadata.query.map{ defaultQuery => JSONQuery( + fields = query.fields, filter = defaultQuery.filter ++ query.filter, sort = query.sort ++ defaultQuery.sort, paging = query.paging @@ -67,17 +68,6 @@ case class FormActions(metadata:JSONMetadata, } - private def streamSeq(query:JSONQuery):DBIO[Seq[Json]] = { - - for{ - q <- queryForm(query) - rows <- jsonAction.findSimple(q) - result <- DBIO.sequence(rows.map(expandJson)) - } yield result - - } - - override def distinctOn(fields: Seq[String], query: JSONQuery): DBIO[Seq[Json]] = jsonAction.distinctOn(fields, query) override def findSimple(query:JSONQuery): DBIO[Seq[Json]] = { @@ -85,7 +75,7 @@ case class FormActions(metadata:JSONMetadata, for{ q <- queryForm(query) rows <- jsonAction.findSimple(q) - result <- DBIO.sequence(rows.map(expandJson)) + result <- DBIO.sequence(rows.map(r => expandJson(r,query.fields.getOrElse(metadata.fields.map(_.name))))) } yield result } @@ -104,7 +94,7 @@ case class FormActions(metadata:JSONMetadata, private def _list(query:JSONQuery):DBIO[Seq[Json]] = { queryForm(query).flatMap { q => metadata.view.map(v => Registry().actions(v)) match { - case None => streamSeq(q) + case None => findSimple(q) case Some(v) => v.findSimple(q) } } @@ -173,7 +163,7 @@ case class FormActions(metadata:JSONMetadata, } } - def list(query:JSONQuery,dropHtml:Boolean = false,fields:JSONMetadata => Seq[String] = _.tabularFields):DBIO[Seq[Json]] = __list(query, dropHtml).map{ rows => + def list(query:JSONQuery,dropHtml:Boolean = false,fields:Seq[String]):DBIO[Seq[Json]] = __list(query.copy(fields=Some(fields)), dropHtml).map{ rows => rows.map(Json.fromFields) } @@ -414,9 +404,9 @@ case class FormActions(metadata:JSONMetadata, FormActions(metadata,registry,metadataFactory,Some(field)).findSimple(query) } - private def expandJson(dataJson:Json):DBIO[Json] = { + private def expandJson(dataJson:Json,fields:Seq[String]):DBIO[Json] = { - val values = metadata.fields.map{ field => + val values = metadata.fields.filter(x => fields.contains(x.name)).map{ field => {(field.`type`,field.child) match { case ("static",_) => DBIO.successful(field.name -> field.default.asJson) //set default value case (_,None) => DBIO.successful(field.name -> dataJson.js(field.name)) //use given value diff --git a/server/src/main/scala/ch/wsl/box/rest/metadata/EntityMetadataFactory.scala b/server/src/main/scala/ch/wsl/box/rest/metadata/EntityMetadataFactory.scala index 4684936d..834de253 100644 --- a/server/src/main/scala/ch/wsl/box/rest/metadata/EntityMetadataFactory.scala +++ b/server/src/main/scala/ch/wsl/box/rest/metadata/EntityMetadataFactory.scala @@ -184,11 +184,15 @@ object EntityMetadataFactory extends Logging { } } - def keysOf(schema:String,table:String)(implicit ec:ExecutionContext,services:Services):DBIO[Seq[String]] = { - + def cachedKeyOf(schema:String,table:String) = { val cacheKey = (schema,table) logger.info("Getting " + cacheKey + " keys") - getSchema(schema).cacheKeys.get(table) match { + getSchema(schema).cacheKeys.get(table) + } + + def keysOf(schema:String,table:String)(implicit ec:ExecutionContext,services:Services):DBIO[Seq[String]] = { + + cachedKeyOf(schema,table) match { case Some(r) => DBIO.successful(r) case None => { logger.info(s"Metadata keys cache miss! cache key: ($table)") diff --git a/server/src/main/scala/ch/wsl/box/rest/routes/Exporters.scala b/server/src/main/scala/ch/wsl/box/rest/routes/Exporters.scala index 4f83362d..e5888564 100644 --- a/server/src/main/scala/ch/wsl/box/rest/routes/Exporters.scala +++ b/server/src/main/scala/ch/wsl/box/rest/routes/Exporters.scala @@ -3,7 +3,7 @@ package ch.wsl.box.rest.routes import akka.http.scaladsl.model.{HttpEntity, HttpResponse, MediaTypes} import akka.http.scaladsl.model.headers.{ContentDispositionTypes, `Content-Disposition`} import akka.http.scaladsl.server.Directives._ -import akka.http.scaladsl.server.Route +import akka.http.scaladsl.server.{RequestContext, Route} import akka.stream.Materializer import ch.wsl.box.model.shared.{CSVTable, JSONField, JSONFieldLookupData, JSONFieldLookupExtractor, JSONFieldLookupRemote, JSONMetadata, JSONQuery, XLSTable} import ch.wsl.box.rest.io.xls.XLS @@ -20,7 +20,7 @@ import ch.wsl.box.services.Services import ch.wsl.box.shared.utils.JSONUtils.EnhancedJson import io.circe.Json -import scala.concurrent.ExecutionContext +import scala.concurrent.{ExecutionContext, Future} trait Exporters { @@ -74,7 +74,7 @@ trait Exporters { metadata <- DBIO.from(boxDb.adminDb.run(tabularMetadata())) formActions = FormActions(metadata, registry, metadataFactory) fkValues <- Lookup.valuesForEntity(metadata) - data <- formActions.list(query, true, _.exportFieldsNoGeom.map(_.name)) + data <- formActions.list(query, true, metadata.exportFieldsNoGeom.map(_.name)) xlsTable = XLSTable( title = name, header = metadata.exportFieldsNoGeom.map(_.title), @@ -88,34 +88,38 @@ trait Exporters { } } - def exportCsv(q:String,fk:Option[String],fields:Option[String])(implicit session:BoxSession, db:FullDatabase, mat:Materializer, ec:ExecutionContext, services:Services) = { + def exportCsv(q:String,fk:Option[String],_fields:Option[String])(implicit session:BoxSession, db:FullDatabase, mat:Materializer, ec:ExecutionContext, services:Services): Route = { val extractFk = fk.forall(_ == "resolve_fk") val query = parse(q).right.get.as[JSONQuery].right.get def selectedFields(mf:Seq[JSONField]):Seq[JSONField] = { - fields.map(_.split(",").toSeq) match { + _fields.map(_.split(",").toSeq) match { case Some(value) => value.flatMap(f => mf.find(_.name == f)) case None => mf } } - val io = for { - metadata <- DBIO.from(boxDb.adminDb.run(tabularMetadata())) - formActions = FormActions(metadata, registry, metadataFactory) - fkValues <- Lookup.valuesForEntity(metadata) - fields = selectedFields(metadata.exportFieldsNoGeom) - data <- formActions.list(query, true, _ => fields.map(_.name)) - csvTable = CSVTable( - title = name, - header = fields.map(_.title), - rows = mergeWithForeignKeys(extractFk,data,fkValues,metadata).map(row => fields.map(cell => row.get(cell.name))) - ) - } yield { - CSV.download(csvTable) - } - onSuccess(db.db.run(io))(x => x) + val fut: Future[Route] = boxDb.adminDb.run(tabularMetadata()).flatMap { metadata => + + val formActions = FormActions(metadata, registry, metadataFactory) + val fields = selectedFields(metadata.exportFieldsNoGeom) + val io = for { + fkValues <- Lookup.valuesForEntity(metadata) + data <- formActions.list(query, true, fields.map(_.name)) + csvTable = CSVTable( + title = name, + header = fields.map(_.title), + rows = mergeWithForeignKeys(extractFk, data, fkValues, metadata).map(row => fields.map(cell => row.get(cell.name))) + ) + } yield { + CSV.download(csvTable) + } + db.db.run(io) + } + + rc:RequestContext => fut.flatMap(x => x(rc)) } // def shp(implicit session:BoxSession, db:FullDatabase, mat:Materializer, ec:ExecutionContext, services:Services): Route = path("shp") { diff --git a/server/src/main/scala/ch/wsl/box/rest/routes/Form.scala b/server/src/main/scala/ch/wsl/box/rest/routes/Form.scala index bce9d76e..6cb4e2a8 100755 --- a/server/src/main/scala/ch/wsl/box/rest/routes/Form.scala +++ b/server/src/main/scala/ch/wsl/box/rest/routes/Form.scala @@ -258,7 +258,7 @@ case class Form( val io = for { metadata <- DBIO.from(boxDb.adminDb.run(tabularMetadata())) formActions = FormActions(metadata, registry, metadataFactory) - result <- formActions.list(query, true) + result <- formActions.list(query, true, metadata.tabularFields) } yield { result } diff --git a/shared/src/main/scala/ch/wsl/box/model/shared/JSONQuery.scala b/shared/src/main/scala/ch/wsl/box/model/shared/JSONQuery.scala index c0f9f281..49678317 100755 --- a/shared/src/main/scala/ch/wsl/box/model/shared/JSONQuery.scala +++ b/shared/src/main/scala/ch/wsl/box/model/shared/JSONQuery.scala @@ -19,7 +19,8 @@ case class JSONQuery( filter:List[JSONQueryFilter], sort:List[JSONSort], paging:Option[JSONQueryPaging], - sqlWhere:Option[String] = None + sqlWhere:Option[String] = None, + fields:Option[Seq[String]] = None ){ def validatedWhere = sqlWhere.map(_.replaceAll("insert ","not valid") From bc548c381e0e24116800c49c175408f57f2e328a Mon Sep 17 00:00:00 2001 From: Andrea Minetti Date: Tue, 24 Mar 2026 09:56:22 +0100 Subject: [PATCH 25/42] Fixed other tab validation --- .../wsl/box/client/views/EntityFormView.scala | 20 +++++++++-- .../components/JSONMetadataRenderer.scala | 36 ++++++++++++++----- 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/client/src/main/scala/ch/wsl/box/client/views/EntityFormView.scala b/client/src/main/scala/ch/wsl/box/client/views/EntityFormView.scala index f2005abc..3ceab97a 100755 --- a/client/src/main/scala/ch/wsl/box/client/views/EntityFormView.scala +++ b/client/src/main/scala/ch/wsl/box/client/views/EntityFormView.scala @@ -304,8 +304,24 @@ case class EntityFormPresenter(model:ModelProperty[EntityFormModel]) extends Pre val errors = document.querySelectorAll("*:invalid") for(i <- 0 to errors.length) { errors.item(i) match { - case e:HTMLElement => logger.warn(s"Error on: ${e.outerHTML}") - case _ => logger.warn(s"Error on non HTMLElement") + case e:dom.HTMLElement => { + logger.warn(s"Error on: ${e} cass: ${e.className}") + window.asInstanceOf[js.Dynamic].testEL = e + e.closest(".box-tab") match { + case element: dom.HTMLElement => element.dataset.get("boxTab") match { + case Some(value) => document.getElementById(JSONMetadataRenderer.tabSelectorId(value)) match { + case element: dom.HTMLElement => { + element.click() + _form.reportValidity() + } + case _ => ??? + } + case None => logger.warn("Box tab element not found") + } + case _ => logger.warn("Non HTMLElement box-tab") + } + } + case e => logger.warn(s"Error on non HTMLElement: ${e}") } } logger.warn(s"Form validation failed") diff --git a/client/src/main/scala/ch/wsl/box/client/views/components/JSONMetadataRenderer.scala b/client/src/main/scala/ch/wsl/box/client/views/components/JSONMetadataRenderer.scala index 8a904a5f..42bc49f5 100755 --- a/client/src/main/scala/ch/wsl/box/client/views/components/JSONMetadataRenderer.scala +++ b/client/src/main/scala/ch/wsl/box/client/views/components/JSONMetadataRenderer.scala @@ -31,8 +31,11 @@ import org.scalajs.dom.{Event, window} * Created by andre on 4/25/2017. */ +object JSONMetadataRenderer { + def tabSelectorId(value:String):String = s"box-tab-select-${value.replaceAll(" ","-")}" +} -case class JSONMetadataRenderer(metadata: JSONMetadata, data: Property[Json], children: Seq[JSONMetadata], id: ReadableProperty[Option[String]],actions: WidgetCallbackActions,changed:Property[Boolean], public:Boolean) extends ChildWidget { +case class JSONMetadataRenderer(metadata: JSONMetadata, data: Property[Json], children: Seq[JSONMetadata], _id: ReadableProperty[Option[String]],actions: WidgetCallbackActions,changed:Property[Boolean], public:Boolean) extends ChildWidget { import ch.wsl.box.client.Context._ @@ -120,7 +123,7 @@ case class JSONMetadataRenderer(metadata: JSONMetadata, data: Property[Json], ch val blocks: Seq[FormBlock] = metadata.layout.blocks.map { block => FormBlock( - new BlockRendererWidget(WidgetParams(id,data,field,metadata,data,children,actions,public),block.fields,block.layoutType), + new BlockRendererWidget(WidgetParams(_id,data,field,metadata,data,children,actions,public),block.fields,block.layoutType), block.extractFields(metadata), Some(block), ) @@ -180,14 +183,25 @@ case class JSONMetadataRenderer(metadata: JSONMetadata, data: Property[Json], ch val tabKey = SelectedTabKey(metadata.objId,tabGroup) val selectedTab = Property(services.clientSession.selectedTab(tabKey).orElse(tabs.headOption.flatten)) div(BootstrapCol.md(blks.map(_.block.width).max), - ul(BootstrapStyles.Navigation.nav, BootstrapStyles.Navigation.tabs, BootstrapStyles.Navigation.fill, + ul(BootstrapStyles.Navigation.nav, BootstrapStyles.Navigation.tabs, BootstrapStyles.Navigation.fill,role := "tablist", tabs.map { tabId => val title:String = blocks.find(_.layoutBlock.exists(_.tab == tabId)) .flatMap(_.layoutBlock.flatMap(_.title).flatMap(Internationalization.either(services.clientSession.lang()))).orElse(tabId).getOrElse("") + val isSelected = selectedTab.transform(_ == tabId) + li(BootstrapStyles.Navigation.item, - a(BootstrapStyles.Navigation.link, - BootstrapStyles.active.styleIf(selectedTab.transform(_ == tabId)), + //input(`class` := s"box-tab-validator-$tabId",width := 1.px, height := 1.px, padding := 0, border := 0, float.left), + button(BootstrapStyles.Navigation.link, + id := JSONMetadataRenderer.tabSelectorId(tabId.getOrElse("")), + role := "tab", + aria.selected.bind(isSelected.transform(_.toString)), + aria.controls := s"box-panel-$tabId", + tabindex.bind(isSelected.transform{ + case true => "0" + case false => "-1" + }), + BootstrapStyles.active.styleIf(isSelected), onclick :+= { (e: Event) => selectedTab.set(tabId); tabId.foreach(n => services.clientSession.setSelectedTab(tabKey,n)) @@ -197,14 +211,18 @@ case class JSONMetadataRenderer(metadata: JSONMetadata, data: Property[Json], ch ).render } ), - nested(produce(selectedTab) { tabName => + tabs.map{ tabName => val block = blks.filter(_.block.tab == tabName) window.setTimeout(() => block.map(x => x.widget.afterRender()),0) // make tab fill the space val fullwidth = block.map(b => b.copy(b.block.copy(width = 12/block.length))) - renderBlocks(fullwidth).render - - }) + val isSelected = selectedTab.transform(_ == tabName) + isSelected.listen({ + case true => window.setTimeout(() => block.map(x => x.widget.afterRender()),0) + case _ => () + },true) + div(id := s"box-panel-${tabName.getOrElse("")}",`class` := "box-tab",attr("data-box-tab") := tabName.getOrElse("noname"), role := "tabpanel", (style := "display: none;").attrIfNot(isSelected), renderBlocks(fullwidth)).render + } ) } From fc24966f4230ad94db57950d906240f869e2efa9 Mon Sep 17 00:00:00 2001 From: Andrea Minetti Date: Tue, 24 Mar 2026 11:06:04 +0100 Subject: [PATCH 26/42] Fixed basePath for UI --- server/src/main/twirl/ch/wsl/box/templates/index.scala.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/main/twirl/ch/wsl/box/templates/index.scala.html b/server/src/main/twirl/ch/wsl/box/templates/index.scala.html index 117c624f..5723038f 100644 --- a/server/src/main/twirl/ch/wsl/box/templates/index.scala.html +++ b/server/src/main/twirl/ch/wsl/box/templates/index.scala.html @@ -34,8 +34,8 @@ - - + + @matomo.map{ m => From a3c48c6559f435c23a5b40308a3aac50bf109196 Mon Sep 17 00:00:00 2001 From: Andrea Minetti Date: Thu, 26 Mar 2026 15:46:51 +0100 Subject: [PATCH 27/42] PGLite WASM loaded dynamically so it works with different subpaths --- client/src/main/scala/ch/wsl/box/client/db/DB.scala | 2 +- client/workers/postgres.worker.js | 12 +++++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/client/src/main/scala/ch/wsl/box/client/db/DB.scala b/client/src/main/scala/ch/wsl/box/client/db/DB.scala index 165bd2a9..664b9694 100644 --- a/client/src/main/scala/ch/wsl/box/client/db/DB.scala +++ b/client/src/main/scala/ch/wsl/box/client/db/DB.scala @@ -20,7 +20,7 @@ object DB { val worker_options = new WorkerOptions {} worker_options.`type` = WorkerType.module - val worker = new Worker(s"./ui/postgres.worker.${version}.js",worker_options) + val worker = new Worker(s"./ui/postgres.worker.${version}.js?version=$version",worker_options) connection = new PGliteWorker(worker) diff --git a/client/workers/postgres.worker.js b/client/workers/postgres.worker.js index d1686e9a..4cf6425c 100644 --- a/client/workers/postgres.worker.js +++ b/client/workers/postgres.worker.js @@ -5,8 +5,18 @@ console.log('Worker started'); try { worker({ async init() { + const params = new URLSearchParams(location.search) + const version = params.get("version") + + const [pgliteWasmModule, fsBundle] = await Promise.all([ + WebAssembly.compileStreaming(fetch(`./postgres.${version}.wasm`)), + fetch(`./postgres.${version}.data`).then((response) => response.blob()), + ]) // Create and return a PGlite instance - return new PGlite('idb://box-pgdata') + return new PGlite('idb://box-pgdata',{ + pgliteWasmModule: pgliteWasmModule, + fsBundle: fsBundle + }) }, }) } catch (error) { From f81e1146763850f7eeac9bbf1134796b17cc2fe7 Mon Sep 17 00:00:00 2001 From: Andrea Minetti Date: Fri, 27 Mar 2026 12:35:54 +0100 Subject: [PATCH 28/42] Updated pgLite and fixed vite interoperability --- client/index.html | 3 ++- client/package.json | 17 +++++++++----- .../main/scala/ch/wsl/box/client/Main.scala | 13 +++++++++-- .../main/scala/ch/wsl/box/client/db/DB.scala | 5 ++-- .../ch/wsl/box/client/db/LocalRecord.scala | 2 +- .../wsl/box/client/vendors/PgLiteWorker.scala | 11 +++++++++ .../wsl/box/client/views/admin/DBRepl.scala | 2 +- client/vite.config.app.js | 21 ++++++++++++++++- client/vite.config.worker.js | 7 +++--- client/workers/postgres.worker.js | 10 ++++---- .../scala/ch/wsl/box/rest/routes/UI.scala | 23 +++++++++++-------- 11 files changed, 83 insertions(+), 31 deletions(-) create mode 100644 client/src/main/scala/ch/wsl/box/client/vendors/PgLiteWorker.scala diff --git a/client/index.html b/client/index.html index 4167466a..7af8856c 100644 --- a/client/index.html +++ b/client/index.html @@ -9,7 +9,8 @@
diff --git a/client/package.json b/client/package.json index 2a518ea7..6c7dcdce 100644 --- a/client/package.json +++ b/client/package.json @@ -4,15 +4,18 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "vite --config vite.config.app.js", + "dev": "npm run dev:app & npm run dev:worker-build & npm run dev:worker-serve", + "dev:app": "vite --config vite.config.app.js", + "dev:worker-build": "VITE_BOX_VERSION=dev vite build --config vite.config.worker.js --watch", + "dev:worker-serve": "http-server ./dist/ui/workers -c-1 -p 5174 --cors --base-path /ui/workers", "build:app": "vite build --config vite.config.app.js", "build:worker": "vite build --config vite.config.worker.js", "build": "npm run build:worker && npm run build:app", "preview": "vite preview" }, "dependencies": { - "@electric-sql/pglite": "0.2.17", - "@electric-sql/pglite-repl": "0.2.17", + "@electric-sql/pglite": "0.4.2", + "@electric-sql/pglite-repl": "0.3.2", "@fontsource/open-sans": "5.0.15", "@fortawesome/fontawesome-free": "5.15.4", "@tailwindcss/vite": "^4.1.18", @@ -34,6 +37,7 @@ "flatpickr": "4.6.3", "gridstack": "12.2.2", "hotkeys-js": "3.10.0", + "is-blob": "3.0.0", "jquery": "3.4.1", "js-md5": "0.8.3", "jspdf": "4.2.1", @@ -52,13 +56,14 @@ "string-strip-html": "13.5.3", "tailwindcss": "^4.1.18", "toolcool-range-slider": "4.0.28", - "xlsx-js-style": "1.2.0", - "is-blob": "3.0.0" + "xlsx-js-style": "1.2.0" }, "devDependencies": { "@scala-js/vite-plugin-scalajs": "^1.1.0", + "http-server": "^14.1.1", "sass-embedded": "^1.93.2", "typescript": "^4.1.6", - "vite": "^7.1.9" + "vite": "^7.1.9", + "vite-plugin-static-copy": "^4.0.0" } } diff --git a/client/src/main/scala/ch/wsl/box/client/Main.scala b/client/src/main/scala/ch/wsl/box/client/Main.scala index 1f27c4ae..a7c5083c 100644 --- a/client/src/main/scala/ch/wsl/box/client/Main.scala +++ b/client/src/main/scala/ch/wsl/box/client/Main.scala @@ -59,10 +59,19 @@ object Main extends Logging { } def setupUI(): Future[Unit] = { + + val (_version,_appVersion) = { + val local = dom.window.asInstanceOf[js.Dynamic].boxVersion.asInstanceOf[js.UndefOr[String]] + local.toOption match { + case Some(v) => (Future.successful(v),Future.successful(v)) + case None => (services.rest.version(),services.rest.appVersion()) + } + } + for { _ <- services.clientSession.refreshSession() - appVersion <- services.rest.appVersion() - version <- services.rest.version() + appVersion <- _appVersion + version <- _version _ <- services.rest.conf().map{ conf => ClientConf.load(conf,version,appVersion) //needs to be loaded before labels, fix #91 } diff --git a/client/src/main/scala/ch/wsl/box/client/db/DB.scala b/client/src/main/scala/ch/wsl/box/client/db/DB.scala index 664b9694..555a200a 100644 --- a/client/src/main/scala/ch/wsl/box/client/db/DB.scala +++ b/client/src/main/scala/ch/wsl/box/client/db/DB.scala @@ -1,8 +1,9 @@ package ch.wsl.box.client.db +import ch.wsl.box.client.routes.Routes import ch.wsl.box.client.services.BrowserConsole +import ch.wsl.box.client.vendors.PGliteWorker import ch.wsl.typings.electricSqlPglite.mod.PGlite -import ch.wsl.typings.electricSqlPglite.workerMod.PGliteWorker import org.scalajs.dom import org.scalajs.dom.{URL, Worker, WorkerOptions, WorkerType} @@ -20,7 +21,7 @@ object DB { val worker_options = new WorkerOptions {} worker_options.`type` = WorkerType.module - val worker = new Worker(s"./ui/postgres.worker.${version}.js?version=$version",worker_options) + val worker = new Worker(s"${Routes.baseUri}ui/workers/postgres.worker.${version}.js?version=$version",worker_options) connection = new PGliteWorker(worker) diff --git a/client/src/main/scala/ch/wsl/box/client/db/LocalRecord.scala b/client/src/main/scala/ch/wsl/box/client/db/LocalRecord.scala index e04e6520..c0dae9d7 100644 --- a/client/src/main/scala/ch/wsl/box/client/db/LocalRecord.scala +++ b/client/src/main/scala/ch/wsl/box/client/db/LocalRecord.scala @@ -1,8 +1,8 @@ package ch.wsl.box.client.db import ch.wsl.box.client.services.BrowserConsole +import ch.wsl.box.client.vendors.PGliteWorker import ch.wsl.box.model.shared.{EntityKind, JSONID} -import ch.wsl.typings.electricSqlPglite.workerMod.PGliteWorker import io.circe.Json import scribe.Logging diff --git a/client/src/main/scala/ch/wsl/box/client/vendors/PgLiteWorker.scala b/client/src/main/scala/ch/wsl/box/client/vendors/PgLiteWorker.scala new file mode 100644 index 00000000..c193fc6d --- /dev/null +++ b/client/src/main/scala/ch/wsl/box/client/vendors/PgLiteWorker.scala @@ -0,0 +1,11 @@ +package ch.wsl.box.client.vendors + +import ch.wsl.typings.electricSqlPglite.distPgliteBdaQNAoWMod.BasePGlite +import org.scalajs.dom.{Blob, Worker} + +import scala.scalajs.js +import scala.scalajs.js.annotation.JSImport + +@JSImport("@electric-sql/pglite/worker", "PGliteWorker") +@js.native +class PGliteWorker(worker:Worker) extends js.Object with BasePGlite diff --git a/client/src/main/scala/ch/wsl/box/client/views/admin/DBRepl.scala b/client/src/main/scala/ch/wsl/box/client/views/admin/DBRepl.scala index 52d5b6f2..977e4d17 100644 --- a/client/src/main/scala/ch/wsl/box/client/views/admin/DBRepl.scala +++ b/client/src/main/scala/ch/wsl/box/client/views/admin/DBRepl.scala @@ -34,7 +34,7 @@ class DBReplView( presenter:DBReplPresenter) extends View { val content:HTMLDivElement = div( - script(src := Routes.baseUri + "assets/@electric-sql/pglite-repl/dist-webcomponent/Repl.js", `type` := "module",onload :+= loaded), + script(src := Routes.baseUri + "public/@electric-sql/pglite-repl/dist-webcomponent/Repl.js", `type` := "module",onload :+= loaded), ).render def loaded(e:Event) = { diff --git a/client/vite.config.app.js b/client/vite.config.app.js index 070a416a..6b1ebabc 100644 --- a/client/vite.config.app.js +++ b/client/vite.config.app.js @@ -1,6 +1,7 @@ import { defineConfig } from "vite"; import scalaJSPlugin from "@scala-js/vite-plugin-scalajs"; import { loadEnv } from 'vite'; +import { viteStaticCopy } from 'vite-plugin-static-copy'; const env = loadEnv(process.env.NODE_ENV, process.cwd()); @@ -8,6 +9,14 @@ export default defineConfig({ server: { proxy: { '/api': 'http://localhost:8080', + '/ui/workers': { + target: 'http://127.0.0.1:5174', + changeOrigin: true, + rewrite: (path) => { + console.log(path) + return path.replace(/^\/ui\/workers/, '') + }, + } }, cors: true }, @@ -32,7 +41,17 @@ export default defineConfig({ // URI prefix of imports that this plugin catches (without the trailing ':') // default: 'scalajs' (so the plugin recognizes URIs starting with 'scalajs:') uriPrefix: 'scalajs', - })], + }), + viteStaticCopy({ + targets: [ + { + src: './node_modules/@electric-sql/pglite-repl/dist-webcomponent/*', + dest: 'public', + rename: { stripBase: 1 } + } + ] + }) + ], build: { emptyOutDir: false, rollupOptions: { diff --git a/client/vite.config.worker.js b/client/vite.config.worker.js index 18424f27..8dbdaa6f 100644 --- a/client/vite.config.worker.js +++ b/client/vite.config.worker.js @@ -9,15 +9,16 @@ export default defineConfig({ }, build: { outDir: './dist', + //watch: {}, emptyOutDir: false, rollupOptions: { input: ['./workers/postgres.worker.js','./workers/sw.js'], // externalize the package so Rollup doesn't bundle its JS/WASM output: { // keep emitted asset names predictable - entryFileNames: `ui/[name].${env.VITE_BOX_VERSION}.js`, - chunkFileNames: `ui/[name].${env.VITE_BOX_VERSION}.js`, - assetFileNames: `ui/[name].${env.VITE_BOX_VERSION}.[ext]`, + entryFileNames: `ui/workers/[name].${env.VITE_BOX_VERSION}.js`, + chunkFileNames: `ui/workers/[name].${env.VITE_BOX_VERSION}.js`, + assetFileNames: `ui/workers/[name].${env.VITE_BOX_VERSION}.[ext]`, }, } diff --git a/client/workers/postgres.worker.js b/client/workers/postgres.worker.js index 4cf6425c..af3b9b28 100644 --- a/client/workers/postgres.worker.js +++ b/client/workers/postgres.worker.js @@ -8,13 +8,15 @@ try { const params = new URLSearchParams(location.search) const version = params.get("version") - const [pgliteWasmModule, fsBundle] = await Promise.all([ - WebAssembly.compileStreaming(fetch(`./postgres.${version}.wasm`)), - fetch(`./postgres.${version}.data`).then((response) => response.blob()), + const [pgliteWasmModule, initdbWasmModule, fsBundle] = await Promise.all([ + WebAssembly.compileStreaming(fetch(`./pglite.${version}.wasm`)), + WebAssembly.compileStreaming(fetch(`./initdb.${version}.wasm`)), + fetch(`./pglite.${version}.data`).then((response) => response.blob()), ]) // Create and return a PGlite instance - return new PGlite('idb://box-pgdata',{ + return PGlite.create('idb://box-pgdata',{ pgliteWasmModule: pgliteWasmModule, + initdbWasmModule: initdbWasmModule, fsBundle: fsBundle }) }, diff --git a/server/src/main/scala/ch/wsl/box/rest/routes/UI.scala b/server/src/main/scala/ch/wsl/box/rest/routes/UI.scala index 3d98beb6..74bccc61 100755 --- a/server/src/main/scala/ch/wsl/box/rest/routes/UI.scala +++ b/server/src/main/scala/ch/wsl/box/rest/routes/UI.scala @@ -24,6 +24,17 @@ object UI { import Directives._ + def resourcesAt(basePath:String) = pathPrefix(basePath) { + extractUnmatchedPath { path => + val assetName = path.toString().substring(1) + path.endsWith("wasm") match { + case false => getFromResource(s"$basePath/$assetName") + case true => getFromResource(s"$basePath/$assetName", contentType = ContentType.apply(MediaType.applicationBinary("wasm",MediaType.Compressible))) + } + + } + } + def clientFiles(implicit system:ActorSystem,services:Services):Route = { pathPrefix("icon") { @@ -85,16 +96,8 @@ object UI { complete(HttpEntity(ContentType(MediaType.applicationWithOpenCharset("manifest+json","webmanifest"),HttpCharsets.`UTF-8`) ,manifest)) } } ~ - pathPrefix("ui") { - extractUnmatchedPath { path => - val assetName = path.toString().substring(1) - path.endsWith("wasm") match { - case false => getFromResource(s"ui/$assetName") - case true => getFromResource(s"ui/$assetName", contentType = ContentType.apply(MediaType.applicationBinary("wasm",MediaType.Compressible))) - } - - } - } ~ + resourcesAt("ui") ~ + resourcesAt("public") ~ get { complete { val module = if(services.config.localDb) AvailableUIModule.prod else AvailableUIModule.prodNoLocalDb From 6371dcaad4036d0a1dd24a9c03d83b1003b813c9 Mon Sep 17 00:00:00 2001 From: Andrea Minetti Date: Fri, 27 Mar 2026 15:46:46 +0100 Subject: [PATCH 29/42] Added app id to IndexedDB to avoid conflicts between apps --- client/src/main/scala/ch/wsl/box/client/db/DB.scala | 4 ++-- .../main/scala/ch/wsl/box/client/services/ClientConf.scala | 2 ++ client/workers/postgres.worker.js | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/client/src/main/scala/ch/wsl/box/client/db/DB.scala b/client/src/main/scala/ch/wsl/box/client/db/DB.scala index 555a200a..9559ad0d 100644 --- a/client/src/main/scala/ch/wsl/box/client/db/DB.scala +++ b/client/src/main/scala/ch/wsl/box/client/db/DB.scala @@ -1,7 +1,7 @@ package ch.wsl.box.client.db import ch.wsl.box.client.routes.Routes -import ch.wsl.box.client.services.BrowserConsole +import ch.wsl.box.client.services.{BrowserConsole, ClientConf} import ch.wsl.box.client.vendors.PGliteWorker import ch.wsl.typings.electricSqlPglite.mod.PGlite import org.scalajs.dom @@ -21,7 +21,7 @@ object DB { val worker_options = new WorkerOptions {} worker_options.`type` = WorkerType.module - val worker = new Worker(s"${Routes.baseUri}ui/workers/postgres.worker.${version}.js?version=$version",worker_options) + val worker = new Worker(s"${Routes.baseUri}ui/workers/postgres.worker.${version}.js?version=$version&appId=${ClientConf.applicationId}",worker_options) connection = new PGliteWorker(worker) diff --git a/client/src/main/scala/ch/wsl/box/client/services/ClientConf.scala b/client/src/main/scala/ch/wsl/box/client/services/ClientConf.scala index dc76b365..40385866 100644 --- a/client/src/main/scala/ch/wsl/box/client/services/ClientConf.scala +++ b/client/src/main/scala/ch/wsl/box/client/services/ClientConf.scala @@ -47,6 +47,8 @@ object ClientConf { def version: String = _version def appVersion: String = _appVersion + def applicationId:String = conf.get("application_id").getOrElse("box-app") + def pageLength: Int = Try(conf("page_length").toInt).getOrElse(30) // def lookupMaxRows = Try(conf("fk_rows").toInt).getOrElse(30) diff --git a/client/workers/postgres.worker.js b/client/workers/postgres.worker.js index af3b9b28..bac02fa8 100644 --- a/client/workers/postgres.worker.js +++ b/client/workers/postgres.worker.js @@ -7,6 +7,7 @@ try { async init() { const params = new URLSearchParams(location.search) const version = params.get("version") + const id = params.get("appId") const [pgliteWasmModule, initdbWasmModule, fsBundle] = await Promise.all([ WebAssembly.compileStreaming(fetch(`./pglite.${version}.wasm`)), @@ -14,7 +15,7 @@ try { fetch(`./pglite.${version}.data`).then((response) => response.blob()), ]) // Create and return a PGlite instance - return PGlite.create('idb://box-pgdata',{ + return PGlite.create(`idb://box-pgdata-${id}`,{ pgliteWasmModule: pgliteWasmModule, initdbWasmModule: initdbWasmModule, fsBundle: fsBundle From 421214f54d41da25942cd3b18b14753632b6d769 Mon Sep 17 00:00:00 2001 From: Andrea Minetti Date: Fri, 27 Mar 2026 15:51:13 +0100 Subject: [PATCH 30/42] PDF visualization works in dev mode too --- client/vite.config.app.js | 1 + 1 file changed, 1 insertion(+) diff --git a/client/vite.config.app.js b/client/vite.config.app.js index 6b1ebabc..9b05c3a4 100644 --- a/client/vite.config.app.js +++ b/client/vite.config.app.js @@ -9,6 +9,7 @@ export default defineConfig({ server: { proxy: { '/api': 'http://localhost:8080', + '/pdf': 'http://localhost:8080', '/ui/workers': { target: 'http://127.0.0.1:5174', changeOrigin: true, From 8c97fcf151c62c7f507428b1d1a3ce0d4ba66281 Mon Sep 17 00:00:00 2001 From: Andrea Minetti Date: Fri, 27 Mar 2026 17:01:27 +0100 Subject: [PATCH 31/42] JS application need to be published as well --- build.sbt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/build.sbt b/build.sbt index 0be17fe5..4cbb4c2e 100755 --- a/build.sbt +++ b/build.sbt @@ -278,6 +278,8 @@ lazy val publishAllTask = { (codegen / clean), (codegen / Compile / compile), (sharedJVM / publishSigned), + (sharedJS / publishSigned), + (client / publishSigned), (codegen / publishSigned), (server / publishSigned), (serverCacheRedis / publishSigned), From fac4b499342eb4bc8725dd70eb5484e90dff5bd9 Mon Sep 17 00:00:00 2001 From: Andrea Minetti Date: Mon, 30 Mar 2026 09:32:59 +0200 Subject: [PATCH 32/42] Using frontendURL properly --- server/src/main/scala/ch/wsl/box/rest/routes/UI.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/main/scala/ch/wsl/box/rest/routes/UI.scala b/server/src/main/scala/ch/wsl/box/rest/routes/UI.scala index 74bccc61..c1b38ccb 100755 --- a/server/src/main/scala/ch/wsl/box/rest/routes/UI.scala +++ b/server/src/main/scala/ch/wsl/box/rest/routes/UI.scala @@ -101,7 +101,7 @@ object UI { get { complete { val module = if(services.config.localDb) AvailableUIModule.prod else AvailableUIModule.prodNoLocalDb - ch.wsl.box.templates.html.index.render(BoxBuildInfo.version,module,services.config.basePath,services.config.mainColor,services.config.matomo) + ch.wsl.box.templates.html.index.render(BoxBuildInfo.version,module,services.config.frontendUrl,services.config.mainColor,services.config.matomo) } } } From daa6f25f162b3a7c0e90ed5d848dc8ac62535fbf Mon Sep 17 00:00:00 2001 From: Andrea Minetti Date: Mon, 30 Mar 2026 11:05:20 +0200 Subject: [PATCH 33/42] Added fonts embedded in the app and fixed spaces --- client/main.js | 7 +++++++ client/package.json | 4 ++-- client/src/main/scala/ch/wsl/box/client/Main.scala | 1 - .../main/scala/ch/wsl/box/client/services/ClientConf.scala | 6 ++++-- .../src/main/scala/ch/wsl/box/client/styles/BoxStyle.scala | 2 +- .../main/scala/ch/wsl/box/client/styles/GlobalStyles.scala | 4 ++-- client/vite.config.app.js | 3 ++- 7 files changed, 18 insertions(+), 9 deletions(-) diff --git a/client/main.js b/client/main.js index b3b2879b..d62873ed 100644 --- a/client/main.js +++ b/client/main.js @@ -3,3 +3,10 @@ import './libraries.css'; import './style.scss' import * as bootstrap from 'bootstrap' import '@fortawesome/fontawesome-free/js/all.min.js' +import '@fontsource/open-sans'; +import '@fontsource/open-sans/400.css'; +import '@fontsource/open-sans/600.css'; +import '@fontsource/open-sans/700.css'; +import '@fontsource/open-sans/400-italic.css'; +import '@fontsource/open-sans/600-italic.css'; +import '@fontsource/open-sans/700-italic.css'; \ No newline at end of file diff --git a/client/package.json b/client/package.json index 6c7dcdce..c796dfd6 100644 --- a/client/package.json +++ b/client/package.json @@ -7,7 +7,7 @@ "dev": "npm run dev:app & npm run dev:worker-build & npm run dev:worker-serve", "dev:app": "vite --config vite.config.app.js", "dev:worker-build": "VITE_BOX_VERSION=dev vite build --config vite.config.worker.js --watch", - "dev:worker-serve": "http-server ./dist/ui/workers -c-1 -p 5174 --cors --base-path /ui/workers", + "dev:worker-serve": "http-server ./dist/ui/workers -c-1 -p 5174 --cors --base-path /ui/workers -a 0.0.0.0", "build:app": "vite build --config vite.config.app.js", "build:worker": "vite build --config vite.config.worker.js", "build": "npm run build:worker && npm run build:app", @@ -16,7 +16,7 @@ "dependencies": { "@electric-sql/pglite": "0.4.2", "@electric-sql/pglite-repl": "0.3.2", - "@fontsource/open-sans": "5.0.15", + "@fontsource/open-sans": "^5.0.15", "@fortawesome/fontawesome-free": "5.15.4", "@tailwindcss/vite": "^4.1.18", "@types/bootstrap": "4.1.3", diff --git a/client/src/main/scala/ch/wsl/box/client/Main.scala b/client/src/main/scala/ch/wsl/box/client/Main.scala index a7c5083c..21826a1f 100644 --- a/client/src/main/scala/ch/wsl/box/client/Main.scala +++ b/client/src/main/scala/ch/wsl/box/client/Main.scala @@ -98,7 +98,6 @@ object Main extends Logging { val mainStyle = document.createElement("style") - ClientConf.style.global //load unsaferoot global styles mainStyle.innerText = ClientConf.style.render(cssStringRenderer,cssEnv) val olStyle = document.createElement("style") diff --git a/client/src/main/scala/ch/wsl/box/client/services/ClientConf.scala b/client/src/main/scala/ch/wsl/box/client/services/ClientConf.scala index 40385866..d511d6f3 100644 --- a/client/src/main/scala/ch/wsl/box/client/services/ClientConf.scala +++ b/client/src/main/scala/ch/wsl/box/client/services/ClientConf.scala @@ -6,7 +6,7 @@ import java.sql.Timestamp import java.time.temporal.ChronoUnit import ch.wsl.box.client.styles.constants.StyleConstants import ch.wsl.box.client.styles.constants.StyleConstants.{ChildProperties, Colors} -import ch.wsl.box.client.styles.{GlobalStyleFactory, StyleConf} +import ch.wsl.box.client.styles.{BoxStyle, GlobalStyleFactory, StyleConf} import ch.wsl.box.model.shared.JSONFieldTypes import ch.wsl.box.model.shared.oidc.OIDCFrontendConf import io.circe._ @@ -29,11 +29,13 @@ object ClientConf { private var conf:Map[String,String] = Map() private var _version:String = "" private var _appVersion:String = "" + private var _style:BoxStyle = null def load(table:Map[String,String],version:String,appVersion:String) = { conf = table _version = version _appVersion = appVersion + _style = services.style.build(scalacss.devOrProdDefaults) } def loggerLevel = conf.get("client.logger.level") match { @@ -96,7 +98,7 @@ object ClientConf { ) //lazy val style = new GlobalStyleFactory.GlobalStyles(styleConf) - lazy val style = services.style.build(scalacss.devOrProdDefaults) + def style = _style def filterPrecisionDatetime: String = Try(conf("filter.precision.datetime").toUpperCase).toOption match { case Some("DATE") => JSONFieldTypes.DATE diff --git a/client/src/main/scala/ch/wsl/box/client/styles/BoxStyle.scala b/client/src/main/scala/ch/wsl/box/client/styles/BoxStyle.scala index 072462e8..5a46a353 100644 --- a/client/src/main/scala/ch/wsl/box/client/styles/BoxStyle.scala +++ b/client/src/main/scala/ch/wsl/box/client/styles/BoxStyle.scala @@ -12,7 +12,7 @@ trait BoxStyle { val inputHighlight: StyleA val inputInvalid: StyleA - def global: StyleA + val global: StyleA val spaceBetween: StyleA val flexContainer: StyleA val sidebarRightContent: StyleA diff --git a/client/src/main/scala/ch/wsl/box/client/styles/GlobalStyles.scala b/client/src/main/scala/ch/wsl/box/client/styles/GlobalStyles.scala index 5be48787..4a3e527e 100755 --- a/client/src/main/scala/ch/wsl/box/client/styles/GlobalStyles.scala +++ b/client/src/main/scala/ch/wsl/box/client/styles/GlobalStyles.scala @@ -49,7 +49,7 @@ class GlobalStyles(settings:Settings,conf:StyleConf) extends StyleSheet.Inline() backgroundColor(ColorUtils.RGB.fromHex(conf.colors.dangerColor).withTrasparency(0.3)) ) - override def global = style( + override val global = style( unsafeRoot(".col, .col-1, .col-10, .col-11, .col-12, .col-2, .col-3, .col-4, .col-5, .col-6, .col-7, .col-8, .col-9, .col-auto, .col-lg, .col-lg-1, .col-lg-10, .col-lg-11, .col-lg-12, .col-lg-2, .col-lg-3, .col-lg-4, .col-lg-5, .col-lg-6, .col-lg-7, .col-lg-8, .col-lg-9, .col-lg-auto, .col-md, .col-md-1, .col-md-10, .col-md-11, .col-md-12, .col-md-2, .col-md-3, .col-md-4, .col-md-5, .col-md-6, .col-md-7, .col-md-8, .col-md-9, .col-md-auto, .col-sm, .col-sm-1, .col-sm-10, .col-sm-11, .col-sm-12, .col-sm-2, .col-sm-3, .col-sm-4, .col-sm-5, .col-sm-6, .col-sm-7, .col-sm-8, .col-sm-9, .col-sm-auto, .col-xl, .col-xl-1, .col-xl-10, .col-xl-11, .col-xl-12, .col-xl-2, .col-xl-3, .col-xl-4, .col-xl-5, .col-xl-6, .col-xl-7, .col-xl-8, .col-xl-9, .col-xl-auto") ( padding.`0` @@ -640,7 +640,7 @@ class GlobalStyles(settings:Settings,conf:StyleConf) extends StyleSheet.Inline() override val mediumBottomMargin = style( marginBottom(15 px) ) override val subBlock = style( - padding(conf.paddingBlocks px), + padding(conf.paddingBlocks px).important, minHeight.`0` ) diff --git a/client/vite.config.app.js b/client/vite.config.app.js index 9b05c3a4..6effc672 100644 --- a/client/vite.config.app.js +++ b/client/vite.config.app.js @@ -19,7 +19,8 @@ export default defineConfig({ }, } }, - cors: true + cors: true, + host: '0.0.0.0' }, optimizeDeps: { exclude: ['@electric-sql/pglite'], From 6185eeddcbaff19cefc2a55372e58aff6cca850b Mon Sep 17 00:00:00 2001 From: Andrea Minetti Date: Mon, 30 Mar 2026 16:07:57 +0200 Subject: [PATCH 34/42] Generate scalablytypes locally --- .github/workflows/release.yml | 5 ++ build.sbt | 31 ++++---- .../main/scala/ch/wsl/box/client/Module.scala | 1 - .../wsl/box/client/geo/MapGeolocation.scala | 6 +- .../box/client/views/components/MapList.scala | 1 - .../widget/admin/LayoutWidget.scala | 3 +- .../widget/array/TwoListWidget.scala | 1 - project/BoxScalablyTypes.scala | 78 +++++++++++++++++++ project/Settings.scala | 3 +- 9 files changed, 103 insertions(+), 26 deletions(-) create mode 100644 project/BoxScalablyTypes.scala diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 27392623..90ad9ef3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,6 +42,11 @@ jobs: echo "tag=$tag" >> "$GITHUB_OUTPUT" - name: Export VITE_BOX_VERSION run: echo "VITE_BOX_VERSION=${{ steps.tag.outputs.tag }}" >> $GITHUB_ENV + - name: Import TS Types + env: + SBT_OPTS: -XX:+UseG1GC -Xmx4G -Xss10M -XX:MaxInlineLevel=20 + run: | + sbt client/generateScalaTypes - name: Build bundle env: SBT_OPTS: -XX:+UseG1GC -Xmx4G -Xss10M -XX:MaxInlineLevel=20 diff --git a/build.sbt b/build.sbt index 4cbb4c2e..aec39d36 100755 --- a/build.sbt +++ b/build.sbt @@ -1,4 +1,6 @@ import com.jsuereth.sbtpgp.PgpKeys.publishSigned +import org.scalablytyped.converter.internal.Json +import org.scalablytyped.converter.internal.ts.PackageJson //import xerial.sbt.Sonatype.sonatypeCentralHost import locales.LocalesFilter import org.scalajs.jsenv.Input.Script @@ -120,21 +122,15 @@ lazy val client: Project = (project in file("client")) // use Scala.js provided launcher code to start the client app scalaJSUseMainModuleInitializer := true, scalaJSStage := FullOptStage, - externalNpm := baseDirectory.value, - stIncludeDev := false, - stStdlib := List("es6", "es2018.asyncgenerator"), - stIgnore += "@fontsource/open-sans", - stIgnore += "redux", - stIgnore += "node", - stIgnore += "crypto-browserify", - stIgnore += "ol-ext", - stIgnore += "@fortawesome/fontawesome-free", - stIgnore += "stream-browserify", - stIgnore += "toolcool-range-slider", - stIgnore += "@tailwindcss/vite", - stIgnore += "string-strip-html", - stIgnore += "autocompleter", - stOutputPackage := "ch.wsl.typings", + +// Compile / sourceGenerators += Def.task { +// generateScalaTypesTask.value +// }, + + Compile / unmanagedSourceDirectories += baseDirectory.value / "target" / "scala-2.13" / "src_scalablytypes" / "main", + + generateScalaTypes := generateScalaTypesTask.value, + scalaJSLinkerConfig ~= (_.withModuleKind(ModuleKind.ESModule).withModuleSplitStyle(ModuleSplitStyle.FewestModules)), @@ -168,12 +164,11 @@ lazy val client: Project = (project in file("client")) Tags.limit(Tags.Test,5) //browserstack limit ), localesFilter := LocalesFilter.Selection("en", "de", "fr", "it"), - publishTo := sonatypeCentralPublishToBundle.value + publishTo := sonatypeCentralPublishToBundle.value, ) .settings(publishSettings) .enablePlugins( ScalaJSPlugin, - ScalablyTypedConverterExternalNpmPlugin, LocalesPlugin ) .dependsOn(sharedJS) @@ -318,3 +313,5 @@ lazy val dropBoxTask = Def.sequential( } ) +lazy val generateScalaTypes = taskKey[Unit]("generateScalaTypes") +lazy val generateScalaTypesTask = BoxScalablyTypes.generateSJSFromTS(baseDirectory) \ No newline at end of file diff --git a/client/src/main/scala/ch/wsl/box/client/Module.scala b/client/src/main/scala/ch/wsl/box/client/Module.scala index 15fe2eb1..40dfb58e 100644 --- a/client/src/main/scala/ch/wsl/box/client/Module.scala +++ b/client/src/main/scala/ch/wsl/box/client/Module.scala @@ -4,7 +4,6 @@ import ch.wsl.box.client.services.impl.{DaoLocalDbImpl, DaoPassthroughImpl, Http import ch.wsl.box.client.styles.{BoxStyle, BoxStyleFactory, GlobalStyleFactory} import ch.wsl.box.client.views.components.{BoxMainLayout, MainLayout} import ch.wsl.box.model.shared.AvailableUIModule -import ch.wsl.typings.std.WebAssembly.Global import wvlet.airframe._ object Module { diff --git a/client/src/main/scala/ch/wsl/box/client/geo/MapGeolocation.scala b/client/src/main/scala/ch/wsl/box/client/geo/MapGeolocation.scala index 41eb29ed..18adc600 100644 --- a/client/src/main/scala/ch/wsl/box/client/geo/MapGeolocation.scala +++ b/client/src/main/scala/ch/wsl/box/client/geo/MapGeolocation.scala @@ -3,7 +3,6 @@ package ch.wsl.box.client.geo import ch.wsl.typings.ol import ch.wsl.typings.ol.geomMod.Point import ch.wsl.typings.ol.{controlControlMod, controlMod, geolocationMod, geomGeometryMod, geomMod, imageMod, layerBaseVectorMod, layerMod, mod, renderFeatureMod, sourceMod, sourceVectorMod, styleMod} -import ch.wsl.typings.std.PositionOptions import org.scalajs.dom.{Event, HTMLInputElement} import scalatags.JsDom.all.{`class`, `type`, div, input, onchange, style} @@ -13,12 +12,13 @@ import scalatags.JsDom.all._ import io.udash._ class MapGeolocation(map:mod.Map) { - val positionOptions = PositionOptions().setEnableHighAccuracy(true) + val positionOptions = new org.scalajs.dom.PositionOptions() + positionOptions.enableHighAccuracy = true val geolocation = new mod.Geolocation( geolocationMod.Options() .setProjection(map.getView().getProjection()) - .setTrackingOptions(positionOptions.asInstanceOf[org.scalajs.dom.PositionOptions]) + .setTrackingOptions(positionOptions) ) val accuracyFeature = new mod.Feature[geomMod.Geometry]() diff --git a/client/src/main/scala/ch/wsl/box/client/views/components/MapList.scala b/client/src/main/scala/ch/wsl/box/client/views/components/MapList.scala index 843a8731..c376fa8c 100644 --- a/client/src/main/scala/ch/wsl/box/client/views/components/MapList.scala +++ b/client/src/main/scala/ch/wsl/box/client/views/components/MapList.scala @@ -22,7 +22,6 @@ import ch.wsl.typings.ol.mod.{MapBrowserEvent, Overlay} import ch.wsl.typings.ol.objectMod.ObjectEvent import ch.wsl.typings.ol.viewMod.FitOptions import ch.wsl.typings.ol.{extentMod, featureMod, formatGeoJSONMod, geomGeometryMod, layerBaseVectorMod, layerMod, mapBrowserEventMod, mod, olStrings, renderFeatureMod, sourceMod, sourceVectorMod, viewMod} -import ch.wsl.typings.std.PositionOptions import scala.concurrent.duration.DurationInt import scala.scalajs.js diff --git a/client/src/main/scala/ch/wsl/box/client/views/components/widget/admin/LayoutWidget.scala b/client/src/main/scala/ch/wsl/box/client/views/components/widget/admin/LayoutWidget.scala index 00d5faba..0042f392 100644 --- a/client/src/main/scala/ch/wsl/box/client/views/components/widget/admin/LayoutWidget.scala +++ b/client/src/main/scala/ch/wsl/box/client/views/components/widget/admin/LayoutWidget.scala @@ -19,12 +19,11 @@ import io.udash.bootstrap.BootstrapStyles import io.udash.bootstrap.tooltip.UdashTooltip import scalacss.ScalatagsCss._ import io.udash.css._ -import org.scalajs.dom.{DOMParser, Event, HTMLDivElement, HTMLElement, MIMEType, MutationObserver, MutationObserverInit, document} +import org.scalajs.dom.{DOMParser, Event, HTMLDivElement, HTMLElement, HTMLInputElement, HTMLSelectElement, MIMEType, MutationObserver, MutationObserverInit, document} import scribe.Logging import ch.wsl.typings.gridstack.mod._ import ch.wsl.typings.gridstack.distTypesMod._ import ch.wsl.typings.gridstack.gridstackStrings -import ch.wsl.typings.std.global.{HTMLInputElement, HTMLSelectElement} import io.udash.bootstrap.utils.BootstrapStyles.Form import io.udash.bootstrap.utils.BootstrapTags import org.scalajs.dom.html.Input diff --git a/client/src/main/scala/ch/wsl/box/client/views/components/widget/array/TwoListWidget.scala b/client/src/main/scala/ch/wsl/box/client/views/components/widget/array/TwoListWidget.scala index 6becb324..6cd3c1d5 100644 --- a/client/src/main/scala/ch/wsl/box/client/views/components/widget/array/TwoListWidget.scala +++ b/client/src/main/scala/ch/wsl/box/client/views/components/widget/array/TwoListWidget.scala @@ -26,7 +26,6 @@ import scribe.Logging import ch.wsl.typings.gridstack.mod._ import ch.wsl.typings.gridstack.distTypesMod._ import ch.wsl.typings.gridstack.gridstackStrings -import ch.wsl.typings.std.global.{HTMLInputElement, HTMLSelectElement} import io.udash.bootstrap.utils.BootstrapStyles.Form import io.udash.bootstrap.utils.{BootstrapTags, UdashIcons} import org.scalajs.dom.html.Input diff --git a/project/BoxScalablyTypes.scala b/project/BoxScalablyTypes.scala new file mode 100644 index 00000000..fb08f5fe --- /dev/null +++ b/project/BoxScalablyTypes.scala @@ -0,0 +1,78 @@ +import com.olvind.logging +import com.olvind.logging.LogLevel +import org.scalablytyped.converter.{Flavour, Selection} +import org.scalablytyped.converter.internal.{BuildInfo, IArray, ImportTypingsGenSources, InFolder, Json, files} +import org.scalablytyped.converter.internal.ImportTypingsGenSources.Input +import org.scalablytyped.converter.internal.importer.{ConversionOptions, EnabledTypeMappingExpansion} +import org.scalablytyped.converter.internal.scalajs.{Name, QualifiedName, Versions} +import org.scalablytyped.converter.internal.ts.{PackageJson, TsIdentLibrary} +import os.Path +import sbt.* + +import scala.collection.immutable.{SortedMap, SortedSet} + +object BoxScalablyTypes { + + val outputName = Name("ch.wsl.typings") + + + + + val conversion = ConversionOptions( + useScalaJsDomTypes = true, + flavour = Flavour.Slinky, + outputPackage = outputName, + enableScalaJsDefined = Selection.All, + stdLibs = SortedSet("es6", "es2018.asyncgenerator"), + expandTypeMappings = EnabledTypeMappingExpansion.DefaultSelection, + ignored = SortedSet( + "@fontsource/open-sans", + "redux", + "node", + "crypto-browserify", + "ol-ext", + "@fortawesome/fontawesome-free", + "stream-browserify", + "toolcool-range-slider", + "@tailwindcss/vite", + "string-strip-html", + "autocompleter", + ), + versions = Versions(Versions.Scala213, Versions.ScalaJs1), + organization = "org.scalablytyped", + enableReactTreeShaking = Selection.None, + enableLongApplyMethod = false, + privateWithin = None, + useDeprecatedModuleNames = false, + ) + + + + def generateSJSFromTS(_clientPath: SettingKey[_root_.java.io.File]) = Def.task { + val clientPath = os.Path(_clientPath.value) + + val libs = Json.force[PackageJson](clientPath / "package.json").allLibs(dev = false, peer = false) + + println(libs) + + ImportTypingsGenSources( + input = Input( + fromFolder = InFolder(clientPath / "node_modules"), + targetFolder = files.existing(clientPath / "target" / "scala-2.13" / "src_scalablytypes" / "main"), + overrideTargetFolder = Map.empty, + converterVersion = BuildInfo.version, + conversion = conversion, + wantedLibs = libs, + minimize = Selection.NoneExcept(TsIdentLibrary("std")), + minimizeKeep = IArray(), + ), + logger = logging.stdout.filter(LogLevel.warn), + parseCacheDirOpt = None, + cacheDirOpt = clientPath, + ).toOption.toSeq.flatten + + } + + + +} \ No newline at end of file diff --git a/project/Settings.scala b/project/Settings.scala index ff500959..d2aa2e4b 100755 --- a/project/Settings.scala +++ b/project/Settings.scala @@ -66,7 +66,7 @@ object Settings { //js val bootstrap = "3.4.1-1" - val udash = "0.9.0-M39" + val udash = "0.22.0" val udashJQuery = "3.0.4" val scribe = "3.0.2" @@ -203,6 +203,7 @@ object Settings { "org.http4s" %%% "http4s-dom" % "0.2.3", "org.http4s" %%% "http4s-client" % "0.23.16", "org.http4s" %%% "http4s-circe" % "0.23.16", + "com.olvind" %%% "scalablytyped-runtime" % "2.4.2" //"io.github.cquiroz" %%% "scala-java-locales" % "1.5.1", // "io.github.cquiroz" %%% "scala-java-time-tzdb" % "2.5.0" )) From 0f4a494fc87c3697f4d30d98bdcf0839fe79dac8 Mon Sep 17 00:00:00 2001 From: Andrea Minetti Date: Mon, 30 Mar 2026 16:13:48 +0200 Subject: [PATCH 35/42] Generate scalablytypes locally --- .github/workflows/release.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 90ad9ef3..cc04693e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,6 +42,13 @@ jobs: echo "tag=$tag" >> "$GITHUB_OUTPUT" - name: Export VITE_BOX_VERSION run: echo "VITE_BOX_VERSION=${{ steps.tag.outputs.tag }}" >> $GITHUB_ENV + - name: Install node dependencies + env: + SBT_OPTS: -XX:+UseG1GC -Xmx4G -Xss10M -XX:MaxInlineLevel=20 + run: | + cd client + npm install + cd .. - name: Import TS Types env: SBT_OPTS: -XX:+UseG1GC -Xmx4G -Xss10M -XX:MaxInlineLevel=20 @@ -52,7 +59,6 @@ jobs: SBT_OPTS: -XX:+UseG1GC -Xmx4G -Xss10M -XX:MaxInlineLevel=20 run: | cd client - npm install npm run build cd .. - name: Version info From 086ee3a0324a42a2eebb199674950b4321d0de63 Mon Sep 17 00:00:00 2001 From: Andrea Minetti Date: Tue, 31 Mar 2026 10:47:25 +0200 Subject: [PATCH 36/42] Scaladoc on clients give error, disabiling for now --- build.sbt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/build.sbt b/build.sbt index aec39d36..775bf19a 100755 --- a/build.sbt +++ b/build.sbt @@ -165,6 +165,8 @@ lazy val client: Project = (project in file("client")) ), localesFilter := LocalesFilter.Selection("en", "de", "fr", "it"), publishTo := sonatypeCentralPublishToBundle.value, + Compile / doc / sources := Seq() + ) .settings(publishSettings) .enablePlugins( From 8cf2c8f5edac94f0fc41aefc099f07881ac074ac Mon Sep 17 00:00:00 2001 From: Andrea Minetti Date: Tue, 31 Mar 2026 15:21:38 +0200 Subject: [PATCH 37/42] Fixed style and geolocation --- .../scala/ch/wsl/box/client/geo/MapGeolocation.scala | 6 +----- .../ch/wsl/box/client/services/ClientSession.scala | 5 +++++ .../main/scala/ch/wsl/box/client/styles/BoxStyle.scala | 1 - .../scala/ch/wsl/box/client/styles/GlobalStyles.scala | 10 ++++++++-- .../main/scala/ch/wsl/box/client/views/DataView.scala | 2 +- .../main/scala/ch/wsl/box/shared/utils/JSONUtils.scala | 2 +- 6 files changed, 16 insertions(+), 10 deletions(-) diff --git a/client/src/main/scala/ch/wsl/box/client/geo/MapGeolocation.scala b/client/src/main/scala/ch/wsl/box/client/geo/MapGeolocation.scala index 18adc600..5d221643 100644 --- a/client/src/main/scala/ch/wsl/box/client/geo/MapGeolocation.scala +++ b/client/src/main/scala/ch/wsl/box/client/geo/MapGeolocation.scala @@ -3,22 +3,18 @@ package ch.wsl.box.client.geo import ch.wsl.typings.ol import ch.wsl.typings.ol.geomMod.Point import ch.wsl.typings.ol.{controlControlMod, controlMod, geolocationMod, geomGeometryMod, geomMod, imageMod, layerBaseVectorMod, layerMod, mod, renderFeatureMod, sourceMod, sourceVectorMod, styleMod} -import org.scalajs.dom.{Event, HTMLInputElement} +import org.scalajs.dom.{Event, HTMLInputElement, PositionOptions} import scalatags.JsDom.all.{`class`, `type`, div, input, onchange, style} import scala.scalajs.js - import scalatags.JsDom.all._ import io.udash._ class MapGeolocation(map:mod.Map) { - val positionOptions = new org.scalajs.dom.PositionOptions() - positionOptions.enableHighAccuracy = true val geolocation = new mod.Geolocation( geolocationMod.Options() .setProjection(map.getView().getProjection()) - .setTrackingOptions(positionOptions) ) val accuracyFeature = new mod.Feature[geomMod.Geometry]() diff --git a/client/src/main/scala/ch/wsl/box/client/services/ClientSession.scala b/client/src/main/scala/ch/wsl/box/client/services/ClientSession.scala index e7cdefef..d67bc1a8 100644 --- a/client/src/main/scala/ch/wsl/box/client/services/ClientSession.scala +++ b/client/src/main/scala/ch/wsl/box/client/services/ClientSession.scala @@ -255,6 +255,8 @@ class ClientSession(rest:REST,httpClient: HttpClient) extends Logging { get[Seq[TableChildElement]](TABLECHILD_OPEN).toSeq.flatten.filterNot(_ == tc) ) + + def lang():String = { Routes.urlParams.get(LANG).foreach(l => @@ -271,7 +273,10 @@ class ClientSession(rest:REST,httpClient: HttpClient) extends Logging { } } + val langProperty = Property(lang()) + def setLang(lang:String) = rest.labels(lang).map{ labels => + langProperty.set(lang) Labels.load(labels) dom.window.sessionStorage.setItem(LANG,lang) Context.applicationInstance.reload() diff --git a/client/src/main/scala/ch/wsl/box/client/styles/BoxStyle.scala b/client/src/main/scala/ch/wsl/box/client/styles/BoxStyle.scala index 5a46a353..0dd330b3 100644 --- a/client/src/main/scala/ch/wsl/box/client/styles/BoxStyle.scala +++ b/client/src/main/scala/ch/wsl/box/client/styles/BoxStyle.scala @@ -12,7 +12,6 @@ trait BoxStyle { val inputHighlight: StyleA val inputInvalid: StyleA - val global: StyleA val spaceBetween: StyleA val flexContainer: StyleA val sidebarRightContent: StyleA diff --git a/client/src/main/scala/ch/wsl/box/client/styles/GlobalStyles.scala b/client/src/main/scala/ch/wsl/box/client/styles/GlobalStyles.scala index 4a3e527e..4cfcf29e 100755 --- a/client/src/main/scala/ch/wsl/box/client/styles/GlobalStyles.scala +++ b/client/src/main/scala/ch/wsl/box/client/styles/GlobalStyles.scala @@ -25,7 +25,11 @@ case class StyleConf(colors:Colors, smallCellsSize:Int, childProps: ChildPropert class GlobalStyleFactory extends BoxStyleFactory { - override def build(settings:Settings): BoxStyle = new GlobalStyles(settings,ClientConf.styleConf) + override def build(settings:Settings): BoxStyle = { + val styles = new GlobalStyles(settings,ClientConf.styleConf) + styles.loadGlobalStyle() + styles + } } @@ -49,7 +53,9 @@ class GlobalStyles(settings:Settings,conf:StyleConf) extends StyleSheet.Inline() backgroundColor(ColorUtils.RGB.fromHex(conf.colors.dangerColor).withTrasparency(0.3)) ) - override val global = style( + + + def loadGlobalStyle() = style( unsafeRoot(".col, .col-1, .col-10, .col-11, .col-12, .col-2, .col-3, .col-4, .col-5, .col-6, .col-7, .col-8, .col-9, .col-auto, .col-lg, .col-lg-1, .col-lg-10, .col-lg-11, .col-lg-12, .col-lg-2, .col-lg-3, .col-lg-4, .col-lg-5, .col-lg-6, .col-lg-7, .col-lg-8, .col-lg-9, .col-lg-auto, .col-md, .col-md-1, .col-md-10, .col-md-11, .col-md-12, .col-md-2, .col-md-3, .col-md-4, .col-md-5, .col-md-6, .col-md-7, .col-md-8, .col-md-9, .col-md-auto, .col-sm, .col-sm-1, .col-sm-10, .col-sm-11, .col-sm-12, .col-sm-2, .col-sm-3, .col-sm-4, .col-sm-5, .col-sm-6, .col-sm-7, .col-sm-8, .col-sm-9, .col-sm-auto, .col-xl, .col-xl-1, .col-xl-10, .col-xl-11, .col-xl-12, .col-xl-2, .col-xl-3, .col-xl-4, .col-xl-5, .col-xl-6, .col-xl-7, .col-xl-8, .col-xl-9, .col-xl-auto") ( padding.`0` diff --git a/client/src/main/scala/ch/wsl/box/client/views/DataView.scala b/client/src/main/scala/ch/wsl/box/client/views/DataView.scala index 9f89743f..ff4b2837 100755 --- a/client/src/main/scala/ch/wsl/box/client/views/DataView.scala +++ b/client/src/main/scala/ch/wsl/box/client/views/DataView.scala @@ -152,7 +152,7 @@ case class DataView(model:ModelProperty[DataModel], presenter:DataPresenter) ext metadata.label ), produce(model.subProp(_.exportDef)){ ed => - div(ClientConf.style.global)(ed.map(_.description.getOrElse[String]("")).getOrElse[String]("")).render + div(ed.map(_.description.getOrElse[String]("")).getOrElse[String]("")).render }, br, JSONMetadataRenderer(metadata, model.subProp(_.queryData),Seq(),Property(model.get.queryData.ID(metadata.keyFields).map(_.asString)),WidgetCallbackActions.noAction,Property(false),false).edit(nested), diff --git a/shared/src/main/scala/ch/wsl/box/shared/utils/JSONUtils.scala b/shared/src/main/scala/ch/wsl/box/shared/utils/JSONUtils.scala index 9a9ad6a9..331bf8d7 100755 --- a/shared/src/main/scala/ch/wsl/box/shared/utils/JSONUtils.scala +++ b/shared/src/main/scala/ch/wsl/box/shared/utils/JSONUtils.scala @@ -24,7 +24,7 @@ object JSONUtils extends Logging { Try { val json:Json = typ match { case JSONFieldTypes.NUMBER => value.toDouble.asJson - case JSONFieldTypes.INTEGER => value.toLong.asJson + case JSONFieldTypes.INTEGER => value.toDouble.toLong.asJson case JSONFieldTypes.BOOLEAN => Json.fromBoolean(value.toBoolean) case JSONFieldTypes.JSON => parser.parse(value) match { case Left(value) => throw new Exception(value.message) From 7703c5fc4d484bdc3d9e656a2cb2dd48cb33dd95 Mon Sep 17 00:00:00 2001 From: Andrea Minetti Date: Wed, 1 Apr 2026 08:24:45 +0200 Subject: [PATCH 38/42] Improved translations --- .../main/scala/ch/wsl/box/client/services/ClientSession.scala | 2 +- .../wsl/box/client/views/components/JSONMetadataRenderer.scala | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/main/scala/ch/wsl/box/client/services/ClientSession.scala b/client/src/main/scala/ch/wsl/box/client/services/ClientSession.scala index d67bc1a8..20d13eba 100644 --- a/client/src/main/scala/ch/wsl/box/client/services/ClientSession.scala +++ b/client/src/main/scala/ch/wsl/box/client/services/ClientSession.scala @@ -276,9 +276,9 @@ class ClientSession(rest:REST,httpClient: HttpClient) extends Logging { val langProperty = Property(lang()) def setLang(lang:String) = rest.labels(lang).map{ labels => - langProperty.set(lang) Labels.load(labels) dom.window.sessionStorage.setItem(LANG,lang) + langProperty.set(lang) Context.applicationInstance.reload() } diff --git a/client/src/main/scala/ch/wsl/box/client/views/components/JSONMetadataRenderer.scala b/client/src/main/scala/ch/wsl/box/client/views/components/JSONMetadataRenderer.scala index 42bc49f5..c6231019 100755 --- a/client/src/main/scala/ch/wsl/box/client/views/components/JSONMetadataRenderer.scala +++ b/client/src/main/scala/ch/wsl/box/client/views/components/JSONMetadataRenderer.scala @@ -206,7 +206,7 @@ case class JSONMetadataRenderer(metadata: JSONMetadata, data: Property[Json], ch selectedTab.set(tabId); tabId.foreach(n => services.clientSession.setSelectedTab(tabKey,n)) e.preventDefault() }, - title + Labels(title) ).render ).render } From 872cf531cccadeebe26fa1663a76af6b75ca52e3 Mon Sep 17 00:00:00 2001 From: Andrea Minetti Date: Wed, 1 Apr 2026 09:01:39 +0200 Subject: [PATCH 39/42] Fixed paths on index.scala.html --- .../main/twirl/ch/wsl/box/templates/index.scala.html | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/server/src/main/twirl/ch/wsl/box/templates/index.scala.html b/server/src/main/twirl/ch/wsl/box/templates/index.scala.html index 5723038f..3f24f8d1 100644 --- a/server/src/main/twirl/ch/wsl/box/templates/index.scala.html +++ b/server/src/main/twirl/ch/wsl/box/templates/index.scala.html @@ -6,10 +6,10 @@ - - - - + + + + @@ -34,8 +34,8 @@ - - + + @matomo.map{ m => From 66181ffb638972e8996c540a5b97476a0a6d662b Mon Sep 17 00:00:00 2001 From: Andrea Minetti Date: Wed, 1 Apr 2026 09:55:43 +0200 Subject: [PATCH 40/42] Fixed paths --- client/vite.config.app.js | 1 + .../scala/ch/wsl/box/rest/auth/oidc/AuthFlow.scala | 2 +- .../main/twirl/ch/wsl/box/templates/index.scala.html | 12 ++++++------ 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/client/vite.config.app.js b/client/vite.config.app.js index 6effc672..ef5f3213 100644 --- a/client/vite.config.app.js +++ b/client/vite.config.app.js @@ -6,6 +6,7 @@ import { viteStaticCopy } from 'vite-plugin-static-copy'; const env = loadEnv(process.env.NODE_ENV, process.cwd()); export default defineConfig({ + base: './', server: { proxy: { '/api': 'http://localhost:8080', diff --git a/server/src/main/scala/ch/wsl/box/rest/auth/oidc/AuthFlow.scala b/server/src/main/scala/ch/wsl/box/rest/auth/oidc/AuthFlow.scala index bc6ac334..5ff4b7d8 100644 --- a/server/src/main/scala/ch/wsl/box/rest/auth/oidc/AuthFlow.scala +++ b/server/src/main/scala/ch/wsl/box/rest/auth/oidc/AuthFlow.scala @@ -74,7 +74,7 @@ object AuthFlow { "client_id" -> provider.client_id, "client_secret" -> provider.client_secret, "code" -> c, - "redirect_uri" -> s"${services.config.frontendUrl}/authenticate/${provider.provider_id}" + "redirect_uri" -> s"${services.config.frontendUrl}authenticate/${provider.provider_id}" )) .response(asJson[OpenIDToken]) diff --git a/server/src/main/twirl/ch/wsl/box/templates/index.scala.html b/server/src/main/twirl/ch/wsl/box/templates/index.scala.html index 3f24f8d1..5723038f 100644 --- a/server/src/main/twirl/ch/wsl/box/templates/index.scala.html +++ b/server/src/main/twirl/ch/wsl/box/templates/index.scala.html @@ -6,10 +6,10 @@ - - - - + + + + @@ -34,8 +34,8 @@ - - + + @matomo.map{ m => From 0a8df61cb62e94a368f6d51136b5765768b85a62 Mon Sep 17 00:00:00 2001 From: Andrea Minetti Date: Wed, 1 Apr 2026 10:22:05 +0200 Subject: [PATCH 41/42] Fixed paths --- .../scala/ch/wsl/box/services/config/ConfigFileImpl.scala | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/server-services/src/main/scala/ch/wsl/box/services/config/ConfigFileImpl.scala b/server-services/src/main/scala/ch/wsl/box/services/config/ConfigFileImpl.scala index 86b1505b..add2e5dc 100644 --- a/server-services/src/main/scala/ch/wsl/box/services/config/ConfigFileImpl.scala +++ b/server-services/src/main/scala/ch/wsl/box/services/config/ConfigFileImpl.scala @@ -12,5 +12,8 @@ class ConfigFileImpl extends Config { override def postgisSchemaName: String = conf.as[Option[String]]("db.postgis.schema").getOrElse(schemaName) override def langs:Seq[String] = conf.as[Option[String]]("langs").map(_.split(",").map(_.trim).toSeq).getOrElse(Seq("en")) - override def frontendUrl: String = conf.as[String]("box.frontend.url").stripSuffix("/") + override def frontendUrl: String = { + val url = conf.as[String]("box.frontend.url") + if(url.endsWith("/")) url else url + "/" + } } From 6f0379ed7a5b7a382c9bef829b0d0ff08ad47667 Mon Sep 17 00:00:00 2001 From: Andrea Minetti Date: Wed, 1 Apr 2026 10:32:35 +0200 Subject: [PATCH 42/42] Fixed paths --- .../scala/ch/wsl/box/client/views/components/LoginForm.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/main/scala/ch/wsl/box/client/views/components/LoginForm.scala b/client/src/main/scala/ch/wsl/box/client/views/components/LoginForm.scala index d5775399..a9b292ec 100644 --- a/client/src/main/scala/ch/wsl/box/client/views/components/LoginForm.scala +++ b/client/src/main/scala/ch/wsl/box/client/views/components/LoginForm.scala @@ -48,7 +48,7 @@ case class LoginForm(login: ModelProperty[LoginData] => Unit) { img(src := openid.logo, maxWidth := 80.px), onclick :+= ((e:Event) => { e.preventDefault() - val redirectUri = URLEncoder.encode(s"${ClientConf.frontendUrl}/authenticate/${openid.provider_id}","UTF-8") + val redirectUri = URLEncoder.encode(s"${ClientConf.frontendUrl}authenticate/${openid.provider_id}","UTF-8") window.location.href = s"${openid.authorize_url}?client_id=${openid.client_id}&scope=${openid.scope}&response_type=code&state=${UUID.randomUUID()}&redirect_uri=$redirectUri" }) )