diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3c0856c59..cc04693e6 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,38 @@ jobs: passphrase: ${{ secrets.PGP_PASSPHRASE }} - name: List keys run: gpg -K + - id: tag + run: | + if [[ "${GITHUB_REF#refs/tags/}" != "$GITHUB_REF" ]]; then + tag=${GITHUB_REF#refs/tags/} + else + 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: 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 + run: | + sbt client/generateScalaTypes - name: Build bundle - run: sbt -J-Xmx4G -J-XX:MaxMetaspaceSize=1G -J-Xss10m client/fullOptJS::webpack + env: + SBT_OPTS: -XX:+UseG1GC -Xmx4G -Xss10M -XX:MaxInlineLevel=20 + run: | + cd client + npm run build + cd .. - name: Version info run: | sbt server/version diff --git a/.gitignore b/.gitignore index 9a9299794..985082ab9 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 da2999c32..775bf19a4 100755 --- a/build.sbt +++ b/build.sbt @@ -1,8 +1,10 @@ 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 -import scalajsbundler.util.JSON +import org.scalajs.linker.interface.ModuleSplitStyle val publishSettings = List( Global / scalaJSStage := FullOptStage, @@ -74,6 +76,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, @@ -81,24 +84,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 +94,6 @@ lazy val server: Project = project .enablePlugins( GitVersioning, BuildInfoPlugin, - WebScalaJSBundlerPlugin, SbtTwirl ) .dependsOn(sharedJVM) @@ -129,90 +113,26 @@ 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, 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", - ), - 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", - 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", - ), - webpack / version := "5.89.0", - webpackCliVersion := "5.1.4", - installJsdom / version := "20.0.0", +// 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)), + //To use jsdom headless browser uncomment the following lines Test / requireJsDomEnv := true, @@ -244,12 +164,13 @@ 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, + Compile / doc / sources := Seq() + ) .settings(publishSettings) .enablePlugins( ScalaJSPlugin, - ScalablyTypedConverterGenSourcePlugin, LocalesPlugin ) .dependsOn(sharedJS) @@ -348,15 +269,14 @@ 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), + (sharedJS / publishSigned), + (client / publishSigned), (codegen / publishSigned), (server / publishSigned), (serverCacheRedis / publishSigned), @@ -368,8 +288,6 @@ lazy val publishAllTask = { lazy val publishAllLocal = taskKey[Unit]("Publish all modules") lazy val publishAllLocalTask = { Def.sequential( - (client / Compile / fullOptJS / webpack), - (codegen / Compile / compile), (sharedJVM / publishLocal), (codegen / publishLocal), (server / publishLocal), @@ -397,3 +315,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/index.html b/client/index.html new file mode 100644 index 000000000..7af8856cb --- /dev/null +++ b/client/index.html @@ -0,0 +1,18 @@ + + + + + + + Vite App + + +
+ + + + diff --git a/client/javascript.svg b/client/javascript.svg new file mode 100644 index 000000000..f9abb2b72 --- /dev/null +++ b/client/javascript.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/libraries.css b/client/libraries.css new file mode 100644 index 000000000..e407c2e9e --- /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 new file mode 100644 index 000000000..d62873ed7 --- /dev/null +++ b/client/main.js @@ -0,0 +1,12 @@ +import 'scalajs:main.js' +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 new file mode 100644 index 000000000..c796dfd6b --- /dev/null +++ b/client/package.json @@ -0,0 +1,69 @@ +{ + "name": "box-client", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "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 -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", + "preview": "vite preview" + }, + "dependencies": { + "@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", + "@types/bootstrap": "4.1.3", + "@types/file-saver": "2.0.1", + "@types/jquery": "3.5.6", + "@types/js-md5": "0.8.0", + "@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", + "is-blob": "3.0.0", + "jquery": "3.4.1", + "js-md5": "0.8.3", + "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", + "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", + "string-strip-html": "13.5.3", + "tailwindcss": "^4.1.18", + "toolcool-range-slider": "4.0.28", + "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-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 75225b622..21826a1f6 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._ @@ -9,6 +10,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 +23,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,34 +56,37 @@ object Main extends Logging { }) //window.onerror = ErrorHandler.onError - } 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 } 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(version) else Future.successful() } yield { Logger.root.clearHandlers().clearModifiers().withHandler(minimumLevel = Some(ClientConf.loggerLevel)).replace() println(s"Setting logger level to ${ClientConf.loggerLevel}") //loads datetime picker - ch.wsl.typings.bootstrap.bootstrapRequire //ch.wsl.typings.toolcoolRangeSlider.toolcoolRangeSliderRequire - - Labels.load(labels) UI.load(uiConf) @@ -85,6 +96,7 @@ object Main extends Logging { val CssSettings = scalacss.devOrProdDefaults import CssSettings._ + val mainStyle = document.createElement("style") mainStyle.innerText = ClientConf.style.render(cssStringRenderer,cssEnv) @@ -130,10 +142,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/src/main/scala/ch/wsl/box/client/Module.scala b/client/src/main/scala/ch/wsl/box/client/Module.scala index 18969de8a..40dfb58e4 100644 --- a/client/src/main/scala/ch/wsl/box/client/Module.scala +++ b/client/src/main/scala/ch/wsl/box/client/Module.scala @@ -1,6 +1,8 @@ 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} +import ch.wsl.box.client.views.components.{BoxMainLayout, MainLayout} import ch.wsl.box.model.shared.AvailableUIModule import wvlet.airframe._ @@ -18,6 +20,8 @@ object Module { .bind[ClientSession].toEagerSingleton .bind[Navigator].toEagerSingleton .bind[NotificationChannel].to[NotificationWebSocket] + .bind[BoxStyleFactory].to[GlobalStyleFactory] + .bind[MainLayout].to[BoxMainLayout] val prod = newDesign .bind[HttpClient].to[HttpClientImpl] @@ -26,5 +30,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 c3603dafb..9559ad0d8 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.services.BrowserConsole +import ch.wsl.box.client.routes.Routes +import ch.wsl.box.client.services.{BrowserConsole, ClientConf} +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} @@ -12,18 +13,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"${Routes.baseUri}ui/workers/postgres.worker.${version}.js?version=$version&appId=${ClientConf.applicationId}",worker_options) + connection = new PGliteWorker(worker) + localRecord = new LocalRecordDAO(connection) for { result <- localRecord.init() 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 e04e65207..c0dae9d79 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/geo/MapGeolocation.scala b/client/src/main/scala/ch/wsl/box/client/geo/MapGeolocation.scala index 41eb29ede..5d2216432 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 ch.wsl.typings.std.PositionOptions -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 = PositionOptions().setEnableHighAccuracy(true) val geolocation = new mod.Geolocation( geolocationMod.Options() .setProjection(map.getView().getProjection()) - .setTrackingOptions(positionOptions.asInstanceOf[org.scalajs.dom.PositionOptions]) ) val accuracyFeature = new mod.Feature[geomMod.Geometry]() 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 68f1764ec..80bc49973 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/BoxFileReader.scala b/client/src/main/scala/ch/wsl/box/client/services/BoxFileReader.scala index 824d853c3..322b9c346 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/ClientConf.scala b/client/src/main/scala/ch/wsl/box/client/services/ClientConf.scala index b1f18f094..d511d6f31 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,17 +1,21 @@ 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.{BoxStyle, GlobalStyleFactory, StyleConf} import ch.wsl.box.model.shared.JSONFieldTypes 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 /** @@ -25,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 { @@ -40,11 +46,11 @@ object ClientConf { case _ => Level.Warn } - def localDb:Boolean = Try(conf("local.db").toBoolean).getOrElse(true) - 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) @@ -59,7 +65,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") @@ -91,7 +97,8 @@ object ClientConf { inputWidth ) - lazy val style = GlobalStyleFactory.GlobalStyles(styleConf) + //lazy val style = new GlobalStyleFactory.GlobalStyles(styleConf) + 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/services/ClientSession.scala b/client/src/main/scala/ch/wsl/box/client/services/ClientSession.scala index e7cdefef1..20d13ebac 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,9 +273,12 @@ class ClientSession(rest:REST,httpClient: HttpClient) extends Logging { } } + val langProperty = Property(lang()) + def setLang(lang:String) = rest.labels(lang).map{ labels => 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/services/DataAccessObject.scala b/client/src/main/scala/ch/wsl/box/client/services/DataAccessObject.scala index 512136552..8fdba8fc9 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/HttpClient.scala b/client/src/main/scala/ch/wsl/box/client/services/HttpClient.scala index 0a131f19b..b5d31cd3c 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 b96549d58..15ddb4dd8 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) @@ -164,6 +165,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/services/Notification.scala b/client/src/main/scala/ch/wsl/box/client/services/Notification.scala index b78705da1..84bc5372b 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/PDF.scala b/client/src/main/scala/ch/wsl/box/client/services/PDF.scala new file mode 100644 index 000000000..407a73720 --- /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/ServiceModule.scala b/client/src/main/scala/ch/wsl/box/client/services/ServiceModule.scala index 1935ca981..78d1ccf76 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,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 { @@ -9,4 +11,6 @@ trait ServiceModule { val clientSession = bind[ClientSession] 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 73e5d6125..a11388b88 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 99c96db50..5db0bd230 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/services/impl/HttpClientImpl.scala b/client/src/main/scala/ch/wsl/box/client/services/impl/HttpClientImpl.scala index e6f576057..0b1941ee8 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,9 +3,9 @@ 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 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/styles/BoxStyle.scala b/client/src/main/scala/ch/wsl/box/client/styles/BoxStyle.scala new file mode 100644 index 000000000..0dd330b30 --- /dev/null +++ b/client/src/main/scala/ch/wsl/box/client/styles/BoxStyle.scala @@ -0,0 +1,152 @@ +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 + 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 tableWrapper: 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 popupEntiresItem: 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 mapTable: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 789e4c89e..4cfcf29e1 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,1683 @@ 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 = { + val styles = new GlobalStyles(settings,ClientConf.styleConf) + styles.loadGlobalStyle() + styles + } - case class GlobalStyles(conf:StyleConf) extends StyleSheet.Inline { +} +class GlobalStyles(settings:Settings,conf:StyleConf) extends StyleSheet.Inline()(settings.cssRegister) with BoxStyle { - import dsl._ + import settings._ - val inputDefaultWidth = width(conf.inputPercentage %%) + import dsl._ - val inputHighlight = style( - borderWidth(0 px,0 px,1 px,0 px), - borderColor(conf.colors.main), - backgroundColor(c"#f5f5f5"), - outlineWidth.`0` - ) + protected val inputDefaultWidth = width(conf.inputPercentage %%) - val inputInvalid = style( - borderColor(conf.colors.danger), - backgroundColor(ColorUtils.RGB.fromHex(conf.colors.dangerColor).withTrasparency(0.3)) - ) + override val inputHighlight = style( + borderWidth(0 px,0 px,1 px,0 px), + borderColor(conf.colors.main), + backgroundColor(c"#f5f5f5"), + outlineWidth.`0` + ) - val global = style( + override val inputInvalid = style( + borderColor(conf.colors.danger), + backgroundColor(ColorUtils.RGB.fromHex(conf.colors.dangerColor).withTrasparency(0.3)) + ) - 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("b") ( - Font.bold - ), + def loadGlobalStyle() = style( - unsafeRoot("h4")( - marginTop(20 px) - ), + 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(".block-el-0 h4")( - marginTop.`0`.important - ), + unsafeRoot(".row")( + margin.`0` + ), - unsafeRoot("h5")( - Font.bold, - fontSize(14 px), - media.maxWidth(600 px)( //disable autozoom - fontSize(16 px) - ) - ), + unsafeRoot("b") ( + Font.bold + ), - unsafeRoot("body") ( - StyleConstants.defaultFontSize, - backgroundColor.white, - Font.regular - ), + unsafeRoot("h4")( + marginTop(20 px) + ), - unsafeRoot("html, body") ( - touchAction := "pan-x pan-y" - ), + unsafeRoot(".block-el-0 h4")( + marginTop.`0`.important + ), - unsafeRoot("h3") ( - marginTop(18 px) - ), + unsafeRoot("h5")( + Font.bold, + fontSize(14 px), + media.maxWidth(600 px)( //disable autozoom + fontSize(16 px) + ) + ), - unsafeRoot("*:focus")( - outline.none - ), + unsafeRoot("body") ( + StyleConstants.defaultFontSize, + backgroundColor.white, + Font.regular + ), - 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("html, body") ( + touchAction := "pan-x pan-y" + ), - 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) - ) - ), + unsafeRoot("h3") ( + marginTop(18 px) + ), + unsafeRoot("*:focus")( + outline.none + ), - unsafeRoot("label")( - Font.bold + unsafeRoot("select")( + inputDefaultWidth, + media.maxWidth(600 px)( + width(100 %%) ), - - unsafeRoot("input[type='checkbox']")( - width.auto, - height.auto + 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("input[type='number']")( - textAlign.right + &.hover( + inputHighlight ), - - unsafeRoot(".flatpickr-time input[type='number']")( - textAlign.center + &.invalid( + inputInvalid ), + media.maxWidth(600 px)( //disable autozoom + height(26 px), + fontSize(16 px) + ) + ), - unsafeRoot("input[type='file']")( - width(100 %%), - height.auto, - borderWidth(0 px), - backgroundColor.transparent + unsafeRoot("input")( + inputDefaultWidth, + media.maxWidth(600 px)( + width(100 %%) ), - - 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 - ), + 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) + ) + ), - 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(".action") ( + cursor.pointer + ), + + unsafeRoot(".nav-item") ( + cursor.pointer + ), + + 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).important, + 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 tableWrapper = style( + addClassName("box-table-wrapper") + ) + + 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 popupEntiresItem = style( + display.flex, + justifyContent.spaceBetween, + marginBottom(10 px) + ) + + 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`, + unsafeExt(_ + ".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)" + ) + + + 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`, + 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 - ) - + ), + display.flex, + flexDirection.column, + justifyContent.center, + alignItems.center, + margin.vertical(10 px), + unsafeChild("p")( + color(Colors.Grey), + fontSize(11 px), + Font.bold ) + ) - val editableTableEditButton = style( - fontSize(12 px), - marginLeft(2 px), - unsafeChild("svg") ( - color.gray, - ), - &.hover( - unsafeChild("svg") ( - color(conf.colors.main) - ) - ) + val dropFileZoneDropping = style( + borderColor(conf.colors.main), + backgroundColor.rgba(0,0,0,0.3), + unsafeChild("p")( + color(conf.colors.main) ) + ) - val editableTableMulti = style( - minHeight(20 px), - padding.`0` + val sidebarButton = style( + position.fixed, + top(5 px), + left(5 px), + zIndex(101), + media.maxWidth(600 px)( + display.none ) - 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 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 +1710,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/utils/ImageUtils.scala b/client/src/main/scala/ch/wsl/box/client/utils/ImageUtils.scala new file mode 100644 index 000000000..c28509257 --- /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/utils/Shorten.scala b/client/src/main/scala/ch/wsl/box/client/utils/Shorten.scala index 602b9a98e..ab1aeca00 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 000000000..9d985826d --- /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/vendors/CompressorJS.scala b/client/src/main/scala/ch/wsl/box/client/vendors/CompressorJS.scala new file mode 100644 index 000000000..6610b0e64 --- /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/vendors/PgLiteWorker.scala b/client/src/main/scala/ch/wsl/box/client/vendors/PgLiteWorker.scala new file mode 100644 index 000000000..c193fc6d9 --- /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/DataView.scala b/client/src/main/scala/ch/wsl/box/client/views/DataView.scala index 9f89743f5..ff4b28370 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/client/src/main/scala/ch/wsl/box/client/views/EntitiesView.scala b/client/src/main/scala/ch/wsl/box/client/views/EntitiesView.scala index 410be9391..063d922f4 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/EntityFormView.scala b/client/src/main/scala/ch/wsl/box/client/views/EntityFormView.scala index f2005abc6..3ceab97a2 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/EntityTableView.scala b/client/src/main/scala/ch/wsl/box/client/views/EntityTableView.scala index e9f32ea7d..fe84a4242 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.table + 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] = { @@ -774,11 +779,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) => @@ -1026,7 +1027,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 => { @@ -1058,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) @@ -1144,9 +1145,13 @@ 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.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/LoginView.scala b/client/src/main/scala/ch/wsl/box/client/views/LoginView.scala index 972901e63..984c53974 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) } } @@ -44,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/RootView.scala b/client/src/main/scala/ch/wsl/box/client/views/RootView.scala index 37383fa8c..fc5498e50 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/admin/DBRepl.scala b/client/src/main/scala/ch/wsl/box/client/views/admin/DBRepl.scala index 52d5b6f20..977e4d17c 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/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 000000000..ca272420b --- /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/Footer.scala b/client/src/main/scala/ch/wsl/box/client/views/components/Footer.scala index 10aac4838..ef434fe49 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/main/scala/ch/wsl/box/client/views/components/JSONMetadataRenderer.scala b/client/src/main/scala/ch/wsl/box/client/views/components/JSONMetadataRenderer.scala index 2936112f6..c62310199 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,28 +183,46 @@ 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)) e.preventDefault() }, - title + Labels(title) ).render ).render } ), - nested(produce(selectedTab) { tabName => + tabs.map{ tabName => val block = blks.filter(_.block.tab == tabName) window.setTimeout(() => block.map(x => x.widget.afterRender()),0) - renderBlocks(block).render - }) + // make tab fill the space + val fullwidth = block.map(b => b.copy(b.block.copy(width = 12/block.length))) + 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 + } ) } 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 d5775399e..a9b292ec9 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" }) ) 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 000000000..9fc27e7f0 --- /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 +} 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 843a87310..c376fa8c3 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/TableFieldsRenderer.scala b/client/src/main/scala/ch/wsl/box/client/views/components/TableFieldsRenderer.scala index 8693f554e..0f76a1b18 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/ui/Autocomplete.scala b/client/src/main/scala/ch/wsl/box/client/views/components/ui/Autocomplete.scala index b87049709..8c9d49664 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 201fdbc31..5af2aed91 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 a9522be14..71afa0f10 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/admin/LayoutWidget.scala b/client/src/main/scala/ch/wsl/box/client/views/components/widget/admin/LayoutWidget.scala index 45ecac7f9..0042f392f 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} @@ -20,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 63738c35d..6cd3c1d5e 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} @@ -27,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/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 7f3247855..fd6e998d1 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/LookupWidget.scala b/client/src/main/scala/ch/wsl/box/client/views/components/widget/lookup/LookupWidget.scala index 7e9972999..2e8fa3645 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,20 +93,16 @@ 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.^(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 Some(value) if !force => 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)) { @@ -140,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)) } @@ -201,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 ee7bcdacc..c8cb0967a 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,16 @@ object PopupSelectWidget extends ComponentWidgetFactory { val Open = "open" } + import ch.wsl.box.client.Context.Implicits._ + + + 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()) + + 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) @@ -64,23 +80,78 @@ 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, + 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( 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(ClientConf.style.popupEntiresItem, + a(bind(x.transform(_.value)), onclick :+= ((e: Event) => { + modalStatus.set(Status.Closed) + model.set(Some(x.get)) + 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 } ) ) ).render })).render } - )) + )) + + def editEntity(entity:String,nested:NestedInterceptor):Modifier = { + + + + + + logger.debug("Loading child metadata") + 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(childMetadata) { (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,23 +165,67 @@ 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( - 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)( @@ -124,7 +239,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 _ => {} } } @@ -157,11 +275,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, diff --git a/client/src/main/scala/io/udash/routing/BoxUrlChangeProvider.scala b/client/src/main/scala/io/udash/routing/BoxUrlChangeProvider.scala index 1fba3788d..ec0b60604 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/src/test/scala/ch/wsl/box/client/mocks/HttpClientMock.scala b/client/src/test/scala/ch/wsl/box/client/mocks/HttpClientMock.scala index d52a21ce9..7da3efeab 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/client/src/test/scala/ch/wsl/box/client/mocks/RestMock.scala b/client/src/test/scala/ch/wsl/box/client/mocks/RestMock.scala index fa5b5baf4..509201089 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/client/style.scss b/client/style.scss new file mode 100644 index 000000000..e69de29bb diff --git a/client/vite.config.app.js b/client/vite.config.app.js new file mode 100644 index 000000000..ef5f3213a --- /dev/null +++ b/client/vite.config.app.js @@ -0,0 +1,68 @@ +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()); + +export default defineConfig({ + base: './', + server: { + proxy: { + '/api': 'http://localhost:8080', + '/pdf': '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, + host: '0.0.0.0' + }, + optimizeDeps: { + exclude: ['@electric-sql/pglite'], + }, + 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', + }), + viteStaticCopy({ + targets: [ + { + src: './node_modules/@electric-sql/pglite-repl/dist-webcomponent/*', + dest: 'public', + rename: { stripBase: 1 } + } + ] + }) + ], + 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 000000000..8dbdaa6f4 --- /dev/null +++ b/client/vite.config.worker.js @@ -0,0 +1,26 @@ +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', + //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/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 new file mode 100644 index 000000000..bac02fa81 --- /dev/null +++ b/client/workers/postgres.worker.js @@ -0,0 +1,30 @@ +import { PGlite } from '@electric-sql/pglite' +import { worker } from '@electric-sql/pglite/worker' + +console.log('Worker started'); +try { + worker({ + 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`)), + WebAssembly.compileStreaming(fetch(`./initdb.${version}.wasm`)), + fetch(`./pglite.${version}.data`).then((response) => response.blob()), + ]) + // Create and return a PGlite instance + return PGlite.create(`idb://box-pgdata-${id}`,{ + pgliteWasmModule: pgliteWasmModule, + initdbWasmModule: initdbWasmModule, + fsBundle: fsBundle + }) + }, + }) +} catch (error) { + console.error('Error starting worker:', error); +} +self.onmessage = function(event) { + console.log('Message received in worker:', event.data); +}; 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/codegen/src/main/scala/ch/wsl/box/jdbc/Connection.scala b/codegen/src/main/scala/ch/wsl/box/jdbc/Connection.scala index d0d7c7a28..a3411c4d7 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,9 @@ trait Connection extends Logging { //val executor = AsyncExecutor("public-executor",50,50,10000,50) + def notificationSession():Resource[IO, skunk.Session[IO]] + def pooledAdminSession(): Resource[IO, Resource[IO, Session[IO]]] + def adminDB = dbForUser(adminUser,"box_admin",adminDbConnection) @@ -93,8 +98,26 @@ class ConnectionConfImpl extends Connection { ConfigValueFactory.fromAnyRef("disabled") } + import natchez.Trace.Implicits.noop + + private val pgConf = JdbcParser.parse(dbPath).get + override def notificationSession(): Resource[IO, Session[IO]] = Session.single[IO]( + host=pgConf.host, + port= pgConf.port, + user=adminUser, + database = pgConf.database, + 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") @@ -169,10 +192,11 @@ class ConnectionTestContainerImpl(container: PostgreSQLContainer,schema:String) val idleTimeout = 300000 + override def notificationSession(): Resource[IO, Session[IO]] = ??? + override def pooledAdminSession(): Resource[IO, 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 000000000..3bb5b96e6 --- /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/BOX_R__v_jsonschema.sql b/db/box_migrations/BOX_R__v_jsonschema.sql new file mode 100644 index 000000000..00995e796 --- /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/db/box_migrations/functions/BOX_R__box_notify.sql b/db/box_migrations/functions/BOX_R__box_notify.sql new file mode 100644 index 000000000..cf18979f7 --- /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 jsonb +) + returns void set search_path from current as $$ +declare + output text := ''; +begin + + 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); +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 000000000..6d30eea84 --- /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}'::jsonb); + + 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__translate_column.sql b/db/box_migrations/functions/BOX_R__translate_column.sql new file mode 100644 index 000000000..2a6faacbd --- /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 new file mode 100644 index 000000000..3d9391330 --- /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_jsonb( + (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/BoxScalablyTypes.scala b/project/BoxScalablyTypes.scala new file mode 100644 index 000000000..fb08f5fec --- /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 b82389888..d2aa2e4b0 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" @@ -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" @@ -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", @@ -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( @@ -202,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" )) diff --git a/project/plugins.sbt b/project/plugins.sbt index b7ab820c8..3f230dfba 100755 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,13 +1,11 @@ //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("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-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 86b1505bd..add2e5dcc 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 + "/" + } } 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 4dd3699fa..f960c1d36 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/model/UpdateTable.scala b/server/src/main/scala/ch/wsl/box/model/UpdateTable.scala index 9c205fc5f..e65629e4b 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/server/src/main/scala/ch/wsl/box/rest/Boot.scala b/server/src/main/scala/ch/wsl/box/rest/Boot.scala index dee115f9a..b94597d3d 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() @@ -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/Module.scala b/server/src/main/scala/ch/wsl/box/rest/Module.scala index 410584a79..cb6831041 100644 --- a/server/src/main/scala/ch/wsl/box/rest/Module.scala +++ b/server/src/main/scala/ch/wsl/box/rest/Module.scala @@ -2,14 +2,16 @@ 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} -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.{ColumnTranslate, DeepLTranslateService, TranslateService} import com.softwaremill.session.{InMemoryRefreshTokenStorage, RefreshTokenStorage} import scribe.Logging import wvlet.airframe._ @@ -45,9 +47,12 @@ 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] + .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) }) @@ -57,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/auth/oidc/AuthFlow.scala b/server/src/main/scala/ch/wsl/box/rest/auth/oidc/AuthFlow.scala index bc6ac3343..5ff4b7d85 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/scala/ch/wsl/box/rest/io/geotools/GeoPackageWriter.scala b/server/src/main/scala/ch/wsl/box/rest/io/geotools/GeoPackageWriter.scala index 86fc78062..522c0f793 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) } } @@ -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,24 +70,56 @@ object GeoPackageWriter { val geopkg = new GeoPackage(File.createTempFile("geopkg", "db")) geopkg.init() + 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(geomNameTrim(c.name))).map(_.name).getOrElse("no-field") + + 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 + val builder: SimpleFeatureTypeBuilder = new SimpleFeatureTypeBuilder + builder.setName(s"${name}_$geomName") + //builder.setCRS(org.geotools.referencing.CRS.decode(s"EPSG:$srid")) - data.geometry.foreach { case (geomName, values) => - val builder: SimpleFeatureTypeBuilder = new SimpleFeatureTypeBuilder - builder.setName(s"${name}_$geomName") - //builder.setCRS(org.geotools.referencing.CRS.decode(crs.name)) + 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]) + } - 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) - case JSONFieldTypes.GEOMETRY => () - case _ => builder.add(t.name, classOf[String]) - } } val schema = builder.buildFeatureType() @@ -95,8 +128,9 @@ 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 => + + 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)) @@ -111,8 +145,10 @@ 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) //geopkg.createSpatialIndex(entry) 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 3252cf184..74218204c 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 4b0d3d79f..dd663f69a 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/logic/notification/DbNotify.scala b/server/src/main/scala/ch/wsl/box/rest/logic/notification/DbNotify.scala new file mode 100644 index 000000000..eb7e01a7f --- /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.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) + 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 e7b9fd92d..d91ad2050 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 75bb33186..000000000 --- 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/metadata/EntityMetadataFactory.scala b/server/src/main/scala/ch/wsl/box/rest/metadata/EntityMetadataFactory.scala index 4684936d3..834de253a 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/metadata/InMemoryMetadataFactory.scala b/server/src/main/scala/ch/wsl/box/rest/metadata/InMemoryMetadataFactory.scala new file mode 100644 index 000000000..54fa1f4c7 --- /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/Exporters.scala b/server/src/main/scala/ch/wsl/box/rest/routes/Exporters.scala index 72428d8b6..e5888564c 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,9 +3,9 @@ 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, 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 @@ -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 { @@ -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] = { @@ -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,24 +88,38 @@ 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): Route = { val extractFk = fk.forall(_ == "resolve_fk") val query = parse(q).right.get.as[JSONQuery].right.get - 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)) - 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))) - ) - } yield { - CSV.download(csvTable) + + + 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 + } } - 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/File.scala b/server/src/main/scala/ch/wsl/box/rest/routes/File.scala index 8917bd865..6b91056b4 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 fe61a4fb1..6cb4e2a80 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) } } } @@ -268,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/server/src/main/scala/ch/wsl/box/rest/routes/Table.scala b/server/src/main/scala/ch/wsl/box/rest/routes/Table.scala index 405041016..13480c427 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/server/src/main/scala/ch/wsl/box/rest/routes/UI.scala b/server/src/main/scala/ch/wsl/box/rest/routes/UI.scala index 1ab97485a..c1b38ccb2 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,72 +24,19 @@ 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('./assets/@electric-sql/pglite/dist/index.js') - | const { worker } = await import('./assets/@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 + 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))) + } - 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") - } ~ - postgresWorker ~ pathPrefix("icon") { pathPrefix("icon.png") { get{ @@ -112,9 +59,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 => @@ -152,28 +96,12 @@ object UI { complete(HttpEntity(ContentType(MediaType.applicationWithOpenCharset("manifest+json","webmanifest"),HttpCharsets.`UTF-8`) ,manifest)) } } ~ - pathPrefix("assets") { - postgresWasm ~ - postgresData ~ - 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)) - } - } ~ + resourcesAt("ui") ~ + resourcesAt("public") ~ 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.frontendUrl,services.config.mainColor,services.config.matomo) } } } 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 f499569a7..eb99366f1 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/rest/routes/v1/WebsocketNotifications.scala b/server/src/main/scala/ch/wsl/box/rest/routes/v1/WebsocketNotifications.scala index 6fe0cf054..0e3406b6b 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 b6ce9c344..5a0605ae3 100644 --- a/server/src/main/scala/ch/wsl/box/services/Services.scala +++ b/server/src/main/scala/ch/wsl/box/services/Services.scala @@ -2,12 +2,14 @@ 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} 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._ @@ -17,7 +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] + } 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 b91497e1d..d26931997 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)} @@ -109,13 +110,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 @@ -148,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/DummyFullConfig.scala b/server/src/main/scala/ch/wsl/box/services/config/DummyFullConfig.scala index 24c9a42da..8b1c03353 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 eb8fd3a45..38f619945 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 @@ -18,9 +23,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 010fcb177..0acf8ae47 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/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 000000000..df3a29c41 --- /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) + } + }) +} 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 000000000..2acde2511 --- /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 000000000..f182eac81 --- /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/main/twirl/ch/wsl/box/templates/index.scala.html b/server/src/main/twirl/ch/wsl/box/templates/index.scala.html index 20f839908..5723038f9 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 @@ } - +
+ 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 136a7b14f..91c878f5e 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 2f5af2e74..297da1778 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) 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 6143bb934..01eb3fa7c 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) } } 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 440b358d5..ee15e5ee4 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,14 @@ 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 + } def tableLookupFields = table.filter(_.lookup.isDefined) lazy val keyFields:Seq[JSONField] = keys.flatMap(k => fields.find(_.name == k)) 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 c0f9f281a..496783176 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") 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 d802218dc..1992168de 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 @@ -272,10 +274,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 ) } 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 9a9ad6a94..331bf8d73 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) diff --git a/stats.json b/stats.json new file mode 100644 index 000000000..f1f7d3b08 --- /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