Make improvements to RB.DialogView

Review Request #10820 — Created Jan. 13, 2020 and submitted

Information

Review Board
release-3.0.x
2ce2905...

Reviewers

This review request changes makes improvements to RB.DialogView. It introduces a property on
RB.DialogView which is a map of buttons in the dialog from button ids to
button elements. The id field of a RB.DialogView button is now required so that
buttons can be referenced by their id in the map. In addition, disabled and class
are added as button keys so that buttons can be created with initial state set to disabled
and with classes set, respectively.

Unit tests:
- new dialog view button properties (id, class, disabled)

Description From Last Updated

In your description, we don't need to talk about browsers without JS--Review Board really isn't usable if JS is turned …

daviddavid

Thanks for working on this, Hannah! Reversing the order of buttons may fix it, but I think only accidentally (and …

chipx86chipx86

Just curious, can you test what happens when esc is pressed?

daviddavid

Oh, one last thing. May have mentioned this before, but we now effectively have two distinct changes tangled up here: …

chipx86chipx86

We should call this only if there's an id set. I'd make this its own statement, wrapped in an if …

chipx86chipx86

Rather than define these, let's use the definitions that already exist: $.ui.keyCode.ENTER and $.ui.keyCode.ESCAPE.

chipx86chipx86

Let's pull buttons[0] into a variable and then test against that. It'll be a little faster (avoids repeated index lookups).

chipx86chipx86

Jasmine has a handy .toBeFalse() and .toBeTrue(), which is more correct in these cases.

chipx86chipx86

You'll want to do this right at the class level, like: events: _.extend({ 'change #path': '.....', }, RB.DialogView.prototype.events),

chipx86chipx86

So something that I noticed when I looked at the original code we had earlier is that we're playing with …

chipx86chipx86

This is still best done in render. It's kind of like initialize but for the initial display, so it's the …

chipx86chipx86

Is there a reason why we're using !!buttonInfo.danger instead of just simply buttonInfo.danger?

bui1bui1

Col: 37 ['testid'] is better written in dot notation.

reviewbotreviewbot

Col: 37 ['testid'] is better written in dot notation.

reviewbotreviewbot

Col: 37 ['testid'] is better written in dot notation.

reviewbotreviewbot

Col: 37 ['testid'] is better written in dot notation.

reviewbotreviewbot

Col: 41 ['testid'] is better written in dot notation.

reviewbotreviewbot

Col: 41 ['testid'] is better written in dot notation.

reviewbotreviewbot

Col: 41 ['upload'] is better written in dot notation.

reviewbotreviewbot

We like to keep these in alphabetical order, but have completely failed to do so here. Doh. Can you shuffle …

chipx86chipx86

"ID" should be capitalized in sentences.

chipx86chipx86

I think we only want to do this inside of the code building the body and modalBox. Otherwise, a second …

chipx86chipx86

I think this should probably go right before modalBox(), so we're not at risk of page elements jumping around or …

chipx86chipx86

So the biggest thing I'm noticing is that we're now returning a mapping exclusively. The issue is that we're no …

chipx86chipx86

We can just set this to true directly, since we already know that's the value.

chipx86chipx86

toggleClass isn't very useful here, since we already know we want to enable these classes. It's actually a bit slower, …

chipx86chipx86

I mentioned this in a reply to Monica's review, but we don't need to use reduce here. Even though there's …

chipx86chipx86

I think this can now be this.$('.js-path') (and you could remove the id="path" on the element). If we can remove …

chipx86chipx86
david
  1. Code looks pretty good. Just a couple small questions.

    I'd suggest wrapping your description and testing done sections to a reasonable length (80 chars, typically) to make them more readable.

    1. Woops missed the part about the description until now

  2. Show all issues

    In your description, we don't need to talk about browsers without JS--Review Board really isn't usable if JS is turned off.

    1. Removed bit about browsers without JS

  3. Show all issues

    Just curious, can you test what happens when esc is pressed?

    1. Nothing happens when esc is pressed

    2. Can we add a handler so that esc cancels the dialog?

    3. In fact, while I think switching the order of the buttons is correct, I think we should also have a handler to ensure that the enter key binds to the correct thing.

  4. 
      
hxqlin
chipx86
  1. 
      
  2. Show all issues

    Thanks for working on this, Hannah!

    Reversing the order of buttons may fix it, but I think only accidentally (and I don't like having to shuffle around UI to make something work). Since the Upload button is disabled by default, I don't believe we're guaranteed that all browsers will treat it as the primary button.

    It's always better to be explicit in how a bug gets fixed, particularly when we're dependent on browser behavior. Also, we're going to encounter this in other dialogs as well, which may have even more buttons and more complicated button layouts.

    modalBox is ooooold

    So this is old code. We actually have this far-nicer thing called RB.DialogView, which we'd like to port code to. It has a concept of primary buttons, too.

    It's a bit newer, and it might need some improvement, but that's where any work toward doing this correctly should go.

    So I'd suggest changing this code to not call .modalBox() directly (okay, RB.DialogView does call this, but don't worry about that). You can create the element for the contents of the dialog and instantiate using:

    const dialogView = new RB.DialogView({
        title: ...,
        body: yourElement,
        buttons: [
            {
                label: gettext('Upload'),
                primary: true,
                onClick: () => this.send(),
            },
            {
                label: gettext('Cancel'),
            }
        ],
    });
    

    Something like that.

    Now, this probably won't fix it directly, but here's where phase 2 comes in! You can modify reviewboard/static/rb/js/ui/views/dialogView.es6.js to register a key event for Enter and a key event for Escape and hook those up, like:

    RB.DialogView = Backbone.View.extend({
       events: {
           'keypress': _onKeyPress,
       },
    
       ...
    
       _onKeyPress(e) {
           /* Check for Enter and Escape here */
       }
    });
    

    If Enter, activate the primary button if we have one (loop through this.options, look for primary).

    If Escape, just call this.hide().

    I think this would be fantastic addition, and would solve the bug cleanly!

    Make sure that you write unit tests, though. Check out ui/views/tests/dialogView.es6.js. You'll want to simulate key events. Here's how:

    const evt = $.Event('keypress')
    evt.which = $.ui.keyCode.ENTER;
    dialogView.$el.trigger(evt);
    
    1. Oops, that last bit was meant to point you to ui/views/tests/dialogViewTests.es6.js.

      Check out our (short) guide on writing JavaScript unit tests.

    2. Sounds good, I'm looking into it! Have a couple questions though -
      1. when you say "activate the primary button," are you saying just to call the onClick function of the button found by something like this.options.buttons.find(button => button.primary)
      2. what's the best way of checking if this primary button is safe to activate? for the upload attachment dialog, we should only enable the upload button if a file has been uploaded, meaning the file path is not null. In the dialog view though, there's currently no way of enforcing that

    3. Hi Hannah.

      1. Sorta. The onClick being passed in isn't always a function. It can be a name of a function on the instance of RB.DialogView to call (works like the Backbone.View's events mapping). So we can't rely on it being a function. What we can do is call triggerHandler('click') on the button element, which will simulate a click and result in the right function being called.
      2. Excellent point. I think the best approach would be, when generating the list of buttons in _getButtons(), to store the primary button as a member variable on RB.DialogView (this._$primaryButton). No need for a loop then in the Enter handler. We can then check if it's disabled before calling onClick.
  3. 
      
