phetsims / circuit-construction-kit-common

"Circuit Construction Kit: Basics" is an educational simulation in HTML5, by PhET Interactive Simulations.
GNU General Public License v3.0
10 stars 10 forks source link

Enable creation of circuits with PhET-iO via invoke, copying state from either model.circuit or model.circuitElements #929

Closed matthew-blackman closed 1 year ago

matthew-blackman commented 1 year ago

There isn’t any clear way for clients to populate specific circuits into the play area. They can get/set state, but that is the state for the ENTIRE sim, not just the circuit elements. We would probably want to do something similar for the tracks in ESP. Model.circuit or model.circuit.circuitElements could have a “Copy Set Value Code” button so instructional designers could extract the appropriate command to be used later in the wrapper.

We are not proposing a change that is visible in PhET brand or creating a circuit language that is human-readable.

Example use case: A client should be able to add custom buttons to load specific circuits, eg 'SERIES CIRCUIT' and 'PARALLEL CIRCUIT'. In creating the invoke statements that these buttons would trigger, the client should be able to copy/paste from studio using model.circuit or model.circuit.circuitElemnents.

@samreid and I discussed that we will not be able to leverage the preexisting get/set interface that works for axon properties, and may need to either 1) build a similar UI to appear within studio or 2) rely on PhET-iO clients running a command in the dev tools.

This is related to https://github.com/phetsims/circuit-construction-kit-common/issues/151, and the conversation was reopened during review of https://github.com/phetsims/circuit-construction-kit-common/issues/917

samreid commented 1 year ago

This implementation is working OK:

