-
Notifications
You must be signed in to change notification settings - Fork 112
Workflow visualizer UI improvement #1366
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
Hoists the UI state up to App.kt to allow for manual reset each time a different frame is selected
1. Force it to consume all pointerInput, meaning there cannot be interactino with any objects shadowed by it. 2. Hoist alignment into App.kt instead of forcing it to the right with a full Spacer
Currently this builds one tree which is an amalgamation of all the frames. It uses a recursive algorithm to fill in missing children nodes after each renderpass has been parsed
These can be viewed as pairs: for each frame, there is the full workflow tree that is slowly built up with the new nodes. Add a copy method onto Node class to store a current copy of the workflow tree.
2c50fde to
370d1c7
Compare
cf2c3ed to
13fa318
Compare
a5ea5dd to
b989a1a
Compare
5ebf042 to
32c5b9f
Compare
This commit introduces the idea of a NodeDiff, which stores the node's current and previous state - from the previous frame - in order to better represent what is actually changing through the UI. If there is no previous frame (only on the first frame of the trace), there is no need to show a diff, so we plainly depict the node's states.
...w-trace-viewer/src/jvmMain/kotlin/com/squareup/workflow1/traceviewer/ui/WorkflowInfoPanel.kt
Show resolved
Hide resolved
471f276 to
abe0486
Compare
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
| } | ||
|
|
||
| internal fun Node.replaceChild(child: Node): Node { | ||
| return copy(children = this.children.map { if (it.id == child.id) child else it }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
immutable maps can use the plus operator (.plus or + as infix) to do this without having to map each node. e.g.,
| return copy(children = this.children.map { if (it.id == child.id) child else it }) | |
| return copy(children = this.children.plus(child.id to child)) |
That will replace only the value with child.id as its key.
Kotlin Playground link: https://pl.kotl.in/8IX2XBth3
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
.plus would work on a map, but children is a list of Nodes. Would you think a map is a better use case here? I was thinking I could at least keep the insertion order using a List, unless I used something like a LinkedHashMap?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sounds like LinkedHashMap would be better for this case.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sounds good, doing this would also mean that addChild and replaceChild are the exact same in implementation. Is it good still keep those as different functions, though, to make the recursive algorithm easier to understand?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you can leave them separate as long as one uses the other (so you avoid having to update multiple places with the duplicate code).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
WEre you going to do this? Or on a later PR?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in another comment here
| package com.squareup.workflow1.traceviewer.model | ||
|
|
||
| /** | ||
| * Represents the difference between the current and previous state of a node in the workflow trace. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It does not really represent the difference in any way. It just holds both nodes.
It might better be called NodeUpdate or something like that?
You might start adding some diffing methods on this class to help with that (if it's useful).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's true, NodeUpdate is good.
| modifier = Modifier.padding(top = 8.dp, bottom = 8.dp) | ||
| ) | ||
| } | ||
| val fields = listOf("Name", "Id", "Props", "State", "Rendering") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If you stick with this method (which I think is fine btw!) it would be good to just have this list as a constant on Node so that it keeps it close the code its representing.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed with reflection in this PR #1367
| if (mainTreeChild != null) { | ||
| mergedTree.replaceChild(mergeFrameIntoMainTree(frameChild, mainTreeChild)) | ||
| } else { | ||
| mergedTree.addChild(frameChild) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Another option rather than keeping a separate list of affectedNodes would be to mark up these nodes as they are parsed within the actual node class itself. I.e. you could have an enum of change types and a field on Node for status., e.g., status is SAME, NEW, PROPS_CHANGED, STATE_CHANGED, CHILD_STATE_CHANGED or something like that? That way we can continue to add more colouring information to the UI for different aspects.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah this would be great to have. I'll note this as a TODO in the future.
| dependencies { | ||
| implementation(kotlin("test")) | ||
| implementation(kotlin("test-junit5")) | ||
| implementation(libs.junit.jupiter) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What is jupiter for? I could not see something related to this on your unit test that you wrote?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It wasn't being used for anything, thanks for catching that. I had goose write the tests and it had just included it. Is it cleaning if it's removed?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would be great to remove this if it's not needed.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
| LaunchedEffect(frameIndex) { | ||
| sandboxState.reset() | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should be
LaunchedEffect(sandboxState) {
snapshotFlow { frameIndex }.collect {
sandboxState.reset()
}
}There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
| } | ||
|
|
||
| internal fun Node.replaceChild(child: Node): Node { | ||
| return copy(children = this.children.map { if (it.id == child.id) child else it }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sounds like LinkedHashMap would be better for this case.
| var frames by remember { mutableStateOf<List<Node>>(emptyList()) } | ||
| var fullTree by remember { mutableStateOf<List<Node>>(emptyList()) } | ||
| var affectedNodes by remember { mutableStateOf<List<Set<Node>>>(emptyList()) } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: Can any of these be SnapshotStateLists?
Or if you want them to be immutable, then consider using ImmutableList and friends.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I didn't know about this, this is great to know! The next step in the project would require these being mutable, so thanks for pointing this out
| if (frame.id != main.id) { | ||
| throw IllegalArgumentException("Frame root ID does not match main tree root ID.") | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: Could be require
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
| val factor = 1f + (-scrollDelta * 0.1f) | ||
| sandboxState.scale = (sandboxState.scale * factor).coerceIn(0.1f, 10f) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you add a comment explaining what this math is for and what the constants mean?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
| if (!open) { | ||
| return@Card | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
UX nit: Would AnimatedVisibility look more polished?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Definitely, there's a lot of ui/ux changes I have written down to look more polished, and it's something I'm looking to complete later in the project.
| val state: String, | ||
| val rendering: String = "", | ||
| val children: List<Node>, | ||
| ) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This class looks like it's intended to basically be a database table, where the properties are referenced dynamically by the UI. Can these properties just be stored in a map, with keys defined as constants? No need to use reflection.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These properties are all here for the purpose of moshi being able to parse a file into these objects. Unless there is a way to use moshi in the way you're describing it.
Even then, I'm not sure what the difference is. Could you help me explain the use case of having a data class like this vs using a map and treating it like a database table?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The map woudl be just key : value where both are strings. Since everything but children here is a string, the idea would be that essentially we are using this data class to store a bunch of String properties. Since they are all strings, we could just make it a nodeProperties = mapOf<String, String>() for everything but the children. once that is a map then you are able to easily iterate both the 'properties' keys (with nodeProperties.keys.) and the values (with nodeProperties.values) without needing to use reflection etc.
As for Moshi I imagine they have a default adapter that just maps JSON properties to a map.
Where it gets slightly more complicated is with the children property here, since that is not a String and is why we have this as a full class.
You could do something like:
data class Node ( val properties: ImmutableMap<String, String>, val children: LinkedHashMap<String, Node>) {}
and then you'd have to change the parsing. ultimately probably that would be the 'best' fit for the data (and you'd be able to avoid using reflection as the properties list change since you'd always iterate over all the keys).
As for whether you do that now, I'm not too worried either way 🤷🏻
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
understood, this makes sense. However, I still don't understand the distinction that @zach-klippenstein made originally about classes and "database table classes". Are data classes not meant to be used in a way that just holds data?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ya they are meant for that. I think the distinction comes down to the type of data. if you have properties of different strongly types vs if you just have a set of String properties.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ah gotcha, I understand now, thanks for clarifying that.
| Box( | ||
| modifier = modifier | ||
| ) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not in PR scope, but you probably want propagateMinConstraints here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could you explain this more? As far as I understand, this would be telling that all children containers need to be a specific size within the Box?
| } | ||
| } | ||
|
|
||
| tasks.named<Test>("jvmTest") { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I also wonder if this is not needed?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
+1 - do we know why this is needed? if not, lets remove.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it seems like it's needed for registering and running junit tests in a multiplatform app?
https://kotlinlang.org/docs/jvm-test-using-junit.html#add-dependencies
But I havent removed the logging parts afterwards, those aren't necessary.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great. sounds good.
| } | ||
| } | ||
|
|
||
| tasks.named<Test>("jvmTest") { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
+1 - do we know why this is needed? if not, lets remove.
| val state: String, | ||
| val rendering: String = "", | ||
| val children: List<Node>, | ||
| ) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The map woudl be just key : value where both are strings. Since everything but children here is a string, the idea would be that essentially we are using this data class to store a bunch of String properties. Since they are all strings, we could just make it a nodeProperties = mapOf<String, String>() for everything but the children. once that is a map then you are able to easily iterate both the 'properties' keys (with nodeProperties.keys.) and the values (with nodeProperties.values) without needing to use reflection etc.
As for Moshi I imagine they have a default adapter that just maps JSON properties to a map.
Where it gets slightly more complicated is with the children property here, since that is not a String and is why we have this as a full class.
You could do something like:
data class Node ( val properties: ImmutableMap<String, String>, val children: LinkedHashMap<String, Node>) {}
and then you'd have to change the parsing. ultimately probably that would be the 'best' fit for the data (and you'd be able to avoid using reflection as the properties list change since you'd always iterate over all the keys).
As for whether you do that now, I'm not too worried either way 🤷🏻
| } | ||
|
|
||
| internal fun Node.replaceChild(child: Node): Node { | ||
| return copy(children = this.children.map { if (it.id == child.id) child else it }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
you can leave them separate as long as one uses the other (so you avoid having to update multiple places with the duplicate code).
| } | ||
|
|
||
| internal fun Node.replaceChild(child: Node): Node { | ||
| return copy(children = this.children.map { if (it.id == child.id) child else it }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
WEre you going to do this? Or on a later PR?
| } | ||
|
|
||
| internal fun Node.replaceChild(child: Node): Node { | ||
| return copy(children = LinkedHashMap(this.children.plus(child.id to child))) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Make replaceChild call addChild so you only have 1 place to update/maintain
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
UI changes
The specific nodes that were affected in the render pass now highlighted in context to the whole tree, rather than just showing the specific frame. This makes it easier to see the exact process and layers of workflow that were passed through
UI for displaying node data is improved. It also shows the transition between the node's previous state and current state to allow for better understanding on what actually changed.
The display window is also recentered each time a new frame is selected.
Code changes
More fields were added to
Nodeto support visualization capabilities, and is closely related with howActionLoggerrecords data from workflow runtime.NodeDiffwas created to store a node's previous and current state.All UI functions are now internal, since there's no need to support any consumers using this specific API (as it is meant to be run as a downloadable application).