hxqlin
chipx86
  1. 
      
  2. Show all issues

    We should call this only if there's an id set. I'd make this its own statement, wrapped in an if that checks for it.

    (Frankly, we should probably be doing that with all the things we're adding -- we're cheating a bit with the toggleClass thing, and it's not as performant as just an if with addClass.)

  3. Show all issues

    Rather than define these, let's use the definitions that already exist: $.ui.keyCode.ENTER and $.ui.keyCode.ESCAPE.

  4. Show all issues

    Let's pull buttons[0] into a variable and then test against that. It'll be a little faster (avoids repeated index lookups).

  5. Show all issues

    Jasmine has a handy .toBeFalse() and .toBeTrue(), which is more correct in these cases.

    1. These can't be used at the moment because of the Jasmine version - the current version is 2.4.1 but toBeFalse and toBeTrue are available since 3.5.0. https://jasmine.github.io/api/edge/matchers.html

    2. Oh right, this is on Review Board 3.x. We bumped Jasmine for 4.x.

  6. Show all issues

    You'll want to do this right at the class level, like:

    events: _.extend({
        'change #path': '.....',
    }, RB.DialogView.prototype.events),
    
  7. Show all issues

    So something that I noticed when I looked at the original code we had earlier is that we're playing with fire using these IDs. #upload and #path are wayyyyy too generic, and are bound to conflict. There should never be more than one element with an ID, and if there is, bad things can happen with event handling and such.

    So what I'd suggest doing is two things:

    1. Adding support for CSS classes instead of or in addition to IDs, when registering buttons.
    2. Using our CSS standard naming scheme to come up with suitable classes.

    For the latter, I don't want to have you pour too much more work into this, because this can get out of control, so instead of revamping the entire CSS for this dialog, we can make use of these handy js--prefixed classes. So, js-path and js-upload-button.

    What js- means is "I'm a thing owned by some JavaScript, and nothing but the thing that owns me should deal with me." So no custom styling, so page-wide DOM queries, nothing. It's good for this purpose.

    By using those instead of hard-coded IDs, we'll avoid potentially weird bugs down the line.

  8. Show all issues

    This is still best done in render. It's kind of like initialize but for the initial display, so it's the right place to pull out variables. We then don't have to define show(), letting us just take advantage of the parent class's version, and trusting that we'll be pulling out variables before it's actually shown.

    Make sure though, especially with the above js- prefixes I mentioned, to isolate the lookup to this element. You can use a shorthand available to views:

    this._$path = this.$('.js-path');
    this._$uploadBtn = this.$('.js-upload-button');
    
    1. I don't think it's possible to do this in render - render gets called before show and _getButtons does. If we tried to do this in render, this.$path and this._$uploadBtn before they even exist in the DOM. Also just wanted to check - did you mean

      this._$path = $('.js-path');
      this._$uploadBtn = $('.js-upload-button');
      

      or the snippet you wrote above?

    2. Good point! Okay, so thinking through this...

      1. We provider render() for custom rendering of the dialog, but
      2. We call it before we add the dialog body (if set), meaning that it can't operate on it, and
      3. We create the buttons after all that, during the process of displaying the dialog.

      On top of that, we actually have this fun design flaw where if we continuously call show() and hide(), we'll end up appending the same dialog content over and over, since we never erase the element's contents in hide(). So that's fun.

      Okay, so we have a couple goals here:

      1. We want to be able to access contents in the dialog and buttons in a reliable way.
      2. We want to be able to use render() for this, since that's the convention in every other view out there.

      Fortunately, not really much of anything uses RB.DialogView, so we can form the conventions for how it works.

      How about this:

      1. Let's update _getButtons() to not only create the buttons, but also to store in a this.$buttons map, which would map button IDs (when set) to $button elements. This would allow for accessing things like this.$buttons['upload'], so we don't need to do stuff like the .js-blah class workaround.
      2. Let's then call this._getButtons() before we do anything else in show(). Since these are independent of the content of the dialog at this stage, we can create them first, save the result, and then pass that in to modalBox() at the end.
      3. Let's then move render() to after the body management, so that it can operate on any elements that might have bene defined in body.

      Doing that, render() will have full access to the elements in the body and the buttons.

      There's still the matter of state cleanup (making sure something like this._$path is nulled out when the dialog is hidden, making sure elements are removed), but the above is already changing a lot, and the rest might need more thought.

      I think, given this work, we're now at a place where it might make sense to repurpose this change for the DialogView work, and then make a new change that's just about the file upload dialog (built on top of this branch).

    3. Good plan - I made the changes you recommended and took out the bits that dealt with keyboard stuff!

  9. 
      
hxqlin
hxqlin
bui1
  1. 
      
  2. Show all issues

    Is there a reason why we're using !!buttonInfo.danger instead of just simply buttonInfo.danger?

    1. This is a JavaScriptism that converts a truthy/falsy value into a true/false.

      For instance, let's say you have a truthy value like "foo". Negating (!"foo") gives you false, and then negating again (!!"foo") gives you true.

      For a falsy value, like "", negating onces (!"") gives you true, and then again (!!"") gives you false.

      So it's really just shorthand for value ? true : false.

  3. Just a question out of curiousity but what will happen to the dialogue if neither enter/esc is pressed on the keyboard but rather another keyCode like any of the digits/alphabet etc?

    Will the dialogue stay open and not produce any errors? Does the dialogue just ignore those key inputs?

    1. If focus isn't on the caption input, keys other than esc and enter will be ignored. If the caption input is focused, those key presses will fill in the caption field.

  4. Also wondering why 'send' is used for onClick instead of a function that would trigger an event that would allow the user to upload their file?

    1. Ignore this comment above. I just spent some more time reading backbone js documentations and got it!

  5. 
      
hxqlin
Review request changed
Change Summary:

Remove bug fix and make this review request just do the refactoring for RB.UploadAttachmentView - bug fix to come in another rb

Summary:
Fix for pressing enter on upload file dialog cancelling upload
Refactor RB.UploadAttachmentView to extend RB.DialogView
Description:
~  

Fix bug where pressing enter on upload file dialog would cancel the upload

~   instead of actually uploading it. This bug occurred because the first button,
~   which is the cancel button in the dialog, is always the default button used
~   to submit a form. The fix is to make the upload button the primary, or
~   default, button and use the Enter key event to upload the file if one has
~   been uploaded. This RB changes uploadAttachmentView to extend dialogView
~   as opposed to calling the dialogView constructor directly. In addition, it
  ~

This review request changes RB.UploadAttachmentView to extend RB.DialogView as opposed

  ~ to calling the RB.DialogView constructor directly. It also introduces a property on
  ~ RB.DialogView which is a map of buttons in the dialog from button ids to
  ~ button elements. The id field of a RB.DialogView button is now required so that
  ~ buttons can be referenced by their id in the map. In addition, disabled and class
  ~ are added as button keys so that buttons can be created with initial state set to disabled
  ~ and with classes set, respectively.

-   adds a handler for the Escape key so that the dialog can be closed using the
-   Escape key.

Testing Done:
   

Manually tested:

~   - uploading a file via the dialog and using the enter key to upload or by
~   pressing the "Upload" button
  ~ - uploading a file via the dialog pressing the "Upload" button
  ~ - canceling the dialog by pressing the "Cancel" button

-   - canceling the dialog by pressing the escape key or by pressing the "Cancel"
-   button

   
   

Unit tests:

~   - new dialog view button properties (id, class, disabled)
  ~ - new dialog view button properties (id, class, disabled)

-   - pressing the enter key when the primary button is enabled and disabled and
-   pressing the escape key for the dialog

Commit:
f973c8515471201d371fe32cf2ca34a36b13dd4e
d8213875ed85f4cae1ed1fba87d52a9c99a7e582

Checks run (1 failed, 1 succeeded)

flake8 passed.
JSHint failed.

JSHint

hxqlin
bui1
  1. Changes look really good! Only have one small comment that I'm not too sure about

  2. I really like how simple it is to read the reduce function callback.

    More for a question for any mentor/maintainer reviewing this, are reduce functions allowed for es6 files? I've looked through https://www.notion.so/reviewboard/JavaScript-ES6-Guidelines-a5d6efb850d24ac295e1b8792cecdd25
    but couldn't find anything on it

    1. Good question!

      Short answer

      Yes, you can, but we shouldn't here.

      Longer answer

      reduce is available on a wide range of browsers, and has been for many years. IE introduced support with IE9, which we're no longer supporting anyway. Chrome introduced with with version 3, and Firefox with version 3 as well. You can find a compatibility table here:

      https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/reduce

      I wouldn't use it here, though, because it's just an unnecessary second loop. We're already looping through all the buttons once, and that's the right time to create the mapping. A second loop (while fast, given how few buttons we'd ever use) isn't really beneficial in any way, when the first loop can just add to the map with a single statement.

  3. 
      
chipx86
  1. 
      
  2. reviewboard/static/rb/js/ui/views/dialogView.es6.js (Diff revision 6)
     
     
     
     
     
     
     
     
     
     
     
     
     
     
     
     
     
     
    Show all issues

    We like to keep these in alphabetical order, but have completely failed to do so here. Doh.

    Can you shuffle these around so they're in order?

  3. Show all issues

    "ID" should be capitalized in sentences.

  4. Show all issues

    I think we only want to do this inside of the code building the body and modalBox. Otherwise, a second call would replace our button map.

    1. Left this issue open since I wasn't quite sure if I understood correctly with where I moved this call.

  5. Show all issues

    I think this should probably go right before modalBox(), so we're not at risk of page elements jumping around or modalBox's size calculations being incorrect as things re-render.

  6. Show all issues

    So the biggest thing I'm noticing is that we're now returning a mapping exclusively. The issue is that we're no longer going to have ordering information preserved, so we're browser-dependent. This could cause buttons to swap around.

    We're going to want to ultimately end up having two pieces of information calculated:

    1. A list of button elements (to pass to modalBox) -- let's call this this.$buttonList.
    2. A mapping of button elements, for render() to reference. -- let's call this this.$buttonsMap, for consistency.

    How about we rename this to _makeButtons(), and we just populate both of those attributes instead of returning anything? Then it behaves the way that show() now uses it, and it gives us public attributes for both of these useful bits of state.

    It'll be important to start the function off by clearing both of those attributes before we do anything.

  7. Show all issues

    We can just set this to true directly, since we already know that's the value.

  8. reviewboard/static/rb/js/ui/views/dialogView.es6.js (Diff revision 6)
     
     
     
     
     
     
     
    Show all issues

    toggleClass isn't very useful here, since we already know we want to enable these classes. It's actually a bit slower, since it's first going to split the existing class list from a string and do a search in there. So instead, let's just use addClass() with the class name we want.

  9. reviewboard/static/rb/js/ui/views/dialogView.es6.js (Diff revision 6)
     
     
     
     
     
     
     
     
    Show all issues

    I mentioned this in a reply to Monica's review, but we don't need to use reduce here. Even though there's so few buttons in practice, we want to avoid a second loop (and all the function calls that come from each iteration of a reduce call).

    Instead, let's just set the entry in the map up above, after we've set all the button state (so after the if (buttonInfo.onClick) { ... } block).

  10. 
      
hxqlin
chipx86
  1. This looks just about done! One tiny thing (and you can also close the opened issue -- you got it right!).

  2. Show all issues

    I think this can now be this.$('.js-path') (and you could remove the id="path" on the element). If we can remove the IDs on the elements in this dialog, we'll reduce the risk of hard-to-diagnose conflicts.

  3. 
      
chipx86
  1. 
      
  2. Show all issues

    Oh, one last thing. May have mentioned this before, but we now effectively have two distinct changes tangled up here:

    1. A set of improvements to RB.DialogView, which are independent of the changes to RB.UploadAttachmentView.
    2. The fixes to RB.UploadAttachmentView and making it a subclass of RB.DialogView.

    So I'd suggest repurposing this change for the RB.DialogView cahnges and describing them, and then having a follow-up review request just for the UploadAttachmentView changes.

    It's helpful for a variety of reasons to keep each review request/commit focused like that, so we encourage it where possible.

    1. Updated this review request to just be RB.DialogView; new review request for RB.UploadAttachmentView: https://reviews.reviewboard.org/r/10884/

  3. 
      
hxqlin
hxqlin
chipx86
  1. Looks good! I'll land this once 3.0.17 goes out the door (we're putting out a quick compatibility fix release, and are frozen for that).

  2. 
      
hxqlin
Review request changed
Status:
Completed
Change Summary:
Pushed to release-3.0.x (ea5725f)