diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e28e8ef4..988394f47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +### Added +- [#808](https://github.com/plotly/dash-table/pull/808)Fix a regression introduced with [#787](https://github.com/plotly/dash-table/pull/787) making it impossible to open markdown links in the current tab. + - Adds a new `markdown_options` property that supports: + - `link_target` nested prop with values `_blank`, `_parent`, `_self`, `_top` or an arbitrary string (default: `_blank`) + ### Fixed - [#806](https://github.com/plotly/dash-table/pull/806) Fix a bug where fixed rows a misaligned after navigating or editing cells [#803](https://github.com/plotly/dash-table/issues/803) diff --git a/src/core/objPropsToCamel.ts b/src/core/objPropsToCamel.ts new file mode 100644 index 000000000..74014a708 --- /dev/null +++ b/src/core/objPropsToCamel.ts @@ -0,0 +1,16 @@ +import { reduce, toPairs, assoc } from 'ramda'; +import { toCamelCase } from 'dash-table/derived/style/py2jsCssProperties'; + +const objPropsToCamel = (value: any): any => (value !== null && typeof value === 'object') ? + reduce(( + acc, + [key, pValue]: [string, any] + ) => assoc(toCamelCase(key.split('_')), objPropsToCamel(pValue), acc), + {} as any, + toPairs(value) + ) : + Array.isArray(value) ? + value.map(objPropsToCamel, value) : + value; + +export default objPropsToCamel; \ No newline at end of file diff --git a/src/dash-table/components/CellFactory.tsx b/src/dash-table/components/CellFactory.tsx index a5d9ddb52..a1ba35262 100644 --- a/src/dash-table/components/CellFactory.tsx +++ b/src/dash-table/components/CellFactory.tsx @@ -4,7 +4,7 @@ import React, { CSSProperties } from 'react'; import { matrixMap2, matrixMap3 } from 'core/math/matrixZipMap'; import { arrayMap2 } from 'core/math/arrayZipMap'; -import { ICellFactoryProps } from 'dash-table/components/Table/props'; +import { ICellFactoryProps, IMarkdownOptions } from 'dash-table/components/Table/props'; import derivedCellWrappers from 'dash-table/derived/cell/wrappers'; import derivedCellContents from 'dash-table/derived/cell/contents'; import derivedCellOperations from 'dash-table/derived/cell/operations'; @@ -14,6 +14,7 @@ import { derivedRelevantCellStyles } from 'dash-table/derived/style'; import { IEdgesMatrices } from 'dash-table/derived/edges/type'; import { memoizeOne } from 'core/memoizer'; import memoizerCache from 'core/cache/memoizer'; +import Markdown from 'dash-table/utils/Markdown'; export default class CellFactory { @@ -33,6 +34,10 @@ export default class CellFactory { private readonly relevantStyles = derivedRelevantCellStyles() ) { } + private getMarkdown = memoizeOne(( + options: IMarkdownOptions + ) => new Markdown(options)); + public createCells(dataEdges: IEdgesMatrices | undefined, dataOpEdges: IEdgesMatrices | undefined) { const { active_cell, @@ -44,6 +49,7 @@ export default class CellFactory { id, is_focused, loading_state, + markdown_options, row_deletable, row_selectable, selected_cells, @@ -121,13 +127,16 @@ export default class CellFactory { selected_cells ); + const markdown = this.getMarkdown(markdown_options); + const partialCellContents = this.cellContents.partialGet( visibleColumns, virtualized.data, virtualized.offset, !!is_focused, dropdowns, - loading_state + loading_state, + markdown ); const cellContents = this.cellContents.get( @@ -139,7 +148,8 @@ export default class CellFactory { virtualized.offset, !!is_focused, dropdowns, - loading_state + loading_state, + markdown ); const ops = this.getDataOpCells( diff --git a/src/dash-table/components/CellMarkdown/index.tsx b/src/dash-table/components/CellMarkdown/index.tsx index 741dafca5..5c661ca23 100644 --- a/src/dash-table/components/CellMarkdown/index.tsx +++ b/src/dash-table/components/CellMarkdown/index.tsx @@ -11,14 +11,15 @@ interface IProps { active: boolean; applyFocus: boolean; className: string; + markdown: Markdown; value: any; } export default class CellMarkdown extends PureComponent { - getMarkdown = memoizeOne((value: string, _ready: any) => ({ + getMarkdown = memoizeOne((value: any, md: Markdown, _ready: any) => ({ dangerouslySetInnerHTML: { - __html: Markdown.render(String(value)) + __html: md.render(String(value)) } })); @@ -41,13 +42,14 @@ export default class CellMarkdown extends PureComponent { render() { const { className, + markdown, value } = this.props; return (
); } diff --git a/src/dash-table/components/Table/props.ts b/src/dash-table/components/Table/props.ts index 81caaae49..f67ce33af 100644 --- a/src/dash-table/components/Table/props.ts +++ b/src/dash-table/components/Table/props.ts @@ -155,6 +155,10 @@ export interface INumberLocale { separate_4digits?: boolean; } +export interface IMarkdownOptions { + link_target: '_blank' | '_parent' | '_self' | '_top' | string; +} + export type NumberFormat = ({ locale: INumberLocale; nully: any; @@ -315,6 +319,7 @@ export interface IProps { hidden_columns?: string[]; include_headers_on_copy_paste?: boolean; locale_format: INumberLocale; + markdown_options: IMarkdownOptions; merge_duplicate_headers?: boolean; fixed_columns?: Fixed; fixed_rows?: Fixed; @@ -491,6 +496,7 @@ export interface ICellFactoryProps { id: string; is_focused?: boolean; loading_state: boolean; + markdown_options: IMarkdownOptions; paginator: IPaginator; row_deletable: boolean; row_selectable: Selection; diff --git a/src/dash-table/dash/DataTable.js b/src/dash-table/dash/DataTable.js index 581001b48..08edbddbd 100644 --- a/src/dash-table/dash/DataTable.js +++ b/src/dash-table/dash/DataTable.js @@ -70,6 +70,10 @@ export const defaultProps = { data: 0 }, + markdown_options: { + link_target: '_blank' + }, + tooltip: {}, tooltip_conditional: [], tooltip_data: [], @@ -423,6 +427,24 @@ export const propTypes = { separate_4digits: PropTypes.bool }), + /** + * The `markdown_options` property allows customization of the markdown cells behavior. + * 'link_target': (default: '_blank') the link's behavior (_blank opens the link in a + * new tab, _parent opens the link in the parent frame, _self opens the link in the + * current tab, and _top opens the link in the top frame) or a string + */ + markdown_options: PropTypes.exact({ + link_target: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.oneOf([ + '_blank', + '_parent', + '_self', + '_top' + ]) + ]).isRequired + }), + /** * The `css` property is a way to embed CSS selectors and rules * onto the page. diff --git a/src/dash-table/derived/cell/contents.tsx b/src/dash-table/derived/cell/contents.tsx index f0a5cb591..a1fe7711a 100644 --- a/src/dash-table/derived/cell/contents.tsx +++ b/src/dash-table/derived/cell/contents.tsx @@ -21,6 +21,7 @@ import { memoizeOne } from 'core/memoizer'; import getFormatter from 'dash-table/type/formatter'; import { shallowClone } from 'core/math/matrixZipMap'; import CellMarkdown from 'dash-table/components/CellMarkdown'; +import Markdown from 'dash-table/utils/Markdown'; const mapData = R.addIndex(R.map); const mapRow = R.addIndex(R.map); @@ -68,7 +69,8 @@ class Contents { _offset: IViewportOffset, isFocused: boolean, dropdowns: (IDropdown | undefined)[][], - data_loading: boolean + data_loading: boolean, + markdown: Markdown ): JSX.Element[][] => { const formatters = R.map(getFormatter, columns); @@ -84,7 +86,8 @@ class Contents { rowIndex, datum, formatters, - data_loading + data_loading, + markdown ), columns), data); }); @@ -97,7 +100,8 @@ class Contents { offset: IViewportOffset, isFocused: boolean, dropdowns: (IDropdown | undefined)[][], - data_loading: boolean + data_loading: boolean, + markdown: Markdown ): JSX.Element[][] => { if (!activeCell) { return contents; @@ -124,13 +128,26 @@ class Contents { iActive, data[i], formatters, - data_loading + data_loading, + markdown ); return contents; }); - private getContent(active: boolean, applyFocus: boolean, isFocused: boolean, column: IColumn, dropdown: IDropdown | undefined, columnIndex: number, rowIndex: number, datum: any, formatters: ((value: any) => any)[], data_loading: boolean) { + private getContent( + active: boolean, + applyFocus: boolean, + isFocused: boolean, + column: IColumn, + dropdown: IDropdown | undefined, + columnIndex: number, + rowIndex: number, + datum: any, + formatters: ((value: any) => any)[], + data_loading: boolean, + markdown: Markdown + ) { const className = [ ...(active ? ['input-active'] : []), @@ -139,7 +156,6 @@ class Contents { ].join(' '); const cellType = getCellType(active, column.editable, dropdown && dropdown.options, column.presentation, data_loading); - switch (cellType) { case CellType.Dropdown: return (); case CellType.DropdownLabel: diff --git a/src/dash-table/derived/style/py2jsCssProperties.ts b/src/dash-table/derived/style/py2jsCssProperties.ts index ae6698555..2711d5729 100644 --- a/src/dash-table/derived/style/py2jsCssProperties.ts +++ b/src/dash-table/derived/style/py2jsCssProperties.ts @@ -2,7 +2,7 @@ import cssProperties from './cssProperties'; export type StyleProperty = string | number; -const toCamelCase = (fragments: string[]) => fragments.map((f, i) => i ? +export const toCamelCase = (fragments: string[]) => fragments.map((f, i) => i ? f.charAt(0).toUpperCase() + f.substring(1) : f ).join(''); diff --git a/src/dash-table/utils/Markdown.ts b/src/dash-table/utils/Markdown.ts index 0b2e69018..e4f9fac2b 100644 --- a/src/dash-table/utils/Markdown.ts +++ b/src/dash-table/utils/Markdown.ts @@ -1,21 +1,11 @@ import { Remarkable } from 'remarkable'; +import objPropsToCamel from 'core/objPropsToCamel'; import LazyLoader from 'dash-table/LazyLoader'; +import { IMarkdownOptions } from 'dash-table/components/Table/props'; export default class Markdown { - static isReady: Promise | true = new Promise(resolve => { - Markdown.hljsResolve = resolve; - }); - - static render = (value: string) => { - return Markdown.md.render(value); - } - - private static hljsResolve: () => any; - - private static hljs: any; - - private static readonly md: Remarkable = new Remarkable({ + private readonly md: Remarkable = new Remarkable({ highlight: (str: string, lang: string) => { if (Markdown.hljs) { if (lang && Markdown.hljs.getLanguage(lang)) { @@ -32,12 +22,28 @@ export default class Markdown { } return ''; }, - linkTarget:'_blank' + ...objPropsToCamel(this.options) + }); + + constructor(private readonly options: IMarkdownOptions) { + + } + + public render = (value: string) => this.md.render(value); + + public static get isReady() { + return Markdown._isReady; + } + + private static hljs: any; + private static hljsResolve: () => any; + private static _isReady: Promise | true = new Promise(resolve => { + Markdown.hljsResolve = resolve; }); private static async loadhljs() { Markdown.hljs = await LazyLoader.hljs; Markdown.hljsResolve(); - Markdown.isReady = true; + Markdown._isReady = true; } } diff --git a/tests/selenium/test_markdown_link.py b/tests/selenium/test_markdown_link.py index 385a3f6e9..9ccd08ffa 100644 --- a/tests/selenium/test_markdown_link.py +++ b/tests/selenium/test_markdown_link.py @@ -3,7 +3,7 @@ import pytest -def get_app(cell_selectable): +def get_app(cell_selectable, markdown_options): md = "[Click me](https://www.google.com)" data = [ @@ -13,7 +13,7 @@ def get_app(cell_selectable): app = dash.Dash(__name__) - app.layout = DataTable( + props = dict( id="table", columns=[ dict(name="a", id="a", type="text", presentation="markdown"), @@ -23,24 +23,43 @@ def get_app(cell_selectable): cell_selectable=cell_selectable, ) + if markdown_options is not None: + props["markdown_options"] = markdown_options + + app.layout = DataTable(**props) + return app +@pytest.mark.parametrize( + "markdown_options,new_tab", + [ + [None, True], + [dict(linkTarget="_blank"), True], + [dict(linkTarget="_self"), False], + ], +) @pytest.mark.parametrize("cell_selectable", [True, False]) -def test_tmdl001_copy_markdown_to_text(test, cell_selectable): - test.start_server(get_app(cell_selectable)) +def test_tmdl001_click_markdown_link(test, markdown_options, new_tab, cell_selectable): + test.start_server(get_app(cell_selectable, markdown_options)) target = test.table("table") assert len(test.driver.window_handles) == 1 target.cell(0, "a").get().find_element_by_css_selector("a").click() - assert target.cell(0, "a").is_selected() == cell_selectable - assert len(test.driver.window_handles) == 2 # Make sure the new tab is what's expected - test.driver.switch_to_window(test.driver.window_handles[1]) - assert test.driver.current_url.startswith("https://www.google.com") + if new_tab: + assert target.cell(0, "a").is_selected() == cell_selectable + + assert len(test.driver.window_handles) == 2 + test.driver.switch_to_window(test.driver.window_handles[1]) + assert test.driver.current_url.startswith("https://www.google.com") + + # Make sure the cell is still selected iff cell_selectable, after switching tabs + test.driver.switch_to_window(test.driver.window_handles[0]) + assert target.cell(0, "a").is_selected() == cell_selectable - # Make sure the cell is still selected iff cell_selectable, after switching tabs - test.driver.switch_to_window(test.driver.window_handles[0]) - assert target.cell(0, "a").is_selected() == cell_selectable + else: + assert len(test.driver.window_handles) == 1 + assert test.driver.current_url.startswith("https://www.google.com")