From 5bbf029c22f02e8a9873f88eea5567dbfc080069 Mon Sep 17 00:00:00 2001 From: Andrea Minetti Date: Mon, 3 Aug 2026 10:11:33 +0200 Subject: [PATCH 1/7] Added export widget --- .../components/table/ExportTableDialog.scala | 1 - .../components/widget/WidgetRegistry.scala | 3 +- .../widget/child/ExportWidget.scala | 51 +++++++++++++++++++ .../wsl/box/rest/metadata/box/FormUIDef.scala | 3 ++ .../ch/wsl/box/rest/routes/v1/Access.scala | 13 ++++- .../wsl/box/model/shared/WidgetsNames.scala | 4 +- 6 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 client/src/main/scala/ch/wsl/box/client/views/components/widget/child/ExportWidget.scala diff --git a/client/src/main/scala/ch/wsl/box/client/views/components/table/ExportTableDialog.scala b/client/src/main/scala/ch/wsl/box/client/views/components/table/ExportTableDialog.scala index 6dd0cddc..f04819f4 100644 --- a/client/src/main/scala/ch/wsl/box/client/views/components/table/ExportTableDialog.scala +++ b/client/src/main/scala/ch/wsl/box/client/views/components/table/ExportTableDialog.scala @@ -64,7 +64,6 @@ class ExportTableDialog extends Logging { Seq( button(`type` := "button", onclick :+= ((e:Event) => open(modal,onOpen())), ClientConf.style.boxButton, "Export"), - ) } diff --git a/client/src/main/scala/ch/wsl/box/client/views/components/widget/WidgetRegistry.scala b/client/src/main/scala/ch/wsl/box/client/views/components/widget/WidgetRegistry.scala index ea6f6acd..fe3b2156 100644 --- a/client/src/main/scala/ch/wsl/box/client/views/components/widget/WidgetRegistry.scala +++ b/client/src/main/scala/ch/wsl/box/client/views/components/widget/WidgetRegistry.scala @@ -5,7 +5,7 @@ import ch.wsl.box.client.views.components.widget.array.{ChoicesWidget, MultiWidg import ch.wsl.box.client.views.components.widget.boolean.SelectBooleanWidget import ch.wsl.box.client.views.components.widget.child.{EditableTable, LookupFormWidget, SimpleChildFactory, TableChildFactory, TrasparentChild} import ch.wsl.box.client.views.components.widget.geo.{MapChild, MapPointWidget, OlMapListWidget, OlMapWidget} -import ch.wsl.box.client.views.components.widget.child.{EditableTable, LookupFormWidget, SimpleChildFactory, Spreadsheet, TableChildFactory, TrasparentChild} +import ch.wsl.box.client.views.components.widget.child.{EditableTable, LookupFormWidget, SimpleChildFactory, Spreadsheet, TableChildFactory, TrasparentChild,ExportWidget} import ch.wsl.box.client.views.components.widget.geo.{MapPointWidget, OlMapListWidget, OlMapWidget} import ch.wsl.box.client.views.components.widget.labels.{HtmlWidget, LinkedFormWidget, LookupLabelWidget, StaticTextWidget, TitleWidget} import ch.wsl.box.client.views.components.widget.lookup.{MultipleLookupWidget, PopupSelectWidget, SelectWidgetFactory} @@ -56,6 +56,7 @@ object WidgetRegistry extends Logging { LookupFormWidget, LinkedFormWidget, Spreadsheet, + ExportWidget, FileSimpleWidgetFactory, diff --git a/client/src/main/scala/ch/wsl/box/client/views/components/widget/child/ExportWidget.scala b/client/src/main/scala/ch/wsl/box/client/views/components/widget/child/ExportWidget.scala new file mode 100644 index 00000000..0ece83e7 --- /dev/null +++ b/client/src/main/scala/ch/wsl/box/client/views/components/widget/child/ExportWidget.scala @@ -0,0 +1,51 @@ +package ch.wsl.box.client.views.components.widget.child + +import ch.wsl.box.client.views.components.table.{ExportParams, ExportTableDialog} +import ch.wsl.box.client.views.components.widget.{ComponentWidgetFactory, Widget, WidgetParams} +import ch.wsl.box.model.shared.{JSONField, JSONQuery, JSONQueryFilter, WidgetsNames} +import ch.wsl.box.shared.utils.JSONUtils.EnhancedJson +import io.udash.bindings.modifiers.Binding +import scalatags.JsDom +import scalatags.JsDom.all._ + + + +object ExportWidget extends ComponentWidgetFactory { + + override def name: String = WidgetsNames.`export` + + override def create(params: WidgetParams): Widget = ExportWidgetImpl(params) + + case class ExportWidgetImpl(params: WidgetParams) extends Widget { + + val exportDialog = new ExportTableDialog + + override def field: JSONField = params.field + + + def exportParams = for{ + c <- field.child + m <- params.children.find(_.objId == c.objId) + } yield { + val childFilters = c.mapping.map(m => JSONQueryFilter.WHERE.eq(m.child,params.allData.get.get(m.parent))) + ExportParams(m,m.table,c.childQuery.getOrElse(JSONQuery.empty).filterWith(childFilters:_*)) + } + + override def killWidget(): Unit = { + super.killWidget() + exportDialog.clean() + } + + override protected def show(nested:Binding.NestedInterceptor): JsDom.all.Modifier = { + exportParams match { + case Some(ep) => div( + exportDialog.render(nested,() => ep) + ) + case None => div("Please check form definition, can't render the export") + } + + } + + override protected def edit(nested:Binding.NestedInterceptor): JsDom.all.Modifier = show(nested) + } +} diff --git a/server/src/main/scala/ch/wsl/box/rest/metadata/box/FormUIDef.scala b/server/src/main/scala/ch/wsl/box/rest/metadata/box/FormUIDef.scala index 8a014cc9..e18a5c0c 100644 --- a/server/src/main/scala/ch/wsl/box/rest/metadata/box/FormUIDef.scala +++ b/server/src/main/scala/ch/wsl/box/rest/metadata/box/FormUIDef.scala @@ -379,6 +379,7 @@ object FormUIDef { WidgetsNames.editableTable, WidgetsNames.trasparentChild, WidgetsNames.spreadsheet, + WidgetsNames.export, ))), lookup = Some(JSONFieldLookup.withExtractor( "entity", @@ -394,6 +395,7 @@ object FormUIDef { WidgetsNames.editableTable, WidgetsNames.trasparentChild, WidgetsNames.spreadsheet, + WidgetsNames.export, ))), lookup = Some(JSONFieldLookup.withExtractor( "child_form_uuid", @@ -416,6 +418,7 @@ object FormUIDef { WidgetsNames.editableTable, WidgetsNames.trasparentChild, WidgetsNames.spreadsheet, + WidgetsNames.export, ))) ), CommonField.condition, diff --git a/server/src/main/scala/ch/wsl/box/rest/routes/v1/Access.scala b/server/src/main/scala/ch/wsl/box/rest/routes/v1/Access.scala index 71ef4861..47664d61 100644 --- a/server/src/main/scala/ch/wsl/box/rest/routes/v1/Access.scala +++ b/server/src/main/scala/ch/wsl/box/rest/routes/v1/Access.scala @@ -48,10 +48,21 @@ case class Access(session:BoxSession)(implicit ec:ExecutionContext,services:Serv } } + val boxTableRowAccess = pathPrefix(EntityKind.BOX_FORM.kind | EntityKind.BOX_TABLE.kind) { + pathPrefix(Segment) { table => + path("row-access") { + post { + complete(true) // no RLS on Box schema + } + } + } + } + val route = pathPrefix("access") { boxTableAccess ~ tableAccess ~ - rowAccess + rowAccess ~ + boxTableRowAccess } } diff --git a/shared/src/main/scala/ch/wsl/box/model/shared/WidgetsNames.scala b/shared/src/main/scala/ch/wsl/box/model/shared/WidgetsNames.scala index 3326a412..80523a9d 100755 --- a/shared/src/main/scala/ch/wsl/box/model/shared/WidgetsNames.scala +++ b/shared/src/main/scala/ch/wsl/box/model/shared/WidgetsNames.scala @@ -42,6 +42,7 @@ object WidgetsNames { val tableChild = "tableChild" val editableTable = "editableTable" val spreadsheet = "spreadsheet" + val export = "export" val h1 = "title_h1" val h2 = "title_h2" val h3 = "title_h3" @@ -117,7 +118,8 @@ object WidgetsNames { trasparentChild, dynamicWidget, popupWidget, - spreadsheet + spreadsheet, + `export` ), FILE -> Seq( simpleFile, From 69ad6dcb134659f155ff7ceb04a5dae315c66ac2 Mon Sep 17 00:00:00 2001 From: Andrea Minetti Date: Mon, 3 Aug 2026 10:28:46 +0200 Subject: [PATCH 2/7] Fixed styiling issues --- .../scala/ch/wsl/box/client/styles/BoxStyle.scala | 1 + .../ch/wsl/box/client/styles/GlobalStyles.scala | 13 +++++++++++-- .../ch/wsl/box/client/views/EntityFormView.scala | 2 +- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/client/src/main/scala/ch/wsl/box/client/styles/BoxStyle.scala b/client/src/main/scala/ch/wsl/box/client/styles/BoxStyle.scala index 036e0aa1..a2a05554 100644 --- a/client/src/main/scala/ch/wsl/box/client/styles/BoxStyle.scala +++ b/client/src/main/scala/ch/wsl/box/client/styles/BoxStyle.scala @@ -65,6 +65,7 @@ trait BoxStyle { val headerTitle: StyleA val linkHeaderFooter: StyleA val fullHeightMax: StyleA + val fullHeightMaxForm: StyleA val tableWrapper: StyleA val fullHeight: StyleA val loading: StyleA diff --git a/client/src/main/scala/ch/wsl/box/client/styles/GlobalStyles.scala b/client/src/main/scala/ch/wsl/box/client/styles/GlobalStyles.scala index 8a0b0b85..2d8b0cbb 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 @@ -831,7 +831,16 @@ class GlobalStyles(settings:Settings,conf:StyleConf) extends StyleSheet.Inline() override val fullHeightMax = style( - height :=! "calc(100vh - 150px)", + height :=! "calc(100vh - 201px)", + media.maxWidth(600 px)( + height :=! "calc(100vh - 110px)", + //paddingBottom(70 px) + ), + overflow.auto + ) + + override val fullHeightMaxForm = style( + height :=! "calc(100vh - 229px)", media.maxWidth(600 px)( height :=! "calc(100vh - 110px)", //paddingBottom(70 px) @@ -1447,7 +1456,7 @@ class GlobalStyles(settings:Settings,conf:StyleConf) extends StyleSheet.Inline() override val mapTable = style( - height :=! "calc(100vh - 105px)", + height :=! "calc(100vh - 201px)", media.maxWidth(600 px)( height :=! "calc(100vh - 50px)", ), 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 2003c00a..4f52f2b5 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 @@ -924,7 +924,7 @@ case class EntityFormView(model:ModelProperty[EntityFormModel], presenter:Entity if(showHeader && _form.isDefined) { formHeader(nested,showId,_form.get).render }, - div(BootstrapCol.md(12),if(showHeader) { ClientConf.style.fullHeightMax }, + div(BootstrapCol.md(12),if(showHeader) { ClientConf.style.fullHeightMaxForm }, _form match { case None => div() From 28063269260df8c41bb167cd2376d820bb78b444 Mon Sep 17 00:00:00 2001 From: Andrea Minetti Date: Tue, 4 Aug 2026 09:35:55 +0200 Subject: [PATCH 3/7] Fixed non db fields in column selection --- .../main/scala/ch/wsl/box/client/views/EntityTableView.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/main/scala/ch/wsl/box/client/views/EntityTableView.scala b/client/src/main/scala/ch/wsl/box/client/views/EntityTableView.scala index 9226ae2c..e620b71e 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 @@ -1033,7 +1033,7 @@ case class EntityTableView(model:ModelProperty[EntityTableModel], presenter:Enti sc.set(sc.get.flatMap{ f => if(f.title == oldPosition) Seq() - else if(f.title == newPosition) metadata.table.find(_.title == oldPosition) ++ Seq(f) + else if(f.title == newPosition) metadata.fields.find(_.title == oldPosition) ++ Seq(f) else Seq(f) }) From acb91021e90de61bd1059a0765f06aa705300dc3 Mon Sep 17 00:00:00 2001 From: Andrea Minetti Date: Tue, 4 Aug 2026 11:06:40 +0200 Subject: [PATCH 4/7] Removed sqlWhere in JSONQuery for security purposes --- .../components/widget/child/Spreadsheet.scala | 25 +++---------- .../scala/ch/wsl/box/db/SQLComposer.scala | 35 ++++++++++++++++--- .../ch/wsl/box/model/shared/JSONQuery.scala | 14 ++------ 3 files changed, 38 insertions(+), 36 deletions(-) diff --git a/client/src/main/scala/ch/wsl/box/client/views/components/widget/child/Spreadsheet.scala b/client/src/main/scala/ch/wsl/box/client/views/components/widget/child/Spreadsheet.scala index 38f530c7..13507019 100644 --- a/client/src/main/scala/ch/wsl/box/client/views/components/widget/child/Spreadsheet.scala +++ b/client/src/main/scala/ch/wsl/box/client/views/components/widget/child/Spreadsheet.scala @@ -86,32 +86,17 @@ object Spreadsheet extends ComponentWidgetFactory { case Some(value:JSONFieldLookupRemote) => value case None => throw new Exception("Not a remote lookup") } - val allColumnId: Seq[String] = rawData.map{ row => - val keys = fieldLookup.map.localKeysColumn.map { local => - val value = row.js(local).fold( - "null", - bool => bool.toString, - num => num.toString, - str => s"'$str'", - arr => arr.toString, - obj => obj.toString - ) - value + val allColumnId: Seq[Seq[Json]] = rawData.map{ row => + fieldLookup.map.localKeysColumn.map { local => + row.js(local) } - keys.mkString("(",",",")") } logger.debug(s"Loading dropdown for $allColumnId") - val query = - s""" - | ${fieldLookup.map.foreign.keyColumns.mkString("(",",",")")} in ${allColumnId.mkString("(",",",")")} - |""".stripMargin + val query = JSONQuery.filterWith(JSONQueryFilter.WHERE.inSet(fieldLookup.map.foreign.keyColumns,allColumnId)) - logger.debug(s"Generated where: $query") - val values = JSONQuery.where(query) - - services.rest.lookup(EntityKind.FORM.kind,services.clientSession.lang(),childMetadata.get.name,field.name,values).map{ rows => + services.rest.lookup(EntityKind.FORM.kind,services.clientSession.lang(),childMetadata.get.name,field.name,query).map{ rows => rows.map { row => row.id -> row.value.asJson }.toMap diff --git a/server/src/main/scala/ch/wsl/box/db/SQLComposer.scala b/server/src/main/scala/ch/wsl/box/db/SQLComposer.scala index 896b74d3..434c125c 100644 --- a/server/src/main/scala/ch/wsl/box/db/SQLComposer.scala +++ b/server/src/main/scala/ch/wsl/box/db/SQLComposer.scala @@ -96,8 +96,36 @@ trait SQLCompose extends Logging { .filter(_.nonEmpty) } + if(jsonQuery.operator.contains(Filter.IN_SET)) { + + val t = for{ + colsJS <- io.circe.parser.parse(jsonQuery.column) + cols <- colsJS.as[Seq[String]] + valsJs <- io.circe.parser.parse(jsonQuery.value.getOrElse("")) + vals <- valsJs.as[Seq[Seq[Json]]] + } yield (cols,vals) + + t match { + case Right((cols,vals)) => { + + val allColumnId = vals.map(_.map { v => + v.fold( + "null", + bool => bool.toString, + num => num.toString, + str => s"'$str'", + arr => arr.toString, + obj => obj.toString + ) + }).mkString("(",",",")") + + + Some(sql""" #${cols.mkString("(\"","\",\"","\")")} in #${allColumnId.mkString("(",",",")")} """) + } + case Left(value) => throw new Exception(s"Fields ${jsonQuery.column} cannot be parsed for inset. $value") + } - if(jsonQuery.operator.exists(o => Filter.multiEl.contains(o))) { + } else if(jsonQuery.operator.exists(o => Filter.multiEl.contains(o))) { col.name match { case "String" => filterMany(Some(splitAndTrim(v))) case "Int" => filterMany[Int](Some(splitAndTrim(v).flatMap(_.toIntOption))) @@ -218,14 +246,11 @@ trait SQLCompose extends Logging { val kv = jsonQueryComposer() // val nonEmptyFilters = query.filter.filter(isNonEmptyFilter) - val where = query.validatedWhere match { - case Some(whereClause) => sql""" where #${whereClause}""" - case None => { + val where = { val filters = query.filter.flatMap(kv) if (filters.nonEmpty) { filters.tail.foldLeft(concat(sql" where ", filters.head)) { case (builder, pair) => concat(builder, concat(sql" and ", pair)) } } else sql"" - } } 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 af9cf0b6..104b8e51 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 @@ -6,6 +6,7 @@ import scribe.Logging import io.circe._ import io.circe.parser._ import io.circe.generic.auto._ +import io.circe.syntax.EncoderOps //import ch.wsl.box.model.shared.JSONQuery.empty @@ -19,20 +20,11 @@ case class JSONQuery( filter:List[JSONQueryFilter], sort:List[JSONSort], paging:Option[JSONQueryPaging], - sqlWhere:Option[String] = None, fields:Option[Seq[String]] = None, lookups:Option[Seq[JSONFieldLookupRemote]] = None, fullText:Option[String] = None, ){ - def validatedWhere = sqlWhere.map(_.replaceAll("insert ","not valid") - .replaceAll("update ","not valid") - .replaceAll("truncate ","not valid") - .replaceAll("drop ","not valid") - .replaceAll("delete ","not valid") - .replaceAll("alter ","not valid") - .replaceAll("grant ","not valid") - ) def filterWith(filter:JSONQueryFilter*) = this.copy(filter = filter.toList) def sortWith(sort:JSONSort*) = this.copy(sort = sort.toList) @@ -127,6 +119,7 @@ object JSONQueryFilter{ def eq(column: String, value: String) = JSONQueryFilter.withValue(column, Some(Filter.EQUALS), value) def in(column: String, value: Seq[String]) = JSONQueryFilter.withValue(column, Some(Filter.IN), value.mkString(",")) + def inSet(columns: Seq[String], value: Seq[Seq[Json]]) = JSONQueryFilter.withValue(columns.asJson.toString(), Some(Filter.IN_SET), value.asJson.toString()) def notIn(column: String, value: Seq[String]) = JSONQueryFilter.withValue(column, Some(Filter.NOTIN), value.mkString(",")) def not(column: String, value: String) = JSONQueryFilter.withValue(column, Some(Filter.NOT), value) @@ -177,8 +170,6 @@ object JSONQuery extends Logging { paging = Some(JSONQueryPaging(1000)), ) - def where(sql:String) = empty.copy(sqlWhere = Some(sql)) - def filterWith(filter:JSONQueryFilter*) = empty.copy(filter = filter.toList) def sortWith(sort:JSONSort*) = empty.copy(sort = sort.toList) @@ -240,6 +231,7 @@ object Filter extends Logging { final val FK_LIKE = "FKlike" final val FK_DISLIKE = "FKdislike" final val IN = "in" + final val IN_SET = "in_set" final val NOTIN = "notin" final val BETWEEN = "between" final val IS_NULL = "isNull" From 482182d5882df24fe901b47cf24651c74e0a19af Mon Sep 17 00:00:00 2001 From: Andrea Minetti Date: Tue, 4 Aug 2026 11:44:49 +0200 Subject: [PATCH 5/7] Better keyboard shortcuts for editableTable --- .../widget/child/EditableTable.scala | 37 ++++++++++++++++--- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/client/src/main/scala/ch/wsl/box/client/views/components/widget/child/EditableTable.scala b/client/src/main/scala/ch/wsl/box/client/views/components/widget/child/EditableTable.scala index bfb8f2ba..e91aed3d 100644 --- a/client/src/main/scala/ch/wsl/box/client/views/components/widget/child/EditableTable.scala +++ b/client/src/main/scala/ch/wsl/box/client/views/components/widget/child/EditableTable.scala @@ -124,6 +124,7 @@ case class TableStyle(conf:StyleConf,columns:Int) extends StyleSheet.Inline { object EditableTable extends ChildRendererFactory { + val tableId = s"table-${UUID.randomUUID()}" override def name: String = WidgetsNames.editableTable @@ -412,17 +413,35 @@ object EditableTable extends ChildRendererFactory { case _ => None } + def setFocus(row:Int,col:Int):Boolean = Seq("select", "input").map { tagname => + document + .querySelector(s" #$tableId tr[data-row='$row'] > td[data-column='$col']") + .getElementsByTagName(tagname).headOption match { + case Some(e: dom.HTMLElement) => { + e.focus() + true + } + case _ => false + } + }.exists(x => x) + def select(offsetRow:Int,offsetCol:Int) = { for{ c <- column r <- row } yield { - Seq("select","input").foreach { tagname => - document - .querySelector(s"tr[data-row='${r + offsetRow}'] > td[data-column='${c + offsetCol}']") - .getElementsByTagName(tagname).headOption.foreach { case e: dom.HTMLElement => e.focus() } + if(r + offsetRow >= entity.length) { + addItem(child,metadata.get) + val observer = Some(new MutationObserver({ (mutations, observer) => { + if(setFocus(r + offsetRow,c + offsetCol)) + observer.disconnect() + } })) + observer.foreach(_.observe(document,MutationObserverInit(childList = true, subtree = true))) + } else { + setFocus(r + offsetRow,c + offsetCol) } + } e.stopImmediatePropagation() e.preventDefault() @@ -432,6 +451,14 @@ object EditableTable extends ChildRendererFactory { e match { case ke:KeyboardEvent if ke.key == "Enter" || ke.key == "ArrowDown" => select(1,0) case ke:KeyboardEvent if ke.key == "ArrowUp" => select(-1,0) + case ke:KeyboardEvent if ke.key == "Tab" => { + val lastRow = row.exists(r => r+1 == entity.length) + val lastCol = column.exists(c => c+1 == fields(metadata.get).length) + if(lastRow && lastCol) { + select(1,-column.getOrElse(0)) + } else true + + } case _ => true } } @@ -451,7 +478,7 @@ object EditableTable extends ChildRendererFactory { val additionalColumns = if (write && !disableRemove) 1 else 0 val colWidth = (width := _colWidth(additionalColumns)) - val tab = table(tableStyle.table, + val tab = table(id := tableId, tableStyle.table, thead( for (field <- f) yield { val name = colHeader(field) From 8d7f54bb36e82ea7140221facba79993fe1d14b4 Mon Sep 17 00:00:00 2001 From: Andrea Minetti Date: Tue, 4 Aug 2026 12:03:48 +0200 Subject: [PATCH 6/7] Tristate widget tab handling --- .../wsl/box/client/styles/GlobalStyles.scala | 3 ++ .../components/widget/TristateWidget.scala | 47 +++++++++++++++---- 2 files changed, 40 insertions(+), 10 deletions(-) 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 2d8b0cbb..e9fdf0ab 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 @@ -1522,6 +1522,9 @@ class GlobalStyles(settings:Settings,conf:StyleConf) extends StyleSheet.Inline() unsafeChild("svg")( marginTop(-4 px), svgStroke(c"#fff") + ), + &.focus( + borderColor(conf.colors.main).important ) ) diff --git a/client/src/main/scala/ch/wsl/box/client/views/components/widget/TristateWidget.scala b/client/src/main/scala/ch/wsl/box/client/views/components/widget/TristateWidget.scala index d5c4d415..05bab67d 100644 --- a/client/src/main/scala/ch/wsl/box/client/views/components/widget/TristateWidget.scala +++ b/client/src/main/scala/ch/wsl/box/client/views/components/widget/TristateWidget.scala @@ -1,5 +1,6 @@ package ch.wsl.box.client.views.components.widget import ch.wsl.box.client.services.ClientConf +import ch.wsl.box.client.utils.ListenerManager import io.circe._ import io.circe.syntax._ import io.udash._ @@ -12,12 +13,19 @@ import io.udash.bindings.modifiers.Binding import io.udash.bootstrap.tooltip.UdashTooltip import scalacss.ScalatagsCss._ import io.udash.css._ -import org.scalajs.dom.Event +import org.scalajs.dom.{Event, KeyboardEvent} case class TristateWidget(field:JSONField, data: Property[Json]) extends Widget with IsCheckBoxWithData { val noLabel = field.params.exists(_.js("nolabel") == true.asJson) + val lm = new ListenerManager() + import lm._ + + override def killWidget(): Unit = { + lm.clearAll() + } + def jsToBool(json:Json):Option[Boolean] = field.`type` match { case JSONFieldTypes.BOOLEAN => json.asBoolean case JSONFieldTypes.NUMBER => json.asNumber.flatMap(_.toInt).map(_ == 1) @@ -34,12 +42,25 @@ case class TristateWidget(field:JSONField, data: Property[Json]) extends Widget case _ => Json.Null } + + def tristateCheckbox(booleanModel:Property[Option[Boolean]]) = { + + def rotate() = { + booleanModel.set{ + booleanModel.get match { + case Some(true) => Some(false) + case Some(false) => None + case None =>Some(true) + }} + } + // https://carsonf92.medium.com/introducing-the-three-state-checkbox-1b6f00b6ec89 val positive = raw("") val negative = raw("") val checkbox = span( + tabindex := 0, `class`.bind(booleanModel.transform { case Some(true) => Seq(ClientConf.style.tristateCheckBox.htmlClass,ClientConf.style.tristatePositive.htmlClass).mkString(" ") case Some(false) => Seq(ClientConf.style.tristateCheckBox.htmlClass,ClientConf.style.tristateNegative.htmlClass).mkString(" ") @@ -51,15 +72,21 @@ case class TristateWidget(field:JSONField, data: Property[Json]) extends Widget case None => span().render }, onclick :+= {(e:Event) => - booleanModel.set{ - booleanModel.get match { - case Some(true) => Some(false) - case Some(false) => None - case None =>Some(true) - }} + e.preventDefault() } - ) + ).render + + checkbox.listen("click", _ => rotate()) + checkbox.listen("keydown",{ + case ke: KeyboardEvent if ke.key == " " => { + rotate() + ke.stopPropagation() + ke.preventDefault() + } + case _ => true + }) + checkbox } @@ -75,7 +102,7 @@ case class TristateWidget(field:JSONField, data: Property[Json]) extends Widget div( div(ClientConf.style.label50,if(!noLabel) { WidgetUtils.toLabel(field,WidgetUtils.LabelRight) } else frag()), - tooltip(tristateCheckbox(booleanModel).render)._1 + tooltip(tristateCheckbox(booleanModel))._1 ) } @@ -83,7 +110,7 @@ case class TristateWidget(field:JSONField, data: Property[Json]) extends Widget val booleanModel:Property[Option[Boolean]] = Property(None) autoRelease(data.sync[Option[Boolean]](booleanModel)(js => jsToBool(js),bool => boolToJson(bool))) - tristateCheckbox(booleanModel).render + tristateCheckbox(booleanModel) } override def json(): _root_.io.udash.ReadableProperty[Json] = data From 19cc34d476da50f94661b97ddf1cddad887e029e Mon Sep 17 00:00:00 2001 From: Andrea Minetti Date: Tue, 4 Aug 2026 13:54:02 +0200 Subject: [PATCH 7/7] Improved UX of EditableTable --- .../views/components/widget/child/EditableTable.scala | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/client/src/main/scala/ch/wsl/box/client/views/components/widget/child/EditableTable.scala b/client/src/main/scala/ch/wsl/box/client/views/components/widget/child/EditableTable.scala index e91aed3d..a8c362d6 100644 --- a/client/src/main/scala/ch/wsl/box/client/views/components/widget/child/EditableTable.scala +++ b/client/src/main/scala/ch/wsl/box/client/views/components/widget/child/EditableTable.scala @@ -124,7 +124,7 @@ case class TableStyle(conf:StyleConf,columns:Int) extends StyleSheet.Inline { object EditableTable extends ChildRendererFactory { - val tableId = s"table-${UUID.randomUUID()}" + override def name: String = WidgetsNames.editableTable @@ -134,6 +134,9 @@ object EditableTable extends ChildRendererFactory { case class EditableTableRenderer(widgetParam:WidgetParams) extends ChildRenderer { + + val tableId = s"table-${UUID.randomUUID()}" + val parentMetadata = widgetParam.metadata import ch.wsl.box.client.Context._ @@ -449,7 +452,7 @@ object EditableTable extends ChildRendererFactory { } e match { - case ke:KeyboardEvent if ke.key == "Enter" || ke.key == "ArrowDown" => select(1,0) + case ke:KeyboardEvent if (ke.key == "Enter" && !column.exists(c => fields(metadata.get).lift(c).exists(_.lookup.isDefined))) || ke.key == "ArrowDown" => select(1,0) case ke:KeyboardEvent if ke.key == "ArrowUp" => select(-1,0) case ke:KeyboardEvent if ke.key == "Tab" => { val lastRow = row.exists(r => r+1 == entity.length)