```diff Subject: [PATCH] Update API files, see https://github.com/phetsims/scenery-phet/issues/793 --- Index: main/studio/js/PhetioElementView.ts IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== diff --git a/main/studio/js/PhetioElementView.ts b/main/studio/js/PhetioElementView.ts --- a/main/studio/js/PhetioElementView.ts (revision 4651e5748f6e86526c2e3b25d7723a4cea2a3e22) +++ b/main/studio/js/PhetioElementView.ts (date 1673814289164) @@ -462,7 +462,7 @@ */ const creators: Record = { - ObjectIO( phetioElement ) { + ObjectIO( phetioElement: PhetioElement, phetioElementView: PhetioElementView ) { const div = document.createElement( 'div' ); div.id = window.phetio.PhetioIDUtils.getDOMElementID( phetioElement.phetioID ); @@ -803,110 +803,7 @@ studio.typesHandler.phetioTypes[ phetioElement.typeName ].parameterTypes, [ 'BooleanIO', 'StringIO', 'NullableIO', 'NullableIO' ] ).length === 0 ) { - - const MAX_ROWS = 30; - const MAX_COLS = 100; - const MIN_COLS = 20; // default for most browsers - - const setterContainer = document.createElement( 'div' ); - - const getValueButton = document.createElement( 'button' ); - getValueButton.innerHTML = 'Get Value ↓'; - getValueButton.style.marginRight = '10px'; - getValueButton.addEventListener( 'click', () => getValueFunction() ); - setterContainer.appendChild( getValueButton ); - - const setValueButton = document.createElement( 'button' ); - setValueButton.innerHTML = 'Set Value ↑'; - setValueButton.style.marginRight = '10px'; - setValueButton.addEventListener( 'click', () => { - const jsonValue = getTextAreaAsJSON(); - if ( jsonValue !== PHET_IO_ELEMENT_NON_JSON_MARKER ) { - phetioElementView.invoke( phetioElement.phetioID, 'setValue', [ jsonValue ] ); - } - } ); - setterContainer.appendChild( setValueButton ); - - const copyCodeButton = document.createElement( 'button' ); - copyCodeButton.innerHTML = 'Copy Set Value Code'; - copyCodeButton.title = 'Copy to clipboard a line of code that invokes this customization on phetioClient.'; - copyCodeButton.addEventListener( 'click', async () => { - - const copyText = async () => { - - const jsonValue = getTextAreaAsJSON(); - if ( jsonValue !== PHET_IO_ELEMENT_NON_JSON_MARKER ) { - - const setValueCommand = `phetioClient.invoke( '${phetioElement.phetioID}', 'setValue', [ ${JSON.stringify( jsonValue, null, 2 )} ] );`; - await navigator.clipboard.writeText( setValueCommand ); - } - }; - - // If there isn't anything in the text box, use the current value by getting it first. - if ( textarea.value.trim().length === 0 ) { - getValueFunction( copyText ); - } - else { - await copyText(); - } - } ); - setterContainer.appendChild( copyCodeButton ); - - setterContainer.appendChild( document.createElement( 'br' ) ); - - const textarea = document.createElement( 'textarea' ); - textarea.rows = 1; - - setterContainer.appendChild( textarea ); - - // This text is used to ensure that we don't continue with setting the value if JSON is invalid. - const PHET_IO_ELEMENT_NON_JSON_MARKER = 'INVALID MARKER STRING'; - - const getTextAreaAsJSON = () => { - try { - let value = textarea.value.trim(); - - // ".1" is not valid JSON, so hack it out to 0.1! - if ( value.startsWith( '.' ) && !isNaN( parseFloat( value ) ) ) { - value = `0${value}`; - } - return JSON.parse( value ); - } - catch( e ) { - alert( `Invalid JSON value: ${textarea.value}` ); - return PHET_IO_ELEMENT_NON_JSON_MARKER; - } - }; - - // Callback may be undefined - const getValueFunction = ( callback?: () => void ) => { - phetioElementView.invoke( phetioElement.phetioID, 'getValue', [], value => { - const formatted = JSON.stringify( value, null, 2 ); - const rowsCols = getRowsCols( formatted ); - - // Before setting rows/cols again, make sure that any user-input resizing is removed - textarea.style.removeProperty( 'width' ); - textarea.style.removeProperty( 'height' ); - - textarea.value = formatted; - textarea.rows = Math.min( MAX_ROWS, rowsCols.rows ); - textarea.cols = Math.min( MAX_COLS, Math.max( MIN_COLS, rowsCols.cols + 1 ) ); - callback && callback(); - } ); - }; - - // Resize rows based on the input provided, this will cancel out whatever the current height is (if user changed it) - textarea.addEventListener( 'input', () => { - const rowsCols = getRowsCols( textarea.value ); - if ( rowsCols.rows <= MAX_ROWS ) { - - // Before setting rows/cols again, make sure that any user-input resizing is removed - textarea.style.removeProperty( 'height' ); - textarea.rows = rowsCols.rows; - } - } ); - - div.appendChild( setterContainer ); + showGetSetButtons( phetioElement, phetioElementView, div ); } return div; @@ -1003,7 +900,7 @@ if ( creators[ typeName ] ) { return creators[ typeName ]; } - else if ( allTypes[ typeName ].parameterTypes ) { + else if ( allTypes[ typeName ].parameterTypes && allTypes[ typeName ].parameterTypes!.length > 0 ) { // parametric types look like PropertyIO, map them to "PropertyIO" to see if there is a creator for // the general, non-parameterized type. @@ -1014,7 +911,125 @@ } } + // TODO: What if the IOType has getValue/setValue/getValidationError, but its parent class is supposed to add more functions?, see https://github.com/phetsims/circuit-construction-kit-common/issues/929 + if ( + allTypes[ typeName ].methods.getValue && + allTypes[ typeName ].methods.setValue && + allTypes[ typeName ].methods.getValidationError ) { + return ( phetioElement, phetioElementView ) => { + const div = creators.ObjectIO( phetioElement, phetioElementView ); + showGetSetButtons( phetioElement, phetioElementView, div ); + return div; + }; + } + return getCreator( allTypes[ typeName ].supertype!, allTypes ); }; +function showGetSetButtons( phetioElement: PhetioElement, phetioElementView: PhetioElementView, div: HTMLElement ): void { + const MAX_ROWS = 30; + const MAX_COLS = 100; + const MIN_COLS = 20; // default for most browsers + + const setterContainer = document.createElement( 'div' ); + + const getValueButton = document.createElement( 'button' ); + getValueButton.innerHTML = 'Get Value ↓'; + getValueButton.style.marginRight = '10px'; + getValueButton.addEventListener( 'click', () => getValueFunction() ); + setterContainer.appendChild( getValueButton ); + + const setValueButton = document.createElement( 'button' ); + setValueButton.innerHTML = 'Set Value ↑'; + setValueButton.style.marginRight = '10px'; + setValueButton.addEventListener( 'click', () => { + const jsonValue = getTextAreaAsJSON(); + if ( jsonValue !== PHET_IO_ELEMENT_NON_JSON_MARKER ) { + phetioElementView.invoke( phetioElement.phetioID, 'setValue', [ jsonValue ] ); + } + } ); + setterContainer.appendChild( setValueButton ); + + const copyCodeButton = document.createElement( 'button' ); + copyCodeButton.innerHTML = 'Copy Set Value Code'; + copyCodeButton.title = 'Copy to clipboard a line of code that invokes this customization on phetioClient.'; + copyCodeButton.addEventListener( 'click', async () => { + + const copyText = async () => { + + const jsonValue = getTextAreaAsJSON(); + if ( jsonValue !== PHET_IO_ELEMENT_NON_JSON_MARKER ) { + + const setValueCommand = `phetioClient.invoke( '${phetioElement.phetioID}', 'setValue', [ ${JSON.stringify( jsonValue, null, 2 )} ] );`; + await navigator.clipboard.writeText( setValueCommand ); + } + }; + + // If there isn't anything in the text box, use the current value by getting it first. + if ( textarea.value.trim().length === 0 ) { + getValueFunction( copyText ); + } + else { + await copyText(); + } + } ); + setterContainer.appendChild( copyCodeButton ); + + setterContainer.appendChild( document.createElement( 'br' ) ); + + const textarea = document.createElement( 'textarea' ); + textarea.rows = 1; + + setterContainer.appendChild( textarea ); + + // This text is used to ensure that we don't continue with setting the value if JSON is invalid. + const PHET_IO_ELEMENT_NON_JSON_MARKER = 'INVALID MARKER STRING'; + + const getTextAreaAsJSON = () => { + try { + let value = textarea.value.trim(); + + // ".1" is not valid JSON, so hack it out to 0.1! + if ( value.startsWith( '.' ) && !isNaN( parseFloat( value ) ) ) { + value = `0${value}`; + } + return JSON.parse( value ); + } + catch( e ) { + alert( `Invalid JSON value: ${textarea.value}` ); + return PHET_IO_ELEMENT_NON_JSON_MARKER; + } + }; + + // Callback may be undefined + const getValueFunction = ( callback?: () => void ) => { + phetioElementView.invoke( phetioElement.phetioID, 'getValue', [], value => { + const formatted = JSON.stringify( value, null, 2 ); + const rowsCols = getRowsCols( formatted ); + + // Before setting rows/cols again, make sure that any user-input resizing is removed + textarea.style.removeProperty( 'width' ); + textarea.style.removeProperty( 'height' ); + + textarea.value = formatted; + textarea.rows = Math.min( MAX_ROWS, rowsCols.rows ); + textarea.cols = Math.min( MAX_COLS, Math.max( MIN_COLS, rowsCols.cols + 1 ) ); + callback && callback(); + } ); + }; + + // Resize rows based on the input provided, this will cancel out whatever the current height is (if user changed it) + textarea.addEventListener( 'input', () => { + const rowsCols = getRowsCols( textarea.value ); + if ( rowsCols.rows <= MAX_ROWS ) { + + // Before setting rows/cols again, make sure that any user-input resizing is removed + textarea.style.removeProperty( 'height' ); + textarea.rows = rowsCols.rows; + } + } ); + + div.appendChild( setterContainer ); +} + export default PhetioElementView; Index: main/circuit-construction-kit-common/js/model/Circuit.ts IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== diff --git a/main/circuit-construction-kit-common/js/model/Circuit.ts b/main/circuit-construction-kit-common/js/model/Circuit.ts --- a/main/circuit-construction-kit-common/js/model/Circuit.ts (revision cb18ce3b099a3f9217d372ec79af24ecc73522c5) +++ b/main/circuit-construction-kit-common/js/model/Circuit.ts (date 1673674841671) @@ -50,6 +50,10 @@ import TEmitter from '../../../axon/js/TEmitter.js'; import OrIO from '../../../tandem/js/types/OrIO.js'; import IntentionalAny from '../../../phet-core/js/types/IntentionalAny.js'; +import IOType from '../../../tandem/js/types/IOType.js'; +import StringIO from '../../../tandem/js/types/StringIO.js'; +import VoidIO from '../../../tandem/js/types/VoidIO.js'; +import PhetioObject from '../../../tandem/js/PhetioObject.js'; // constants const SNAP_RADIUS = 30; // For two vertices to join together, they must be this close, in view coordinates @@ -79,7 +83,7 @@ type Pair = { v1: Vertex; v2: Vertex }; -export default class Circuit { +export default class Circuit extends PhetioObject { private readonly viewTypeProperty: Property; public readonly addRealBulbsProperty: Property; private readonly blackBoxStudy: boolean; @@ -152,6 +156,12 @@ public constructor( viewTypeProperty: Property, addRealBulbsProperty: Property, tandem: Tandem, providedOptions: CircuitOptions ) { + super( { + tandem: tandem, + phetioType: CircuitStateIO, + phetioState: false + } ); + this.viewTypeProperty = viewTypeProperty; this.addRealBulbsProperty = addRealBulbsProperty; @@ -1546,7 +1556,7 @@ } // only works in unbuilt mode - private toString(): string { + public override toString(): string { return this.circuitElements.map( c => c.constructor.name ).join( ', ' ); } @@ -1564,4 +1574,44 @@ } } +const CircuitStateIO = new IOType( 'CircuitStateIO', { + valueType: Circuit, + methods: { + getValue: { + returnType: StringIO, + parameterTypes: [], + implementation: function( this: Circuit ) { + const state = phet.phetio.phetioEngine.phetioStateEngine.getState(); + const prunedState = _.pickBy( state, ( value, key ) => key.startsWith( this.phetioID ) ); + return JSON.stringify( prunedState, null, 2 ); + }, + documentation: 'Gets the current value.' + }, + getValidationError: { + returnType: NullableIO( StringIO ), + parameterTypes: [ StringIO ], + implementation: function( value ) { + try { + JSON.parse( value ); + } + catch( e: IntentionalAny ) { + return e.message; + } + return null; + }, + documentation: 'Checks to see if a proposed value is valid. Returns the first validation error, or null if the value is valid.' + }, + + setValue: { + returnType: VoidIO, + parameterTypes: [ StringIO ], + documentation: 'hello', + implementation: function( state: string ) { + const test = JSON.parse( state ); + phet.phetio.phetioEngine.phetioStateEngine.setState( test, Tandem.ROOT ); + } + } + } +} ); + circuitConstructionKitCommon.register( 'Circuit', Circuit ); \ No newline at end of file ```

