From 85476f8b3199d6ab1f57e1730040a43732f236dd Mon Sep 17 00:00:00 2001 From: Florian M Date: Tue, 11 Jun 2024 14:08:42 +0200 Subject: [PATCH 01/79] WIP: user-defined key-value properties for skeletons --- .../proto/SkeletonTracing.proto | 2 ++ .../proto/UserDefinedProperties.proto | 12 +++++++ .../tracings/UserDefinedProperty.scala | 36 +++++++++++++++++++ .../updating/SkeletonUpdateActions.scala | 12 ++++--- 4 files changed, 58 insertions(+), 4 deletions(-) create mode 100644 webknossos-datastore/proto/UserDefinedProperties.proto create mode 100644 webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/UserDefinedProperty.scala diff --git a/webknossos-datastore/proto/SkeletonTracing.proto b/webknossos-datastore/proto/SkeletonTracing.proto index fbad84d3beb..f8a4e454c88 100644 --- a/webknossos-datastore/proto/SkeletonTracing.proto +++ b/webknossos-datastore/proto/SkeletonTracing.proto @@ -3,6 +3,7 @@ syntax = "proto2"; package com.scalableminds.webknossos.datastore; import "geometry.proto"; +import "UserDefinedProperties.proto"; message Node { required int32 id = 1; @@ -15,6 +16,7 @@ message Node { required bool interpolation = 8; required int64 createdTimestamp = 9; repeated AdditionalCoordinateProto additionalCoordinates = 10; + repeated UserDefinedPropertyProto userDefinedProperties = 11; } message Edge { diff --git a/webknossos-datastore/proto/UserDefinedProperties.proto b/webknossos-datastore/proto/UserDefinedProperties.proto new file mode 100644 index 00000000000..56670262b67 --- /dev/null +++ b/webknossos-datastore/proto/UserDefinedProperties.proto @@ -0,0 +1,12 @@ +syntax = "proto2"; + +package com.scalableminds.webknossos.datastore; + +// Use exactly one of the value fields! +message UserDefinedPropertyProto { + required string key = 1; + optional string stringValue = 2; + optional bool boolValue = 3; + optional double numberValue = 4; + repeated string stringListValue = 6; +} diff --git a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/UserDefinedProperty.scala b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/UserDefinedProperty.scala new file mode 100644 index 00000000000..09bb5d6171e --- /dev/null +++ b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/UserDefinedProperty.scala @@ -0,0 +1,36 @@ +package com.scalableminds.webknossos.tracingstore.tracings + +import com.scalableminds.webknossos.datastore.UserDefinedProperties.UserDefinedPropertyProto +import play.api.libs.json.{Json, OFormat} +import play.api.libs.json.Json.WithDefaultValues + +case class UserDefinedProperty(key: String, + stringValue: Option[String] = None, + boolValue: Option[Boolean] = None, + numberValue: Option[Double] = None, + stringListValue: Option[Seq[String]] = None) { + def toProto: UserDefinedPropertyProto = UserDefinedPropertyProto( + key, + stringValue, + boolValue, + numberValue, + stringListValue.getOrElse(Seq.empty) + ) +} + +object UserDefinedProperty { + def fromProto(propertyProto: UserDefinedPropertyProto): UserDefinedProperty = + UserDefinedProperty( + propertyProto.key, + propertyProto.stringValue, + propertyProto.boolValue, + propertyProto.numberValue, + if (propertyProto.stringListValue.isEmpty) None else Some(propertyProto.stringListValue) + ) + + def toProtoMultiple(propertiesOpt: Option[Seq[UserDefinedProperty]]): Seq[UserDefinedPropertyProto] = + propertiesOpt.map(_.map(_.toProto)).getOrElse(Seq.empty) + + implicit val jsonFormat: OFormat[UserDefinedProperty] = + Json.using[WithDefaultValues].format[UserDefinedProperty] +} diff --git a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/skeleton/updating/SkeletonUpdateActions.scala b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/skeleton/updating/SkeletonUpdateActions.scala index 1dd5f64d5d9..f74d7a22767 100644 --- a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/skeleton/updating/SkeletonUpdateActions.scala +++ b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/skeleton/updating/SkeletonUpdateActions.scala @@ -217,7 +217,8 @@ case class CreateNodeSkeletonAction(id: Int, actionTimestamp: Option[Long] = None, actionAuthorId: Option[String] = None, info: Option[String] = None, - additionalCoordinates: Option[Seq[AdditionalCoordinate]] = None) + additionalCoordinates: Option[Seq[AdditionalCoordinate]] = None, + userDefinedProperties: Option[Seq[UserDefinedProperty]] = None) extends UpdateAction.SkeletonUpdateAction with SkeletonUpdateActionHelper with ProtoGeometryImplicits { @@ -233,7 +234,8 @@ case class CreateNodeSkeletonAction(id: Int, bitDepth getOrElse NodeDefaults.bitDepth, interpolation getOrElse NodeDefaults.interpolation, createdTimestamp = timestamp, - additionalCoordinates = AdditionalCoordinate.toProto(additionalCoordinates) + additionalCoordinates = AdditionalCoordinate.toProto(additionalCoordinates), + userDefinedProperties = UserDefinedProperty.toProtoMultiple(userDefinedProperties) ) def treeTransform(tree: Tree) = tree.withNodes(newNode +: tree.nodes) @@ -261,7 +263,8 @@ case class UpdateNodeSkeletonAction(id: Int, actionTimestamp: Option[Long] = None, actionAuthorId: Option[String] = None, info: Option[String] = None, - additionalCoordinates: Option[Seq[AdditionalCoordinate]] = None) + additionalCoordinates: Option[Seq[AdditionalCoordinate]] = None, + userDefinedProperties: Option[Seq[UserDefinedProperty]] = None) extends UpdateAction.SkeletonUpdateAction with SkeletonUpdateActionHelper with ProtoGeometryImplicits { @@ -278,7 +281,8 @@ case class UpdateNodeSkeletonAction(id: Int, bitDepth getOrElse NodeDefaults.bitDepth, interpolation getOrElse NodeDefaults.interpolation, createdTimestamp = timestamp, - additionalCoordinates = AdditionalCoordinate.toProto(additionalCoordinates) + additionalCoordinates = AdditionalCoordinate.toProto(additionalCoordinates), + userDefinedProperties = UserDefinedProperty.toProtoMultiple(userDefinedProperties) ) def treeTransform(tree: Tree) = From 3e8fcb1d58411bf1e928e4322dc4c139eaf7c7c8 Mon Sep 17 00:00:00 2001 From: Florian M Date: Tue, 11 Jun 2024 15:19:51 +0200 Subject: [PATCH 02/79] adapt UpdateNode test --- .../SkeletonUpdateActionsUnitTestSuite.scala | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/test/backend/SkeletonUpdateActionsUnitTestSuite.scala b/test/backend/SkeletonUpdateActionsUnitTestSuite.scala index e0a9a749727..f3e4d1bfa03 100644 --- a/test/backend/SkeletonUpdateActionsUnitTestSuite.scala +++ b/test/backend/SkeletonUpdateActionsUnitTestSuite.scala @@ -1,7 +1,8 @@ package backend -import com.scalableminds.util.geometry.{Vec3Int, Vec3Double} +import com.scalableminds.util.geometry.{Vec3Double, Vec3Int} import com.scalableminds.webknossos.datastore.SkeletonTracing._ +import com.scalableminds.webknossos.datastore.UserDefinedProperties.UserDefinedPropertyProto import com.scalableminds.webknossos.tracingstore.tracings._ import com.scalableminds.webknossos.tracingstore.tracings.skeleton.updating._ import org.scalatestplus.play._ @@ -167,7 +168,11 @@ class SkeletonUpdateActionsUnitTestSuite extends PlaySpec { "UpdateNodeSkeletonAction" should { "update the specified node" in { - val newNode = Dummies.createDummyNode(1) + val newNode = Dummies + .createDummyNode(1) + .copy( + userDefinedProperties = List(UserDefinedPropertyProto("myKey", numberValue = Some(5.0)), + UserDefinedPropertyProto("anotherKey", stringListValue = Seq("hello", "there")))) val updateNodeSkeletonAction = new UpdateNodeSkeletonAction( newNode.id, Vec3Int(newNode.position.x, newNode.position.y, newNode.position.z), @@ -178,7 +183,11 @@ class SkeletonUpdateActionsUnitTestSuite extends PlaySpec { Option(newNode.bitDepth), Option(newNode.interpolation), treeId = 1, - Dummies.timestamp + Dummies.timestamp, + None, + userDefinedProperties = Some( + List(UserDefinedProperty("myKey", numberValue = Some(5.0)), + UserDefinedProperty("anotherKey", stringListValue = Some(Seq("hello", "there"))))) ) val result = applyUpdateAction(updateNodeSkeletonAction) assert(result.trees.length == Dummies.skeletonTracing.trees.length) From b08384eb2cdc3d38b16fb92fa309dfe3a7c3de3a Mon Sep 17 00:00:00 2001 From: Florian M Date: Wed, 12 Jun 2024 09:50:45 +0200 Subject: [PATCH 03/79] also for trees,segments. add to backend NMLWriter --- app/models/annotation/nml/NmlWriter.scala | 22 +++++++++++ .../annotations.e2e.js.md | 35 ++++++++++++++++++ .../annotations.e2e.js.snap | Bin 14045 -> 14490 bytes .../proto/SkeletonTracing.proto | 1 + .../proto/VolumeTracing.proto | 2 + .../updating/SkeletonUpdateActions.scala | 12 ++++-- .../tracings/volume/VolumeUpdateActions.scala | 28 ++++++++------ 7 files changed, 85 insertions(+), 15 deletions(-) diff --git a/app/models/annotation/nml/NmlWriter.scala b/app/models/annotation/nml/NmlWriter.scala index 8e0979486ef..dbd503eb4a2 100644 --- a/app/models/annotation/nml/NmlWriter.scala +++ b/app/models/annotation/nml/NmlWriter.scala @@ -6,6 +6,7 @@ import com.scalableminds.util.time.Instant import com.scalableminds.util.tools.{Fox, FoxImplicits} import com.scalableminds.util.xml.Xml import com.scalableminds.webknossos.datastore.SkeletonTracing._ +import com.scalableminds.webknossos.datastore.UserDefinedProperties.UserDefinedPropertyProto import com.scalableminds.webknossos.datastore.VolumeTracing.{Segment, SegmentGroup} import com.scalableminds.webknossos.datastore.geometry._ import com.scalableminds.webknossos.datastore.models.annotation.{AnnotationLayerType, FetchedAnnotationLayer} @@ -296,10 +297,29 @@ class NmlWriter @Inject()(implicit ec: ExecutionContext) extends FoxImplicits { } s.color.foreach(_ => writeColor(s.color)) s.groupId.foreach(groupId => writer.writeAttribute("groupId", groupId.toString)) + s.userDefinedProperties.foreach(writeUserDefinedProperty) } } } + private def writeUserDefinedProperty(p: UserDefinedPropertyProto)(implicit writer: XMLStreamWriter): Unit = + Xml.withinElementSync("userDefinedProperty") { + writer.writeAttribute("key", p.key) + p.stringValue.foreach { v => + writer.writeAttribute("stringValue", v) + } + p.boolValue.foreach { v => + writer.writeAttribute("boolValue", v.toString) + } + p.numberValue.foreach { v => + writer.writeAttribute("numberValue", v.toString) + } + p.stringListValue.zipWithIndex.foreach { + case (v, index) => + writer.writeAttribute(s"stringListValue_$index", v) + } + } + private def writeSkeletonThings(skeletonTracing: SkeletonTracing)(implicit writer: XMLStreamWriter): Unit = { writeTreesAsXml(skeletonTracing.trees) Xml.withinElementSync("branchpoints")( @@ -318,6 +338,7 @@ class NmlWriter @Inject()(implicit ec: ExecutionContext) extends FoxImplicits { t.`type`.foreach(t => writer.writeAttribute("type", t.toString)) Xml.withinElementSync("nodes")(writeNodesAsXml(t.nodes.sortBy(_.id))) Xml.withinElementSync("edges")(writeEdgesAsXml(t.edges)) + t.userDefinedProperties.foreach(writeUserDefinedProperty) } } @@ -338,6 +359,7 @@ class NmlWriter @Inject()(implicit ec: ExecutionContext) extends FoxImplicits { writer.writeAttribute("interpolation", n.interpolation.toString) writer.writeAttribute("time", n.createdTimestamp.toString) n.additionalCoordinates.foreach(writeAdditionalCoordinateValue) + n.userDefinedProperties.foreach(writeUserDefinedProperty) } } diff --git a/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/annotations.e2e.js.md b/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/annotations.e2e.js.md index 2faf8847bf1..3686c89748a 100644 --- a/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/annotations.e2e.js.md +++ b/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/annotations.e2e.js.md @@ -1497,6 +1497,7 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, + userDefinedProperties: [], viewport: 1, }, { @@ -1517,6 +1518,7 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, + userDefinedProperties: [], viewport: 1, }, { @@ -1537,6 +1539,7 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, + userDefinedProperties: [], viewport: 1, }, { @@ -1557,6 +1560,7 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, + userDefinedProperties: [], viewport: 1, }, { @@ -1577,6 +1581,7 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, + userDefinedProperties: [], viewport: 1, }, { @@ -1597,11 +1602,13 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, + userDefinedProperties: [], viewport: 1, }, ], treeId: 5, type: 'DEFAULT', + userDefinedProperties: [], }, { branchPoints: [], @@ -1657,6 +1664,7 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, + userDefinedProperties: [], viewport: 1, }, { @@ -1677,6 +1685,7 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, + userDefinedProperties: [], viewport: 1, }, { @@ -1697,6 +1706,7 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, + userDefinedProperties: [], viewport: 1, }, { @@ -1717,6 +1727,7 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, + userDefinedProperties: [], viewport: 1, }, { @@ -1737,6 +1748,7 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, + userDefinedProperties: [], viewport: 1, }, { @@ -1757,11 +1769,13 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, + userDefinedProperties: [], viewport: 1, }, ], treeId: 4, type: 'DEFAULT', + userDefinedProperties: [], }, { branchPoints: [], @@ -1817,6 +1831,7 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, + userDefinedProperties: [], viewport: 1, }, { @@ -1837,6 +1852,7 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, + userDefinedProperties: [], viewport: 1, }, { @@ -1857,6 +1873,7 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, + userDefinedProperties: [], viewport: 1, }, { @@ -1877,6 +1894,7 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, + userDefinedProperties: [], viewport: 1, }, { @@ -1897,6 +1915,7 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, + userDefinedProperties: [], viewport: 1, }, { @@ -1917,11 +1936,13 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, + userDefinedProperties: [], viewport: 1, }, ], treeId: 3, type: 'DEFAULT', + userDefinedProperties: [], }, { branchPoints: [], @@ -1977,6 +1998,7 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, + userDefinedProperties: [], viewport: 1, }, { @@ -1997,6 +2019,7 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, + userDefinedProperties: [], viewport: 1, }, { @@ -2017,6 +2040,7 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, + userDefinedProperties: [], viewport: 1, }, { @@ -2037,6 +2061,7 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, + userDefinedProperties: [], viewport: 1, }, { @@ -2057,6 +2082,7 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, + userDefinedProperties: [], viewport: 1, }, { @@ -2077,11 +2103,13 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, + userDefinedProperties: [], viewport: 1, }, ], treeId: 2, type: 'DEFAULT', + userDefinedProperties: [], }, { branchPoints: [], @@ -2137,6 +2165,7 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, + userDefinedProperties: [], viewport: 1, }, { @@ -2157,6 +2186,7 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, + userDefinedProperties: [], viewport: 1, }, { @@ -2177,6 +2207,7 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, + userDefinedProperties: [], viewport: 1, }, { @@ -2197,6 +2228,7 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, + userDefinedProperties: [], viewport: 1, }, { @@ -2217,6 +2249,7 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, + userDefinedProperties: [], viewport: 1, }, { @@ -2237,11 +2270,13 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, + userDefinedProperties: [], viewport: 1, }, ], treeId: 1, type: 'DEFAULT', + userDefinedProperties: [], }, ], typ: 'Skeleton', diff --git a/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/annotations.e2e.js.snap b/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/annotations.e2e.js.snap index c0155c71b7cd9b09e5b3fcc12ba5f7d42eabc7b0..1b72bb5c6990b485d7b5c1075cdb24b783862da8 100644 GIT binary patch literal 14490 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 Date: Wed, 12 Jun 2024 10:24:24 +0200 Subject: [PATCH 04/79] parse string list values too --- app/models/annotation/nml/NmlParser.scala | 40 +++++++++++++++++++++-- app/models/annotation/nml/NmlWriter.scala | 2 +- test/backend/Dummies.scala | 28 ++++++++++------ 3 files changed, 56 insertions(+), 14 deletions(-) diff --git a/app/models/annotation/nml/NmlParser.scala b/app/models/annotation/nml/NmlParser.scala index cba08ac7159..57758fda5b2 100755 --- a/app/models/annotation/nml/NmlParser.scala +++ b/app/models/annotation/nml/NmlParser.scala @@ -4,6 +4,7 @@ import com.scalableminds.util.geometry.{BoundingBox, Vec3Double, Vec3Int} import com.scalableminds.util.tools.ExtendedTypes.{ExtendedDouble, ExtendedString} import com.scalableminds.util.tools.JsonHelper.bool2Box import com.scalableminds.webknossos.datastore.SkeletonTracing._ +import com.scalableminds.webknossos.datastore.UserDefinedProperties.UserDefinedPropertyProto import com.scalableminds.webknossos.datastore.VolumeTracing.{Segment, SegmentGroup, VolumeTracing} import com.scalableminds.webknossos.datastore.geometry.{ AdditionalAxisProto, @@ -209,6 +210,7 @@ object NmlParser extends LazyLogging with ProtoGeometryImplicits with ColorGener case _ => None } val anchorPositionAdditionalCoordinates = parseAdditionalCoordinateValues(node) + val userDefinedProperties = parseUserDefinedProperties(node \ "userDefinedProperty") Segment( segmentId = getSingleAttribute(node, "id").toLong, anchorPosition = anchorPosition, @@ -216,10 +218,38 @@ object NmlParser extends LazyLogging with ProtoGeometryImplicits with ColorGener creationTime = getSingleAttributeOpt(node, "created").flatMap(_.toLongOpt), color = parseColorOpt(node), groupId = getSingleAttribute(node, "groupId").toIntOpt, - anchorPositionAdditionalCoordinates = anchorPositionAdditionalCoordinates + anchorPositionAdditionalCoordinates = anchorPositionAdditionalCoordinates, + userDefinedProperties = userDefinedProperties ) }) + private def parseUserDefinedProperties(userDefinedPropertyNodes: NodeSeq): Seq[UserDefinedPropertyProto] = + userDefinedPropertyNodes.map(node => { + UserDefinedPropertyProto( + getSingleAttribute(node, "key"), + getSingleAttributeOpt(node, "stringValue"), + getSingleAttributeOpt(node, "boolValue").flatMap(_.toBooleanOpt), + getSingleAttributeOpt(node, "numberValue").flatMap(_.toDoubleOpt), + parseStringListValue(node) + ) + }) + + private def parseStringListValue(node: XMLNode): Seq[String] = { + val regex = "^stringListValue-(\\d+)".r + val valuesWithIndex: Seq[(Int, String)] = node.attributes.flatMap { + case attribute: Attribute => + attribute.key match { + case regex(indexStr) => + indexStr.toIntOpt.map { index => + (index, attribute.value.toString) + } + case _ => None + } + case _ => None + }.toSeq + valuesWithIndex.sortBy(_._1).map(_._2) + } + private def parseTrees(treeNodes: NodeSeq, branchPoints: Map[Int, List[BranchPoint]], comments: Map[Int, List[Comment]])(implicit m: MessagesProvider) = @@ -413,6 +443,7 @@ object NmlParser extends LazyLogging with ProtoGeometryImplicits with ColorGener nodeIds = nodes.map(_.id) treeBranchPoints = nodeIds.flatMap(nodeId => branchPoints.getOrElse(nodeId, List())) treeComments = nodeIds.flatMap(nodeId => comments.getOrElse(nodeId, List())) + userDefinedProperties = parseUserDefinedProperties(tree \ "userDefinedProperty") createdTimestamp = if (nodes.isEmpty) System.currentTimeMillis() else nodes.minBy(_.createdTimestamp).createdTimestamp } yield @@ -426,7 +457,8 @@ object NmlParser extends LazyLogging with ProtoGeometryImplicits with ColorGener createdTimestamp, groupId, isVisible, - treeType) + treeType, + userDefinedProperties = userDefinedProperties) } private def parseComments(comments: NodeSeq)(implicit m: MessagesProvider): Box[List[Comment]] = @@ -512,6 +544,7 @@ object NmlParser extends LazyLogging with ProtoGeometryImplicits with ColorGener val bitDepth = parseBitDepth(node) val interpolation = parseInterpolation(node) val rotation = parseRotationForNode(node).getOrElse(NodeDefaults.rotation) + val userDefinedProperties = parseUserDefinedProperties(node \ "userDefinedProperty") Node(id, position, rotation, @@ -521,7 +554,8 @@ object NmlParser extends LazyLogging with ProtoGeometryImplicits with ColorGener bitDepth, interpolation, timestamp, - additionalCoordinates) + additionalCoordinates, + userDefinedProperties = userDefinedProperties) } } diff --git a/app/models/annotation/nml/NmlWriter.scala b/app/models/annotation/nml/NmlWriter.scala index dbd503eb4a2..b7103bda609 100644 --- a/app/models/annotation/nml/NmlWriter.scala +++ b/app/models/annotation/nml/NmlWriter.scala @@ -316,7 +316,7 @@ class NmlWriter @Inject()(implicit ec: ExecutionContext) extends FoxImplicits { } p.stringListValue.zipWithIndex.foreach { case (v, index) => - writer.writeAttribute(s"stringListValue_$index", v) + writer.writeAttribute(s"stringListValue-$index", v) } } diff --git a/test/backend/Dummies.scala b/test/backend/Dummies.scala index 50b497b99ae..0bfeb514362 100644 --- a/test/backend/Dummies.scala +++ b/test/backend/Dummies.scala @@ -1,6 +1,7 @@ package backend import com.scalableminds.webknossos.datastore.SkeletonTracing._ +import com.scalableminds.webknossos.datastore.UserDefinedProperties.UserDefinedPropertyProto import com.scalableminds.webknossos.datastore.VolumeTracing.{Segment, VolumeTracing} import com.scalableminds.webknossos.datastore.VolumeTracing.VolumeTracing.ElementClassProto import com.scalableminds.webknossos.datastore.geometry.{BoundingBoxProto, ColorProto, Vec3DoubleProto, Vec3IntProto} @@ -10,15 +11,17 @@ object Dummies { val timestampLong = 123456789L def createDummyNode(id: Int): Node = - Node(id, - Vec3IntProto(id, id + 1, id + 2), - Vec3DoubleProto(id, id + 1, id + 2), - id.toFloat, - 1, - 10, - 8, - id % 2 == 0, - timestamp) + Node( + id, + Vec3IntProto(id, id + 1, id + 2), + Vec3DoubleProto(id, id + 1, id + 2), + id.toFloat, + 1, + 10, + 8, + id % 2 == 0, + timestamp + ) val tree1: Tree = Tree( 1, @@ -30,7 +33,12 @@ object Dummies { "TestTree-1", timestamp, None, - Some(true) + Some(true), + userDefinedProperties = Seq( + UserDefinedPropertyProto("aKey", numberValue = Some(5.7)), + UserDefinedPropertyProto("anotherKey", boolValue = Some(true)), + UserDefinedPropertyProto("aThirdKey", stringListValue = Seq("multiple", "strings")) + ) ) val tree2: Tree = Tree( From 3a629917e6432d571dc8b5b966eae560ae4af594 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Mon, 19 Aug 2024 17:17:28 +0200 Subject: [PATCH 05/79] allow to create string tags for segments; styling tweaks --- .../javascripts/components/fast_tooltip.tsx | 3 ++ .../model/reducers/volumetracing_reducer.ts | 3 +- .../oxalis/model/sagas/update_actions.ts | 9 +++- .../oxalis/model/sagas/volumetracing_saga.tsx | 2 + frontend/javascripts/oxalis/store.ts | 2 + .../view/components/editable_text_label.tsx | 5 +- .../segments_tab/segment_list_item.tsx | 54 ++++++++++++++++++- frontend/javascripts/types/api_flow_types.ts | 8 +++ frontend/stylesheets/dark.less | 4 ++ 9 files changed, 85 insertions(+), 5 deletions(-) diff --git a/frontend/javascripts/components/fast_tooltip.tsx b/frontend/javascripts/components/fast_tooltip.tsx index f3ff709bcfe..fbb5cfeec27 100644 --- a/frontend/javascripts/components/fast_tooltip.tsx +++ b/frontend/javascripts/components/fast_tooltip.tsx @@ -59,6 +59,7 @@ export default function FastTooltip({ onMouseLeave, wrapper, html, + className, style, dynamicRenderer, }: { @@ -70,6 +71,7 @@ export default function FastTooltip({ onMouseLeave?: () => void; wrapper?: "div" | "span" | "p" | "tr"; // Any valid HTML tag, span by default. html?: string | null | undefined; + className?: string; // class name attached to the wrapper style?: React.CSSProperties; // style attached to the wrapper dynamicRenderer?: () => React.ReactElement; }) { @@ -108,6 +110,7 @@ export default function FastTooltip({ data-unique-key={uniqueKeyForDynamic} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} + className={className} style={style} > {children} diff --git a/frontend/javascripts/oxalis/model/reducers/volumetracing_reducer.ts b/frontend/javascripts/oxalis/model/reducers/volumetracing_reducer.ts index 4cfbd27d0fa..2462291e159 100644 --- a/frontend/javascripts/oxalis/model/reducers/volumetracing_reducer.ts +++ b/frontend/javascripts/oxalis/model/reducers/volumetracing_reducer.ts @@ -183,7 +183,7 @@ function handleUpdateSegment(state: OxalisState, action: UpdateSegmentAction) { // without a position. } - const newSegment = { + const newSegment: Segment = { // If oldSegment exists, its creationTime will be // used by ...oldSegment creationTime: action.timestamp, @@ -191,6 +191,7 @@ function handleUpdateSegment(state: OxalisState, action: UpdateSegmentAction) { color: null, groupId: null, someAdditionalCoordinates: someAdditionalCoordinates, + userDefinedProperties: [], ...oldSegment, ...segment, somePosition, diff --git a/frontend/javascripts/oxalis/model/sagas/update_actions.ts b/frontend/javascripts/oxalis/model/sagas/update_actions.ts index 7df4a0179e2..019e9d1f280 100644 --- a/frontend/javascripts/oxalis/model/sagas/update_actions.ts +++ b/frontend/javascripts/oxalis/model/sagas/update_actions.ts @@ -10,7 +10,7 @@ import type { NumberLike, } from "oxalis/store"; import { convertUserBoundingBoxesFromFrontendToServer } from "oxalis/model/reducers/reducer_helpers"; -import { AdditionalCoordinate } from "types/api_flow_types"; +import { AdditionalCoordinate, UserDefinedProperty } from "types/api_flow_types"; export type NodeWithTreeId = { treeId: number; @@ -317,8 +317,11 @@ export function createSegmentVolumeAction( name: string | null | undefined, color: Vector3 | null, groupId: number | null | undefined, + userDefinedProperties: UserDefinedProperty[], creationTime: number | null | undefined = Date.now(), ) { + // todop: validate here? + return { name: "createSegment", value: { @@ -327,10 +330,12 @@ export function createSegmentVolumeAction( name, color, groupId, + userDefinedProperties, creationTime, }, } as const; } + export function updateSegmentVolumeAction( id: number, anchorPosition: Vector3 | null | undefined, @@ -338,6 +343,7 @@ export function updateSegmentVolumeAction( name: string | null | undefined, color: Vector3 | null, groupId: number | null | undefined, + userDefinedProperties: Array, creationTime: number | null | undefined = Date.now(), ) { return { @@ -349,6 +355,7 @@ export function updateSegmentVolumeAction( name, color, groupId, + userDefinedProperties, creationTime, }, } as const; diff --git a/frontend/javascripts/oxalis/model/sagas/volumetracing_saga.tsx b/frontend/javascripts/oxalis/model/sagas/volumetracing_saga.tsx index 76923a1ca0f..88c76f38fd6 100644 --- a/frontend/javascripts/oxalis/model/sagas/volumetracing_saga.tsx +++ b/frontend/javascripts/oxalis/model/sagas/volumetracing_saga.tsx @@ -661,6 +661,7 @@ function* uncachedDiffSegmentLists( segment.name, segment.color, segment.groupId, + segment.userDefinedProperties, ); } @@ -676,6 +677,7 @@ function* uncachedDiffSegmentLists( segment.name, segment.color, segment.groupId, + segment.userDefinedProperties, segment.creationTime, ); } diff --git a/frontend/javascripts/oxalis/store.ts b/frontend/javascripts/oxalis/store.ts index f449ef51634..e7195fd7895 100644 --- a/frontend/javascripts/oxalis/store.ts +++ b/frontend/javascripts/oxalis/store.ts @@ -26,6 +26,7 @@ import type { APIUserCompact, AdditionalCoordinate, AdditionalAxis, + UserDefinedProperty, } from "types/api_flow_types"; import type { TracingStats } from "oxalis/model/accessors/annotation_accessor"; import type { Action } from "oxalis/model/actions/actions"; @@ -231,6 +232,7 @@ export type Segment = { readonly creationTime: number | null | undefined; readonly color: Vector3 | null; readonly groupId: number | null | undefined; + readonly userDefinedProperties: UserDefinedProperty[]; }; export type SegmentMap = DiffableMap; diff --git a/frontend/javascripts/oxalis/view/components/editable_text_label.tsx b/frontend/javascripts/oxalis/view/components/editable_text_label.tsx index 0bc58f707ab..e4623265998 100644 --- a/frontend/javascripts/oxalis/view/components/editable_text_label.tsx +++ b/frontend/javascripts/oxalis/view/components/editable_text_label.tsx @@ -24,6 +24,7 @@ export type EditableTextLabelProp = { disableEditing?: boolean; onContextMenu?: () => void; width?: string | number; + iconClassName?: string; isInvalid?: boolean | null | undefined; trimValue?: boolean | null | undefined; onRenameStart?: (() => void) | undefined; @@ -190,7 +191,9 @@ class EditableTextLabel extends React.PureComponent ), }, + { + key: "addProperty", + label: "Add Property", + onClick: () => { + if (visibleSegmentationLayer == null) { + return; + } + const key = prompt("Please type in a key"); + const value = prompt("Please type in a value"); + if (key && value) { + updateSegment( + segment.id, + { + userDefinedProperties: [ + ...segment.userDefinedProperties, + { key, stringValue: value }, + ], + }, + visibleSegmentationLayer.name, + true, + ); + } + hideContextMenu(); + }, + }, { key: "resetSegmentColor", disabled: segment.color == null, @@ -556,7 +583,6 @@ function _SegmentListItem({ : createSegmentContextMenu(), ); }; - return ( + {(segment.userDefinedProperties || []).length > 0 ? ( + `${prop.key}: ${prop.stringValue}`) + .join(", ")} + > + + + ) : null} @@ -639,6 +676,19 @@ function _SegmentListItem({ setAdditionalCoordinates={setAdditionalCoordinates} /> +
+ {(segment.userDefinedProperties || []).map((prop) => ( + + {prop.key}: {prop.stringValue} + + ))} +
); diff --git a/frontend/javascripts/types/api_flow_types.ts b/frontend/javascripts/types/api_flow_types.ts index 1f9d2322daa..d034ec65886 100644 --- a/frontend/javascripts/types/api_flow_types.ts +++ b/frontend/javascripts/types/api_flow_types.ts @@ -769,6 +769,13 @@ export type ServerSkeletonTracingTree = { type?: TreeType; edgesAreVisible?: boolean; }; +export type UserDefinedProperty = { + key: string; + stringValue?: string; + boolValue?: boolean; + numberValue?: number; + stringListValue?: string[]; +}; type ServerSegment = { segmentId: number; name: string | null | undefined; @@ -777,6 +784,7 @@ type ServerSegment = { creationTime: number | null | undefined; color: ColorObject | null; groupId: number | null | undefined; + userDefinedProperties: UserDefinedProperty[]; }; export type ServerTracingBase = { id: string; diff --git a/frontend/stylesheets/dark.less b/frontend/stylesheets/dark.less index 91e0a7fc11d..14cd22d1dab 100644 --- a/frontend/stylesheets/dark.less +++ b/frontend/stylesheets/dark.less @@ -114,4 +114,8 @@ .dataset-table-thumbnail.icon-thumbnail { filter: invert() contrast(0.8); } + + .deemphasized { + color: #afb8ba; + } } From ff8472c19b1861291373aff71a60e5115a2c7593 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Wed, 21 Aug 2024 09:08:04 +0200 Subject: [PATCH 06/79] don't use inline tags; instead use a resizable two-split-pane and a table --- .../segments_tab/segment_list_item.tsx | 13 -- .../segments_tab/segments_view.tsx | 204 ++++++++++++++---- frontend/stylesheets/main.less | 29 +++ .../stylesheets/trace_view/_right_menu.less | 34 +++ 4 files changed, 221 insertions(+), 59 deletions(-) diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segment_list_item.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segment_list_item.tsx index 75d7ba311f2..5e7cbdabac4 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segment_list_item.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segment_list_item.tsx @@ -676,19 +676,6 @@ function _SegmentListItem({ setAdditionalCoordinates={setAdditionalCoordinates} /> -
- {(segment.userDefinedProperties || []).map((prop) => ( - - {prop.key}: {prop.stringValue} - - ))} -
); diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx index 86d92a8a102..bbed2f7dd40 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx @@ -101,7 +101,7 @@ import { getBaseSegmentationName, } from "oxalis/view/right-border-tabs/segments_tab/segments_view_helper"; import SegmentListItem from "oxalis/view/right-border-tabs/segments_tab/segment_list_item"; -import React, { Key } from "react"; +import React, { Key, useEffect, useRef, useState } from "react"; import { connect, useSelector } from "react-redux"; import { AutoSizer } from "react-virtualized"; import type { Dispatch } from "redux"; @@ -406,6 +406,70 @@ const rootGroup = { isExpanded: true, }; +function ResizableSplitPane({ + firstChild, + secondChild, +}: { firstChild: React.ReactElement; secondChild: React.ReactElement | null }) { + const [height1, setHeight1] = useState("calc(100vh - 400px)"); + const [height2, setHeight2] = useState(400); + const dividerRef = useRef(null); + const containerRef = useRef(null); + const isResizingRef = useRef(false); + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (!isResizingRef.current || containerRef.current == null || dividerRef.current == null) + return; + + const DIVIDER_HEIGHT = 22; + const containerRect = containerRef.current.getBoundingClientRect(); + const newHeight1 = e.clientY - containerRect.top - DIVIDER_HEIGHT / 2; + const newHeight2 = containerRect.height - newHeight1 - dividerRef.current.clientHeight; + + if (newHeight1 > 0 && newHeight2 > 0) { + setHeight1(newHeight1); + setHeight2(newHeight2); + } + }; + + const handleMouseUp = () => { + isResizingRef.current = false; + document.body.style.cursor = "default"; + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, []); + + const handleMouseDown = () => { + isResizingRef.current = true; + document.body.style.cursor = "row-resize"; + }; + + if (secondChild == null) { + return firstChild; + } + + return ( +
+
+ {firstChild} +
+
+ +
+
+ {secondChild} +
+
+ ); +} + class SegmentsView extends React.Component { intervalID: ReturnType | null | undefined; state: State = { @@ -1839,51 +1903,61 @@ class SegmentsView extends React.Component { }`} /> ) : ( - /* Without the default height, height will be 0 on the first render, leading to tree virtualization being disabled. - This has a major performance impact. */ - - {({ height, width }) => ( -
- - // Forbid renaming when segments or groups are being renamed, - // since selecting text within the editable input box would not work - // otherwise (instead, the item would be dragged). - this.state.renamingCounter === 0 && this.props.allowUpdate, - }} - multiple - showLine - selectedKeys={this.getSelectedItemKeys()} - switcherIcon={} - treeData={this.state.groupTree} - titleRender={titleRender} - style={{ - marginTop: 12, - marginLeft: -26, // hide switcherIcon for root group - flex: "1 1 auto", - overflow: "auto", // use hidden when not using virtualization - }} - ref={this.tree} - onExpand={this.setExpandedGroups} - expandedKeys={this.state.expandedGroupKeys} - /> -
- )} -
+ <> + + {/* Without the default height, height will be 0 on the first render, leading + to tree virtualization being disabled. This has a major performance impact. */} + + {({ height, width }) => ( +
+ + // Forbid renaming when segments or groups are being renamed, + // since selecting text within the editable input box would not work + // otherwise (instead, the item would be dragged). + this.state.renamingCounter === 0 && this.props.allowUpdate, + }} + multiple + showLine + selectedKeys={this.getSelectedItemKeys()} + switcherIcon={} + treeData={this.state.groupTree} + titleRender={titleRender} + style={{ + marginTop: 12, + marginLeft: -26, // hide switcherIcon for root group + flex: "1 1 auto", + overflow: "auto", // use hidden when not using virtualization + }} + ref={this.tree} + onExpand={this.setExpandedGroups} + expandedKeys={this.state.expandedGroupKeys} + /> +
+ )} +
+ + } + secondChild={this.renderDetailsForSelection()} + /> + )} {groupToDelete !== null ? ( @@ -1905,6 +1979,44 @@ class SegmentsView extends React.Component { ); } + renderDetailsForSelection() { + const { segments } = this.props.selectedIds; + if (segments.length === 1) { + const segment = this.props.segments?.getNullable(segments[0]); + if (segment == null) { + return "Cannot find details for selected segment."; + } + return ( + + + + + + + + + + + {segment.userDefinedProperties?.length > 0 ? ( + <> + + + + {segment.userDefinedProperties.map((prop) => ( + + + + + ))} + + ) : null} +
ID{segment.id}
Name{segment.name}
User-defined Properties +
{prop.key}{prop.stringValue}
+ ); + } + return null; + } + getExpandSubgroupsItem(groupId: number) { const children = this.getKeysOfSubGroups(groupId); const expandedGroupsSet = new Set(this.state.expandedGroupKeys); diff --git a/frontend/stylesheets/main.less b/frontend/stylesheets/main.less index 24f22d4901d..a565237c7c1 100644 --- a/frontend/stylesheets/main.less +++ b/frontend/stylesheets/main.less @@ -688,3 +688,32 @@ button.narrow { .max-z-index { z-index: 10000000000; } + + + + + + +// todop: rename classes; +.segment-container { + display: flex; + flex-direction: column; + height: 100%; +} + +.child-1 { + overflow-y: auto; +} + +.resizable-divider { + padding: 2px 0 10px; + cursor: row-resize; + + .ant-divider { + margin: 0; + } +} + +.child-2 { + overflow-y: auto; +} diff --git a/frontend/stylesheets/trace_view/_right_menu.less b/frontend/stylesheets/trace_view/_right_menu.less index ddf03282673..8b4579dbd12 100644 --- a/frontend/stylesheets/trace_view/_right_menu.less +++ b/frontend/stylesheets/trace_view/_right_menu.less @@ -222,3 +222,37 @@ padding: 8px; } } + +.segment-details-table { + border-collapse: collapse; + width: 100%; + + th, td { + border: 1px solid #4A4A4A; + padding: 2px 4px; + text-align: left; + color: #FFFFFF; + } + + th { + background-color: #333333; + } + + td { + background-color: #262626; + } + + tr:nth-child(even) td { + background-color: #1A1A1A; + } + + tr.divider-row { + font-weight: bold; + td:first-child { + border-right: 0; + } + td:nth-child(2) { + border-left: 0; + } + } +} From de4c61e4c215bcd949cf856c6e27fb96a95e226b Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Wed, 21 Aug 2024 09:44:22 +0200 Subject: [PATCH 07/79] tweak layout so that the details pane only occupies as much space as needed and not more than the user-specified (via drag) height --- .../segments_tab/segments_view.tsx | 21 ++++++-------- frontend/stylesheets/_utils.less | 27 +++++++++++++++++ frontend/stylesheets/main.less | 29 ------------------- 3 files changed, 36 insertions(+), 41 deletions(-) diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx index bbed2f7dd40..ad2f8a65c3b 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx @@ -410,8 +410,7 @@ function ResizableSplitPane({ firstChild, secondChild, }: { firstChild: React.ReactElement; secondChild: React.ReactElement | null }) { - const [height1, setHeight1] = useState("calc(100vh - 400px)"); - const [height2, setHeight2] = useState(400); + const [maxHeightForSecondChild, setMaxHeightForSecondChild] = useState(400); const dividerRef = useRef(null); const containerRef = useRef(null); const isResizingRef = useRef(false); @@ -423,12 +422,12 @@ function ResizableSplitPane({ const DIVIDER_HEIGHT = 22; const containerRect = containerRef.current.getBoundingClientRect(); - const newHeight1 = e.clientY - containerRect.top - DIVIDER_HEIGHT / 2; - const newHeight2 = containerRect.height - newHeight1 - dividerRef.current.clientHeight; + const newHeightForFirstChild = e.clientY - containerRect.top - DIVIDER_HEIGHT / 2; + const newMaxHeightForSecondChild = + containerRect.height - newHeightForFirstChild - dividerRef.current.clientHeight; - if (newHeight1 > 0 && newHeight2 > 0) { - setHeight1(newHeight1); - setHeight2(newHeight2); + if (newHeightForFirstChild > 0 && newMaxHeightForSecondChild > 0) { + setMaxHeightForSecondChild(newMaxHeightForSecondChild); } }; @@ -456,14 +455,12 @@ function ResizableSplitPane({ } return ( -
-
- {firstChild} -
+
+
{firstChild}
-
+
{secondChild}
diff --git a/frontend/stylesheets/_utils.less b/frontend/stylesheets/_utils.less index 91ae318342d..b9aa0452554 100644 --- a/frontend/stylesheets/_utils.less +++ b/frontend/stylesheets/_utils.less @@ -156,3 +156,30 @@ td.nowrap * { cursor: grabbing !important; user-select: none; } + + +.resizable-two-split-pane { + display: flex; + flex-direction: column; + height: 100%; + + .child-1 { + overflow-y: auto; + flex-grow: 1; + } + + .resizable-divider { + padding: 2px 0 10px; + cursor: row-resize; + + .ant-divider { + margin: 0; + } + } + + .child-2 { + overflow-y: auto; + flex-shrink: 1; + } +} + diff --git a/frontend/stylesheets/main.less b/frontend/stylesheets/main.less index a565237c7c1..24f22d4901d 100644 --- a/frontend/stylesheets/main.less +++ b/frontend/stylesheets/main.less @@ -688,32 +688,3 @@ button.narrow { .max-z-index { z-index: 10000000000; } - - - - - - -// todop: rename classes; -.segment-container { - display: flex; - flex-direction: column; - height: 100%; -} - -.child-1 { - overflow-y: auto; -} - -.resizable-divider { - padding: 2px 0 10px; - cursor: row-resize; - - .ant-divider { - margin: 0; - } -} - -.child-2 { - overflow-y: auto; -} From 7860b7253ebaa370793b9ce9f66f44e2d6da5af7 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Wed, 21 Aug 2024 11:58:01 +0200 Subject: [PATCH 08/79] allow to edit values in segment details table --- .../segments_tab/segment_list_item.tsx | 3 +- .../segments_tab/segments_view.tsx | 102 ++++++++++++++++-- .../stylesheets/trace_view/_right_menu.less | 8 ++ 3 files changed, 105 insertions(+), 8 deletions(-) diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segment_list_item.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segment_list_item.tsx index 5e7cbdabac4..8728147b534 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segment_list_item.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segment_list_item.tsx @@ -6,7 +6,7 @@ import { EllipsisOutlined, TagsOutlined, } from "@ant-design/icons"; -import { List, MenuProps, App, Tag } from "antd"; +import { List, MenuProps, App } from "antd"; import { useDispatch, useSelector } from "react-redux"; import Checkbox, { CheckboxChangeEvent } from "antd/lib/checkbox/Checkbox"; import React from "react"; @@ -44,7 +44,6 @@ import { type AdditionalCoordinate } from "types/api_flow_types"; import { getAdditionalCoordinatesAsString } from "oxalis/model/accessors/flycam_accessor"; import FastTooltip from "components/fast_tooltip"; import { getContextMenuPositionFromEvent } from "oxalis/view/context_menu"; -import { stringToColor } from "libs/format_utils"; const ALSO_DELETE_SEGMENT_FROM_LIST_KEY = "also-delete-segment-from-list"; diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx index ad2f8a65c3b..bb7eafcd4fc 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx @@ -14,6 +14,7 @@ import { CloseOutlined, ShrinkOutlined, ExpandAltOutlined, + TagsOutlined, } from "@ant-design/icons"; import type RcTree from "rc-tree"; import { getJobs, startComputeMeshFileJob } from "admin/admin_rest_api"; @@ -105,7 +106,13 @@ import React, { Key, useEffect, useRef, useState } from "react"; import { connect, useSelector } from "react-redux"; import { AutoSizer } from "react-virtualized"; import type { Dispatch } from "redux"; -import type { APIDataset, APIMeshFile, APISegmentationLayer, APIUser } from "types/api_flow_types"; +import type { + APIDataset, + APIMeshFile, + APISegmentationLayer, + APIUser, + UserDefinedProperty, +} from "types/api_flow_types"; import DeleteGroupModalView from "../delete_group_modal_view"; import { createGroupToSegmentsMap, @@ -467,6 +474,28 @@ function ResizableSplitPane({ ); } +function InputWithUpdateOnBlur({ + value, + onChange, +}: { value: string; onChange: (value: string) => void }) { + const [localValue, setLocalValue] = useState(value); + + useEffect(() => { + setLocalValue(value); + }, [value]); + + return ( + onChange(localValue)} + onChange={(event) => { + setLocalValue(event.currentTarget.value); + }} + /> + ); +} + class SegmentsView extends React.Component { intervalID: ReturnType | null | undefined; state: State = { @@ -1981,7 +2010,7 @@ class SegmentsView extends React.Component { if (segments.length === 1) { const segment = this.props.segments?.getNullable(segments[0]); if (segment == null) { - return "Cannot find details for selected segment."; + return <>Cannot find details for selected segment.; } return ( @@ -1991,19 +2020,55 @@ class SegmentsView extends React.Component { - + {segment.userDefinedProperties?.length > 0 ? ( <> - + {segment.userDefinedProperties.map((prop) => ( - - + + + ))} @@ -2014,6 +2079,31 @@ class SegmentsView extends React.Component { return null; } + updateUserDefinedProperty = ( + segment: Segment, + oldProp: UserDefinedProperty, + newPropPartial: Partial, + ) => { + if (this.props.visibleSegmentationLayer == null) { + return; + } + this.props.updateSegment( + segment.id, + { + userDefinedProperties: segment.userDefinedProperties.map((element) => + element.key === oldProp.key + ? { + ...element, + ...newPropPartial, + } + : element, + ), + }, + this.props.visibleSegmentationLayer.name, + true, + ); + }; + getExpandSubgroupsItem(groupId: number) { const children = this.getKeysOfSubGroups(groupId); const expandedGroupsSet = new Set(this.state.expandedGroupKeys); diff --git a/frontend/stylesheets/trace_view/_right_menu.less b/frontend/stylesheets/trace_view/_right_menu.less index 8b4579dbd12..c92d596ae42 100644 --- a/frontend/stylesheets/trace_view/_right_menu.less +++ b/frontend/stylesheets/trace_view/_right_menu.less @@ -255,4 +255,12 @@ border-left: 0; } } + + input { + background: transparent; + border: 0; + width: 100%; + height: 100%; + padding: 0; + } } From 90e6654e4086fa250a9ac081dd5b52d5868dd9e5 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Wed, 21 Aug 2024 14:04:47 +0200 Subject: [PATCH 09/79] refactor --- .../model/helpers/generate_dummy_trees.ts | 5 +- .../oxalis/model/helpers/nml_helpers.ts | 3 + .../skeletontracing_reducer_helpers.ts | 5 + .../oxalis/model/sagas/update_actions.ts | 1 + frontend/javascripts/oxalis/store.ts | 4 + .../connectome_tab/connectome_view.tsx | 1 + .../resizable_split_pane.tsx | 63 ++++++ .../segments_tab/segments_view.tsx | 190 ++++-------------- .../skeletontracing_server_objects.ts | 2 + .../fixtures/tasktracing_server_objects.ts | 1 + frontend/javascripts/test/libs/nml.spec.ts | 2 + .../reducers/skeletontracing_reducer.spec.ts | 5 + .../test/sagas/skeletontracing_saga.spec.ts | 1 + frontend/javascripts/types/api_flow_types.ts | 2 + 14 files changed, 131 insertions(+), 154 deletions(-) create mode 100644 frontend/javascripts/oxalis/view/right-border-tabs/resizable_split_pane.tsx diff --git a/frontend/javascripts/oxalis/model/helpers/generate_dummy_trees.ts b/frontend/javascripts/oxalis/model/helpers/generate_dummy_trees.ts index cc0619756a8..4f4f0bfebfb 100644 --- a/frontend/javascripts/oxalis/model/helpers/generate_dummy_trees.ts +++ b/frontend/javascripts/oxalis/model/helpers/generate_dummy_trees.ts @@ -3,9 +3,7 @@ import type { ServerSkeletonTracingTree } from "types/api_flow_types"; // This i // Since the server cannot handle such big tracings at the moment, we'll // use this code to test the front-end performance. -// By default, this code is not used, but can be used similar to: -// tracing.trees = generateDummyTrees(1, 1000000); -// in model.js +// Prefer WkDev.createManyTrees for interactive tests. export default function generateDummyTrees( treeCount: number, @@ -67,6 +65,7 @@ export default function generateDummyTrees( name: "explorative_2017-10-09_SCM_Boy_023", createdTimestamp: 1507550576213, isVisible: true, + userDefinedProperties: [], }; } diff --git a/frontend/javascripts/oxalis/model/helpers/nml_helpers.ts b/frontend/javascripts/oxalis/model/helpers/nml_helpers.ts index ba391abf182..3b0a77982d0 100644 --- a/frontend/javascripts/oxalis/model/helpers/nml_helpers.ts +++ b/frontend/javascripts/oxalis/model/helpers/nml_helpers.ts @@ -689,6 +689,7 @@ function splitTreeIntoComponents( groupId: newGroupId, type: tree.type, edgesAreVisible: tree.edgesAreVisible, + userDefinedProperties: tree.userDefinedProperties, }; newTrees.push(newTree); } @@ -818,6 +819,8 @@ export function parseNml(nmlString: string): Promise<{ groupId: groupId >= 0 ? groupId : DEFAULT_GROUP_ID, type: _parseTreeType(attr, "type", TreeTypeEnum.DEFAULT), edgesAreVisible: _parseBool(attr, "edgesAreVisible", true), + // todop + userDefinedProperties: [], }; if (trees[currentTree.treeId] != null) throw new NmlParseError(`${messages["nml.duplicate_tree_id"]} ${currentTree.treeId}`); diff --git a/frontend/javascripts/oxalis/model/reducers/skeletontracing_reducer_helpers.ts b/frontend/javascripts/oxalis/model/reducers/skeletontracing_reducer_helpers.ts index 47654e113bc..f5aed99ecfe 100644 --- a/frontend/javascripts/oxalis/model/reducers/skeletontracing_reducer_helpers.ts +++ b/frontend/javascripts/oxalis/model/reducers/skeletontracing_reducer_helpers.ts @@ -24,6 +24,7 @@ import type { ServerSkeletonTracingTree, ServerNode, ServerBranchPoint, + UserDefinedProperty, } from "types/api_flow_types"; import { getSkeletonTracing, @@ -361,6 +362,7 @@ function splitTreeByNodes( groupId: activeTree.groupId, type: activeTree.type, edgesAreVisible: true, + userDefinedProperties: activeTree.userDefinedProperties, }; } else { const immutableNewTree = createTree( @@ -484,6 +486,7 @@ export function createTree( name?: string, type: TreeType = TreeTypeEnum.DEFAULT, edgesAreVisible: boolean = true, + userDefinedProperties: UserDefinedProperty[] = [], ): Maybe { return getSkeletonTracing(state.tracing).chain((skeletonTracing) => { // Create a new tree id and name @@ -513,6 +516,7 @@ export function createTree( groupId, type, edgesAreVisible, + userDefinedProperties, }; return Maybe.Just(tree); }); @@ -850,6 +854,7 @@ export function createMutableTreeMapFromTreeArray( groupId: tree.groupId, type: tree.type != null ? tree.type : TreeTypeEnum.DEFAULT, edgesAreVisible: tree.edgesAreVisible != null ? tree.edgesAreVisible : true, + userDefinedProperties: tree.userDefinedProperties, }), ), "treeId", diff --git a/frontend/javascripts/oxalis/model/sagas/update_actions.ts b/frontend/javascripts/oxalis/model/sagas/update_actions.ts index 019e9d1f280..97c38946ea3 100644 --- a/frontend/javascripts/oxalis/model/sagas/update_actions.ts +++ b/frontend/javascripts/oxalis/model/sagas/update_actions.ts @@ -130,6 +130,7 @@ export function createTree(tree: Tree) { isVisible: tree.isVisible, type: tree.type, edgesAreVisible: tree.edgesAreVisible, + userDefinedProperties: tree.userDefinedProperties, }, } as const; } diff --git a/frontend/javascripts/oxalis/store.ts b/frontend/javascripts/oxalis/store.ts index e7195fd7895..c522dbda441 100644 --- a/frontend/javascripts/oxalis/store.ts +++ b/frontend/javascripts/oxalis/store.ts @@ -130,6 +130,7 @@ export type UserBoundingBoxWithoutId = { export type UserBoundingBox = UserBoundingBoxWithoutId & { id: number; }; +// Remember to also update Tree export type MutableTree = { treeId: number; groupId: number | null | undefined; @@ -143,7 +144,9 @@ export type MutableTree = { nodes: MutableNodeMap; type: TreeType; edgesAreVisible: boolean; + userDefinedProperties: UserDefinedProperty[]; }; +// Remember to also update MutableTree export type Tree = { readonly treeId: number; readonly groupId: number | null | undefined; @@ -157,6 +160,7 @@ export type Tree = { readonly nodes: NodeMap; readonly type: TreeType; readonly edgesAreVisible: boolean; + readonly userDefinedProperties: UserDefinedProperty[]; }; export type TreeGroupTypeFlat = { readonly name: string; diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/connectome_tab/connectome_view.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/connectome_tab/connectome_view.tsx index 9ebd0dd9bce..ddab47ce2ce 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/connectome_tab/connectome_view.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/connectome_tab/connectome_view.tsx @@ -155,6 +155,7 @@ const synapseTreeCreator = (synapseId: number, synapseType: string): MutableTree groupId: null, type: TreeTypeEnum.DEFAULT, edgesAreVisible: true, + userDefinedProperties: [], }); const synapseNodeCreator = (synapseId: number, synapsePosition: Vector3): MutableNode => ({ diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/resizable_split_pane.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/resizable_split_pane.tsx new file mode 100644 index 00000000000..94ca8e9d67c --- /dev/null +++ b/frontend/javascripts/oxalis/view/right-border-tabs/resizable_split_pane.tsx @@ -0,0 +1,63 @@ +import { Divider } from "antd"; +import React, { useEffect, useRef, useState } from "react"; + +export function ResizableSplitPane({ + firstChild, + secondChild, +}: { firstChild: React.ReactElement; secondChild: React.ReactElement | null }) { + const [maxHeightForSecondChild, setMaxHeightForSecondChild] = useState(400); + const dividerRef = useRef(null); + const containerRef = useRef(null); + const isResizingRef = useRef(false); + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (!isResizingRef.current || containerRef.current == null || dividerRef.current == null) + return; + + const DIVIDER_HEIGHT = 22; + const containerRect = containerRef.current.getBoundingClientRect(); + const newHeightForFirstChild = e.clientY - containerRect.top - DIVIDER_HEIGHT / 2; + const newMaxHeightForSecondChild = + containerRect.height - newHeightForFirstChild - dividerRef.current.clientHeight; + + if (newHeightForFirstChild > 0 && newMaxHeightForSecondChild > 0) { + setMaxHeightForSecondChild(newMaxHeightForSecondChild); + } + }; + + const handleMouseUp = () => { + isResizingRef.current = false; + document.body.style.cursor = "default"; + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, []); + + const handleMouseDown = () => { + isResizingRef.current = true; + document.body.style.cursor = "row-resize"; + }; + + if (secondChild == null) { + return firstChild; + } + + return ( +
+
{firstChild}
+
+ +
+
+ {secondChild} +
+
+ ); +} diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx index bb7eafcd4fc..420bc76961f 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx @@ -1,24 +1,21 @@ import { + ArrowRightOutlined, + CloseOutlined, DeleteOutlined, + DownloadOutlined, DownOutlined, + ExclamationCircleOutlined, + ExpandAltOutlined, + EyeInvisibleOutlined, + EyeOutlined, LoadingOutlined, PlusOutlined, ReloadOutlined, - SettingOutlined, - ExclamationCircleOutlined, - ArrowRightOutlined, - DownloadOutlined, SearchOutlined, - EyeInvisibleOutlined, - EyeOutlined, - CloseOutlined, + SettingOutlined, ShrinkOutlined, - ExpandAltOutlined, - TagsOutlined, } from "@ant-design/icons"; -import type RcTree from "rc-tree"; import { getJobs, startComputeMeshFileJob } from "admin/admin_rest_api"; -import { api } from "oxalis/singletons"; import { getFeatureNotAvailableInPlanMessage, isFeatureAllowedByPricingPlan, @@ -35,15 +32,20 @@ import { Select, Tree, } from "antd"; +import { ItemType } from "antd/lib/menu/hooks/useItems"; +import { DataNode } from "antd/lib/tree"; +import { ChangeColorMenuItemContent } from "components/color_picker"; +import FastTooltip from "components/fast_tooltip"; import Toast from "libs/toast"; +import { pluralize } from "libs/utils"; import _, { isNumber } from "lodash"; import type { Vector3 } from "oxalis/constants"; import { EMPTY_OBJECT, MappingStatusEnum } from "oxalis/constants"; import { getSegmentIdForPosition } from "oxalis/controller/combinations/volume_handlers"; import { getMappingInfo, - getResolutionInfoOfVisibleSegmentationLayer, getMaybeSegmentIndexAvailability, + getResolutionInfoOfVisibleSegmentationLayer, getVisibleSegmentationLayer, } from "oxalis/model/accessors/dataset_accessor"; import { @@ -65,6 +67,7 @@ import { updateCurrentMeshFileAction, updateMeshVisibilityAction, } from "oxalis/model/actions/annotation_actions"; +import { ensureSegmentIndexIsLoadedAction } from "oxalis/model/actions/dataset_actions"; import { setAdditionalCoordinatesAction, setPositionAction, @@ -76,14 +79,15 @@ import { import { updateTemporarySettingAction } from "oxalis/model/actions/settings_actions"; import { batchUpdateGroupsAndSegmentsAction, - removeSegmentAction, deleteSegmentDataAction, + removeSegmentAction, setActiveCellAction, - updateSegmentAction, - setSelectedSegmentsOrGroupAction, setExpandedSegmentGroupsAction, + setSelectedSegmentsOrGroupAction, + updateSegmentAction, } from "oxalis/model/actions/volumetracing_actions"; import { ResolutionInfo } from "oxalis/model/helpers/resolution_info"; +import { api } from "oxalis/singletons"; import type { ActiveMappingInfo, MeshInformation, @@ -95,14 +99,17 @@ import type { VolumeTracing, } from "oxalis/store"; import Store from "oxalis/store"; +import ButtonComponent from "oxalis/view/components/button_component"; import DomVisibilityObserver from "oxalis/view/components/dom_visibility_observer"; import EditableTextLabel from "oxalis/view/components/editable_text_label"; +import { getContextMenuPositionFromEvent } from "oxalis/view/context_menu"; +import SegmentListItem from "oxalis/view/right-border-tabs/segments_tab/segment_list_item"; import { - SegmentHierarchyNode, getBaseSegmentationName, + SegmentHierarchyNode, } from "oxalis/view/right-border-tabs/segments_tab/segments_view_helper"; -import SegmentListItem from "oxalis/view/right-border-tabs/segments_tab/segment_list_item"; -import React, { Key, useEffect, useRef, useState } from "react"; +import type RcTree from "rc-tree"; +import React, { Key } from "react"; import { connect, useSelector } from "react-redux"; import { AutoSizer } from "react-virtualized"; import type { Dispatch } from "redux"; @@ -113,7 +120,12 @@ import type { APIUser, UserDefinedProperty, } from "types/api_flow_types"; +import { APIJobType, type AdditionalCoordinate } from "types/api_flow_types"; +import { ValueOf } from "types/globals"; +import AdvancedSearchPopover from "../advanced_search_popover"; import DeleteGroupModalView from "../delete_group_modal_view"; +import { ResizableSplitPane } from "../resizable_split_pane"; +import { ContextMenuContainer } from "../sidebar_context_menu"; import { createGroupToSegmentsMap, findParentIdForGroupId, @@ -121,19 +133,8 @@ import { getGroupNodeKey, MISSING_GROUP_ID, } from "../tree_hierarchy_view_helpers"; -import { ChangeColorMenuItemContent } from "components/color_picker"; -import { ItemType } from "antd/lib/menu/hooks/useItems"; -import { pluralize } from "libs/utils"; -import AdvancedSearchPopover from "../advanced_search_popover"; -import ButtonComponent from "oxalis/view/components/button_component"; +import { InputWithUpdateOnBlur, UserDefinedTableRows } from "../user_defined_properties_table"; import { SegmentStatisticsModal } from "./segment_statistics_modal"; -import { APIJobType, type AdditionalCoordinate } from "types/api_flow_types"; -import { DataNode } from "antd/lib/tree"; -import { ensureSegmentIndexIsLoadedAction } from "oxalis/model/actions/dataset_actions"; -import { ValueOf } from "types/globals"; -import { getContextMenuPositionFromEvent } from "oxalis/view/context_menu"; -import FastTooltip from "components/fast_tooltip"; -import { ContextMenuContainer } from "../sidebar_context_menu"; const { confirm } = Modal; const { Option } = Select; @@ -413,89 +414,6 @@ const rootGroup = { isExpanded: true, }; -function ResizableSplitPane({ - firstChild, - secondChild, -}: { firstChild: React.ReactElement; secondChild: React.ReactElement | null }) { - const [maxHeightForSecondChild, setMaxHeightForSecondChild] = useState(400); - const dividerRef = useRef(null); - const containerRef = useRef(null); - const isResizingRef = useRef(false); - - useEffect(() => { - const handleMouseMove = (e: MouseEvent) => { - if (!isResizingRef.current || containerRef.current == null || dividerRef.current == null) - return; - - const DIVIDER_HEIGHT = 22; - const containerRect = containerRef.current.getBoundingClientRect(); - const newHeightForFirstChild = e.clientY - containerRect.top - DIVIDER_HEIGHT / 2; - const newMaxHeightForSecondChild = - containerRect.height - newHeightForFirstChild - dividerRef.current.clientHeight; - - if (newHeightForFirstChild > 0 && newMaxHeightForSecondChild > 0) { - setMaxHeightForSecondChild(newMaxHeightForSecondChild); - } - }; - - const handleMouseUp = () => { - isResizingRef.current = false; - document.body.style.cursor = "default"; - }; - - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); - - return () => { - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - }; - }, []); - - const handleMouseDown = () => { - isResizingRef.current = true; - document.body.style.cursor = "row-resize"; - }; - - if (secondChild == null) { - return firstChild; - } - - return ( -
-
{firstChild}
-
- -
-
- {secondChild} -
-
- ); -} - -function InputWithUpdateOnBlur({ - value, - onChange, -}: { value: string; onChange: (value: string) => void }) { - const [localValue, setLocalValue] = useState(value); - - useEffect(() => { - setLocalValue(value); - }, [value]); - - return ( - onChange(localValue)} - onChange={(event) => { - setLocalValue(event.currentTarget.value); - }} - /> - ); -} - class SegmentsView extends React.Component { intervalID: ReturnType | null | undefined; state: State = { @@ -2037,42 +1955,12 @@ class SegmentsView extends React.Component { /> - - {segment.userDefinedProperties?.length > 0 ? ( - <> -
- - - {segment.userDefinedProperties.map((prop) => ( - - - - - - ))} - - ) : null} + ) => { + this.updateUserDefinedProperty(segment, oldKey, propPartial); + }} + />
Name{segment.name} + { + if (this.props.visibleSegmentationLayer == null) { + return; + } + this.props.updateSegment( + segment.id, + { name: newValue }, + this.props.visibleSegmentationLayer.name, + true, + ); + }} + /> +
User-defined Properties + User-defined Properties +
{prop.key}{prop.stringValue} + { + this.updateUserDefinedProperty(segment, prop, { + key: newValue, + }); + }} + /> + + { + this.updateUserDefinedProperty(segment, prop, { + stringValue: newValue, + }); + }} + /> +
- User-defined Properties - -
- { - this.updateUserDefinedProperty(segment, prop, { - key: newValue, - }); - }} - /> - - { - this.updateUserDefinedProperty(segment, prop, { - stringValue: newValue, - }); - }} - /> -
); } @@ -2081,7 +1969,7 @@ class SegmentsView extends React.Component { updateUserDefinedProperty = ( segment: Segment, - oldProp: UserDefinedProperty, + oldPropKey: string, newPropPartial: Partial, ) => { if (this.props.visibleSegmentationLayer == null) { @@ -2091,7 +1979,7 @@ class SegmentsView extends React.Component { segment.id, { userDefinedProperties: segment.userDefinedProperties.map((element) => - element.key === oldProp.key + element.key === oldPropKey ? { ...element, ...newPropPartial, diff --git a/frontend/javascripts/test/fixtures/skeletontracing_server_objects.ts b/frontend/javascripts/test/fixtures/skeletontracing_server_objects.ts index 691f1b876d5..70800053e16 100644 --- a/frontend/javascripts/test/fixtures/skeletontracing_server_objects.ts +++ b/frontend/javascripts/test/fixtures/skeletontracing_server_objects.ts @@ -49,6 +49,7 @@ export const tracing: ServerSkeletonTracing = { ], name: "explorative_2017-08-09_SCM_Boy_002", isVisible: true, + userDefinedProperties: [], }, { treeId: 1, @@ -116,6 +117,7 @@ export const tracing: ServerSkeletonTracing = { comments: [], isVisible: true, name: "explorative_2017-08-09_SCM_Boy_001", + userDefinedProperties: [], }, ], treeGroups: [ diff --git a/frontend/javascripts/test/fixtures/tasktracing_server_objects.ts b/frontend/javascripts/test/fixtures/tasktracing_server_objects.ts index 71a3e9f6896..a5e8dead0ea 100644 --- a/frontend/javascripts/test/fixtures/tasktracing_server_objects.ts +++ b/frontend/javascripts/test/fixtures/tasktracing_server_objects.ts @@ -39,6 +39,7 @@ export const tracing: ServerSkeletonTracing = { name: "", isVisible: true, createdTimestamp: 1528811979356, + userDefinedProperties: [], }, ], treeGroups: [], diff --git a/frontend/javascripts/test/libs/nml.spec.ts b/frontend/javascripts/test/libs/nml.spec.ts index c7ac7b41c6b..448dab46526 100644 --- a/frontend/javascripts/test/libs/nml.spec.ts +++ b/frontend/javascripts/test/libs/nml.spec.ts @@ -93,6 +93,7 @@ const initialSkeletonTracing: SkeletonTracing = { groupId: 3, type: TreeTypeEnum.DEFAULT, edgesAreVisible: true, + userDefinedProperties: [], }, "2": { treeId: 2, @@ -120,6 +121,7 @@ const initialSkeletonTracing: SkeletonTracing = { groupId: 2, type: TreeTypeEnum.DEFAULT, edgesAreVisible: true, + userDefinedProperties: [], }, }, treeGroups: [ diff --git a/frontend/javascripts/test/reducers/skeletontracing_reducer.spec.ts b/frontend/javascripts/test/reducers/skeletontracing_reducer.spec.ts index 988616a4179..3a382f0444c 100644 --- a/frontend/javascripts/test/reducers/skeletontracing_reducer.spec.ts +++ b/frontend/javascripts/test/reducers/skeletontracing_reducer.spec.ts @@ -79,6 +79,7 @@ initialSkeletonTracing.trees[1] = { groupId: MISSING_GROUP_ID, type: TreeTypeEnum.DEFAULT, edgesAreVisible: true, + userDefinedProperties: [], }; const initialState: OxalisState = update(defaultState, { tracing: { @@ -351,6 +352,7 @@ test("SkeletonTracing should delete nodes and split the tree", (t) => { isVisible: true, type: TreeTypeEnum.DEFAULT, edgesAreVisible: true, + userDefinedProperties: [], }, [1]: { treeId: 1, @@ -378,6 +380,7 @@ test("SkeletonTracing should delete nodes and split the tree", (t) => { isVisible: true, type: TreeTypeEnum.DEFAULT, edgesAreVisible: true, + userDefinedProperties: [], }, }, }, @@ -507,6 +510,7 @@ test("SkeletonTracing should delete an edge and split the tree", (t) => { isVisible: true, type: TreeTypeEnum.DEFAULT, edgesAreVisible: true, + userDefinedProperties: [], }, [1]: { treeId: 1, @@ -534,6 +538,7 @@ test("SkeletonTracing should delete an edge and split the tree", (t) => { isVisible: true, type: TreeTypeEnum.DEFAULT, edgesAreVisible: true, + userDefinedProperties: [], }, }, }, diff --git a/frontend/javascripts/test/sagas/skeletontracing_saga.spec.ts b/frontend/javascripts/test/sagas/skeletontracing_saga.spec.ts index 2975995baec..909b703921b 100644 --- a/frontend/javascripts/test/sagas/skeletontracing_saga.spec.ts +++ b/frontend/javascripts/test/sagas/skeletontracing_saga.spec.ts @@ -141,6 +141,7 @@ skeletonTracing.trees[1] = { groupId: MISSING_GROUP_ID, type: TreeTypeEnum.DEFAULT, edgesAreVisible: true, + userDefinedProperties: [], }; const initialState = update(defaultState, { tracing: { diff --git a/frontend/javascripts/types/api_flow_types.ts b/frontend/javascripts/types/api_flow_types.ts index d034ec65886..5da8b3131de 100644 --- a/frontend/javascripts/types/api_flow_types.ts +++ b/frontend/javascripts/types/api_flow_types.ts @@ -768,6 +768,8 @@ export type ServerSkeletonTracingTree = { isVisible?: boolean; type?: TreeType; edgesAreVisible?: boolean; + // todop: check whether this is really not-optional + userDefinedProperties: UserDefinedProperty[]; }; export type UserDefinedProperty = { key: string; From 152b0d5462dac236652c69db67e0fe0fc9087eff Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Wed, 21 Aug 2024 14:44:42 +0200 Subject: [PATCH 10/79] add property UI to skeleton trees --- .../model/actions/skeletontracing_actions.tsx | 15 +- .../model/reducers/skeletontracing_reducer.ts | 20 +++ .../model/sagas/skeletontracing_saga.ts | 7 +- .../oxalis/model/sagas/update_actions.ts | 1 + .../segments_tab/segments_view.tsx | 108 ++++++------ .../tree_hierarchy_renderers.tsx | 20 +++ .../right-border-tabs/tree_hierarchy_view.tsx | 166 ++++++++++++------ .../stylesheets/trace_view/_right_menu.less | 6 - 8 files changed, 229 insertions(+), 114 deletions(-) diff --git a/frontend/javascripts/oxalis/model/actions/skeletontracing_actions.tsx b/frontend/javascripts/oxalis/model/actions/skeletontracing_actions.tsx index 5f346f18657..10c1e19b0a2 100644 --- a/frontend/javascripts/oxalis/model/actions/skeletontracing_actions.tsx +++ b/frontend/javascripts/oxalis/model/actions/skeletontracing_actions.tsx @@ -1,6 +1,6 @@ import { Modal } from "antd"; import React, { Key } from "react"; -import type { ServerSkeletonTracing } from "types/api_flow_types"; +import type { ServerSkeletonTracing, UserDefinedProperty } from "types/api_flow_types"; import type { Vector3, TreeType } from "oxalis/constants"; import { enforceSkeletonTracing, @@ -46,6 +46,7 @@ type SetActiveTreeGroupAction = ReturnType; type DeselectActiveTreeGroupAction = ReturnType; export type MergeTreesAction = ReturnType; type SetTreeNameAction = ReturnType; +type SetTreeUserDefinedPropertiesAction = ReturnType; type SelectNextTreeAction = ReturnType; type SetTreeColorIndexAction = ReturnType; type ShuffleTreeColorAction = ReturnType; @@ -97,6 +98,7 @@ export type SkeletonTracingAction = | DeselectActiveTreeAction | MergeTreesAction | SetTreeNameAction + | SetTreeUserDefinedPropertiesAction | SelectNextTreeAction | SetTreeColorAction | SetTreeTypeAction @@ -138,6 +140,7 @@ export const SkeletonTracingSaveRelevantActions = [ "SET_ACTIVE_TREE", "SET_ACTIVE_TREE_BY_NAME", "SET_TREE_NAME", + "SET_TREE_USER_DEFINED_PROPERTIES", "MERGE_TREES", "SELECT_NEXT_TREE", "SHUFFLE_TREE_COLOR", @@ -422,6 +425,16 @@ export const setTreeNameAction = ( treeId, }) as const; +export const setTreeUserDefinedPropertiesAction = ( + userDefinedProperties: UserDefinedProperty[], + treeId?: number | null | undefined, +) => + ({ + type: "SET_TREE_USER_DEFINED_PROPERTIES", + userDefinedProperties, + treeId, + }) as const; + export const selectNextTreeAction = (forward: boolean | null | undefined = true) => ({ type: "SELECT_NEXT_TREE", diff --git a/frontend/javascripts/oxalis/model/reducers/skeletontracing_reducer.ts b/frontend/javascripts/oxalis/model/reducers/skeletontracing_reducer.ts index 280b1911699..2e65a0a77c0 100644 --- a/frontend/javascripts/oxalis/model/reducers/skeletontracing_reducer.ts +++ b/frontend/javascripts/oxalis/model/reducers/skeletontracing_reducer.ts @@ -1024,6 +1024,26 @@ function SkeletonTracingReducer(state: OxalisState, action: Action): OxalisState .getOrElse(state); } + case "SET_TREE_USER_DEFINED_PROPERTIES": { + return getTree(skeletonTracing, action.treeId) + .map((tree) => { + return update(state, { + tracing: { + skeleton: { + trees: { + [tree.treeId]: { + userDefinedProperties: { + $set: action.userDefinedProperties, + }, + }, + }, + }, + }, + }); + }) + .getOrElse(state); + } + case "SET_EDGES_ARE_VISIBLE": { return getTree(skeletonTracing, action.treeId) .map((tree) => { diff --git a/frontend/javascripts/oxalis/model/sagas/skeletontracing_saga.ts b/frontend/javascripts/oxalis/model/sagas/skeletontracing_saga.ts index b83a9531439..76aa6331820 100644 --- a/frontend/javascripts/oxalis/model/sagas/skeletontracing_saga.ts +++ b/frontend/javascripts/oxalis/model/sagas/skeletontracing_saga.ts @@ -542,13 +542,14 @@ function updateTracingPredicate( function updateTreePredicate(prevTree: Tree, tree: Tree): boolean { return ( - !_.isEqual(prevTree.branchPoints, tree.branchPoints) || + prevTree.branchPoints !== tree.branchPoints || prevTree.color !== tree.color || prevTree.name !== tree.name || - !_.isEqual(prevTree.comments, tree.comments) || + prevTree.comments !== tree.comments || prevTree.timestamp !== tree.timestamp || prevTree.groupId !== tree.groupId || - prevTree.type !== tree.type + prevTree.type !== tree.type || + prevTree.userDefinedProperties !== tree.userDefinedProperties ); } diff --git a/frontend/javascripts/oxalis/model/sagas/update_actions.ts b/frontend/javascripts/oxalis/model/sagas/update_actions.ts index 97c38946ea3..10175c3be0a 100644 --- a/frontend/javascripts/oxalis/model/sagas/update_actions.ts +++ b/frontend/javascripts/oxalis/model/sagas/update_actions.ts @@ -157,6 +157,7 @@ export function updateTree(tree: Tree) { isVisible: tree.isVisible, type: tree.type, edgesAreVisible: tree.edgesAreVisible, + userDefinedProperties: tree.userDefinedProperties, }, } as const; } diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx index 420bc76961f..12a7167acd7 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx @@ -1847,61 +1847,59 @@ class SegmentsView extends React.Component { }`} /> ) : ( - <> - - {/* Without the default height, height will be 0 on the first render, leading - to tree virtualization being disabled. This has a major performance impact. */} - - {({ height, width }) => ( -
- - // Forbid renaming when segments or groups are being renamed, - // since selecting text within the editable input box would not work - // otherwise (instead, the item would be dragged). - this.state.renamingCounter === 0 && this.props.allowUpdate, - }} - multiple - showLine - selectedKeys={this.getSelectedItemKeys()} - switcherIcon={} - treeData={this.state.groupTree} - titleRender={titleRender} - style={{ - marginTop: 12, - marginLeft: -26, // hide switcherIcon for root group - flex: "1 1 auto", - overflow: "auto", // use hidden when not using virtualization - }} - ref={this.tree} - onExpand={this.setExpandedGroups} - expandedKeys={this.state.expandedGroupKeys} - /> -
- )} -
- - } - secondChild={this.renderDetailsForSelection()} - /> - + + {({ height, width }) => ( +
+ + // Forbid renaming when segments or groups are being renamed, + // since selecting text within the editable input box would not work + // otherwise (instead, the item would be dragged). + this.state.renamingCounter === 0 && this.props.allowUpdate, + }} + multiple + showLine + selectedKeys={this.getSelectedItemKeys()} + switcherIcon={} + treeData={this.state.groupTree} + titleRender={titleRender} + style={{ + marginTop: 12, + marginLeft: -26, // hide switcherIcon for root group + flex: "1 1 auto", + overflow: "auto", // use hidden when not using virtualization + }} + ref={this.tree} + onExpand={this.setExpandedGroups} + expandedKeys={this.state.expandedGroupKeys} + /> +
+ )} + + } + secondChild={this.renderDetailsForSelection()} + /> )}
{groupToDelete !== null ? ( diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_renderers.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_renderers.tsx index d87e8ed200b..72bf4357f30 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_renderers.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_renderers.tsx @@ -5,6 +5,7 @@ import { FolderOutlined, PlusOutlined, ShrinkOutlined, + TagsOutlined, } from "@ant-design/icons"; import { MenuProps, notification } from "antd"; import _ from "lodash"; @@ -32,6 +33,7 @@ import { setTreeGroupAction, setTreeGroupsAction, setTreeTypeAction, + setTreeUserDefinedPropertiesAction, shuffleAllTreeColorsAction, shuffleTreeColorAction, toggleInactiveTreesAction, @@ -126,6 +128,24 @@ const createMenuForTree = (tree: Tree, props: Props, hideContextMenu: () => void icon: , label: "Shuffle Tree Color", }, + { + key: "addProperty", + label: "Add Property", + icon: , + onClick: () => { + const key = prompt("Please type in a key"); + const value = prompt("Please type in a value"); + if (key && value) { + Store.dispatch( + setTreeUserDefinedPropertiesAction( + [...tree.userDefinedProperties, { key, stringValue: value }], + tree.treeId, + ), + ); + } + hideContextMenu(); + }, + }, { key: "deleteTree", onClick: () => { diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx index 9e98ae6c744..333aae95f3e 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx @@ -5,12 +5,14 @@ import { AutoSizer } from "react-virtualized"; import { mapGroups } from "oxalis/model/accessors/skeletontracing_accessor"; import { setTreeGroupAction, + setTreeNameAction, + setTreeUserDefinedPropertiesAction, toggleAllTreesAction, toggleTreeAction, toggleTreeGroupAction, } from "oxalis/model/actions/skeletontracing_actions"; import { Store } from "oxalis/singletons"; -import type { TreeGroup } from "oxalis/store"; +import type { Tree, TreeGroup, TreeMap } from "oxalis/store"; import { createGroupToTreesMap, deepFlatFilter, @@ -33,6 +35,9 @@ import { setExpandedGroups, setUpdateTreeGroups, } from "./tree_hierarchy_renderers"; +import { ResizableSplitPane } from "./resizable_split_pane"; +import { InputWithUpdateOnBlur, UserDefinedTableRows } from "./user_defined_properties_table"; +import { UserDefinedProperty } from "types/api_flow_types"; const onCheck: TreeProps["onCheck"] = (_checkedKeysValue, info) => { const { id, type } = info.node; @@ -261,57 +266,120 @@ function TreeHierarchyView(props: Props) { menu={menu} className="tree-list-context-menu-overlay" /> - - {({ height, width }) => ( -
- - node.type === GroupTypeEnum.TREE - ? renderTreeNode(props, onOpenContextMenu, hideContextMenu, node) - : renderGroupNode( - props, - onOpenContextMenu, - hideContextMenu, - node, - expandedNodeKeys, - ) - } - switcherIcon={} - onSelect={(_selectedKeys, info: { node: TreeNode; nativeEvent: MouseEvent }) => - info.node.type === GroupTypeEnum.TREE - ? onSelectTreeNode(info.node, info.nativeEvent) - : onSelectGroupNode(info.node) - } - onDrop={onDrop} - onCheck={onCheck} - onExpand={onExpand} - // @ts-expect-error isNodeDraggable has argument of base type DataNode but we use it's extended parent type TreeNode - draggable={{ nodeDraggable: isNodeDraggable, icon: false }} - checkedKeys={checkedKeys} - expandedKeys={expandedNodeKeys} - selectedKeys={selectedKeys} - style={{ marginLeft: -14 }} - autoExpandParent - checkable - blockNode - showLine - multiple - defaultExpandAll - /> -
- )} -
+ + {({ height, width }) => ( +
+ + node.type === GroupTypeEnum.TREE + ? renderTreeNode(props, onOpenContextMenu, hideContextMenu, node) + : renderGroupNode( + props, + onOpenContextMenu, + hideContextMenu, + node, + expandedNodeKeys, + ) + } + switcherIcon={} + onSelect={(_selectedKeys, info: { node: TreeNode; nativeEvent: MouseEvent }) => + info.node.type === GroupTypeEnum.TREE + ? onSelectTreeNode(info.node, info.nativeEvent) + : onSelectGroupNode(info.node) + } + onDrop={onDrop} + onCheck={onCheck} + onExpand={onExpand} + // @ts-expect-error isNodeDraggable has argument of base type DataNode but we use it's extended parent type TreeNode + draggable={{ nodeDraggable: isNodeDraggable, icon: false }} + checkedKeys={checkedKeys} + expandedKeys={expandedNodeKeys} + selectedKeys={selectedKeys} + style={{ marginLeft: -14 }} + autoExpandParent + checkable + blockNode + showLine + multiple + defaultExpandAll + /> +
+ )} + + } + secondChild={ + + } + /> ); } +const updateUserDefinedProperty = ( + tree: Tree, + oldPropKey: string, + newPropPartial: Partial, +) => { + Store.dispatch( + setTreeUserDefinedPropertiesAction( + tree.userDefinedProperties.map((element) => + element.key === oldPropKey + ? { + ...element, + ...newPropPartial, + } + : element, + ), + tree.treeId, + ), + ); +}; + +function DetailsForSelection({ + trees, + selectedTreeIds, +}: { trees: TreeMap; selectedTreeIds: number[] }) { + if (selectedTreeIds.length === 1) { + const tree = trees[selectedTreeIds[0]]; + if (tree == null) { + return <>Cannot find details for selected tree.; + } + return ( + + + + + + + + + + ) => { + updateUserDefinedProperty(tree, oldKey, propPartial); + }} + /> +
ID{tree.treeId}
Name + Store.dispatch(setTreeNameAction(newValue, tree.treeId))} + /> +
+ ); + } + return null; +} + // React.memo is used to prevent the component from re-rendering without the props changing export default React.memo(TreeHierarchyView); diff --git a/frontend/stylesheets/trace_view/_right_menu.less b/frontend/stylesheets/trace_view/_right_menu.less index c92d596ae42..1c8e05a573c 100644 --- a/frontend/stylesheets/trace_view/_right_menu.less +++ b/frontend/stylesheets/trace_view/_right_menu.less @@ -248,12 +248,6 @@ tr.divider-row { font-weight: bold; - td:first-child { - border-right: 0; - } - td:nth-child(2) { - border-left: 0; - } } input { From 86aecdb779a71bdcf738c2f293ae6d416f7c428e Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Wed, 21 Aug 2024 15:01:49 +0200 Subject: [PATCH 11/79] add missing file --- .../user_defined_properties_table.tsx | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 frontend/javascripts/oxalis/view/right-border-tabs/user_defined_properties_table.tsx diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/user_defined_properties_table.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/user_defined_properties_table.tsx new file mode 100644 index 00000000000..54689d04b8e --- /dev/null +++ b/frontend/javascripts/oxalis/view/right-border-tabs/user_defined_properties_table.tsx @@ -0,0 +1,71 @@ +import { TagsOutlined } from "@ant-design/icons"; +import React, { useEffect, useState } from "react"; +import type { UserDefinedProperty } from "types/api_flow_types"; + +export function InputWithUpdateOnBlur({ + value, + onChange, +}: { value: string; onChange: (value: string) => void }) { + const [localValue, setLocalValue] = useState(value); + + useEffect(() => { + setLocalValue(value); + }, [value]); + + return ( + onChange(localValue)} + onChange={(event) => { + setLocalValue(event.currentTarget.value); + }} + /> + ); +} + +export function UserDefinedTableRows({ + userDefinedProperties, + onChange, +}: { + userDefinedProperties: UserDefinedProperty[] | null; + onChange: (oldKey: string, propPartial: Partial) => void; +}) { + if (userDefinedProperties == null || userDefinedProperties.length === 0) { + return null; + } + return ( + <> + + + User-defined Properties + + + {userDefinedProperties.map((prop) => ( + + + + onChange(prop.key, { + key: newKey, + }) + } + /> + + + + { + onChange(prop.key, { + stringValue: newValue, + }); + }} + /> + + + ))} + + ); +} From 56fce0ad4d81c7a8baa86ae1b8f1c2b62d5c838f Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Wed, 21 Aug 2024 15:46:18 +0200 Subject: [PATCH 12/79] fix tests --- .../oxalis/model/sagas/skeletontracing_saga.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/javascripts/oxalis/model/sagas/skeletontracing_saga.ts b/frontend/javascripts/oxalis/model/sagas/skeletontracing_saga.ts index 76aa6331820..30c34838dad 100644 --- a/frontend/javascripts/oxalis/model/sagas/skeletontracing_saga.ts +++ b/frontend/javascripts/oxalis/model/sagas/skeletontracing_saga.ts @@ -542,10 +542,14 @@ function updateTracingPredicate( function updateTreePredicate(prevTree: Tree, tree: Tree): boolean { return ( - prevTree.branchPoints !== tree.branchPoints || + // branchPoints and comments are arrays and therefore checked for + // equality. This avoids unnecessary updates in certain cases (e.g., + // when two trees are merged, the comments are concatenated, even + // if one of them is empty; thus, resulting in new instances). + !_.isEqual(prevTree.branchPoints, tree.branchPoints) || + !_.isEqual(prevTree.comments, tree.comments) || prevTree.color !== tree.color || prevTree.name !== tree.name || - prevTree.comments !== tree.comments || prevTree.timestamp !== tree.timestamp || prevTree.groupId !== tree.groupId || prevTree.type !== tree.type || From e6e569c985059f6bc0ea27801430a3ea9be69051 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Fri, 23 Aug 2024 15:44:00 +0200 Subject: [PATCH 13/79] fix spinner margin in dashboard --- .../javascripts/dashboard/explorative_annotations_view.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/javascripts/dashboard/explorative_annotations_view.tsx b/frontend/javascripts/dashboard/explorative_annotations_view.tsx index 357a2e98ad2..dfb41afc76e 100644 --- a/frontend/javascripts/dashboard/explorative_annotations_view.tsx +++ b/frontend/javascripts/dashboard/explorative_annotations_view.tsx @@ -793,7 +793,7 @@ class ExplorativeAnnotationsView extends React.PureComponent { render() { return ( -
+
{ archiveAll={this.archiveAll} /> {this.renderSearchTags()} - + {this.renderTable()}
Date: Fri, 23 Aug 2024 15:44:20 +0200 Subject: [PATCH 14/79] allow user to resize pane larger than needed --- .../view/right-border-tabs/resizable_split_pane.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/resizable_split_pane.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/resizable_split_pane.tsx index 94ca8e9d67c..0106a88ed9e 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/resizable_split_pane.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/resizable_split_pane.tsx @@ -5,7 +5,7 @@ export function ResizableSplitPane({ firstChild, secondChild, }: { firstChild: React.ReactElement; secondChild: React.ReactElement | null }) { - const [maxHeightForSecondChild, setMaxHeightForSecondChild] = useState(400); + const [heightForSecondChild, setHeightForSecondChild] = useState(100); const dividerRef = useRef(null); const containerRef = useRef(null); const isResizingRef = useRef(false); @@ -18,11 +18,11 @@ export function ResizableSplitPane({ const DIVIDER_HEIGHT = 22; const containerRect = containerRef.current.getBoundingClientRect(); const newHeightForFirstChild = e.clientY - containerRect.top - DIVIDER_HEIGHT / 2; - const newMaxHeightForSecondChild = + const newHeightForSecondChild = containerRect.height - newHeightForFirstChild - dividerRef.current.clientHeight; - if (newHeightForFirstChild > 0 && newMaxHeightForSecondChild > 0) { - setMaxHeightForSecondChild(newMaxHeightForSecondChild); + if (newHeightForFirstChild > 0 && newHeightForSecondChild > 0) { + setHeightForSecondChild(newHeightForSecondChild); } }; @@ -55,7 +55,7 @@ export function ResizableSplitPane({
-
+
{secondChild}
From fe796f4ced17e2c91b32a46fc4ecf8cabb8c4a2d Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Fri, 23 Aug 2024 16:20:06 +0200 Subject: [PATCH 15/79] fix styling in light theme --- frontend/stylesheets/dark.less | 6 ++++++ .../stylesheets/trace_view/_right_menu.less | 17 +++++++++-------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/frontend/stylesheets/dark.less b/frontend/stylesheets/dark.less index 14cd22d1dab..cfc6eb94572 100644 --- a/frontend/stylesheets/dark.less +++ b/frontend/stylesheets/dark.less @@ -1,4 +1,10 @@ .dark-theme { + .segment-details-table { + --compact-table-bg1: #262626; + --compact-table-bg2: #1A1A1A; + --compact-table-border: #4A4A4A; + } + .brain-loading-content img { filter: invert(1) contrast(0.7); } diff --git a/frontend/stylesheets/trace_view/_right_menu.less b/frontend/stylesheets/trace_view/_right_menu.less index 1c8e05a573c..e6bba1ab22d 100644 --- a/frontend/stylesheets/trace_view/_right_menu.less +++ b/frontend/stylesheets/trace_view/_right_menu.less @@ -224,26 +224,27 @@ } .segment-details-table { + // These colors are redefined in dark.less + --compact-table-bg1: #f7f7f7; + --compact-table-bg2: #fefefe; + --compact-table-border: #e7e7e7; + border-collapse: collapse; width: 100%; th, td { - border: 1px solid #4A4A4A; + border: 1px solid var(--compact-table-border); padding: 2px 4px; text-align: left; - color: #FFFFFF; - } - - th { - background-color: #333333; + color: var(--ant-color-text-base); } td { - background-color: #262626; + background-color: var(--compact-table-bg1); } tr:nth-child(even) td { - background-color: #1A1A1A; + background-color: var(--compact-table-bg2); } tr.divider-row { From b1ec6e08763cad8e411cf166462b37988e86fc69 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Fri, 23 Aug 2024 16:35:31 +0200 Subject: [PATCH 16/79] filter duplicate keys in action creator --- .../model/actions/skeletontracing_actions.tsx | 3 +- .../model/actions/volumetracing_actions.ts | 11 +++- .../segments_tab/segments_view.tsx | 60 ++++++++++--------- .../right-border-tabs/tree_hierarchy_view.tsx | 40 +++++++------ 4 files changed, 63 insertions(+), 51 deletions(-) diff --git a/frontend/javascripts/oxalis/model/actions/skeletontracing_actions.tsx b/frontend/javascripts/oxalis/model/actions/skeletontracing_actions.tsx index 10c1e19b0a2..d9bc47e8ca1 100644 --- a/frontend/javascripts/oxalis/model/actions/skeletontracing_actions.tsx +++ b/frontend/javascripts/oxalis/model/actions/skeletontracing_actions.tsx @@ -15,6 +15,7 @@ import renderIndependently from "libs/render_independently"; import { AllUserBoundingBoxActions } from "oxalis/model/actions/annotation_actions"; import { batchActions } from "redux-batched-actions"; import { type AdditionalCoordinate } from "types/api_flow_types"; +import _ from "lodash"; export type InitializeSkeletonTracingAction = ReturnType; export type CreateNodeAction = ReturnType; @@ -431,7 +432,7 @@ export const setTreeUserDefinedPropertiesAction = ( ) => ({ type: "SET_TREE_USER_DEFINED_PROPERTIES", - userDefinedProperties, + userDefinedProperties: _.uniqBy(userDefinedProperties, (el: UserDefinedProperty) => el.key), treeId, }) as const; diff --git a/frontend/javascripts/oxalis/model/actions/volumetracing_actions.ts b/frontend/javascripts/oxalis/model/actions/volumetracing_actions.ts index 582495dd7b6..be1e677a696 100644 --- a/frontend/javascripts/oxalis/model/actions/volumetracing_actions.ts +++ b/frontend/javascripts/oxalis/model/actions/volumetracing_actions.ts @@ -7,7 +7,8 @@ import type { Dispatch } from "redux"; import { AllUserBoundingBoxActions } from "oxalis/model/actions/annotation_actions"; import { QuickSelectGeometry } from "oxalis/geometries/helper_geometries"; import { batchActions } from "redux-batched-actions"; -import { type AdditionalCoordinate } from "types/api_flow_types"; +import { type AdditionalCoordinate, type UserDefinedProperty } from "types/api_flow_types"; +import _ from "lodash"; export type InitializeVolumeTracingAction = ReturnType; export type InitializeEditableMappingAction = ReturnType; @@ -238,11 +239,17 @@ export const updateSegmentAction = ( if (segmentId == null) { throw new Error("Segment ID must not be null."); } + const { userDefinedProperties, ...restSegment } = segment; + const sanitizedUserDefinedProperties = + userDefinedProperties !== undefined + ? _.uniqBy(userDefinedProperties, (el: UserDefinedProperty) => el.key) + : undefined; + return { type: "UPDATE_SEGMENT", // TODO: Proper 64 bit support (#6921) segmentId: Number(segmentId), - segment, + segment: { ...restSegment, sanitizedUserDefinedProperties }, layerName, timestamp, createsNewUndoState, diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx index 12a7167acd7..2b509f924d8 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx @@ -1930,35 +1930,37 @@ class SegmentsView extends React.Component { } return ( - - - - - - - - - ) => { - this.updateUserDefinedProperty(segment, oldKey, propPartial); - }} - /> + + + + + + + + + + ) => { + this.updateUserDefinedProperty(segment, oldKey, propPartial); + }} + /> +
ID{segment.id}
Name - { - if (this.props.visibleSegmentationLayer == null) { - return; - } - this.props.updateSegment( - segment.id, - { name: newValue }, - this.props.visibleSegmentationLayer.name, - true, - ); - }} - /> -
ID{segment.id}
Name + { + if (this.props.visibleSegmentationLayer == null) { + return; + } + this.props.updateSegment( + segment.id, + { name: newValue }, + this.props.visibleSegmentationLayer.name, + true, + ); + }} + /> +
); } diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx index 333aae95f3e..539cc8a0d0c 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx @@ -356,25 +356,27 @@ function DetailsForSelection({ } return ( - - - - - - - - - ) => { - updateUserDefinedProperty(tree, oldKey, propPartial); - }} - /> + + + + + + + + + + ) => { + updateUserDefinedProperty(tree, oldKey, propPartial); + }} + /> +
ID{tree.treeId}
Name - Store.dispatch(setTreeNameAction(newValue, tree.treeId))} - /> -
ID{tree.treeId}
Name + Store.dispatch(setTreeNameAction(newValue, tree.treeId))} + /> +
); } From e173dfdc7349ed67ad8b8a90bd52ce233126c95f Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Fri, 23 Aug 2024 17:26:18 +0200 Subject: [PATCH 17/79] implement NML parsing and serialization of user defined properties for trees --- frontend/javascripts/messages.tsx | 2 + .../oxalis/model/helpers/nml_helpers.ts | 42 ++++++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/frontend/javascripts/messages.tsx b/frontend/javascripts/messages.tsx index ccabcb8ec64..aec9cd27156 100644 --- a/frontend/javascripts/messages.tsx +++ b/frontend/javascripts/messages.tsx @@ -435,6 +435,8 @@ instead. Only enable this option if you understand its effect. All layers will n "NML contains tag that is not enclosed by a tag: Node with id", "nml.edge_outside_tree": "NML contains tag that is not enclosed by a tag: Edge", + "nml.user_defined_property_outside_tree": + "NML contains tag that is not enclosed by a tag", "nml.expected_attribute_missing": "Attribute with the following name was expected, but is missing or empty:", "nml.invalid_timestamp": "Attribute with the following name was expected to be a unix timestamp:", diff --git a/frontend/javascripts/oxalis/model/helpers/nml_helpers.ts b/frontend/javascripts/oxalis/model/helpers/nml_helpers.ts index 3b0a77982d0..443c69f73aa 100644 --- a/frontend/javascripts/oxalis/model/helpers/nml_helpers.ts +++ b/frontend/javascripts/oxalis/model/helpers/nml_helpers.ts @@ -1,7 +1,7 @@ // @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module 'saxo... Remove this comment to see the full error message import Saxophone from "saxophone"; import _ from "lodash"; -import type { APIBuildInfo } from "types/api_flow_types"; +import type { APIBuildInfo, UserDefinedProperty } from "types/api_flow_types"; import { getMaximumGroupId, getMaximumTreeId, @@ -378,6 +378,7 @@ function serializeTrees( "", ...indent(serializeEdges(tree.edges)), "", + ...serializeUserDefinedProperties(tree.userDefinedProperties), ], ), ), @@ -456,6 +457,25 @@ function serializeEdges(edges: EdgeCollection): Array { ); } +function serializeUserDefinedProperties(userDefinedProperties: UserDefinedProperty[]): string[] { + return userDefinedProperties.map((prop) => { + const values: any = {}; + if (prop.stringValue != null) { + values.stringValue = prop.stringValue; + } else if (prop.boolValue != null) { + values.boolValue = prop.boolValue ? "true" : "false"; + } else if (prop.numberValue != null) { + values.numberValue = `${prop.numberValue}`; + } else if (prop.stringListValue != null) { + for (let i = 0; i < prop.stringListValue.length; i++) { + values[`stringListValue-${i}`] = prop.stringListValue[i]; + } + } + + return serializeTag("userDefinedProperty", { key: prop.key, ...values }); + }); +} + function serializeBranchPoints(trees: Array): Array { const branchPoints = _.flatten(trees.map((tree) => tree.branchPoints)); @@ -769,6 +789,18 @@ function parseBoundingBoxObject(attr: Record): BoundingBoxObject { return boundingBoxObject; } +function parseUserDefinedProperty(attr: Record): UserDefinedProperty { + return { + key: _parseEntities(attr, "key"), + stringValue: _parseEntities(attr, "stringValue", undefined), + boolValue: _parseBool(attr, "boolValue", undefined), + numberValue: _parseFloat(attr, "numberValue", undefined), + // todop: + // @ts-ignore + stringListValue: _parseArray(attr, "stringListValue", undefined), + }; +} + export function parseNml(nmlString: string): Promise<{ trees: MutableTreeMap; treeGroups: Array; @@ -868,6 +900,14 @@ export function parseNml(nmlString: string): Promise<{ break; } + case "userDefinedProperty": { + if (currentTree == null) { + throw new NmlParseError(messages["nml.user_defined_property_outside_tree"]); + } + + currentTree.userDefinedProperties.push(parseUserDefinedProperty(attr)); + } + case "edge": { const currentEdge = { source: _parseInt(attr, "source"), From 9442e4632f7a0c77eb880499dd8498863f27611d Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Wed, 28 Aug 2024 15:28:06 +0200 Subject: [PATCH 18/79] adapt parser and serialize for userDefinedProperties; add test --- .../oxalis/model/helpers/nml_helpers.ts | 206 +++++++++++++----- frontend/javascripts/test/libs/nml.spec.ts | 67 ++++++ 2 files changed, 216 insertions(+), 57 deletions(-) diff --git a/frontend/javascripts/oxalis/model/helpers/nml_helpers.ts b/frontend/javascripts/oxalis/model/helpers/nml_helpers.ts index 443c69f73aa..44fca71e133 100644 --- a/frontend/javascripts/oxalis/model/helpers/nml_helpers.ts +++ b/frontend/javascripts/oxalis/model/helpers/nml_helpers.ts @@ -21,6 +21,7 @@ import type { MutableTree, TreeGroup, BoundingBoxObject, + MutableNode, } from "oxalis/store"; import { findGroup } from "oxalis/view/right-border-tabs/tree_hierarchy_view_helpers"; import messages from "messages"; @@ -361,8 +362,12 @@ function serializeTrees( applyTransform: boolean, ): Array { return _.flatten( - trees.map((tree) => - serializeTagWithChildren( + trees.map((tree) => { + const userDefinedPropertiesString = serializeUserDefinedProperties( + tree.userDefinedProperties, + ); + + return serializeTagWithChildren( "thing", { id: tree.treeId, @@ -378,10 +383,16 @@ function serializeTrees( "", ...indent(serializeEdges(tree.edges)), "", - ...serializeUserDefinedProperties(tree.userDefinedProperties), + ...(userDefinedPropertiesString.length > 0 + ? [ + "", + ...indent(userDefinedPropertiesString), + "", + ] + : []), ], - ), - ), + ); + }), ); } @@ -537,32 +548,66 @@ export class NmlParseError extends Error { name = "NmlParseError"; } -function _parseInt(obj: Record, key: string, defaultValue?: number): number { +function _parseInt( + obj: Record, + key: string, + options?: { + defaultValue: number | T; + }, +): number | T { if (obj[key] == null || obj[key].length === 0) { - if (defaultValue == null) { + if (options == null) { throw new NmlParseError(`${messages["nml.expected_attribute_missing"]} ${key}`); } else { - return defaultValue; + return options.defaultValue; } } return Number.parseInt(obj[key], 10); } -function _parseFloat(obj: Record, key: string, defaultValue?: number): number { +function _parseFloat( + obj: Record, + key: string, + options?: { + defaultValue: number | T; + }, +): number | T { if (obj[key] == null || obj[key].length === 0) { - if (defaultValue == null) { + if (options == null) { throw new NmlParseError(`${messages["nml.expected_attribute_missing"]} ${key}`); } else { - return defaultValue; + return options.defaultValue; } } return Number.parseFloat(obj[key]); } +function _parseStringArray( + obj: Record, + prefix: string, + options?: { + defaultValue: string[] | undefined; + }, +): string[] | undefined { + const indices = Object.keys(obj) + .map((key) => key.split(`${prefix}-`)) + .filter((splitElements) => splitElements.length === 2) + .map((splitElements) => parseInt(splitElements[1], 10)); + if (indices.length === 0) { + if (options) { + return options.defaultValue; + } else { + throw new NmlParseError(`${messages["nml.expected_attribute_missing"]} ${prefix}`); + } + } + indices.sort((a, b) => a - b); + return indices.map((idx) => obj[`${prefix}-${idx}`]); +} + function _parseTimestamp(obj: Record, key: string, defaultValue?: number): number { - const timestamp = _parseInt(obj, key, defaultValue); + const timestamp = _parseInt(obj, key, defaultValue != null ? { defaultValue } : undefined); const isValid = new Date(timestamp).getTime() > 0; @@ -577,12 +622,18 @@ function _parseTimestamp(obj: Record, key: string, defaultValue? return timestamp; } -function _parseBool(obj: Record, key: string, defaultValue?: boolean): boolean { +function _parseBool( + obj: Record, + key: string, + options?: { + defaultValue: boolean | T; + }, +): boolean | T { if (obj[key] == null || obj[key].length === 0) { - if (defaultValue == null) { + if (options == null) { throw new NmlParseError(`${messages["nml.expected_attribute_missing"]} ${key}`); } else { - return defaultValue; + return options.defaultValue; } } @@ -591,9 +642,9 @@ function _parseBool(obj: Record, key: string, defaultValue?: boo function _parseColor(obj: Record, defaultColor: Vector3): Vector3 { const color = [ - _parseFloat(obj, "color.r", defaultColor[0]), - _parseFloat(obj, "color.g", defaultColor[1]), - _parseFloat(obj, "color.b", defaultColor[2]), + _parseFloat(obj, "color.r", { defaultValue: defaultColor[0] }), + _parseFloat(obj, "color.g", { defaultValue: defaultColor[1] }), + _parseFloat(obj, "color.b", { defaultValue: defaultColor[2] }), ]; // @ts-expect-error ts-migrate(2322) FIXME: Type 'number[]' is not assignable to type 'Vector3... Remove this comment to see the full error message return color; @@ -621,12 +672,18 @@ function _parseTreeType( } } -function _parseEntities(obj: Record, key: string, defaultValue?: string): string { +function _parseEntities( + obj: Record, + key: string, + options?: { + defaultValue: string | T; + }, +): string | T { if (obj[key] == null) { - if (defaultValue == null) { + if (options == null) { throw new NmlParseError(`${messages["nml.expected_attribute_missing"]} ${key}`); } else { - return defaultValue; + return options.defaultValue; } } @@ -790,15 +847,29 @@ function parseBoundingBoxObject(attr: Record): BoundingBoxObject { } function parseUserDefinedProperty(attr: Record): UserDefinedProperty { - return { + const stringValue = _parseEntities(attr, "stringValue", { defaultValue: undefined }); + const boolValue = _parseBool(attr, "boolValue", { defaultValue: undefined }); + const numberValue = _parseFloat(attr, "numberValue", { defaultValue: undefined }); + const stringListValue = _parseStringArray(attr, "stringListValue", { defaultValue: undefined }); + const prop: UserDefinedProperty = { key: _parseEntities(attr, "key"), - stringValue: _parseEntities(attr, "stringValue", undefined), - boolValue: _parseBool(attr, "boolValue", undefined), - numberValue: _parseFloat(attr, "numberValue", undefined), - // todop: - // @ts-ignore - stringListValue: _parseArray(attr, "stringListValue", undefined), + stringValue, + boolValue, + numberValue, + stringListValue, }; + const compactProp = Object.fromEntries( + Object.entries(prop).filter(([_k, v]) => v !== undefined), + ) as UserDefinedProperty; + if (Object.entries(compactProp).length !== 2) { + throw new NmlParseError( + `Could not parse user-defined property. Expected exactly one key and one value. Got: ${Object.keys( + compactProp, + )}`, + ); + } + + return compactProp; } export function parseNml(nmlString: string): Promise<{ @@ -817,6 +888,7 @@ export function parseNml(nmlString: string): Promise<{ const existingEdges = new Set(); let currentTree: MutableTree | null | undefined = null; let currentTreeGroup: TreeGroup | null | undefined = null; + let currentNode: MutableNode | null | undefined = null; let containedVolumes = false; let isParsingVolumeTag = false; @@ -835,13 +907,15 @@ export function parseNml(nmlString: string): Promise<{ } case "thing": { - const groupId = _parseInt(attr, "groupId", -1); + const groupId = _parseInt(attr, "groupId", { defaultValue: -1 }); currentTree = { treeId: _parseInt(attr, "id"), color: _parseColor(attr, DEFAULT_COLOR), // In Knossos NMLs, there is usually a tree comment instead of a name - name: _parseEntities(attr, "name", "") || _parseEntities(attr, "comment", ""), + name: + _parseEntities(attr, "name", { defaultValue: "" }) || + _parseEntities(attr, "comment", { defaultValue: "" }), comments: [], nodes: new DiffableMap(), branchPoints: [], @@ -850,8 +924,7 @@ export function parseNml(nmlString: string): Promise<{ isVisible: _parseFloat(attr, "color.a") !== 0, groupId: groupId >= 0 ? groupId : DEFAULT_GROUP_ID, type: _parseTreeType(attr, "type", TreeTypeEnum.DEFAULT), - edgesAreVisible: _parseBool(attr, "edgesAreVisible", true), - // todop + edgesAreVisible: _parseBool(attr, "edgesAreVisible", { defaultValue: true }), userDefinedProperties: [], }; if (trees[currentTree.treeId] != null) @@ -861,9 +934,9 @@ export function parseNml(nmlString: string): Promise<{ } case "node": { - const nodeId = _parseInt(attr, "id"); + const nodeId = _parseInt(attr, "id"); - const currentNode = { + currentNode = { id: nodeId, untransformedPosition: [ Math.trunc(_parseFloat(attr, "x")), @@ -876,18 +949,20 @@ export function parseNml(nmlString: string): Promise<{ .filter(([_key, name]) => name != null) .map(([key, name]) => ({ name, - value: _parseFloat(attr, key, 0), + value: _parseFloat(attr, key, { defaultValue: 0 }), })) as AdditionalCoordinate[], rotation: [ - _parseFloat(attr, "rotX", DEFAULT_ROTATION[0]), - _parseFloat(attr, "rotY", DEFAULT_ROTATION[1]), - _parseFloat(attr, "rotZ", DEFAULT_ROTATION[2]), + _parseFloat(attr, "rotX", { defaultValue: DEFAULT_ROTATION[0] }), + _parseFloat(attr, "rotY", { defaultValue: DEFAULT_ROTATION[1] }), + _parseFloat(attr, "rotZ", { defaultValue: DEFAULT_ROTATION[2] }), ] as Vector3, - interpolation: _parseBool(attr, "interpolation", DEFAULT_INTERPOLATION), - bitDepth: _parseInt(attr, "bitDepth", DEFAULT_BITDEPTH), - viewport: _parseInt(attr, "inVp", DEFAULT_VIEWPORT), - resolution: _parseInt(attr, "inMag", DEFAULT_RESOLUTION), - radius: _parseFloat(attr, "radius", Constants.DEFAULT_NODE_RADIUS), + interpolation: _parseBool(attr, "interpolation", { + defaultValue: DEFAULT_INTERPOLATION, + }), + bitDepth: _parseInt(attr, "bitDepth", { defaultValue: DEFAULT_BITDEPTH }), + viewport: _parseInt(attr, "inVp", { defaultValue: DEFAULT_VIEWPORT }), + resolution: _parseInt(attr, "inMag", { defaultValue: DEFAULT_RESOLUTION }), + radius: _parseFloat(attr, "radius", { defaultValue: Constants.DEFAULT_NODE_RADIUS }), timestamp: _parseTimestamp(attr, "time", DEFAULT_TIMESTAMP), }; if (currentTree == null) @@ -897,6 +972,10 @@ export function parseNml(nmlString: string): Promise<{ nodeIdToTreeId[nodeId] = currentTree.treeId; currentTree.nodes.mutableSet(currentNode.id, currentNode); existingNodeIds.add(currentNode.id); + + if (node.isSelfClosing) { + currentNode = null; + } break; } @@ -904,14 +983,18 @@ export function parseNml(nmlString: string): Promise<{ if (currentTree == null) { throw new NmlParseError(messages["nml.user_defined_property_outside_tree"]); } - - currentTree.userDefinedProperties.push(parseUserDefinedProperty(attr)); + if (currentNode == null) { + currentTree.userDefinedProperties.push(parseUserDefinedProperty(attr)); + } else { + // todop: also handle for nodes in this PR? + } + break; } case "edge": { const currentEdge = { - source: _parseInt(attr, "source"), - target: _parseInt(attr, "target"), + source: _parseInt(attr, "source"), + target: _parseInt(attr, "target"), }; const edgeHash = getEdgeHash(currentEdge.source, currentEdge.target); if (currentTree == null) @@ -942,8 +1025,8 @@ export function parseNml(nmlString: string): Promise<{ case "comment": { const currentComment = { - nodeId: _parseInt(attr, "node"), - content: _parseEntities(attr, "content"), + nodeId: _parseInt(attr, "node"), + content: _parseEntities(attr, "content"), }; const tree = trees[nodeIdToTreeId[currentComment.nodeId]]; if (tree == null) @@ -956,8 +1039,8 @@ export function parseNml(nmlString: string): Promise<{ case "branchpoint": { const currentBranchpoint = { - nodeId: _parseInt(attr, "id"), - timestamp: _parseInt(attr, "time", DEFAULT_TIMESTAMP), + nodeId: _parseInt(attr, "id"), + timestamp: _parseInt(attr, "time", { defaultValue: DEFAULT_TIMESTAMP }), }; const tree = trees[nodeIdToTreeId[currentBranchpoint.nodeId]]; if (tree == null) @@ -973,9 +1056,9 @@ export function parseNml(nmlString: string): Promise<{ return; } const newGroup = { - groupId: _parseInt(attr, "id"), - name: _parseEntities(attr, "name"), - isExpanded: _parseBool(attr, "isExpanded", true), + groupId: _parseInt(attr, "id"), + name: _parseEntities(attr, "name"), + isExpanded: _parseBool(attr, "isExpanded", { defaultValue: true }), children: [], }; if (existingTreeGroupIds.has(newGroup.groupId)) { @@ -1000,7 +1083,7 @@ export function parseNml(nmlString: string): Promise<{ } case "userBoundingBox": { - const parsedUserBoundingBoxId = _parseInt(attr, "id", 0); + const parsedUserBoundingBoxId = _parseInt(attr, "id", { defaultValue: 0 }); const userBoundingBoxId = getUnusedUserBoundingBoxId( userBoundingBoxes, @@ -1011,8 +1094,12 @@ export function parseNml(nmlString: string): Promise<{ boundingBox: Utils.computeBoundingBoxFromBoundingBoxObject(boundingBoxObject), color: _parseColor(attr, DEFAULT_COLOR), id: userBoundingBoxId, - isVisible: _parseBool(attr, "isVisible", DEFAULT_USER_BOUNDING_BOX_VISIBILITY), - name: _parseEntities(attr, "name", `user bounding box ${userBoundingBoxId}`), + isVisible: _parseBool(attr, "isVisible", { + defaultValue: DEFAULT_USER_BOUNDING_BOX_VISIBILITY, + }), + name: _parseEntities(attr, "name", { + defaultValue: `user bounding box ${userBoundingBoxId}`, + }), }; userBoundingBoxes.push(userBoundingBox); break; @@ -1057,6 +1144,11 @@ export function parseNml(nmlString: string): Promise<{ break; } + case "node": { + currentNode = null; + break; + } + case "group": { if (!isParsingVolumeTag) { if (currentTreeGroup != null) { diff --git a/frontend/javascripts/test/libs/nml.spec.ts b/frontend/javascripts/test/libs/nml.spec.ts index 448dab46526..b13da84323c 100644 --- a/frontend/javascripts/test/libs/nml.spec.ts +++ b/frontend/javascripts/test/libs/nml.spec.ts @@ -455,6 +455,73 @@ test("NML serializer should produce correct NMLs with additional coordinates", ( id: "nml-with-additional-coordinates", }); }); + +test("NML serializer should produce correct NMLs with userDefinedProperties for trees", async (t) => { + const properties = [ + { + key: "key of string", + stringValue: "string value", + }, + { + key: "key of true", + boolValue: true, + }, + { + key: "key of false", + boolValue: false, + }, + { + key: "key of number", + numberValue: 1234, + }, + { + key: "key of string list", + stringListValue: ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"], + }, + ]; + const state = update(initialState, { + tracing: { + skeleton: { + trees: { + "1": { + userDefinedProperties: { + $set: properties, + }, + }, + }, + }, + }, + }); + const serializedNml = serializeToNml( + state, + state.tracing, + enforceSkeletonTracing(state.tracing), + BUILD_INFO, + false, + ); + + t.true( + serializedNml.includes( + '', + ), + ); + + t.true(serializedNml.includes('')); + t.true(serializedNml.includes('')); + t.true(serializedNml.includes('')); + t.true( + serializedNml.includes( + '', + ), + ); + + const { trees } = await parseNml(serializedNml); + if (state.tracing.skeleton == null) { + throw new Error("Unexpected null for skeleton"); + } + t.deepEqual(state.tracing.skeleton.trees[1], trees[1]); +}); + test("NML serializer should escape special characters and multilines", (t) => { const state = update(initialState, { tracing: { From 79d02694421ead50739bd53c5abfe39b683b5a79 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Mon, 2 Sep 2024 14:45:01 +0200 Subject: [PATCH 19/79] start unifying metadata tables --- .../dashboard/folders/metadata_table.tsx | 135 +++++++++++------- .../right-border-tabs/tree_hierarchy_view.tsx | 93 ++++++++---- 2 files changed, 153 insertions(+), 75 deletions(-) diff --git a/frontend/javascripts/dashboard/folders/metadata_table.tsx b/frontend/javascripts/dashboard/folders/metadata_table.tsx index b2fc7d2b04c..91db449cefc 100644 --- a/frontend/javascripts/dashboard/folders/metadata_table.tsx +++ b/frontend/javascripts/dashboard/folders/metadata_table.tsx @@ -26,7 +26,7 @@ import React, { useEffect } from "react"; import { useState } from "react"; import { APIDataset, Folder, APIMetadata, APIMetadataEnum } from "types/api_flow_types"; -type APIMetadataWithError = APIMetadata & { error?: string | null }; +export type APIMetadataWithError = APIMetadata & { error?: string | null }; type IndexedMetadataEntries = APIMetadataWithError[]; function getMetadataTypeLabel(type: APIMetadata["type"]) { @@ -409,56 +409,17 @@ export default function MetadataTable({
{/* Not using AntD Table to have more control over the styling. */} {metadata.length > 0 ? ( - - {/* Each row except the last row has a custom horizontal divider created via a css pseudo element. */} - - - - - - - - {metadata.map((record, index) => ( - - - - - - - ))} - - - - -
Property - Value -
{getKeyInput(record, index)}: - - {getDeleteEntryButton(record, index)}
-
- - - -
-
+ ) : ( )} @@ -466,3 +427,75 @@ export default function MetadataTable({
); } + +export function InnerMetadataTable({ + metadata, + getKeyInput, + focusedRow, + setFocusedRow, + updateMetadataValue, + isSaving, + availableStrArrayTagOptions, + getDeleteEntryButton, + addNewEntryMenuItems, +}: { + metadata: IndexedMetadataEntries; + getKeyInput: (record: APIMetadataWithError, index: number) => JSX.Element; + focusedRow: number | null; + setFocusedRow: (newState: number | ((prevState: number | null) => number | null) | null) => void; + updateMetadataValue: (indexToUpdate: number, newValue: number | string | string[]) => void; + isSaving: boolean; + availableStrArrayTagOptions: { value: string; label: string }[]; + getDeleteEntryButton: (_: APIMetadataWithError, index: number) => JSX.Element; + addNewEntryMenuItems: MenuProps; +}): React.ReactElement { + return ( + + {/* Each row except the last row has a custom horizontal divider created via a css pseudo element. */} + + + + + + + + {metadata.map((record, index) => ( + + + + + + + ))} + + + + +
Property + Value +
{getKeyInput(record, index)}: + + {getDeleteEntryButton(record, index)}
+
+ + + +
+
+ ); +} diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx index a52ea0a3477..52cc4571a2d 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx @@ -1,5 +1,5 @@ import { DownOutlined } from "@ant-design/icons"; -import { Tree as AntdTree, GetRef, MenuProps, Modal, TreeProps } from "antd"; +import { Tree as AntdTree, GetRef, Input, MenuProps, Modal, TreeProps } from "antd"; import React, { useCallback, useEffect, useRef, useState } from "react"; import { AutoSizer } from "react-virtualized"; import { mapGroups } from "oxalis/model/accessors/skeletontracing_accessor"; @@ -38,6 +38,7 @@ import { import { ResizableSplitPane } from "./resizable_split_pane"; import { InputWithUpdateOnBlur, UserDefinedTableRows } from "./user_defined_properties_table"; import { UserDefinedProperty } from "types/api_flow_types"; +import { APIMetadataWithError, InnerMetadataTable } from "dashboard/folders/metadata_table"; const onCheck: TreeProps["onCheck"] = (_checkedKeysValue, info) => { const { id, type } = info.node; @@ -354,30 +355,74 @@ function DetailsForSelection({ if (tree == null) { return <>Cannot find details for selected tree.; } + + // todop + const getKeyInput = (record: APIMetadataWithError, _index: number) => { + return ( + setFocusedRow(index)} + // onBlur={() => setFocusedRow(null)} + value={record.key} + // onChange={(evt) => updateMetadataKey(index, evt.target.value)} + placeholder="Property" + size="small" + // disabled={isSaving} + // id={getKeyInputIdForIndex(index)} + /> + ); + }; + return ( - - - - - - - - - - - ) => { - updateUserDefinedProperty(tree, oldKey, propPartial); - }} - /> - -
ID{tree.treeId}
Name - Store.dispatch(setTreeNameAction(newValue, tree.treeId))} - /> -
+
+ + + + + + + + + + + ) => { + updateUserDefinedProperty(tree, oldKey, propPartial); + }} + /> + +
ID{tree.treeId}
Name + Store.dispatch(setTreeNameAction(newValue, tree.treeId))} + /> +
+ + {/* + // todop + export type APIMetadata = { + type: APIMetadataType; + key: string; + value: string | number | string[]; + };*/} + + ({ + key: prop.key, + type: "string", + value: prop.stringValue || "", + }))} + getKeyInput={getKeyInput} + focusedRow={null} + setFocusedRow={() => {}} + updateMetadataValue={() => {}} + isSaving={false} + availableStrArrayTagOptions={[]} + getDeleteEntryButton={() =>
} + addNewEntryMenuItems={{}} + /> +
); } return null; From e13ad220484019f41411e6ea9b3588f8d6805dbc Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Mon, 2 Sep 2024 15:29:58 +0200 Subject: [PATCH 20/79] remove superfluous type wrapper --- frontend/javascripts/dashboard/folders/metadata_table.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/frontend/javascripts/dashboard/folders/metadata_table.tsx b/frontend/javascripts/dashboard/folders/metadata_table.tsx index 528dc0c52e2..0f0bfc42970 100644 --- a/frontend/javascripts/dashboard/folders/metadata_table.tsx +++ b/frontend/javascripts/dashboard/folders/metadata_table.tsx @@ -33,7 +33,6 @@ import { } from "types/api_flow_types"; export type APIMetadataWithError = APIMetadata & { error?: string | null }; -type IndexedMetadataEntries = APIMetadataWithError[]; function getMetadataTypeLabel(type: APIMetadata["type"]) { switch (type) { @@ -146,7 +145,7 @@ const MetadataValueInput: React.FC = ({ const saveCurrentMetadata = async ( datasetOrFolderToUpdate: APIDataset | Folder, - metadata: IndexedMetadataEntries, + metadata: APIMetadataWithError[], context: DatasetCollectionContextValue, setIsSaving: (isSaving: boolean) => void, setHasUnsavedChanges: (hasUnsavedChanges: boolean) => void, @@ -227,7 +226,7 @@ export default function MetadataTable({ datasetOrFolder, }: { datasetOrFolder: APIDataset | Folder }) { const context = useDatasetCollectionContext(); - const [metadata, metadataRef, setMetadata] = useStateWithRef( + const [metadata, metadataRef, setMetadata] = useStateWithRef( datasetOrFolder?.metadata?.map((entry) => ({ ...entry, error: null })) || [], ); const [focusedRow, focusedRowRef, setFocusedRow] = useStateWithRef(null); @@ -445,7 +444,7 @@ export function InnerMetadataTable({ getDeleteEntryButton, addNewEntryMenuItems, }: { - metadata: IndexedMetadataEntries; + metadata: APIMetadataWithError[]; getKeyInput: (record: APIMetadataWithError, index: number) => JSX.Element; focusedRow: number | null; setFocusedRow: (newState: number | ((prevState: number | null) => number | null) | null) => void; From b8075158344e895023d92db0dd1b79a410a76d24 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Mon, 2 Sep 2024 17:46:44 +0200 Subject: [PATCH 21/79] integrate dashboards metadata table into tree hierarchy view (styling + string-based props) --- .../dashboard/folders/metadata_table.tsx | 95 +++++++----- .../resizable_split_pane.tsx | 9 +- .../right-border-tabs/tree_hierarchy_view.tsx | 142 ++++++++++++------ .../user_defined_properties_table.tsx | 80 ++++------ .../stylesheets/trace_view/_right_menu.less | 7 + 5 files changed, 192 insertions(+), 141 deletions(-) diff --git a/frontend/javascripts/dashboard/folders/metadata_table.tsx b/frontend/javascripts/dashboard/folders/metadata_table.tsx index 0f0bfc42970..633cf7c0dd6 100644 --- a/frontend/javascripts/dashboard/folders/metadata_table.tsx +++ b/frontend/javascripts/dashboard/folders/metadata_table.tsx @@ -22,6 +22,7 @@ import { import { useIsMounted, useStateWithRef } from "libs/react_hooks"; import Toast from "libs/toast"; import _ from "lodash"; +import { InputWithUpdateOnBlur } from "oxalis/view/right-border-tabs/user_defined_properties_table"; import type React from "react"; import { useEffect } from "react"; import { useState } from "react"; @@ -121,9 +122,9 @@ const MetadataValueInput: React.FC = ({ ); case APIMetadataEnum.STRING: return ( - updateMetadataValue(index, evt.target.value)} + updateMetadataValue(index, newValue as string)} {...sharedProps} /> ); @@ -443,6 +444,8 @@ export function InnerMetadataTable({ availableStrArrayTagOptions, getDeleteEntryButton, addNewEntryMenuItems, + isVisualStudioTheme, + onlyReturnRows }: { metadata: APIMetadataWithError[]; getKeyInput: (record: APIMetadataWithError, index: number) => JSX.Element; @@ -453,53 +456,67 @@ export function InnerMetadataTable({ availableStrArrayTagOptions: { value: string; label: string }[]; getDeleteEntryButton: (_: APIMetadataWithError, index: number) => JSX.Element; addNewEntryMenuItems: MenuProps; + isVisualStudioTheme?: boolean; + onlyReturnRows?: boolean; }): React.ReactElement { + const rows = <> + {metadata.map((record, index) => ( + + {getKeyInput(record, index)} + {isVisualStudioTheme ? null : :} + + + + {getDeleteEntryButton(record, index)} + + ))} + + +
+ + + +
+ + + ; + + if (onlyReturnRows) { + return rows; + } + return ( - +
{/* Each row except the last row has a custom horizontal divider created via a css pseudo element. */} - - {metadata.map((record, index) => ( - - - - - - - ))} - - - + {rows}
Property + {isVisualStudioTheme ? null : } Value
{getKeyInput(record, index)}: - - {getDeleteEntryButton(record, index)}
-
- - - -
-
); diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/resizable_split_pane.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/resizable_split_pane.tsx index 0106a88ed9e..46c047c34eb 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/resizable_split_pane.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/resizable_split_pane.tsx @@ -1,11 +1,16 @@ import { Divider } from "antd"; -import React, { useEffect, useRef, useState } from "react"; +import type React from "react"; +import { useEffect, useRef, useState } from "react"; + +// todop: change back to 100? +// should this be memorized per user? local storage maybe? then, it's sync and device specific +const INITIAL_HEIGHT = 400; export function ResizableSplitPane({ firstChild, secondChild, }: { firstChild: React.ReactElement; secondChild: React.ReactElement | null }) { - const [heightForSecondChild, setHeightForSecondChild] = useState(100); + const [heightForSecondChild, setHeightForSecondChild] = useState(INITIAL_HEIGHT); const dividerRef = useRef(null); const containerRef = useRef(null); const isResizingRef = useRef(false); diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx index e1285e88edb..74e18589714 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx @@ -1,5 +1,5 @@ -import { DownOutlined } from "@ant-design/icons"; -import { Tree as AntdTree, type GetRef, Input, type MenuProps, Modal, type TreeProps } from "antd"; +import { DeleteOutlined, DownOutlined, TagsOutlined } from "@ant-design/icons"; +import { Tree as AntdTree, Button, type GetRef, Input, type MenuProps, Modal, type TreeProps } from "antd"; import React, { useCallback, useEffect, useRef, useState } from "react"; import { AutoSizer } from "react-virtualized"; import { mapGroups } from "oxalis/model/accessors/skeletontracing_accessor"; @@ -36,7 +36,7 @@ import { setUpdateTreeGroups, } from "./tree_hierarchy_renderers"; import { ResizableSplitPane } from "./resizable_split_pane"; -import { InputWithUpdateOnBlur, UserDefinedTableRows } from "./user_defined_properties_table"; +import { InputWithUpdateOnBlur } from "./user_defined_properties_table"; import { UserDefinedProperty } from "types/api_flow_types"; import { APIMetadataWithError, InnerMetadataTable } from "dashboard/folders/metadata_table"; @@ -285,12 +285,12 @@ function TreeHierarchyView(props: Props) { node.type === GroupTypeEnum.TREE ? renderTreeNode(props, onOpenContextMenu, hideContextMenu, node) : renderGroupNode( - props, - onOpenContextMenu, - hideContextMenu, - node, - expandedNodeKeys, - ) + props, + onOpenContextMenu, + hideContextMenu, + node, + expandedNodeKeys, + ) } switcherIcon={} onSelect={(_selectedKeys, info: { node: TreeNode; nativeEvent: MouseEvent }) => @@ -336,9 +336,29 @@ const updateUserDefinedProperty = ( tree.userDefinedProperties.map((element) => element.key === oldPropKey ? { - ...element, - ...newPropPartial, - } + ...element, + ...newPropPartial, + } + : element, + ), + tree.treeId, + ), + ); +}; + +const updateUserDefinedPropertyByIndex = ( + tree: Tree, + index: number, + newPropPartial: Partial, +) => { + Store.dispatch( + setTreeUserDefinedPropertiesAction( + tree.userDefinedProperties.map((element, idx) => + idx === index + ? { + ...element, + ...newPropPartial, + } : element, ), tree.treeId, @@ -355,20 +375,43 @@ function DetailsForSelection({ if (tree == null) { return <>Cannot find details for selected tree.; } + // todop + const isReadOnly = false; + + const getDeleteEntryButton = (_: APIMetadataWithError, index: number) => ( +
+
+ ); // todop - const getKeyInput = (record: APIMetadataWithError, _index: number) => { + const getKeyInput = (record: APIMetadataWithError, index: number) => { return ( - setFocusedRow(index)} // onBlur={() => setFocusedRow(null)} value={record.key} - // onChange={(evt) => updateMetadataKey(index, evt.target.value)} + onChange={(value) => updateUserDefinedPropertyByIndex(tree, index, { key: value })} placeholder="Property" size="small" - // disabled={isSaving} - // id={getKeyInputIdForIndex(index)} + // disabled={isSaving} + // id={getKeyInputIdForIndex(index)} /> ); }; @@ -379,49 +422,54 @@ function DetailsForSelection({ ID - {tree.treeId} + {tree.treeId} Name - + Store.dispatch(setTreeNameAction(newValue, tree.treeId))} /> - ) => { - updateUserDefinedProperty(tree, oldKey, propPartial); - }} + + + User-defined Properties + + + {/* + // todop + export type APIMetadata = { + type: APIMetadataType; + key: string; + value: string | number | string[]; + };*/} + ({ + key: prop.key, + type: "string", + value: prop.stringValue || "", + }))} + getKeyInput={getKeyInput} + focusedRow={null} + setFocusedRow={() => {}} + updateMetadataValue={ + (indexToUpdate: number, newValue: number | string | string[]) => { + updateUserDefinedPropertyByIndex(tree, indexToUpdate, { stringValue: newValue as string }) + } + } + isSaving={false} + availableStrArrayTagOptions={[]} + getDeleteEntryButton={getDeleteEntryButton} + addNewEntryMenuItems={{}} /> - {/* - // todop - export type APIMetadata = { - type: APIMetadataType; - key: string; - value: string | number | string[]; - };*/} - - ({ - key: prop.key, - type: "string", - value: prop.stringValue || "", - }))} - getKeyInput={getKeyInput} - focusedRow={null} - setFocusedRow={() => {}} - updateMetadataValue={() => {}} - isSaving={false} - availableStrArrayTagOptions={[]} - getDeleteEntryButton={() =>
} - addNewEntryMenuItems={{}} - /> +
); } diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/user_defined_properties_table.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/user_defined_properties_table.tsx index 54689d04b8e..7019035b74d 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/user_defined_properties_table.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/user_defined_properties_table.tsx @@ -1,71 +1,45 @@ -import { TagsOutlined } from "@ant-design/icons"; -import React, { useEffect, useState } from "react"; -import type { UserDefinedProperty } from "types/api_flow_types"; +import { Input, type InputProps } from "antd"; +import type React from "react"; +import { useCallback, useEffect, useState } from "react"; export function InputWithUpdateOnBlur({ value, onChange, -}: { value: string; onChange: (value: string) => void }) { + onBlur, + ...props +}: { value: string; onChange: (value: string) => void } & InputProps) { const [localValue, setLocalValue] = useState(value); + const onKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "Enter") { + onChange(localValue); + } else if (event.key === "Escape") { + document.activeElement ? (document.activeElement as HTMLElement).blur() : null; + } + if (props.onKeyDown) { + return props.onKeyDown(event); + } + }, + [onChange, props.onKeyDown, localValue], + ); + useEffect(() => { setLocalValue(value); }, [value]); return ( - onChange(localValue)} + onBlur={(event) => { + if (onBlur) onBlur(event); + onChange(localValue); + }} onChange={(event) => { setLocalValue(event.currentTarget.value); }} + onKeyDown={onKeyDown} + {...props} /> ); } - -export function UserDefinedTableRows({ - userDefinedProperties, - onChange, -}: { - userDefinedProperties: UserDefinedProperty[] | null; - onChange: (oldKey: string, propPartial: Partial) => void; -}) { - if (userDefinedProperties == null || userDefinedProperties.length === 0) { - return null; - } - return ( - <> - - - User-defined Properties - - - {userDefinedProperties.map((prop) => ( - - - - onChange(prop.key, { - key: newKey, - }) - } - /> - - - - { - onChange(prop.key, { - stringValue: newValue, - }); - }} - /> - - - ))} - - ); -} diff --git a/frontend/stylesheets/trace_view/_right_menu.less b/frontend/stylesheets/trace_view/_right_menu.less index e6bba1ab22d..d06441f4f61 100644 --- a/frontend/stylesheets/trace_view/_right_menu.less +++ b/frontend/stylesheets/trace_view/_right_menu.less @@ -243,6 +243,13 @@ background-color: var(--compact-table-bg1); } + td:nth-child(2):not(:last-child) { + border-right: 0; + } + td:nth-child(3) { + border-left: 0; + } + tr:nth-child(even) td { background-color: var(--compact-table-bg2); } From 907dc1602d582f710c643c6f906cc848d0649a21 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Tue, 3 Sep 2024 09:39:06 +0200 Subject: [PATCH 22/79] also integrate with segments tab --- .../dashboard/folders/metadata_table.tsx | 71 +++++----- .../model/actions/skeletontracing_actions.tsx | 4 +- .../model/actions/volumetracing_actions.ts | 2 +- .../oxalis/model/helpers/nml_helpers.ts | 2 +- .../oxalis/model/sagas/update_actions.ts | 2 +- .../segments_tab/segments_view.tsx | 29 ++-- .../right-border-tabs/tree_hierarchy_view.tsx | 134 ++++-------------- .../user_defined_properties_table.tsx | 101 ++++++++++++- 8 files changed, 179 insertions(+), 166 deletions(-) diff --git a/frontend/javascripts/dashboard/folders/metadata_table.tsx b/frontend/javascripts/dashboard/folders/metadata_table.tsx index 633cf7c0dd6..fb24525a2c2 100644 --- a/frontend/javascripts/dashboard/folders/metadata_table.tsx +++ b/frontend/javascripts/dashboard/folders/metadata_table.tsx @@ -445,7 +445,7 @@ export function InnerMetadataTable({ getDeleteEntryButton, addNewEntryMenuItems, isVisualStudioTheme, - onlyReturnRows + onlyReturnRows, }: { metadata: APIMetadataWithError[]; getKeyInput: (record: APIMetadataWithError, index: number) => JSX.Element; @@ -459,42 +459,39 @@ export function InnerMetadataTable({ isVisualStudioTheme?: boolean; onlyReturnRows?: boolean; }): React.ReactElement { - const rows = <> - {metadata.map((record, index) => ( - - {getKeyInput(record, index)} - {isVisualStudioTheme ? null : :} - - + const rows = ( + <> + {metadata.map((record, index) => ( + + {getKeyInput(record, index)} + {isVisualStudioTheme ? null : :} + + + + {getDeleteEntryButton(record, index)} + + ))} + + +
+ + + +
- {getDeleteEntryButton(record, index)} - ))} - - -
- - - -
- - - ; + + ); if (onlyReturnRows) { return rows; @@ -515,9 +512,7 @@ export function InnerMetadataTable({ - - {rows} - + {rows} ); } diff --git a/frontend/javascripts/oxalis/model/actions/skeletontracing_actions.tsx b/frontend/javascripts/oxalis/model/actions/skeletontracing_actions.tsx index a6c2056b63e..6bfd490ce01 100644 --- a/frontend/javascripts/oxalis/model/actions/skeletontracing_actions.tsx +++ b/frontend/javascripts/oxalis/model/actions/skeletontracing_actions.tsx @@ -12,10 +12,10 @@ import { AllUserBoundingBoxActions } from "oxalis/model/actions/annotation_actio import type { MutableTreeMap, OxalisState, SkeletonTracing, TreeGroup } from "oxalis/store"; import Store from "oxalis/store"; import RemoveTreeModal from "oxalis/view/remove_tree_modal"; -import { Key } from "react"; +import type { Key } from "react"; import { batchActions } from "redux-batched-actions"; import type { ServerSkeletonTracing, UserDefinedProperty } from "types/api_flow_types"; -import { type AdditionalCoordinate } from "types/api_flow_types"; +import type { AdditionalCoordinate } from "types/api_flow_types"; export type InitializeSkeletonTracingAction = ReturnType; export type CreateNodeAction = ReturnType; diff --git a/frontend/javascripts/oxalis/model/actions/volumetracing_actions.ts b/frontend/javascripts/oxalis/model/actions/volumetracing_actions.ts index 49f98f91579..62501c996ee 100644 --- a/frontend/javascripts/oxalis/model/actions/volumetracing_actions.ts +++ b/frontend/javascripts/oxalis/model/actions/volumetracing_actions.ts @@ -7,7 +7,7 @@ import type { Dispatch } from "redux"; import { AllUserBoundingBoxActions } from "oxalis/model/actions/annotation_actions"; import type { QuickSelectGeometry } from "oxalis/geometries/helper_geometries"; import { batchActions } from "redux-batched-actions"; -import { type AdditionalCoordinate, type UserDefinedProperty } from "types/api_flow_types"; +import type { AdditionalCoordinate, UserDefinedProperty } from "types/api_flow_types"; import _ from "lodash"; export type InitializeVolumeTracingAction = ReturnType; diff --git a/frontend/javascripts/oxalis/model/helpers/nml_helpers.ts b/frontend/javascripts/oxalis/model/helpers/nml_helpers.ts index 16e11053ceb..22f03a60fc9 100644 --- a/frontend/javascripts/oxalis/model/helpers/nml_helpers.ts +++ b/frontend/javascripts/oxalis/model/helpers/nml_helpers.ts @@ -594,7 +594,7 @@ function _parseStringArray( const indices = Object.keys(obj) .map((key) => key.split(`${prefix}-`)) .filter((splitElements) => splitElements.length === 2) - .map((splitElements) => parseInt(splitElements[1], 10)); + .map((splitElements) => Number.parseInt(splitElements[1], 10)); if (indices.length === 0) { if (options) { return options.defaultValue; diff --git a/frontend/javascripts/oxalis/model/sagas/update_actions.ts b/frontend/javascripts/oxalis/model/sagas/update_actions.ts index 10175c3be0a..74bacf86e21 100644 --- a/frontend/javascripts/oxalis/model/sagas/update_actions.ts +++ b/frontend/javascripts/oxalis/model/sagas/update_actions.ts @@ -10,7 +10,7 @@ import type { NumberLike, } from "oxalis/store"; import { convertUserBoundingBoxesFromFrontendToServer } from "oxalis/model/reducers/reducer_helpers"; -import { AdditionalCoordinate, UserDefinedProperty } from "types/api_flow_types"; +import type { AdditionalCoordinate, UserDefinedProperty } from "types/api_flow_types"; export type NodeWithTreeId = { treeId: number; diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx index e1b1dca84c9..37c2947af06 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx @@ -32,7 +32,7 @@ import { Tree, type MenuProps, } from "antd"; -import { DataNode } from "antd/lib/tree"; +import type { DataNode } from "antd/lib/tree"; import { ChangeColorMenuItemContent } from "components/color_picker"; import FastTooltip from "components/fast_tooltip"; import Toast from "libs/toast"; @@ -108,7 +108,7 @@ import { type SegmentHierarchyNode, } from "oxalis/view/right-border-tabs/segments_tab/segments_view_helper"; import type RcTree from "rc-tree"; -import React, { Key } from "react"; +import React, { type Key } from "react"; import { connect, useSelector } from "react-redux"; import { AutoSizer } from "react-virtualized"; import type { Dispatch } from "redux"; @@ -120,7 +120,7 @@ import type { UserDefinedProperty, } from "types/api_flow_types"; import { APIJobType, type AdditionalCoordinate } from "types/api_flow_types"; -import { ValueOf } from "types/globals"; +import type { ValueOf } from "types/globals"; import AdvancedSearchPopover from "../advanced_search_popover"; import DeleteGroupModalView from "../delete_group_modal_view"; import { ResizableSplitPane } from "../resizable_split_pane"; @@ -132,9 +132,12 @@ import { getGroupNodeKey, MISSING_GROUP_ID, } from "../tree_hierarchy_view_helpers"; -import { InputWithUpdateOnBlur, UserDefinedTableRows } from "../user_defined_properties_table"; +import { + InputWithUpdateOnBlur, + UserDefinedPropertyTableRows, +} from "../user_defined_properties_table"; import { SegmentStatisticsModal } from "./segment_statistics_modal"; -import { ItemType } from "antd/lib/menu/interface"; +import type { ItemType } from "antd/lib/menu/interface"; const { confirm } = Modal; const { Option } = Select; @@ -1954,11 +1957,9 @@ class SegmentsView extends React.Component { /> - ) => { - this.updateUserDefinedProperty(segment, oldKey, propPartial); - }} + @@ -1967,9 +1968,9 @@ class SegmentsView extends React.Component { return null; } - updateUserDefinedProperty = ( + updateUserDefinedPropertyByIndex = ( segment: Segment, - oldPropKey: string, + index: number, newPropPartial: Partial, ) => { if (this.props.visibleSegmentationLayer == null) { @@ -1978,8 +1979,8 @@ class SegmentsView extends React.Component { this.props.updateSegment( segment.id, { - userDefinedProperties: segment.userDefinedProperties.map((element) => - element.key === oldPropKey + userDefinedProperties: segment.userDefinedProperties.map((element, idx) => + idx === index ? { ...element, ...newPropPartial, diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx index 74e18589714..106702b51a2 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx @@ -1,5 +1,13 @@ import { DeleteOutlined, DownOutlined, TagsOutlined } from "@ant-design/icons"; -import { Tree as AntdTree, Button, type GetRef, Input, type MenuProps, Modal, type TreeProps } from "antd"; +import { + Tree as AntdTree, + Button, + type GetRef, + Input, + type MenuProps, + Modal, + type TreeProps, +} from "antd"; import React, { useCallback, useEffect, useRef, useState } from "react"; import { AutoSizer } from "react-virtualized"; import { mapGroups } from "oxalis/model/accessors/skeletontracing_accessor"; @@ -36,9 +44,12 @@ import { setUpdateTreeGroups, } from "./tree_hierarchy_renderers"; import { ResizableSplitPane } from "./resizable_split_pane"; -import { InputWithUpdateOnBlur } from "./user_defined_properties_table"; -import { UserDefinedProperty } from "types/api_flow_types"; -import { APIMetadataWithError, InnerMetadataTable } from "dashboard/folders/metadata_table"; +import { + InputWithUpdateOnBlur, + UserDefinedPropertyTableRows, +} from "./user_defined_properties_table"; +import type { UserDefinedProperty } from "types/api_flow_types"; +import { APIMetadataWithError } from "dashboard/folders/metadata_table"; const onCheck: TreeProps["onCheck"] = (_checkedKeysValue, info) => { const { id, type } = info.node; @@ -285,12 +296,12 @@ function TreeHierarchyView(props: Props) { node.type === GroupTypeEnum.TREE ? renderTreeNode(props, onOpenContextMenu, hideContextMenu, node) : renderGroupNode( - props, - onOpenContextMenu, - hideContextMenu, - node, - expandedNodeKeys, - ) + props, + onOpenContextMenu, + hideContextMenu, + node, + expandedNodeKeys, + ) } switcherIcon={} onSelect={(_selectedKeys, info: { node: TreeNode; nativeEvent: MouseEvent }) => @@ -326,26 +337,6 @@ function TreeHierarchyView(props: Props) { ); } -const updateUserDefinedProperty = ( - tree: Tree, - oldPropKey: string, - newPropPartial: Partial, -) => { - Store.dispatch( - setTreeUserDefinedPropertiesAction( - tree.userDefinedProperties.map((element) => - element.key === oldPropKey - ? { - ...element, - ...newPropPartial, - } - : element, - ), - tree.treeId, - ), - ); -}; - const updateUserDefinedPropertyByIndex = ( tree: Tree, index: number, @@ -356,9 +347,9 @@ const updateUserDefinedPropertyByIndex = ( tree.userDefinedProperties.map((element, idx) => idx === index ? { - ...element, - ...newPropPartial, - } + ...element, + ...newPropPartial, + } : element, ), tree.treeId, @@ -375,46 +366,6 @@ function DetailsForSelection({ if (tree == null) { return <>Cannot find details for selected tree.; } - // todop - const isReadOnly = false; - - const getDeleteEntryButton = (_: APIMetadataWithError, index: number) => ( -
-
- ); - - // todop - const getKeyInput = (record: APIMetadataWithError, index: number) => { - return ( - setFocusedRow(index)} - // onBlur={() => setFocusedRow(null)} - value={record.key} - onChange={(value) => updateUserDefinedPropertyByIndex(tree, index, { key: value })} - placeholder="Property" - size="small" - // disabled={isSaving} - // id={getKeyInputIdForIndex(index)} - /> - ); - }; return (
@@ -433,43 +384,12 @@ function DetailsForSelection({ /> - - - User-defined Properties - - - {/* - // todop - export type APIMetadata = { - type: APIMetadataType; - key: string; - value: string | number | string[]; - };*/} - ({ - key: prop.key, - type: "string", - value: prop.stringValue || "", - }))} - getKeyInput={getKeyInput} - focusedRow={null} - setFocusedRow={() => {}} - updateMetadataValue={ - (indexToUpdate: number, newValue: number | string | string[]) => { - updateUserDefinedPropertyByIndex(tree, indexToUpdate, { stringValue: newValue as string }) - } - } - isSaving={false} - availableStrArrayTagOptions={[]} - getDeleteEntryButton={getDeleteEntryButton} - addNewEntryMenuItems={{}} + - -
); } diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/user_defined_properties_table.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/user_defined_properties_table.tsx index 7019035b74d..3fafd4cb997 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/user_defined_properties_table.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/user_defined_properties_table.tsx @@ -1,13 +1,16 @@ -import { Input, type InputProps } from "antd"; +import { DeleteOutlined, TagsOutlined } from "@ant-design/icons"; +import { Button, Input, type InputProps } from "antd"; +import { type APIMetadataWithError, InnerMetadataTable } from "dashboard/folders/metadata_table"; import type React from "react"; import { useCallback, useEffect, useState } from "react"; +import type { UserDefinedProperty } from "types/api_flow_types"; export function InputWithUpdateOnBlur({ value, onChange, onBlur, ...props -}: { value: string; onChange: (value: string) => void } & InputProps) { +}: { value: string; onChange: (value: string) => void } & Omit) { const [localValue, setLocalValue] = useState(value); const onKeyDown = useCallback( @@ -43,3 +46,97 @@ export function InputWithUpdateOnBlur({ /> ); } + +export function UserDefinedPropertyTableRows< + ItemType extends { userDefinedProperties: UserDefinedProperty[] }, +>({ + item, + updateUserDefinedPropertyByIndex, +}: { + item: ItemType; + updateUserDefinedPropertyByIndex: ( + item: ItemType, + index: number, + newPropPartial: Partial, + ) => void; +}) { + // todop + const isReadOnly = false; + + const getDeleteEntryButton = (_: APIMetadataWithError, _index: number) => ( +
+
+ ); + + // todop + const getKeyInput = (record: APIMetadataWithError, index: number) => { + return ( + setFocusedRow(index)} + // onBlur={() => setFocusedRow(null)} + value={record.key} + onChange={(value) => updateUserDefinedPropertyByIndex(item, index, { key: value })} + placeholder="Property" + size="small" + // disabled={isSaving} + // id={getKeyInputIdForIndex(index)} + /> + ); + }; + + return ( + <> + + + User-defined Properties + + + ; + {/* + // todop + export type APIMetadata = { + type: APIMetadataType; + key: string; + value: string | number | string[]; + };*/} + ({ + key: prop.key, + type: "string", + value: prop.stringValue || "", + }))} + getKeyInput={getKeyInput} + focusedRow={null} + setFocusedRow={() => {}} + updateMetadataValue={(indexToUpdate: number, newValue: number | string | string[]) => { + updateUserDefinedPropertyByIndex(item, indexToUpdate, { + stringValue: newValue as string, + }); + }} + isSaving={false} + availableStrArrayTagOptions={[]} + getDeleteEntryButton={getDeleteEntryButton} + addNewEntryMenuItems={{}} + /> + + ); +} From a28d92412ef73e1fe191b5af442fead841634b9f Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Wed, 4 Sep 2024 07:38:31 +0200 Subject: [PATCH 23/79] make adding and removing properties work properly --- .../dashboard/folders/metadata_table.tsx | 26 ++++--- .../segments_tab/segments_view.tsx | 17 +---- .../right-border-tabs/tree_hierarchy_view.tsx | 29 +++----- .../user_defined_properties_table.tsx | 71 +++++++++++++++---- .../stylesheets/trace_view/_right_menu.less | 4 ++ 5 files changed, 90 insertions(+), 57 deletions(-) diff --git a/frontend/javascripts/dashboard/folders/metadata_table.tsx b/frontend/javascripts/dashboard/folders/metadata_table.tsx index fb24525a2c2..e3ab0c597ce 100644 --- a/frontend/javascripts/dashboard/folders/metadata_table.tsx +++ b/frontend/javascripts/dashboard/folders/metadata_table.tsx @@ -58,6 +58,18 @@ function getMetadataTypeLabel(type: APIMetadata["type"]) { } } +export function getTypeSelectDropdownMenu(addNewEntryWithType: (type: APIMetadata["type"]) => void): MenuProps { + return { + items: Object.values(APIMetadataEnum).map((type) => { + return { + key: type, + label: getMetadataTypeLabel(type as APIMetadata["type"]), + onClick: () => addNewEntryWithType(type as APIMetadata["type"]), + }; + }), + } +}; + type EmptyMetadataPlaceholderProps = { addNewEntryMenuItems: MenuProps; }; @@ -351,15 +363,7 @@ export default function MetadataTable({ label: string; }[]; - const getTypeSelectDropdownMenu: () => MenuProps = () => ({ - items: Object.values(APIMetadataEnum).map((type) => { - return { - key: type, - label: getMetadataTypeLabel(type as APIMetadata["type"]), - onClick: () => addNewEntryWithType(type as APIMetadata["type"]), - }; - }), - }); + const getKeyInput = (record: APIMetadataWithError, index: number) => { const isFocused = index === focusedRow; @@ -407,7 +411,7 @@ export default function MetadataTable({
); - const addNewEntryMenuItems = getTypeSelectDropdownMenu(); + const addNewEntryMenuItems = getTypeSelectDropdownMenu(addNewEntryWithType); return (
@@ -483,7 +487,7 @@ export function InnerMetadataTable({
- diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx index 37c2947af06..c90e216a6e2 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx @@ -1959,7 +1959,7 @@ class SegmentsView extends React.Component { @@ -1968,25 +1968,14 @@ class SegmentsView extends React.Component { return null; } - updateUserDefinedPropertyByIndex = ( - segment: Segment, - index: number, - newPropPartial: Partial, - ) => { + setUserDefinedProperties = (segment: Segment, newProperties: UserDefinedProperty[]) => { if (this.props.visibleSegmentationLayer == null) { return; } this.props.updateSegment( segment.id, { - userDefinedProperties: segment.userDefinedProperties.map((element, idx) => - idx === index - ? { - ...element, - ...newPropPartial, - } - : element, - ), + userDefinedProperties: newProperties, }, this.props.visibleSegmentationLayer.name, true, diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx index 106702b51a2..e4f78abca0f 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx @@ -296,12 +296,12 @@ function TreeHierarchyView(props: Props) { node.type === GroupTypeEnum.TREE ? renderTreeNode(props, onOpenContextMenu, hideContextMenu, node) : renderGroupNode( - props, - onOpenContextMenu, - hideContextMenu, - node, - expandedNodeKeys, - ) + props, + onOpenContextMenu, + hideContextMenu, + node, + expandedNodeKeys, + ) } switcherIcon={} onSelect={(_selectedKeys, info: { node: TreeNode; nativeEvent: MouseEvent }) => @@ -337,26 +337,19 @@ function TreeHierarchyView(props: Props) { ); } -const updateUserDefinedPropertyByIndex = ( +const setUserDefinedProperties = ( tree: Tree, - index: number, - newPropPartial: Partial, + newProperties: UserDefinedProperty[], ) => { Store.dispatch( setTreeUserDefinedPropertiesAction( - tree.userDefinedProperties.map((element, idx) => - idx === index - ? { - ...element, - ...newPropPartial, - } - : element, - ), + newProperties, tree.treeId, ), ); }; + function DetailsForSelection({ trees, selectedTreeIds, @@ -386,7 +379,7 @@ function DetailsForSelection({ diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/user_defined_properties_table.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/user_defined_properties_table.tsx index 3fafd4cb997..78075980ade 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/user_defined_properties_table.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/user_defined_properties_table.tsx @@ -1,9 +1,13 @@ -import { DeleteOutlined, TagsOutlined } from "@ant-design/icons"; +import { CloseOutlined, TagsOutlined } from "@ant-design/icons"; import { Button, Input, type InputProps } from "antd"; -import { type APIMetadataWithError, InnerMetadataTable } from "dashboard/folders/metadata_table"; +import { + type APIMetadataWithError, + getTypeSelectDropdownMenu, + InnerMetadataTable, +} from "dashboard/folders/metadata_table"; import type React from "react"; import { useCallback, useEffect, useState } from "react"; -import type { UserDefinedProperty } from "types/api_flow_types"; +import { type APIMetadata, APIMetadataEnum, type UserDefinedProperty } from "types/api_flow_types"; export function InputWithUpdateOnBlur({ value, @@ -51,26 +55,49 @@ export function UserDefinedPropertyTableRows< ItemType extends { userDefinedProperties: UserDefinedProperty[] }, >({ item, - updateUserDefinedPropertyByIndex, + setUserDefinedProperties, }: { item: ItemType; - updateUserDefinedPropertyByIndex: ( - item: ItemType, - index: number, - newPropPartial: Partial, - ) => void; + setUserDefinedProperties: (item: ItemType, newProperties: UserDefinedProperty[]) => void; }) { // todop const isReadOnly = false; - const getDeleteEntryButton = (_: APIMetadataWithError, _index: number) => ( + const updateUserDefinedPropertyByIndex = ( + item: ItemType, + index: number, + newPropPartial: Partial, + ) => { + const newProps = item.userDefinedProperties.map((element, idx) => + idx === index + ? { + ...element, + ...newPropPartial, + } + : element, + ); + + setUserDefinedProperties(item, newProps); + }; + + const removeUserDefinedPropertyByIndex = (item: ItemType, index: number) => { + const newProps = item.userDefinedProperties.filter((_element, idx) => idx !== index); + setUserDefinedProperties(item, newProps); + }; + + const addUserDefinedProperty = (item: ItemType, newProp: UserDefinedProperty) => { + const newProps = item.userDefinedProperties.concat([newProp]); + setUserDefinedProperties(item, newProps); + }; + + const getDeleteEntryButton = (_: APIMetadataWithError, index: number) => (
@@ -101,6 +128,23 @@ export function UserDefinedPropertyTableRows< ); }; + const addNewEntryWithType = (type: APIMetadata["type"]) => { + // const indexOfNewEntry = prev.length; + // Auto focus the key input of the new entry. + // setTimeout( + // () => document.getElementById(getKeyInputIdForIndex(indexOfNewEntry))?.focus(), + // 50, + // ); + addUserDefinedProperty(item, { + key: "", + stringValue: type === APIMetadataEnum.STRING ? "" : undefined, + numberValue: type === APIMetadataEnum.NUMBER ? 0 : undefined, + stringListValue: type === APIMetadataEnum.STRING_ARRAY ? [] : undefined, + }); + }; + + const addNewEntryMenuItems = getTypeSelectDropdownMenu(addNewEntryWithType); + return ( <> @@ -108,7 +152,6 @@ export function UserDefinedPropertyTableRows< User-defined Properties - ; {/* // todop export type APIMetadata = { @@ -135,7 +178,7 @@ export function UserDefinedPropertyTableRows< isSaving={false} availableStrArrayTagOptions={[]} getDeleteEntryButton={getDeleteEntryButton} - addNewEntryMenuItems={{}} + addNewEntryMenuItems={addNewEntryMenuItems} /> ); diff --git a/frontend/stylesheets/trace_view/_right_menu.less b/frontend/stylesheets/trace_view/_right_menu.less index d06441f4f61..4f1831b3b65 100644 --- a/frontend/stylesheets/trace_view/_right_menu.less +++ b/frontend/stylesheets/trace_view/_right_menu.less @@ -258,6 +258,10 @@ font-weight: bold; } + .add-property-button { + width: 100%; + } + input { background: transparent; border: 0; From 0b7bc4bbb0d29a7a3bccf251129dbf76a4f4bb5a Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Wed, 4 Sep 2024 08:47:16 +0200 Subject: [PATCH 24/79] also support number and string array as property types --- .../dashboard/folders/metadata_table.tsx | 78 ++++++++++--------- .../tree_hierarchy_renderers.tsx | 1 + .../user_defined_properties_table.tsx | 61 ++++++++++----- frontend/javascripts/types/api_flow_types.ts | 3 +- .../stylesheets/trace_view/_right_menu.less | 19 ++++- 5 files changed, 100 insertions(+), 62 deletions(-) diff --git a/frontend/javascripts/dashboard/folders/metadata_table.tsx b/frontend/javascripts/dashboard/folders/metadata_table.tsx index e3ab0c597ce..fb66da1e7b9 100644 --- a/frontend/javascripts/dashboard/folders/metadata_table.tsx +++ b/frontend/javascripts/dashboard/folders/metadata_table.tsx @@ -58,7 +58,9 @@ function getMetadataTypeLabel(type: APIMetadata["type"]) { } } -export function getTypeSelectDropdownMenu(addNewEntryWithType: (type: APIMetadata["type"]) => void): MenuProps { +export function getTypeSelectDropdownMenu( + addNewEntryWithType: (type: APIMetadata["type"]) => void, +): MenuProps { return { items: Object.values(APIMetadataEnum).map((type) => { return { @@ -67,8 +69,8 @@ export function getTypeSelectDropdownMenu(addNewEntryWithType: (type: APIMetadat onClick: () => addNewEntryWithType(type as APIMetadata["type"]), }; }), - } -}; + }; +} type EmptyMetadataPlaceholderProps = { addNewEntryMenuItems: MenuProps; @@ -99,12 +101,16 @@ interface MetadataValueInputProps { index: number; focusedRow: number | null; setFocusedRow: (row: number | null) => void; - updateMetadataValue: (index: number, newValue: number | string | string[]) => void; + updateMetadataValue: ( + index: number, + newValue: number | string | string[], + type: APIMetadataEnum, + ) => void; isSaving: boolean; availableStrArrayTagOptions: { value: string; label: string }[]; } -const MetadataValueInput: React.FC = ({ +export const MetadataValueInput: React.FC = ({ record, index, focusedRow, @@ -128,7 +134,8 @@ const MetadataValueInput: React.FC = ({ return ( updateMetadataValue(index, newNum || 0)} + controls={false} + onChange={(newNum) => updateMetadataValue(index, newNum || 0, APIMetadataEnum.NUMBER)} {...sharedProps} /> ); @@ -136,7 +143,9 @@ const MetadataValueInput: React.FC = ({ return ( updateMetadataValue(index, newValue as string)} + onChange={(newValue) => + updateMetadataValue(index, newValue as string, APIMetadataEnum.STRING) + } {...sharedProps} /> ); @@ -145,9 +154,10 @@ const MetadataValueInput: React.FC = ({ { + if (onBlur) onBlur(event); + onChange(localValue); + }} + onChange={(event) => { + setLocalValue(event.currentTarget.value); + }} + onKeyDown={onKeyDown} + {...props} + /> + ); +} diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx index c90e216a6e2..bb8c525a54c 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx @@ -132,12 +132,10 @@ import { getGroupNodeKey, MISSING_GROUP_ID, } from "../tree_hierarchy_view_helpers"; -import { - InputWithUpdateOnBlur, - UserDefinedPropertyTableRows, -} from "../user_defined_properties_table"; +import { UserDefinedPropertyTableRows } from "../user_defined_properties_table"; import { SegmentStatisticsModal } from "./segment_statistics_modal"; import type { ItemType } from "antd/lib/menu/interface"; +import { InputWithUpdateOnBlur } from "oxalis/view/components/input_with_update_on_blur"; const { confirm } = Modal; const { Option } = Select; diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx index 1d0c406aaa1..ea50364ad76 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx @@ -44,12 +44,10 @@ import { setUpdateTreeGroups, } from "./tree_hierarchy_renderers"; import { ResizableSplitPane } from "./resizable_split_pane"; -import { - InputWithUpdateOnBlur, - UserDefinedPropertyTableRows, -} from "./user_defined_properties_table"; +import { UserDefinedPropertyTableRows } from "./user_defined_properties_table"; import type { UserDefinedProperty } from "types/api_flow_types"; import { APIMetadataWithError } from "dashboard/folders/metadata_table"; +import { InputWithUpdateOnBlur } from "../components/input_with_update_on_blur"; const onCheck: TreeProps["onCheck"] = (_checkedKeysValue, info) => { const { id, type } = info.node; diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/user_defined_properties_table.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/user_defined_properties_table.tsx index c2735cc164a..483785ef5dd 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/user_defined_properties_table.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/user_defined_properties_table.tsx @@ -9,48 +9,7 @@ import { import type React from "react"; import { useCallback, useEffect, useState } from "react"; import { type APIMetadata, APIMetadataEnum, type UserDefinedProperty } from "types/api_flow_types"; - -export function InputWithUpdateOnBlur({ - value, - onChange, - onBlur, - ...props -}: { value: string; onChange: (value: string) => void } & Omit) { - const [localValue, setLocalValue] = useState(value); - - const onKeyDown = useCallback( - (event: React.KeyboardEvent) => { - if (event.key === "Enter") { - onChange(localValue); - } else if (event.key === "Escape") { - document.activeElement ? (document.activeElement as HTMLElement).blur() : null; - } - if (props.onKeyDown) { - return props.onKeyDown(event); - } - }, - [onChange, props.onKeyDown, localValue], - ); - - useEffect(() => { - setLocalValue(value); - }, [value]); - - return ( - { - if (onBlur) onBlur(event); - onChange(localValue); - }} - onChange={(event) => { - setLocalValue(event.currentTarget.value); - }} - onKeyDown={onKeyDown} - {...props} - /> - ); -} +import { InputWithUpdateOnBlur } from "../components/input_with_update_on_blur"; export function UserDefinedPropertyTableRows< ItemType extends { userDefinedProperties: UserDefinedProperty[] }, From 7c77f55de7867dd817b6ae9b1da5048e1e504709 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Wed, 4 Sep 2024 09:59:59 +0200 Subject: [PATCH 28/79] temporarily disable ci --- .circleci/not-on-master.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.circleci/not-on-master.sh b/.circleci/not-on-master.sh index 581393ebead..e3078cdb9ce 100755 --- a/.circleci/not-on-master.sh +++ b/.circleci/not-on-master.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash set -Eeuo pipefail -if [ "${CIRCLE_BRANCH}" == "master" ]; then +# if [ "${CIRCLE_BRANCH}" == "master" ]; then echo "Skipping this step on master..." -else - exec "$@" -fi +# else +# exec "$@" +# fi From 501142a51aa3f43fdc95a623308640a1cba81bda Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Thu, 5 Sep 2024 11:43:29 +0200 Subject: [PATCH 29/79] fix metadata editing for segments --- .../oxalis/model/actions/volumetracing_actions.ts | 10 +++++++++- .../right-border-tabs/segments_tab/segments_view.tsx | 4 ++-- .../user_defined_properties_table.tsx | 6 ++---- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/frontend/javascripts/oxalis/model/actions/volumetracing_actions.ts b/frontend/javascripts/oxalis/model/actions/volumetracing_actions.ts index 62501c996ee..100cc2ded50 100644 --- a/frontend/javascripts/oxalis/model/actions/volumetracing_actions.ts +++ b/frontend/javascripts/oxalis/model/actions/volumetracing_actions.ts @@ -247,11 +247,19 @@ export const updateSegmentAction = ( ? _.uniqBy(userDefinedProperties, (el: UserDefinedProperty) => el.key) : undefined; + const newSegment: Partial = + sanitizedUserDefinedProperties != null + ? { + ...restSegment, + userDefinedProperties: sanitizedUserDefinedProperties, + } + : restSegment; + return { type: "UPDATE_SEGMENT", // TODO: Proper 64 bit support (#6921) segmentId: Number(segmentId), - segment: { ...restSegment, sanitizedUserDefinedProperties }, + segment: newSegment, layerName, timestamp, createsNewUndoState, diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx index bb8c525a54c..afee206f24c 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx @@ -1934,11 +1934,11 @@ class SegmentsView extends React.Component { ID - {segment.id} + {segment.id} Name - + { diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/user_defined_properties_table.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/user_defined_properties_table.tsx index 483785ef5dd..9698ed83d28 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/user_defined_properties_table.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/user_defined_properties_table.tsx @@ -1,13 +1,11 @@ import { CloseOutlined, TagsOutlined } from "@ant-design/icons"; -import { Button, Input, type InputProps } from "antd"; +import { Button } from "antd"; import { type APIMetadataWithError, getTypeSelectDropdownMenu, InnerMetadataTable, MetadataValueInput, } from "dashboard/folders/metadata_table"; -import type React from "react"; -import { useCallback, useEffect, useState } from "react"; import { type APIMetadata, APIMetadataEnum, type UserDefinedProperty } from "types/api_flow_types"; import { InputWithUpdateOnBlur } from "../components/input_with_update_on_blur"; @@ -137,7 +135,7 @@ export function UserDefinedPropertyTableRows< <> - User-defined Properties + Metadata Date: Thu, 5 Sep 2024 11:44:21 +0200 Subject: [PATCH 30/79] change trash to close icon in dashboard --- frontend/javascripts/dashboard/folders/metadata_table.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/javascripts/dashboard/folders/metadata_table.tsx b/frontend/javascripts/dashboard/folders/metadata_table.tsx index 9f181138296..4f2151d67e3 100644 --- a/frontend/javascripts/dashboard/folders/metadata_table.tsx +++ b/frontend/javascripts/dashboard/folders/metadata_table.tsx @@ -1,5 +1,5 @@ import { - DeleteOutlined, + CloseOutlined, FieldNumberOutlined, FieldStringOutlined, PlusOutlined, @@ -424,7 +424,7 @@ export default function MetadataTable({ type="text" disabled={isSaving} icon={ - Date: Thu, 5 Sep 2024 15:54:53 +0200 Subject: [PATCH 31/79] give metadata table rounded corners --- .../stylesheets/trace_view/_right_menu.less | 37 +++++++++++++++++-- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/frontend/stylesheets/trace_view/_right_menu.less b/frontend/stylesheets/trace_view/_right_menu.less index 71c676d36ae..c8a8d169a5a 100644 --- a/frontend/stylesheets/trace_view/_right_menu.less +++ b/frontend/stylesheets/trace_view/_right_menu.less @@ -229,11 +229,8 @@ --compact-table-bg2: #fefefe; --compact-table-border: #e7e7e7; - border-collapse: collapse; width: 100%; - th, td { - border: 1px solid var(--compact-table-border); padding: 2px 4px; text-align: left; color: var(--ant-color-text-base); @@ -284,4 +281,38 @@ padding-bottom: 0; } } + + + // The remaining code is only for the rounded corners of the table + border-collapse: separate; + border-spacing: 0; + tr th, + tr td { + border-right: 1px solid var(--compact-table-border); + border-bottom: 1px solid var(--compact-table-border); + } + + tr th:first-child, + tr td:first-child { + border-left: 1px solid var(--compact-table-border); + } + tr:first-child td { + border-top: solid 1px var(--compact-table-border); + } + + tr:first-child td:first-child { + border-top-left-radius: var(--ant-border-radius); + } + + tr:first-child td:last-child { + border-top-right-radius: var(--ant-border-radius); + } + + tr:last-child td:first-child { + border-bottom-left-radius: var(--ant-border-radius); + } + + tr:last-child td:last-child { + border-bottom-right-radius: var(--ant-border-radius); + } } From b3a2cfe0eb3186ff3c218c96535786abe51bdb57 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Thu, 5 Sep 2024 18:59:18 +0200 Subject: [PATCH 32/79] warn user when keys are not unique; avoid frequent rerenders via memo --- .../javascripts/components/fast_tooltip.tsx | 3 + .../model/actions/volumetracing_actions.ts | 29 +- .../components/input_with_update_on_blur.tsx | 38 ++- .../segments_tab/segments_view.tsx | 33 +- .../right-border-tabs/tree_hierarchy_view.tsx | 77 ++--- .../user_defined_properties_table.tsx | 295 ++++++++++-------- frontend/stylesheets/_variables.less | 9 + .../stylesheets/trace_view/_right_menu.less | 59 ++-- 8 files changed, 304 insertions(+), 239 deletions(-) diff --git a/frontend/javascripts/components/fast_tooltip.tsx b/frontend/javascripts/components/fast_tooltip.tsx index 36b92c78601..cb31751b8fc 100644 --- a/frontend/javascripts/components/fast_tooltip.tsx +++ b/frontend/javascripts/components/fast_tooltip.tsx @@ -62,6 +62,7 @@ export default function FastTooltip({ html, className, style, + variant, dynamicRenderer, }: { title?: string | null | undefined; @@ -74,6 +75,7 @@ export default function FastTooltip({ html?: string | null | undefined; className?: string; // class name attached to the wrapper style?: React.CSSProperties; // style attached to the wrapper + variant?: "dark" | "light" | "success" | "warning" | "error" | "info"; dynamicRenderer?: () => React.ReactElement; }) { const Tag = wrapper || "span"; @@ -109,6 +111,7 @@ export default function FastTooltip({ data-tooltip-place={placement || "top"} data-tooltip-html={html} data-unique-key={uniqueKeyForDynamic} + data-tooltip-variant={variant} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} className={className} diff --git a/frontend/javascripts/oxalis/model/actions/volumetracing_actions.ts b/frontend/javascripts/oxalis/model/actions/volumetracing_actions.ts index 100cc2ded50..0a1f2a49d90 100644 --- a/frontend/javascripts/oxalis/model/actions/volumetracing_actions.ts +++ b/frontend/javascripts/oxalis/model/actions/volumetracing_actions.ts @@ -241,25 +241,26 @@ export const updateSegmentAction = ( if (segmentId == null) { throw new Error("Segment ID must not be null."); } - const { userDefinedProperties, ...restSegment } = segment; - const sanitizedUserDefinedProperties = - userDefinedProperties !== undefined - ? _.uniqBy(userDefinedProperties, (el: UserDefinedProperty) => el.key) - : undefined; - - const newSegment: Partial = - sanitizedUserDefinedProperties != null - ? { - ...restSegment, - userDefinedProperties: sanitizedUserDefinedProperties, - } - : restSegment; + // todop + // const { userDefinedProperties, ...restSegment } = segment; + // const sanitizedUserDefinedProperties = + // userDefinedProperties !== undefined + // ? _.uniqBy(userDefinedProperties, (el: UserDefinedProperty) => el.key) + // : undefined; + + // const newSegment: Partial = + // sanitizedUserDefinedProperties != null + // ? { + // ...restSegment, + // userDefinedProperties: sanitizedUserDefinedProperties, + // } + // : restSegment; return { type: "UPDATE_SEGMENT", // TODO: Proper 64 bit support (#6921) segmentId: Number(segmentId), - segment: newSegment, + segment, layerName, timestamp, createsNewUndoState, diff --git a/frontend/javascripts/oxalis/view/components/input_with_update_on_blur.tsx b/frontend/javascripts/oxalis/view/components/input_with_update_on_blur.tsx index bf26032e84a..62854c44bd2 100644 --- a/frontend/javascripts/oxalis/view/components/input_with_update_on_blur.tsx +++ b/frontend/javascripts/oxalis/view/components/input_with_update_on_blur.tsx @@ -1,12 +1,18 @@ import { Input, type InputProps } from "antd"; +import FastTooltip from "components/fast_tooltip"; import { useCallback, useEffect, useState } from "react"; export function InputWithUpdateOnBlur({ value, onChange, onBlur, + validate, ...props -}: { value: string; onChange: (value: string) => void } & Omit) { +}: { + value: string; + validate?: (value: string) => string | null; + onChange: (value: string) => void; +} & Omit) { const [localValue, setLocalValue] = useState(value); const onKeyDown = useCallback( @@ -27,18 +33,24 @@ export function InputWithUpdateOnBlur({ setLocalValue(value); }, [value]); + const validationError = validate != null ? validate(localValue) : null; + const status = validationError != null ? "error" : undefined; + return ( - { - if (onBlur) onBlur(event); - onChange(localValue); - }} - onChange={(event) => { - setLocalValue(event.currentTarget.value); - }} - onKeyDown={onKeyDown} - {...props} - /> + + { + if (onBlur) onBlur(event); + onChange(localValue); + }} + onChange={(event) => { + setLocalValue(event.currentTarget.value); + }} + onKeyDown={onKeyDown} + status={status} + {...props} + /> + ); } diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx index afee206f24c..47649db8890 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx @@ -1922,6 +1922,27 @@ class SegmentsView extends React.Component { ); } + renameActiveSegment = (newName: string) => { + if (this.props.visibleSegmentationLayer == null) { + return; + } + const { segments } = this.props.selectedIds; + if (segments.length !== 1) { + return; + } + const segment = this.props.segments?.getNullable(segments[0]); + if (segment == null) { + return; + } + + this.props.updateSegment( + segment.id, + { name: newName }, + this.props.visibleSegmentationLayer.name, + true, + ); + }; + renderDetailsForSelection() { const { segments } = this.props.selectedIds; if (segments.length === 1) { @@ -1941,17 +1962,7 @@ class SegmentsView extends React.Component { { - if (this.props.visibleSegmentationLayer == null) { - return; - } - this.props.updateSegment( - segment.id, - { name: newValue }, - this.props.visibleSegmentationLayer.name, - true, - ); - }} + onChange={this.renameActiveSegment} /> diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx index ea50364ad76..51371fd3748 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx @@ -8,7 +8,7 @@ import { Modal, type TreeProps, } from "antd"; -import React, { useCallback, useEffect, useRef, useState } from "react"; +import React, { memo, useCallback, useEffect, useRef, useState } from "react"; import { AutoSizer } from "react-virtualized"; import { mapGroups } from "oxalis/model/accessors/skeletontracing_accessor"; import { @@ -339,44 +339,45 @@ const setUserDefinedProperties = (tree: Tree, newProperties: UserDefinedProperty Store.dispatch(setTreeUserDefinedPropertiesAction(newProperties, tree.treeId)); }; -function DetailsForSelection({ - trees, - selectedTreeIds, -}: { trees: TreeMap; selectedTreeIds: number[] }) { - if (selectedTreeIds.length === 1) { - const tree = trees[selectedTreeIds[0]]; - if (tree == null) { - return <>Cannot find details for selected tree.; - } +const DetailsForSelection = memo( + ({ trees, selectedTreeIds }: { trees: TreeMap; selectedTreeIds: number[] }) => { + if (selectedTreeIds.length === 1) { + const tree = trees[selectedTreeIds[0]]; + if (tree == null) { + return <>Cannot find details for selected tree.; + } - return ( -
- - - - - - - - - - - - -
ID{tree.treeId}
Name - Store.dispatch(setTreeNameAction(newValue, tree.treeId))} - /> -
-
- ); - } - return null; -} + return ( +
+ + + + + + + + + + + + +
ID{tree.treeId}
Name + + Store.dispatch(setTreeNameAction(newValue, tree.treeId)) + } + /> +
+
+ ); + } + return null; + }, +); // React.memo is used to prevent the component from re-rendering without the props changing export default React.memo(TreeHierarchyView); diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/user_defined_properties_table.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/user_defined_properties_table.tsx index 9698ed83d28..3315879d2c5 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/user_defined_properties_table.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/user_defined_properties_table.tsx @@ -8,154 +8,173 @@ import { } from "dashboard/folders/metadata_table"; import { type APIMetadata, APIMetadataEnum, type UserDefinedProperty } from "types/api_flow_types"; import { InputWithUpdateOnBlur } from "../components/input_with_update_on_blur"; +import _ from "lodash"; +import { memo } from "react"; -export function UserDefinedPropertyTableRows< - ItemType extends { userDefinedProperties: UserDefinedProperty[] }, ->({ - item, - setUserDefinedProperties, -}: { - item: ItemType; - setUserDefinedProperties: (item: ItemType, newProperties: UserDefinedProperty[]) => void; -}) { - // todop - const isReadOnly = false; +export const UserDefinedPropertyTableRows = memo( + ({ + item, + setUserDefinedProperties, + }: { + item: ItemType; + setUserDefinedProperties: (item: ItemType, newProperties: UserDefinedProperty[]) => void; + }) => { + // todop + const isReadOnly = false; - const updateUserDefinedPropertyByIndex = ( - item: ItemType, - index: number, - newPropPartial: Partial, - ) => { - const newProps = item.userDefinedProperties.map((element, idx) => - idx === index - ? { - ...element, - ...newPropPartial, - } - : element, + const keyCountByKey = _.countBy( + item.userDefinedProperties.map((prop) => prop.key), + (x) => x, ); - setUserDefinedProperties(item, newProps); - }; + const updateUserDefinedPropertyByIndex = ( + item: ItemType, + index: number, + newPropPartial: Partial, + ) => { + const newProps = item.userDefinedProperties.map((element, idx) => + idx === index + ? { + ...element, + ...newPropPartial, + } + : element, + ); - const removeUserDefinedPropertyByIndex = (item: ItemType, index: number) => { - const newProps = item.userDefinedProperties.filter((_element, idx) => idx !== index); - setUserDefinedProperties(item, newProps); - }; + setUserDefinedProperties(item, newProps); + }; - const addUserDefinedProperty = (item: ItemType, newProp: UserDefinedProperty) => { - const newProps = item.userDefinedProperties.concat([newProp]); - setUserDefinedProperties(item, newProps); - }; + const removeUserDefinedPropertyByIndex = (item: ItemType, index: number) => { + const newProps = item.userDefinedProperties.filter((_element, idx) => idx !== index); + setUserDefinedProperties(item, newProps); + }; - const getDeleteEntryButton = (_: APIMetadataWithError, index: number) => ( -
-
- ); + const addUserDefinedProperty = (item: ItemType, newProp: UserDefinedProperty) => { + const newProps = item.userDefinedProperties.concat([newProp]); + setUserDefinedProperties(item, newProps); + }; - const getKeyInput = (record: APIMetadataWithError, index: number) => { - return ( - updateUserDefinedPropertyByIndex(item, index, { key: value })} - placeholder="Property" - size="small" - // todop - // onFocus={() => setFocusedRow(index)} - // onBlur={() => setFocusedRow(null)} - // disabled={isSaving} - // id={getKeyInputIdForIndex(index)} - /> + const getDeleteEntryButton = (_: APIMetadataWithError, index: number) => ( +
+
); - }; - const getValueInput = (record: APIMetadataWithError, index: number) => { - return ( - { - updateUserDefinedPropertyByIndex(item, indexToUpdate, { - stringValue: type === APIMetadataEnum.STRING ? (newValue as string) : undefined, - stringListValue: - type === APIMetadataEnum.STRING_ARRAY ? (newValue as string[]) : undefined, - numberValue: type === APIMetadataEnum.NUMBER ? (newValue as number) : undefined, - // todop: support bool? - }); - }} - // todop: provide availableStrArrayTagOptions - availableStrArrayTagOptions={[]} - // todop: make props optional - focusedRow={null} - setFocusedRow={() => {}} - isSaving={false} - /> - ); - }; + const getKeyInput = (record: APIMetadataWithError, index: number) => { + return ( +
+ } + className="transparent-input" + value={record.key} + onChange={(value) => updateUserDefinedPropertyByIndex(item, index, { key: value })} + placeholder="Property" + size="small" + // status={record.error != null ? "error" : undefined} + validate={(value: string) => { + if ( + // If all items (except for the current one) have another key, + // everything is fine. + item.userDefinedProperties.every( + (otherItem, idx) => idx === index || otherItem.key !== value, + ) + ) { + return null; + } + return "Each key must only be used once."; + }} + // todop + // onFocus={() => setFocusedRow(index)} + // onBlur={() => setFocusedRow(null)} + // disabled={isSaving} + // id={getKeyInputIdForIndex(index)} + /> +
+ ); + }; - const addNewEntryWithType = (type: APIMetadata["type"]) => { - // const indexOfNewEntry = prev.length; - // Auto focus the key input of the new entry. - // setTimeout( - // () => document.getElementById(getKeyInputIdForIndex(indexOfNewEntry))?.focus(), - // 50, - // ); - addUserDefinedProperty(item, { - key: "", - stringValue: type === APIMetadataEnum.STRING ? "" : undefined, - numberValue: type === APIMetadataEnum.NUMBER ? 0 : undefined, - stringListValue: type === APIMetadataEnum.STRING_ARRAY ? [] : undefined, - }); - }; + const getValueInput = (record: APIMetadataWithError, index: number) => { + return ( + { + updateUserDefinedPropertyByIndex(item, indexToUpdate, { + stringValue: type === APIMetadataEnum.STRING ? (newValue as string) : undefined, + stringListValue: + type === APIMetadataEnum.STRING_ARRAY ? (newValue as string[]) : undefined, + numberValue: type === APIMetadataEnum.NUMBER ? (newValue as number) : undefined, + // todop: support bool? + }); + }} + // todop: provide availableStrArrayTagOptions + availableStrArrayTagOptions={[]} + // todop: make props optional + focusedRow={null} + setFocusedRow={() => {}} + isSaving={false} + /> + ); + }; - const addNewEntryMenuItems = getTypeSelectDropdownMenu(addNewEntryWithType); + const addNewEntryWithType = (type: APIMetadata["type"]) => { + // const indexOfNewEntry = prev.length; + // Auto focus the key input of the new entry. + // setTimeout( + // () => document.getElementById(getKeyInputIdForIndex(indexOfNewEntry))?.focus(), + // 50, + // ); + addUserDefinedProperty(item, { + key: "", + stringValue: type === APIMetadataEnum.STRING ? "" : undefined, + numberValue: type === APIMetadataEnum.NUMBER ? 0 : undefined, + stringListValue: type === APIMetadataEnum.STRING_ARRAY ? [] : undefined, + }); + }; - return ( - <> - - - Metadata - - - ({ - key: prop.key, - type: - prop.stringValue != null - ? APIMetadataEnum.STRING - : prop.numberValue != null - ? APIMetadataEnum.NUMBER - : APIMetadataEnum.STRING_ARRAY, - value: prop.stringValue || prop.numberValue || prop.stringListValue || "", - }))} - getKeyInput={getKeyInput} - getValueInput={getValueInput} - getDeleteEntryButton={getDeleteEntryButton} - addNewEntryMenuItems={addNewEntryMenuItems} - /> - - ); -} + const addNewEntryMenuItems = getTypeSelectDropdownMenu(addNewEntryWithType); + + return ( + <> + ({ + key: prop.key, + type: + prop.stringValue != null + ? APIMetadataEnum.STRING + : prop.numberValue != null + ? APIMetadataEnum.NUMBER + : APIMetadataEnum.STRING_ARRAY, + value: prop.stringValue || prop.numberValue || prop.stringListValue || "", + error: keyCountByKey[prop.key] > 1 ? "Each key must only be used once." : undefined, + }))} + getKeyInput={getKeyInput} + getValueInput={getValueInput} + getDeleteEntryButton={getDeleteEntryButton} + addNewEntryMenuItems={addNewEntryMenuItems} + /> + + ); + }, +); diff --git a/frontend/stylesheets/_variables.less b/frontend/stylesheets/_variables.less index 10d00388f40..5c82d16660a 100644 --- a/frontend/stylesheets/_variables.less +++ b/frontend/stylesheets/_variables.less @@ -14,6 +14,7 @@ --color-xz-viewport: var(--color-blue-zircon); --navbar-height: 48px; --footer-height: 20px; + } .ant-app { @@ -21,4 +22,12 @@ // the component is expected to be there only once and on a very high level in the DOM tree --background-blue-organelles: url(/assets/images/background_mixed_cells_drawn.svg) 0 / cover no-repeat, var(--ant-color-primary); --background-blue-neurons: url(/assets/images/background_neurons_drawn.svg) 0 / cover no-repeat, var(--ant-color-primary); + + // Override react-tooltip colors + --rt-color-error: var(--ant-color-error); + --rt-color-warning: var(--ant-color-warning); + --rt-color-success: var(--ant-color-success); + --rt-color-info: var(--ant-color-info); + --rt-opacity: 0.95; } + diff --git a/frontend/stylesheets/trace_view/_right_menu.less b/frontend/stylesheets/trace_view/_right_menu.less index c8a8d169a5a..fd81f95ce82 100644 --- a/frontend/stylesheets/trace_view/_right_menu.less +++ b/frontend/stylesheets/trace_view/_right_menu.less @@ -230,36 +230,12 @@ --compact-table-border: #e7e7e7; width: 100%; - th, td { - padding: 2px 4px; - text-align: left; - color: var(--ant-color-text-base); - } - - td { - background-color: var(--compact-table-bg1); - } - - td:nth-child(2):not(:last-child) { - border-right: 0; - } - td:nth-child(3) { - border-left: 0; - } - - tr:nth-child(even) td { - background-color: var(--compact-table-bg2); - } - - tr.divider-row { - font-weight: bold; - } .add-property-button { width: 100%; } - input, .ant-input-number { + input, .ant-input-affix-wrapper, .ant-input-number { &:not(:hover) { background: transparent; } @@ -269,6 +245,13 @@ padding: 0; } + .ant-input-status-error { + border: 0 !important; + input { + color: var(--ant-color-error); + } + } + .ant-input-number-input { padding: 0 !important; } @@ -282,6 +265,32 @@ } } + // Table + th, td { + padding: 2px 4px; + text-align: left; + color: var(--ant-color-text-base); + } + + td { + background-color: var(--compact-table-bg1); + } + + td:nth-child(2):not(:last-child) { + border-right: 0; + } + td:nth-child(3) { + border-left: 0; + } + + tr:nth-child(even) td { + background-color: var(--compact-table-bg2); + } + + tr.divider-row { + font-weight: bold; + } + // The remaining code is only for the rounded corners of the table border-collapse: separate; From f3ed54cc39970cb87c45119e44d8ab708d7d34b0 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Fri, 6 Sep 2024 10:54:41 +0200 Subject: [PATCH 33/79] iterate on validation, styling and tag input --- .../dashboard/folders/metadata_table.tsx | 62 ++++++++++++++----- frontend/javascripts/libs/utils.ts | 13 ++++ .../model/actions/skeletontracing_actions.tsx | 2 +- .../components/input_with_update_on_blur.tsx | 58 ++++++++++++++++- .../user_defined_properties_table.tsx | 50 ++++++++------- frontend/javascripts/types/api_flow_types.ts | 1 + .../stylesheets/trace_view/_right_menu.less | 7 +++ 7 files changed, 154 insertions(+), 39 deletions(-) diff --git a/frontend/javascripts/dashboard/folders/metadata_table.tsx b/frontend/javascripts/dashboard/folders/metadata_table.tsx index 4f2151d67e3..39758aafd63 100644 --- a/frontend/javascripts/dashboard/folders/metadata_table.tsx +++ b/frontend/javascripts/dashboard/folders/metadata_table.tsx @@ -15,14 +15,19 @@ import { Dropdown, Button, } from "antd"; +import FastTooltip from "components/fast_tooltip"; import { type DatasetCollectionContextValue, useDatasetCollectionContext, } from "dashboard/dataset/dataset_collection_context"; import { useIsMounted, useStateWithRef } from "libs/react_hooks"; import Toast from "libs/toast"; +import { isStringNumeric } from "libs/utils"; import _ from "lodash"; -import { InputWithUpdateOnBlur } from "oxalis/view/components/input_with_update_on_blur"; +import { + InputNumberWithUpdateOnBlur, + InputWithUpdateOnBlur, +} from "oxalis/view/components/input_with_update_on_blur"; import type React from "react"; import { useEffect } from "react"; import { useState } from "react"; @@ -124,7 +129,6 @@ export const MetadataValueInput: React.FC = ({ className: isFocused ? undefined : "transparent-input", onFocus: () => setFocusedRow(index), onBlur: () => setFocusedRow(null), - placeholder: "Value", size: "small" as InputNumberProps["size"], disabled: isSaving, }; @@ -135,7 +139,19 @@ export const MetadataValueInput: React.FC = ({ updateMetadataValue(index, newNum || 0, APIMetadataEnum.NUMBER)} + placeholder="Enter a number" + onChange={(newNum) => { + console.log("onChange was called with", newNum); + return updateMetadataValue(index, newNum || 0, APIMetadataEnum.NUMBER); + }} + // validate={(value: string) => { + // if (isStringNumeric(value)) { + // console.log("validating", value, ": okay"); + // return null; + // } + // console.log("validating", value, ": not okay"); + // return "The value must be a number."; + // }} {...sharedProps} /> ); @@ -143,6 +159,7 @@ export const MetadataValueInput: React.FC = ({ return ( updateMetadataValue(index, newValue as string, APIMetadataEnum.STRING) } @@ -153,6 +170,7 @@ export const MetadataValueInput: React.FC = ({ return ( setFocusedRow(index)} - onBlur={() => setFocusedRow(null)} - value={record.key} - onChange={(evt) => updateMetadataKey(index, evt.target.value)} - placeholder="Property" - size="small" - disabled={isSaving} - id={getKeyInputIdForIndex(index)} - /> - {record.error != null ? ( - <> -
- - {record.error} - - - ) : null} + + setFocusedRow(index)} + onBlur={() => setFocusedRow(null)} + value={record.key} + onChange={(evt) => updateMetadataKey(index, evt.target.value)} + placeholder="Property" + size="small" + disabled={isSaving} + id={getKeyInputIdForIndex(index)} + status={record.error != null ? "warning" : undefined} + // Use a span as an empty prefix, because null would lose the focus + // when the prefix changes. + prefix={record.error != null ? : } + /> + ); }; diff --git a/frontend/javascripts/main.tsx b/frontend/javascripts/main.tsx index 9c48adddd51..a0a7acd5832 100644 --- a/frontend/javascripts/main.tsx +++ b/frontend/javascripts/main.tsx @@ -27,6 +27,7 @@ import Model from "oxalis/model"; import { setupApi } from "oxalis/api/internal_api"; import { setActiveOrganizationAction } from "oxalis/model/actions/organization_actions"; import checkBrowserFeatures from "libs/browser_feature_check"; +import { RootForFastTooltips } from "components/fast_tooltip"; import "../stylesheets/main.less"; import GlobalThemeProvider, { getThemeFromUser } from "theme"; @@ -115,6 +116,7 @@ document.addEventListener("DOMContentLoaded", async () => { https://github.com/frontend-collective/react-sortable-tree/blob/9aeaf3d38b500d58e2bcc1d9b6febce12f8cc7b4/stories/barebones-no-context.js */} + diff --git a/frontend/javascripts/oxalis/view/layouting/tracing_layout_view.tsx b/frontend/javascripts/oxalis/view/layouting/tracing_layout_view.tsx index f7ce6c90688..7362b366cd8 100644 --- a/frontend/javascripts/oxalis/view/layouting/tracing_layout_view.tsx +++ b/frontend/javascripts/oxalis/view/layouting/tracing_layout_view.tsx @@ -46,7 +46,6 @@ import { determineLayout } from "./default_layout_configs"; import FlexLayoutWrapper from "./flex_layout_wrapper"; import { FloatingMobileControls } from "./floating_mobile_controls"; import app from "app"; -import { RootForFastTooltips } from "components/fast_tooltip"; const { Sider } = Layout; @@ -279,7 +278,6 @@ class TracingLayoutView extends React.PureComponent { return ( - {this.state.showFloatingMobileButtons && } diff --git a/frontend/stylesheets/trace_view/_right_menu.less b/frontend/stylesheets/trace_view/_right_menu.less index 33c2fe6bcd6..8c71c2623be 100644 --- a/frontend/stylesheets/trace_view/_right_menu.less +++ b/frontend/stylesheets/trace_view/_right_menu.less @@ -240,16 +240,22 @@ background: transparent; } border: 0; + padding: 0; + box-shadow: none !important; width: 100%; height: 100%; - padding: 0; } - .ant-input-status-error { + .ant-input-status-error, .ant-input-status-warning { border: 0 !important; - input { - color: var(--ant-color-error); - } + } + input.ant-input-status-error, + .ant-input-status-error input { + color: var(--ant-color-error); + } + input.ant-input-status-warning, + .ant-input-status-warning input { + color: var(--ant-color-warning); } .ant-input-number-input { From 7ee43a3a3d85071796d80e49d2334b700863d344 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Fri, 6 Sep 2024 14:02:00 +0200 Subject: [PATCH 40/79] make some props optional --- .../javascripts/dashboard/folders/metadata_table.tsx | 10 +++++----- .../user_defined_properties_table.tsx | 4 ---- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/frontend/javascripts/dashboard/folders/metadata_table.tsx b/frontend/javascripts/dashboard/folders/metadata_table.tsx index 8606d4a15db..d12ff0d050b 100644 --- a/frontend/javascripts/dashboard/folders/metadata_table.tsx +++ b/frontend/javascripts/dashboard/folders/metadata_table.tsx @@ -105,14 +105,14 @@ const EmptyMetadataPlaceholder: React.FC = ({ interface MetadataValueInputProps { record: APIMetadataWithError; index: number; - focusedRow: number | null; - setFocusedRow: (row: number | null) => void; + focusedRow?: number | null; + setFocusedRow?: (row: number | null) => void; updateMetadataValue: ( index: number, newValue: number | string | string[], type: APIMetadataEnum, ) => void; - isSaving: boolean; + isSaving?: boolean; availableStrArrayTagOptions: { value: string; label: string }[]; } @@ -128,8 +128,8 @@ export const MetadataValueInput: React.FC = ({ const isFocused = index === focusedRow; const sharedProps = { className: isFocused ? undefined : "transparent-input", - onFocus: () => setFocusedRow(index), - onBlur: () => setFocusedRow(null), + onFocus: () => setFocusedRow?.(index), + onBlur: () => setFocusedRow?.(null), size: "small" as InputNumberProps["size"], disabled: isSaving, }; diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/user_defined_properties_table.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/user_defined_properties_table.tsx index 2565e9550f2..544a24b1994 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/user_defined_properties_table.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/user_defined_properties_table.tsx @@ -143,10 +143,6 @@ export const UserDefinedPropertyTableRows = memo( }); }} availableStrArrayTagOptions={getUsedTagsWithinMetadata(itemMetadata)} - // todop: make props optional - focusedRow={null} - setFocusedRow={() => {}} - isSaving={false} /> ); }; From b2387bcfe47a611a5a219bfa9cd75a47367b7e49 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Fri, 6 Sep 2024 14:10:59 +0200 Subject: [PATCH 41/79] move multiple-trees-selected note into details pane --- .../right-border-tabs/skeleton_tab_view.tsx | 17 ----------------- .../tree_hierarchy_renderers.tsx | 1 - .../right-border-tabs/tree_hierarchy_view.tsx | 7 +++++++ .../stylesheets/trace_view/_right_menu.less | 9 --------- 4 files changed, 7 insertions(+), 27 deletions(-) diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/skeleton_tab_view.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/skeleton_tab_view.tsx index 6ffb31c67bd..94fa8e41b6d 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/skeleton_tab_view.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/skeleton_tab_view.tsx @@ -785,22 +785,6 @@ class SkeletonTabView extends React.PureComponent { }; } - getSelectedTreesAlert = () => - this.state.selectedTreeIds.length > 1 ? ( - - {this.state.selectedTreeIds.length}{" "} - {Utils.pluralize("Tree", this.state.selectedTreeIds.length)} selected.{" "} - - - } - /> - ) : null; - handleMeasureAllSkeletonsLength = () => { const { unit } = Store.getState().dataset.dataSource.scale; const [totalLengthNm, totalLengthVx] = api.tracing.measureAllTrees(); @@ -970,7 +954,6 @@ class SkeletonTabView extends React.PureComponent { padding: 0, }} > -
{this.getSelectedTreesAlert()}
{this.getTreesComponents(orderAttribute)} )} diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_renderers.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_renderers.tsx index 36aa4dbc92b..70472e73310 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_renderers.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_renderers.tsx @@ -33,7 +33,6 @@ import { setTreeGroupAction, setTreeGroupsAction, setTreeTypeAction, - setTreeUserDefinedPropertiesAction, shuffleAllTreeColorsAction, shuffleTreeColorAction, toggleInactiveTreesAction, diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx index 31947dc31fd..e4c4c2cafdf 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx @@ -11,6 +11,7 @@ import { toggleTreeAction, toggleTreeGroupAction, } from "oxalis/model/actions/skeletontracing_actions"; +import * as Utils from "libs/utils"; import { Store } from "oxalis/singletons"; import type { Tree, TreeGroup, TreeMap } from "oxalis/store"; import { @@ -367,6 +368,12 @@ const DetailsForSelection = memo(
); + } else if (selectedTreeIds.length > 1) { + return ( +
+ {selectedTreeIds.length} {Utils.pluralize("Tree", selectedTreeIds.length)} selected.{" "} +
+ ); } return null; }, diff --git a/frontend/stylesheets/trace_view/_right_menu.less b/frontend/stylesheets/trace_view/_right_menu.less index 8c71c2623be..311ceb059bb 100644 --- a/frontend/stylesheets/trace_view/_right_menu.less +++ b/frontend/stylesheets/trace_view/_right_menu.less @@ -80,15 +80,6 @@ // Spacing between skeleton tab buttons and name input margin: 0 0 10px 0; } - - .tree-hierarchy-header { - margin-left: 16px; - margin-right: auto; - position: absolute; - right: 12px; - top: 12px; - z-index: 1; - } } .clickable-text { From 9d64f0a1c960267c12df96baf3ac1f25a3606821 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Fri, 6 Sep 2024 14:21:51 +0200 Subject: [PATCH 42/79] make fields read only if no update permissions exist --- .../dashboard/folders/metadata_table.tsx | 52 +++++++++++-------- .../components/input_with_update_on_blur.tsx | 2 +- .../segments_tab/segments_view.tsx | 2 + .../right-border-tabs/tree_hierarchy_view.tsx | 13 ++++- .../user_defined_properties_table.tsx | 13 +++-- 5 files changed, 52 insertions(+), 30 deletions(-) diff --git a/frontend/javascripts/dashboard/folders/metadata_table.tsx b/frontend/javascripts/dashboard/folders/metadata_table.tsx index d12ff0d050b..83a9a5d29d0 100644 --- a/frontend/javascripts/dashboard/folders/metadata_table.tsx +++ b/frontend/javascripts/dashboard/folders/metadata_table.tsx @@ -113,6 +113,7 @@ interface MetadataValueInputProps { type: APIMetadataEnum, ) => void; isSaving?: boolean; + readOnly?: boolean; availableStrArrayTagOptions: { value: string; label: string }[]; } @@ -123,6 +124,7 @@ export const MetadataValueInput: React.FC = ({ setFocusedRow, updateMetadataValue, isSaving, + readOnly, availableStrArrayTagOptions, }) => { const isFocused = index === focusedRow; @@ -131,7 +133,7 @@ export const MetadataValueInput: React.FC = ({ onFocus: () => setFocusedRow?.(index), onBlur: () => setFocusedRow?.(null), size: "small" as InputNumberProps["size"], - disabled: isSaving, + disabled: isSaving || readOnly, }; switch (record.type) { @@ -488,6 +490,7 @@ export function InnerMetadataTable({ getDeleteEntryButton, addNewEntryMenuItems, onlyReturnRows, + readOnly, }: { metadata: APIMetadataWithError[]; getKeyInput: (record: APIMetadataWithError, index: number) => JSX.Element; @@ -495,6 +498,7 @@ export function InnerMetadataTable({ getDeleteEntryButton: (_: APIMetadataWithError, index: number) => JSX.Element; addNewEntryMenuItems: MenuProps; onlyReturnRows?: boolean; + readOnly?: boolean; }): React.ReactElement { const rows = ( <> @@ -505,29 +509,31 @@ export function InnerMetadataTable({ {getDeleteEntryButton(record, index)} ))} - - -
- - - - - -
- - + + + +
+ + + )} ); diff --git a/frontend/javascripts/oxalis/view/components/input_with_update_on_blur.tsx b/frontend/javascripts/oxalis/view/components/input_with_update_on_blur.tsx index 0fcaf7b5d73..1265261ca31 100644 --- a/frontend/javascripts/oxalis/view/components/input_with_update_on_blur.tsx +++ b/frontend/javascripts/oxalis/view/components/input_with_update_on_blur.tsx @@ -1,4 +1,4 @@ -import { Input, InputNumber, InputNumberProps, type InputProps } from "antd"; +import { Input, InputNumber, type InputNumberProps, type InputProps } from "antd"; import FastTooltip from "components/fast_tooltip"; import { useCallback, useEffect, useState } from "react"; diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx index cd0d1909d3d..8c42dd1db18 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx @@ -1946,6 +1946,7 @@ class SegmentsView extends React.Component { renderDetailsForSelection() { const { segments } = this.props.selectedIds; if (segments.length === 1) { + const readOnly = !this.props.allowUpdate; const segment = this.props.segments?.getNullable(segments[0]); if (segment == null) { return <>Cannot find details for selected segment.; @@ -1969,6 +1970,7 @@ class SegmentsView extends React.Component { diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx index e4c4c2cafdf..676d81088d4 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx @@ -320,7 +320,11 @@ function TreeHierarchyView(props: Props) { } secondChild={ - + } /> @@ -332,7 +336,11 @@ const setUserDefinedProperties = (tree: Tree, newProperties: UserDefinedProperty }; const DetailsForSelection = memo( - ({ trees, selectedTreeIds }: { trees: TreeMap; selectedTreeIds: number[] }) => { + ({ + trees, + selectedTreeIds, + readOnly, + }: { trees: TreeMap; selectedTreeIds: number[]; readOnly: boolean }) => { if (selectedTreeIds.length === 1) { const tree = trees[selectedTreeIds[0]]; if (tree == null) { @@ -363,6 +371,7 @@ const DetailsForSelection = memo( diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/user_defined_properties_table.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/user_defined_properties_table.tsx index 544a24b1994..36df0c13c05 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/user_defined_properties_table.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/user_defined_properties_table.tsx @@ -18,18 +18,20 @@ export const UserDefinedPropertyTableRows = memo( ({ item, setUserDefinedProperties, + readOnly, }: { item: ItemType; setUserDefinedProperties: (item: ItemType, newProperties: UserDefinedProperty[]) => void; + readOnly: boolean; }) => { - // todop - const isReadOnly = false; - const updateUserDefinedPropertyByIndex = ( item: ItemType, index: number, newPropPartial: Partial, ) => { + if (readOnly) { + return; + } const newProps = item.userDefinedProperties.map((element, idx) => idx === index ? { @@ -68,7 +70,7 @@ export const UserDefinedPropertyTableRows = memo(
diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/user_defined_properties_table.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/user_defined_properties_table.tsx index 9291bc7902f..853abb3c0e8 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/user_defined_properties_table.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/user_defined_properties_table.tsx @@ -7,33 +7,31 @@ import { InnerMetadataTable, MetadataValueInput, } from "dashboard/folders/metadata_table"; -import { type APIMetadata, APIMetadataEnum, type UserDefinedProperty } from "types/api_flow_types"; +import { type APIMetadata, APIMetadataEnum, type MetadataEntry } from "types/api_flow_types"; import { InputWithUpdateOnBlur } from "../components/input_with_update_on_blur"; import _ from "lodash"; import { memo } from "react"; const getKeyInputIdForIndex = (index: number) => `metadata-key-input-id-${index}`; -function _UserDefinedPropertyTableRows< - ItemType extends { userDefinedProperties: UserDefinedProperty[] }, ->({ +function _MetadataTableRows({ item, - setUserDefinedProperties, + setMetadata, readOnly, }: { item: ItemType; - setUserDefinedProperties: (item: ItemType, newProperties: UserDefinedProperty[]) => void; + setMetadata: (item: ItemType, newProperties: MetadataEntry[]) => void; readOnly: boolean; }) { - const updateUserDefinedPropertyByIndex = ( + const updateMetadataEntryByIndex = ( item: ItemType, index: number, - newPropPartial: Partial, + newPropPartial: Partial, ) => { if (readOnly) { return; } - const newProps = item.userDefinedProperties.map((element, idx) => + const newProps = item.metadata.map((element, idx) => idx === index ? { ...element, @@ -54,17 +52,17 @@ function _UserDefinedPropertyTableRows< console.error("invalid newprops?"); } - setUserDefinedProperties(item, newProps); + setMetadata(item, newProps); }; - const removeUserDefinedPropertyByIndex = (item: ItemType, index: number) => { - const newProps = item.userDefinedProperties.filter((_element, idx) => idx !== index); - setUserDefinedProperties(item, newProps); + const removeMetadataEntryByIndex = (item: ItemType, index: number) => { + const newProps = item.metadata.filter((_element, idx) => idx !== index); + setMetadata(item, newProps); }; - const addUserDefinedProperty = (item: ItemType, newProp: UserDefinedProperty) => { - const newProps = item.userDefinedProperties.concat([newProp]); - setUserDefinedProperties(item, newProps); + const addMetadataEntry = (item: ItemType, newProp: MetadataEntry) => { + const newProps = item.metadata.concat([newProp]); + setMetadata(item, newProps); }; const getDeleteEntryButton = (_: APIMetadataWithError, index: number) => ( @@ -82,7 +80,7 @@ function _UserDefinedPropertyTableRows< /> } onClick={() => { - removeUserDefinedPropertyByIndex(item, index); + removeMetadataEntryByIndex(item, index); }} />
@@ -96,16 +94,14 @@ function _UserDefinedPropertyTableRows< className="transparent-input" value={record.key} disabled={readOnly} - onChange={(value) => updateUserDefinedPropertyByIndex(item, index, { key: value })} + onChange={(value) => updateMetadataEntryByIndex(item, index, { key: value })} placeholder="Property" size="small" validate={(value: string) => { if ( // If all items (except for the current one) have another key, // everything is fine. - item.userDefinedProperties.every( - (otherItem, idx) => idx === index || otherItem.key !== value, - ) + item.metadata.every((otherItem, idx) => idx === index || otherItem.key !== value) ) { return null; } @@ -117,7 +113,7 @@ function _UserDefinedPropertyTableRows< ); }; - const itemMetadata = item.userDefinedProperties.map((prop) => ({ + const itemMetadata = item.metadata.map((prop) => ({ key: prop.key, type: prop.stringValue != null @@ -139,7 +135,7 @@ function _UserDefinedPropertyTableRows< newValue: number | string | string[], type: APIMetadataEnum, ) => { - updateUserDefinedPropertyByIndex(item, indexToUpdate, { + updateMetadataEntryByIndex(item, indexToUpdate, { stringValue: type === APIMetadataEnum.STRING ? (newValue as string) : undefined, stringListValue: type === APIMetadataEnum.STRING_ARRAY ? (newValue as string[]) : undefined, @@ -153,10 +149,10 @@ function _UserDefinedPropertyTableRows< }; const addNewEntryWithType = (type: APIMetadata["type"]) => { - const indexOfNewEntry = item.userDefinedProperties.length; + const indexOfNewEntry = item.metadata.length; // Auto focus the key input of the new entry. setTimeout(() => document.getElementById(getKeyInputIdForIndex(indexOfNewEntry))?.focus(), 50); - addUserDefinedProperty(item, { + addMetadataEntry(item, { key: "", stringValue: type === APIMetadataEnum.STRING ? "" : undefined, numberValue: type === APIMetadataEnum.NUMBER ? 0 : undefined, @@ -181,6 +177,4 @@ function _UserDefinedPropertyTableRows< ); } -export const UserDefinedPropertyTableRows = memo( - _UserDefinedPropertyTableRows, -) as typeof _UserDefinedPropertyTableRows; +export const MetadataEntryTableRows = memo(_MetadataTableRows) as typeof _MetadataTableRows; diff --git a/frontend/javascripts/test/fixtures/skeletontracing_server_objects.ts b/frontend/javascripts/test/fixtures/skeletontracing_server_objects.ts index 70800053e16..3bf873d4665 100644 --- a/frontend/javascripts/test/fixtures/skeletontracing_server_objects.ts +++ b/frontend/javascripts/test/fixtures/skeletontracing_server_objects.ts @@ -49,7 +49,7 @@ export const tracing: ServerSkeletonTracing = { ], name: "explorative_2017-08-09_SCM_Boy_002", isVisible: true, - userDefinedProperties: [], + metadata: [], }, { treeId: 1, @@ -117,7 +117,7 @@ export const tracing: ServerSkeletonTracing = { comments: [], isVisible: true, name: "explorative_2017-08-09_SCM_Boy_001", - userDefinedProperties: [], + metadata: [], }, ], treeGroups: [ diff --git a/frontend/javascripts/test/fixtures/tasktracing_server_objects.ts b/frontend/javascripts/test/fixtures/tasktracing_server_objects.ts index a5e8dead0ea..ac821f43780 100644 --- a/frontend/javascripts/test/fixtures/tasktracing_server_objects.ts +++ b/frontend/javascripts/test/fixtures/tasktracing_server_objects.ts @@ -39,7 +39,7 @@ export const tracing: ServerSkeletonTracing = { name: "", isVisible: true, createdTimestamp: 1528811979356, - userDefinedProperties: [], + metadata: [], }, ], treeGroups: [], diff --git a/frontend/javascripts/test/libs/nml.spec.ts b/frontend/javascripts/test/libs/nml.spec.ts index 1ceb3e90dca..5506e01c012 100644 --- a/frontend/javascripts/test/libs/nml.spec.ts +++ b/frontend/javascripts/test/libs/nml.spec.ts @@ -93,7 +93,7 @@ const initialSkeletonTracing: SkeletonTracing = { groupId: 3, type: TreeTypeEnum.DEFAULT, edgesAreVisible: true, - userDefinedProperties: [], + metadata: [], }, "2": { treeId: 2, @@ -121,7 +121,7 @@ const initialSkeletonTracing: SkeletonTracing = { groupId: 2, type: TreeTypeEnum.DEFAULT, edgesAreVisible: true, - userDefinedProperties: [], + metadata: [], }, }, treeGroups: [ @@ -452,7 +452,7 @@ test("NML serializer should produce correct NMLs with additional coordinates", ( t.snapshot(serializedNml); }); -test("NML serializer should produce correct NMLs with userDefinedProperties for trees", async (t) => { +test("NML serializer should produce correct NMLs with metadata for trees", async (t) => { const properties = [ { key: "key of string", @@ -480,7 +480,7 @@ test("NML serializer should produce correct NMLs with userDefinedProperties for skeleton: { trees: { "1": { - userDefinedProperties: { + metadata: { $set: properties, }, }, @@ -497,17 +497,15 @@ test("NML serializer should produce correct NMLs with userDefinedProperties for ); t.true( - serializedNml.includes( - '', - ), + serializedNml.includes(''), ); - t.true(serializedNml.includes('')); - t.true(serializedNml.includes('')); - t.true(serializedNml.includes('')); + t.true(serializedNml.includes('')); + t.true(serializedNml.includes('')); + t.true(serializedNml.includes('')); t.true( serializedNml.includes( - '', + '', ), ); diff --git a/frontend/javascripts/test/reducers/skeletontracing_reducer.spec.ts b/frontend/javascripts/test/reducers/skeletontracing_reducer.spec.ts index 6efd1b1a27d..2eb6a6858b4 100644 --- a/frontend/javascripts/test/reducers/skeletontracing_reducer.spec.ts +++ b/frontend/javascripts/test/reducers/skeletontracing_reducer.spec.ts @@ -79,7 +79,7 @@ initialSkeletonTracing.trees[1] = { groupId: MISSING_GROUP_ID, type: TreeTypeEnum.DEFAULT, edgesAreVisible: true, - userDefinedProperties: [], + metadata: [], }; const initialState: OxalisState = update(defaultState, { tracing: { @@ -352,7 +352,7 @@ test("SkeletonTracing should delete nodes and split the tree", (t) => { isVisible: true, type: TreeTypeEnum.DEFAULT, edgesAreVisible: true, - userDefinedProperties: [], + metadata: [], }, [1]: { treeId: 1, @@ -380,7 +380,7 @@ test("SkeletonTracing should delete nodes and split the tree", (t) => { isVisible: true, type: TreeTypeEnum.DEFAULT, edgesAreVisible: true, - userDefinedProperties: [], + metadata: [], }, }, }, @@ -510,7 +510,7 @@ test("SkeletonTracing should delete an edge and split the tree", (t) => { isVisible: true, type: TreeTypeEnum.DEFAULT, edgesAreVisible: true, - userDefinedProperties: [], + metadata: [], }, [1]: { treeId: 1, @@ -538,7 +538,7 @@ test("SkeletonTracing should delete an edge and split the tree", (t) => { isVisible: true, type: TreeTypeEnum.DEFAULT, edgesAreVisible: true, - userDefinedProperties: [], + metadata: [], }, }, }, diff --git a/frontend/javascripts/test/sagas/skeletontracing_saga.spec.ts b/frontend/javascripts/test/sagas/skeletontracing_saga.spec.ts index 4901ce146d4..d6c9490cc8b 100644 --- a/frontend/javascripts/test/sagas/skeletontracing_saga.spec.ts +++ b/frontend/javascripts/test/sagas/skeletontracing_saga.spec.ts @@ -141,7 +141,7 @@ skeletonTracing.trees[1] = { groupId: MISSING_GROUP_ID, type: TreeTypeEnum.DEFAULT, edgesAreVisible: true, - userDefinedProperties: [], + metadata: [], }; const initialState = update(defaultState, { tracing: { diff --git a/frontend/javascripts/types/api_flow_types.ts b/frontend/javascripts/types/api_flow_types.ts index 888fe4819d0..514116aceb5 100644 --- a/frontend/javascripts/types/api_flow_types.ts +++ b/frontend/javascripts/types/api_flow_types.ts @@ -779,9 +779,9 @@ export type ServerSkeletonTracingTree = { type?: TreeType; edgesAreVisible?: boolean; // todop: check whether this is really not-optional - userDefinedProperties: UserDefinedProperty[]; + metadata: MetadataEntry[]; }; -export type UserDefinedProperty = { +export type MetadataEntry = { key: string; stringValue?: string; boolValue?: boolean; @@ -797,7 +797,7 @@ type ServerSegment = { creationTime: number | null | undefined; color: ColorObject | null; groupId: number | null | undefined; - userDefinedProperties: UserDefinedProperty[]; + metadata: MetadataEntry[]; }; export type ServerTracingBase = { id: string; From 871ac941bb14f5ab5b0118871634ff6803051277 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Thu, 12 Sep 2024 17:00:34 +0200 Subject: [PATCH 53/79] clean up --- .../dashboard/folders/metadata_table.tsx | 9 --------- frontend/javascripts/libs/utils.ts | 13 ------------- frontend/javascripts/messages.tsx | 2 +- frontend/javascripts/oxalis/api/wk_dev.ts | 6 +++--- .../javascripts/oxalis/model/helpers/nml_helpers.ts | 2 +- 5 files changed, 5 insertions(+), 27 deletions(-) diff --git a/frontend/javascripts/dashboard/folders/metadata_table.tsx b/frontend/javascripts/dashboard/folders/metadata_table.tsx index 83a9a5d29d0..b8874decfee 100644 --- a/frontend/javascripts/dashboard/folders/metadata_table.tsx +++ b/frontend/javascripts/dashboard/folders/metadata_table.tsx @@ -23,7 +23,6 @@ import { } from "dashboard/dataset/dataset_collection_context"; import { useIsMounted, useStateWithRef } from "libs/react_hooks"; import Toast from "libs/toast"; -import { isStringNumeric } from "libs/utils"; import _ from "lodash"; import { InputNumberWithUpdateOnBlur, @@ -147,14 +146,6 @@ export const MetadataValueInput: React.FC = ({ console.log("onChange was called with", newNum); return updateMetadataValue(index, newNum || 0, APIMetadataEnum.NUMBER); }} - // validate={(value: string) => { - // if (isStringNumeric(value)) { - // console.log("validating", value, ": okay"); - // return null; - // } - // console.log("validating", value, ": not okay"); - // return "The value must be a number."; - // }} {...sharedProps} /> ); diff --git a/frontend/javascripts/libs/utils.ts b/frontend/javascripts/libs/utils.ts index 1be7b9361e9..2a7af53dab6 100644 --- a/frontend/javascripts/libs/utils.ts +++ b/frontend/javascripts/libs/utils.ts @@ -1237,19 +1237,6 @@ export function notEmpty(value: TValue | null | undefined): value is TVa return value !== null && value !== undefined; } -export function isStringNumeric(str: string) { - // Adapted from: https://stackoverflow.com/a/175787/896760 - if (typeof str !== "string") { - return false; - } - return ( - // Use type coercion to parse the _entirety_ of the - // string (`parseFloat` alone does not do this) and - // and ensure strings of whitespace fail - !isNaN(str as any as number) && !isNaN(Number.parseFloat(str)) - ); -} - export function isNumberMap(x: Map): x is Map { const { value } = x.entries().next(); return value && typeof value[0] === "number"; diff --git a/frontend/javascripts/messages.tsx b/frontend/javascripts/messages.tsx index 6bfd0db2c22..689703831f4 100644 --- a/frontend/javascripts/messages.tsx +++ b/frontend/javascripts/messages.tsx @@ -435,7 +435,7 @@ instead. Only enable this option if you understand its effect. All layers will n "NML contains tag that is not enclosed by a tag: Node with id", "nml.edge_outside_tree": "NML contains tag that is not enclosed by a tag: Edge", - "nml.user_defined_property_outside_tree": + "nml.metadata_entry_outside_tree": "NML contains tag that is not enclosed by a tag", "nml.expected_attribute_missing": "Attribute with the following name was expected, but is missing or empty:", diff --git a/frontend/javascripts/oxalis/api/wk_dev.ts b/frontend/javascripts/oxalis/api/wk_dev.ts index 333f523a88f..d3b5c4d7f16 100644 --- a/frontend/javascripts/oxalis/api/wk_dev.ts +++ b/frontend/javascripts/oxalis/api/wk_dev.ts @@ -182,12 +182,12 @@ export default class WkDev { } const duration = performance.now() - start; console.timeEnd("Move Benchmark"); - if (this.benchmarkHistory.length > 0) { + this.benchmarkHistory.push(duration); + if (this.benchmarkHistory.length > 1) { console.log( - `Average of previous ${this.benchmarkHistory.length} benchmark runs:`, + `Average of all ${this.benchmarkHistory.length} benchmark runs:`, _.mean(this.benchmarkHistory), ); } - this.benchmarkHistory.push(duration); } } diff --git a/frontend/javascripts/oxalis/model/helpers/nml_helpers.ts b/frontend/javascripts/oxalis/model/helpers/nml_helpers.ts index 9bb6ed98d2d..c0dc23a7179 100644 --- a/frontend/javascripts/oxalis/model/helpers/nml_helpers.ts +++ b/frontend/javascripts/oxalis/model/helpers/nml_helpers.ts @@ -975,7 +975,7 @@ export function parseNml(nmlString: string): Promise<{ case "metadataEntry": { if (currentTree == null) { - throw new NmlParseError(messages["nml.user_defined_property_outside_tree"]); + throw new NmlParseError(messages["nml.metadata_entry_outside_tree"]); } if (currentNode == null) { currentTree.metadata.push(parseMetadataEntry(attr)); From 5fbe505af37104ab051185186a4d6280658527f0 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Thu, 12 Sep 2024 17:03:06 +0200 Subject: [PATCH 54/79] rename metadata-table-wrapper class --- frontend/javascripts/dashboard/folders/metadata_table.tsx | 2 +- frontend/stylesheets/_dashboard.less | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/javascripts/dashboard/folders/metadata_table.tsx b/frontend/javascripts/dashboard/folders/metadata_table.tsx index b8874decfee..19c81e78b29 100644 --- a/frontend/javascripts/dashboard/folders/metadata_table.tsx +++ b/frontend/javascripts/dashboard/folders/metadata_table.tsx @@ -447,7 +447,7 @@ export default function MetadataTable({ return (
Metadata
-
+
{/* Not using AntD Table to have more control over the styling. */} {metadata.length > 0 ? ( Date: Thu, 12 Sep 2024 17:05:37 +0200 Subject: [PATCH 55/79] more renaming --- .../oxalis/model/actions/skeletontracing_actions.tsx | 4 ++-- .../oxalis/model/reducers/skeletontracing_reducer.ts | 2 +- .../{user_defined_properties_table.tsx => metadata_table.tsx} | 0 .../view/right-border-tabs/segments_tab/segments_view.tsx | 2 +- .../oxalis/view/right-border-tabs/tree_hierarchy_view.tsx | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) rename frontend/javascripts/oxalis/view/right-border-tabs/{user_defined_properties_table.tsx => metadata_table.tsx} (100%) diff --git a/frontend/javascripts/oxalis/model/actions/skeletontracing_actions.tsx b/frontend/javascripts/oxalis/model/actions/skeletontracing_actions.tsx index ee14d134f24..f04d6a03662 100644 --- a/frontend/javascripts/oxalis/model/actions/skeletontracing_actions.tsx +++ b/frontend/javascripts/oxalis/model/actions/skeletontracing_actions.tsx @@ -141,7 +141,7 @@ export const SkeletonTracingSaveRelevantActions = [ "SET_ACTIVE_TREE", "SET_ACTIVE_TREE_BY_NAME", "SET_TREE_NAME", - "SET_TREE_USER_DEFINED_PROPERTIES", + "SET_TREE_METADATA", "MERGE_TREES", "SELECT_NEXT_TREE", "SHUFFLE_TREE_COLOR", @@ -431,7 +431,7 @@ export const setTreeMetadataAction = ( treeId?: number | null | undefined, ) => ({ - type: "SET_TREE_USER_DEFINED_PROPERTIES", + type: "SET_TREE_METADATA", metadata, treeId, }) as const; diff --git a/frontend/javascripts/oxalis/model/reducers/skeletontracing_reducer.ts b/frontend/javascripts/oxalis/model/reducers/skeletontracing_reducer.ts index 8c978a3b66d..d9d4ee1fac1 100644 --- a/frontend/javascripts/oxalis/model/reducers/skeletontracing_reducer.ts +++ b/frontend/javascripts/oxalis/model/reducers/skeletontracing_reducer.ts @@ -1025,7 +1025,7 @@ function SkeletonTracingReducer(state: OxalisState, action: Action): OxalisState .getOrElse(state); } - case "SET_TREE_USER_DEFINED_PROPERTIES": { + case "SET_TREE_METADATA": { return getTree(skeletonTracing, action.treeId) .map((tree) => { return update(state, { diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/user_defined_properties_table.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/metadata_table.tsx similarity index 100% rename from frontend/javascripts/oxalis/view/right-border-tabs/user_defined_properties_table.tsx rename to frontend/javascripts/oxalis/view/right-border-tabs/metadata_table.tsx diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx index 9574678de7d..769edee1c26 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx @@ -132,7 +132,7 @@ import { getGroupNodeKey, MISSING_GROUP_ID, } from "../tree_hierarchy_view_helpers"; -import { MetadataEntryTableRows } from "../user_defined_properties_table"; +import { MetadataEntryTableRows } from "../metadata_table"; import { SegmentStatisticsModal } from "./segment_statistics_modal"; import type { ItemType } from "antd/lib/menu/interface"; import { InputWithUpdateOnBlur } from "oxalis/view/components/input_with_update_on_blur"; diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx index 35243ecdbe9..f38bc29f13b 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx @@ -37,7 +37,7 @@ import { setUpdateTreeGroups, } from "./tree_hierarchy_renderers"; import { ResizableSplitPane } from "./resizable_split_pane"; -import { MetadataEntryTableRows } from "./user_defined_properties_table"; +import { MetadataEntryTableRows } from "./metadata_table"; import type { MetadataEntry } from "types/api_flow_types"; import { InputWithUpdateOnBlur } from "../components/input_with_update_on_blur"; From 9b788083a53d3e9380c6c437c7f3f12c0174c797 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Thu, 12 Sep 2024 17:06:00 +0200 Subject: [PATCH 56/79] remove debug code --- .../oxalis/view/right-border-tabs/metadata_table.tsx | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/metadata_table.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/metadata_table.tsx index 853abb3c0e8..27cfb2f4c25 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/metadata_table.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/metadata_table.tsx @@ -40,18 +40,6 @@ function _MetadataTableRows({ : element, ); - // todop: remove again? - if ( - newProps.some( - (el) => - (el.stringValue != null && el.stringListValue != null) || - (el.stringListValue as any) === "" || - Array.isArray(el.stringValue), - ) - ) { - console.error("invalid newprops?"); - } - setMetadata(item, newProps); }; From 0fb7f3fd8cbae7bb8a9a73106c77d60a088894e4 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Thu, 12 Sep 2024 17:08:56 +0200 Subject: [PATCH 57/79] clarify comments --- frontend/javascripts/oxalis/store.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/javascripts/oxalis/store.ts b/frontend/javascripts/oxalis/store.ts index 0812b5ab5ca..53f722e6e66 100644 --- a/frontend/javascripts/oxalis/store.ts +++ b/frontend/javascripts/oxalis/store.ts @@ -130,7 +130,7 @@ export type UserBoundingBoxWithoutId = { export type UserBoundingBox = UserBoundingBoxWithoutId & { id: number; }; -// Remember to also update Tree +// When changing MutableTree, remember to also update Tree export type MutableTree = { treeId: number; groupId: number | null | undefined; @@ -146,7 +146,7 @@ export type MutableTree = { edgesAreVisible: boolean; metadata: MetadataEntry[]; }; -// Remember to also update MutableTree +// When changing Tree, remember to also update MutableTree export type Tree = { readonly treeId: number; readonly groupId: number | null | undefined; From ccc2960bb02462bb982120aaa9e763016da9391d Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Thu, 12 Sep 2024 17:09:19 +0200 Subject: [PATCH 58/79] delete unused InputNumberWithUpdateOnBlur --- .../dashboard/folders/metadata_table.tsx | 5 +- .../components/input_with_update_on_blur.tsx | 56 ------------------- 2 files changed, 1 insertion(+), 60 deletions(-) diff --git a/frontend/javascripts/dashboard/folders/metadata_table.tsx b/frontend/javascripts/dashboard/folders/metadata_table.tsx index 19c81e78b29..883878c82e0 100644 --- a/frontend/javascripts/dashboard/folders/metadata_table.tsx +++ b/frontend/javascripts/dashboard/folders/metadata_table.tsx @@ -24,10 +24,7 @@ import { import { useIsMounted, useStateWithRef } from "libs/react_hooks"; import Toast from "libs/toast"; import _ from "lodash"; -import { - InputNumberWithUpdateOnBlur, - InputWithUpdateOnBlur, -} from "oxalis/view/components/input_with_update_on_blur"; +import { InputWithUpdateOnBlur } from "oxalis/view/components/input_with_update_on_blur"; import type React from "react"; import { useEffect } from "react"; import { useState } from "react"; diff --git a/frontend/javascripts/oxalis/view/components/input_with_update_on_blur.tsx b/frontend/javascripts/oxalis/view/components/input_with_update_on_blur.tsx index 1265261ca31..8508742977e 100644 --- a/frontend/javascripts/oxalis/view/components/input_with_update_on_blur.tsx +++ b/frontend/javascripts/oxalis/view/components/input_with_update_on_blur.tsx @@ -54,59 +54,3 @@ export function InputWithUpdateOnBlur({ ); } - -// todop: delete again together with isnumeric -export function InputNumberWithUpdateOnBlur({ - value, - onChange, - onBlur, - validate, - ...props -}: { - value: number; - validate?: (value: string) => string | null; - onChange: (value: number) => void; -} & Omit) { - const [localValue, setLocalValue] = useState(`${value}`); - - const onKeyDown = useCallback( - (event: React.KeyboardEvent) => { - if (event.key === "Enter") { - onChange(Number.parseFloat(localValue)); - } else if (event.key === "Escape") { - document.activeElement ? (document.activeElement as HTMLElement).blur() : null; - } - if (props.onKeyDown) { - return props.onKeyDown(event); - } - }, - [onChange, props.onKeyDown, localValue], - ); - - useEffect(() => { - setLocalValue(`${value}`); - }, [value]); - - const validationError = validate != null ? validate(localValue) : null; - const status = validationError != null ? "error" : undefined; - - return ( - - { - if (onBlur) onBlur(event); - onChange(Number.parseFloat(localValue)); - }} - onChange={(value) => { - console.log("setting local value", value); - setLocalValue(value as string); - }} - onKeyDown={onKeyDown} - status={status} - {...props} - /> - - ); -} From a6296eae5daed442e0ebafdd811f20d293a9e328 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Thu, 12 Sep 2024 17:18:43 +0200 Subject: [PATCH 59/79] minor clean up --- frontend/javascripts/oxalis/model/helpers/nml_helpers.ts | 2 +- .../oxalis/model/reducers/skeletontracing_reducer.ts | 6 ++++-- .../oxalis/view/right-border-tabs/metadata_table.tsx | 1 - frontend/javascripts/types/api_flow_types.ts | 4 +++- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/frontend/javascripts/oxalis/model/helpers/nml_helpers.ts b/frontend/javascripts/oxalis/model/helpers/nml_helpers.ts index c0dc23a7179..633911100ea 100644 --- a/frontend/javascripts/oxalis/model/helpers/nml_helpers.ts +++ b/frontend/javascripts/oxalis/model/helpers/nml_helpers.ts @@ -980,7 +980,7 @@ export function parseNml(nmlString: string): Promise<{ if (currentNode == null) { currentTree.metadata.push(parseMetadataEntry(attr)); } else { - // todop: link follow-up issue for custom metadata in nodes + // TODO: Also support MetadataEntry in nodes. See #7483 } break; } diff --git a/frontend/javascripts/oxalis/model/reducers/skeletontracing_reducer.ts b/frontend/javascripts/oxalis/model/reducers/skeletontracing_reducer.ts index d9d4ee1fac1..862d4fc54c6 100644 --- a/frontend/javascripts/oxalis/model/reducers/skeletontracing_reducer.ts +++ b/frontend/javascripts/oxalis/model/reducers/skeletontracing_reducer.ts @@ -1156,8 +1156,10 @@ function SkeletonTracingReducer(state: OxalisState, action: Action): OxalisState } export function sanitizeMetadata(metadata: MetadataEntry[]) { - // todop: or should this happen in enforceValidMetadata when the update-actions for - // saving are crafted? + // Workaround for stringList values that are [], even though they + // should be null. This workaround is necessary because protobuf cannot + // distinguish between an empty list and an not existent property. + // Therefore, we clean this up here. return metadata.map((prop) => { // If stringList value is defined, but it's an empty array, it should // be switched to undefined diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/metadata_table.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/metadata_table.tsx index 27cfb2f4c25..07facdd0c0e 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/metadata_table.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/metadata_table.tsx @@ -128,7 +128,6 @@ function _MetadataTableRows({ stringListValue: type === APIMetadataEnum.STRING_ARRAY ? (newValue as string[]) : undefined, numberValue: type === APIMetadataEnum.NUMBER ? (newValue as number) : undefined, - // todop: support bool? }); }} availableStrArrayTagOptions={getUsedTagsWithinMetadata(itemMetadata)} diff --git a/frontend/javascripts/types/api_flow_types.ts b/frontend/javascripts/types/api_flow_types.ts index 514116aceb5..223f55ca445 100644 --- a/frontend/javascripts/types/api_flow_types.ts +++ b/frontend/javascripts/types/api_flow_types.ts @@ -786,7 +786,9 @@ export type MetadataEntry = { stringValue?: string; boolValue?: boolean; numberValue?: number; - // todop: the server always sends an empty array + // Note that the server always sends an empty array currently, + // because of the protobuf format. However, for consistency within + // JS land, we mark it as nullable here. stringListValue?: string[]; }; type ServerSegment = { From dcbdbe92fca44084724289a99cb735496595d29d Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Fri, 13 Sep 2024 09:45:12 +0200 Subject: [PATCH 60/79] re-enable ci --- .circleci/not-on-master.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.circleci/not-on-master.sh b/.circleci/not-on-master.sh index e3078cdb9ce..581393ebead 100755 --- a/.circleci/not-on-master.sh +++ b/.circleci/not-on-master.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash set -Eeuo pipefail -# if [ "${CIRCLE_BRANCH}" == "master" ]; then +if [ "${CIRCLE_BRANCH}" == "master" ]; then echo "Skipping this step on master..." -# else -# exec "$@" -# fi +else + exec "$@" +fi From ce62e056ef739e6900f4239730b26a35ee124fa6 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Fri, 13 Sep 2024 10:01:49 +0200 Subject: [PATCH 61/79] fix compact_toggle_actions.spec.ts and make it type check --- .../test/sagas/compact_toggle_actions.spec.ts | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/frontend/javascripts/test/sagas/compact_toggle_actions.spec.ts b/frontend/javascripts/test/sagas/compact_toggle_actions.spec.ts index 4103d7daebf..ab4b7c12d28 100644 --- a/frontend/javascripts/test/sagas/compact_toggle_actions.spec.ts +++ b/frontend/javascripts/test/sagas/compact_toggle_actions.spec.ts @@ -1,7 +1,6 @@ -// @ts-nocheck import _ from "lodash"; import "test/mocks/lz4"; -import type { Flycam, OxalisState, Tree, TreeMap } from "oxalis/store"; +import type { Flycam, OxalisState, Tree, TreeGroup, TreeMap } from "oxalis/store"; import { diffSkeletonTracing } from "oxalis/model/sagas/skeletontracing_saga"; import { enforceSkeletonTracing } from "oxalis/model/accessors/skeletontracing_accessor"; import { updateTreeGroupVisibility, updateTreeVisibility } from "oxalis/model/sagas/update_actions"; @@ -12,7 +11,7 @@ import compactToggleActions from "oxalis/model/helpers/compaction/compact_toggle import defaultState from "oxalis/default_state"; import test from "ava"; -const createTree = (id, groupId, isVisible) => ({ +const createTree = (id: number, groupId: number | null, isVisible: boolean): Tree => ({ treeId: id, name: "TestTree", nodes: new DiffableMap(), @@ -23,11 +22,14 @@ const createTree = (id, groupId, isVisible) => ({ color: [23, 23, 23], isVisible, groupId, + edgesAreVisible: true, + metadata: [], + type: "DEFAULT", }); -const makeTreesObject = (trees) => _.keyBy(trees, "treeId") as any as TreeMap; +const makeTreesObject = (trees: Tree[]) => _.keyBy(trees, "treeId") as TreeMap; -const treeGroups = [ +const treeGroups: TreeGroup[] = [ { name: "subroot1", groupId: 1, @@ -53,12 +55,12 @@ const treeGroups = [ ]; const flycamMock = {} as any as Flycam; -const createState = (trees, _treeGroups): OxalisState => ({ +const createState = (trees: Tree[], _treeGroups: TreeGroup[]): OxalisState => ({ ...defaultState, tracing: { ...defaultState.tracing, skeleton: { - ...defaultState.tracing.skeleton, + additionalAxes: [], createdTimestamp: 0, version: 0, tracingId: "tracingId", @@ -93,7 +95,7 @@ const allVisible = createState( treeGroups, ); -function testDiffing(prevState, nextState) { +function testDiffing(prevState: OxalisState, nextState: OxalisState) { // Let's remove updateTree actions as well, as these will occur here // because we don't do shallow updates within the tests (instead, we are // are creating completely new trees, so that we don't have to go through the @@ -120,7 +122,7 @@ function _updateTreeVisibility(treeId: number, isVisible: boolean) { return updateTreeVisibility(tree); } -function getActions(initialState, newState) { +function getActions(initialState: OxalisState, newState: OxalisState) { const updateActions = testDiffing(initialState, newState); if (newState.tracing.skeleton == null) { From 6870faf359087d13ce8733d7553a7408528ba20e Mon Sep 17 00:00:00 2001 From: Florian M Date: Mon, 16 Sep 2024 11:12:31 +0200 Subject: [PATCH 62/79] update snapshots --- .../annotations.e2e.js.md | 70 +++++++++--------- .../annotations.e2e.js.snap | Bin 0 -> 13017 bytes 2 files changed, 35 insertions(+), 35 deletions(-) create mode 100644 frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/annotations.e2e.js.snap diff --git a/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/annotations.e2e.js.md b/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/annotations.e2e.js.md index 088581e8f17..c20e7760ef9 100644 --- a/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/annotations.e2e.js.md +++ b/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/annotations.e2e.js.md @@ -1501,6 +1501,7 @@ Generated by [AVA](https://avajs.dev). ], edgesAreVisible: true, isVisible: true, + metadata: [], name: 'explorative_2017-10-09_SCM_Boy_023', nodes: [ { @@ -1509,6 +1510,7 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, + metadata: [], position: { x: 10120, y: 3727, @@ -1521,7 +1523,6 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, - userDefinedProperties: [], viewport: 1, }, { @@ -1530,6 +1531,7 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, + metadata: [], position: { x: 9120, y: 3727, @@ -1542,7 +1544,6 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, - userDefinedProperties: [], viewport: 1, }, { @@ -1551,6 +1552,7 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, + metadata: [], position: { x: 8120, y: 3727, @@ -1563,7 +1565,6 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, - userDefinedProperties: [], viewport: 1, }, { @@ -1572,6 +1573,7 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, + metadata: [], position: { x: 7120, y: 3727, @@ -1584,7 +1586,6 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, - userDefinedProperties: [], viewport: 1, }, { @@ -1593,6 +1594,7 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, + metadata: [], position: { x: 6120, y: 3727, @@ -1605,7 +1607,6 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, - userDefinedProperties: [], viewport: 1, }, { @@ -1614,6 +1615,7 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, + metadata: [], position: { x: 5120, y: 3727, @@ -1626,13 +1628,11 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, - userDefinedProperties: [], viewport: 1, }, ], treeId: 5, type: 'DEFAULT', - userDefinedProperties: [], }, { branchPoints: [], @@ -1668,6 +1668,7 @@ Generated by [AVA](https://avajs.dev). ], edgesAreVisible: true, isVisible: true, + metadata: [], name: 'explorative_2017-10-09_SCM_Boy_023', nodes: [ { @@ -1676,6 +1677,7 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, + metadata: [], position: { x: 10120, y: 3726, @@ -1688,7 +1690,6 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, - userDefinedProperties: [], viewport: 1, }, { @@ -1697,6 +1698,7 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, + metadata: [], position: { x: 9120, y: 3726, @@ -1709,7 +1711,6 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, - userDefinedProperties: [], viewport: 1, }, { @@ -1718,6 +1719,7 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, + metadata: [], position: { x: 8120, y: 3726, @@ -1730,7 +1732,6 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, - userDefinedProperties: [], viewport: 1, }, { @@ -1739,6 +1740,7 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, + metadata: [], position: { x: 7120, y: 3726, @@ -1751,7 +1753,6 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, - userDefinedProperties: [], viewport: 1, }, { @@ -1760,6 +1761,7 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, + metadata: [], position: { x: 6120, y: 3726, @@ -1772,7 +1774,6 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, - userDefinedProperties: [], viewport: 1, }, { @@ -1781,6 +1782,7 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, + metadata: [], position: { x: 5120, y: 3726, @@ -1793,13 +1795,11 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, - userDefinedProperties: [], viewport: 1, }, ], treeId: 4, type: 'DEFAULT', - userDefinedProperties: [], }, { branchPoints: [], @@ -1835,6 +1835,7 @@ Generated by [AVA](https://avajs.dev). ], edgesAreVisible: true, isVisible: true, + metadata: [], name: 'explorative_2017-10-09_SCM_Boy_023', nodes: [ { @@ -1843,6 +1844,7 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, + metadata: [], position: { x: 10120, y: 3726, @@ -1855,7 +1857,6 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, - userDefinedProperties: [], viewport: 1, }, { @@ -1864,6 +1865,7 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, + metadata: [], position: { x: 9120, y: 3726, @@ -1876,7 +1878,6 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, - userDefinedProperties: [], viewport: 1, }, { @@ -1885,6 +1886,7 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, + metadata: [], position: { x: 8120, y: 3726, @@ -1897,7 +1899,6 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, - userDefinedProperties: [], viewport: 1, }, { @@ -1906,6 +1907,7 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, + metadata: [], position: { x: 7120, y: 3726, @@ -1918,7 +1920,6 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, - userDefinedProperties: [], viewport: 1, }, { @@ -1927,6 +1928,7 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, + metadata: [], position: { x: 6120, y: 3726, @@ -1939,7 +1941,6 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, - userDefinedProperties: [], viewport: 1, }, { @@ -1948,6 +1949,7 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, + metadata: [], position: { x: 5120, y: 3726, @@ -1960,13 +1962,11 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, - userDefinedProperties: [], viewport: 1, }, ], treeId: 3, type: 'DEFAULT', - userDefinedProperties: [], }, { branchPoints: [], @@ -2002,6 +2002,7 @@ Generated by [AVA](https://avajs.dev). ], edgesAreVisible: true, isVisible: true, + metadata: [], name: 'explorative_2017-10-09_SCM_Boy_023', nodes: [ { @@ -2010,6 +2011,7 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, + metadata: [], position: { x: 10120, y: 3725, @@ -2022,7 +2024,6 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, - userDefinedProperties: [], viewport: 1, }, { @@ -2031,6 +2032,7 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, + metadata: [], position: { x: 9120, y: 3725, @@ -2043,7 +2045,6 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, - userDefinedProperties: [], viewport: 1, }, { @@ -2052,6 +2053,7 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, + metadata: [], position: { x: 8120, y: 3725, @@ -2064,7 +2066,6 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, - userDefinedProperties: [], viewport: 1, }, { @@ -2073,6 +2074,7 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, + metadata: [], position: { x: 7120, y: 3725, @@ -2085,7 +2087,6 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, - userDefinedProperties: [], viewport: 1, }, { @@ -2094,6 +2095,7 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, + metadata: [], position: { x: 6120, y: 3725, @@ -2106,7 +2108,6 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, - userDefinedProperties: [], viewport: 1, }, { @@ -2115,6 +2116,7 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, + metadata: [], position: { x: 5120, y: 3725, @@ -2127,13 +2129,11 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, - userDefinedProperties: [], viewport: 1, }, ], treeId: 2, type: 'DEFAULT', - userDefinedProperties: [], }, { branchPoints: [], @@ -2169,6 +2169,7 @@ Generated by [AVA](https://avajs.dev). ], edgesAreVisible: true, isVisible: true, + metadata: [], name: 'explorative_2017-10-09_SCM_Boy_023', nodes: [ { @@ -2177,6 +2178,7 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, + metadata: [], position: { x: 10120, y: 3725, @@ -2189,7 +2191,6 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, - userDefinedProperties: [], viewport: 1, }, { @@ -2198,6 +2199,7 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, + metadata: [], position: { x: 9120, y: 3725, @@ -2210,7 +2212,6 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, - userDefinedProperties: [], viewport: 1, }, { @@ -2219,6 +2220,7 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, + metadata: [], position: { x: 8120, y: 3725, @@ -2231,7 +2233,6 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, - userDefinedProperties: [], viewport: 1, }, { @@ -2240,6 +2241,7 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, + metadata: [], position: { x: 7120, y: 3725, @@ -2252,7 +2254,6 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, - userDefinedProperties: [], viewport: 1, }, { @@ -2261,6 +2262,7 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, + metadata: [], position: { x: 6120, y: 3725, @@ -2273,7 +2275,6 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, - userDefinedProperties: [], viewport: 1, }, { @@ -2282,6 +2283,7 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, + metadata: [], position: { x: 5120, y: 3725, @@ -2294,13 +2296,11 @@ Generated by [AVA](https://avajs.dev). y: 270, z: 0, }, - userDefinedProperties: [], viewport: 1, }, ], treeId: 1, type: 'DEFAULT', - userDefinedProperties: [], }, ], typ: 'Skeleton', diff --git a/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/annotations.e2e.js.snap b/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/annotations.e2e.js.snap new file mode 100644 index 0000000000000000000000000000000000000000..c34f2588f55fc31d184a6453739be5af5c0df44f GIT binary patch literal 13017 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 Date: Mon, 16 Sep 2024 16:32:57 +0200 Subject: [PATCH 63/79] remove todo comment --- frontend/javascripts/types/api_flow_types.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/javascripts/types/api_flow_types.ts b/frontend/javascripts/types/api_flow_types.ts index 223f55ca445..81ab3858e97 100644 --- a/frontend/javascripts/types/api_flow_types.ts +++ b/frontend/javascripts/types/api_flow_types.ts @@ -778,7 +778,6 @@ export type ServerSkeletonTracingTree = { isVisible?: boolean; type?: TreeType; edgesAreVisible?: boolean; - // todop: check whether this is really not-optional metadata: MetadataEntry[]; }; export type MetadataEntry = { From 06d938c89cc0db5c9e03f6c878fa0c29a09011b9 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Mon, 16 Sep 2024 16:33:48 +0200 Subject: [PATCH 64/79] make tooltip color consistent orange --- .../oxalis/view/components/input_with_update_on_blur.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/javascripts/oxalis/view/components/input_with_update_on_blur.tsx b/frontend/javascripts/oxalis/view/components/input_with_update_on_blur.tsx index 8508742977e..0e69a41ecc9 100644 --- a/frontend/javascripts/oxalis/view/components/input_with_update_on_blur.tsx +++ b/frontend/javascripts/oxalis/view/components/input_with_update_on_blur.tsx @@ -37,7 +37,7 @@ export function InputWithUpdateOnBlur({ const status = validationError != null ? "error" : undefined; return ( - + { From 706d3a9bee7897d9c51dca02c2a6117b7337a580 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Mon, 16 Sep 2024 16:36:46 +0200 Subject: [PATCH 65/79] add tooltip to tag icon --- .../oxalis/view/right-border-tabs/metadata_table.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/metadata_table.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/metadata_table.tsx index 07facdd0c0e..2ddb0e8e110 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/metadata_table.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/metadata_table.tsx @@ -11,6 +11,7 @@ import { type APIMetadata, APIMetadataEnum, type MetadataEntry } from "types/api import { InputWithUpdateOnBlur } from "../components/input_with_update_on_blur"; import _ from "lodash"; import { memo } from "react"; +import FastTooltip from "components/fast_tooltip"; const getKeyInputIdForIndex = (index: number) => `metadata-key-input-id-${index}`; @@ -78,7 +79,11 @@ function _MetadataTableRows({ return (
} + prefix={ + + + + } className="transparent-input" value={record.key} disabled={readOnly} From cc733a2c0c9ae5b94c80de29f6f307f83e5619de Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Mon, 16 Sep 2024 16:44:20 +0200 Subject: [PATCH 66/79] Update frontend/stylesheets/_variables.less Co-authored-by: MichaelBuessemeyer <39529669+MichaelBuessemeyer@users.noreply.github.com> --- frontend/stylesheets/_variables.less | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/stylesheets/_variables.less b/frontend/stylesheets/_variables.less index 5c82d16660a..bb47e804f6a 100644 --- a/frontend/stylesheets/_variables.less +++ b/frontend/stylesheets/_variables.less @@ -14,7 +14,6 @@ --color-xz-viewport: var(--color-blue-zircon); --navbar-height: 48px; --footer-height: 20px; - } .ant-app { From a09f31d7beca02a1e4a9a19c8920313674e8b08f Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Mon, 16 Sep 2024 16:44:30 +0200 Subject: [PATCH 67/79] integrate feedback --- .../dashboard/folders/metadata_table.tsx | 19 +++++++++---------- .../model/reducers/skeletontracing_reducer.ts | 2 +- .../view/components/input_component.tsx | 3 +-- .../components/input_with_update_on_blur.tsx | 2 +- 4 files changed, 12 insertions(+), 14 deletions(-) diff --git a/frontend/javascripts/dashboard/folders/metadata_table.tsx b/frontend/javascripts/dashboard/folders/metadata_table.tsx index 883878c82e0..8eb934f478a 100644 --- a/frontend/javascripts/dashboard/folders/metadata_table.tsx +++ b/frontend/javascripts/dashboard/folders/metadata_table.tsx @@ -98,6 +98,15 @@ const EmptyMetadataPlaceholder: React.FC = ({ ); }; +export function getUsedTagsWithinMetadata(metadata: APIMetadataWithError[]) { + return _.uniq( + metadata.flatMap((entry) => (entry.type === APIMetadataEnum.STRING_ARRAY ? entry.value : [])), + ).map((tag) => ({ value: tag, label: tag })) as { + value: string; + label: string; + }[]; +} + interface MetadataValueInputProps { record: APIMetadataWithError; index: number; @@ -140,7 +149,6 @@ export const MetadataValueInput: React.FC = ({ controls={false} placeholder="Enter a number" onChange={(newNum) => { - console.log("onChange was called with", newNum); return updateMetadataValue(index, newNum || 0, APIMetadataEnum.NUMBER); }} {...sharedProps} @@ -462,15 +470,6 @@ export default function MetadataTable({ ); } -export function getUsedTagsWithinMetadata(metadata: APIMetadataWithError[]) { - return _.uniq( - metadata.flatMap((entry) => (entry.type === APIMetadataEnum.STRING_ARRAY ? entry.value : [])), - ).map((tag) => ({ value: tag, label: tag })) as { - value: string; - label: string; - }[]; -} - export function InnerMetadataTable({ metadata, getKeyInput, diff --git a/frontend/javascripts/oxalis/model/reducers/skeletontracing_reducer.ts b/frontend/javascripts/oxalis/model/reducers/skeletontracing_reducer.ts index 862d4fc54c6..c5a4e284019 100644 --- a/frontend/javascripts/oxalis/model/reducers/skeletontracing_reducer.ts +++ b/frontend/javascripts/oxalis/model/reducers/skeletontracing_reducer.ts @@ -1166,7 +1166,7 @@ export function sanitizeMetadata(metadata: MetadataEntry[]) { const needsCorrection = prop.stringListValue != null && prop.stringListValue.length === 0 && - (prop.stringValue != null || prop.numberValue != null); + (prop.stringValue != null || prop.numberValue != null || prop.boolValue != null); if (needsCorrection) { return { ...prop, diff --git a/frontend/javascripts/oxalis/view/components/input_component.tsx b/frontend/javascripts/oxalis/view/components/input_component.tsx index bf431f2b60b..6db361a48ce 100644 --- a/frontend/javascripts/oxalis/view/components/input_component.tsx +++ b/frontend/javascripts/oxalis/view/components/input_component.tsx @@ -95,8 +95,7 @@ class InputComponent extends React.PureComponent - document.activeElement ? (document.activeElement as HTMLElement).blur() : null; + blurYourself = () => (document.activeElement as HTMLElement | null)?.blur(); blurOnEscape = (event: React.KeyboardEvent) => { if (event.key === "Escape") { diff --git a/frontend/javascripts/oxalis/view/components/input_with_update_on_blur.tsx b/frontend/javascripts/oxalis/view/components/input_with_update_on_blur.tsx index 0e69a41ecc9..95bcd8ee61b 100644 --- a/frontend/javascripts/oxalis/view/components/input_with_update_on_blur.tsx +++ b/frontend/javascripts/oxalis/view/components/input_with_update_on_blur.tsx @@ -20,7 +20,7 @@ export function InputWithUpdateOnBlur({ if (event.key === "Enter") { onChange(localValue); } else if (event.key === "Escape") { - document.activeElement ? (document.activeElement as HTMLElement).blur() : null; + (document.activeElement as HTMLElement | null)?.blur(); } if (props.onKeyDown) { return props.onKeyDown(event); From a4dd272d626a51bf0fb5176eb902903186b3206d Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Mon, 16 Sep 2024 16:52:47 +0200 Subject: [PATCH 68/79] rename record to entry --- .../dashboard/folders/metadata_table.tsx | 38 +++++++++---------- .../view/right-border-tabs/metadata_table.tsx | 8 ++-- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/frontend/javascripts/dashboard/folders/metadata_table.tsx b/frontend/javascripts/dashboard/folders/metadata_table.tsx index 8eb934f478a..4ba7229116a 100644 --- a/frontend/javascripts/dashboard/folders/metadata_table.tsx +++ b/frontend/javascripts/dashboard/folders/metadata_table.tsx @@ -108,7 +108,7 @@ export function getUsedTagsWithinMetadata(metadata: APIMetadataWithError[]) { } interface MetadataValueInputProps { - record: APIMetadataWithError; + entry: APIMetadataWithError; index: number; focusedRow?: number | null; setFocusedRow?: (row: number | null) => void; @@ -123,7 +123,7 @@ interface MetadataValueInputProps { } export const MetadataValueInput: React.FC = ({ - record, + entry, index, focusedRow, setFocusedRow, @@ -141,11 +141,11 @@ export const MetadataValueInput: React.FC = ({ disabled: isSaving || readOnly, }; - switch (record.type) { + switch (entry.type) { case APIMetadataEnum.NUMBER: return ( { @@ -157,7 +157,7 @@ export const MetadataValueInput: React.FC = ({ case APIMetadataEnum.STRING: return ( updateMetadataValue(index, newValue as string, APIMetadataEnum.STRING) @@ -170,7 +170,7 @@ export const MetadataValueInput: React.FC = ({ setFocusedRow(index)} onBlur={() => setFocusedRow(null)} - value={record.key} + value={entry.key} onChange={(evt) => updateMetadataKey(index, evt.target.value)} placeholder="Property" size="small" disabled={isSaving} id={getKeyInputIdForIndex(index)} - status={record.error != null ? "warning" : undefined} + status={entry.error != null ? "warning" : undefined} // Use a span as an empty prefix, because null would lose the focus // when the prefix changes. - prefix={record.error != null ? : } + prefix={entry.error != null ? : } /> ); }; - const getValueInput = (record: APIMetadataWithError, index: number) => { + const getValueInput = (entry: APIMetadataWithError, index: number) => { return ( JSX.Element; - getValueInput: (record: APIMetadataWithError, index: number) => JSX.Element; + getKeyInput: (entry: APIMetadataWithError, index: number) => JSX.Element; + getValueInput: (entry: APIMetadataWithError, index: number) => JSX.Element; getDeleteEntryButton: (_: APIMetadataWithError, index: number) => JSX.Element; addNewEntryMenuItems: MenuProps; onlyReturnRows?: boolean; @@ -489,11 +489,11 @@ export function InnerMetadataTable({ }): React.ReactElement { const rows = ( <> - {metadata.map((record, index) => ( + {metadata.map((entry, index) => ( - {getKeyInput(record, index)} - {getValueInput(record, index)} - {getDeleteEntryButton(record, index)} + {getKeyInput(entry, index)} + {getValueInput(entry, index)} + {getDeleteEntryButton(entry, index)} ))} {readOnly ? null : ( diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/metadata_table.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/metadata_table.tsx index 2ddb0e8e110..57fe26f6c4d 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/metadata_table.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/metadata_table.tsx @@ -75,7 +75,7 @@ function _MetadataTableRows({
); - const getKeyInput = (record: APIMetadataWithError, index: number) => { + const getKeyInput = (entry: APIMetadataWithError, index: number) => { return (
({ } className="transparent-input" - value={record.key} + value={entry.key} disabled={readOnly} onChange={(value) => updateMetadataEntryByIndex(item, index, { key: value })} placeholder="Property" @@ -117,10 +117,10 @@ function _MetadataTableRows({ value: prop.stringValue || prop.numberValue || prop.stringListValue || "", })); - const getValueInput = (record: APIMetadataWithError, index: number) => { + const getValueInput = (entry: APIMetadataWithError, index: number) => { return ( Date: Mon, 16 Sep 2024 17:00:06 +0200 Subject: [PATCH 69/79] adapt parseTimestamp to defaultValue options interface --- .../oxalis/model/helpers/nml_helpers.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/frontend/javascripts/oxalis/model/helpers/nml_helpers.ts b/frontend/javascripts/oxalis/model/helpers/nml_helpers.ts index 633911100ea..ce90d9a31c0 100644 --- a/frontend/javascripts/oxalis/model/helpers/nml_helpers.ts +++ b/frontend/javascripts/oxalis/model/helpers/nml_helpers.ts @@ -600,16 +600,22 @@ function _parseStringArray( return indices.map((idx) => obj[`${prefix}-${idx}`]); } -function _parseTimestamp(obj: Record, key: string, defaultValue?: number): number { - const timestamp = _parseInt(obj, key, defaultValue != null ? { defaultValue } : undefined); +function _parseTimestamp( + obj: Record, + key: string, + options?: { + defaultValue: number | undefined; + }, +): number { + const timestamp = _parseInt(obj, key, options); - const isValid = new Date(timestamp).getTime() > 0; + const isValid = timestamp != null && new Date(timestamp).getTime() > 0; if (!isValid) { - if (defaultValue == null) { + if (options?.defaultValue == null) { throw new NmlParseError(`${messages["nml.invalid_timestamp"]} ${key}`); } else { - return defaultValue; + return options.defaultValue; } } @@ -957,7 +963,7 @@ export function parseNml(nmlString: string): Promise<{ viewport: _parseInt(attr, "inVp", { defaultValue: DEFAULT_VIEWPORT }), resolution: _parseInt(attr, "inMag", { defaultValue: DEFAULT_RESOLUTION }), radius: _parseFloat(attr, "radius", { defaultValue: Constants.DEFAULT_NODE_RADIUS }), - timestamp: _parseTimestamp(attr, "time", DEFAULT_TIMESTAMP), + timestamp: _parseTimestamp(attr, "time", { defaultValue: DEFAULT_TIMESTAMP }), }; if (currentTree == null) throw new NmlParseError(`${messages["nml.node_outside_tree"]} ${currentNode.id}`); From 54ddfe7205cd70bf011bd3c40ea08005e8b128ba Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Mon, 16 Sep 2024 17:03:45 +0200 Subject: [PATCH 70/79] also adapt _parseTreeType --- .../javascripts/oxalis/model/helpers/nml_helpers.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/frontend/javascripts/oxalis/model/helpers/nml_helpers.ts b/frontend/javascripts/oxalis/model/helpers/nml_helpers.ts index ce90d9a31c0..473318a9612 100644 --- a/frontend/javascripts/oxalis/model/helpers/nml_helpers.ts +++ b/frontend/javascripts/oxalis/model/helpers/nml_helpers.ts @@ -653,13 +653,15 @@ function _parseColor(obj: Record, defaultColor: Vector3): Vector function _parseTreeType( obj: Record, key: string, - defaultValue?: TreeType, + options?: { + defaultValue: TreeType; + }, ): TreeType { if (obj[key] == null || obj[key].length === 0) { - if (defaultValue == null) { + if (options?.defaultValue == null) { throw new NmlParseError(`${messages["nml.expected_attribute_missing"]} ${key}`); } else { - return defaultValue; + return options.defaultValue; } } @@ -923,7 +925,7 @@ export function parseNml(nmlString: string): Promise<{ edges: new EdgeCollection(), isVisible: _parseFloat(attr, "color.a") !== 0, groupId: groupId >= 0 ? groupId : DEFAULT_GROUP_ID, - type: _parseTreeType(attr, "type", TreeTypeEnum.DEFAULT), + type: _parseTreeType(attr, "type", { defaultValue: TreeTypeEnum.DEFAULT }), edgesAreVisible: _parseBool(attr, "edgesAreVisible", { defaultValue: true }), metadata: [], }; From 6e8a483271e91ae9b729ae38d7d231dd68a8e30f Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Mon, 16 Sep 2024 17:09:17 +0200 Subject: [PATCH 71/79] rename MetadataEntry to MetadataEntryProto --- .../oxalis/model/actions/skeletontracing_actions.tsx | 4 ++-- .../oxalis/model/actions/volumetracing_actions.ts | 2 +- .../javascripts/oxalis/model/helpers/nml_helpers.ts | 12 ++++++------ .../oxalis/model/reducers/skeletontracing_reducer.ts | 4 ++-- .../reducers/skeletontracing_reducer_helpers.ts | 4 ++-- .../javascripts/oxalis/model/sagas/update_actions.ts | 8 ++++---- frontend/javascripts/oxalis/store.ts | 8 ++++---- .../oxalis/view/right-border-tabs/metadata_table.tsx | 10 +++++----- .../right-border-tabs/segments_tab/segments_view.tsx | 4 ++-- .../view/right-border-tabs/tree_hierarchy_view.tsx | 4 ++-- frontend/javascripts/types/api_flow_types.ts | 6 +++--- 11 files changed, 33 insertions(+), 33 deletions(-) diff --git a/frontend/javascripts/oxalis/model/actions/skeletontracing_actions.tsx b/frontend/javascripts/oxalis/model/actions/skeletontracing_actions.tsx index f04d6a03662..ad5235ce29f 100644 --- a/frontend/javascripts/oxalis/model/actions/skeletontracing_actions.tsx +++ b/frontend/javascripts/oxalis/model/actions/skeletontracing_actions.tsx @@ -14,7 +14,7 @@ import Store from "oxalis/store"; import RemoveTreeModal from "oxalis/view/remove_tree_modal"; import type { Key } from "react"; import { batchActions } from "redux-batched-actions"; -import type { ServerSkeletonTracing, MetadataEntry } from "types/api_flow_types"; +import type { ServerSkeletonTracing, MetadataEntryProto } from "types/api_flow_types"; import type { AdditionalCoordinate } from "types/api_flow_types"; export type InitializeSkeletonTracingAction = ReturnType; @@ -427,7 +427,7 @@ export const setTreeNameAction = ( }) as const; export const setTreeMetadataAction = ( - metadata: MetadataEntry[], + metadata: MetadataEntryProto[], treeId?: number | null | undefined, ) => ({ diff --git a/frontend/javascripts/oxalis/model/actions/volumetracing_actions.ts b/frontend/javascripts/oxalis/model/actions/volumetracing_actions.ts index 94c28276fea..43784c7974c 100644 --- a/frontend/javascripts/oxalis/model/actions/volumetracing_actions.ts +++ b/frontend/javascripts/oxalis/model/actions/volumetracing_actions.ts @@ -7,7 +7,7 @@ import type { Dispatch } from "redux"; import { AllUserBoundingBoxActions } from "oxalis/model/actions/annotation_actions"; import type { QuickSelectGeometry } from "oxalis/geometries/helper_geometries"; import { batchActions } from "redux-batched-actions"; -import type { AdditionalCoordinate, MetadataEntry } from "types/api_flow_types"; +import type { AdditionalCoordinate, MetadataEntryProto } from "types/api_flow_types"; import _ from "lodash"; export type InitializeVolumeTracingAction = ReturnType; diff --git a/frontend/javascripts/oxalis/model/helpers/nml_helpers.ts b/frontend/javascripts/oxalis/model/helpers/nml_helpers.ts index 473318a9612..95400af562d 100644 --- a/frontend/javascripts/oxalis/model/helpers/nml_helpers.ts +++ b/frontend/javascripts/oxalis/model/helpers/nml_helpers.ts @@ -1,7 +1,7 @@ // @ts-expect-error ts-migrate(7016) FIXME: Could not find a declaration file for module 'saxo... Remove this comment to see the full error message import Saxophone from "saxophone"; import _ from "lodash"; -import type { APIBuildInfo, MetadataEntry } from "types/api_flow_types"; +import type { APIBuildInfo, MetadataEntryProto } from "types/api_flow_types"; import { getMaximumGroupId, getMaximumTreeId, @@ -462,7 +462,7 @@ function serializeEdges(edges: EdgeCollection): Array { ); } -function serializeMetadata(metadata: MetadataEntry[]): string[] { +function serializeMetadata(metadata: MetadataEntryProto[]): string[] { return metadata.map((prop) => { const values: any = {}; if (prop.stringValue != null) { @@ -848,12 +848,12 @@ function parseBoundingBoxObject(attr: Record): BoundingBoxObject { return boundingBoxObject; } -function parseMetadataEntry(attr: Record): MetadataEntry { +function parseMetadataEntry(attr: Record): MetadataEntryProto { const stringValue = _parseEntities(attr, "stringValue", { defaultValue: undefined }); const boolValue = _parseBool(attr, "boolValue", { defaultValue: undefined }); const numberValue = _parseFloat(attr, "numberValue", { defaultValue: undefined }); const stringListValue = _parseStringArray(attr, "stringListValue", { defaultValue: undefined }); - const prop: MetadataEntry = { + const prop: MetadataEntryProto = { key: _parseEntities(attr, "key"), stringValue, boolValue, @@ -862,7 +862,7 @@ function parseMetadataEntry(attr: Record): MetadataEntry { }; const compactProp = Object.fromEntries( Object.entries(prop).filter(([_k, v]) => v !== undefined), - ) as MetadataEntry; + ) as MetadataEntryProto; if (Object.entries(compactProp).length !== 2) { throw new NmlParseError( `Could not parse user-defined property. Expected exactly one key and one value. Got: ${Object.keys( @@ -988,7 +988,7 @@ export function parseNml(nmlString: string): Promise<{ if (currentNode == null) { currentTree.metadata.push(parseMetadataEntry(attr)); } else { - // TODO: Also support MetadataEntry in nodes. See #7483 + // TODO: Also support MetadataEntryProto in nodes. See #7483 } break; } diff --git a/frontend/javascripts/oxalis/model/reducers/skeletontracing_reducer.ts b/frontend/javascripts/oxalis/model/reducers/skeletontracing_reducer.ts index c5a4e284019..e9aa5d7bca4 100644 --- a/frontend/javascripts/oxalis/model/reducers/skeletontracing_reducer.ts +++ b/frontend/javascripts/oxalis/model/reducers/skeletontracing_reducer.ts @@ -47,7 +47,7 @@ import { GroupTypeEnum, getNodeKey, } from "oxalis/view/right-border-tabs/tree_hierarchy_view_helpers"; -import type { MetadataEntry } from "types/api_flow_types"; +import type { MetadataEntryProto } from "types/api_flow_types"; function SkeletonTracingReducer(state: OxalisState, action: Action): OxalisState { switch (action.type) { @@ -1155,7 +1155,7 @@ function SkeletonTracingReducer(state: OxalisState, action: Action): OxalisState .getOrElse(state); } -export function sanitizeMetadata(metadata: MetadataEntry[]) { +export function sanitizeMetadata(metadata: MetadataEntryProto[]) { // Workaround for stringList values that are [], even though they // should be null. This workaround is necessary because protobuf cannot // distinguish between an empty list and an not existent property. diff --git a/frontend/javascripts/oxalis/model/reducers/skeletontracing_reducer_helpers.ts b/frontend/javascripts/oxalis/model/reducers/skeletontracing_reducer_helpers.ts index 48c90b03ca8..08b30fe4d26 100644 --- a/frontend/javascripts/oxalis/model/reducers/skeletontracing_reducer_helpers.ts +++ b/frontend/javascripts/oxalis/model/reducers/skeletontracing_reducer_helpers.ts @@ -24,7 +24,7 @@ import type { ServerSkeletonTracingTree, ServerNode, ServerBranchPoint, - MetadataEntry, + MetadataEntryProto, } from "types/api_flow_types"; import { getSkeletonTracing, @@ -486,7 +486,7 @@ export function createTree( name?: string, type: TreeType = TreeTypeEnum.DEFAULT, edgesAreVisible: boolean = true, - metadata: MetadataEntry[] = [], + metadata: MetadataEntryProto[] = [], ): Maybe { return getSkeletonTracing(state.tracing).chain((skeletonTracing) => { // Create a new tree id and name diff --git a/frontend/javascripts/oxalis/model/sagas/update_actions.ts b/frontend/javascripts/oxalis/model/sagas/update_actions.ts index d91aa1c25f2..32ed16ef2da 100644 --- a/frontend/javascripts/oxalis/model/sagas/update_actions.ts +++ b/frontend/javascripts/oxalis/model/sagas/update_actions.ts @@ -10,7 +10,7 @@ import type { NumberLike, } from "oxalis/store"; import { convertUserBoundingBoxesFromFrontendToServer } from "oxalis/model/reducers/reducer_helpers"; -import type { AdditionalCoordinate, MetadataEntry } from "types/api_flow_types"; +import type { AdditionalCoordinate, MetadataEntryProto } from "types/api_flow_types"; export type NodeWithTreeId = { treeId: number; @@ -319,7 +319,7 @@ export function createSegmentVolumeAction( name: string | null | undefined, color: Vector3 | null, groupId: number | null | undefined, - metadata: MetadataEntry[], + metadata: MetadataEntryProto[], creationTime: number | null | undefined = Date.now(), ) { return { @@ -343,7 +343,7 @@ export function updateSegmentVolumeAction( name: string | null | undefined, color: Vector3 | null, groupId: number | null | undefined, - metadata: Array, + metadata: Array, creationTime: number | null | undefined = Date.now(), ) { return { @@ -504,7 +504,7 @@ export function mergeAgglomerate( } as const; } -function enforceValidMetadata(metadata: MetadataEntry[]): MetadataEntry[] { +function enforceValidMetadata(metadata: MetadataEntryProto[]): MetadataEntryProto[] { // We do not want to save metadata with duplicate keys. Validation errors // will warn the user in case this exists. However, we allow duplicate keys in the // redux store to avoid losing information while the user is editing something. diff --git a/frontend/javascripts/oxalis/store.ts b/frontend/javascripts/oxalis/store.ts index 53f722e6e66..dc4847893e1 100644 --- a/frontend/javascripts/oxalis/store.ts +++ b/frontend/javascripts/oxalis/store.ts @@ -26,7 +26,7 @@ import type { APIUserCompact, AdditionalCoordinate, AdditionalAxis, - MetadataEntry, + MetadataEntryProto, } from "types/api_flow_types"; import type { TracingStats } from "oxalis/model/accessors/annotation_accessor"; import type { Action } from "oxalis/model/actions/actions"; @@ -144,7 +144,7 @@ export type MutableTree = { nodes: MutableNodeMap; type: TreeType; edgesAreVisible: boolean; - metadata: MetadataEntry[]; + metadata: MetadataEntryProto[]; }; // When changing Tree, remember to also update MutableTree export type Tree = { @@ -160,7 +160,7 @@ export type Tree = { readonly nodes: NodeMap; readonly type: TreeType; readonly edgesAreVisible: boolean; - readonly metadata: MetadataEntry[]; + readonly metadata: MetadataEntryProto[]; }; export type TreeGroupTypeFlat = { readonly name: string; @@ -236,7 +236,7 @@ export type Segment = { readonly creationTime: number | null | undefined; readonly color: Vector3 | null; readonly groupId: number | null | undefined; - readonly metadata: MetadataEntry[]; + readonly metadata: MetadataEntryProto[]; }; export type SegmentMap = DiffableMap; diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/metadata_table.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/metadata_table.tsx index 57fe26f6c4d..b2b3f34bffd 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/metadata_table.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/metadata_table.tsx @@ -7,7 +7,7 @@ import { InnerMetadataTable, MetadataValueInput, } from "dashboard/folders/metadata_table"; -import { type APIMetadata, APIMetadataEnum, type MetadataEntry } from "types/api_flow_types"; +import { type APIMetadata, APIMetadataEnum, type MetadataEntryProto } from "types/api_flow_types"; import { InputWithUpdateOnBlur } from "../components/input_with_update_on_blur"; import _ from "lodash"; import { memo } from "react"; @@ -15,19 +15,19 @@ import FastTooltip from "components/fast_tooltip"; const getKeyInputIdForIndex = (index: number) => `metadata-key-input-id-${index}`; -function _MetadataTableRows({ +function _MetadataTableRows({ item, setMetadata, readOnly, }: { item: ItemType; - setMetadata: (item: ItemType, newProperties: MetadataEntry[]) => void; + setMetadata: (item: ItemType, newProperties: MetadataEntryProto[]) => void; readOnly: boolean; }) { const updateMetadataEntryByIndex = ( item: ItemType, index: number, - newPropPartial: Partial, + newPropPartial: Partial, ) => { if (readOnly) { return; @@ -49,7 +49,7 @@ function _MetadataTableRows({ setMetadata(item, newProps); }; - const addMetadataEntry = (item: ItemType, newProp: MetadataEntry) => { + const addMetadataEntry = (item: ItemType, newProp: MetadataEntryProto) => { const newProps = item.metadata.concat([newProp]); setMetadata(item, newProps); }; diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx index 769edee1c26..fb54200bef9 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx @@ -117,7 +117,7 @@ import type { APIMeshFile, APISegmentationLayer, APIUser, - MetadataEntry, + MetadataEntryProto, } from "types/api_flow_types"; import { APIJobType, type AdditionalCoordinate } from "types/api_flow_types"; import type { ValueOf } from "types/globals"; @@ -1977,7 +1977,7 @@ class SegmentsView extends React.Component { return null; } - setMetadata = (segment: Segment, newProperties: MetadataEntry[]) => { + setMetadata = (segment: Segment, newProperties: MetadataEntryProto[]) => { if (this.props.visibleSegmentationLayer == null) { return; } diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx index f38bc29f13b..e42f7901d1b 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/tree_hierarchy_view.tsx @@ -38,7 +38,7 @@ import { } from "./tree_hierarchy_renderers"; import { ResizableSplitPane } from "./resizable_split_pane"; import { MetadataEntryTableRows } from "./metadata_table"; -import type { MetadataEntry } from "types/api_flow_types"; +import type { MetadataEntryProto } from "types/api_flow_types"; import { InputWithUpdateOnBlur } from "../components/input_with_update_on_blur"; const onCheck: TreeProps["onCheck"] = (_checkedKeysValue, info) => { @@ -331,7 +331,7 @@ function TreeHierarchyView(props: Props) { ); } -const setMetadata = (tree: Tree, newProperties: MetadataEntry[]) => { +const setMetadata = (tree: Tree, newProperties: MetadataEntryProto[]) => { Store.dispatch(setTreeMetadataAction(newProperties, tree.treeId)); }; diff --git a/frontend/javascripts/types/api_flow_types.ts b/frontend/javascripts/types/api_flow_types.ts index 81ab3858e97..1f703256e8f 100644 --- a/frontend/javascripts/types/api_flow_types.ts +++ b/frontend/javascripts/types/api_flow_types.ts @@ -778,9 +778,9 @@ export type ServerSkeletonTracingTree = { isVisible?: boolean; type?: TreeType; edgesAreVisible?: boolean; - metadata: MetadataEntry[]; + metadata: MetadataEntryProto[]; }; -export type MetadataEntry = { +export type MetadataEntryProto = { key: string; stringValue?: string; boolValue?: boolean; @@ -798,7 +798,7 @@ type ServerSegment = { creationTime: number | null | undefined; color: ColorObject | null; groupId: number | null | undefined; - metadata: MetadataEntry[]; + metadata: MetadataEntryProto[]; }; export type ServerTracingBase = { id: string; From df0a089f46ec634e2a3eeb2c68e09c2a2d849747 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Mon, 16 Sep 2024 17:12:25 +0200 Subject: [PATCH 72/79] also rename APIMetadata to APIMetadataEntry and add comments about the distinction --- .../dashboard/folders/metadata_table.tsx | 14 ++++++------- .../view/right-border-tabs/metadata_table.tsx | 8 +++++-- frontend/javascripts/types/api_flow_types.ts | 21 ++++++++++++------- 3 files changed, 27 insertions(+), 16 deletions(-) diff --git a/frontend/javascripts/dashboard/folders/metadata_table.tsx b/frontend/javascripts/dashboard/folders/metadata_table.tsx index 4ba7229116a..872f92cb216 100644 --- a/frontend/javascripts/dashboard/folders/metadata_table.tsx +++ b/frontend/javascripts/dashboard/folders/metadata_table.tsx @@ -31,13 +31,13 @@ import { useState } from "react"; import { type APIDataset, type Folder, - type APIMetadata, + type APIMetadataEntry, APIMetadataEnum, } from "types/api_flow_types"; -export type APIMetadataWithError = APIMetadata & { error?: string | null }; +export type APIMetadataWithError = APIMetadataEntry & { error?: string | null }; -function getMetadataTypeLabel(type: APIMetadata["type"]) { +function getMetadataTypeLabel(type: APIMetadataEntry["type"]) { switch (type) { case "string": return ( @@ -61,14 +61,14 @@ function getMetadataTypeLabel(type: APIMetadata["type"]) { } export function getTypeSelectDropdownMenu( - addNewEntryWithType: (type: APIMetadata["type"]) => void, + addNewEntryWithType: (type: APIMetadataEntry["type"]) => void, ): MenuProps { return { items: Object.values(APIMetadataEnum).map((type) => { return { key: type, - label: getMetadataTypeLabel(type as APIMetadata["type"]), - onClick: () => addNewEntryWithType(type as APIMetadata["type"]), + label: getMetadataTypeLabel(type as APIMetadataEntry["type"]), + onClick: () => addNewEntryWithType(type as APIMetadataEntry["type"]), }; }), }; @@ -360,7 +360,7 @@ export default function MetadataTable({ }); }; - const addNewEntryWithType = (type: APIMetadata["type"]) => { + const addNewEntryWithType = (type: APIMetadataEntry["type"]) => { setMetadata((prev) => { const indexOfNewEntry = prev.length; const newEntry: APIMetadataWithError = { diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/metadata_table.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/metadata_table.tsx index b2b3f34bffd..2d75f653c1f 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/metadata_table.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/metadata_table.tsx @@ -7,7 +7,11 @@ import { InnerMetadataTable, MetadataValueInput, } from "dashboard/folders/metadata_table"; -import { type APIMetadata, APIMetadataEnum, type MetadataEntryProto } from "types/api_flow_types"; +import { + type APIMetadataEntry, + APIMetadataEnum, + type MetadataEntryProto, +} from "types/api_flow_types"; import { InputWithUpdateOnBlur } from "../components/input_with_update_on_blur"; import _ from "lodash"; import { memo } from "react"; @@ -140,7 +144,7 @@ function _MetadataTableRows ); }; - const addNewEntryWithType = (type: APIMetadata["type"]) => { + const addNewEntryWithType = (type: APIMetadataEntry["type"]) => { const indexOfNewEntry = item.metadata.length; // Auto focus the key input of the new entry. setTimeout(() => document.getElementById(getKeyInputIdForIndex(indexOfNewEntry))?.focus(), 50); diff --git a/frontend/javascripts/types/api_flow_types.ts b/frontend/javascripts/types/api_flow_types.ts index 1f703256e8f..3b52e676c8b 100644 --- a/frontend/javascripts/types/api_flow_types.ts +++ b/frontend/javascripts/types/api_flow_types.ts @@ -177,12 +177,15 @@ export enum APIMetadataEnum { NUMBER = "number", STRING_ARRAY = "string[]", } -export type APIMetadata = { + +// Note that this differs from MetadataEntryProto, because +// it's stored in sql and not in protobuf. +// The type is used for datasets and folders. +export type APIMetadataEntry = { type: APIMetadataEnum; key: string; value: string | number | string[]; }; -export type APIMetadataEntries = APIMetadata[]; type MutableAPIDatasetBase = MutableAPIDatasetId & { isUnreported: boolean; @@ -192,7 +195,7 @@ type MutableAPIDatasetBase = MutableAPIDatasetId & { created: number; dataStore: APIDataStore; description: string | null | undefined; - metadata: APIMetadataEntries | null | undefined; + metadata: APIMetadataEntry[] | null | undefined; isEditable: boolean; isPublic: boolean; displayName: string | null | undefined; @@ -780,6 +783,10 @@ export type ServerSkeletonTracingTree = { edgesAreVisible?: boolean; metadata: MetadataEntryProto[]; }; + +// Note that this differs from APIMetadataEntry, because +// it's internally stored as protobuf and not in sql. +// The type is used for in-annotation entities (segments, trees etc.) export type MetadataEntryProto = { key: string; stringValue?: string; @@ -1092,7 +1099,7 @@ export type FlatFolderTreeItem = { name: string; id: string; parent: string | null; - metadata: APIMetadataEntries; + metadata: APIMetadataEntry[]; isEditable: boolean; }; @@ -1103,7 +1110,7 @@ export type FolderItem = { parent: string | null | undefined; children: FolderItem[]; isEditable: boolean; - metadata: APIMetadataEntries; + metadata: APIMetadataEntry[]; // Can be set so that the antd tree component can disable // individual folder items. disabled?: boolean; @@ -1114,7 +1121,7 @@ export type Folder = { id: string; allowedTeams: APITeam[]; allowedTeamsCumulative: APITeam[]; - metadata: APIMetadataEntries; + metadata: APIMetadataEntry[]; isEditable: boolean; }; @@ -1122,7 +1129,7 @@ export type FolderUpdater = { id: string; name: string; allowedTeams: string[]; - metadata: APIMetadataEntries; + metadata: APIMetadataEntry[]; }; export enum CAMERA_POSITIONS { From 6b269e3f5dfc54c59983b9ff0a37b7a734a1ffc1 Mon Sep 17 00:00:00 2001 From: Philipp Otto Date: Tue, 17 Sep 2024 10:24:37 +0200 Subject: [PATCH 73/79] fix e2e tests --- frontend/javascripts/types/api_flow_types.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/frontend/javascripts/types/api_flow_types.ts b/frontend/javascripts/types/api_flow_types.ts index 3b52e676c8b..fba05dde62c 100644 --- a/frontend/javascripts/types/api_flow_types.ts +++ b/frontend/javascripts/types/api_flow_types.ts @@ -177,12 +177,20 @@ export enum APIMetadataEnum { NUMBER = "number", STRING_ARRAY = "string[]", } +// This type has to be defined redundantly to the above enum, unfortunately, +// because the e2e tests assert that an object literal matches the type of +// APIMetadataEntry. In that object literal, a string literal exists (e.g., "number"). +// TypeScript Enums don't typecheck against such literals by design (see +// https://github.com/microsoft/TypeScript/issues/17690#issuecomment-337975541). +// Therefore, we redundantly define the type of the enum here again and use that +// in APIMetadataEntry. +type APIMetadataType = "string" | "number" | "string[]"; // Note that this differs from MetadataEntryProto, because // it's stored in sql and not in protobuf. // The type is used for datasets and folders. export type APIMetadataEntry = { - type: APIMetadataEnum; + type: APIMetadataType; key: string; value: string | number | string[]; }; From c05ac35c41747c33c70569190ecf502def7ffbf9 Mon Sep 17 00:00:00 2001 From: Florian M Date: Mon, 23 Sep 2024 13:10:38 +0200 Subject: [PATCH 74/79] deduplicate metadata entries when applying update actions --- .../webknossos/tracingstore/tracings/MetadataEntry.scala | 3 +++ .../skeleton/updating/SkeletonUpdateActions.scala | 8 ++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/MetadataEntry.scala b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/MetadataEntry.scala index 470efee220d..f5cbc79f10a 100644 --- a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/MetadataEntry.scala +++ b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/MetadataEntry.scala @@ -31,6 +31,9 @@ object MetadataEntry { def toProtoMultiple(propertiesOpt: Option[Seq[MetadataEntry]]): Seq[MetadataEntryProto] = propertiesOpt.map(_.map(_.toProto)).getOrElse(Seq.empty) + def deduplicate(propertiesOpt: Option[Seq[MetadataEntry]]): Option[Seq[MetadataEntry]] = + propertiesOpt.map(properties => properties.distinctBy(_.key)) + implicit val jsonFormat: OFormat[MetadataEntry] = Json.using[WithDefaultValues].format[MetadataEntry] } diff --git a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/skeleton/updating/SkeletonUpdateActions.scala b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/skeleton/updating/SkeletonUpdateActions.scala index 046a9b75b4f..479c02154e3 100644 --- a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/skeleton/updating/SkeletonUpdateActions.scala +++ b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/skeleton/updating/SkeletonUpdateActions.scala @@ -38,7 +38,7 @@ case class CreateTreeSkeletonAction(id: Int, isVisible, `type`.map(TreeType.toProto), edgesAreVisible, - metadata = MetadataEntry.toProtoMultiple(metadata) + metadata = MetadataEntry.toProtoMultiple(MetadataEntry.deduplicate(metadata)) ) tracing.withTrees(newTree +: tracing.trees) } @@ -89,7 +89,7 @@ case class UpdateTreeSkeletonAction(id: Int, name = name, groupId = groupId, `type` = `type`.map(TreeType.toProto), - metadata = MetadataEntry.toProtoMultiple(metadata) + metadata = MetadataEntry.toProtoMultiple(MetadataEntry.deduplicate(metadata)) ) tracing.withTrees(mapTrees(tracing, id, treeTransform)) @@ -239,7 +239,7 @@ case class CreateNodeSkeletonAction(id: Int, interpolation getOrElse NodeDefaults.interpolation, createdTimestamp = timestamp, additionalCoordinates = AdditionalCoordinate.toProto(additionalCoordinates), - metadata = MetadataEntry.toProtoMultiple(metadata) + metadata = MetadataEntry.toProtoMultiple(MetadataEntry.deduplicate(metadata)) ) def treeTransform(tree: Tree) = tree.withNodes(newNode +: tree.nodes) @@ -286,7 +286,7 @@ case class UpdateNodeSkeletonAction(id: Int, interpolation getOrElse NodeDefaults.interpolation, createdTimestamp = timestamp, additionalCoordinates = AdditionalCoordinate.toProto(additionalCoordinates), - metadata = MetadataEntry.toProtoMultiple(metadata) + metadata = MetadataEntry.toProtoMultiple(MetadataEntry.deduplicate(metadata)) ) def treeTransform(tree: Tree) = From 2c53eef087a4df3f7830f1fffdfaa484d11a8e8a Mon Sep 17 00:00:00 2001 From: Florian M Date: Mon, 23 Sep 2024 13:16:41 +0200 Subject: [PATCH 75/79] changelog, deduplicate for segments too --- CHANGELOG.unreleased.md | 2 +- .../tracingstore/tracings/volume/VolumeUpdateActions.scala | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index 614ce789d17..b7656b82afc 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -12,8 +12,8 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released ### Added - It is now possible to focus a bounding box in the bounding box tab by clicking its edges in a viewport or via a newly added context menu entry. [#8054](https://github.com/scalableminds/webknossos/pull/8054) -### Added - Added an assertion to the backend to ensure unique keys in the metadata info of datasets and folders. [#8068](https://github.com/scalableminds/webknossos/issues/8068) +- It is now possible to add metadata in annotations to Trees and Segments. [#7875](https://github.com/scalableminds/webknossos/pull/7875) ### Changed - Clicking on a bounding box within the bounding box tab centers it within the viewports and focusses it in the list. [#8049](https://github.com/scalableminds/webknossos/pull/8049) diff --git a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/VolumeUpdateActions.scala b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/VolumeUpdateActions.scala index 069fa92c860..1bfd4e6009e 100644 --- a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/VolumeUpdateActions.scala +++ b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/VolumeUpdateActions.scala @@ -306,7 +306,7 @@ case class UpdateSegmentVolumeAction(id: Long, color = colorOptToProto(color), groupId = groupId, anchorPositionAdditionalCoordinates = AdditionalCoordinate.toProto(additionalCoordinates), - metadata = MetadataEntry.toProtoMultiple(metadata) + metadata = MetadataEntry.toProtoMultiple(MetadataEntry.deduplicate(metadata)) ) tracing.withSegments(mapSegments(tracing, id, segmentTransform)) } From dba57db2ace9a9515999971c0fd5596ec281a734 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20B=C3=BC=C3=9Femeyer?= Date: Mon, 23 Sep 2024 13:27:52 +0200 Subject: [PATCH 76/79] remove unused imports in frontend code --- .../oxalis/model/actions/volumetracing_actions.ts | 4 +--- .../view/right-border-tabs/segments_tab/segments_view.tsx | 6 +----- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/frontend/javascripts/oxalis/model/actions/volumetracing_actions.ts b/frontend/javascripts/oxalis/model/actions/volumetracing_actions.ts index 43784c7974c..781ac413557 100644 --- a/frontend/javascripts/oxalis/model/actions/volumetracing_actions.ts +++ b/frontend/javascripts/oxalis/model/actions/volumetracing_actions.ts @@ -7,8 +7,7 @@ import type { Dispatch } from "redux"; import { AllUserBoundingBoxActions } from "oxalis/model/actions/annotation_actions"; import type { QuickSelectGeometry } from "oxalis/geometries/helper_geometries"; import { batchActions } from "redux-batched-actions"; -import type { AdditionalCoordinate, MetadataEntryProto } from "types/api_flow_types"; -import _ from "lodash"; +import type { AdditionalCoordinate } from "types/api_flow_types"; export type InitializeVolumeTracingAction = ReturnType; export type InitializeEditableMappingAction = ReturnType; @@ -241,7 +240,6 @@ export const updateSegmentAction = ( if (segmentId == null) { throw new Error("Segment ID must not be null."); } - return { type: "UPDATE_SEGMENT", // TODO: Proper 64 bit support (#6921) diff --git a/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx b/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx index fb54200bef9..ad656e9fd9f 100644 --- a/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx +++ b/frontend/javascripts/oxalis/view/right-border-tabs/segments_tab/segments_view.tsx @@ -40,17 +40,13 @@ import { pluralize } from "libs/utils"; import _, { isNumber } from "lodash"; import type { Vector3 } from "oxalis/constants"; import { EMPTY_OBJECT, MappingStatusEnum } from "oxalis/constants"; -import { getSegmentIdForPosition } from "oxalis/controller/combinations/volume_handlers"; import { getMappingInfo, getMaybeSegmentIndexAvailability, getResolutionInfoOfVisibleSegmentationLayer, getVisibleSegmentationLayer, } from "oxalis/model/accessors/dataset_accessor"; -import { - getAdditionalCoordinatesAsString, - getPosition, -} from "oxalis/model/accessors/flycam_accessor"; +import { getAdditionalCoordinatesAsString } from "oxalis/model/accessors/flycam_accessor"; import { getActiveSegmentationTracing, getMeshesForCurrentAdditionalCoordinates, From 6068c0750fa6529b80c4b8045aefa590761d8a21 Mon Sep 17 00:00:00 2001 From: Florian M Date: Mon, 23 Sep 2024 13:52:30 +0200 Subject: [PATCH 77/79] integrate backend pr feedback --- app/models/annotation/nml/NmlParser.scala | 4 +- app/models/annotation/nml/NmlWriter.scala | 2 - .../SkeletonUpdateActionsUnitTestSuite.scala | 18 ++-- .../proto/MetadataEntry.proto | 2 +- .../proto/SkeletonTracing.proto | 89 +++++++++---------- .../proto/VolumeTracing.proto | 80 ++++++++--------- webknossos-datastore/proto/geometry.proto | 20 ++--- .../updating/SkeletonUpdateActions.scala | 15 ++-- .../tracings/volume/VolumeUpdateActions.scala | 2 +- 9 files changed, 112 insertions(+), 120 deletions(-) diff --git a/app/models/annotation/nml/NmlParser.scala b/app/models/annotation/nml/NmlParser.scala index 655cec12c66..b3567fd2079 100755 --- a/app/models/annotation/nml/NmlParser.scala +++ b/app/models/annotation/nml/NmlParser.scala @@ -545,7 +545,6 @@ object NmlParser extends LazyLogging with ProtoGeometryImplicits with ColorGener val bitDepth = parseBitDepth(node) val interpolation = parseInterpolation(node) val rotation = parseRotationForNode(node).getOrElse(NodeDefaults.rotation) - val metadata = parseMetadata(node \ "metadata" \ "metadataEntry") Node(id, position, rotation, @@ -555,8 +554,7 @@ object NmlParser extends LazyLogging with ProtoGeometryImplicits with ColorGener bitDepth, interpolation, timestamp, - additionalCoordinates, - metadata = metadata) + additionalCoordinates) } } diff --git a/app/models/annotation/nml/NmlWriter.scala b/app/models/annotation/nml/NmlWriter.scala index 2d8c0eabd70..4a667dd5f57 100644 --- a/app/models/annotation/nml/NmlWriter.scala +++ b/app/models/annotation/nml/NmlWriter.scala @@ -364,8 +364,6 @@ class NmlWriter @Inject()(implicit ec: ExecutionContext) extends FoxImplicits { writer.writeAttribute("interpolation", n.interpolation.toString) writer.writeAttribute("time", n.createdTimestamp.toString) n.additionalCoordinates.foreach(writeAdditionalCoordinateValue) - if (n.metadata.nonEmpty) - Xml.withinElementSync("metadata")(n.metadata.foreach(writeMetadataEntry)) } } diff --git a/test/backend/SkeletonUpdateActionsUnitTestSuite.scala b/test/backend/SkeletonUpdateActionsUnitTestSuite.scala index 918a7f2f0f7..8d7a23ac771 100644 --- a/test/backend/SkeletonUpdateActionsUnitTestSuite.scala +++ b/test/backend/SkeletonUpdateActionsUnitTestSuite.scala @@ -67,7 +67,10 @@ class SkeletonUpdateActionsUnitTestSuite extends PlaySpec { name = "updated tree", branchPoints = List(UpdateActionBranchPoint(0, Dummies.timestamp)), comments = List[UpdateActionComment](), - groupId = None + groupId = None, + metadata = Some( + List(MetadataEntry("myKey", numberValue = Some(5.0)), + MetadataEntry("anotherKey", stringListValue = Some(Seq("hello", "there"))))) ) val result = applyUpdateAction(updateTreeAction) @@ -77,6 +80,9 @@ class SkeletonUpdateActionsUnitTestSuite extends PlaySpec { assert(tree.createdTimestamp == Dummies.timestamp) assert(tree.comments == updateTreeAction.comments) assert(tree.name == updateTreeAction.name) + assert( + tree.metadata == List(MetadataEntryProto("myKey", numberValue = Some(5.0)), + MetadataEntryProto("anotherKey", stringListValue = Seq("hello", "there")))) } } @@ -168,10 +174,7 @@ class SkeletonUpdateActionsUnitTestSuite extends PlaySpec { "UpdateNodeSkeletonAction" should { "update the specified node" in { - val newNode = Dummies - .createDummyNode(1) - .copy(metadata = List(MetadataEntryProto("myKey", numberValue = Some(5.0)), - MetadataEntryProto("anotherKey", stringListValue = Seq("hello", "there")))) + val newNode = Dummies.createDummyNode(1) val updateNodeSkeletonAction = new UpdateNodeSkeletonAction( newNode.id, Vec3Int(newNode.position.x, newNode.position.y, newNode.position.z), @@ -183,10 +186,7 @@ class SkeletonUpdateActionsUnitTestSuite extends PlaySpec { Option(newNode.interpolation), treeId = 1, Dummies.timestamp, - None, - metadata = Some( - List(MetadataEntry("myKey", numberValue = Some(5.0)), - MetadataEntry("anotherKey", stringListValue = Some(Seq("hello", "there"))))) + None ) val result = applyUpdateAction(updateNodeSkeletonAction) assert(result.trees.length == Dummies.skeletonTracing.trees.length) diff --git a/webknossos-datastore/proto/MetadataEntry.proto b/webknossos-datastore/proto/MetadataEntry.proto index bc99d1ce4fd..6c780beb6f7 100644 --- a/webknossos-datastore/proto/MetadataEntry.proto +++ b/webknossos-datastore/proto/MetadataEntry.proto @@ -8,5 +8,5 @@ message MetadataEntryProto { optional string stringValue = 2; optional bool boolValue = 3; optional double numberValue = 4; - repeated string stringListValue = 6; + repeated string stringListValue = 5; } diff --git a/webknossos-datastore/proto/SkeletonTracing.proto b/webknossos-datastore/proto/SkeletonTracing.proto index fd1ac3c20d5..7f9ebc91e38 100644 --- a/webknossos-datastore/proto/SkeletonTracing.proto +++ b/webknossos-datastore/proto/SkeletonTracing.proto @@ -6,17 +6,16 @@ import "geometry.proto"; import "MetadataEntry.proto"; message Node { - required int32 id = 1; - required Vec3IntProto position = 2; - required Vec3DoubleProto rotation = 3; - required float radius = 4; - required int32 viewport = 5; - required int32 resolution = 6; - required int32 bitDepth = 7; - required bool interpolation = 8; - required int64 createdTimestamp = 9; - repeated AdditionalCoordinateProto additionalCoordinates = 10; - repeated MetadataEntryProto metadata = 11; + required int32 id = 1; + required Vec3IntProto position = 2; + required Vec3DoubleProto rotation = 3; + required float radius = 4; + required int32 viewport = 5; + required int32 resolution = 6; + required int32 bitDepth = 7; + required bool interpolation = 8; + required int64 createdTimestamp = 9; + repeated AdditionalCoordinateProto additionalCoordinates = 10; } message Edge { @@ -25,13 +24,13 @@ message Edge { } message Comment { - required int32 nodeId = 1; - required string content = 2; + required int32 nodeId = 1; + required string content = 2; } message BranchPoint { - required int32 nodeId = 1; - required int64 createdTimestamp = 2; + required int32 nodeId = 1; + required int64 createdTimestamp = 2; } enum TreeTypeProto { @@ -40,19 +39,19 @@ enum TreeTypeProto { } message Tree { - required int32 treeId = 1; - repeated Node nodes = 2; - repeated Edge edges = 3; - optional ColorProto color = 4; - repeated BranchPoint branchPoints = 5; - repeated Comment comments = 6; - required string name = 7; - required int64 createdTimestamp = 8; - optional int32 groupId = 9; - optional bool isVisible = 10; // `None` means `true` - optional TreeTypeProto type = 11; - optional bool edgesAreVisible = 12; // `None` means `true` - repeated MetadataEntryProto metadata = 13; + required int32 treeId = 1; + repeated Node nodes = 2; + repeated Edge edges = 3; + optional ColorProto color = 4; + repeated BranchPoint branchPoints = 5; + repeated Comment comments = 6; + required string name = 7; + required int64 createdTimestamp = 8; + optional int32 groupId = 9; + optional bool isVisible = 10; // `None` means `true` + optional TreeTypeProto type = 11; + optional bool edgesAreVisible = 12; // `None` means `true` + repeated MetadataEntryProto metadata = 13; } message TreeGroup { @@ -63,27 +62,27 @@ message TreeGroup { } message SkeletonTracing { - required string datasetName = 1; // used when parsing and handling nmls, not used in tracing store anymore, do not rely on correct values - repeated Tree trees = 2; - required int64 createdTimestamp = 3; - optional BoundingBoxProto boundingBox = 4; - optional int32 activeNodeId = 5; - required Vec3IntProto editPosition = 6; - required Vec3DoubleProto editRotation = 7; - required double zoomLevel = 8; - required int64 version = 9; - optional BoundingBoxProto userBoundingBox = 10; - repeated TreeGroup treeGroups = 11; - repeated NamedBoundingBoxProto userBoundingBoxes = 12; - optional string organizationId = 13; // used when parsing and handling nmls, not used in tracing store anymore, do not rely on correct values - repeated AdditionalCoordinateProto editPositionAdditionalCoordinates = 21; - repeated AdditionalAxisProto additionalAxes = 22; // Additional axes for which this tracing is defined + required string datasetName = 1; // used when parsing and handling nmls, not used in tracing store anymore, do not rely on correct values + repeated Tree trees = 2; + required int64 createdTimestamp = 3; + optional BoundingBoxProto boundingBox = 4; + optional int32 activeNodeId = 5; + required Vec3IntProto editPosition = 6; + required Vec3DoubleProto editRotation = 7; + required double zoomLevel = 8; + required int64 version = 9; + optional BoundingBoxProto userBoundingBox = 10; + repeated TreeGroup treeGroups = 11; + repeated NamedBoundingBoxProto userBoundingBoxes = 12; + optional string organizationId = 13; // used when parsing and handling nmls, not used in tracing store anymore, do not rely on correct values + repeated AdditionalCoordinateProto editPositionAdditionalCoordinates = 21; + repeated AdditionalAxisProto additionalAxes = 22; // Additional axes for which this tracing is defined } message SkeletonTracingOpt { - optional SkeletonTracing tracing = 1; + optional SkeletonTracing tracing = 1; } message SkeletonTracings { - repeated SkeletonTracingOpt tracings = 1; + repeated SkeletonTracingOpt tracings = 1; } diff --git a/webknossos-datastore/proto/VolumeTracing.proto b/webknossos-datastore/proto/VolumeTracing.proto index b8973faa5bb..ee2651eea33 100644 --- a/webknossos-datastore/proto/VolumeTracing.proto +++ b/webknossos-datastore/proto/VolumeTracing.proto @@ -6,48 +6,48 @@ import "geometry.proto"; import "MetadataEntry.proto"; message Segment { - required int64 segmentId = 1; - optional Vec3IntProto anchorPosition = 2; - optional string name = 3; - optional int64 creationTime = 4; - optional ColorProto color = 5; - optional int32 groupId = 6; - repeated AdditionalCoordinateProto anchorPositionAdditionalCoordinates = 7; - repeated MetadataEntryProto metadata = 11; + required int64 segmentId = 1; + optional Vec3IntProto anchorPosition = 2; + optional string name = 3; + optional int64 creationTime = 4; + optional ColorProto color = 5; + optional int32 groupId = 6; + repeated AdditionalCoordinateProto anchorPositionAdditionalCoordinates = 7; + repeated MetadataEntryProto metadata = 11; } message VolumeTracing { - enum ElementClassProto { - uint8 = 1; - uint16 = 2; - uint24 = 3; - uint32 = 4; - uint64 = 8; - } + enum ElementClassProto { + uint8 = 1; + uint16 = 2; + uint24 = 3; + uint32 = 4; + uint64 = 8; + } - optional int64 activeSegmentId = 1; - required BoundingBoxProto boundingBox = 2; - required int64 createdTimestamp = 3; - required string datasetName = 4; // used when parsing and handling nmls, not used in tracing store anymore, do not rely on correct values - required Vec3IntProto editPosition = 5; - required Vec3DoubleProto editRotation = 6; - required ElementClassProto elementClass = 7; - optional string fallbackLayer = 8; - optional int64 largestSegmentId = 9; - required int64 version = 10; - required double zoomLevel = 11; - optional BoundingBoxProto userBoundingBox = 12; - repeated NamedBoundingBoxProto userBoundingBoxes = 13; - optional string organizationId = 14; // used when parsing and handling nmls, not used in tracing store anymore, do not rely on correct values - repeated Vec3IntProto resolutions = 15; - repeated Segment segments = 16; - optional string mappingName = 17; // either a mapping present in the fallback layer, or an editable mapping on the tracingstore - optional bool hasEditableMapping = 18; // the selected mapping is an editable mapping - repeated SegmentGroup segmentGroups = 19; - optional bool hasSegmentIndex = 20; - repeated AdditionalCoordinateProto editPositionAdditionalCoordinates = 21; - repeated AdditionalAxisProto additionalAxes = 22; // Additional axes for which this tracing is defined - optional bool mappingIsLocked = 23; // user may not select another mapping (e.g. because they have already mutated buckets) + optional int64 activeSegmentId = 1; + required BoundingBoxProto boundingBox = 2; + required int64 createdTimestamp = 3; + required string datasetName = 4; // used when parsing and handling nmls, not used in tracing store anymore, do not rely on correct values + required Vec3IntProto editPosition = 5; + required Vec3DoubleProto editRotation = 6; + required ElementClassProto elementClass = 7; + optional string fallbackLayer = 8; + optional int64 largestSegmentId = 9; + required int64 version = 10; + required double zoomLevel = 11; + optional BoundingBoxProto userBoundingBox = 12; + repeated NamedBoundingBoxProto userBoundingBoxes = 13; + optional string organizationId = 14; // used when parsing and handling nmls, not used in tracing store anymore, do not rely on correct values + repeated Vec3IntProto resolutions = 15; + repeated Segment segments = 16; + optional string mappingName = 17; // either a mapping present in the fallback layer, or an editable mapping on the tracingstore + optional bool hasEditableMapping = 18; // the selected mapping is an editable mapping + repeated SegmentGroup segmentGroups = 19; + optional bool hasSegmentIndex = 20; + repeated AdditionalCoordinateProto editPositionAdditionalCoordinates = 21; + repeated AdditionalAxisProto additionalAxes = 22; // Additional axes for which this tracing is defined + optional bool mappingIsLocked = 23; // user may not select another mapping (e.g. because they have already mutated buckets) } message SegmentGroup { @@ -58,9 +58,9 @@ message SegmentGroup { } message VolumeTracingOpt { - optional VolumeTracing tracing = 1; + optional VolumeTracing tracing = 1; } message VolumeTracings { - repeated VolumeTracingOpt tracings = 1; + repeated VolumeTracingOpt tracings = 1; } diff --git a/webknossos-datastore/proto/geometry.proto b/webknossos-datastore/proto/geometry.proto index 4868bd6296e..501b0b62173 100644 --- a/webknossos-datastore/proto/geometry.proto +++ b/webknossos-datastore/proto/geometry.proto @@ -4,9 +4,9 @@ package com.scalableminds.webknossos.datastore; message Vec3IntProto { - required int32 x = 1; - required int32 y = 2; - required int32 z = 3; + required int32 x = 1; + required int32 y = 2; + required int32 z = 3; } message Vec2IntProto { @@ -19,9 +19,9 @@ message ListOfVec3IntProto { } message Vec3DoubleProto { - required double x = 1; - required double y = 2; - required double z = 3; + required double x = 1; + required double y = 2; + required double z = 3; } message ColorProto { @@ -32,10 +32,10 @@ message ColorProto { } message BoundingBoxProto { - required Vec3IntProto topLeft = 1; - required int32 width = 2; - required int32 height = 3; - required int32 depth = 4; + required Vec3IntProto topLeft = 1; + required int32 width = 2; + required int32 height = 3; + required int32 depth = 4; } message NamedBoundingBoxProto { diff --git a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/skeleton/updating/SkeletonUpdateActions.scala b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/skeleton/updating/SkeletonUpdateActions.scala index 479c02154e3..46dc8bad0f0 100644 --- a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/skeleton/updating/SkeletonUpdateActions.scala +++ b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/skeleton/updating/SkeletonUpdateActions.scala @@ -221,8 +221,7 @@ case class CreateNodeSkeletonAction(id: Int, actionTimestamp: Option[Long] = None, actionAuthorId: Option[String] = None, info: Option[String] = None, - additionalCoordinates: Option[Seq[AdditionalCoordinate]] = None, - metadata: Option[Seq[MetadataEntry]] = None) + additionalCoordinates: Option[Seq[AdditionalCoordinate]] = None) extends UpdateAction.SkeletonUpdateAction with SkeletonUpdateActionHelper with ProtoGeometryImplicits { @@ -238,8 +237,7 @@ case class CreateNodeSkeletonAction(id: Int, bitDepth getOrElse NodeDefaults.bitDepth, interpolation getOrElse NodeDefaults.interpolation, createdTimestamp = timestamp, - additionalCoordinates = AdditionalCoordinate.toProto(additionalCoordinates), - metadata = MetadataEntry.toProtoMultiple(MetadataEntry.deduplicate(metadata)) + additionalCoordinates = AdditionalCoordinate.toProto(additionalCoordinates) ) def treeTransform(tree: Tree) = tree.withNodes(newNode +: tree.nodes) @@ -267,8 +265,7 @@ case class UpdateNodeSkeletonAction(id: Int, actionTimestamp: Option[Long] = None, actionAuthorId: Option[String] = None, info: Option[String] = None, - additionalCoordinates: Option[Seq[AdditionalCoordinate]] = None, - metadata: Option[Seq[MetadataEntry]] = None) + additionalCoordinates: Option[Seq[AdditionalCoordinate]] = None) extends UpdateAction.SkeletonUpdateAction with SkeletonUpdateActionHelper with ProtoGeometryImplicits { @@ -285,8 +282,7 @@ case class UpdateNodeSkeletonAction(id: Int, bitDepth getOrElse NodeDefaults.bitDepth, interpolation getOrElse NodeDefaults.interpolation, createdTimestamp = timestamp, - additionalCoordinates = AdditionalCoordinate.toProto(additionalCoordinates), - metadata = MetadataEntry.toProtoMultiple(MetadataEntry.deduplicate(metadata)) + additionalCoordinates = AdditionalCoordinate.toProto(additionalCoordinates) ) def treeTransform(tree: Tree) = @@ -604,7 +600,8 @@ object SkeletonUpdateAction { } } - def deserialize[T](json: JsValue, shouldTransformPositions: Boolean = false)(implicit tjs: Reads[T]): JsResult[T] = + private def deserialize[T](json: JsValue, shouldTransformPositions: Boolean = false)( + implicit tjs: Reads[T]): JsResult[T] = if (shouldTransformPositions) json.transform(positionTransform).get.validate[T] else diff --git a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/VolumeUpdateActions.scala b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/VolumeUpdateActions.scala index 1bfd4e6009e..d35b3cf7da8 100644 --- a/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/VolumeUpdateActions.scala +++ b/webknossos-tracingstore/app/com/scalableminds/webknossos/tracingstore/tracings/volume/VolumeUpdateActions.scala @@ -265,7 +265,7 @@ case class CreateSegmentVolumeAction(id: Long, colorOptToProto(color), groupId, AdditionalCoordinate.toProto(additionalCoordinates), - metadata = MetadataEntry.toProtoMultiple(metadata) + metadata = MetadataEntry.toProtoMultiple(MetadataEntry.deduplicate(metadata)) ) tracing.addSegments(newSegment) } From 80a6aa1ab7ffd22ead2b9d627090709edc2db46a Mon Sep 17 00:00:00 2001 From: Florian M Date: Mon, 23 Sep 2024 14:27:03 +0200 Subject: [PATCH 78/79] refresh snapshots --- .../annotations.e2e.js.md | 30 ------------------ .../annotations.e2e.js.snap | Bin 13017 -> 12677 bytes 2 files changed, 30 deletions(-) diff --git a/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/annotations.e2e.js.md b/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/annotations.e2e.js.md index c20e7760ef9..a04266f4614 100644 --- a/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/annotations.e2e.js.md +++ b/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/annotations.e2e.js.md @@ -1510,7 +1510,6 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, - metadata: [], position: { x: 10120, y: 3727, @@ -1531,7 +1530,6 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, - metadata: [], position: { x: 9120, y: 3727, @@ -1552,7 +1550,6 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, - metadata: [], position: { x: 8120, y: 3727, @@ -1573,7 +1570,6 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, - metadata: [], position: { x: 7120, y: 3727, @@ -1594,7 +1590,6 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, - metadata: [], position: { x: 6120, y: 3727, @@ -1615,7 +1610,6 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, - metadata: [], position: { x: 5120, y: 3727, @@ -1677,7 +1671,6 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, - metadata: [], position: { x: 10120, y: 3726, @@ -1698,7 +1691,6 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, - metadata: [], position: { x: 9120, y: 3726, @@ -1719,7 +1711,6 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, - metadata: [], position: { x: 8120, y: 3726, @@ -1740,7 +1731,6 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, - metadata: [], position: { x: 7120, y: 3726, @@ -1761,7 +1751,6 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, - metadata: [], position: { x: 6120, y: 3726, @@ -1782,7 +1771,6 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, - metadata: [], position: { x: 5120, y: 3726, @@ -1844,7 +1832,6 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, - metadata: [], position: { x: 10120, y: 3726, @@ -1865,7 +1852,6 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, - metadata: [], position: { x: 9120, y: 3726, @@ -1886,7 +1872,6 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, - metadata: [], position: { x: 8120, y: 3726, @@ -1907,7 +1892,6 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, - metadata: [], position: { x: 7120, y: 3726, @@ -1928,7 +1912,6 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, - metadata: [], position: { x: 6120, y: 3726, @@ -1949,7 +1932,6 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, - metadata: [], position: { x: 5120, y: 3726, @@ -2011,7 +1993,6 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, - metadata: [], position: { x: 10120, y: 3725, @@ -2032,7 +2013,6 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, - metadata: [], position: { x: 9120, y: 3725, @@ -2053,7 +2033,6 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, - metadata: [], position: { x: 8120, y: 3725, @@ -2074,7 +2053,6 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, - metadata: [], position: { x: 7120, y: 3725, @@ -2095,7 +2073,6 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, - metadata: [], position: { x: 6120, y: 3725, @@ -2116,7 +2093,6 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, - metadata: [], position: { x: 5120, y: 3725, @@ -2178,7 +2154,6 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, - metadata: [], position: { x: 10120, y: 3725, @@ -2199,7 +2174,6 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, - metadata: [], position: { x: 9120, y: 3725, @@ -2220,7 +2194,6 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, - metadata: [], position: { x: 8120, y: 3725, @@ -2241,7 +2214,6 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, - metadata: [], position: { x: 7120, y: 3725, @@ -2262,7 +2234,6 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, - metadata: [], position: { x: 6120, y: 3725, @@ -2283,7 +2254,6 @@ Generated by [AVA](https://avajs.dev). createdTimestamp: 'createdTimestamp', id: 'id', interpolation: true, - metadata: [], position: { x: 5120, y: 3725, diff --git a/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/annotations.e2e.js.snap b/frontend/javascripts/test/snapshots/public-test/test-bundle/test/backend-snapshot-tests/annotations.e2e.js.snap index c34f2588f55fc31d184a6453739be5af5c0df44f..3b4745a1a18190744be0daf75af08b2cc81fa465 100644 GIT binary patch literal 12677 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 Date: Mon, 23 Sep 2024 15:40:29 +0200 Subject: [PATCH 79/79] readd border around empty metadata placeholder in dashboard --- .../dashboard/folders/metadata_table.tsx | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/frontend/javascripts/dashboard/folders/metadata_table.tsx b/frontend/javascripts/dashboard/folders/metadata_table.tsx index 872f92cb216..4f203951aa6 100644 --- a/frontend/javascripts/dashboard/folders/metadata_table.tsx +++ b/frontend/javascripts/dashboard/folders/metadata_table.tsx @@ -12,9 +12,9 @@ import { InputNumber, Input, Select, - Typography, Dropdown, Button, + Tag, } from "antd"; import FastTooltip from "components/fast_tooltip"; import { @@ -81,20 +81,22 @@ const EmptyMetadataPlaceholder: React.FC = ({ addNewEntryMenuItems, }) => { return ( -
- Metadata preview - - - - - -
+ +
+ Metadata preview + + + + + +
+
); };