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

[EuiDataGrid] Handle exposed ref APIs potentially pointing to invalid, off-page, or out of view cells #5572

Merged
merged 10 commits into from
Feb 1, 2022

Conversation

cee-chen
Copy link
Member

@cee-chen cee-chen commented Jan 27, 2022

Summary

Follow up to #5550 (review)

This should resolve the issue of setFocusedCell and openCellPopover being called on sorted or paginated grids. It should also:

  • Convert the documentation ref demo to Typescript to better catch potential type issues that our consumers may run into
  • Create a full E2E suite of Cypress tests for our ref APIs

I strongly recommend following along by commit.

Screencaps

ref

Checklist

- [ ] Check against all themes for compatibility in both light and dark modes
- [ ] Checked in mobile
- [ ] Checked in Chrome, Safari, Edge, and Firefox
- [ ] Props have proper autodocs and playground toggles

- [ ] Checked Code Sandbox works for any docs examples

- [ ] Checked for breaking changes and labeled appropriately
- [ ] Checked for accessibility including keyboard-only and screenreader modes
- [ ] A changelog entry exists and is marked appropriately Final changelog will be in final feature branch PR

- to test handling the exposed APIs when dealing with sorted/paginated data
- to test type issues during consumer usage

+ @ts-ignore faker complaints
- this will let us set up ref-specific helpers & add more comment context without bloating the main file
- Converts the `rowIndex` from the consumer to a `visibleRowIndex` that our internal state can use