The proposed approach utilizes the existing interface for PhET-iO statefulness and makes use of the PhET-iO Group. This allows for ease of maintenance through migration engines. However, there is some complexity in that it currently does not allow for creating a circuit in the first screen and then loading it in the second screen. Additionally, it saves the entire state of the circuit, including the selected circuit element, which may or may not be desired.

Also, it relies on the setting of partial state, which is used in other places such as gravity and orbits for restoring scenes, but is not very widely used.

samreid commented 1 year ago

I discussed this with @zepumph @matthew-blackman and @arouinfar and we agreed the UI seems reasonable. The constraint to only be able to load screen1 circuits into screen1 is reasonable. The phet-io implementation is reasonable. Here is our list of desired improvements and quesions:

  1. Why is the view buggy in the lab screen after setting the circuit in intro screen?
  2. Use the right scope tandem for setState, which will bypass (1) by sweeping (1) under the rug.
  3. Update getValidationError to make sure it describes the appropriate circuit (correct screen, etc)
  4. Mess with PhetioElementView. @zepumph will write an issue about moving things to IOType.
samreid commented 1 year ago

Current patch:

```diff Subject: [PATCH] Update API files, see https://github.com/phetsims/scenery-phet/issues/793 --- Index: main/studio/js/PhetioElementView.ts IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== diff --git a/main/studio/js/PhetioElementView.ts b/main/studio/js/PhetioElementView.ts --- a/main/studio/js/PhetioElementView.ts (revision 4651e5748f6e86526c2e3b25d7723a4cea2a3e22) +++ b/main/studio/js/PhetioElementView.ts (date 1674080332511) @@ -462,9 +462,10 @@ */ const creators: Record = { - ObjectIO( phetioElement ) { + ObjectIO( phetioElement: PhetioElement, phetioElementView: PhetioElementView ) { const div = document.createElement( 'div' ); + div.id = window.phetio.PhetioIDUtils.getDOMElementID( phetioElement.phetioID ); div.appendChild( createSpan( window.phetio.PhetioIDUtils.getComponentName( phetioElement.phetioID ), { className: 'friendlyName' } ) ); @@ -803,110 +804,7 @@ studio.typesHandler.phetioTypes[ phetioElement.typeName ].parameterTypes, [ 'BooleanIO', 'StringIO', 'NullableIO', 'NullableIO' ] ).length === 0 ) { - - const MAX_ROWS = 30; - const MAX_COLS = 100; - const MIN_COLS = 20; // default for most browsers - - const setterContainer = document.createElement( 'div' ); - - const getValueButton = document.createElement( 'button' ); - getValueButton.innerHTML = 'Get Value ↓'; - getValueButton.style.marginRight = '10px'; - getValueButton.addEventListener( 'click', () => getValueFunction() ); - setterContainer.appendChild( getValueButton ); - - const setValueButton = document.createElement( 'button' ); - setValueButton.innerHTML = 'Set Value ↑'; - setValueButton.style.marginRight = '10px'; - setValueButton.addEventListener( 'click', () => { - const jsonValue = getTextAreaAsJSON(); - if ( jsonValue !== PHET_IO_ELEMENT_NON_JSON_MARKER ) { - phetioElementView.invoke( phetioElement.phetioID, 'setValue', [ jsonValue ] ); - } - } ); - setterContainer.appendChild( setValueButton ); - - const copyCodeButton = document.createElement( 'button' ); - copyCodeButton.innerHTML = 'Copy Set Value Code'; - copyCodeButton.title = 'Copy to clipboard a line of code that invokes this customization on phetioClient.'; - copyCodeButton.addEventListener( 'click', async () => { - - const copyText = async () => { - - const jsonValue = getTextAreaAsJSON(); - if ( jsonValue !== PHET_IO_ELEMENT_NON_JSON_MARKER ) { - - const setValueCommand = `phetioClient.invoke( '${phetioElement.phetioID}', 'setValue', [ ${JSON.stringify( jsonValue, null, 2 )} ] );`; - await navigator.clipboard.writeText( setValueCommand ); - } - }; - - // If there isn't anything in the text box, use the current value by getting it first. - if ( textarea.value.trim().length === 0 ) { - getValueFunction( copyText ); - } - else { - await copyText(); - } - } ); - setterContainer.appendChild( copyCodeButton ); - - setterContainer.appendChild( document.createElement( 'br' ) ); - - const textarea = document.createElement( 'textarea' ); - textarea.rows = 1; - - setterContainer.appendChild( textarea ); - - // This text is used to ensure that we don't continue with setting the value if JSON is invalid. - const PHET_IO_ELEMENT_NON_JSON_MARKER = 'INVALID MARKER STRING'; - - const getTextAreaAsJSON = () => { - try { - let value = textarea.value.trim(); - - // ".1" is not valid JSON, so hack it out to 0.1! - if ( value.startsWith( '.' ) && !isNaN( parseFloat( value ) ) ) { - value = `0${value}`; - } - return JSON.parse( value ); - } - catch( e ) { - alert( `Invalid JSON value: ${textarea.value}` ); - return PHET_IO_ELEMENT_NON_JSON_MARKER; - } - }; - - // Callback may be undefined - const getValueFunction = ( callback?: () => void ) => { - phetioElementView.invoke( phetioElement.phetioID, 'getValue', [], value => { - const formatted = JSON.stringify( value, null, 2 ); - const rowsCols = getRowsCols( formatted ); - - // Before setting rows/cols again, make sure that any user-input resizing is removed - textarea.style.removeProperty( 'width' ); - textarea.style.removeProperty( 'height' ); - - textarea.value = formatted; - textarea.rows = Math.min( MAX_ROWS, rowsCols.rows ); - textarea.cols = Math.min( MAX_COLS, Math.max( MIN_COLS, rowsCols.cols + 1 ) ); - callback && callback(); - } ); - }; - - // Resize rows based on the input provided, this will cancel out whatever the current height is (if user changed it) - textarea.addEventListener( 'input', () => { - const rowsCols = getRowsCols( textarea.value ); - if ( rowsCols.rows <= MAX_ROWS ) { - - // Before setting rows/cols again, make sure that any user-input resizing is removed - textarea.style.removeProperty( 'height' ); - textarea.rows = rowsCols.rows; - } - } ); - - div.appendChild( setterContainer ); + showGetSetButtons( phetioElement, phetioElementView, div ); } return div; @@ -1003,7 +901,7 @@ if ( creators[ typeName ] ) { return creators[ typeName ]; } - else if ( allTypes[ typeName ].parameterTypes ) { + else if ( allTypes[ typeName ].parameterTypes && allTypes[ typeName ].parameterTypes!.length > 0 ) { // parametric types look like PropertyIO, map them to "PropertyIO" to see if there is a creator for // the general, non-parameterized type. @@ -1014,7 +912,125 @@ } } + // TODO: What if the IOType has getValue/setValue/getValidationError, but its parent class is supposed to add more functions?, see https://github.com/phetsims/circuit-construction-kit-common/issues/929 + if ( + allTypes[ typeName ].methods.getValue && + allTypes[ typeName ].methods.setValue && + allTypes[ typeName ].methods.getValidationError ) { + return ( phetioElement, phetioElementView ) => { + const div = creators.ObjectIO( phetioElement, phetioElementView ); + showGetSetButtons( phetioElement, phetioElementView, div ); + return div; + }; + } + return getCreator( allTypes[ typeName ].supertype!, allTypes ); }; +function showGetSetButtons( phetioElement: PhetioElement, phetioElementView: PhetioElementView, div: HTMLElement ): void { + const MAX_ROWS = 30; + const MAX_COLS = 100; + const MIN_COLS = 20; // default for most browsers + + const setterContainer = document.createElement( 'div' ); + + const getValueButton = document.createElement( 'button' ); + getValueButton.innerHTML = 'Get Value ↓'; + getValueButton.style.marginRight = '10px'; + getValueButton.addEventListener( 'click', () => getValueFunction() ); + setterContainer.appendChild( getValueButton ); + + const setValueButton = document.createElement( 'button' ); + setValueButton.innerHTML = 'Set Value ↑'; + setValueButton.style.marginRight = '10px'; + setValueButton.addEventListener( 'click', () => { + const jsonValue = getTextAreaAsJSON(); + if ( jsonValue !== PHET_IO_ELEMENT_NON_JSON_MARKER ) { + phetioElementView.invoke( phetioElement.phetioID, 'setValue', [ jsonValue ] ); + } + } ); + setterContainer.appendChild( setValueButton ); + + const copyCodeButton = document.createElement( 'button' ); + copyCodeButton.innerHTML = 'Copy Set Value Code'; + copyCodeButton.title = 'Copy to clipboard a line of code that invokes this customization on phetioClient.'; + copyCodeButton.addEventListener( 'click', async () => { + + const copyText = async () => { + + const jsonValue = getTextAreaAsJSON(); + if ( jsonValue !== PHET_IO_ELEMENT_NON_JSON_MARKER ) { + + const setValueCommand = `phetioClient.invoke( '${phetioElement.phetioID}', 'setValue', [ ${JSON.stringify( jsonValue, null, 2 )} ] );`; + await navigator.clipboard.writeText( setValueCommand ); + } + }; + + // If there isn't anything in the text box, use the current value by getting it first. + if ( textarea.value.trim().length === 0 ) { + getValueFunction( copyText ); + } + else { + await copyText(); + } + } ); + setterContainer.appendChild( copyCodeButton ); + + setterContainer.appendChild( document.createElement( 'br' ) ); + + const textarea = document.createElement( 'textarea' ); + textarea.rows = 1; + + setterContainer.appendChild( textarea ); + + // This text is used to ensure that we don't continue with setting the value if JSON is invalid. + const PHET_IO_ELEMENT_NON_JSON_MARKER = 'INVALID MARKER STRING'; + + const getTextAreaAsJSON = () => { + try { + let value = textarea.value.trim(); + + // ".1" is not valid JSON, so hack it out to 0.1! + if ( value.startsWith( '.' ) && !isNaN( parseFloat( value ) ) ) { + value = `0${value}`; + } + return JSON.parse( value ); + } + catch( e ) { + alert( `Invalid JSON value: ${textarea.value}` ); + return PHET_IO_ELEMENT_NON_JSON_MARKER; + } + }; + + // Callback may be undefined + const getValueFunction = ( callback?: () => void ) => { + phetioElementView.invoke( phetioElement.phetioID, 'getValue', [], value => { + const formatted = JSON.stringify( value, null, 2 ); + const rowsCols = getRowsCols( formatted ); + + // Before setting rows/cols again, make sure that any user-input resizing is removed + textarea.style.removeProperty( 'width' ); + textarea.style.removeProperty( 'height' ); + + textarea.value = formatted; + textarea.rows = Math.min( MAX_ROWS, rowsCols.rows ); + textarea.cols = Math.min( MAX_COLS, Math.max( MIN_COLS, rowsCols.cols + 1 ) ); + callback && callback(); + } ); + }; + + // Resize rows based on the input provided, this will cancel out whatever the current height is (if user changed it) + textarea.addEventListener( 'input', () => { + const rowsCols = getRowsCols( textarea.value ); + if ( rowsCols.rows <= MAX_ROWS ) { + + // Before setting rows/cols again, make sure that any user-input resizing is removed + textarea.style.removeProperty( 'height' ); + textarea.rows = rowsCols.rows; + } + } ); + + div.appendChild( setterContainer ); +} + export default PhetioElementView; Index: main/circuit-construction-kit-common/js/model/Circuit.ts IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== diff --git a/main/circuit-construction-kit-common/js/model/Circuit.ts b/main/circuit-construction-kit-common/js/model/Circuit.ts --- a/main/circuit-construction-kit-common/js/model/Circuit.ts (revision 05818b7d39ad432d34623d6151eba6663f72101f) +++ b/main/circuit-construction-kit-common/js/model/Circuit.ts (date 1673990578998) @@ -50,6 +50,10 @@ import TEmitter from '../../../axon/js/TEmitter.js'; import OrIO from '../../../tandem/js/types/OrIO.js'; import IntentionalAny from '../../../phet-core/js/types/IntentionalAny.js'; +import IOType from '../../../tandem/js/types/IOType.js'; +import StringIO from '../../../tandem/js/types/StringIO.js'; +import VoidIO from '../../../tandem/js/types/VoidIO.js'; +import PhetioObject from '../../../tandem/js/PhetioObject.js'; // constants const SNAP_RADIUS = 30; // For two vertices to join together, they must be this close, in view coordinates @@ -79,7 +83,7 @@ type Pair = { v1: Vertex; v2: Vertex }; -export default class Circuit { +export default class Circuit extends PhetioObject { private readonly viewTypeProperty: Property; public readonly addRealBulbsProperty: Property; private readonly blackBoxStudy: boolean; @@ -152,6 +156,12 @@ public constructor( viewTypeProperty: Property, addRealBulbsProperty: Property, tandem: Tandem, providedOptions: CircuitOptions ) { + super( { + tandem: tandem, + phetioType: CircuitStateIO, + phetioState: false + } ); + this.viewTypeProperty = viewTypeProperty; this.addRealBulbsProperty = addRealBulbsProperty; @@ -1546,7 +1556,7 @@ } // only works in unbuilt mode - private toString(): string { + public override toString(): string { return this.circuitElements.map( c => c.constructor.name ).join( ', ' ); } @@ -1564,4 +1574,50 @@ } } +const CircuitStateIO = new IOType( 'CircuitStateIO', { + valueType: Circuit, + methods: { + getValue: { + returnType: StringIO, + parameterTypes: [], + implementation: function( this: Circuit ) { + + // TODO: Move to state engine, like getState(tandem), like a filter tandem + // Describe that for clients, state is monolithic, but for scenes/groups/etc it can be filtered (for PhET Team) + const state = phet.phetio.phetioEngine.phetioStateEngine.getState(); + const prunedState = _.pickBy( state, ( value, key ) => key.startsWith( this.phetioID ) ); + return JSON.stringify( prunedState, null, 2 ); + }, + documentation: 'Gets the current value.' + }, + getValidationError: { + returnType: NullableIO( StringIO ), + parameterTypes: [ StringIO ], + implementation: function( value ) { + try { + JSON.parse( value ); + + // TODO check if the specified circuit corresponds to this.tandemID. To avoid pasting + // a circuit from screen1 into screen2 + } + catch( e: IntentionalAny ) { + return e.message; + } + return null; + }, + documentation: 'Checks to see if a proposed value is valid. Returns the first validation error, or null if the value is valid.' + }, + + setValue: { + returnType: VoidIO, + parameterTypes: [ StringIO ], + documentation: 'hello', + implementation: function( state: string ) { + const test = JSON.parse( state ); + phet.phetio.phetioEngine.phetioStateEngine.setState( test, Tandem.ROOT ); + } + } + } +} ); + circuitConstructionKitCommon.register( 'Circuit', Circuit ); \ No newline at end of file ```
samreid commented 1 year ago

