phetsims / chipper

Tools for developing and building PhET interactive simulations.
MIT License
11 stars 14 forks source link

Use Idiomatic React Patterns #1268

Open samreid opened 2 years ago

samreid commented 2 years ago

In https://github.com/phetsims/phet-io-wrappers/issues/440 we want to create a function that creates a DOM element dialog for entering username and password. Following https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/password we wanted to create code like this:

<div>
    <label for="username">Username:</label>
    <input type="text" id="username" name="username">
</div>

<div>
    <label for="pass">Password (8 characters minimum):</label>
    <input type="password" id="pass" name="password"
           minlength="8" required>
</div>

<input type="submit" value="Sign in">

Which led to code like https://github.com/phetsims/phet-io-wrappers/commit/bae01d791d6e858ed6c974e9cfd80138814cb722

    const div = document.createElement( 'div' );
    const label = document.createElement( 'label' );
    label.setAttribute( 'for', 'username' );
    label.innerText = labelText;
    label.style.display = 'block';
    // label.style.marginTop = '1rem';

    const input = document.createElement( 'input' );
    input.setAttribute( 'type', inputType );
    input.setAttribute( 'id', id );
    input.setAttribute( 'name', 'username' );

    div.appendChild( label );
    div.appendChild( input );

// ...

    const submitButton = document.createElement( 'input' );
    submitButton.disabled = true;
    submitButton.setAttribute( 'type', 'submit' );
    submitButton.addEventListener( 'click', onEnter );
    submitButton.value = 'Submit';
    submitButton.style.marginTop = '1rem';
    submitButton.style.marginRight = '1rem';

    const enableButton = () => {
      submitButton.disabled = usernameSection.input.value.length === 0 || passwordSection.input.value.length === 0;
    };
    usernameSection.input.addEventListener( 'keydown', enableButton );
    passwordSection.input.addEventListener( 'keydown', enableButton );

    const cancelButton = document.createElement( 'input' );
    cancelButton.setAttribute( 'type', 'submit' );
    cancelButton.addEventListener( 'click', () => {
      reject( new Error( 'cancelled by user' ) );
    } );
    cancelButton.value = 'Cancel';
    cancelButton.style.marginTop = '1rem';

When we faced similar problems in PhetioElementView, we ended up creating our own utility framework for creating dom elements, like:

const createRadioButtonObject = ( label, id, phetioID, phetioElementMetadata ) => {
  const span = document.createElement( 'span' );
  const radioButton = createInput( 'radio', {
    id: id,
    name: phetioID,
    disabled: phetioElementMetadata.phetioReadOnly
  } );
  span.appendChild( radioButton );
  span.appendChild( createLabel( label, id ) );

  return {
    span: span,
    radioButton: radioButton
  };
};

But it seems like React would be preferable to either of these options, and would yield more readable, maintainable code. For example, here is the credentials dialog using react:

  ReactDOM.render(
        // Following https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/password
        <div id="credentialsDialog" style={{
          border: 'black', display: 'inline-block', borderWidth: '1px', borderStyle: 'solid', margin: '10px',
          padding: '10px', position: 'fixed', top: '50%', left: '50%',
          transform: 'translate(-50%, -50%)', webkitTransform: 'translate(-50%, -50%)',
          borderRadius: '10px'
        }}>
          <div>
            <label htmlFor="username" style={{ display: 'block' }}>PhET-iO Username</label>
            <input type="text" id="username" name="username" onKeyDown={onTextEntry} onKeyUp={onKeyUp} style={textInputStyle} required/>
          </div>

          <div style={{ marginTop: '1rem' }}>
            <label htmlFor="password" style={{ display: 'block' }}>PhET-iO Password</label>
            <input type="password" id="password" name="password" onKeyDown={onTextEntry} onKeyUp={onKeyUp} style={textInputStyle} required/>
          </div>

          <input id="submitButton" type="submit" value="Sign in" style={buttonStyle} onClick={onEnter}/>
          <input type="submit" value="Cancel" style={buttonStyle} onClick={onCancel}/>

          <div style={{ marginTop: '10px' }}>
            <input type="checkbox" id="checkbox"/>
            <label htmlFor="checkbox">Remember username</label>
          </div>

        </div>,
        document.getElementById( 'root' )
      );