- Account for sorted grid by finding the inversed index of the `sortedRowMap`
- To make this easier, I converted soredRowMap to an array (since it's already only uses numbers for both keys and values), since arrays have a handy .findIndex method

- Handles automatically paginating the grid if the targeted cell is on a different page
Comment on lines 1 to 3
import React, { useCallback, useMemo, useState, useRef } from 'react';
// @ts-ignore - faker does not have type declarations
import { fake } from 'faker';
Copy link
Member Author

Choose a reason for hiding this comment

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

Just curious: is there a quick or easy way of making it so this // @ts-ignore doesn't show up in our Demo TS tab or CodeSandbox?

Screen Shot 2022-01-27 at 2 02 02 PM

I'm guessing no unless we switch to a lib with type declarations? (Might be worth doing anyway, what with the whole faker kerfluffle 🤷‍♀️)

Copy link
Contributor

Choose a reason for hiding this comment

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

I keep promising Elizabet that I would make the snapshot of github data we use in the height examples available to other examples, which would hopefully help phase out faker.

Probably not worth putting any time into removing those comments, they may even be beneficial to consumers by indicating what part of an example may need some extra attention when copying.

Comment on lines +57 to +58
describe('useImperativeGridRef', () => {
const ref = createRef<EuiDataGridRefProps>();
Copy link
Member Author

Choose a reason for hiding this comment

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

Looking for feedback on if there's any tests you think I'm missing in these new Cypress tests! I'm pretty happy with how they turned out and they're much more robust than just plain old Jest unit tests, but there might be some more edge/regression cases that I'm missing

Comment on lines 46 to 57
// When we pass this API to the consumer, we can't know for sure that
// the targeted cell is valid or in view (unlike our internal state, where
// both of those states can be guaranteed), so we need to do some extra
// checks here to make sure the grid automatically handles all cells
const setFocusedCell = useCallback(
({ rowIndex, colIndex }) => {
checkCellExists({ rowIndex, colIndex });
const visibleRowIndex = getVisibleRowIndex(rowIndex);
_setFocusedCell([colIndex, visibleRowIndex]); // Transmog args from obj to array
},
[_setFocusedCell, checkCellExists, getVisibleRowIndex]
);
Copy link
Member Author

@cee-chen cee-chen Jan 27, 2022

Choose a reason for hiding this comment

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

Just want to chat a little bit about the approach I ended up going with here:

I briefly spiked out changing setFocusedCell and openCellPopover to use 'correct' rowIndexs instead of visibleRowIndexs. However, unfortunately this is isn't really realistic or doable for setFocusedCell primarily because of our keyboard navigation. Our up/down/left/arrow keys work by incrementing the current visibleRowIndex number, which obviously works pretty poorly when you're storing a correct rowIndex there instead and your grid is sorted.

I pretty quickly abandoned that approach as largely impractical (unless we decide for some reason that the overhead of constantly transmogging between visible and correct row indices is worth it). I instead decided to instead thoroughly document via comments that we were internally using visibleRowIndex in specific places (4a80624), and then opted to have only the exposed setFocusedCell and openCellPopver APIs perform some extra checks/transmogs regarding cell location before they called the underlying state updates.

In the end, I think this is a more practical (if less elegant) approach, since we can always rely on our internal state to call the correct row index, and the number of consumers who might use these ref APIs in the first place may not be incredibly high. I'm definitely open to other thoughts if there's some even more optimal and elegant way to handle this that I didn't consider however!

@@ -43,7 +43,7 @@ export const useSorting = ({
const sortingColumns = sorting?.columns;

const sortedRowMap = useMemo(() => {
const rowMap: DataGridSortingContextShape['sortedRowMap'] = {};
const rowMap: DataGridSortingContextShape['sortedRowMap'] = [];
Copy link
Member Author

@cee-chen cee-chen Jan 27, 2022

Choose a reason for hiding this comment

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

I went back and forth on storing an invertedRowMap that was the opposite of sortedRowMap. In the end, based on my decision in the above comment, I decided against it and went with a slightly cheaper shortcut of converting this object map to an array.

Since both the keys and values of our map are guaranteed to always be numbers, the typing is safe for an array, and this lets me quickly find an index by its value using Array.findIndex (which is performant because it short-circuits after the first result) and additionally doesn't require an extra traversal to flip all keys/values like an Object would.

LMK if that makes sense, happy to chat more if not!

@kibanamachine
Copy link

Preview documentation changes for this PR: https://eui.elastic.co/pr_5572/

// If the targeted row is on a different page than the current page,
// we should automatically navigate the user to the correct page
if (pageIndex !== pagination.pageIndex) {
pagination.onChangePage(pageIndex);
Copy link
Contributor

Choose a reason for hiding this comment

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

It feels a bit awkward to me that this getter function can alter state. What do you think of splitting this logic into a separate step for setFocusedCell/openCellPopover to use?

Copy link
Member Author

@cee-chen cee-chen Feb 1, 2022

Choose a reason for hiding this comment

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

I don't disagree, I thought about naming the fn getVisibleRowIndexAndPaginate but felt like I was writing Java at that point 🙈

On second review I agree this can be split out without excess duplication of logic - will do so here shortly

Copy link
Member Author

Choose a reason for hiding this comment

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

I ended up speaking too soon - it turns out I need the unsorted but visibleRowIndex on line 134 but before line 146 to calculate the correct page that a cell should be on. Otherwise I have to repeat the sortedRowMap search which isn't great. There doesn't seem to be a DRY way to separate out pagination from the found row index, so I opted to instead rename getVisibleRowIndex to findVisibleRowIndex to more clearly notate the side effect in 55a4ba6

- having a side effect in a getter feels bad, so change that to a `find`
- rename use hook to indicate sorting and pagination concerns
Copy link
Contributor

@chandlerprall chandlerprall left a comment

Choose a reason for hiding this comment

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

Changes LGTM; enjoyed watching cypress tests run locally, tried to break the examples but couldn't find any issues

@cee-chen cee-chen merged commit a31880b into elastic:feat/datagrid/5310 Feb 1, 2022
@cee-chen cee-chen deleted the datagrid/ref/row-index branch February 1, 2022 20:43
@kibanamachine
Copy link

Preview documentation changes for this PR: https://eui.elastic.co/pr_5572/

cee-chen pushed a commit that referenced this pull request Feb 2, 2022
…l state via `ref` (#5590)

* [EuiDataGrid] Set up `ref` that exposes focus/popover internal APIs (#5499)

* Set up types

* Set up forwardRef

* Add setFocusedCell API to returned grid ref obj

* Add colIndex prop to cell actions

- so that cell actions that trigger modals or flyouts can re-focus into the correct cell using the new ref API

* Add documentation + example + props

* Add changelog

* [PR feedback] Types

Co-authored-by: Chandler Prall <chandler.prall@gmail.com>

* [PR feedback] Clean up unit test

* [Rebase] Tweak useImperativeHandle location

- Moving it below fullscreen logic, as we're oging to expose setIsFullScreen as an API shortly

Co-authored-by: Chandler Prall <chandler.prall@gmail.com>

* [EuiDataGrid] Add `setIsFullScreen` to ref API (#5531)

* Expose `setIsFullScreen` to ref API

* Update documentation/examples

* [EuiDataGrid] Add `openCellPopover` and `closeCellPopover` to ref APIs (#5550)

* [setup] Update testCustomHook to expose fn that allows accessing most recent state/value

- without this callback, the initial returned hook values will be stale/not properly return most recent values
- see next commit for example usage within useCellPopover

* Set up cell popover context

- set up initial open/location state, + open/close popover APIs returned to consumers

- improve auto props documentation - remove EuiDataGridCellLocation in favor of specifying rowIndex and colIndex (it's less DRY but it's easier for devs to not have to look up EuiDataGridCellLocation from our docs)

* Pass down popoverContext to cells as a prop

- I'm not using context here because we're already using this.context for focus, and unfortunately class components can only initialize one context at time using `static contextType` (see https://reactjs.org/docs/context.html#classcontexttype)

* Remove internal cell popoverIsOpen state

- This should now be handled by the overarching context state, and the cell should simply react to it or update it (similar to how focusContext works)

+ add new var for hasCellButtons

+ add unit tests for isFocusedCell alongside isPopoverOpen (since both methods perform similar functions)

* Update cell popovers to set the popover anchor & content

- content is TODO, will likely be easier to compare when cleaning it up/moving it all at once

* Refactor EuiDataGridCellPopover

- It should no longer exist as a reusable component that belongs to every single cell, but instead as a single popover that exists at the top grid level and moves from cell to cell

- I cleaned and split up the JSX for the popover (e.g. moving popover actions to data_grid_cell_buttons, where it feels like it belongs more) and think it's significantly more DRY now - note the entire `anchorContent` branch removed from EuiDataGridCell that is no longer necessary

- Note that due to this change, we now have to mock EuiWrappingPopover in EuiDataGrid tests, as we see failures otherwise

* [bugfix] Handle cells with open popover being scrolled out of view

- this is the same behavior as in prod

- causes weird DOM issues if we don't close the cell popover automatically

* [bugfix] Workaround for popover DOM stuttering issues

* [enhancement] Account for openCellPopover being called on cells out of view

+ write bonus Cypress test for useScroll's focus effect now that we have access to the imperative ref

* Update documentation example

+ remove code snippet - it was starting to get redundant with the API bullet points, and is already available as tab if needed

+ fix control button widths

* [PR feedback] Be more specific about useImperativeHandle dependencies

+ add a few explanatory comments

* [PR feedback] Rename openCellLocation to cellLocation

- to make it sound less like a verb/method

* [PR feedback] Ignore edge case of `openCellPopover` being called on an `isExpandable: false` cell

* [EuiDataGrid] Handle exposed ref APIs potentially pointing to invalid, off-page, or out of view cells (#5572)

* Enable sorting + targeting row indices outside of the current page

- to test handling the exposed APIs when dealing with sorted/paginated data

* Switch data grid example to Typescript

- to test type issues during consumer usage

+ @ts-ignore faker complaints

* Fix cell expansion buttons on paginated pages not working correctly

* Attempt to more clearly document `rowIndex`s that are actually `visibleRowIndex`s

* [setup] Move imperative handler setup to its own util file

- this will let us set up ref-specific helpers & add more comment context without bloating the main file

* Add catch/check for cell locations that do not exist in the current grid

* Add getVisibleRowIndex helper

- Converts the `rowIndex` from the consumer to a `visibleRowIndex` that our internal state can use

- Account for sorted grid by finding the inversed index of the `sortedRowMap`
- To make this easier, I converted soredRowMap to an array (since it's already only uses numbers for both keys and values), since arrays have a handy .findIndex method

- Handles automatically paginating the grid if the targeted cell is on a different page

* Replace grid ref Jest tests with more complete Cypress tests

* Update documentation with new behavior

* [PR feedback] Rename fns to indicate multiple concerns

- having a side effect in a getter feels bad, so change that to a `find`
- rename use hook to indicate sorting and pagination concerns

* Improve changelog

* Data grid ref methods docs review

* Fix colIndex to be available in renderCellValue as well as cell actions

- primarily for use within trailing/leading cells and custom actions

- see 1609e45

* Fix main datagrid example to restore cell focus on modal/flyout close

* [a11y] Fix missing header cell text in main datagrid example

Co-authored-by: Chandler Prall <chandler.prall@gmail.com>
Co-authored-by: Elizabet Oliveira <elizabet.oliveira@elastic.co>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants