Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Table] Deselect cells on cmd+click only #1665

Merged
merged 19 commits into from
Oct 13, 2017

Conversation

cmslewis
Copy link
Contributor

@cmslewis cmslewis commented Oct 5, 2017

Fixes #1073, Addresses #1079

Checklist

  • Include tests

Changes proposed in this pull request:

A morning surprise for @llorca.

  • 🔄 CHANGED Table no longer toggles selection on click; now, you must cmd + click to deselect cells, columns, or rows.
  • 🐛 FIXED Table ctrl + click now works on Windows again.

Reviewers should focus on:

  • There was an edge case for rows due to the fact that selected rows become reorderable, meaning the DragReorderable component would swallow all clicks (even cmd + clicks meant for deselecting). The fix required a refactor of the disabled? prop to optionally be a function that accepts the current MouseEvent, so it can check if the metaKey is pressed.
  • This isn't an issue for columns, because the reorder handle is the only trigger for reordering; the rest of the column header cell is managed by the DragSelectable layer.
  • This isn't an issue for body cells either, because reordering isn't even a thing there.
  • You can still start new drag-selections from an already-selected cell. 🎉

@blueprint-bot
Copy link

Little cleanups

Preview: documentation | table
Coverage: core | datetime

Copy link
Contributor

@llorca llorca left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

feels amazing 💯

* Whether the meta key should be pressed to enable deselection on click.
* @default false
*/
requireMetaKeyToDeselect?: boolean;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is now true in every context (body cells and header cells). Shall I get rid of it and make the true path the default behavior?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes please

ignoredSelectors.some((selector: string) => element.closest(selector) != null)
);
}

// Update logic
// ============

/**
* Returns true if the component should ignore subsequent drag-move's.
*/
private handleUpdateExistingSelection = (selectedRegionIndex: number, event: MouseEvent) => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we get rid of the requireMetaKeyToDeselect prop, I can also promote the two logic paths in this function to the top-level handleActivate function (with new, nicely named helper functions to manage each logic path). That would obviate the need for a return value here.