Now that we already have babel in our toolchain, adding support for react seems feasible. This will require changes in transpiling, linting and type checking.

samreid commented 2 years ago

I notified the team over slack dev-public:

I’ll be adding support for React in https://github.com/phetsims/chipper/issues/1268, it has the potential to disrupt transpiling, linting and type checking, please let me know if you discover problems.

samreid commented 2 years ago

I notified slack:

For developers: I pushed support for react, please pull-all, then npm install in chipper and restart your transpiler process at your convenience. Please report problems in https://github.com/phetsims/chipper/issues/1268

Anyone want to volunteer for code reviewing that issue? I’ll mark it for dev meeting in case nobody volunteers before then.

chrisklus commented 2 years ago

From 6/16/22 dev meeting:

PSA delivered! @chrisklus is going to review.

samreid commented 2 years ago

For the review, can you please check the following:

chrisklus commented 2 years ago

@marlitas and I took a look through the above commits and files. As far as we can see, things are looking good. However, we do feel a little out of our comfort zone in regards to using React with Promises, so this case is not what we're used to.

We did notice that some aspects of CredentialsDialog are still using JavaScript patterns and could be done in a more React-like way, but what is there is working fine so it feels like it's up to developer preference. For example:

Clearly none of these things are needed, because everything is working as is, but @marlitas and I were just noticing that it feels like React is not being used to its full potential. It seems like the main improvement of this file is the addition of JSX. Perhaps with some of the changes above, React will make scaling easier when we make components that are more complicated and state-dependent.

samreid commented 2 years ago

Excellent, thanks for the review!

Maybe it would be best to schedule collaboration for next steps for this issue. I'll send a slack message about scheduling.

chrisklus commented 2 years ago

This patch is not working, but I wanted to leave these changes here as a placeholder before I lose them - was trying out using refs and events a couple days ago. It may be a good starting point for collaboration.