This patch solves all the problems listed above and works well in testing. I haven't pushed yet because I don't know how to verify that this won't disrupt other sims get/set buttons. It's difficult to know where to look for those and to determine if one was inadvertently dropped.

```diff Subject: [PATCH] Rename circuitElementTools => items, regenerate API, see https://github.com/phetsims/sun/issues/814 --- Index: main/phet-io/js/PhetioStateEngine.ts IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== diff --git a/main/phet-io/js/PhetioStateEngine.ts b/main/phet-io/js/PhetioStateEngine.ts --- a/main/phet-io/js/PhetioStateEngine.ts (revision 4777d3114ed0f15df5451fa83fa9e886b6b14fa5) +++ b/main/phet-io/js/PhetioStateEngine.ts (date 1675491860720) @@ -174,15 +174,17 @@ * Get the state for an entire sim by iterating through all API elements and getting their values * * (used by wrapper type) + * @param root - the root object to get the state for, or null for the entire sim. + * - for clients, state is monolithic, but for scenes/groups/etc it can be filtered (for PhET Team) */ - public getState(): PhetioState { + public getState( root: PhetioObject | null = null ): PhetioState { // Key = route, value = save state value const state: PhetioState = {}; // Iterate over the elements of the API in order. The order of appearance in the API file determines the order of // save & load. This is important for sims that have order dependency between saved elements. - this.phetioEngine.getPhetioIDs().forEach( ( phetioID: string ) => { + this.phetioEngine.getPhetioIDsForRoot( root ).forEach( ( phetioID: string ) => { const phetioObject = this.phetioEngine.phetioObjectMap[ phetioID ]; if ( phetioObject.phetioState && !phetioObject.phetioIsArchetype ) { Index: main/phet-io/js/phetioEngine.ts IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== diff --git a/main/phet-io/js/phetioEngine.ts b/main/phet-io/js/phetioEngine.ts --- a/main/phet-io/js/phetioEngine.ts (revision 4777d3114ed0f15df5451fa83fa9e886b6b14fa5) +++ b/main/phet-io/js/phetioEngine.ts (date 1675491783947) @@ -765,6 +765,21 @@ return Object.keys( this.phetioObjectMap ); } + /** + * (phet-io-internal) + */ + public getPhetioIDsForRoot( root: PhetioObject | null ): string[] { + + if ( root === null ) { + return this.getPhetioIDs(); + } + else { + return Object.values( this.phetioObjectMap ) + .filter( phetioObject => phetioObject.tandem.equals( root.tandem ) || phetioObject.tandem.hasAncestor( root.tandem ) ) + .map( phetioObject => phetioObject.tandem.phetioID ); + } + } + /** * (phet-io-internal) */ Index: main/studio/js/PhetioElementView.ts IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== diff --git a/main/studio/js/PhetioElementView.ts b/main/studio/js/PhetioElementView.ts --- a/main/studio/js/PhetioElementView.ts (revision 00064f4f1d13d03a5285e4ff691119f0eed80bad) +++ b/main/studio/js/PhetioElementView.ts (date 1675491051575) @@ -476,9 +476,10 @@ */ const creators: Record = { - ObjectIO( phetioElement ) { + ObjectIO( phetioElement: PhetioElement, phetioElementView: PhetioElementView ) { const div = document.createElement( 'div' ); + div.id = window.phetio.PhetioIDUtils.getDOMElementID( phetioElement.phetioID ); div.appendChild( createSpan( window.phetio.PhetioIDUtils.getComponentName( phetioElement.phetioID ), { className: 'friendlyName' } ) ); @@ -830,110 +831,7 @@ studio.typesHandler.phetioTypes[ phetioElement.typeName ].parameterTypes, [ 'BooleanIO', 'StringIO', 'NullableIO', 'NullableIO' ] ).length === 0 ) { - - const MAX_ROWS = 30; - const MAX_COLS = 100; - const MIN_COLS = 20; // default for most browsers - - const setterContainer = document.createElement( 'div' ); - - const getValueButton = document.createElement( 'button' ); - getValueButton.innerHTML = 'Get Value ↓'; - getValueButton.style.marginRight = '10px'; - getValueButton.addEventListener( 'click', () => getValueFunction() ); - setterContainer.appendChild( getValueButton ); - - const setValueButton = document.createElement( 'button' ); - setValueButton.innerHTML = 'Set Value ↑'; - setValueButton.style.marginRight = '10px'; - setValueButton.addEventListener( 'click', () => { - const jsonValue = getTextAreaAsJSON(); - if ( jsonValue !== PHET_IO_ELEMENT_NON_JSON_MARKER ) { - phetioElementView.invoke( phetioElement.phetioID, 'setValue', [ jsonValue ] ); - } - } ); - setterContainer.appendChild( setValueButton ); - - const copyCodeButton = document.createElement( 'button' ); - copyCodeButton.innerHTML = 'Copy Set Value Code'; - copyCodeButton.title = 'Copy to clipboard a line of code that invokes this customization on phetioClient.'; - copyCodeButton.addEventListener( 'click', async () => { - - const copyText = async () => { - - const jsonValue = getTextAreaAsJSON(); - if ( jsonValue !== PHET_IO_ELEMENT_NON_JSON_MARKER ) { - - const setValueCommand = `phetioClient.invoke( '${phetioElement.phetioID}', 'setValue', [ ${JSON.stringify( jsonValue, null, 2 )} ] );`; - await navigator.clipboard.writeText( setValueCommand ); - } - }; - - // If there isn't anything in the text box, use the current value by getting it first. - if ( textarea.value.trim().length === 0 ) { - getValueFunction( copyText ); - } - else { - await copyText(); - } - } ); - setterContainer.appendChild( copyCodeButton ); - - setterContainer.appendChild( document.createElement( 'br' ) ); - - const textarea = document.createElement( 'textarea' ); - textarea.rows = 1; - - setterContainer.appendChild( textarea ); - - // This text is used to ensure that we don't continue with setting the value if JSON is invalid. - const PHET_IO_ELEMENT_NON_JSON_MARKER = 'INVALID MARKER STRING'; - - const getTextAreaAsJSON = () => { - try { - let value = textarea.value.trim(); - - // ".1" is not valid JSON, so hack it out to 0.1! - if ( value.startsWith( '.' ) && !isNaN( parseFloat( value ) ) ) { - value = `0${value}`; - } - return JSON.parse( value ); - } - catch( e ) { - alert( `Invalid JSON value: ${textarea.value}` ); - return PHET_IO_ELEMENT_NON_JSON_MARKER; - } - }; - - // Callback may be undefined - const getValueFunction = ( callback?: () => void ) => { - phetioElementView.invoke( phetioElement.phetioID, 'getValue', [], value => { - const formatted = JSON.stringify( value, null, 2 ); - const rowsCols = getRowsCols( formatted ); - - // Before setting rows/cols again, make sure that any user-input resizing is removed - textarea.style.removeProperty( 'width' ); - textarea.style.removeProperty( 'height' ); - - textarea.value = formatted; - textarea.rows = Math.min( MAX_ROWS, rowsCols.rows ); - textarea.cols = Math.min( MAX_COLS, Math.max( MIN_COLS, rowsCols.cols + 1 ) ); - callback && callback(); - } ); - }; - - // Resize rows based on the input provided, this will cancel out whatever the current height is (if user changed it) - textarea.addEventListener( 'input', () => { - const rowsCols = getRowsCols( textarea.value ); - if ( rowsCols.rows <= MAX_ROWS ) { - - // Before setting rows/cols again, make sure that any user-input resizing is removed - textarea.style.removeProperty( 'height' ); - textarea.rows = rowsCols.rows; - } - } ); - - div.appendChild( setterContainer ); + showGetSetButtons( phetioElement, phetioElementView, div ); } return div; @@ -1030,7 +928,7 @@ if ( creators[ typeName ] ) { return creators[ typeName ]; } - else if ( allTypes[ typeName ].parameterTypes ) { + else if ( allTypes[ typeName ].parameterTypes && allTypes[ typeName ].parameterTypes!.length > 0 ) { // parametric types look like PropertyIO, map them to "PropertyIO" to see if there is a creator for // the general, non-parameterized type. @@ -1041,7 +939,125 @@ } } + // TODO: What if the IOType has getValue/setValue/getValidationError, but its parent class is supposed to add more functions?, see https://github.com/phetsims/circuit-construction-kit-common/issues/929 + if ( + allTypes[ typeName ].methods.getValue && + allTypes[ typeName ].methods.setValue && + allTypes[ typeName ].methods.getValidationError ) { + return ( phetioElement, phetioElementView ) => { + const div = creators.ObjectIO( phetioElement, phetioElementView ); + showGetSetButtons( phetioElement, phetioElementView, div ); + return div; + }; + } + return getCreator( allTypes[ typeName ].supertype!, allTypes ); }; +function showGetSetButtons( phetioElement: PhetioElement, phetioElementView: PhetioElementView, div: HTMLElement ): void { + const MAX_ROWS = 30; + const MAX_COLS = 100; + const MIN_COLS = 20; // default for most browsers + + const setterContainer = document.createElement( 'div' ); + + const getValueButton = document.createElement( 'button' ); + getValueButton.innerHTML = 'Get Value ↓'; + getValueButton.style.marginRight = '10px'; + getValueButton.addEventListener( 'click', () => getValueFunction() ); + setterContainer.appendChild( getValueButton ); + + const setValueButton = document.createElement( 'button' ); + setValueButton.innerHTML = 'Set Value ↑'; + setValueButton.style.marginRight = '10px'; + setValueButton.addEventListener( 'click', () => { + const jsonValue = getTextAreaAsJSON(); + if ( jsonValue !== PHET_IO_ELEMENT_NON_JSON_MARKER ) { + phetioElementView.invoke( phetioElement.phetioID, 'setValue', [ jsonValue ] ); + } + } ); + setterContainer.appendChild( setValueButton ); + + const copyCodeButton = document.createElement( 'button' ); + copyCodeButton.innerHTML = 'Copy Set Value Code'; + copyCodeButton.title = 'Copy to clipboard a line of code that invokes this customization on phetioClient.'; + copyCodeButton.addEventListener( 'click', async () => { + + const copyText = async () => { + + const jsonValue = getTextAreaAsJSON(); + if ( jsonValue !== PHET_IO_ELEMENT_NON_JSON_MARKER ) { + + const setValueCommand = `phetioClient.invoke( '${phetioElement.phetioID}', 'setValue', [ ${JSON.stringify( jsonValue, null, 2 )} ] );`; + await navigator.clipboard.writeText( setValueCommand ); + } + }; + + // If there isn't anything in the text box, use the current value by getting it first. + if ( textarea.value.trim().length === 0 ) { + getValueFunction( copyText ); + } + else { + await copyText(); + } + } ); + setterContainer.appendChild( copyCodeButton ); + + setterContainer.appendChild( document.createElement( 'br' ) ); + + const textarea = document.createElement( 'textarea' ); + textarea.rows = 1; + + setterContainer.appendChild( textarea ); + + // This text is used to ensure that we don't continue with setting the value if JSON is invalid. + const PHET_IO_ELEMENT_NON_JSON_MARKER = 'INVALID MARKER STRING'; + + const getTextAreaAsJSON = () => { + try { + let value = textarea.value.trim(); + + // ".1" is not valid JSON, so hack it out to 0.1! + if ( value.startsWith( '.' ) && !isNaN( parseFloat( value ) ) ) { + value = `0${value}`; + } + return JSON.parse( value ); + } + catch( e ) { + alert( `Invalid JSON value: ${textarea.value}` ); + return PHET_IO_ELEMENT_NON_JSON_MARKER; + } + }; + + // Callback may be undefined + const getValueFunction = ( callback?: () => void ) => { + phetioElementView.invoke( phetioElement.phetioID, 'getValue', [], value => { + const formatted = JSON.stringify( value, null, 2 ); + const rowsCols = getRowsCols( formatted ); + + // Before setting rows/cols again, make sure that any user-input resizing is removed + textarea.style.removeProperty( 'width' ); + textarea.style.removeProperty( 'height' ); + + textarea.value = formatted; + textarea.rows = Math.min( MAX_ROWS, rowsCols.rows ); + textarea.cols = Math.min( MAX_COLS, Math.max( MIN_COLS, rowsCols.cols + 1 ) ); + callback && callback(); + } ); + }; + + // Resize rows based on the input provided, this will cancel out whatever the current height is (if user changed it) + textarea.addEventListener( 'input', () => { + const rowsCols = getRowsCols( textarea.value ); + if ( rowsCols.rows <= MAX_ROWS ) { + + // Before setting rows/cols again, make sure that any user-input resizing is removed + textarea.style.removeProperty( 'height' ); + textarea.rows = rowsCols.rows; + } + } ); + + div.appendChild( setterContainer ); +} + export default PhetioElementView; Index: main/circuit-construction-kit-common/js/model/Circuit.ts IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== diff --git a/main/circuit-construction-kit-common/js/model/Circuit.ts b/main/circuit-construction-kit-common/js/model/Circuit.ts --- a/main/circuit-construction-kit-common/js/model/Circuit.ts (revision 91b7fc4b52b2fc494e94fe78674f1da4a4499224) +++ b/main/circuit-construction-kit-common/js/model/Circuit.ts (date 1675492557767) @@ -49,6 +49,10 @@ import TEmitter from '../../../axon/js/TEmitter.js'; import OrIO from '../../../tandem/js/types/OrIO.js'; import IntentionalAny from '../../../phet-core/js/types/IntentionalAny.js'; +import IOType from '../../../tandem/js/types/IOType.js'; +import StringIO from '../../../tandem/js/types/StringIO.js'; +import VoidIO from '../../../tandem/js/types/VoidIO.js'; +import PhetioObject from '../../../tandem/js/PhetioObject.js'; // constants const SNAP_RADIUS = 30; // For two vertices to join together, they must be this close, in view coordinates @@ -78,7 +82,7 @@ type Pair = { v1: Vertex; v2: Vertex }; -export default class Circuit { +export default class Circuit extends PhetioObject { private readonly viewTypeProperty: Property; public readonly addRealBulbsProperty: Property; private readonly blackBoxStudy: boolean; @@ -153,6 +157,14 @@ public constructor( viewTypeProperty: Property, addRealBulbsProperty: Property, tandem: Tandem, providedOptions: CircuitOptions ) { + super( { + tandem: tandem, + phetioType: CircuitStateIO, + + // Used for get/set for the circuit on one screen but the entire state is already instrumented via the PhetioGroups + phetioState: false + } ); + this.viewTypeProperty = viewTypeProperty; this.addRealBulbsProperty = addRealBulbsProperty; @@ -1565,7 +1577,7 @@ } // only works in unbuilt mode - private toString(): string { + public override toString(): string { return this.circuitElements.map( c => c.constructor.name ).join( ', ' ); } @@ -1583,4 +1595,53 @@ } } +const CircuitStateIO = new IOType( 'CircuitStateIO', { + valueType: Circuit, + methods: { + getValue: { + returnType: StringIO, + parameterTypes: [], + implementation: function( this: Circuit ) { + const state = phet.phetio.phetioEngine.phetioStateEngine.getState( this ); + return JSON.stringify( state, null, 2 ); + }, + documentation: 'Gets the current value.' + }, + getValidationError: { + returnType: NullableIO( StringIO ), + parameterTypes: [ StringIO ], + implementation: function( this: Circuit, value ) { + try { + const result = JSON.parse( value ); + + // check if the specified circuit corresponds to this.tandemID. To avoid pasting a circuit from screen1 into screen2 + const keys = Array.from( Object.keys( result ) ); + + for ( let i = 0; i < keys.length; i++ ) { + const key = keys[ i ]; + if ( !key.startsWith( this.phetioID ) ) { + return 'key had incorrect prefix. Expected: ' + this.phetioID + ' but got: ' + key; + } + } + } + catch( e: IntentionalAny ) { + return e.message; + } + return null; + }, + documentation: 'Checks to see if a proposed value is valid. Returns the first validation error, or null if the value is valid.' + }, + + setValue: { + returnType: VoidIO, + parameterTypes: [ StringIO ], + documentation: 'hello', + implementation: function( this: Circuit, state: string ) { + const test = JSON.parse( state ); + phet.phetio.phetioEngine.phetioStateEngine.setState( test, this.tandem ); + } + } + } +} ); + circuitConstructionKitCommon.register( 'Circuit', Circuit ); \ No newline at end of file ```
samreid commented 1 year ago