if (event.metaKey) {
// if the meta key is pressed, we want to forcefully ignore reordering
// interactions and prioritize drag-selection interactions (e.g. to make
// it possible to deselect a row).
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i don't think meta is the ideal key here for all platforms. meta means cmd on mac and windows-key on windows. typically i use what the hotkeys code calls mod which is cmd on mac and ctrl on windows. i think the windows-key accesses OS resources like the start menu so it's not usually used in a key combo.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe holding any modifier key should disable the drag?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems like this would likely conflict with other key combos

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed by using DragEvents.isAdditive(event)

@@ -85,6 +85,7 @@ export class TableBody extends React.Component<ITableBodyProps, {}> {
onFocus={this.props.onFocus}
onSelection={this.props.onSelection}
onSelectionEnd={this.handleSelectionEnd}
requireMetaKeyToDeselect={true}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

non-configurable? why even have a prop?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because I added it at the beginning of this work. And then realized by the end of it that I didn't need it to be configurable. Will remove.

public state: IHeaderState = {
hasSelectionEnded: false,
};
public state: IHeaderState;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

remove this if defining in constructor.

// a selection is already defined, so enable reordering interactions
// right away if other criteria are satisfied too.
this.state = {
hasSelectionEnded: props.selectedRegions != null && props.selectedRegions.length > 0,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this logic need to appear in componentWillReceiveProps too?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, already does.

if (event.metaKey) {
// if the meta key is pressed, we want to forcefully ignore reordering
// interactions and prioritize drag-selection interactions (e.g. to make
// it possible to deselect a row).
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe holding any modifier key should disable the drag?

* Whether the meta key should be pressed to enable deselection on click.
* @default false
*/
requireMetaKeyToDeselect?: boolean;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes please

Copy link
Contributor

@giladgray giladgray left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(to prevent accidental merge after recent reviews)

@blueprint-bot
Copy link

Use DragEvents.isAdditive, fix tests

Preview: documentation | table
Coverage: core | datetime

@cmslewis cmslewis added the P1 label Oct 9, 2017
@cmslewis cmslewis dismissed giladgray’s stale review October 9, 2017 17:05

Addressed comments

@blueprint-bot
Copy link

Write tests for PlatformUtils

Preview: documentation | table
Coverage: core | datetime

@cmslewis
Copy link
Contributor Author

cmslewis commented Oct 10, 2017

@themadcreator @giladgray @adidahiya @gscshoyru

The situation:

  • On non-MAC, CTRL + Click should add disjoint selections.
  • On Mac, CMD + Click should add disjoint selections.
  • On Mac, CTRL + Click triggers the context menu and shouldn't add disjoint selections.

I tried using Hotkeys in the DragSelectable component so that I could leverage its platform detection capabilities, but given that a click is involved, I would have to add some awkward flag-toggling callbacks like:

<Hotkey
    label="???"
    combo="mod"
    onKeyDown={() => (this.isModKeyPressed = true)}
    onKeyUp={() => (this.isModKeyPressed = false)}
/>

And then do something on click like if (isLeftClick && this.isModKeyPressed).

But this has some problems:

  • There's a click involved, so using a Hotkey feels forced.
  • The Hotkey label is meaningless.
  • I still need to know the platform somehow: if I'm on a Mac, CTRL + click requires special handling, so I need some way to write if (isLeftClick && event.ctrlKey && isMac()).

So I need some kind of platform detection. We're already doing this via the isMac helper in hotkeyParser.ts in core, but I don't particularly want to export that function for table to reuse, because that would also enable any other Blueprint consumer to use it (and it ain't the most rigorous function in the world).

For now, I've copied the isMac code into table, written an isModKeyPressed helper too, and written/copied tests for each. Everything now works as expected on both Mac and Windows.

^ Thoughts on this approach? I'd rather not copy code between packages, but nothing else seemed workable.


it("returns true if CTRL key pressed", () => {
const fakeEvent: any = { metaKey: false, ctrlKey: true };
expect(PlatformUtils.isModKeyPressed(fakeEvent, PLATFORM)).to.be.true;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can't do const isMac = sinon.stub(PlatformUtils, "isMac") and expect the tests to pass, because under the hood, isModKeyPressed invokes isMac as its own free-floating function, not as a child of some PlatformUtils construct.

Thus, a platformOverride parameter.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that seems wrong... you should be able to stub the function at the module where it's exported (but not at some other module which re-exports the symbol). did you try stubbing it with a specific implementation (specify its return value)?

Copy link
Contributor Author

@cmslewis cmslewis Oct 11, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd love to be wrong, but it wouldn't work with my normal strategies. I tried stubbing the return value within the describe, but that actually stubs the isMac function before the describe("isMac") block runs, which causes all those tests to fail. When I did the stubbing in a before or in a beforeEach, it didn't work. Maybe I was just tired and missing something yesterday - I'll try some other stuff right now.

Copy link
Contributor Author

@cmslewis cmslewis Oct 11, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this test fails when run on a Mac; isModKeyPressed still invokes the real isMac function in its same file:

import * as PlatformUtils from "../../../src/common/internal/platformUtils";

it("returns true if CTRL key pressed on a non-Mac", () => {
    const isMac = sinon.stub(PlatformUtils, "isMac").returns(false);
    const fakeEvent: any = { metaKey: false, ctrlKey: true };
    expect(PlatformUtils.isModKeyPressed(fakeEvent)).to.be.true;
    isMac.restore();
});

Copy link
Contributor

@giladgray giladgray Oct 11, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what if the signature was isModKeyPressed(event, isMac = isMac) (callback param, defaults to real impl in the file)?
then you could simply pass a fake isMac impl () => false instead of trying to stub some module export.
also then your actual isMac impl wouldn't need to support platformOverride.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How would I test the logic in the real isMac without a platformOverride param? I'd like to just stub navigator.platform, but you can't because it's not a function.

} else {
this.setState({ hasSelectionEnded: false });
}
this.setState({ hasSelectionEnded: this.isSelectedRegionsControlledAndNonEmpty(nextProps) });
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ this will always update the state when props change; was that your intention behind this change? or does it need to be gated with an if clause?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, that's intended. For row headers, hasSelectionEnded needs to be true before reordering is allowed. When you pass in selectedRegions in a controlled fashion, we want the flag to be true from the get-go so that reordering is immediately enabled.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe we should rename this since the boolean does not indicate anything about the selection "ending", but rather if it is a valid complete selection.

maybe hasValidSelection or hasReorderableSelection

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed to hasValidSelection.

Copy link
Contributor

@themadcreator themadcreator left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just some nits

} else {
this.setState({ hasSelectionEnded: false });
}
this.setState({ hasSelectionEnded: this.isSelectedRegionsControlledAndNonEmpty(nextProps) });
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe we should rename this since the boolean does not indicate anything about the selection "ending", but rather if it is a valid complete selection.

maybe hasValidSelection or hasReorderableSelection

private wrapInDragReorderable(
index: number,
children: JSX.Element,
disabled: boolean | ((event: MouseEvent) => boolean) = false,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

this method will only ever be called with the function type right?

also, it's another private method, so you could just omit this param and use this.isDragReorderableDisabled directly (though maybe i missed something hidden in folded code)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually this function is used twice: in one case, it falls back to the default false value, in the other, shown in this diff, the invoking function passes this.isDragReorderableDisabled directly.

For clarity, I'll remove the default in the first case and passed in the false value explicitly.

const element = event.target as HTMLElement;

const isLeftClick = Utils.isLeftClick(event);
const isContextMenuTrigger = isLeftClick && event.ctrlKey;
const isContextMenuTrigger = isLeftClick && event.ctrlKey && PlatformUtils.isMac();
const isDisabled = CoreUtils.isFunction(disabled) ? CoreUtils.safeInvoke(disabled, event) : disabled;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: seems like a good opportunity to DRY. maybe a templatized CoreUtils method that resolves a constant or functional result and has a default constant.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added CoreUtils.safeInvokeOrValue. 👍

@blueprint-bot
Copy link

Add CoreUtils.safeInvokeOrValue

Preview: documentation | table
Coverage: core | datetime

* Returns `true` if (1) the platform is Mac and the keypress includes the `cmd`
* key, or (2) the platform is non-Mac and the keypress includes the `ctrl` key.
*/
export const isModKeyPressed = (event: KeyboardEvent, platformOverride?: string) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

...there's no way to do this via feature discovery, huh? Drat.


private isDragSelectableDisabled = (event: MouseEvent) => {
if (DragEvents.isAdditive(event)) {
// if the meta/crtl key was pressed, we want to forcefully ignore
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: ctrl, not crtl :P

@blueprint-bot
Copy link

Nit: 'crtl' => 'ctrl'

Preview: documentation | table
Coverage: core | datetime

@cmslewis cmslewis merged commit cf3e807 into master Oct 13, 2017
@cmslewis cmslewis deleted the cl/table-1079-cmd-click-deselect branch October 13, 2017 20:25
@cmslewis cmslewis mentioned this pull request Oct 16, 2017
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants