From 46f0bee8840166a4d1358c7e84ebf9ff391b06a6 Mon Sep 17 00:00:00 2001 From: Papa-Santo <165276313+Papa-Santo@users.noreply.github.com> Date: Thu, 25 Apr 2024 06:07:22 -0400 Subject: [PATCH 1/8] Feat(AnalyticalTable): introduce autoResize column feature (#3196) A functionality by which the cell width gets auto adjusted when we double click the data-resizer columns. Implemented for text. --- .../AnalyticalTable/AnalyticalTable.cy.tsx | 59 +++++++++ .../AnalyticalTable.stories.tsx | 8 +- .../ColumnHeader/ColumnHeaderContainer.tsx | 10 +- .../TableBody/VirtualTableBody.tsx | 42 +------ .../src/components/AnalyticalTable/index.tsx | 117 ++++++++++++++++-- .../tableReducer/stateReducer.ts | 11 ++ .../components/AnalyticalTable/types/index.ts | 11 +- 7 files changed, 204 insertions(+), 54 deletions(-) diff --git a/packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx b/packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx index 8400b05597a..bdcc8c68387 100644 --- a/packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx +++ b/packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx @@ -239,6 +239,65 @@ describe('AnalyticalTable', () => { cy.findByText('Name-3').should('not.be.visible'); }); + it('autoResize', () => { + let resizeColumns = columns.map((el) => { + return { ...el, autoResizable: true }; + }); + + let dataFixed = data.map((el, i) => { + if (i === 2) return { ...el, name: 'Much Longer Name To Resize Larger For Testing A Larger Auto Resize' }; + return el; + }); + + cy.mount( e.preventDefault()} />); + cy.wait(200); + cy.get('[data-cy="data-resizer-1"]').should('be.visible').dblclick(); + cy.get('[data-column-id="age"]').invoke('outerWidth').should('equal', 476); + cy.get('[data-cy="data-resizer-0"]').should('be.visible').dblclick(); + cy.get('[data-column-id="name"]').invoke('outerWidth').should('equal', 476); + + cy.mount(); + cy.wait(200); + cy.get('[data-cy="data-resizer-1"]').should('be.visible').dblclick(); + cy.get('[data-column-id="age"]').invoke('outerWidth').should('equal', 60); + cy.get('[data-cy="data-resizer-0"]').should('be.visible').dblclick(); + cy.get('[data-column-id="name"]').invoke('outerWidth').should('equal', 451); + + dataFixed = generateMoreData(200); + + dataFixed = dataFixed.map((el, i) => { + if (i === 2) return { ...el, name: 'Much Longer Name To Resize Larger For Testing A Larger Auto Resize' }; + else if (i > 50) return { ...el, name: 'Short Name' }; + return el; + }); + + const loadMore = cy.spy().as('more'); + cy.mount( + + ); + + cy.get('[data-component-name="AnalyticalTableBody"]').scrollTo('bottom'); + cy.get('[data-cy="data-resizer-0"]').should('be.visible').dblclick(); + cy.get('[data-column-id="name"]').invoke('outerWidth').should('equal', 94); + + resizeColumns = columns.map((el) => { + return { ...el, autoResizable: false }; + }); + + cy.mount(); + cy.wait(200); + cy.get('[data-cy="data-resizer-1"]').should('be.visible').dblclick(); + cy.get('[data-column-id="age"]').invoke('outerWidth').should('equal', 472.75); + cy.get('[data-cy="data-resizer-0"]').should('be.visible').dblclick(); + cy.get('[data-column-id="name"]').invoke('outerWidth').should('equal', 472.75); + }); + it('scrollTo', () => { interface ScrollTableProps { scrollFn: string; diff --git a/packages/main/src/components/AnalyticalTable/AnalyticalTable.stories.tsx b/packages/main/src/components/AnalyticalTable/AnalyticalTable.stories.tsx index 8f398baafd8..5ae00702ed7 100644 --- a/packages/main/src/components/AnalyticalTable/AnalyticalTable.stories.tsx +++ b/packages/main/src/components/AnalyticalTable/AnalyticalTable.stories.tsx @@ -32,11 +32,13 @@ const meta = { { Header: 'Name', headerTooltip: 'Full Name', // A more extensive description! - accessor: 'name' // String-based value accessors! + accessor: 'name', // String-based value accessors! + autoResizable: true // Double clicking the resize bar auto resizes the column! }, { Header: 'Age', accessor: 'age', + autoResizable: true, hAlign: TextAlign.End, disableGroupBy: true, disableSortBy: false, @@ -45,12 +47,14 @@ const meta = { }, { Header: 'Friend Name', - accessor: 'friend.name' + accessor: 'friend.name', + autoResizable: true }, { Header: () => Friend Age, headerLabel: 'Friend Age', accessor: 'friend.age', + autoResizable: true, hAlign: TextAlign.End, filter: (rows, accessor, filterValue) => { if (filterValue === 'all') { diff --git a/packages/main/src/components/AnalyticalTable/ColumnHeader/ColumnHeaderContainer.tsx b/packages/main/src/components/AnalyticalTable/ColumnHeader/ColumnHeaderContainer.tsx index ddcfae533aa..80fc78ed086 100644 --- a/packages/main/src/components/AnalyticalTable/ColumnHeader/ColumnHeaderContainer.tsx +++ b/packages/main/src/components/AnalyticalTable/ColumnHeader/ColumnHeaderContainer.tsx @@ -17,6 +17,7 @@ interface ColumnHeaderContainerProps { columnVirtualizer: Virtualizer; uniqueId: string; showVerticalEndBorder: boolean; + onAutoResize: (e: React.MouseEvent, accessor: string) => void; } export const ColumnHeaderContainer = forwardRef((props, ref) => { @@ -30,7 +31,8 @@ export const ColumnHeaderContainer = forwardRef { + if (column.autoResizable) { + onAutoResize(e, rest.id); + } + }} /> )} ; prepareRow: (row: unknown) => void; rows: Record[]; - itemCount: number; - scrollToRef: MutableRefObject; isTreeTable: boolean; internalRowHeight: number; - visibleRows: number; alternateRowColor: boolean; - overscanCount: number; visibleColumns: Record[]; - parentRef: MutableRefObject; renderRowSubComponent: (row?: Record) => ReactNode; popInRowHeight: number; isRtl: boolean; @@ -40,26 +34,20 @@ interface VirtualTableBodyProps { scrollContainerRef?: MutableRefObject; subComponentsBehavior: AnalyticalTablePropTypes['subComponentsBehavior']; triggerScroll?: TriggerScrollState; + scrollToRef: MutableRefObject; + rowVirtualizer: Virtualizer; } -const measureElement = (el: HTMLElement) => { - return el.offsetHeight; -}; - export const VirtualTableBody = (props: VirtualTableBodyProps) => { const { alternateRowColor, classes, prepareRow, rows, - itemCount, scrollToRef, isTreeTable, internalRowHeight, - visibleRows, - overscanCount, visibleColumns, - parentRef, renderRowSubComponent, popInRowHeight, markNavigatedRow, @@ -72,33 +60,13 @@ export const VirtualTableBody = (props: VirtualTableBodyProps) => { subRowsKey, scrollContainerRef, subComponentsBehavior, - triggerScroll + triggerScroll, + rowVirtualizer } = props; - const overscan = overscanCount ? overscanCount : Math.floor(visibleRows / 2); const rowHeight = popInRowHeight !== internalRowHeight ? popInRowHeight : internalRowHeight; const lastNonEmptyRow = useRef(null); - const rowVirtualizer = useVirtualizer({ - count: itemCount, - getScrollElement: () => parentRef.current, - estimateSize: useCallback( - (index) => { - if ( - renderRowSubComponent && - (rows[index]?.isExpanded || alwaysShowSubComponent) && - subComponentsHeight?.[index]?.rowId === rows[index]?.id - ) { - return rowHeight + (subComponentsHeight?.[index]?.subComponentHeight ?? 0); - } - return rowHeight; - }, - [rowHeight, rows, renderRowSubComponent, alwaysShowSubComponent, subComponentsHeight] - ), - overscan, - measureElement, - indexAttribute: 'data-virtual-row-index' - }); scrollToRef.current = { ...scrollToRef.current, scrollToOffset: rowVirtualizer.scrollToOffset, diff --git a/packages/main/src/components/AnalyticalTable/index.tsx b/packages/main/src/components/AnalyticalTable/index.tsx index 629b0eecc6a..572401a1423 100644 --- a/packages/main/src/components/AnalyticalTable/index.tsx +++ b/packages/main/src/components/AnalyticalTable/index.tsx @@ -149,6 +149,7 @@ const AnalyticalTable = forwardRef { + // Helpers + interface canvasHolderProps { + canvas?: HTMLCanvasElement; + } + const canvasHolder: canvasHolderProps = { canvas: undefined }; + // Text Width Analysis + function getTextWidth(text: string, font: string) { + // Reusing the canvas is more efficient + const canvas = canvasHolder.canvas || (canvasHolder.canvas = document.createElement('canvas')); + const context = canvas.getContext('2d'); + context.font = font; + const metrics = context.measureText(text); + return metrics.width; + } + + function getCssStyle(element: Element, prop: string) { + return window.getComputedStyle(element, null).getPropertyValue(prop); + } + + function getCanvasFont(el: Element = document.body) { + const fontWeight = getCssStyle(el, 'font-weight') || 'normal'; + const fontSize = getCssStyle(el, 'font-size') || '12px'; + const fontFamily = getCssStyle(el, 'font-family') || 'Times New Roman'; + + return `${fontWeight} ${fontSize} ${fontFamily}`; + } + + const findWidth = (text: string, el: Element) => { + let font: string | undefined; + if (!font) font = getCanvasFont(el); + return getTextWidth(text, font); + }; + // End Helpers + let largest = 0; + // Currently Including Overscan + const items = rowVirtualizer.getVirtualItems(); + const [start, end] = [items[0].index, items[items.length - 1].index]; + + for (let i = start; i < end; i++) { + // Use the classname for the span where the text lives AnalyticalTable.module.css.js + const collection = document.getElementsByClassName(clsx(classNames.tableText)); + const current = findWidth(rows[i].values[accessor], collection[0]); + largest = current > largest ? current : largest; + } + // Assign padding + largest = Math.ceil(largest + 20); + // Smallest column allowed is 60px + largest = largest < 60 ? 60 : largest; + onAutoResize( + enrichEventWithDetails(e, { + accessor, + width: largest + }) + ); + if (e.defaultPrevented) { + return; + } + dispatch({ + type: 'DOUBLE_CLICK_RESIZE', + payload: { [accessor]: largest } + }); + }; + + const measureElement = (el: HTMLElement) => { + return el.offsetHeight; + }; + + const overscan = overscanCount ? overscanCount : Math.floor(visibleRows / 2); + const rHeight = popInRowHeight !== internalRowHeight ? popInRowHeight : internalRowHeight; + + const itemCount = + Math.max( + minRows, + rows.length, + visibleRowCountMode === AnalyticalTableVisibleRowCountMode.AutoWithEmptyRows ? internalVisibleRowCount : 0 + ) + (!tableState.isScrollable ? additionalEmptyRowsCount : 0); + + const rowVirtualizer = useVirtualizer({ + count: itemCount, + getScrollElement: () => parentRef.current, + estimateSize: useCallback( + (index) => { + if ( + renderRowSubComponent && + (rows[index]?.isExpanded || alwaysShowSubComponent) && + tableState.subComponentsHeight?.[index]?.rowId === rows[index]?.id + ) { + return rHeight + (tableState.subComponentsHeight?.[index]?.subComponentHeight ?? 0); + } + return rHeight; + }, + [rHeight, rows, renderRowSubComponent, alwaysShowSubComponent, tableState.subComponentsHeight] + ), + overscan, + measureElement, + indexAttribute: 'data-virtual-row-index' + }); + return ( <>
) ); @@ -760,23 +861,11 @@ const AnalyticalTable = forwardRef )} @@ -870,7 +960,8 @@ AnalyticalTable.defaultProps = { isTreeTable: false, alternateRowColor: false, overscanCountHorizontal: 5, - visibleRowCountMode: AnalyticalTableVisibleRowCountMode.Fixed + visibleRowCountMode: AnalyticalTableVisibleRowCountMode.Fixed, + onAutoResize: () => {} }; export { AnalyticalTable }; diff --git a/packages/main/src/components/AnalyticalTable/tableReducer/stateReducer.ts b/packages/main/src/components/AnalyticalTable/tableReducer/stateReducer.ts index 215c5b77de6..57f35ac2ea0 100644 --- a/packages/main/src/components/AnalyticalTable/tableReducer/stateReducer.ts +++ b/packages/main/src/components/AnalyticalTable/tableReducer/stateReducer.ts @@ -67,6 +67,17 @@ export const stateReducer = (state, action, _prevState, instance) => { // fallback if the component wasn't ready yet for scrolling (elements are not initialized), e.g. when calling `.scrollToItem` on mount case 'TRIGGER_PROG_SCROLL': return { ...state, triggerScroll: payload }; + case 'DOUBLE_CLICK_RESIZE': + return { + ...state, + columnResizing: { + ...state.columnResizing, + columnWidths: { + ...state.columnResizing.columnWidths, + ...payload + } + } + }; default: return state; } diff --git a/packages/main/src/components/AnalyticalTable/types/index.ts b/packages/main/src/components/AnalyticalTable/types/index.ts index c4333db4aaf..b79f5ee86fc 100644 --- a/packages/main/src/components/AnalyticalTable/types/index.ts +++ b/packages/main/src/components/AnalyticalTable/types/index.ts @@ -257,7 +257,12 @@ export interface AnalyticalTableColumnDefinition { * Disable resizing for this column. */ disableResizing?: boolean; - + /** + * Defines whether double clicking a columns data-resizer will automatically resize the column. + * + * Available on text columns + */ + autoResizable?: boolean; // ui5 web components react properties /** * Horizontal alignment of the cell. @@ -676,6 +681,10 @@ export interface AnalyticalTablePropTypes extends Omit { * Fired when the body of the table is scrolled. */ onTableScroll?: (e?: CustomEvent<{ rows: Record[]; rowElements: HTMLCollection }>) => void; + /** + * Fired when the table is resized by double-clicking the data-resizer. + */ + onAutoResize?: (e?: CustomEvent<{ accessor: string; width: number }>) => void; // default components /** * Component that will be rendered when the table is not loading and has no data. From d5879a21dd4d066de77f16680df9ba402d2b8258 Mon Sep 17 00:00:00 2001 From: Papa-Santo <165276313+Papa-Santo@users.noreply.github.com> Date: Sun, 12 May 2024 08:35:55 -0400 Subject: [PATCH 2/8] Refactored code into useAutoResize hook. Changed pattern to resemble useDynamicColumnWidths. Detecting expandable rows and adjusting for the icon/button space. Removed support for isTreeTable and tables with strings in the groupBy array --- .../AnalyticalTable/AnalyticalTable.cy.tsx | 26 ++++- .../ColumnHeader/ColumnHeaderContainer.tsx | 16 ++- .../defaults/Column/Expandable.tsx | 7 +- .../AnalyticalTable/hooks/useAutoResize.tsx | 98 +++++++++++++++++++ .../hooks/useDynamicColumnWidths.ts | 2 +- .../src/components/AnalyticalTable/index.tsx | 71 ++------------ .../tableReducer/stateReducer.ts | 2 +- .../components/AnalyticalTable/types/index.ts | 1 + 8 files changed, 147 insertions(+), 76 deletions(-) create mode 100644 packages/main/src/components/AnalyticalTable/hooks/useAutoResize.tsx diff --git a/packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx b/packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx index bdcc8c68387..f1e3a30ff0a 100644 --- a/packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx +++ b/packages/main/src/components/AnalyticalTable/AnalyticalTable.cy.tsx @@ -245,7 +245,7 @@ describe('AnalyticalTable', () => { }); let dataFixed = data.map((el, i) => { - if (i === 2) return { ...el, name: 'Much Longer Name To Resize Larger For Testing A Larger Auto Resize' }; + if (i === 2) return { ...el, name: 'Longer Name Too' }; return el; }); @@ -261,7 +261,7 @@ describe('AnalyticalTable', () => { cy.get('[data-cy="data-resizer-1"]').should('be.visible').dblclick(); cy.get('[data-column-id="age"]').invoke('outerWidth').should('equal', 60); cy.get('[data-cy="data-resizer-0"]').should('be.visible').dblclick(); - cy.get('[data-column-id="name"]').invoke('outerWidth').should('equal', 451); + cy.get('[data-column-id="name"]').invoke('outerWidth').should('equal', 127); dataFixed = generateMoreData(200); @@ -284,7 +284,7 @@ describe('AnalyticalTable', () => { cy.get('[data-component-name="AnalyticalTableBody"]').scrollTo('bottom'); cy.get('[data-cy="data-resizer-0"]').should('be.visible').dblclick(); - cy.get('[data-column-id="name"]').invoke('outerWidth').should('equal', 94); + cy.get('[data-column-id="name"]').invoke('outerWidth').should('equal', 91); resizeColumns = columns.map((el) => { return { ...el, autoResizable: false }; @@ -296,6 +296,26 @@ describe('AnalyticalTable', () => { cy.get('[data-column-id="age"]').invoke('outerWidth').should('equal', 472.75); cy.get('[data-cy="data-resizer-0"]').should('be.visible').dblclick(); cy.get('[data-column-id="name"]').invoke('outerWidth').should('equal', 472.75); + + const dataSub = data.map((el, i) => { + if (i === 2) return { ...el, name: 'Longer Name Too' }; + return el; + }); + + resizeColumns = columns.map((el) => { + return { ...el, autoResizable: true }; + }); + + const renderRowSubComponent = () => { + return
SubComponent
; + }; + + cy.mount(); + cy.wait(200); + cy.get('[data-cy="data-resizer-1"]').should('be.visible').dblclick(); + cy.get('[data-column-id="age"]').invoke('outerWidth').should('equal', 60); + cy.get('[data-cy="data-resizer-0"]').should('be.visible').dblclick(); + cy.get('[data-column-id="name"]').invoke('outerWidth').should('equal', 165); }); it('scrollTo', () => { diff --git a/packages/main/src/components/AnalyticalTable/ColumnHeader/ColumnHeaderContainer.tsx b/packages/main/src/components/AnalyticalTable/ColumnHeader/ColumnHeaderContainer.tsx index 80fc78ed086..7f1cd7faeb7 100644 --- a/packages/main/src/components/AnalyticalTable/ColumnHeader/ColumnHeaderContainer.tsx +++ b/packages/main/src/components/AnalyticalTable/ColumnHeader/ColumnHeaderContainer.tsx @@ -17,7 +17,10 @@ interface ColumnHeaderContainerProps { columnVirtualizer: Virtualizer; uniqueId: string; showVerticalEndBorder: boolean; - onAutoResize: (e: React.MouseEvent, accessor: string) => void; + isTreeTable: boolean; + rowVirtualizer: Virtualizer; + onAutoResize: (e?: CustomEvent<{ accessor: string; width: number }>) => void; + groupBy: string[]; } export const ColumnHeaderContainer = forwardRef((props, ref) => { @@ -32,7 +35,10 @@ export const ColumnHeaderContainer = forwardRef { - if (column.autoResizable) { - onAutoResize(e, rest.id); + if (column.autoResizable && !isTreeTable && !groupBy.length) { + const items = rowVirtualizer.getVirtualItems(); + const [start, end] = [items[0].index, items[items.length - 1].index]; + column.getResizerProps().onDoubleClick(e, start, end, rest.id, onAutoResize); } }} /> diff --git a/packages/main/src/components/AnalyticalTable/defaults/Column/Expandable.tsx b/packages/main/src/components/AnalyticalTable/defaults/Column/Expandable.tsx index 9edbd2a3f37..0cb97c8ce1d 100644 --- a/packages/main/src/components/AnalyticalTable/defaults/Column/Expandable.tsx +++ b/packages/main/src/components/AnalyticalTable/defaults/Column/Expandable.tsx @@ -1,6 +1,6 @@ import iconNavDownArrow from '@ui5/webcomponents-icons/dist/navigation-down-arrow.js'; import iconNavRightArrow from '@ui5/webcomponents-icons/dist/navigation-right-arrow.js'; -import { CssSizeVariables, useCurrentTheme, useStylesheet } from '@ui5/webcomponents-react-base'; +import { CssSizeVariables, useCurrentTheme, useStylesheet, useIsomorphicId } from '@ui5/webcomponents-react-base'; import { clsx } from 'clsx'; import React from 'react'; import { ButtonDesign } from '../../../../enums/index.js'; @@ -24,7 +24,7 @@ const getPadding = (level) => { export const Expandable = (props) => { const { cell, row, column, visibleColumns: columns, webComponentsReactProperties } = props; - const { renderRowSubComponent, alwaysShowSubComponent, translatableTexts } = webComponentsReactProperties; + const { renderRowSubComponent, alwaysShowSubComponent, translatableTexts, uniqueId } = webComponentsReactProperties; const currentTheme = useCurrentTheme(); useStylesheet(styleData, Expandable.displayName); const shouldRenderButton = currentTheme === 'sap_horizon' || currentTheme === 'sap_horizon_dark'; @@ -41,6 +41,8 @@ export const Expandable = (props) => { const subComponentExpandable = typeof renderRowSubComponent === 'function' && !!renderRowSubComponent(row) && !alwaysShowSubComponent; + const expandId = useIsomorphicId(); + return ( <> {columnIndex === 0 && ( @@ -53,6 +55,7 @@ export const Expandable = (props) => { className={classNames.container} aria-expanded={row.isExpanded} aria-label={row.isExpanded ? translatableTexts.collapseA11yText : translatableTexts.expandA11yText} + id={`scaleModeHelperExpand-${uniqueId}-${expandId}`} > {shouldRenderButton ? (