I found a way to write the studio logic so that it was nondisruptive to existing code, and also a reasonable long-term solution for Circuit Construction Kit. There was one new line of logic in PhetioElementView which I believe @zepumph and I wrote, which I will save for a separate commit in case it's problematic.

samreid commented 1 year ago

After reading it through, it seems pretty safe. @matthew-blackman and @arouinfar can you please test this behavior? The main problem for me is the error message is very verbose (unreadable json state values) if you try to paste from one screen to another.

arouinfar commented 1 year ago

@samreid this is working, but I've noticed an issue with the Copy Set Value Code button. With the exception of the first time the Copy Set Value Code is used, you need to press Get Value before Copy Set Value Code to successfully copy the correct circuit state.

Steps to reproduce:

  1. Open Intro screen & drag a circuit component from the carousel.
  2. Navigate to circuitConstructionKitDc.introScreen.model.circuit.
  3. Press Copy Set Value Code button. Notice that the textbox populates as though you pressed Get Value.
  4. Press Test to open a Standard PhET-iO Wrapper.
  5. Change the circuit in some way. Drag out another component, change its position, etc.
  6. Open the console and paste in the contents you obtained in (3).
  7. The circuit created in (1) is restored, as expected.
  8. Return to Studio and modify the circuit and use Copy Set Value Code button.
  9. Return to the Standard PhET-iO Wrapper and paste this new value into the console. Nothing appears to happen because the clipboard contents didn't actually change. You still have the circuit from (1) on your clipboard.
  10. Go back to Studio and press Get Value and then Copy Set Value Code.
  11. Again paste the contents of your clipboard into the console. The circuit you created in (8) will be restored.
