[Google Blockly] Add shared functions and behaviors to start blocks#54820
Conversation
| startBlocks = config.level.lastAttempt || startBlocks; | ||
| } | ||
|
|
||
| // Only used in Sprite Lab. |
There was a problem hiding this comment.
moved up and out of the if (isXml) condition
| fieldDescription = block.getFieldValue('DESCRIPTION'); | ||
| } | ||
| return description; | ||
| return fieldDescription || block.description; |
There was a problem hiding this comment.
This change is unrelated to the PR but was committed. It ensures that description fields are properly populated for shared behaviors. These blocks have a description property but do not automatically have a value set in the new description field on the block.
|
For this case:
If a user is in a different language and they add a behavior named after a shared behavior in their language (ex. for Spanish they have a behavior named "nerviosisimo") will we add a duplicate behavior because the behavior id for that block will be "jittering"? Is that a problem? |
Indeed. They'll end up with a "nerviosisimo2" behavior that acts like the shared behavior and they'll keep their original custom one too. This matches how things work on CDO Blockly, but it that doesn't mean it's the perfect logic. I do wonder how we feel about this as an edge case of edge cases or whether there's an obvious quick fix to prevent this difference. (By edge case I mean that this would only impact projects that 1. were created in non-English, 2. were created without shared behaviors, 3. happen to have a custom behavior named exactly after the translation of one of the shared behaviors, 4. suddenly were able to reload this project in a level that includes shared behaviors, either due to a curriculum level getting updated -rare- or template-backed progression that switches this setting -also rare.) |
|
@mikeharv that seems fine, it's a super niche edge case so as long as things don't break or they have 2 behaviors with conflicting names that seems reasonable to me. |
molly-moen
left a comment
There was a problem hiding this comment.
Looks good, a couple comment suggestions and some clarifying questions for my understanding
| return block?.style?.colourPrimary; | ||
| } | ||
|
|
||
| export function appendSharedFunctions(startBlocksSource, functionsXml) { |
There was a problem hiding this comment.
mind adding a comment explaining what this function does?
| return combinedSerialization; | ||
| } | ||
|
|
||
| export function convertFunctionsXmlToJson(functionsXml) { |
There was a problem hiding this comment.
can you add comments on this function and appendProceduresToState?
| blockElement.querySelector('mutation') || | ||
| blockElement.ownerDocument.createElement('mutation'); | ||
| // Place mutator before fields, values, and other nested blocks. | ||
| blockElement.insertBefore(mutationElement, blockElement.firstChild); |
There was a problem hiding this comment.
if the block element already has the mutationElement, will this add it again or do nothing?
There was a problem hiding this comment.
I wondered the same thing! It turns out that this is safe because insertBefore will first remove the element from the element if needed, before re-inserting it.
https://developer.mozilla.org/en-US/docs/Web/API/Node/insertBefore
| nameField.setValidator(function (newValue) { | ||
| // The default validator provided by mainline Blockly. Strips whitespace. | ||
| const rename = Blockly.Procedures.rename.bind(this); | ||
| const legalName = rename(newValue); |
There was a problem hiding this comment.
if it's not a legal name I assume the block won't actually be renamed, right?
There was a problem hiding this comment.
So it seems like Procedures.rename basically ensures that we always end up with a legal name. It works by trimming leading/trailing white space, then ensuring it doesn't conflict with another procedure name, and finally adding a number on the end if does. If the new name is falsy (empty string) it gets replaced with a translation of the string "unnamed", provided that Blockly has one.
| } | ||
| } | ||
| this.behaviorId = xmlElement.nextElementSibling.getAttribute('id'); | ||
| this.behaviorId = xmlElement.getAttribute('behaviorId'); |
There was a problem hiding this comment.
why don't we need to do anything with userCreated here?
There was a problem hiding this comment.
userCreated just exists to tell Blockly that it should update the behaviorId when the behavior gets renamed.
There was a problem hiding this comment.
Do we not need to preserve it when parsing xml?
There was a problem hiding this comment.
Oh, you're right, I think we do. Apologies for not understanding your original question. This would be needed to get the property onto the block when loading XML. I did most of my testing with JSON sources, so I had a gap here. Will update!
Context
If a Sprite Level is configured to include "shared functions and behaviors", we check for the presence of these specific behavior blocks and add them to the student's project if they are not found. Most commonly, a project will always be loaded into a level that includes shared behaviors, but we need to avoid duplicating them. However, a project could be started in a level without shared behaviors, but then loaded into a level that includes them. We also need to ensure that all appended procedures work as expected when the user is using a translation. While the list of shared behaviors does not change regularly, it's also possible for new ones to be added at any time.
There are two pieces of relevant block state at work:
Changes to block state and serialization
In CDO Blockly, we create an
idproperty right on the behavior block's"NAME"field. This is serialized in XML as anidattribute on the<title/>tag. (Title is equivalent to field in older versions of Blockly.) Mainline Blockly doesn't have the ability to deserialize random attributes like this; instead it expects us to use a mutator. When loading a project from XML/CDO Blockly, we will manually search for this field attribute and move it to the mutation element. The value will then be stored directly on the block (block.behaviorId) and serialized to JSON as part ofextraState.When a new behavior is created, CDO Blockly creates a
block.userCreatedproperty on the block. This gets serialized to ausercreated="true"attribute on the block element. (Note that this block property and XML attribute are missing on blocks not created by the user.) Again, we want to move this to a mutation. Now, Google Blockly should always create ablock.userCreatedproperty during deserialization and new block creation. This will be serialized to JSON as part ofextraState.Appending Shared Behaviors
Both versions of Blockly already support adding shared behavior blocks to a project, provided that the source is XML. However, because Google Blockly saves Sprite Lab projects using JSON, we need to be able to perform the same operation with either format. [Example 1: If a user's project contains no shared behaviors, but then the level is updated to include them, they should be added on next load. Example 2: If we eventually start saving level block configurations with JSON on levelbuilder, we will need to be able handle shared behaviors.
To accomplish this, we follow these general steps:
behaviorIdis found, skip adding that behavior.userCreatedis not needed to determine a match directly, it is needed to ensure the ids are accurate.procedureIdproperty, provided by core Blockly)Hiding the delete button
To match the logic of CDO Blockly, we want to hide the modal editor's delete button for shared behaviors. This can be accomplished simply now by checking the
block.userCreatedproperty if the definition block is a behavior.Links
https://codedotorg.atlassian.net/browse/CT-153
https://codedotorg.atlassian.net/browse/CT-143
Testing story
While the above summary captures the logic, a variety of scenarios are worth checking to be sure these changes work as expected. For each video capture below, a project that has been saved as JSON is reloaded with shared behaviors enabled. In some cases, this setting was off when creating the project and in other cases it was always on. Console logging is used to better illustrate what is happening, but
console.log()commands were removed before committing.Blank project with no shared behaviors | All shared behaviors added
The simplest case. If shared behaviors are not present, we expect all of them to be added.
no.shared.behaviors.mp4
In Spanish:
shared.behaviors.i18n.mp4
Normal project with shared behaviors | No behaviors added
If all shared behaviors are already present, we expect nothing to be added on next project load.
shared.behaviors.mp4
In Spanish:
shared.behaviors.i18n.mp4
Project with renamed shared behaviors | No behaviors added
If a user renames "fluttering" to "jumping" (or makes other code changes), the
behaviorIdis not changed. We don't add another "fluttering" behavior to replace the original.renamed.shared.behavior.mp4
In Spanish, using a different renamed behavior:
renamed.shared.behaviors.i18n.mp4
No shared behaviors. User-created behavior named after a shared behavior, e.g. "wandering" | Most behaviors added, but no duplicates
If a user happens to create a behavior with a shared behavior's name (e.g. 'wandering'), its
behaviorIdwill match and we will not add a second 'wandering' behavior.2023-11-15.18-21-50.2023-11-16.12_15_11.mp4
In Spanish:
custom.wandering.i18n.mp4
Edge case: Project with shared behaviors. New shared behavior is created between loading. | Only new procedure added, no duplicates
Rather than adding a new behavior to my local environment, I simulated this by removing the 4 "looping" behaviors from the project serialization by manually editing the main.json file stored in S3.
removed.looping.behaviors.mp4
Deployment strategy
Follow-up work
Prevent users from clicking the "Delete" button unless a project is user-created.
Privacy
Security
Caching
PR Checklist: