diff --git a/package.json b/package.json index f4f45465a..d92ab4f94 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "private::test.python": "python -m unittest tests/unit/format_test.py", "private::test.unit": "cypress run --browser chrome --spec 'tests/cypress/tests/unit/**/*'", "private::test.standalone": "cypress run --browser chrome --spec 'tests/cypress/tests/standalone/**/*'", - "build.watch": "webpack-dev-server --content-base dash_table --mode development --config webpack.dev.config.js", + "build.watch": "webpack-dev-server --disable-host-check --content-base dash_table --mode development --config webpack.dev.config.js", "build": "run-s private::build:js private::build:py", "postbuild": "es-check es5 dash_table/*.js", "format": "run-s private::format.*", diff --git a/src/dash-table/components/ControlledTable/index.tsx b/src/dash-table/components/ControlledTable/index.tsx index c6a179cab..450cd8643 100644 --- a/src/dash-table/components/ControlledTable/index.tsx +++ b/src/dash-table/components/ControlledTable/index.tsx @@ -39,6 +39,8 @@ import reconcile from 'dash-table/type/reconcile'; import PageNavigation from 'dash-table/components/PageNavigation'; +type Refs = { [key: string]: HTMLElement }; + const DEFAULT_STYLE = { width: '100%' }; @@ -48,9 +50,6 @@ const INNER_STYLE = { minWidth: '100%' }; -const WIDTH_EPSILON = 0.5; -const MAX_WIDTH_ITERATIONS = 30; - export default class ControlledTable extends PureComponent { private readonly menuRef = React.createRef(); private readonly stylesheet: Stylesheet = new Stylesheet(`#${CSS.escape(this.props.id)}`); @@ -96,7 +95,7 @@ export default class ControlledTable extends PureComponent return; } - const { r1c1 } = this.refs as { [key: string]: HTMLElement }; + const { r1c1 } = this.refs as Refs; let parent: any = r1c1.parentElement; if (uiViewport && @@ -150,7 +149,33 @@ export default class ControlledTable extends PureComponent componentDidUpdate() { this.updateStylesheet(); this.updateUiViewport(); - this.handleResize(); + + const { + style_as_list_view, + style_cell, + style_cell_conditional, + style_data, + style_data_conditional, + style_filter, + style_filter_conditional, + style_header, + style_header_conditional, + style_table + } = this.props; + + this.handleResizeIf( + style_as_list_view, + style_cell, + style_cell_conditional, + style_data, + style_data_conditional, + style_filter, + style_filter_conditional, + style_header, + style_header_conditional, + style_table + ); + this.handleDropdown(); this.adjustTooltipPosition(); @@ -168,7 +193,7 @@ export default class ControlledTable extends PureComponent return; } - const { r1c1 } = this.refs as { [key: string]: HTMLElement }; + const { r1c1 } = this.refs as Refs; const contentTd = r1c1.querySelector('tr > td:first-of-type'); if (!contentTd) { @@ -225,119 +250,184 @@ export default class ControlledTable extends PureComponent } } - forceHandleResize = () => this.handleResize(true); + private clearCellWidth(cell: HTMLElement) { + cell.style.width = ''; + cell.style.minWidth = ''; + cell.style.maxWidth = ''; + cell.style.boxSizing = ''; + } - handleResize = (force: boolean = false) => { - const { - fixed_columns, - fixed_rows, - forcedResizeOnly, - setState - } = this.props; + private resetFragmentCells = ( + fragment: HTMLElement + ) => { + const lastRowOfCells = fragment.querySelectorAll('table.cell-table > tbody > tr:last-of-type > *'); + if (!lastRowOfCells.length) { + return; + } - if (forcedResizeOnly && !force) { + Array.from( + lastRowOfCells + ).forEach(this.clearCellWidth); + + const firstThs = Array.from(fragment.querySelectorAll('table.cell-table > tbody > tr > th:first-of-type')); + const trOfThs = firstThs.map(th => th.parentElement); + + trOfThs.forEach(tr => { + const ths = Array.from(tr?.children as any); + + if (!ths) { + return; + } + + ths.forEach(this.clearCellWidth); + }); + } + + resizeFragmentCells = ( + fragment: HTMLElement, + widths: number[] + ) => { + const lastRowOfCells = fragment.querySelectorAll('table.cell-table > tbody > tr:last-of-type > *'); + if (!lastRowOfCells.length) { return; } - if (!force) { - setState({ forcedResizeOnly: true }); + Array.from( + lastRowOfCells + ).forEach((c, i) => this.setCellWidth(c, widths[i])); + + const firstThs = Array.from(fragment.querySelectorAll('table.cell-table > tbody > tr > th:first-of-type')); + const trOfThs = firstThs.map(th => th.parentElement); + + trOfThs.forEach(tr => { + const ths = Array.from(tr?.children as any); + + if (!ths) { + return; + } + if (ths.length === widths.length) { + ths.forEach((c, i) => this.setCellWidth(c, widths[i])); + } else { + ths.forEach(c => this.setCellWidth(c, 0)); + } + }); + } + + resizeFragmentTable = (table: HTMLElement | null, width: string) => { + if (!table) { + return; } - this.updateStylesheet(); + table.style.width = width; + } - getScrollbarWidth().then((scrollbarWidth: number) => setState({ scrollbarWidth })); + isDisplayed = (el: HTMLElement) => getComputedStyle(el).display !== 'none'; - const { r0c0, r0c1, r1c0, r1c1 } = this.refs as { [key: string]: HTMLElement }; + forceHandleResize = () => this.handleResize(); + handleResizeIf = memoizeOne((..._: any[]) => { + const { r0c0, r0c1, r1c0, r1c1 } = this.refs as Refs; - // Adjust [fixed columns/fixed rows combo] to fixed rows height - let trs = r0c1.querySelectorAll('tr'); - Array.from(r0c0.querySelectorAll('tr')).forEach((tr, index) => { - const tr2 = trs[index]; + if (!this.isDisplayed(r1c1)) { + return; + } - tr.style.height = `${tr2.clientHeight}px`; - }); + r0c1.style.marginLeft = ''; + r1c1.style.marginLeft = ''; + r0c0.style.width = ''; + r1c0.style.width = ''; - // Adjust fixed columns headers to header's height - let trths = r1c1.querySelectorAll('tr > th:first-of-type'); - Array.from(r1c0.querySelectorAll('tr > th:first-of-type')).forEach((th, index) => { - const tr2 = trths[index].parentElement as HTMLElement; - const tr = th.parentElement as HTMLElement; + [r0c0, r0c1, r1c0].forEach(rc => { + const table = rc.querySelector('table'); + if (table) { + table.style.width = ''; + } - tr.style.height = getComputedStyle(tr2).height; + this.resetFragmentCells(rc); }); - if (fixed_columns) { - const r1c0Table = r1c0.querySelector('table') as HTMLElement; - const r1c1Table = r1c0.querySelector('table') as HTMLElement; + this.handleResize(); + }); - r1c0Table.style.width = getComputedStyle(r1c1Table).width; + handleResize = (previousWidth: number = NaN, cycle: boolean = false) => { + const { + fixed_columns, + fixed_rows, + setState + } = this.props; - const lastVisibleTd = r1c0.querySelector(`tr:first-of-type > *:nth-of-type(${fixed_columns})`); + const { r1c1 } = this.refs as Refs; - let it = 0; - let currentWidth = r1c0.getBoundingClientRect().width; - let lastWidth = currentWidth; + if (!this.isDisplayed(r1c1)) { + return; + } - do { - lastWidth = currentWidth + this.updateStylesheet(); - // Force first column containers width to match visible portion of table - if (lastVisibleTd) { - const r1c0FragmentBounds = r1c0.getBoundingClientRect(); - const lastTdBounds = lastVisibleTd.getBoundingClientRect(); - currentWidth = lastTdBounds.right - r1c0FragmentBounds.left; + getScrollbarWidth().then((scrollbarWidth: number) => setState({ scrollbarWidth })); - const width = `${currentWidth}px`; + const { r0c0, r0c1, r1c0 } = this.refs as Refs; - r0c0.style.width = width; - r1c0.style.width = width; - } + const r0c0Table = r0c0.querySelector('table'); + const r0c1Table = r0c1.querySelector('table'); + const r1c0Table = r1c0.querySelector('table'); + const r1c1Table = r1c1.querySelector('table') as HTMLElement; - // Force second column containers width to match visible portion of table - const firstVisibleTd = r1c1.querySelector(`tr:first-of-type > *:nth-of-type(${fixed_columns + 1})`); - if (firstVisibleTd) { - const r1c1FragmentBounds = r1c1.getBoundingClientRect(); - const firstTdBounds = firstVisibleTd.getBoundingClientRect(); - - const width = firstTdBounds.left - r1c1FragmentBounds.left; - r0c1.style.marginLeft = `-${width}px`; - r0c1.style.marginRight = `${width}px`; - r1c1.style.marginLeft = `-${width}px`; - r1c1.style.marginRight = `${width}px`; - } + const currentTableWidth = getComputedStyle(r1c1Table).width; - it++; - } while ( - Math.abs(currentWidth - lastWidth) > WIDTH_EPSILON || - it < MAX_WIDTH_ITERATIONS - ) + if (!cycle) { + this.resizeFragmentTable(r0c0Table, currentTableWidth); + this.resizeFragmentTable(r0c1Table, currentTableWidth); + this.resizeFragmentTable(r1c0Table, currentTableWidth); } if (fixed_columns || fixed_rows) { - const r1c0CellWidths = Array.from( - r1c0.querySelectorAll('table.cell-table > tbody > tr:first-of-type > *') - ).map(c => c.getBoundingClientRect().width); - - const r1c1CellWidths = Array.from( + const widths = Array.from( r1c1.querySelectorAll('table.cell-table > tbody > tr:first-of-type > *') ).map(c => c.getBoundingClientRect().width); - Array.from( - r0c0.querySelectorAll('table.cell-table > tbody > tr:first-of-type > *') - ).forEach((c, i) => this.setCellWidth(c, r1c0CellWidths[i])); + if (!cycle) { + this.resizeFragmentCells(r0c0, widths); + this.resizeFragmentCells(r0c1, widths); + this.resizeFragmentCells(r1c0, widths); + } + } + + if (fixed_columns) { + const lastFixedTd = r1c1.querySelector(`tr:first-of-type > *:nth-of-type(${fixed_columns})`); + if (lastFixedTd) { + const lastFixedTdBounds = lastFixedTd.getBoundingClientRect(); + const lastFixedTdRight = lastFixedTdBounds.right - r1c1.getBoundingClientRect().left; - Array.from( - r0c0.querySelectorAll('table.cell-table > tbody > tr:last-of-type > *') - ).forEach((c, i) => this.setCellWidth(c, r1c0CellWidths[i])); + // Force first column containers width to match visible portion of table + r0c0.style.width = `${lastFixedTdRight}px`; + r1c0.style.width = `${lastFixedTdRight}px`; + + if (!cycle) { + // Force second column containers width to match visible portion of table + const firstVisibleTd = r1c1.querySelector(`tr:first-of-type > *:nth-of-type(${fixed_columns + 1})`); + if (firstVisibleTd) { + const r1c1FragmentBounds = r1c1.getBoundingClientRect(); + const firstTdBounds = firstVisibleTd.getBoundingClientRect(); + + const width = firstTdBounds.left - r1c1FragmentBounds.left; + r0c1.style.marginLeft = `-${width}px`; + r1c1.style.marginLeft = `-${width}px`; + } + } + } + } - Array.from( - r0c1.querySelectorAll('table.cell-table > tbody > tr:first-of-type > *') - ).forEach((c, i) => this.setCellWidth(c, r1c1CellWidths[i])); + if (!cycle) { + const currentWidth = parseInt(currentTableWidth, 10); + const nextWidth = parseInt(getComputedStyle(r1c1Table).width, 10); - Array.from( - r0c1.querySelectorAll('table.cell-table > tbody > tr:last-of-type > *') - ).forEach((c, i) => this.setCellWidth(c, r1c1CellWidths[i])); + // If the table was resized and isn't in a cycle, re-run `handleResize`. + // If the final size is the same as the starting size from the previous iteration, do not + // resize the main table, instead just use as is, otherwise it will oscillate. + if (nextWidth !== currentWidth) { + this.handleResize(currentWidth, nextWidth === previousWidth); + } } } @@ -699,17 +789,17 @@ export default class ControlledTable extends PureComponent } handleDropdown = () => { - const { r1c1 } = this.refs as { [key: string]: HTMLElement }; + const { r1c1 } = this.refs as Refs; dropdownHelper(r1c1.querySelector('.Select-menu-outer')); } onScroll = (ev: any) => { - const { r0c0, r0c1 } = this.refs as { [key: string]: HTMLElement }; + const { r0c0, r0c1 } = this.refs as Refs; Logger.trace(`ControlledTable fragment scrolled to (left,top)=(${ev.target.scrollLeft},${ev.target.scrollTop})`); - const margin = parseFloat(ev.target.scrollLeft) + parseFloat(r0c0.style.width); + const margin = parseFloat(ev.target.scrollLeft) + (parseFloat(r0c0.style.width) || 0); r0c1.style.marginLeft = `${-margin}px`; @@ -945,23 +1035,15 @@ export default class ControlledTable extends PureComponent } } - private setCellWidth(cell: HTMLElement, width: number) { - cell.style.width = `${width}px`; - cell.style.minWidth = `${width}px`; - cell.style.maxWidth = `${width}px`; - cell.style.boxSizing = 'border-box'; - - /** - * Some browsers handle `th` and `td` size inconsistently. - * Checking the size delta and adjusting for it (different handling of padding and borders) - * allows the table to make sure all sections are correctly aligned. - */ - const delta = cell.getBoundingClientRect().width - width; - if (delta) { - cell.style.width = `${width - delta}px`; - cell.style.minWidth = `${width - delta}px`; - cell.style.maxWidth = `${width - delta}px`; + private setCellWidth(cell: HTMLElement, width: string | number) { + if (typeof width === 'number') { + width = `${width}px`; } + + cell.style.width = width; + cell.style.minWidth = width; + cell.style.maxWidth = width; + cell.style.boxSizing = 'border-box'; } private get showToggleColumns(): boolean { diff --git a/src/dash-table/components/Table/index.tsx b/src/dash-table/components/Table/index.tsx index d9eeecf52..c04e47950 100644 --- a/src/dash-table/components/Table/index.tsx +++ b/src/dash-table/components/Table/index.tsx @@ -35,7 +35,6 @@ export default class Table extends Component table > tbody > tr:last-of-type > *" + ) + table_r0c0_cells = table.find_elements_by_css_selector( + ".cell-0-0 > table > tbody > tr:last-of-type > *" + ) + table_r0c1_cells = table.find_elements_by_css_selector( + ".cell-0-1 > table > tbody > tr:last-of-type > *" + ) + table_r1c0_cells = table.find_elements_by_css_selector( + ".cell-1-0 > table > tbody > tr:last-of-type > *" + ) + table_r1c1_cells = table.find_elements_by_css_selector( + ".cell-1-1 > table > tbody > tr:last-of-type > *" + ) + + # this test is very dependent on the table's implementation details.. we are testing that all the cells are + # the same width after all.. + + # make sure the r1c1 fragment contains all the cells + assert len(target_cells) == len(table_r1c1_cells) + + # for each cell of each fragment, allow a difference of up to 1px either way since + # the resize algorithm can be off by 1px for cycles + for i, target_cell in enumerate(target_cells): + assert abs(target_cell.size["width"] - table_r1c1_cells[i].size["width"]) <= 1 + + if len(table_r0c0_cells) != 0: + assert abs(target_cell.size["width"] - table_r0c0_cells[i].size["width"]) <= 1 + + if len(table_r0c1_cells) != 0: + assert abs(target_cell.size["width"] - table_r0c1_cells[i].size["width"]) <= 1 + + if len(table_r1c0_cells) != 0: + assert abs(target_cell.size["width"] - table_r1c0_cells[i].size["width"]) <= 1 + + +def test_szng001_widths_on_style_change(test): + base_props = dict( + data=[ + {"a": 85, "b": 601, "c": 891}, + {"a": 967, "b": 189, "c": 514}, + {"a": 398, "b": 262, "c": 743}, + {"a": 89, "b": 560, "c": 582}, + {"a": 809, "b": 591, "c": 511}, + ], + columns=[ + {"id": "a", "name": "A"}, + {"id": "b", "name": "B"}, + {"id": "c", "name": "C"}, + ], + style_data={ + "width": 100, + "minWidth": 100, + "maxWidth": 100, + "border": "1px solid blue", + }, + row_selectable="single", + row_deletable=True, + ) + + styles = [ + dict( + style_table=dict( + width=500, minWidth=500, maxWidth=500, paddingBottom=10, display="none" + ) + ), + dict(style_table=dict(width=500, minWidth=500, maxWidth=500, paddingBottom=10)), + dict(style_table=dict(width=750, minWidth=750, maxWidth=750, paddingBottom=10)), + dict( + style_table=dict( + width=750, minWidth=750, maxWidth=750, paddingBottom=10, display="none" + ) + ), + dict(style_table=dict(width=350, minWidth=350, maxWidth=350, paddingBottom=10)), + ] + + fixes = [ + dict(), + dict(fixed_columns=dict(headers=True)), + dict(fixed_rows=dict(headers=True)), + dict(fixed_columns=dict(headers=True), fixed_rows=dict(headers=True)), + dict(fixed_columns=dict(headers=True, data=1)), + dict(fixed_rows=dict(headers=True, data=1)), + dict( + fixed_columns=dict(headers=True, data=1), + fixed_rows=dict(headers=True, data=1), + ), + ] + + variations = [] + style = styles[0] + i = 0 + for fix in fixes: + variations.append({**style, **fix, **base_props, "id": "table{}".format(i)}) + i = i + 1 + + variations_range = range(0, len(variations)) + + tables = [DataTable(**variation) for variation in variations] + + app = dash.Dash(__name__) + app.layout = Div( + children=[ + Button(id="btn", children="Click me"), + Div( + [ + DataTable( + **base_props, + id="table{}".format(width), + style_table=dict( + width=width, + minWidth=width, + maxWidth=width, + paddingBottom=10, + ) + ) + for width in [350, 500, 750] + ] + ), + Div(tables), + ] + ) + + @app.callback( + [Output("table{}".format(i), "style_table") for i in variations_range], + [Input("btn", "n_clicks")], + prevent_initial_call=True, + ) + def update_styles(n_clicks): + if n_clicks < len(styles): + style_table = styles[n_clicks]["style_table"] + return [style_table for i in variations_range] + + test.start_server(app) + + for style in styles: + display = style.get("style_table", dict()).get("display") + width = style.get("style_table", dict()).get("width") + target = ( + test.driver.find_element_by_css_selector("#table{}".format(width)) + if display != "none" + else None + ) + + for variation in variations: + table = test.driver.find_element_by_css_selector( + "#{}".format(variation["id"]) + ) + if target is None: + assert table is not None + assert ( + test.driver.execute_script( + "return getComputedStyle(document.querySelector('#{} .dash-spreadsheet-container')).display".format( + variation["id"] + ) + ) + == "none" + ) + else: + cells_are_same_width(target, table) + + test.driver.find_element_by_css_selector("#btn").click() + + +def test_szng002_percentages_result_in_same_widths(test): + base_props = dict( + columns=[ + {"id": "_", "name": ["_", "_", "_"]}, + { + "id": "a", + "name": [ + "A-----------------VERY LONG", + "A-----------------VERY LONG", + "A-----------------VERY LONG", + ], + }, + { + "id": "b", + "name": [ + "A-----------------VERY LONG", + "A-----------------VERY LONG", + "B-----------------VERY LONG", + ], + }, + { + "id": "c", + "name": [ + "A-----------------VERY LONG---", + "B-----------------VERY LONG---------", + "C-----------------VERY LONG------------------", + ], + }, + ], + data=[ + {"_": 0, "a": 85, "b": 601, "c": 891}, + {"_": 0, "a": 967, "b": 189, "c": 514}, + {"_": 0, "a": 398, "b": 262, "c": 743}, + {"_": 0, "a": 89, "b": 560, "c": 582}, + {"_": 0, "a": 809, "b": 591, "c": 511}, + ], + style_table=dict(width=500, minWidth=500, maxWidth=500, paddingBottom=10), + style_cell=dict(width="25%", minWidth="25%", maxWidth="25%"), + ) + + _fixed_columns = [dict(headers=True, data=1), dict(headers=True)] + + _fixed_rows = [dict(headers=True, data=1), dict(headers=True)] + + _merge_duplicate_headers = [True, False] + + variations = [] + i = 0 + for fixed_columns in _fixed_columns: + for fixed_rows in _fixed_rows: + for merge_duplicate_headers in _merge_duplicate_headers: + variations.append( + { + **base_props, + "fixed_columns": fixed_columns, + "fixed_rows": fixed_rows, + "merge_duplicate_headers": merge_duplicate_headers, + "id": "table{}".format(i), + } + ) + i = i + 1 + + tables = [DataTable(**variation) for variation in variations] + + app = dash.Dash(__name__) + app.layout = Div(tables) + + test.start_server(app) + + target = test.driver.find_element_by_css_selector("#table0") + cells_are_same_width(target, target) + + for i in range(1, len(variations)): + table = test.driver.find_element_by_css_selector("#table{}".format(i)) + cells_are_same_width(target, table)