samreid commented 1 year ago

Thanks for identifying this problem, it appears that this behavior happens for all studio get/set/copy controls and is not specific to circuits. Searching the history of the implementation led me to this comment from @zepumph: https://github.com/phetsims/studio/issues/245#issuecomment-1029358335

  • [x] Add "Copy Set Value Code" that will also error if there is invalid json. IF the text area is blank, then it calls "Get Value" function first to fill in the text area, then copies that value into somthing like phetioClient.invoke( . . . .. ) (note the extra alias needed in window.phetioClient = window.phetio.phetioClient; // extra alias for flexibility and consistency with Wrapper template.)

The popup tooltip also says:

'Copy to clipboard a line of code that invokes this customization on phetioClient.'

So it appears the design/implementation is to copy out of the text area instead of copying the value out of the sim. I wonder if this behavior was more apparent in simpler situations, where the value can be seen and understood at a glance. (Difficult to see what the circuit value means because it is very long).

I wonder if the implementation was proposed this way to support the following use case (I):

  1. The user presses "get value"
  2. The user modifies the text area, updating part of the value to be set
  3. (Note: the user does not set the new text area value back to the sim using Set Value")
  4. The user presses "copy set value code". Now it has code that reflects the value typed in step 2 rather than the sim value

Brainstorming ways forward

I feel we may be better off to design this component around "least surprise" rather than "most flexible". That being said, I also feel it may be important to support the "use case (I)" listed above. If we do change the semantics, we would probably want to re-maintenance release all PhET-iO sims with this feature for consistency. It would be unfortunate if we had sims where "Copy Set Value Code" means different things.

matthew-blackman commented 1 year ago

@arouinfar @samreid and I agree that 'Copy Set Value Code' should have a consistent functionality, independent of whether the state textbox is populated or not. Either clicking 'Copy Set Value Code' should always repopulate the state textbox, or it never should.

@samreid proposed the following solution: Any time the text box is empty, the 'Copy Set Value Code' button should be disabled. The client will then have to click the 'Get Value' button before clicking 'Copy Set Value Code'. This will set up a consistent UX in which a client must always click 'Get Value' before 'Copy Set Value Code' when updating state.

We discussed whether this needs to be maintenance released into prior PhET-iO sims, and agree that it does not. Those sims did not exhibit this type of problem because their states are much simpler, and we want to avoid the cost of that maintenance release.

matthew-blackman commented 1 year ago

In the future, @samreid @arouinfar and I would like to consider how this function could be leveraged in the PhET brand version of the sim, so that teachers could save/load circuits without using the full functionality of PhET-iO. @samreid proposed that this could involve some PhET-iO features being utilized in the PhET brand version of the sim.

This is beyond the scope of the CCK-DC PhET-iO milestone, but we agree that the save/load feature has a high priority for our teacher clients, outside of the PhET-iO licensing model.

This has been discussed in https://github.com/phetsims/circuit-construction-kit-common/issues/151, which we would like to reopen after this milestone is complete.

samreid commented 1 year ago

I added logic that enables/disables the Copy Set Value Code button based on the text area. While I was there, it seemed natural to also disable the Set button if the text area is empty, so I did that too. This could use a code review and testing. Also, I tested and it works well on Chrome, but cross-browser testing will be important here due to the nature of the DOM and input listeners.

UPDATE: I labeled as status:blocks-publication as a way to indicate to other sims that this is code that is committed to master but not fully vetted.

samreid commented 1 year ago

Today @arouinfar and @matthew-blackman and I reviewed this implementation and agree it seems nice. We just need to do more cross-platform testing on each browser, being careful to test:

arouinfar commented 1 year ago

@samreid @matthew-blackman I tested across a variety of browsers and the behavior looks good, closing.