diff --git a/src/dash-table/components/ControlledTable/index.tsx b/src/dash-table/components/ControlledTable/index.tsx index cd3f8a2dc..0e0c87124 100644 --- a/src/dash-table/components/ControlledTable/index.tsx +++ b/src/dash-table/components/ControlledTable/index.tsx @@ -887,9 +887,10 @@ export default class ControlledTable extends PureComponent< scrollbarWidth, style_as_list_view, style_table, + tooltip_data, tooltip_delay, tooltip_duration, - tooltip_data, + tooltip_header, uiCell, uiHeaders, uiViewport, @@ -952,6 +953,7 @@ export default class ControlledTable extends PureComponent< const tableTooltip = derivedTooltips( currentTooltip, tooltip_data, + tooltip_header, tooltip_conditional, tooltip, virtualized, @@ -1155,15 +1157,21 @@ export default class ControlledTable extends PureComponent< return; } - const id = currentTooltip.id; - const row = currentTooltip.row; + const {id, row, header} = currentTooltip; const {table, tooltip: t} = this.refs as {[key: string]: any}; if (t) { - const cell = table.querySelector( - `td[data-dash-column="${id}"][data-dash-row="${row}"]:not(.phantom-cell)` - ); + const cell = header + ? table.querySelector( + `tr:nth-of-type(${ + row + 1 + }) th[data-dash-column="${id}"]:not(.phantom-cell)` + ) + : table.querySelector( + `td[data-dash-column="${id}"][data-dash-row="${row}"]:not(.phantom-cell)` + ); + (this.refs.tooltip as TableTooltip).updateBounds(cell); } } diff --git a/src/dash-table/components/HeaderFactory.tsx b/src/dash-table/components/HeaderFactory.tsx index fb014d850..4c0c4361b 100644 --- a/src/dash-table/components/HeaderFactory.tsx +++ b/src/dash-table/components/HeaderFactory.tsx @@ -22,7 +22,7 @@ export default class HeaderFactory { private readonly headerOperations = derivedHeaderOperations(); private readonly headerStyles = derivedHeaderStyles(); private readonly headerOpStyles = derivedHeaderOpStyles(); - private readonly headerWrappers = derivedHeaderWrappers(); + private readonly headerWrappers = derivedHeaderWrappers(() => this.props); private readonly relevantStyles = derivedRelevantHeaderStyles(); private readonly labelsAndIndices = derivedLabelsAndIndices(); @@ -95,7 +95,7 @@ export default class HeaderFactory { relevantStyles ); - const wrappers = this.headerWrappers( + const wrappers = this.headerWrappers.get( visibleColumns, labelsAndIndices, merge_duplicate_headers diff --git a/src/dash-table/components/Table/props.ts b/src/dash-table/components/Table/props.ts index 77527a682..ebe56ec7b 100644 --- a/src/dash-table/components/Table/props.ts +++ b/src/dash-table/components/Table/props.ts @@ -209,7 +209,8 @@ export interface ILoadingState { export type ConditionalDropdowns = IConditionalDropdown[]; export type DataDropdowns = Partial[]; -export type DataTooltips = Partial[]; +export type DataTooltips = Partial[]; + export type StaticDropdowns = Partial; export type Fixed = {headers: false; data?: 0} | {headers: true; data?: number}; @@ -246,10 +247,14 @@ export interface IStaticDropdowns { [key: string]: IDropdown; } -export interface ITableTooltips { +export interface ITableDataTooltips { [key: string]: Tooltip; } +export interface ITableHeaderTooltips { + [key: string]: (Tooltip | null)[] | Tooltip; +} + export interface ITableStaticTooltips { [key: string]: Tooltip; } @@ -273,6 +278,7 @@ export interface IUserInterfaceViewport { export interface IUSerInterfaceTooltip { delay?: number; duration?: number; + header: boolean; id: ColumnId; row: number; } @@ -303,11 +309,12 @@ export interface IProps { id: string; + tooltip: ITableStaticTooltips; + tooltip_conditional: ConditionalTooltip[]; tooltip_data?: DataTooltips; tooltip_delay: number | null; tooltip_duration: number | null; - tooltip: ITableStaticTooltips; - tooltip_conditional: ConditionalTooltip[]; + tooltip_header?: ITableHeaderTooltips; active_cell?: ICellCoordinates; cell_selectable?: boolean; @@ -393,6 +400,7 @@ interface IDefaultProps { start_cell: ICellCoordinates; style_as_list_view: boolean; tooltip_data: DataTooltips; + tooltip_header: ITableHeaderTooltips; page_action: TableAction; page_current: number; @@ -528,6 +536,7 @@ export interface ICellFactoryProps { style_header_conditional: Headers; style_table: Table; tooltip_data: DataTooltips; + tooltip_header: ITableHeaderTooltips; uiCell?: IUserInterfaceCell; uiViewport?: IUserInterfaceViewport; viewport: IDerivedData; diff --git a/src/dash-table/dash/DataTable.js b/src/dash-table/dash/DataTable.js index 8bad3290f..bf52897b2 100644 --- a/src/dash-table/dash/DataTable.js +++ b/src/dash-table/dash/DataTable.js @@ -77,6 +77,7 @@ export const defaultProps = { tooltip: {}, tooltip_conditional: [], tooltip_data: [], + tooltip_header: {}, tooltip_delay: 350, tooltip_duration: 2000, @@ -843,6 +844,9 @@ export const propTypes = { * or `text`. Defaults to `text`. * The `value` refers to the syntax-based content of * the tooltip. This value is required. + * The `use_with` refers to whether the tooltip will be shown + * only on data or headers. Can be `both`, `data`, `header`. + * Defaults to `both`. * The `delay` represents the delay in milliseconds before * the tooltip is shown when hovering a cell. This overrides * the table's `tooltip_delay` property. If set to `null`, @@ -857,13 +861,14 @@ export const propTypes = { */ tooltip: PropTypes.objectOf( PropTypes.oneOfType([ + PropTypes.string, PropTypes.exact({ delay: PropTypes.number, duration: PropTypes.number, type: PropTypes.oneOf(['text', 'markdown']), + use_with: PropTypes.oneOf(['both', 'data', 'header']), value: PropTypes.string.isRequired - }), - PropTypes.string + }) ]) ), @@ -921,9 +926,9 @@ export const propTypes = { /** * `tooltip_data` represents the tooltip shown * for different columns and cells. - * The `property` name refers to the column ID. Each property - * contains a list of tooltips mapped to the source `data` - * row index. + * A list of objects for which each key is + * a column id and the value a tooltip configuration. + * For each tooltip configuration, * The `type` refers to the type of tooltip syntax used * for the tooltip generation. Can either be `markdown` * or `text`. Defaults to `text`. @@ -955,6 +960,55 @@ export const propTypes = { ) ), + /** + * `tooltip_header` represents the tooltip shown + * for different columns and cells. + * An object for which each key is a column id and the value + * a list of tooltip configurations or a toolttip configuration. + * If the value is tooltip configuration, the same tooltip will + * be used for all header rows of the corresponding column. + * For each tooltip configuration, + * The `type` refers to the type of tooltip syntax used + * for the tooltip generation. Can either be `markdown` + * or `text`. Defaults to `text`. + * The `value` refers to the syntax-based content of + * the tooltip. This value is required. + * The `delay` represents the delay in milliseconds before + * the tooltip is shown when hovering a cell. This overrides + * the table's `tooltip_delay` property. If set to `null`, + * the tooltip will be shown immediately. + * The `duration` represents the duration in milliseconds + * during which the tooltip is shown when hovering a cell. + * This overrides the table's `tooltip_duration` property. + * If set to `null`, the tooltip will not disappear. + * Alternatively, the value of the property can also be + * a plain string or a nully value to . The `text` syntax will be used in + * that case. + */ + tooltip_header: PropTypes.objectOf( + PropTypes.oneOfType([ + PropTypes.string, + PropTypes.exact({ + delay: PropTypes.number, + duration: PropTypes.number, + type: PropTypes.oneOf(['text', 'markdown']), + value: PropTypes.string.isRequired + }), + PropTypes.arrayOf( + PropTypes.oneOfType([ + PropTypes.nully, + PropTypes.string, + PropTypes.exact({ + delay: PropTypes.number, + duration: PropTypes.number, + type: PropTypes.oneOf(['text', 'markdown']), + value: PropTypes.string.isRequired + }) + ]) + ) + ]) + ), + /** * `tooltip_delay` represents the table-wide delay in milliseconds before * the tooltip is shown when hovering a cell. If set to `null`, the tooltip diff --git a/src/dash-table/derived/cell/eventHandlerProps.ts b/src/dash-table/derived/cell/eventHandlerProps.ts index d6397883e..58f5141d7 100644 --- a/src/dash-table/derived/cell/eventHandlerProps.ts +++ b/src/dash-table/derived/cell/eventHandlerProps.ts @@ -6,8 +6,10 @@ import { handleClick, handleDoubleClick, handleEnter, + handleEnterHeader, handleLeave, handleMove, + handleMoveHeader, handleOnMouseUp, handlePaste } from 'dash-table/handlers/cellEvents'; @@ -17,8 +19,10 @@ export enum Handler { Click = 'click', DoubleClick = 'doubleclick', Enter = 'enter', + EnterHeader = 'enterheader', Leave = 'leave', Move = 'move', + MoveHeader = 'moveheader', MouseUp = 'mouseup', Paste = 'paste' } @@ -60,6 +64,13 @@ class EventHandler { rowIndex, columnIndex ); + case Handler.EnterHeader: + return handleEnterHeader.bind( + undefined, + this.propsFn, + rowIndex, + columnIndex + ); case Handler.Leave: return handleLeave.bind( undefined, @@ -74,6 +85,13 @@ class EventHandler { rowIndex, columnIndex ); + case Handler.MoveHeader: + return handleMoveHeader.bind( + undefined, + this.propsFn, + rowIndex, + columnIndex + ); case Handler.MouseUp: return handleOnMouseUp.bind( undefined, diff --git a/src/dash-table/derived/header/wrappers.tsx b/src/dash-table/derived/header/wrappers.tsx index f35dde315..0a09f0ea9 100644 --- a/src/dash-table/derived/header/wrappers.tsx +++ b/src/dash-table/derived/header/wrappers.tsx @@ -1,47 +1,85 @@ import * as R from 'ramda'; import React from 'react'; -import {memoizeOneFactory} from 'core/memoizer'; +import memoizerCache from 'core/cache/memoizer'; +import derivedCellEventHandlerProps, { + Handler +} from 'dash-table/derived/cell/eventHandlerProps'; +import { + ColumnId, + Columns, + ICellFactoryProps +} from 'dash-table/components/Table/props'; -import {Columns} from 'dash-table/components/Table/props'; +class Wrappers { + constructor( + propsFn: () => ICellFactoryProps, + private readonly handlers = derivedCellEventHandlerProps(propsFn) + ) {} -function getter( - columns: Columns, - labelsAndIndices: R.KeyValuePair[], - mergeHeaders: boolean -): JSX.Element[][] { - return R.map(([labels, indices]) => { - return R.addIndex(R.map)((columnIndex, index) => { - const column = columns[columnIndex]; + get = ( + columns: Columns, + labelsAndIndices: R.KeyValuePair[], + mergeHeaders: boolean + ): JSX.Element[][] => + labelsAndIndices.map(([labels, indices], rowIndex) => + indices.map((columnIndex, i) => { + const column = columns[columnIndex]; - let colSpan: number; - if (!mergeHeaders) { - colSpan = 1; - } else { - if (columnIndex === R.last(indices)) { - colSpan = labels.length - columnIndex; + let colSpan: number; + if (!mergeHeaders) { + colSpan = 1; } else { - colSpan = indices[index + 1] - columnIndex; + if (columnIndex === R.last(indices)) { + colSpan = labels.length - columnIndex; + } else { + colSpan = indices[i + 1] - columnIndex; + } } - } - return ( - - ); - }, indices); - }, labelsAndIndices); + return this.wrapper.get(rowIndex, columnIndex)( + columnIndex, + column.id, + colSpan, + columnIndex === columns.length - 1 || + columnIndex === R.last(indices), + this.handlers(Handler.EnterHeader, rowIndex, columnIndex), + this.handlers(Handler.Leave, rowIndex, columnIndex), + this.handlers(Handler.MoveHeader, rowIndex, columnIndex) + ); + }) + ); + + /** + * Returns the wrapper for a header cell. + */ + private wrapper = memoizerCache< + [number, number] + >()( + ( + columnIndex: number, + columnId: ColumnId, + colSpan: number, + lastIndex: boolean, + onEnter: (e: MouseEvent) => void, + onLeave: (e: MouseEvent) => void, + onMove: (e: MouseEvent) => void + ) => ( + + ) + ); } -export default memoizeOneFactory(getter); +export default (propsFn: () => ICellFactoryProps) => new Wrappers(propsFn); diff --git a/src/dash-table/derived/table/tooltip.ts b/src/dash-table/derived/table/tooltip.ts index b0168d3ff..54620dea9 100644 --- a/src/dash-table/derived/table/tooltip.ts +++ b/src/dash-table/derived/table/tooltip.ts @@ -2,12 +2,18 @@ import * as R from 'ramda'; import { IUSerInterfaceTooltip, + ITableHeaderTooltips, ITableStaticTooltips, IVirtualizedDerivedData, DataTooltips } from 'dash-table/components/Table/props'; import {ifColumnId, ifRowIndex, ifFilter} from 'dash-table/conditional'; -import {ConditionalTooltip, TooltipSyntax} from 'dash-table/tooltips/props'; +import { + ConditionalTooltip, + TooltipUsage, + TooltipSyntax, + Tooltip +} from 'dash-table/tooltips/props'; import {memoizeOne} from 'core/memoizer'; // 2^32-1 the largest value setTimout can take safely @@ -16,6 +22,7 @@ export const MAX_32BITS = 2147483647; function getSelectedTooltip( currentTooltip: IUSerInterfaceTooltip, tooltip_data: DataTooltips, + tooltip_header: ITableHeaderTooltips, tooltip_conditional: ConditionalTooltip[], tooltip_static: ITableStaticTooltips, virtualized: IVirtualizedDerivedData @@ -24,34 +31,55 @@ function getSelectedTooltip( return undefined; } - const {id, row} = currentTooltip; + const {header, id, row} = currentTooltip; if (id === undefined || row === undefined) { return undefined; } + const conditionalTooltips = header + ? undefined + : R.findLast(tt => { + return ( + !tt.if || + (ifColumnId(tt.if, id) && + ifRowIndex(tt.if, row) && + ifFilter( + tt.if, + virtualized.data[row - virtualized.offset.rows] + )) + ); + }, tooltip_conditional); + + if (conditionalTooltips) { + return conditionalTooltips; + } - const appliedStaticTooltip = - (tooltip_data && - tooltip_data.length > row && - tooltip_data[row] && - tooltip_data[row][id]) || - tooltip_static[id]; - - const conditionalTooltips = R.filter(tt => { - return ( - !tt.if || - (ifColumnId(tt.if, id) && - ifRowIndex(tt.if, row) && - ifFilter( - tt.if, - virtualized.data[row - virtualized.offset.rows] - )) - ); - }, tooltip_conditional); + let tooltip: Tooltip | null | undefined; + + if (header) { + const headerTooltip = tooltip_header?.[id]; + tooltip = Array.isArray(headerTooltip) + ? headerTooltip?.[row] + : headerTooltip; + } else { + tooltip = tooltip_data?.[row]?.[id]; + } + + if (tooltip) { + return tooltip; + } - return conditionalTooltips.length - ? conditionalTooltips.slice(-1)[0] - : appliedStaticTooltip; + const staticTooltip = tooltip_static?.[id]; + const staticUseWith = + staticTooltip && typeof staticTooltip !== 'string' + ? staticTooltip.use_with + : TooltipUsage.Both; + const staticApplicable = + staticUseWith === TooltipUsage.Both || + (staticUseWith === TooltipUsage.Header) === header; + const resolvedStaticTooltip = staticApplicable ? staticTooltip : undefined; + + return resolvedStaticTooltip; } function convertDelay(delay: number | null) { @@ -78,6 +106,7 @@ export default memoizeOne( ( currentTooltip: IUSerInterfaceTooltip, tooltip_data: DataTooltips, + tooltip_header: ITableHeaderTooltips, tooltip_conditional: ConditionalTooltip[], tooltip_static: ITableStaticTooltips, virtualized: IVirtualizedDerivedData, @@ -87,6 +116,7 @@ export default memoizeOne( const selectedTooltip = getSelectedTooltip( currentTooltip, tooltip_data, + tooltip_header, tooltip_conditional, tooltip_static, virtualized diff --git a/src/dash-table/handlers/cellEvents.ts b/src/dash-table/handlers/cellEvents.ts index e4504341a..e0b21932e 100644 --- a/src/dash-table/handlers/cellEvents.ts +++ b/src/dash-table/handlers/cellEvents.ts @@ -167,13 +167,27 @@ export const handleEnter = ( ) => { const {setState, virtualized, visibleColumns} = propsFn(); - const c = visibleColumns[i]; - const realIdx = virtualized.indices[idx - virtualized.offset.rows]; + setState({ + currentTooltip: { + header: false, + id: visibleColumns[i].id, + row: virtualized.indices[idx - virtualized.offset.rows] + } + }); +}; + +export const handleEnterHeader = ( + propsFn: () => ICellFactoryProps, + idx: number, + i: number +) => { + const {setState, visibleColumns} = propsFn(); setState({ currentTooltip: { - id: c.id, - row: realIdx + header: true, + id: visibleColumns[i].id, + row: idx } }); }; @@ -201,19 +215,48 @@ export const handleMove = ( if ( currentTooltip && currentTooltip.id === c.id && - currentTooltip.row === realIdx + currentTooltip.row === realIdx && + !currentTooltip.header ) { return; } setState({ currentTooltip: { + header: false, id: c.id, row: realIdx } }); }; +export const handleMoveHeader = ( + propsFn: () => ICellFactoryProps, + idx: number, + i: number +) => { + const {currentTooltip, setState, visibleColumns} = propsFn(); + + const c = visibleColumns[i]; + + if ( + currentTooltip && + currentTooltip.id === c.id && + currentTooltip.row === idx && + currentTooltip.header + ) { + return; + } + + setState({ + currentTooltip: { + header: true, + id: c.id, + row: idx + } + }); +}; + export const handleOnMouseUp = ( propsFn: () => ICellFactoryProps, idx: number, diff --git a/src/dash-table/tooltips/props.ts b/src/dash-table/tooltips/props.ts index 302c7898a..3b8d40298 100644 --- a/src/dash-table/tooltips/props.ts +++ b/src/dash-table/tooltips/props.ts @@ -5,7 +5,14 @@ export enum TooltipSyntax { Markdown = 'markdown' } +export enum TooltipUsage { + Both = 'both', + Data = 'data', + Header = 'header' +} + export interface ITooltip { + use_with?: TooltipUsage; delay?: number | null; duration?: number | null; type?: TooltipSyntax; diff --git a/tests/selenium/conftest.py b/tests/selenium/conftest.py index 8887ef3f9..050b7e087 100644 --- a/tests/selenium/conftest.py +++ b/tests/selenium/conftest.py @@ -129,16 +129,22 @@ def __init__(self, id, mixin, col_id, state=_ANY): def get(self, row=0): self.mixin._wait_for_table(self.id, self.state) - return self.mixin.find_elements( - '#{} {} tbody tr th.dash-header[data-dash-column="{}"]:not(.phantom-cell)'.format( - self.id, self.state, self.col_id + return self.mixin.find_element( + '#{} {} tbody tr:nth-of-type({}) th.dash-header[data-dash-column="{}"]:not(.phantom-cell)'.format( + self.id, self.state, row + 1, self.col_id ) - )[row] + ) @preconditions(_validate_row) def hide(self, row=0): self.get(row).find_element_by_css_selector(".column-header--hide").click() + @preconditions(_validate_row) + def move_to(self, row=0): + ac = ActionChains(self.mixin.driver) + ac.move_to_element(self.get(row)) + return ac.perform() + @preconditions(_validate_row) def sort(self, row=0): self.get(row).find_element_by_css_selector(".column-header--sort").click() @@ -265,6 +271,11 @@ def exists(self): return tooltip is not None and tooltip.is_displayed() + def missing(self): + self.mixin._wait_for_table(self.id) + + return len(self.mixin.find_elements(".dash-tooltip")) == 0 + def get_text(self): return ( self._get_tooltip() diff --git a/tests/selenium/test_tooltip.py b/tests/selenium/test_tooltip.py index f39949d26..3017d8a3b 100644 --- a/tests/selenium/test_tooltip.py +++ b/tests/selenium/test_tooltip.py @@ -1,6 +1,7 @@ import dash import math import pytest +import time import dash.testing.wait as wait from dash_table import DataTable @@ -138,3 +139,109 @@ def test_ttip003_tooltip_disappears(test): wait.until(lambda: tooltip.exists(), 2.5) wait.until(lambda: not tooltip.exists(), 2.5) + + +ttip004_tooltip = { + "1": "text1", + "2": {"use_with": "data", "value": "text2"}, + "3": {"use_with": "header", "value": "text3"}, +} + + +@pytest.mark.parametrize( + "tooltip_data,tooltip_header,data_expected,header_expected", + [ + ( + [{"1": "alt-text1"}], + {"1": ["alt-header1-row1", "alt-header1-row2"]}, + ["alt-text1", "text1"], + ["alt-header1-row1", "alt-header1-row2"], + ), + ( + [{"1": "alt-text1"}], + {"1": [None, "alt-header1-row1"]}, + ["alt-text1", "text1"], + ["text1", "alt-header1-row1"], + ), + ( + [{"1": "alt-text1"}, {"1": "alt-text2"}], + {"1": "alt-header1-row1"}, + ["alt-text1", "alt-text2"], + ["alt-header1-row1", "alt-header1-row1"], + ), + ], +) +def test_ttip004_tooltip_applied( + test, tooltip_data, tooltip_header, data_expected, header_expected +): + props = { + **base_props, + "columns": [ + dict( + id=str(i), + name=["Column {}".format(math.ceil(i / 3)), "Column {}".format(i)], + ) + for i in range(1, 30) + ], + "merge_duplicate_headers": True, + "tooltip_delay": 0, + "tooltip_duration": 5000, + "tooltip": { + "1": "text1", + "2": {"use_with": "data", "value": "text2"}, + "3": {"use_with": "header", "value": "text3"}, + }, + "tooltip_data": tooltip_data, + "tooltip_header": tooltip_header, + } + + app = dash.Dash(__name__) + app.layout = DataTable(**props) + + test.start_server(app) + + target = test.table("table") + target.is_ready() + + cell55 = target.cell(5, "5") + tooltip = target.tooltip + + target.cell(0, "1").move_to() + wait.until(lambda: target.tooltip.exists(), 3) + assert tooltip.get_text().strip() == data_expected[0] + cell55.move_to() + + target.cell(1, "1").move_to() + wait.until(lambda: target.tooltip.exists(), 3) + assert tooltip.get_text().strip() == data_expected[1] + cell55.move_to() + + target.cell(0, "2").move_to() + wait.until(lambda: target.tooltip.exists(), 3) + assert tooltip.get_text().strip() == "text2" + cell55.move_to() + + target.cell(0, "3").move_to() + time.sleep(1) + wait.until(lambda: target.tooltip.missing(), 3) + cell55.move_to() + + target.column("1").move_to(0) + wait.until(lambda: target.tooltip.exists(), 3) + assert tooltip.get_text().strip() == header_expected[0] + cell55.move_to() + + target.column("1").move_to(1) + wait.until(lambda: target.tooltip.exists(), 3) + assert tooltip.get_text().strip() == header_expected[1] + cell55.move_to() + + target.column("2").move_to(1) + time.sleep(1) + wait.until(lambda: target.tooltip.missing(), 3) + cell55.move_to() + + target.column("3").move_to(1) + wait.until(lambda: target.tooltip.exists(), 3) + assert tooltip.get_text().strip() == "text3" + cell55.move_to()