From 413e01322bf6b074f54656f43c2656261a55d27c Mon Sep 17 00:00:00 2001 From: Constance Chen Date: Wed, 1 Dec 2021 14:08:26 -0800 Subject: [PATCH 1/5] Add new useUpdateEffect hook - essentially just useEffect that fires only on update/rerender and not on mount - inspiration from https://github.com/streamich/react-use/blob/master/docs/useUpdateEffect.md + update useDependentState to dogfood hook (since it's essentially using the same ref/mount logic that I want) --- src/services/hooks/index.ts | 1 + src/services/hooks/useDependentState.ts | 20 ++----- src/services/hooks/useUpdateEffect.test.tsx | 58 +++++++++++++++++++++ src/services/hooks/useUpdateEffect.ts | 25 +++++++++ src/services/index.ts | 1 + 5 files changed, 90 insertions(+), 15 deletions(-) create mode 100644 src/services/hooks/useUpdateEffect.test.tsx create mode 100644 src/services/hooks/useUpdateEffect.ts diff --git a/src/services/hooks/index.ts b/src/services/hooks/index.ts index 7c6224ff242..573f9165bb2 100644 --- a/src/services/hooks/index.ts +++ b/src/services/hooks/index.ts @@ -7,6 +7,7 @@ */ export * from './useCombinedRefs'; +export * from './useUpdateEffect'; export * from './useDependentState'; export * from './useIsWithinBreakpoints'; export * from './useMouseMove'; diff --git a/src/services/hooks/useDependentState.ts b/src/services/hooks/useDependentState.ts index 84db057d2f0..9eb30d541af 100644 --- a/src/services/hooks/useDependentState.ts +++ b/src/services/hooks/useDependentState.ts @@ -6,7 +6,8 @@ * Side Public License, v 1. */ -import { useEffect, useState, useRef } from 'react'; +import { useState } from 'react'; +import { useUpdateEffect } from './useUpdateEffect'; export function useDependentState( valueFn: (previousState: undefined | T) => T, @@ -14,20 +15,9 @@ export function useDependentState( ) { const [state, setState] = useState(valueFn as () => T); - // use ref instead of a state to avoid causing an unnecessary re-render - const hasMounted = useRef(false); - - useEffect(() => { - // don't call setState on initial mount - if (hasMounted.current === true) { - setState(valueFn); - } else { - hasMounted.current = true; - } - - // purposefully omitting `updateCount.current` and `valueFn` - // this means updating only the valueFn has no effect, but allows for more natural feeling hook use - // eslint-disable-next-line react-hooks/exhaustive-deps + // don't call setState on initial mount + useUpdateEffect(() => { + setState(valueFn); }, deps); return [state, setState] as const; diff --git a/src/services/hooks/useUpdateEffect.test.tsx b/src/services/hooks/useUpdateEffect.test.tsx new file mode 100644 index 00000000000..2599918629c --- /dev/null +++ b/src/services/hooks/useUpdateEffect.test.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { mount } from 'enzyme'; +import { useUpdateEffect } from './useUpdateEffect'; + +describe('useUpdateEffect', () => { + const mockEffect = jest.fn(); + const mockCleanup = jest.fn(); + + const MockComponent = ({ test }: { test?: boolean }) => { + useUpdateEffect(() => { + mockEffect(); + return () => mockCleanup(); + }, [test]); + + return null; + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('does not invoke the passed effect on initial mount', () => { + mount(); + + expect(mockEffect).not.toHaveBeenCalled(); + }); + + it('invokes the passed effect on each component update/rerender', () => { + const component = mount(); + + component.setProps({ test: true }); + expect(mockEffect).toHaveBeenCalledTimes(1); + + component.setProps({ test: false }); + expect(mockEffect).toHaveBeenCalledTimes(2); + + component.setProps({ test: true }); + expect(mockEffect).toHaveBeenCalledTimes(3); + }); + + it('invokes returned cleanup, same as useEffect', () => { + const component = mount(); + + component.setProps({ test: true }); // Trigger first update/call + expect(mockCleanup).not.toHaveBeenCalled(); + + component.unmount(); // Trigger cleanup + expect(mockCleanup).toHaveBeenCalled(); + }); +}); diff --git a/src/services/hooks/useUpdateEffect.ts b/src/services/hooks/useUpdateEffect.ts new file mode 100644 index 00000000000..24a708a223e --- /dev/null +++ b/src/services/hooks/useUpdateEffect.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { useEffect, useRef } from 'react'; + +export const useUpdateEffect = (effect: Function, deps: unknown[]) => { + // use ref instead of a state to avoid causing an unnecessary re-render + const hasMounted = useRef(false); + + useEffect(() => { + // don't invoke the effect on initial mount + if (hasMounted.current === true) { + return effect(); + } else { + hasMounted.current = true; + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); +}; diff --git a/src/services/index.ts b/src/services/index.ts index a0d5677f501..88d92de726c 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -125,6 +125,7 @@ export { EuiWindowEvent } from './window_event'; export { useCombinedRefs, + useUpdateEffect, useDependentState, useIsWithinBreakpoints, useMouseMove, From fb5bb6326c3425d5d702967f5ef1320fd31f43ea Mon Sep 17 00:00:00 2001 From: Constance Chen Date: Wed, 1 Dec 2021 14:13:51 -0800 Subject: [PATCH 2/5] Add `onChange` callbacks to `gridStyles` and `rowHeightsOptions` - and fire said callbacks on update whenever user configurations change --- .../controls/display_selector.test.tsx | 37 ++++++++++++++++++- .../datagrid/controls/display_selector.tsx | 12 ++++++ src/components/datagrid/data_grid_types.ts | 10 +++++ 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/components/datagrid/controls/display_selector.test.tsx b/src/components/datagrid/controls/display_selector.test.tsx index 88b699a8d01..798b01c72fa 100644 --- a/src/components/datagrid/controls/display_selector.test.tsx +++ b/src/components/datagrid/controls/display_selector.test.tsx @@ -92,6 +92,24 @@ describe('useDataGridDisplaySelector', () => { ).toEqual('tableDensityCompact'); }); + it('calls the gridStyles.onDensityChange callback on user change', () => { + const onDensityChange = jest.fn(); + const component = mount( + + ); + + openPopover(component); + component.find('[data-test-subj="expanded"]').simulate('change'); + + expect(onDensityChange).toHaveBeenCalledWith({ + stripes: true, + fontSize: 'l', + cellPadding: 'l', + }); + }); + it('hides the density buttongroup if allowDensity is set to false', () => { const component = mount( @@ -153,6 +171,23 @@ describe('useDataGridDisplaySelector', () => { expect(getSelection(component)).toEqual('auto'); }); + it('calls the rowHeightsOptions.onChange callback on user change', () => { + const onRowHeightChange = jest.fn(); + const component = mount( + + ); + + openPopover(component); + component.find('[data-test-subj="auto"]').simulate('change'); + + expect(onRowHeightChange).toHaveBeenCalledWith({ + defaultHeight: 'auto', + lineHeight: '3', + }); + }); + it('hides the row height buttongroup if allowRowHeight is set to false', () => { const component = mount( @@ -286,7 +321,7 @@ describe('useDataGridDisplaySelector', () => { it('returns an object of grid styles with user overrides', () => { const initialStyles = { ...startingStyles, stripes: true }; const MockComponent = () => { - const [, gridStyles] = useDataGridDisplaySelector( + const [, { onChange, ...gridStyles }] = useDataGridDisplaySelector( true, initialStyles, {} diff --git a/src/components/datagrid/controls/display_selector.tsx b/src/components/datagrid/controls/display_selector.tsx index bafc4591c46..7c5a25ee890 100644 --- a/src/components/datagrid/controls/display_selector.tsx +++ b/src/components/datagrid/controls/display_selector.tsx @@ -8,6 +8,7 @@ import React, { ReactNode, useState, useMemo, useCallback } from 'react'; +import { useUpdateEffect } from '../../../services'; import { EuiI18n, useEuiI18n } from '../../i18n'; import { EuiPopover } from '../../popover'; import { EuiButtonIcon, EuiButtonGroup } from '../../button'; @@ -163,6 +164,17 @@ export const useDataGridDisplaySelector = ( }; }, [initialRowHeightsOptions, userRowHeightsOptions]); + // Invoke onChange callbacks on user input (removing the callback value itself, so that only configuration values are returned) + useUpdateEffect(() => { + const { onChange, ...currentGridStyles } = gridStyles; + initialStyles?.onChange?.(currentGridStyles); + }, [userGridStyles]); + + useUpdateEffect(() => { + const { onChange, ...currentRowHeightsOptions } = rowHeightsOptions; + initialRowHeightsOptions?.onChange?.(currentRowHeightsOptions); + }, [userRowHeightsOptions]); + const buttonLabel = useEuiI18n( 'euiDisplaySelector.buttonText', 'Display options' diff --git a/src/components/datagrid/data_grid_types.ts b/src/components/datagrid/data_grid_types.ts index 9b1b15ab9e8..103898dfc26 100644 --- a/src/components/datagrid/data_grid_types.ts +++ b/src/components/datagrid/data_grid_types.ts @@ -576,6 +576,11 @@ export interface EuiDataGridStyle { * If set to true, the footer row will be sticky */ stickyFooter?: boolean; + /** + * Optional callback returning the current `gridStyle` config when changes occur from user input (e.g. toolbar display controls). + * Can be used for, e.g. storing user `gridStyle` in a local storage object. + */ + onChange?: (gridStyle: EuiDataGridStyle) => void; } export interface EuiDataGridToolBarVisibilityColumnSelectorOptions { @@ -767,6 +772,11 @@ export interface EuiDataGridRowHeightsOptions { * Defines a global lineHeight style to apply to all cells */ lineHeight?: string; + /** + * Optional callback returning the current `rowHeightsOptions` when changes occur from user input (e.g. toolbar display controls). + * Can be used for, e.g. storing user `rowHeightsOptions` in a local storage object. + */ + onChange?: (rowHeightsOptions: EuiDataGridRowHeightsOptions) => void; } export interface EuiDataGridRowManager { From 6b1ba90bf58f075069b33810fb1ac185ffd66a42 Mon Sep 17 00:00:00 2001 From: Constance Chen Date: Wed, 1 Dec 2021 14:17:35 -0800 Subject: [PATCH 3/5] Add documentation example - with density responsiveness & localStorage --- .../datagrid_height_options_example.js | 23 ++++- .../datagrid/datagrid_styling_example.js | 29 ++++++ .../src/views/datagrid/display_callbacks.js | 92 +++++++++++++++++++ 3 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 src-docs/src/views/datagrid/display_callbacks.js diff --git a/src-docs/src/views/datagrid/datagrid_height_options_example.js b/src-docs/src/views/datagrid/datagrid_height_options_example.js index fcaf9d43aa8..f30ba451375 100644 --- a/src-docs/src/views/datagrid/datagrid_height_options_example.js +++ b/src-docs/src/views/datagrid/datagrid_height_options_example.js @@ -1,4 +1,5 @@ import React, { Fragment } from 'react'; +import { Link } from 'react-router-dom'; import { GuideSectionTypes } from '../../components'; import { @@ -136,8 +137,8 @@ export const DataGridRowHeightOptionsExample = { By default, all rows get a height of 34 pixels, but there are scenarios where you might want to adjust the height to fit more content. To do that, you can pass an object to the{' '} - rowHeightsOptions prop. This object accepts three - properties: + rowHeightsOptions prop. This object accepts the + following properties:

  • @@ -173,6 +174,24 @@ export const DataGridRowHeightOptionsExample = {
+
  • + onChange +
      +
    • + Optional callback when the user changes the data grid's + internal rowHeightsOptions (e.g., via the + toolbar display selector). +
    • +
    • + Can be used to store and preserve user display preferences on + page refresh - see this{' '} + + data grid styling and control example + + . +
    • +
    +
  • diff --git a/src-docs/src/views/datagrid/datagrid_styling_example.js b/src-docs/src/views/datagrid/datagrid_styling_example.js index 971f14a2fdb..97494c23f2a 100644 --- a/src-docs/src/views/datagrid/datagrid_styling_example.js +++ b/src-docs/src/views/datagrid/datagrid_styling_example.js @@ -11,6 +11,9 @@ import { EuiListGroupItem, } from '../../../../src/components'; +import DataGridDisplayCallbacks from './display_callbacks'; +const dataGridDisplayCallbacksSource = require('!!raw-loader!./display_callbacks'); + import DataGridContainer from './container'; const dataGridContainerSource = require('!!raw-loader!./container'); const dataGridContainerHtml = renderToHtml(DataGridContainer); @@ -220,6 +223,32 @@ export const DataGridStylingExample = { }, demo: , }, + { + source: [ + { + type: GuideSectionTypes.JS, + code: dataGridDisplayCallbacksSource, + }, + ], + title: 'Adjusting your grid to user/toolbar changes', + text: ( + <> +

    + You can use the optional gridStyle.onChange and{' '} + rowHeightsOptions.onChange callbacks to adjust + your data grid based on user density or row height changes. +

    +

    + For example, if the user changes the grid density to compressed, you + may want to adjust a cell's content sizing in response. Or you + could store user settings in localStorage or other database to + preserve display settings on page refresh, like the below example + does. +

    + + ), + demo: , + }, { source: [ { diff --git a/src-docs/src/views/datagrid/display_callbacks.js b/src-docs/src/views/datagrid/display_callbacks.js new file mode 100644 index 00000000000..7ce872e1325 --- /dev/null +++ b/src-docs/src/views/datagrid/display_callbacks.js @@ -0,0 +1,92 @@ +import React, { useState, useCallback, useMemo } from 'react'; +import { fake } from 'faker'; + +import { EuiDataGrid, EuiIcon } from '../../../../src/components/'; + +const columns = [ + { id: 'name' }, + { id: 'email' }, + { id: 'city' }, + { id: 'country' }, + { id: 'account' }, +]; +const data = []; +for (let i = 1; i <= 5; i++) { + data.push({ + name: fake('{{name.lastName}}, {{name.firstName}} {{name.suffix}}'), + email: fake('{{internet.email}}'), + city: fake('{{address.city}}'), + country: fake('{{address.country}}'), + account: fake('{{finance.account}}'), + }); +} + +const GRID_STYLES_KEY = 'euiDataGridStyles'; +const INITIAL_STYLES = JSON.stringify({ stripes: true }); +const storedGridStyles = JSON.parse( + localStorage.getItem(GRID_STYLES_KEY) || INITIAL_STYLES +); + +const ROW_HEIGHTS_KEY = 'euiDataGridRowHeightsOptions'; +const INITIAL_ROW_HEIGHTS = JSON.stringify({}); +const storedRowHeightsOptions = JSON.parse( + localStorage.getItem(ROW_HEIGHTS_KEY) || INITIAL_ROW_HEIGHTS +); + +export default () => { + const [densitySize, setDensitySize] = useState(''); + const responsiveIcon = useCallback( + () => , + [densitySize] + ); + const responsiveIconWidth = useMemo(() => { + if (densitySize === 'l') return 44; + if (densitySize === 's') return 24; + return 32; + }, [densitySize]); + const leadingControlColumns = useMemo( + () => [ + { + id: 'icon', + width: responsiveIconWidth, + headerCellRender: responsiveIcon, + rowCellRender: responsiveIcon, + }, + ], + [responsiveIcon, responsiveIconWidth] + ); + + const saveRowHeightsOptions = useCallback((updatedRowHeights) => { + console.log(updatedRowHeights); + localStorage.setItem(ROW_HEIGHTS_KEY, JSON.stringify(updatedRowHeights)); + }, []); + + const saveGridStyles = useCallback((updatedStyles) => { + console.log(updatedStyles); + localStorage.setItem(GRID_STYLES_KEY, JSON.stringify(updatedStyles)); + setDensitySize(updatedStyles.fontSize); + }, []); + + const [visibleColumns, setVisibleColumns] = useState(() => + columns.map(({ id }) => id) + ); + + return ( + data[rowIndex][columnId]} + /> + ); +}; From f693f9c352067b79169e369d32f3425210f338b1 Mon Sep 17 00:00:00 2001 From: Constance Chen Date: Wed, 1 Dec 2021 14:17:55 -0800 Subject: [PATCH 4/5] Add changelog entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d061a393bfa..291d57df543 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - Updated `EuiDataGrid`'s full screen mode to use the `fullScreenExit` icon ([#5415](https://github.com/elastic/eui/pull/5415)) - Added `left.append` and `left.prepend` to `EuiDataGrid`'s `toolbarVisibility.additionalControls` prop [#5394](https://github.com/elastic/eui/pull/5394)) - Added a row height control to `EuiDataGrid`'s toolbar ([#5372](https://github.com/elastic/eui/pull/5372)) +- Added `onChange` callbacks to `EuiDataGrid`'s `gridStyle` and `rowHeightOptions` settings ([#5424](https://github.com/elastic/eui/pull/5424)) **Bug fixes** From bd98122986579f44a5b9c2360ee95eac40cb881c Mon Sep 17 00:00:00 2001 From: Constance Chen Date: Thu, 2 Dec 2021 08:38:24 -0800 Subject: [PATCH 5/5] [PR feedback] Adjust callback demo to load stored configs on SPA navigation --- .../src/views/datagrid/display_callbacks.js | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src-docs/src/views/datagrid/display_callbacks.js b/src-docs/src/views/datagrid/display_callbacks.js index 7ce872e1325..e3491d576e2 100644 --- a/src-docs/src/views/datagrid/display_callbacks.js +++ b/src-docs/src/views/datagrid/display_callbacks.js @@ -23,15 +23,9 @@ for (let i = 1; i <= 5; i++) { const GRID_STYLES_KEY = 'euiDataGridStyles'; const INITIAL_STYLES = JSON.stringify({ stripes: true }); -const storedGridStyles = JSON.parse( - localStorage.getItem(GRID_STYLES_KEY) || INITIAL_STYLES -); const ROW_HEIGHTS_KEY = 'euiDataGridRowHeightsOptions'; const INITIAL_ROW_HEIGHTS = JSON.stringify({}); -const storedRowHeightsOptions = JSON.parse( - localStorage.getItem(ROW_HEIGHTS_KEY) || INITIAL_ROW_HEIGHTS -); export default () => { const [densitySize, setDensitySize] = useState(''); @@ -56,12 +50,21 @@ export default () => { [responsiveIcon, responsiveIconWidth] ); - const saveRowHeightsOptions = useCallback((updatedRowHeights) => { + const storedRowHeightsOptions = useMemo( + () => + JSON.parse(localStorage.getItem(ROW_HEIGHTS_KEY) || INITIAL_ROW_HEIGHTS), + [] + ); + const storeRowHeightsOptions = useCallback((updatedRowHeights) => { console.log(updatedRowHeights); localStorage.setItem(ROW_HEIGHTS_KEY, JSON.stringify(updatedRowHeights)); }, []); - const saveGridStyles = useCallback((updatedStyles) => { + const storedGridStyles = useMemo( + () => JSON.parse(localStorage.getItem(GRID_STYLES_KEY) || INITIAL_STYLES), + [] + ); + const storeGridStyles = useCallback((updatedStyles) => { console.log(updatedStyles); localStorage.setItem(GRID_STYLES_KEY, JSON.stringify(updatedStyles)); setDensitySize(updatedStyles.fontSize); @@ -77,11 +80,11 @@ export default () => { leadingControlColumns={leadingControlColumns} rowHeightsOptions={{ ...storedRowHeightsOptions, - onChange: saveRowHeightsOptions, + onChange: storeRowHeightsOptions, }} gridStyle={{ ...storedGridStyles, - onChange: saveGridStyles, + onChange: storeGridStyles, }} columns={columns} columnVisibility={{ visibleColumns, setVisibleColumns }}