```diff Index: common/js/CredentialsDialog.tsx IDEA additional info: Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP <+>UTF-8 =================================================================== diff --git a/common/js/CredentialsDialog.tsx b/common/js/CredentialsDialog.tsx --- a/common/js/CredentialsDialog.tsx (revision afb6e2ab3af42eea53b16eb79e4cb02b01bae798) +++ b/common/js/CredentialsDialog.tsx (date 1657737638282) @@ -24,12 +24,23 @@ export default class CredentialsDialog extends React.Component { private readonly resolve: ( value: Credentials | PromiseLike ) => void; private readonly reject: ( reason?: any ) => void; + private readonly submitButton: React.RefObject; + private readonly cancelButton: React.RefObject; + private readonly usernameInput: React.RefObject; + private readonly passwordInput: React.RefObject; + private readonly checkbox: React.RefObject; public constructor( props: { resolve: ( value: Credentials | PromiseLike ) => void; reject: ( reason?: any ) => void } ) { super( props ); this.resolve = props.resolve; this.reject = props.reject; + + this.submitButton = React.createRef(); + this.cancelButton = React.createRef(); + this.usernameInput = React.createRef(); + this.passwordInput = React.createRef(); + this.checkbox = React.createRef(); } public override render(): React.ReactNode { @@ -46,19 +57,21 @@ }}>
- +
- +
- - + +
- +
@@ -66,13 +79,6 @@ } public override componentDidMount(): void { - - const submitButton = document.getElementById( 'submitButton' ) as HTMLButtonElement; - const cancelButton = document.getElementById( 'cancelButton' ) as HTMLButtonElement; - const usernameInput = document.getElementById( 'username' ) as HTMLInputElement; - const passwordInput = document.getElementById( 'password' ) as HTMLInputElement; - const checkbox = document.getElementById( 'checkbox' ) as HTMLInputElement; - let initialUsername = ''; let isStoreUsername = false; try { @@ -87,68 +93,58 @@ // Could be localstorage failure or value not stored. Either way we ignore } - const onTextEntry = () => { - submitButton.disabled = usernameInput.value.trim().length === 0 || passwordInput.value.trim().length === 0; - }; + this.submitButton.current!.disabled = true; + this.usernameInput.current!.value = initialUsername; + this.checkbox.current!.checked = isStoreUsername; + + if ( initialUsername.length === 0 ) { + this.usernameInput.current!.focus(); + } + else { + this.passwordInput.current!.focus(); + } + } - const onEnter = () => { - if ( !submitButton.disabled ) { - const username = usernameInput.value.trim(); - const password = passwordInput.value.trim(); + private onTextEntry() { + this.submitButton.current!.disabled = this.usernameInput.current!.value.trim().length === 0 || + this.passwordInput.current!.value.trim().length === 0; + } - if ( checkbox.checked ) { - localStorage.setItem( localStorageUsernameKey, username ); - } + private onKeyUp( event: any ) { + if ( event.key === 'Enter' ) { + this.onEnter(); + } + } + + private onEnter() { + if ( !this.submitButton.current!.disabled ) { + const username = this.usernameInput.current!.value.trim(); + const password = this.passwordInput.current!.value.trim(); + + if ( this.checkbox.current!.checked ) { + localStorage.setItem( localStorageUsernameKey, username ); + } - // ReactDOM.unmountComponentAtNode( component ); - const node = document.getElementById( 'credentialsDialog' )!; - node.parentElement!.removeChild( node ); - // root.removeChild( document.getElementById( 'credentialsDialog' )! ); - this.resolve( { username: username, password: password } ); - } - }; - const onKeyUp = ( event: any ) => { - if ( event.key === 'Enter' ) { - onEnter(); - } - }; - const onCancel = () => { - const node = document.getElementById( 'credentialsDialog' )!; - node.parentElement!.removeChild( node ); - this.reject( new Error( 'cancelled by user' ) ); - }; - - submitButton.disabled = true; - usernameInput.value = initialUsername; - checkbox.checked = isStoreUsername; - - usernameInput.addEventListener( 'keydown', onTextEntry ); - passwordInput.addEventListener( 'keydown', onTextEntry ); - - usernameInput.addEventListener( 'keyup', onKeyUp ); - passwordInput.addEventListener( 'keyup', onKeyUp ); - - submitButton.addEventListener( 'click', onEnter ); - cancelButton.addEventListener( 'click', onCancel ); - - if ( initialUsername.length === 0 ) { - usernameInput.focus(); - } - else { - passwordInput.focus(); - } + // ReactDOM.unmountComponentAtNode( component ); + const node = document.getElementById( 'credentialsDialog' )!; + node.parentElement!.removeChild( node ); + // root.removeChild( document.getElementById( 'credentialsDialog' )! ); + this.resolve( { username: username, password: password } ); + } + } + + private onCancel() { + const node = document.getElementById( 'credentialsDialog' )!; + node.parentElement!.removeChild( node ); + this.reject( new Error( 'cancelled by user' ) ); + } - // Handle when the browser auto-fills - passwordInput.addEventListener( 'change', onTextEntry ); - usernameInput.addEventListener( 'change', onTextEntry ); - - checkbox.addEventListener( 'click', () => { - const storeUsername = checkbox.checked; - localStorage.setItem( localStorageStoreUsernameKey, storeUsername ? 'true' : 'false' ); - if ( !storeUsername ) { - localStorage.removeItem( localStorageUsernameKey ); - } - } ); + private onCheckboxClick() { + const storeUsername = this.checkbox.current!.checked; + localStorage.setItem( localStorageStoreUsernameKey, storeUsername ? 'true' : 'false' ); + if ( !storeUsername ) { + localStorage.removeItem( localStorageUsernameKey ); + } } private static async show(): Promise { ```
samreid commented 2 years ago

I'm not planning more time for this until we schedule a sprint or collaboration. Unassigning for now.