diff --git a/.circleci/config.yml b/.circleci/config.yml index b3dcbc341..d162bec94 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -142,11 +142,15 @@ jobs: "node": docker: - - image: circleci/node:8.11.3 + - image: circleci/python:3.6.7-node steps: - checkout + - run: + name: Create virtual env + command: python -m venv || virtualenv venv + - restore_cache: key: deps1-{{ .Branch }}-{{ checksum "package-lock.json" }}-{{ checksum "package.json" }} @@ -159,9 +163,17 @@ jobs: paths: - node_modules + - run: + name: Install requirements + command: | + . venv/bin/activate + pip install -r requirements-base.txt --quiet + - run: name: Run eslint - command: npm run lint + command: | + . venv/bin/activate + npm run lint when: always diff --git a/.vscode/settings.json b/.vscode/settings.json index 69691038e..ec772eb96 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,41 @@ { - "tslint.jsEnable": true + "tslint.jsEnable": true, + "cSpell.allowCompoundWords": true, + "cSpell.ignorePaths": [ + "**/package.json", + "**/package-lock.json", + "**/node_modules/**", + "**/vscode-extension/**", + "**/.git/**", + ".vscode", + "typings" + ], + "cSpell.ignoreRegExpList": [ + "'" + ], + "cSpell.language": "en", + "cSpell.diagnosticLevel": "Error", + "cSpell.languageSettings": [ + { "languageId": "*", "dictionaries": ["fonts", "css", "html", "npm", "typescript", "python"]} + ], + "cSpell.words": [ + "atto", + "deletable", + "femto", + "giga", + "ints", + "milli", + "nully", + "peta", + "pico", + "plotly", + "selectable", + "tera", + "tooltips", + "uneditable", + "yocto", + "yotta", + "zepto", + "zetta" + ] } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 79eb64ac1..0e38b7bde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ This project adheres to [Semantic Versioning](http://semver.org/). ## [3.5.0] - 2019-02-25 ### Added +[#189](https://github.com/plotly/dash-table/issues/189) +- Added `format` nested prop to columns + - Applied to columns with `type=numeric` (more to come) + - Uses [d3-format](https://github.com/d3/d3-format) under the hood + - `format.locale` for localization configuration + - `format.prefix` for SI prefix configuration + - `format.specifier` for formatting configuration + - `format.separate_4digits` to configure grouping behavior for numbers with 4 digits or less + - Python helpers (dash_table.FormatTemplate) +- Added `locale_format` prop to table (default localization configuration, merged with column.format.locale) + [#342](https://github.com/plotly/dash-core/issues/342) - Added `column_type` condition to style `if`; allows applying styles based on the type of the column for props - `style_cell_conditional` diff --git a/dash_table/DataTable.py b/dash_table/DataTable.py index 45bf48ba0..09d08cfe2 100644 --- a/dash_table/DataTable.py +++ b/dash_table/DataTable.py @@ -12,6 +12,29 @@ class DataTable(Component): active. - columns (list; optional): Columns describes various aspects about each individual column. `name` and `id` are the only required parameters. +- locale_format (optional): The localization specific formatting information applied to all columns in the table. + +This prop is derived from the [d3.formatLocale](https://github.com/d3/d3-format#formatLocale) data structure specification. + +When left unspecified, each individual nested prop will default to a pre-determined value. + + 'symbol': (default: ['$', '']) a list of two strings representing the + prefix and suffix symbols. Typically used for currency, and implemented using d3's + currency format, but you can use this for other symbols such as measurement units. + 'decimal': (default: '.') the string used for the decimal separator + 'group': (default: ',') the string used for the groups separator + 'grouping': (default: [3]) a list of integers representing the grouping pattern + 'numerals': a list of ten strings used as replacements for numbers 0-9 + 'percent': (default: '%') the string used for the percentage symbol + 'separate_4digits': (default: True) separate integers with 4-digits or less. locale_format has the following type: dict containing keys 'symbol', 'decimal', 'group', 'grouping', 'numerals', 'percent', 'separate_4digits'. +Those keys have the following types: + - symbol (list; optional) + - decimal (string; optional) + - group (string; optional) + - grouping (list; optional) + - numerals (list; optional) + - percent (string; optional) + - separate_4digits (boolean; optional) - content_style (a value equal to: 'fit', 'grow'; optional): `content_style` toggles between a set of CSS styles for two common behaviors: - `fit`: The table container's width be equal to the width of its content. @@ -208,7 +231,7 @@ class DataTable(Component): in the list. Higher priority (more specific) conditional tooltips should be put at the beginning of the list. -The `if` refers to the condtion that needs to be fulfilled +The `if` refers to the condition that needs to be fulfilled in order for the associated tooltip configuration to be used. If multiple conditions are defined, all conditions must be met for the tooltip to be used by a cell. @@ -387,12 +410,12 @@ class DataTable(Component): Subscribe to [https://github.com/plotly/dash-table/issues/168](https://github.com/plotly/dash-table/issues/168) for updates on the dropdown API.""" @_explicitize_args - def __init__(self, active_cell=Component.UNDEFINED, columns=Component.UNDEFINED, content_style=Component.UNDEFINED, css=Component.UNDEFINED, data=Component.UNDEFINED, data_previous=Component.UNDEFINED, data_timestamp=Component.UNDEFINED, editable=Component.UNDEFINED, end_cell=Component.UNDEFINED, id=Component.UNDEFINED, is_focused=Component.UNDEFINED, merge_duplicate_headers=Component.UNDEFINED, n_fixed_columns=Component.UNDEFINED, n_fixed_rows=Component.UNDEFINED, row_deletable=Component.UNDEFINED, row_selectable=Component.UNDEFINED, selected_cells=Component.UNDEFINED, selected_rows=Component.UNDEFINED, start_cell=Component.UNDEFINED, style_as_list_view=Component.UNDEFINED, pagination_mode=Component.UNDEFINED, pagination_settings=Component.UNDEFINED, navigation=Component.UNDEFINED, column_conditional_dropdowns=Component.UNDEFINED, column_static_dropdown=Component.UNDEFINED, column_static_tooltip=Component.UNDEFINED, column_conditional_tooltips=Component.UNDEFINED, tooltips=Component.UNDEFINED, tooltip_delay=Component.UNDEFINED, tooltip_duration=Component.UNDEFINED, filtering=Component.UNDEFINED, filtering_settings=Component.UNDEFINED, filtering_type=Component.UNDEFINED, filtering_types=Component.UNDEFINED, sorting=Component.UNDEFINED, sorting_type=Component.UNDEFINED, sorting_settings=Component.UNDEFINED, sorting_treat_empty_string_as_none=Component.UNDEFINED, style_table=Component.UNDEFINED, style_cell=Component.UNDEFINED, style_data=Component.UNDEFINED, style_filter=Component.UNDEFINED, style_header=Component.UNDEFINED, style_cell_conditional=Component.UNDEFINED, style_data_conditional=Component.UNDEFINED, style_filter_conditional=Component.UNDEFINED, style_header_conditional=Component.UNDEFINED, virtualization=Component.UNDEFINED, derived_viewport_data=Component.UNDEFINED, derived_viewport_indices=Component.UNDEFINED, derived_viewport_selected_rows=Component.UNDEFINED, derived_virtual_data=Component.UNDEFINED, derived_virtual_indices=Component.UNDEFINED, derived_virtual_selected_rows=Component.UNDEFINED, dropdown_properties=Component.UNDEFINED, **kwargs): - self._prop_names = ['active_cell', 'columns', 'content_style', 'css', 'data', 'data_previous', 'data_timestamp', 'editable', 'end_cell', 'id', 'is_focused', 'merge_duplicate_headers', 'n_fixed_columns', 'n_fixed_rows', 'row_deletable', 'row_selectable', 'selected_cells', 'selected_rows', 'start_cell', 'style_as_list_view', 'pagination_mode', 'pagination_settings', 'navigation', 'column_conditional_dropdowns', 'column_static_dropdown', 'column_static_tooltip', 'column_conditional_tooltips', 'tooltips', 'tooltip_delay', 'tooltip_duration', 'filtering', 'filtering_settings', 'filtering_type', 'filtering_types', 'sorting', 'sorting_type', 'sorting_settings', 'sorting_treat_empty_string_as_none', 'style_table', 'style_cell', 'style_data', 'style_filter', 'style_header', 'style_cell_conditional', 'style_data_conditional', 'style_filter_conditional', 'style_header_conditional', 'virtualization', 'derived_viewport_data', 'derived_viewport_indices', 'derived_viewport_selected_rows', 'derived_virtual_data', 'derived_virtual_indices', 'derived_virtual_selected_rows', 'dropdown_properties'] + def __init__(self, active_cell=Component.UNDEFINED, columns=Component.UNDEFINED, locale_format=Component.UNDEFINED, content_style=Component.UNDEFINED, css=Component.UNDEFINED, data=Component.UNDEFINED, data_previous=Component.UNDEFINED, data_timestamp=Component.UNDEFINED, editable=Component.UNDEFINED, end_cell=Component.UNDEFINED, id=Component.UNDEFINED, is_focused=Component.UNDEFINED, merge_duplicate_headers=Component.UNDEFINED, n_fixed_columns=Component.UNDEFINED, n_fixed_rows=Component.UNDEFINED, row_deletable=Component.UNDEFINED, row_selectable=Component.UNDEFINED, selected_cells=Component.UNDEFINED, selected_rows=Component.UNDEFINED, start_cell=Component.UNDEFINED, style_as_list_view=Component.UNDEFINED, pagination_mode=Component.UNDEFINED, pagination_settings=Component.UNDEFINED, navigation=Component.UNDEFINED, column_conditional_dropdowns=Component.UNDEFINED, column_static_dropdown=Component.UNDEFINED, column_static_tooltip=Component.UNDEFINED, column_conditional_tooltips=Component.UNDEFINED, tooltips=Component.UNDEFINED, tooltip_delay=Component.UNDEFINED, tooltip_duration=Component.UNDEFINED, filtering=Component.UNDEFINED, filtering_settings=Component.UNDEFINED, filtering_type=Component.UNDEFINED, filtering_types=Component.UNDEFINED, sorting=Component.UNDEFINED, sorting_type=Component.UNDEFINED, sorting_settings=Component.UNDEFINED, sorting_treat_empty_string_as_none=Component.UNDEFINED, style_table=Component.UNDEFINED, style_cell=Component.UNDEFINED, style_data=Component.UNDEFINED, style_filter=Component.UNDEFINED, style_header=Component.UNDEFINED, style_cell_conditional=Component.UNDEFINED, style_data_conditional=Component.UNDEFINED, style_filter_conditional=Component.UNDEFINED, style_header_conditional=Component.UNDEFINED, virtualization=Component.UNDEFINED, derived_viewport_data=Component.UNDEFINED, derived_viewport_indices=Component.UNDEFINED, derived_viewport_selected_rows=Component.UNDEFINED, derived_virtual_data=Component.UNDEFINED, derived_virtual_indices=Component.UNDEFINED, derived_virtual_selected_rows=Component.UNDEFINED, dropdown_properties=Component.UNDEFINED, **kwargs): + self._prop_names = ['active_cell', 'columns', 'locale_format', 'content_style', 'css', 'data', 'data_previous', 'data_timestamp', 'editable', 'end_cell', 'id', 'is_focused', 'merge_duplicate_headers', 'n_fixed_columns', 'n_fixed_rows', 'row_deletable', 'row_selectable', 'selected_cells', 'selected_rows', 'start_cell', 'style_as_list_view', 'pagination_mode', 'pagination_settings', 'navigation', 'column_conditional_dropdowns', 'column_static_dropdown', 'column_static_tooltip', 'column_conditional_tooltips', 'tooltips', 'tooltip_delay', 'tooltip_duration', 'filtering', 'filtering_settings', 'filtering_type', 'filtering_types', 'sorting', 'sorting_type', 'sorting_settings', 'sorting_treat_empty_string_as_none', 'style_table', 'style_cell', 'style_data', 'style_filter', 'style_header', 'style_cell_conditional', 'style_data_conditional', 'style_filter_conditional', 'style_header_conditional', 'virtualization', 'derived_viewport_data', 'derived_viewport_indices', 'derived_viewport_selected_rows', 'derived_virtual_data', 'derived_virtual_indices', 'derived_virtual_selected_rows', 'dropdown_properties'] self._type = 'DataTable' self._namespace = 'dash_table' self._valid_wildcard_attributes = [] - self.available_properties = ['active_cell', 'columns', 'content_style', 'css', 'data', 'data_previous', 'data_timestamp', 'editable', 'end_cell', 'id', 'is_focused', 'merge_duplicate_headers', 'n_fixed_columns', 'n_fixed_rows', 'row_deletable', 'row_selectable', 'selected_cells', 'selected_rows', 'start_cell', 'style_as_list_view', 'pagination_mode', 'pagination_settings', 'navigation', 'column_conditional_dropdowns', 'column_static_dropdown', 'column_static_tooltip', 'column_conditional_tooltips', 'tooltips', 'tooltip_delay', 'tooltip_duration', 'filtering', 'filtering_settings', 'filtering_type', 'filtering_types', 'sorting', 'sorting_type', 'sorting_settings', 'sorting_treat_empty_string_as_none', 'style_table', 'style_cell', 'style_data', 'style_filter', 'style_header', 'style_cell_conditional', 'style_data_conditional', 'style_filter_conditional', 'style_header_conditional', 'virtualization', 'derived_viewport_data', 'derived_viewport_indices', 'derived_viewport_selected_rows', 'derived_virtual_data', 'derived_virtual_indices', 'derived_virtual_selected_rows', 'dropdown_properties'] + self.available_properties = ['active_cell', 'columns', 'locale_format', 'content_style', 'css', 'data', 'data_previous', 'data_timestamp', 'editable', 'end_cell', 'id', 'is_focused', 'merge_duplicate_headers', 'n_fixed_columns', 'n_fixed_rows', 'row_deletable', 'row_selectable', 'selected_cells', 'selected_rows', 'start_cell', 'style_as_list_view', 'pagination_mode', 'pagination_settings', 'navigation', 'column_conditional_dropdowns', 'column_static_dropdown', 'column_static_tooltip', 'column_conditional_tooltips', 'tooltips', 'tooltip_delay', 'tooltip_duration', 'filtering', 'filtering_settings', 'filtering_type', 'filtering_types', 'sorting', 'sorting_type', 'sorting_settings', 'sorting_treat_empty_string_as_none', 'style_table', 'style_cell', 'style_data', 'style_filter', 'style_header', 'style_cell_conditional', 'style_data_conditional', 'style_filter_conditional', 'style_header_conditional', 'virtualization', 'derived_viewport_data', 'derived_viewport_indices', 'derived_viewport_selected_rows', 'derived_virtual_data', 'derived_virtual_indices', 'derived_virtual_selected_rows', 'dropdown_properties'] self.available_wildcard_properties = [] _explicit_args = kwargs.pop('_explicit_args') diff --git a/dash_table/Format.py b/dash_table/Format.py new file mode 100644 index 000000000..011d50728 --- /dev/null +++ b/dash_table/Format.py @@ -0,0 +1,289 @@ +import collections +import inspect +import sys + + +def get_named_tuple(name, dict): + return collections.namedtuple(name, dict.keys())(*dict.values()) + + +Align = get_named_tuple('align', { + 'default': '', + 'left': '<', + 'right': '>', + 'center': '^', + "right_sign": '=' +}) + +Group = get_named_tuple('group', { + 'no': '', + 'yes': ',' +}) + +Padding = get_named_tuple('padding', { + 'no': '', + 'yes': '0' +}) + +Prefix = get_named_tuple('prefix', { + 'yocto': 10**-24, + 'zepto': 10**-21, + 'atto': 10**-18, + 'femto': 10**-15, + 'pico': 10**-12, + 'nano': 10**-9, + 'micro': 10**-6, + 'milli': 10**-3, + 'none': None, + 'kilo': 10**3, + 'mega': 10**6, + 'giga': 10**9, + 'tera': 10**12, + 'peta': 10**15, + 'exa': 10**18, + 'zetta': 10**21, + 'yotta': 10**24 +}) + +Scheme = get_named_tuple('scheme', { + 'default': '', + 'decimal': 'r', + 'decimal_integer': 'd', + 'decimal_or_exponent': 'g', + 'decimal_si_prefix': 's', + 'exponent': 'e', + 'fixed': 'f', + 'percentage': '%', + 'percentage_rounded': 'p', + 'binary': 'b', + 'octal': 'o', + 'lower_case_hex': 'x', + 'upper_case_hex': 'X', + 'unicode': 'c' +}) + +Sign = get_named_tuple('sign', { + 'default': '', + 'negative': '-', + 'positive': '+', + 'parantheses': '(', + 'space': ' ' +}) + +Symbol = get_named_tuple('symbol', { + 'no': '', + 'yes': '$', + 'binary': '#b', + 'octal': '#o', + 'hex': '#x' +}) + +Trim = get_named_tuple('trim', { + 'no': '', + 'yes': '~' +}) + + +class Format(): + def __init__(self, **kwargs): + self._locale = {} + self._nully = '' + self._prefix = Prefix.none + self._specifier = { + 'align': Align.default, + 'fill': '', + 'group': Group.no, + 'width': '', + 'padding': Padding.no, + 'precision': '', + 'sign': Sign.default, + 'symbol': Symbol.no, + 'trim': Trim.no, + 'type': Scheme.default + } + + valid_methods = [m for m in dir(self.__class__) if m[0] != '_' and m != 'to_plotly_json'] + + for kw, val in kwargs.items(): + if kw not in valid_methods: + raise TypeError('{0} is not a format method. Expected one of'.format(kw), str(list(valid_methods))) + + getattr(self, kw)(val) + + def _validate_char(self, value): + self._validate_string(value) + + if len(value) != 1: + raise ValueError('expected value to a string of length one') + + def _validate_non_negative_integer_or_none(self, value): + if value is None: + return + + if not isinstance(value, int): + raise TypeError('expected value to be an integer') + + if value < 0: + raise ValueError('expected value to be non-negative', str(value)) + + def _validate_named(self, value, named_values): + if value not in named_values: + raise TypeError('expected value to be one of', str(list(named_values))) + + def _validate_string(self, value): + if not isinstance(value, (str, u''.__class__)): + raise TypeError('expected value to be a string') + + # Specifier + def align(self, value): + self._validate_named(value, Align) + + self._specifier['align'] = value + return self + + def fill(self, value): + self._validate_char(value) + + self._specifier['fill'] = value + return self + + def group(self, value): + if isinstance(value, bool): + value = Group.yes if value else Group.no + + self._validate_named(value, Group) + + self._specifier['group'] = value + return self + + def padding(self, value): + if isinstance(value, bool): + value = Padding.yes if value else Padding.no + + self._validate_named(value, Padding) + + self._specifier['padding'] = value + return self + + def padding_width(self, value): + self._validate_non_negative_integer_or_none(value) + + self._specifier['width'] = value if value is not None else '' + return self + + def precision(self, value): + self._validate_non_negative_integer_or_none(value) + + self._specifier['precision'] = '.{0}'.format(value) if value is not None else '' + return self + + def scheme(self, value): + self._validate_named(value, Scheme) + + self._specifier['type'] = value + return self + + def sign(self, value): + self._validate_named(value, Sign) + + self._specifier['sign'] = value + return self + + def symbol(self, value): + self._validate_named(value, Symbol) + + self._specifier['symbol'] = value + return self + + def trim(self, value): + if isinstance(value, bool): + value = Trim.yes if value else Trim.no + + self._validate_named(value, Trim) + + self._specifier['trim'] = value + return self + + # Locale + def symbol_prefix(self, value): + self._validate_string(value) + + if 'symbol' not in self._locale: + self._locale['symbol'] = [value, ''] + else: + self._locale['symbol'][0] = value + + return self + + def symbol_suffix(self, value): + self._validate_string(value) + + if 'symbol' not in self._locale: + self._locale['symbol'] = ['', value] + else: + self._locale['symbol'][1] = value + + return self + + def decimal_delimiter(self, value): + self._validate_char(value) + + self._locale['decimal'] = value + return self + + def group_delimiter(self, value): + self._validate_char(value) + + self._locale['group'] = value + return self + + def groups(self, groups): + groups = groups if isinstance(groups, list) else [groups] if isinstance(groups, int) else None + + if not isinstance(groups, list): + raise TypeError('expected groups to be an integer or a list of integers') + + if len(groups) == 0: + raise ValueError('expected groups to be an integer or a list of one or more integers') + + for group in groups: + if not isinstance(group, int): + raise TypeError('expected entry to be an integer') + + if group <= 0: + raise ValueError('expected entry to be a non-negative integer') + + self._locale['grouping'] = groups + return self + + # Nully + def nully(self, value): + self._nully = value + return self + + # Prefix + def si_prefix(self, value): + self._validate_named(value, Prefix) + + self._prefix = value + return self + + def to_plotly_json(self): + f = {} + f['locale'] = self._locale.copy() + f['nully'] = self._nully + f['prefix'] = self._prefix + f['specifier'] = '{}{}{}{}{}{}{}{}{}{}'.format( + self._specifier['fill'] if self._specifier['align'] != Align.default else '', + self._specifier['align'], + self._specifier['sign'], + self._specifier['symbol'], + self._specifier['padding'], + self._specifier['width'], + self._specifier['group'], + self._specifier['precision'], + self._specifier['trim'], + self._specifier['type'] + ) + + return f diff --git a/dash_table/FormatTemplate.py b/dash_table/FormatTemplate.py new file mode 100644 index 000000000..b75ce22aa --- /dev/null +++ b/dash_table/FormatTemplate.py @@ -0,0 +1,23 @@ +from enum import Enum +from .Format import Format, Group, Scheme, Sign, Symbol + + +def money(decimals, sign=Sign.default): + return Format( + group=Group.yes, + precision=decimals, + scheme=Scheme.fixed, + sign=sign, + symbol=Symbol.yes + ) + + +def percentage(decimals, rounded=False): + if not isinstance(rounded, bool): + raise TypeError('expected rounded to be a boolean') + + rounded = Scheme.percentage_rounded if rounded else Scheme.percentage + return Format( + scheme=rounded, + precision=decimals + ) diff --git a/dash_table/metadata.json b/dash_table/metadata.json index 539744ebd..76cd3d5a6 100644 --- a/dash_table/metadata.json +++ b/dash_table/metadata.json @@ -1,5 +1,5 @@ { - "src/dash-table/DataTable.js": { + "src/dash-table/dash/DataTable.js": { "description": "", "displayName": "DataTable", "methods": [], @@ -57,6 +57,68 @@ "description": "If True, then the name of this column is editable.\nIf there are multiple column headers (if `name` is a list of strings),\nthen `editable_name` can refer to _which_ column header should be\neditable by setting it to the column header index.\nAlso, updating the name in a merged column header cell will\nupdate the name of each column.", "required": false }, + "format": { + "name": "shape", + "value": { + "locale": { + "name": "shape", + "value": { + "symbol": { + "name": "arrayOf", + "value": { + "name": "string" + }, + "required": false + }, + "decimal": { + "name": "string", + "required": false + }, + "group": { + "name": "string", + "required": false + }, + "grouping": { + "name": "arrayOf", + "value": { + "name": "number" + }, + "required": false + }, + "numerals": { + "name": "arrayOf", + "value": { + "name": "string" + }, + "required": false + }, + "percent": { + "name": "string", + "required": false + }, + "separate_4digits": { + "name": "bool", + "required": false + } + }, + "required": false + }, + "nully": { + "name": "any", + "required": false + }, + "prefix": { + "name": "number", + "required": false + }, + "specifier": { + "name": "string", + "required": false + } + }, + "description": "The formatting applied to the column's data.\n\nThis prop is derived from the [d3-format](https://github.com/d3/d3-format) library specification. Apart from\nbeing structured slightly differently (under a single prop), the usage\nis the same.\n\n'locale': represents localization specific formatting information\n When left unspecified, will use the default value provided by d3-format.\n\n 'symbol': (default: ['$', '']) a list of two strings representing the\n prefix and suffix symbols. Typically used for currency, and implemented using d3's\n currency format, but you can use this for other symbols such as measurement units.\n 'decimal': (default: '.') the string used for the decimal separator\n 'group': (default: ',') the string used for the groups separator\n 'grouping': (default: [3]) a list of integers representing the grouping pattern\n 'numerals': a list of ten strings used as replacements for numbers 0-9\n 'percent': (default: '%') the string used for the percentage symbol\n 'separate_4digits': (default: True) separate integers with 4-digits or less\n\n'nully': a value that will be used in place of the nully value during formatting\n If the value type matches the column type, it will be formatted normally\n'prefix': a number representing the SI unit to use during formatting\n See `dash_table.Format.Prefix` enumeration for the list of valid values\n'specifier': (default: '') represents the rules to apply when formatting the number\n\ndash_table.FormatTemplate contains helper functions to rapidly use certain\ntypical number formats.", + "required": false + }, "hidden": { "name": "bool", "description": "If True, then the column and its data is hidden.\nThis can be useful if you want to transport extra\nmeta data (like a data index) to and from callbacks\nbut you don't necessarily want to display that data.", @@ -191,7 +253,7 @@ } } }, - "description": "DEPRECATED\nPlease use `column_static_dropdown` instead.\nNOTE - Dropdown behaviour will likely change in the future,\nsubscribe to [https://github.com/plotly/dash-table/issues/168](https://github.com/plotly/dash-table/issues/168)\nfor more information.", + "description": "DEPRECATED\nPlease use `column_static_dropdown` instead.\nNOTE - Dropdown behavior will likely change in the future,\nsubscribe to [https://github.com/plotly/dash-table/issues/168](https://github.com/plotly/dash-table/issues/168)\nfor more information.", "required": false }, "type": { @@ -227,6 +289,52 @@ "computed": false } }, + "locale_format": { + "type": { + "name": "shape", + "value": { + "symbol": { + "name": "arrayOf", + "value": { + "name": "string" + }, + "required": false + }, + "decimal": { + "name": "string", + "required": false + }, + "group": { + "name": "string", + "required": false + }, + "grouping": { + "name": "arrayOf", + "value": { + "name": "number" + }, + "required": false + }, + "numerals": { + "name": "arrayOf", + "value": { + "name": "string" + }, + "required": false + }, + "percent": { + "name": "string", + "required": false + }, + "separate_4digits": { + "name": "bool", + "required": false + } + } + }, + "required": false, + "description": "The localization specific formatting information applied to all columns in the table.\n\nThis prop is derived from the [d3.formatLocale](https://github.com/d3/d3-format#formatLocale) data structure specification.\n\nWhen left unspecified, each individual nested prop will default to a pre-determined value.\n\n 'symbol': (default: ['$', '']) a list of two strings representing the\n prefix and suffix symbols. Typically used for currency, and implemented using d3's\n currency format, but you can use this for other symbols such as measurement units.\n 'decimal': (default: '.') the string used for the decimal separator\n 'group': (default: ',') the string used for the groups separator\n 'grouping': (default: [3]) a list of integers representing the grouping pattern\n 'numerals': a list of ten strings used as replacements for numbers 0-9\n 'percent': (default: '%') the string used for the percentage symbol\n 'separate_4digits': (default: True) separate integers with 4-digits or less" + }, "content_style": { "type": { "name": "enum", @@ -736,7 +844,7 @@ } }, "required": false, - "description": "`column_conditional_tooltips` represents the tooltip shown\nfor different columns and cells.\n\nThis property allows you to specify different tooltips for\ndepending on certain conditions. For example, you may have\ndifferent tooltips in the same column based on the value\nof a certain data property.\n\nPriority is from first to last defined conditional tooltip\nin the list. Higher priority (more specific) conditional\ntooltips should be put at the beginning of the list.\n\nThe `if` refers to the condtion that needs to be fulfilled\nin order for the associated tooltip configuration to be\nused. If multiple conditions are defined, all conditions\nmust be met for the tooltip to be used by a cell.\n\nThe `if` nested property `column_id` refers to the column\nID that must be matched.\nThe `if` nested property `row_index` refers to the index\nof the row in the source `data`.\nThe `if` nested property `filter` refers to the query that\nmust evaluate to True.\n\nThe `type` refers to the type of tooltip syntax used\nfor the tooltip generation. Can either be `markdown`\nor `text`. Defaults to `text`.\nThe `value` refers to the syntax-based content of\nthe tooltip. This value is required.\nThe `delay` represents the delay in milliseconds before\nthe tooltip is shown when hovering a cell. This overrides\nthe table's `tooltip_delay` property. If set to `null`,\nthe tooltip will be shown immediately.\nThe `duration` represents the duration in milliseconds\nduring which the tooltip is shown when hovering a cell.\nThis overrides the table's `tooltip_duration` property.\nIf set to `null`, the tooltip will not disappear.", + "description": "`column_conditional_tooltips` represents the tooltip shown\nfor different columns and cells.\n\nThis property allows you to specify different tooltips for\ndepending on certain conditions. For example, you may have\ndifferent tooltips in the same column based on the value\nof a certain data property.\n\nPriority is from first to last defined conditional tooltip\nin the list. Higher priority (more specific) conditional\ntooltips should be put at the beginning of the list.\n\nThe `if` refers to the condition that needs to be fulfilled\nin order for the associated tooltip configuration to be\nused. If multiple conditions are defined, all conditions\nmust be met for the tooltip to be used by a cell.\n\nThe `if` nested property `column_id` refers to the column\nID that must be matched.\nThe `if` nested property `row_index` refers to the index\nof the row in the source `data`.\nThe `if` nested property `filter` refers to the query that\nmust evaluate to True.\n\nThe `type` refers to the type of tooltip syntax used\nfor the tooltip generation. Can either be `markdown`\nor `text`. Defaults to `text`.\nThe `value` refers to the syntax-based content of\nthe tooltip. This value is required.\nThe `delay` represents the delay in milliseconds before\nthe tooltip is shown when hovering a cell. This overrides\nthe table's `tooltip_delay` property. If set to `null`,\nthe tooltip will be shown immediately.\nThe `duration` represents the duration in milliseconds\nduring which the tooltip is shown when hovering a cell.\nThis overrides the table's `tooltip_duration` property.\nIf set to `null`, the tooltip will not disappear.", "defaultValue": { "value": "[]", "computed": false diff --git a/demo/AppMode.ts b/demo/AppMode.ts index 60d02ce61..631c922a4 100644 --- a/demo/AppMode.ts +++ b/demo/AppMode.ts @@ -19,6 +19,7 @@ export enum AppMode { Filtering = 'filtering', FixedTooltips = 'fixed,tooltips', FixedVirtualized = 'fixed,virtualized', + Formatting = 'formatting', ReadOnly = 'readonly', ColumnsInSpace = 'columnsInSpace', Tooltips = 'tooltips', @@ -260,6 +261,61 @@ function getFixedVirtualizedState() { }; } +function getFormattingState() { + const state = getDefaultState(); + + R.forEach((datum: any) => { + if (datum.eee % 2 === 0) { + datum.eee = undefined; + } else if (datum.eee % 10 === 5) { + datum.eee = `xx-${datum.eee}-xx`; + } + }, state.tableProps.data as any); + + R.forEach((column: any) => { + if (column.id === 'rows') { + column.format = { + specifier: '.^5' + }; + } else if (column.id === 'ccc') { + column.format = { + locale: { + separate_4digits: false + }, + prefix: 1000, + specifier: '.3f' + }; + } else if (column.id === 'ddd') { + column.format = { + locale: { + symbol: ['eq. $ ', ''], + separate_4digits: false + }, + nully: 0, + specifier: '$,.2f' + }; + column.on_change = { + action: 'coerce', + failure: 'default' + }; + column.validation = { + allow_nully: true + }; + } else if (column.id === 'eee') { + column.format = { + nully: 'N/A', + specifier: '' + }; + column.on_change = { + action: 'coerce', + failure: 'default' + }; + } + }, state.tableProps.columns as any); + + return state; +} + function getState() { const mode = Environment.searchParams.get('mode'); @@ -272,6 +328,8 @@ function getState() { return getFixedTooltipsState(); case AppMode.FixedVirtualized: return getFixedVirtualizedState(); + case AppMode.Formatting: + return getFormattingState(); case AppMode.ReadOnly: return getReadonlyState(); case AppMode.ColumnsInSpace: diff --git a/package.json b/package.json index 3a1e23a20..d75ab077e 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "private::build:js-dev": "run-s \"private::build -- --mode development\"", "private::build:js-test": "run-s \"private::build -- --config webpack.test.config.js\"", "private::build:js-test-watch": "run-s \"private::build -- --config webpack.test.config.js --watch\"", - "private::build:extract-meta": "node ./extract-meta src/dash-table/DataTable.js > dash_table/metadata.json", + "private::build:extract-meta": "node ./extract-meta src/dash-table/dash/DataTable.js > dash_table/metadata.json", "private::build:copy-package-info": "cp package.json dash_table/package-info.json", "private::build:generate-classes": "python -c \"import dash; dash.development.component_loader.generate_classes('dash_table', 'dash_table/metadata.json')\"", "private::build:py": "run-s private::build:copy-package-info private::build:extract-meta private::build:generate-classes", @@ -19,19 +19,22 @@ "private::host_dash8082": "python tests/cypress/dash/v_copy_paste.py", "private::host_dash8083": "python tests/cypress/dash/v_fe_page.py", "private::host_js": "http-server ./dash_table -c-1 --silent", + "private::lint:ts": "tslint '{src,demo,tests}/**/*.{js,ts,tsx}' --exclude '**/@Types/*.*'", + "private::lint:py": "flake8 --exclude=DataTable.py,__init__.py,_imports_.py --ignore=E501,F401,F841,F811,F821 dash_table", "private::wait_dash8081": "wait-on http://localhost:8081", "private::wait_dash8082": "wait-on http://localhost:8082", "private::wait_dash8083": "wait-on http://localhost:8083", "private::wait_js": "wait-on http://localhost:8080", "private::opentests": "cypress open", + "private::runtests:python": "python -m unittest tests/unit/format_test.py", "private::runtests:unit": "cypress run --browser chrome --spec 'tests/cypress/tests/unit/**/*'", "private::runtests:standalone": "cypress run --browser chrome --spec 'tests/cypress/tests/standalone/**/*'", "private::runtests:server": "cypress run --browser chrome --spec 'tests/cypress/tests/server/**/*'", "private::runtests-v0": "run-s private::runtests:server", - "private::runtests-v1": "run-s private::runtests:unit private::runtests:standalone private::runtests:server", + "private::runtests-v1": "run-s private::runtests:python private::runtests:unit private::runtests:standalone private::runtests:server", "build.watch": "webpack-dev-server --content-base dash_table --mode development", "build": "run-s private::build:js private::build:py", - "lint": "tslint '{src,demo,tests}/**/*.{js,ts,tsx}' --exclude '**/@Types/*.*'", + "lint": "run-s private::lint:*", "test-v0": "run-p --race private::host* private::runtests-v0", "test-v1": "run-p --race private::host* private::runtests-v1", "test.visual": "build-storybook && percy-storybook", @@ -51,6 +54,7 @@ "@percy-io/percy-storybook": "^2.1.0", "@storybook/cli": "^4.1.13", "@storybook/react": "^4.1.13", + "@types/d3-format": "^1.3.1", "@types/ramda": "^0.25.51", "@types/react": "^16.8.5", "@types/react-dom": "^16.8.2", @@ -59,6 +63,7 @@ "core-js": "^2.6.5", "css-loader": "^2.1.0", "cypress": "^3.1.5", + "d3-format": "^1.3.2", "fast-isnumeric": "^1.1.2", "file-loader": "^3.0.1", "http-server": "^0.11.1", diff --git a/src/dash-table/components/CellInput/index.tsx b/src/dash-table/components/CellInput/index.tsx index 6be343ade..4f4437cd9 100644 --- a/src/dash-table/components/CellInput/index.tsx +++ b/src/dash-table/components/CellInput/index.tsx @@ -47,6 +47,11 @@ export default class CellInput extends PureComponent { value } = this.props; + // input does not handle `null` correct (causes console error) + const sanitizedValue = this.state.value === null ? + undefined : + this.state.value; + return (
{value} @@ -62,7 +67,7 @@ export default class CellInput extends PureComponent { onKeyDown={this.handleKeyDown} onMouseUp={onMouseUp} onPaste={onPaste} - value={this.state.value} + value={sanitizedValue} />
); } diff --git a/src/dash-table/components/Table/props.ts b/src/dash-table/components/Table/props.ts index 35b735166..d8b42501a 100644 --- a/src/dash-table/components/Table/props.ts +++ b/src/dash-table/components/Table/props.ts @@ -109,7 +109,25 @@ export interface ITypeColumn { validation?: ITypeValidation; } +export interface INumberLocale { + symbol: [string, string]; + decimal: string; + group: string; + grouping: number[]; + numerals?: string[]; + percent: string; + separate_4digits?: boolean; +} + +export type NumberFormat = ({ + locale: INumberLocale; + nully: any; + prefix?: number; + specifier: string; +}) | undefined; + export interface INumberColumn extends ITypeColumn { + format?: NumberFormat; presentation?: Presentation.Input | Presentation.Dropdown; type: ColumnType.Numeric; } @@ -251,6 +269,7 @@ interface IProps { filtering_settings?: string; filtering_type?: FilteringType; filtering_types?: FilteringType[]; + locale_format: INumberLocale; merge_duplicate_headers?: boolean; navigation?: Navigation; n_fixed_columns?: number; diff --git a/src/dash-table/DataTable.js b/src/dash-table/dash/DataTable.js similarity index 88% rename from src/dash-table/DataTable.js rename to src/dash-table/dash/DataTable.js index 35674d45c..5be2d8c53 100644 --- a/src/dash-table/DataTable.js +++ b/src/dash-table/dash/DataTable.js @@ -1,3 +1,4 @@ +import * as R from 'ramda'; import React, { Component } from 'react'; import PropTypes from 'prop-types'; @@ -5,15 +6,9 @@ import RealTable from 'dash-table/components/Table'; import Logger from 'core/Logger'; -import genRandomId from './utils/generate'; - -function isFrontEnd(value) { - return ['fe', true, false].indexOf(value) !== -1; -} - -function isBackEnd(value) { - return ['be', false].indexOf(value) !== -1; -} +import genRandomId from 'dash-table/utils/generate'; +import isValidProps from './validate'; +import sanitizeProps from './sanitize'; export default class DataTable extends Component { constructor(props) { @@ -24,21 +19,14 @@ export default class DataTable extends Component { } render() { - const { - filtering, - sorting, - pagination_mode - } = this.props; - - const isValid = isFrontEnd(pagination_mode) || - (isBackEnd(filtering) && isBackEnd(sorting)); - - if (!isValid) { - Logger.error(`Invalid combination of filtering / sorting / pagination`, filtering, sorting, pagination_mode); + if (!isValidProps(this.props)) { return (
Invalid props combination
); } - return this.props.id ? () : (); + const sanitizedProps = sanitizeProps(this.props); + return this.props.id ? + () : + (); } } @@ -157,6 +145,50 @@ export const propTypes = { PropTypes.number ]), + /** + * The formatting applied to the column's data. + * + * This prop is derived from the [d3-format](https://github.com/d3/d3-format) library specification. Apart from + * being structured slightly differently (under a single prop), the usage + * is the same. + * + * 'locale': represents localization specific formatting information + * When left unspecified, will use the default value provided by d3-format. + * + * 'symbol': (default: ['$', '']) a list of two strings representing the + * prefix and suffix symbols. Typically used for currency, and implemented using d3's + * currency format, but you can use this for other symbols such as measurement units. + * 'decimal': (default: '.') the string used for the decimal separator + * 'group': (default: ',') the string used for the groups separator + * 'grouping': (default: [3]) a list of integers representing the grouping pattern + * 'numerals': a list of ten strings used as replacements for numbers 0-9 + * 'percent': (default: '%') the string used for the percentage symbol + * 'separate_4digits': (default: True) separate integers with 4-digits or less + * + * 'nully': a value that will be used in place of the nully value during formatting + * If the value type matches the column type, it will be formatted normally + * 'prefix': a number representing the SI unit to use during formatting + * See `dash_table.Format.Prefix` enumeration for the list of valid values + * 'specifier': (default: '') represents the rules to apply when formatting the number + * + * dash_table.FormatTemplate contains helper functions to rapidly use certain + * typical number formats. + */ + format: PropTypes.shape({ + locale: PropTypes.shape({ + symbol: PropTypes.arrayOf(PropTypes.string), + decimal: PropTypes.string, + group: PropTypes.string, + grouping: PropTypes.arrayOf(PropTypes.number), + numerals: PropTypes.arrayOf(PropTypes.string), + percent: PropTypes.string, + separate_4digits: PropTypes.bool + }), + nully: PropTypes.any, + prefix: PropTypes.number, + specifier: PropTypes.string + }), + /** * If True, then the column and its data is hidden. * This can be useful if you want to transport extra @@ -233,7 +265,7 @@ export const propTypes = { /** * DEPRECATED * Please use `column_static_dropdown` instead. - * NOTE - Dropdown behaviour will likely change in the future, + * NOTE - Dropdown behavior will likely change in the future, * subscribe to [https://github.com/plotly/dash-table/issues/168](https://github.com/plotly/dash-table/issues/168) * for more information. */ @@ -275,9 +307,35 @@ export const propTypes = { * Stay tuned by following [https://github.com/plotly/dash-table/issues/166](https://github.com/plotly/dash-table/issues/166) */ type: PropTypes.oneOf(['any', 'numeric', 'text', 'datetime']) - })), + /** + * The localization specific formatting information applied to all columns in the table. + * + * This prop is derived from the [d3.formatLocale](https://github.com/d3/d3-format#formatLocale) data structure specification. + * + * When left unspecified, each individual nested prop will default to a pre-determined value. + * + * 'symbol': (default: ['$', '']) a list of two strings representing the + * prefix and suffix symbols. Typically used for currency, and implemented using d3's + * currency format, but you can use this for other symbols such as measurement units. + * 'decimal': (default: '.') the string used for the decimal separator + * 'group': (default: ',') the string used for the groups separator + * 'grouping': (default: [3]) a list of integers representing the grouping pattern + * 'numerals': a list of ten strings used as replacements for numbers 0-9 + * 'percent': (default: '%') the string used for the percentage symbol + * 'separate_4digits': (default: True) separate integers with 4-digits or less + */ + locale_format: PropTypes.shape({ + symbol: PropTypes.arrayOf(PropTypes.string), + decimal: PropTypes.string, + group: PropTypes.string, + grouping: PropTypes.arrayOf(PropTypes.number), + numerals: PropTypes.arrayOf(PropTypes.string), + percent: PropTypes.string, + separate_4digits: PropTypes.bool + }), + /** * `content_style` toggles between a set of CSS styles for * two common behaviors: @@ -608,7 +666,7 @@ export const propTypes = { * in the list. Higher priority (more specific) conditional * tooltips should be put at the beginning of the list. * - * The `if` refers to the condtion that needs to be fulfilled + * The `if` refers to the condition that needs to be fulfilled * in order for the associated tooltip configuration to be * used. If multiple conditions are defined, all conditions * must be met for the tooltip to be used by a cell. diff --git a/src/dash-table/dash/sanitize.ts b/src/dash-table/dash/sanitize.ts new file mode 100644 index 000000000..2bdecdf15 --- /dev/null +++ b/src/dash-table/dash/sanitize.ts @@ -0,0 +1,58 @@ + +import * as R from 'ramda'; + +import { memoizeOne } from 'core/memoizer'; +import { Columns, ColumnType, INumberLocale } from 'dash-table/components/Table/props'; + +const D3_DEFAULT_LOCALE: INumberLocale = { + symbol: ['$', ''], + decimal: '.', + group: ',', + grouping: [3], + percent: '%', + separate_4digits: true +}; + +const DEFAULT_NULLY = ''; +const DEFAULT_SPECIFIER = ''; + +const applyDefaultToLocale = memoizeOne((locale: INumberLocale) => getLocale(locale)); + +const applyDefaultsToColumns = memoizeOne( + (defaultLocale: INumberLocale, columns: Columns) => R.map(column => { + const c = R.clone(column); + + if (c.type === ColumnType.Numeric && c.format) { + c.format.locale = getLocale(defaultLocale, c.format.locale); + c.format.nully = getNully(c.format.nully); + c.format.specifier = getSpecifier(c.format.specifier); + } + return c; + }, columns) +); + +export default (props: any) => { + const locale_format = applyDefaultToLocale(props.locale_format); + + return R.mergeAll([ + props, + { + columns: applyDefaultsToColumns(locale_format, props.columns), + locale_format + } + ]); +}; + +export const getLocale = (...locales: Partial[]): INumberLocale => + R.mergeAll([ + D3_DEFAULT_LOCALE, + ...locales + ]); + +export const getSpecifier = (specifier?: string) => specifier === undefined ? + DEFAULT_SPECIFIER : + specifier; + +export const getNully = (nully?: any) => nully === undefined ? + DEFAULT_NULLY : + nully; \ No newline at end of file diff --git a/src/dash-table/dash/validate.ts b/src/dash-table/dash/validate.ts new file mode 100644 index 000000000..6fc29134e --- /dev/null +++ b/src/dash-table/dash/validate.ts @@ -0,0 +1,56 @@ +import * as R from 'ramda'; + +import Logger from 'core/Logger'; + +function isFrontEnd(value: any) { + return ['fe', true, false].indexOf(value) !== -1; +} + +function isBackEnd(value: any) { + return ['be', false].indexOf(value) !== -1; +} + +function validColumns(props: any) { + const { + columns + } = props; + + return !R.any((column: any) => + column.format && ( + ( + column.format.symbol && + column.format.symbol.length !== 2 + ) || ( + column.format.grouping && + column.format.grouping.length === 0 + ) || ( + column.format.numerals && + column.format.numerals.length !== 10 + ) + ))(columns); +} + +function validFSP(props: any) { + const { + filtering, + sorting, + pagination_mode + } = props; + + return isFrontEnd(pagination_mode) || + (isBackEnd(filtering) && isBackEnd(sorting)); +} + +export default (props: any): boolean => { + if (!validFSP(props)) { + Logger.error(`Invalid combination of filtering / sorting / pagination`); + return false; + } + + if (!validColumns(props)) { + Logger.error(`Invalid column format`); + return false; + } + + return true; +}; \ No newline at end of file diff --git a/src/dash-table/derived/cell/contents.tsx b/src/dash-table/derived/cell/contents.tsx index 6421feef4..4a7a282ee 100644 --- a/src/dash-table/derived/cell/contents.tsx +++ b/src/dash-table/derived/cell/contents.tsx @@ -5,12 +5,12 @@ import { ActiveCell, Data, Datum, - IVisibleColumn, - VisibleColumns, + DropdownValues, ICellFactoryProps, IViewportOffset, - DropdownValues, - Presentation + IVisibleColumn, + Presentation, + VisibleColumns } from 'dash-table/components/Table/props'; import CellInput from 'dash-table/components/CellInput'; import derivedCellEventHandlerProps, { Handler } from 'dash-table/derived/cell/eventHandlerProps'; @@ -19,6 +19,7 @@ import isCellEditable from './isEditable'; import CellLabel from 'dash-table/components/CellLabel'; import CellDropdown from 'dash-table/components/CellDropdown'; import { memoizeOne } from 'core/memoizer'; +import getFormatter from 'dash-table/type/formatter'; const mapData = R.addIndex(R.map); const mapRow = R.addIndex(R.map); @@ -63,58 +64,62 @@ class Contents { editable: boolean, isFocused: boolean, dropdowns: (DropdownValues | undefined)[][] - ): JSX.Element[][] => mapData( - (datum, rowIndex) => mapRow( - (column, columnIndex) => { - const active = isActiveCell(activeCell, rowIndex + offset.rows, columnIndex + offset.columns); + ): JSX.Element[][] => { + const formatters = R.map(getFormatter, columns); + + return mapData( + (datum, rowIndex) => mapRow( + (column, columnIndex) => { + const active = isActiveCell(activeCell, rowIndex + offset.rows, columnIndex + offset.columns); - const dropdown = dropdowns[rowIndex][columnIndex]; + const dropdown = dropdowns[rowIndex][columnIndex]; - const isEditable = isCellEditable(editable, column.editable); + const isEditable = isCellEditable(editable, column.editable); - const className = [ - ...(active ? ['input-active'] : []), - isFocused ? 'focused' : 'unfocused', - 'dash-cell-value' - ].join(' '); + const className = [ + ...(active ? ['input-active'] : []), + isFocused ? 'focused' : 'unfocused', + 'dash-cell-value' + ].join(' '); - switch (getCellType(active, isEditable, dropdown, column.presentation)) { - case CellType.Dropdown: - return (); - case CellType.Input: - return (); - case CellType.Label: - default: - return (); - } - }, - columns - ), - data - )); + switch (getCellType(active, isEditable, dropdown, column.presentation)) { + case CellType.Dropdown: + return (); + case CellType.Input: + return (); + case CellType.Label: + default: + return (); + } + }, + columns + ), + data + ); + }); } \ No newline at end of file diff --git a/src/dash-table/handlers/cellEvents.ts b/src/dash-table/handlers/cellEvents.ts index 0a4e019be..8560fef7c 100644 --- a/src/dash-table/handlers/cellEvents.ts +++ b/src/dash-table/handlers/cellEvents.ts @@ -1,7 +1,7 @@ import * as R from 'ramda'; import { SelectedCells, ICellFactoryProps } from 'dash-table/components/Table/props'; import isActive from 'dash-table/derived/cell/isActive'; -import reconcile from 'dash-table/reconcile'; +import reconcile from 'dash-table/type/reconcile'; function isCellSelected(selectedCells: SelectedCells, idx: number, i: number) { return selectedCells && R.contains([idx, i], selectedCells); diff --git a/src/dash-table/index.ts b/src/dash-table/index.ts index 908127d0b..1fd0452ae 100644 --- a/src/dash-table/index.ts +++ b/src/dash-table/index.ts @@ -1,7 +1,7 @@ import Environment from 'core/environment'; import Logger from 'core/Logger'; -import DataTable from 'dash-table/DataTable'; +import DataTable from 'dash-table/dash/DataTable'; Logger.setDebugLevel(Environment.debugLevel); Logger.setLogLevel(Environment.logLevel); diff --git a/src/dash-table/reconcile/number.ts b/src/dash-table/reconcile/number.ts deleted file mode 100644 index e9c1ca2a4..000000000 --- a/src/dash-table/reconcile/number.ts +++ /dev/null @@ -1,17 +0,0 @@ -import isNumeric from 'fast-isnumeric'; - -import { INumberColumn } from 'dash-table/components/Table/props'; -import { reconcileNull, isNully } from './null'; -import { IReconciliation } from '.'; - -export function coerce(value: any, options: INumberColumn | undefined): IReconciliation { - return isNumeric(value) ? - { success: true, value: +value } : - reconcileNull(value, options); -} - -export function validate(value: any, options: INumberColumn | undefined): IReconciliation { - return typeof value === 'number' && !isNully(value) ? - { success: true, value } : - reconcileNull(value, options); -} \ No newline at end of file diff --git a/src/dash-table/reconcile/any.ts b/src/dash-table/type/any.ts similarity index 100% rename from src/dash-table/reconcile/any.ts rename to src/dash-table/type/any.ts diff --git a/src/dash-table/reconcile/date.ts b/src/dash-table/type/date.ts similarity index 98% rename from src/dash-table/reconcile/date.ts rename to src/dash-table/type/date.ts index e6dd87c77..45a820efe 100644 --- a/src/dash-table/reconcile/date.ts +++ b/src/dash-table/type/date.ts @@ -1,6 +1,6 @@ import { IDatetimeColumn } from 'dash-table/components/Table/props'; import { reconcileNull } from './null'; -import { IReconciliation } from '.'; +import { IReconciliation } from './reconcile'; // pattern and convertToMs pulled from plotly.js // (simplified - no international calendars for now) diff --git a/src/dash-table/type/formatter.ts b/src/dash-table/type/formatter.ts new file mode 100644 index 000000000..0fae8667d --- /dev/null +++ b/src/dash-table/type/formatter.ts @@ -0,0 +1,18 @@ +import { + ColumnType, + IColumnType +} from 'dash-table/components/Table/props'; + +import { getFormatter as getNumberFormatter } from './number'; + +const DEFAULT_FORMATTER = (value: any) => value; +export default (c: IColumnType) => { + let formatter; + switch (c.type) { + case ColumnType.Numeric: + formatter = getNumberFormatter(c.format); + break; + } + + return formatter || DEFAULT_FORMATTER; +}; \ No newline at end of file diff --git a/src/dash-table/reconcile/null.ts b/src/dash-table/type/null.ts similarity index 92% rename from src/dash-table/reconcile/null.ts rename to src/dash-table/type/null.ts index 582293867..046a48b9f 100644 --- a/src/dash-table/reconcile/null.ts +++ b/src/dash-table/type/null.ts @@ -1,5 +1,5 @@ import { ITypeColumn } from 'dash-table/components/Table/props'; -import { IReconciliation } from '.'; +import { IReconciliation } from './reconcile'; export const reconcileNull = ( value: any, diff --git a/src/dash-table/type/number.ts b/src/dash-table/type/number.ts new file mode 100644 index 000000000..84b318eed --- /dev/null +++ b/src/dash-table/type/number.ts @@ -0,0 +1,55 @@ +import * as R from 'ramda'; +import { formatLocale } from 'd3-format'; +import isNumeric from 'fast-isnumeric'; + +import { INumberColumn, INumberLocale, NumberFormat } from 'dash-table/components/Table/props'; +import { reconcileNull, isNully } from './null'; +import { IReconciliation } from './reconcile'; + +const convertToD3 = ({ group, symbol, ...others }: INumberLocale) => ({ + currency: symbol, + thousands: group, + ...R.omit(['separate_4digits', 'symbol'], others) +}); + +export function coerce(value: any, options: INumberColumn | undefined): IReconciliation { + return isNumeric(value) ? + { success: true, value: +value } : + reconcileNull(value, options); +} + +export function getFormatter(format: NumberFormat) { + if (!format) { + return (value: any) => value; + } + + const locale = formatLocale(convertToD3(format.locale)); + + const numberFormatter = format.prefix ? + locale.formatPrefix(format.specifier, format.prefix) : + locale.format(format.specifier); + + const thousandsSpecifier = format.locale.separate_4digits ? + format.specifier : + format.specifier.replace(/,/, ''); + + const thousandsFormatter = format.prefix ? + locale.formatPrefix(thousandsSpecifier, format.prefix) : + locale.format(thousandsSpecifier); + + return (value: any) => { + value = isNully(value) ? format.nully : value; + + return typeof value !== 'number' ? + value : + Math.abs(value) < 10000 ? + thousandsFormatter(value) : + numberFormatter(value); + }; +} + +export function validate(value: any, options: INumberColumn | undefined): IReconciliation { + return typeof value === 'number' && !isNully(value) ? + { success: true, value } : + reconcileNull(value, options); +} \ No newline at end of file diff --git a/src/dash-table/reconcile/index.ts b/src/dash-table/type/reconcile.ts similarity index 88% rename from src/dash-table/reconcile/index.ts rename to src/dash-table/type/reconcile.ts index bbf633f07..cf8808eed 100644 --- a/src/dash-table/reconcile/index.ts +++ b/src/dash-table/type/reconcile.ts @@ -63,16 +63,14 @@ function doFailureRecovery(result: IReconciliation, c: IColumnType) { const failure = (c && c.on_change && c.on_change.failure) || ChangeFailure.Reject; result.failure = failure; - // If Skip/Prevent - if (failure !== ChangeFailure.Default) { - return result; + if (failure === ChangeFailure.Default) { + const defaultValue = (c && c.validation && c.validation.default) || null; + result.success = true; + result.value = defaultValue; + } else if (failure === ChangeFailure.Accept) { + result.success = true; } - // If Default, apply default - const defaultValue = (c && c.validation && c.validation.default) || null; - result.success = true; - result.value = defaultValue; - return result; } @@ -84,4 +82,4 @@ export default (value: any, c: IColumnType) => { } return doFailureRecovery(res, c); -}; +}; \ No newline at end of file diff --git a/src/dash-table/reconcile/text.ts b/src/dash-table/type/text.ts similarity index 92% rename from src/dash-table/reconcile/text.ts rename to src/dash-table/type/text.ts index aa1d3b5a3..c8f9e45db 100644 --- a/src/dash-table/reconcile/text.ts +++ b/src/dash-table/type/text.ts @@ -1,6 +1,6 @@ import { ITextColumn } from 'dash-table/components/Table/props'; import { isNully, reconcileNull } from './null'; -import { IReconciliation } from '.'; +import { IReconciliation } from './reconcile'; export function coerce(value: any, options: ITextColumn | undefined): IReconciliation { return isNully(value) ? diff --git a/src/dash-table/utils/TableClipboardHelper.ts b/src/dash-table/utils/TableClipboardHelper.ts index e2a5e9d1d..7d7698209 100644 --- a/src/dash-table/utils/TableClipboardHelper.ts +++ b/src/dash-table/utils/TableClipboardHelper.ts @@ -8,6 +8,8 @@ import { ActiveCell, Columns, Data, SelectedCells } from 'dash-table/components/ import applyClipboardToData from './applyClipboardToData'; export default class TableClipboardHelper { + private static lastLocalCopy: any[][] = [[]]; + public static toClipboard(e: any, selectedCells: SelectedCells, columns: Columns, data: Data) { const selectedRows = R.uniq(R.pluck(0, selectedCells).sort((a, b) => a - b)); const selectedCols: any = R.uniq(R.pluck(1, selectedCells).sort((a, b) => a - b)); @@ -21,6 +23,7 @@ export default class TableClipboardHelper { ); const value = SheetClip.prototype.stringify(df); + TableClipboardHelper.lastLocalCopy = df; Logger.trace('TableClipboard -- set clipboard data: ', value); @@ -43,7 +46,11 @@ export default class TableClipboardHelper { return; } - const values = SheetClip.prototype.parse(text); + const localDf = SheetClip.prototype.stringify(TableClipboardHelper.lastLocalCopy); + + const values = localDf === text ? + TableClipboardHelper.lastLocalCopy : + SheetClip.prototype.parse(text); return applyClipboardToData( values, diff --git a/src/dash-table/utils/applyClipboardToData.ts b/src/dash-table/utils/applyClipboardToData.ts index 90b5a0074..7973a891d 100644 --- a/src/dash-table/utils/applyClipboardToData.ts +++ b/src/dash-table/utils/applyClipboardToData.ts @@ -3,11 +3,11 @@ import * as R from 'ramda'; import Logger from 'core/Logger'; import { ActiveCell, Columns, Data, ColumnType } from 'dash-table/components/Table/props'; -import reconcile from 'dash-table/reconcile'; +import reconcile from 'dash-table/type/reconcile'; import isEditable from 'dash-table/derived/cell/isEditable'; export default ( - values: string[][], + values: any[][], activeCell: ActiveCell, derived_viewport_indices: number[], columns: Columns, diff --git a/tests/cypress/tests/standalone/formatting_test.ts b/tests/cypress/tests/standalone/formatting_test.ts new file mode 100644 index 000000000..1ec0483b0 --- /dev/null +++ b/tests/cypress/tests/standalone/formatting_test.ts @@ -0,0 +1,51 @@ +import DashTable from 'cypress/DashTable'; +import DOM from 'cypress/DOM'; +import Key from 'cypress/Key'; +import { AppMode } from 'demo/AppMode'; + +describe('formatting', () => { + beforeEach(() => { + cy.visit(`http://localhost:8080?mode=${AppMode.Formatting}`); + DashTable.toggleScroll(false); + }); + + it('can edit formatted cell', () => { + DashTable.getCellById(1, 'eee').within( + () => cy.get('.dash-cell-value').should('have.html', 'N/A') + ); + DashTable.getCellById(1, 'eee').click(); + DOM.focused.type(`1${Key.Enter}`); + DashTable.getCellById(1, 'eee').within( + () => cy.get('.dash-cell-value').should('have.html', '1') + ); + DashTable.getCellById(1, 'eee').click(); + DOM.focused.type(`abc${Key.Enter}`); + DashTable.getCellById(1, 'eee').within( + () => cy.get('.dash-cell-value').should('have.html', 'N/A') + ); + }); + + it('can copy formatted cell and reformat based on destination cell rules', () => { + DashTable.getCellById(2, 'eee').within( + () => cy.get('.dash-cell-value').should('have.html', '3') + ); + DashTable.getCellById(2, 'eee').click(); + DOM.focused.type(`${Key.Shift}${Key.ArrowDown}${Key.ArrowDown}`); + DOM.focused.type(`${Key.Meta}c`); + + DashTable.getCellById(2, 'ddd').click(); + DOM.focused.type(`${Key.Meta}v`); + + DashTable.getCellById(2, 'eee').click(); + + DashTable.getCellById(2, 'ddd').within( + () => cy.get('.dash-cell-value').should('have.html', 'eq. $ 3.00') + ); + DashTable.getCellById(3, 'ddd').within( + () => cy.get('.dash-cell-value').should('have.html', 'eq. $ 0.00') + ); + DashTable.getCellById(4, 'ddd').within( + () => cy.get('.dash-cell-value').should('have.html', 'eq. $ 0.00') + ); + }); +}); \ No newline at end of file diff --git a/tests/cypress/tests/unit/dateCoercion_test.ts b/tests/cypress/tests/unit/dateCoercion_test.ts index 794a9968a..606cc0185 100644 --- a/tests/cypress/tests/unit/dateCoercion_test.ts +++ b/tests/cypress/tests/unit/dateCoercion_test.ts @@ -1,6 +1,6 @@ import { ColumnType, IDatetimeColumn } from 'dash-table/components/Table/props'; -import { isNully } from 'dash-table/reconcile/null'; -import { coerce } from 'dash-table/reconcile/date'; +import { isNully } from 'dash-table/type/null'; +import { coerce } from 'dash-table/type/date'; const DEFAULT_COERCE_SUCCESS = [ { input: ' 2000 ', output: '2000', name: 'year only' }, diff --git a/tests/cypress/tests/unit/dateValidation_test.ts b/tests/cypress/tests/unit/dateValidation_test.ts index b2f021fc2..78b35d764 100644 --- a/tests/cypress/tests/unit/dateValidation_test.ts +++ b/tests/cypress/tests/unit/dateValidation_test.ts @@ -1,6 +1,6 @@ import { ColumnType, IDatetimeColumn } from 'dash-table/components/Table/props'; -import { isNully } from 'dash-table/reconcile/null'; -import { validate } from 'dash-table/reconcile/date'; +import { isNully } from 'dash-table/type/null'; +import { validate } from 'dash-table/type/date'; const DEFAULT_VALIDATE_SUCCESS = [ { input: ' 2000 ', output: '2000', name: 'year only' }, diff --git a/tests/cypress/tests/unit/formatting_test.ts b/tests/cypress/tests/unit/formatting_test.ts new file mode 100644 index 000000000..3e76d6fc0 --- /dev/null +++ b/tests/cypress/tests/unit/formatting_test.ts @@ -0,0 +1,153 @@ +import { getFormatter } from 'dash-table/type/number'; +import { getLocale, getNully, getSpecifier } from 'dash-table/dash/sanitize'; + +describe('formatting', () => { + describe('number', () => { + it('returns value with undefined format', () => { + const formatter = getFormatter(undefined); + assert.isOk(formatter); + + expect(formatter(0)).to.equal(0); + expect(formatter(1.766)).to.equal(1.766); + expect(formatter('foo')).to.equal('foo'); + expect(isNaN(formatter(NaN))).to.equal(true); + }); + + describe('without nully handling / default locale', () => { + it('formats symbol', () => { + const formatter = getFormatter({ + locale: getLocale(), + nully: getNully(), + specifier: getSpecifier('$.2f') + }); + + assert.isOk(formatter); + + expect(formatter(0)).to.equal('$0.00'); + expect(formatter(1)).to.equal('$1.00'); + expect(formatter(-1)).to.equal('-$1.00'); + expect(formatter(1.23)).to.equal('$1.23'); + expect(formatter(1.232)).to.equal('$1.23'); + expect(formatter(1.239)).to.equal('$1.24'); + expect(formatter(1766)).to.equal('$1766.00'); + expect(formatter(''), 'Empty string case').to.equal(''); + expect(formatter('foo'), 'Foo string case').to.equal('foo'); + expect(formatter(true)).to.equal(true); + expect(formatter(NaN), 'NaN case').to.equal(''); + expect(formatter(Infinity), 'Infinity case').to.equal(''); + expect(formatter(-Infinity), '-Infinity case').to.equal(''); + expect(formatter(null as any), 'null case').to.equal(''); + expect(formatter(undefined as any), 'undef case').to.equal(''); + }); + }); + + describe('with nully handling / default locale', () => { + it('formats significant digits and grouping separator', () => { + const formatter = getFormatter({ + locale: getLocale(), + nully: 42.42, + specifier: getSpecifier(',.2r') + }); + + assert.isOk(formatter); + + expect(formatter(0)).to.equal('0.0'); + expect(formatter(0.13)).to.equal('0.13'); + expect(formatter(0.131)).to.equal('0.13'); + expect(formatter(1.23)).to.equal('1.2'); + expect(formatter(1.299)).to.equal('1.3'); + expect(formatter(1299)).to.equal('1,300'); + expect(formatter(1299431)).to.equal('1,300,000'); + expect(formatter(''), 'Empty string case').to.equal(''); + expect(formatter('foo'), 'Foo string case').to.equal('foo'); + expect(formatter(true)).to.equal(true); + expect(formatter(NaN), 'NaN case').to.equal('42'); + expect(formatter(Infinity), 'Infinity case').to.equal('42'); + expect(formatter(-Infinity), '-Infinity case').to.equal('42'); + expect(formatter(null as any), 'null case').to.equal('42'); + expect(formatter(undefined as any), 'undef case').to.equal('42'); + }); + }); + + describe('with nully handling / partial locale override', () => { + it('formats significant digits and grouping separator', () => { + const formatter = getFormatter({ + locale: getLocale({ + decimal: 'x', + group: 'y', + grouping: [2, 1] + }), + nully: getNully('42.4242'), + specifier: getSpecifier(',.2f') + }); + + assert.isOk(formatter); + + expect(formatter(0)).to.equal('0x00'); + expect(formatter(0.13)).to.equal('0x13'); + expect(formatter(0.131)).to.equal('0x13'); + expect(formatter(1.23)).to.equal('1x23'); + expect(formatter(1.299)).to.equal('1x30'); + expect(formatter(1299)).to.equal('1y2y99x00'); + expect(formatter(1299431)).to.equal('1y2y99y4y31x00'); + expect(formatter(''), 'Empty string case').to.equal(''); + expect(formatter('foo'), 'Foo string case').to.equal('foo'); + expect(formatter(true)).to.equal(true); + expect(formatter(NaN), 'NaN case').to.equal('42.4242'); + expect(formatter(Infinity), 'Infinity case').to.equal('42.4242'); + expect(formatter(-Infinity), '-Infinity case').to.equal('42.4242'); + expect(formatter(null as any), 'null case').to.equal('42.4242'); + expect(formatter(undefined as any), 'undef case').to.equal('42.4242'); + }); + }); + + describe('with nully handling / partial locale override with separate_4digits', () => { + it('formats significant digits and grouping separator', () => { + const formatter = getFormatter({ + locale: getLocale({ + grouping: [2, 1], + separate_4digits: false + }), + nully: getNully('42.4242'), + specifier: getSpecifier(',.2f') + }); + + assert.isOk(formatter); + + expect(formatter(-1299)).to.equal('-1299.00'); + expect(formatter(-1299431)).to.equal('-1,2,99,4,31.00'); + expect(formatter(1299)).to.equal('1299.00'); + expect(formatter(1299431)).to.equal('1,2,99,4,31.00'); + }); + }); + + describe('without nully handling / default locale / si prefix', () => { + it('formats symbol', () => { + const formatter = getFormatter({ + locale: getLocale(), + nully: getNully(), + prefix: 0.001, + specifier: getSpecifier('.0f') + }); + + assert.isOk(formatter); + + expect(formatter(0)).to.equal('0m'); + expect(formatter(1)).to.equal('1000m'); + expect(formatter(-1)).to.equal('-1000m'); + expect(formatter(1.23)).to.equal('1230m'); + expect(formatter(1.232)).to.equal('1232m'); + expect(formatter(1.239)).to.equal('1239m'); + expect(formatter(1766)).to.equal('1766000m'); + expect(formatter(''), 'Empty string case').to.equal(''); + expect(formatter('foo'), 'Foo string case').to.equal('foo'); + expect(formatter(true)).to.equal(true); + expect(formatter(NaN), 'NaN case').to.equal(''); + expect(formatter(Infinity), 'Infinity case').to.equal(''); + expect(formatter(-Infinity), '-Infinity case').to.equal(''); + expect(formatter(null as any), 'null case').to.equal(''); + expect(formatter(undefined as any), 'undef case').to.equal(''); + }); + }); + }); +}); \ No newline at end of file diff --git a/tests/cypress/tests/unit/numberCoercion_test.ts b/tests/cypress/tests/unit/numberCoercion_test.ts index 6a02320c2..c8ea74c69 100644 --- a/tests/cypress/tests/unit/numberCoercion_test.ts +++ b/tests/cypress/tests/unit/numberCoercion_test.ts @@ -1,6 +1,6 @@ import { ColumnType, INumberColumn } from 'dash-table/components/Table/props'; -import { isNully } from 'dash-table/reconcile/null'; -import { coerce } from 'dash-table/reconcile/number'; +import { isNully } from 'dash-table/type/null'; +import { coerce } from 'dash-table/type/number'; const DEFAULT_COERCE_SUCCESS = [ { input: 42, output: 42, name: 'from number' }, diff --git a/tests/cypress/tests/unit/numberValidation_test.ts b/tests/cypress/tests/unit/numberValidation_test.ts index e4304dc49..4e6c577ab 100644 --- a/tests/cypress/tests/unit/numberValidation_test.ts +++ b/tests/cypress/tests/unit/numberValidation_test.ts @@ -1,6 +1,6 @@ import { ColumnType, INumberColumn } from 'dash-table/components/Table/props'; -import { isNully } from 'dash-table/reconcile/null'; -import { validate } from 'dash-table/reconcile/number'; +import { isNully } from 'dash-table/type/null'; +import { validate } from 'dash-table/type/number'; const DEFAULT_VALIDATE_SUCCESS = [ { input: 42, output: 42, name: 'from number' } diff --git a/tests/cypress/tests/unit/textCoercion_test.ts b/tests/cypress/tests/unit/textCoercion_test.ts index 79838bfbd..3474f3e09 100644 --- a/tests/cypress/tests/unit/textCoercion_test.ts +++ b/tests/cypress/tests/unit/textCoercion_test.ts @@ -1,5 +1,5 @@ import { ColumnType, ITextColumn } from 'dash-table/components/Table/props'; -import { coerce } from 'dash-table/reconcile/text'; +import { coerce } from 'dash-table/type/text'; const DEFAULT_COERCE_SUCCESS = [ { input: 42, output: '42', name: 'from number' }, diff --git a/tests/cypress/tests/unit/textValidation_test.ts b/tests/cypress/tests/unit/textValidation_test.ts index 8345f8134..c3be052c2 100644 --- a/tests/cypress/tests/unit/textValidation_test.ts +++ b/tests/cypress/tests/unit/textValidation_test.ts @@ -1,6 +1,6 @@ import { ColumnType, ITextColumn } from 'dash-table/components/Table/props'; -import { isNully } from 'dash-table/reconcile/null'; -import { validate } from 'dash-table/reconcile/text'; +import { isNully } from 'dash-table/type/null'; +import { validate } from 'dash-table/type/text'; const DEFAULT_VALIDATE_SUCCESS = [ { input: '42', output: '42', name: 'from string' } diff --git a/tests/cypress/tsconfig.json b/tests/cypress/tsconfig.json index f9d9ff27c..d939392d7 100644 --- a/tests/cypress/tsconfig.json +++ b/tests/cypress/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "./../../tsconfig.base.json", "compilerOptions": { - "baseUrl": "./../..", + "baseUrl": ".", "paths": { "cypress/*": ["./src/*"], "core/*": ["./../../src/core/*"], diff --git a/tests/unit/format_test.py b/tests/unit/format_test.py new file mode 100644 index 000000000..a31b0e78c --- /dev/null +++ b/tests/unit/format_test.py @@ -0,0 +1,250 @@ +import unittest + +import dash_table.Format as f +from dash_table.Format import Format +import dash_table.FormatTemplate as FormatTemplate + +class FormatTest(unittest.TestCase): + def validate_complex(self, res): + self.assertEqual(res['locale']['symbol'][0], 'a') + self.assertEqual(res['locale']['symbol'][1], 'bc') + self.assertEqual(res['locale']['decimal'], 'x') + self.assertEqual(res['locale']['group'], 'y') + self.assertEqual(res['nully'], 'N/A') + self.assertEqual(res['prefix'], None) + self.assertEqual(res['specifier'], '.^($010,.6s') + + def test_complex_and_valid_in_ctor(self): + res = Format( + align=f.Align.center, + fill='.', + group=f.Group.yes, + padding=True, + padding_width=10, + precision=6, + scheme='s', + sign=f.Sign.parantheses, + symbol=f.Symbol.yes, + symbol_prefix='a', + symbol_suffix='bc', + decimal_delimiter='x', + group_delimiter='y', + groups=[2, 2, 2, 3], + nully='N/A', + si_prefix=f.Prefix.none + ) + + self.validate_complex(res.to_plotly_json()) + + def test_complex_and_valid_in_fluent(self): + res = Format().align(f.Align.center).fill('.').group(f.Group.yes).padding(True).padding_width(10).precision(6).scheme('s').sign(f.Sign.parantheses).symbol(f.Symbol.yes).symbol_prefix('a').symbol_suffix('bc').decimal_delimiter('x').group_delimiter('y').groups([2, 2, 2, 3]).nully('N/A').si_prefix(f.Prefix.none) + + self.validate_complex(res.to_plotly_json()) + + def test_money_template(self): + res = FormatTemplate.money(2).to_plotly_json() + + self.assertEqual(res['specifier'], '$,.2f') + + def test_percentage_template(self): + res = FormatTemplate.percentage(1).to_plotly_json() + + self.assertEqual(res['specifier'], '.1%') + + def test_valid_align_named(self): + Format().align(f.Align.center) + + def test_valid_align_string(self): + Format().align('=') + + def test_invalid_align_string(self): + self.assertRaises(TypeError, Format().align, 'i') + + def test_invalid_align_type(self): + self.assertRaises(TypeError, Format().align, 7) + + def test_valid_fill(self): + Format().fill('.') + + def test_invalid_fill_length(self): + self.assertRaises(ValueError, Format().fill, 'invalid') + + def test_invalid_fill_type(self): + self.assertRaises(TypeError, Format().fill, 7) + + def test_valid_group_bool(self): + Format().group(True) + + def test_valid_group_string(self): + Format().group(',') + + def test_valid_group_named(self): + Format().group(f.Group.no) + + def test_invalid_group_type(self): + self.assertRaises(TypeError, Format().group, 7) + + def test_invalid_group_string(self): + self.assertRaises(TypeError, Format().group, 'invalid') + + def test_valid_padding_bool(self): + Format().padding(False) + + def test_valid_padding_string(self): + Format().padding('0') + + def test_valid_padding_named(self): + Format().padding(f.Padding.no) + + def test_invalid_padding_type(self): + self.assertRaises(TypeError, Format().padding, 7) + + def test_invalid_padding_string(self): + self.assertRaises(TypeError, Format().padding, 'invalid') + + def test_valid_padding_width(self): + Format().padding_width(10) + + def test_valid_padding_width_0(self): + Format().padding_width(0) + + def test_invalid_padding_width_negative(self): + self.assertRaises(ValueError, Format().padding_width, -10) + + def test_invalid_padding_width_type(self): + self.assertRaises(TypeError, Format().padding_width, 7.7) + + def test_valid_precision(self): + Format().precision(10) + + def test_valid_precision_0(self): + Format().precision(0) + + def test_invalid_precision_negative(self): + self.assertRaises(ValueError, Format().precision, -10) + + def test_invalid_precision_type(self): + self.assertRaises(TypeError, Format().precision, 7.7) + + def test_valid_prefix_number(self): + Format().si_prefix(10**-24) + + def test_valid_prefix_named(self): + Format().si_prefix(f.Prefix.micro) + + def test_invalid_prefix_number(self): + self.assertRaises(TypeError, Format().si_prefix, 10**-23) + + def test_invalid_prefix_type(self): + self.assertRaises(TypeError, Format().si_prefix, '10**-23') + + def test_valid_scheme_string(self): + Format().scheme('s') + + def test_valid_scheme_named(self): + Format().scheme(f.Scheme.decimal) + + def test_invalid_scheme_string(self): + self.assertRaises(TypeError, Format().scheme, 'invalid') + + def test_invalid_scheme_type(self): + self.assertRaises(TypeError, Format().scheme, 7) + + def test_valid_sign_string(self): + Format().sign('+') + + def test_valid_sign_named(self): + Format().sign(f.Sign.space) + + def test_invalid_sign_string(self): + self.assertRaises(TypeError, Format().sign, 'invalid') + + def test_invalid_sign_type(self): + self.assertRaises(TypeError, Format().sign, 7) + + def test_valid_symbol_string(self): + Format().symbol('$') + + def test_valid_symbol_named(self): + Format().symbol(f.Symbol.hex) + + def test_invalid_symbol_string(self): + self.assertRaises(TypeError, Format().symbol, 'invalid') + + def test_invalid_symbol_type(self): + self.assertRaises(TypeError, Format().symbol, 7) + + def test_valid_symbol_prefix(self): + Format().symbol_prefix('abc+-') + + def test_invalid_symbol_prefix_type(self): + self.assertRaises(TypeError, Format().symbol_prefix, 7) + + def test_valid_symbol_suffix(self): + Format().symbol_suffix('abc+-') + + def test_invalid_symbol_suffix(self): + self.assertRaises(TypeError, Format().symbol_suffix, 7) + + def test_valid_trim_boolean(self): + Format().trim(False) + + def test_valid_trim_string(self): + Format().trim('~') + + def test_valid_trim_named(self): + Format().trim(f.Trim.yes) + + def test_invalid_trim_string(self): + self.assertRaises(TypeError, Format().trim, 'invalid') + + def test_invalid_trim_type(self): + self.assertRaises(TypeError, Format().trim, 7) + + def test_valid_decimal_delimiter(self): + Format().decimal_delimiter('x') + + def test_valid_decimal_delimiter(self): + self.assertRaises(ValueError, Format().decimal_delimiter, 'xyz') + + def test_invalid_decimal_delimiter(self): + self.assertRaises(TypeError, Format().decimal_delimiter, 7) + + def test_valid_group_delimiator(self): + Format().group_delimiter('y') + + def test_valid_group_delimiator(self): + self.assertRaises(ValueError, Format().group_delimiter, 'xyz') + + def test_invalid_group_delimiter(self): + self.assertRaises(TypeError, Format().group_delimiter, 7) + + def test_valid_groups(self): + Format().groups([3]) + + def test_valid_groups_single(self): + Format().groups(3) + + def test_valid_groups_multi(self): + Format().groups([2, 2, 3]) + + def test_invalid_groups_single_0(self): + self.assertRaises(ValueError, Format().groups, 0) + + def test_invalid_groups_single_negative(self): + self.assertRaises(ValueError, Format().groups, -7) + + def test_invalid_groups_single_type(self): + self.assertRaises(TypeError, Format().groups, 7.7) + + def test_invalid_groups_empty(self): + self.assertRaises(ValueError, Format().groups, []) + + def test_invalid_groups_nested_type(self): + self.assertRaises(TypeError, Format().groups, [7.7, 7]) + + def test_invalid_groups_nested_0(self): + self.assertRaises(ValueError, Format().groups, [3, 3, 0]) + + def test_invalid_groups_nested_negative(self): + self.assertRaises(ValueError, Format().groups, [3, 3, -7]) \ No newline at end of file diff --git a/tests/visual/percy-storybook/Border.percy.tsx b/tests/visual/percy-storybook/Border.percy.tsx index 3fcb60e43..0f60e9732 100644 --- a/tests/visual/percy-storybook/Border.percy.tsx +++ b/tests/visual/percy-storybook/Border.percy.tsx @@ -2,7 +2,7 @@ import * as R from 'ramda'; import React from 'react'; import { storiesOf } from '@storybook/react'; import random from 'core/math/random'; -import DataTable from 'dash-table/DataTable'; +import DataTable from 'dash-table/dash/DataTable'; const setProps = () => { }; diff --git a/tests/visual/percy-storybook/DashTable.percy.tsx b/tests/visual/percy-storybook/DashTable.percy.tsx index 5fd1e9005..c8e6fa184 100644 --- a/tests/visual/percy-storybook/DashTable.percy.tsx +++ b/tests/visual/percy-storybook/DashTable.percy.tsx @@ -2,7 +2,7 @@ import * as R from 'ramda'; import React from 'react'; import { storiesOf } from '@storybook/react'; import random from 'core/math/random'; -import DataTable from 'dash-table/DataTable'; +import DataTable from 'dash-table/dash/DataTable'; import fixtures from './fixtures'; const setProps = () => { }; diff --git a/tests/visual/percy-storybook/Filters.percy.tsx b/tests/visual/percy-storybook/Filters.percy.tsx index 43a6cca14..c08197cf9 100644 --- a/tests/visual/percy-storybook/Filters.percy.tsx +++ b/tests/visual/percy-storybook/Filters.percy.tsx @@ -2,7 +2,7 @@ import * as R from 'ramda'; import React from 'react'; import { storiesOf } from '@storybook/react'; import random from 'core/math/random'; -import DataTable from 'dash-table/DataTable'; +import DataTable from 'dash-table/dash/DataTable'; const setProps = () => { }; diff --git a/tests/visual/percy-storybook/Style.percy.tsx b/tests/visual/percy-storybook/Style.percy.tsx index 67fd6d4a2..b79e9c7cf 100644 --- a/tests/visual/percy-storybook/Style.percy.tsx +++ b/tests/visual/percy-storybook/Style.percy.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { storiesOf } from '@storybook/react'; -import DataTable from 'dash-table/DataTable'; +import DataTable from 'dash-table/dash/DataTable'; import fixtures from './fixtures'; import { ColumnType } from 'dash-table/components/Table/props'; diff --git a/tests/visual/percy-storybook/TriadValidation.percy.tsx b/tests/visual/percy-storybook/TriadValidation.percy.tsx index 871ef90b0..4fbfb3e07 100644 --- a/tests/visual/percy-storybook/TriadValidation.percy.tsx +++ b/tests/visual/percy-storybook/TriadValidation.percy.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { storiesOf } from '@storybook/react'; -import DataTable from 'dash-table/DataTable'; +import DataTable from 'dash-table/dash/DataTable'; const filteringValues = ['fe', 'be']; const sortingValues = ['fe', 'be']; diff --git a/tests/visual/percy-storybook/Width.all.percy.tsx b/tests/visual/percy-storybook/Width.all.percy.tsx index bf48be2f1..bc90d6c6b 100644 --- a/tests/visual/percy-storybook/Width.all.percy.tsx +++ b/tests/visual/percy-storybook/Width.all.percy.tsx @@ -2,7 +2,7 @@ import * as R from 'ramda'; import React from 'react'; import { storiesOf } from '@storybook/react'; import random from 'core/math/random'; -import DataTable from 'dash-table/DataTable'; +import DataTable from 'dash-table/dash/DataTable'; const setProps = () => { }; diff --git a/tests/visual/percy-storybook/Width.defaults.percy.tsx b/tests/visual/percy-storybook/Width.defaults.percy.tsx index dc7d1a7f9..fe050ddcd 100644 --- a/tests/visual/percy-storybook/Width.defaults.percy.tsx +++ b/tests/visual/percy-storybook/Width.defaults.percy.tsx @@ -2,7 +2,7 @@ import * as R from 'ramda'; import React from 'react'; import { storiesOf } from '@storybook/react'; import random from 'core/math/random'; -import DataTable from 'dash-table/DataTable'; +import DataTable from 'dash-table/dash/DataTable'; const setProps = () => { }; diff --git a/tests/visual/percy-storybook/Width.max.percy.tsx b/tests/visual/percy-storybook/Width.max.percy.tsx index ff850ba20..de4157f70 100644 --- a/tests/visual/percy-storybook/Width.max.percy.tsx +++ b/tests/visual/percy-storybook/Width.max.percy.tsx @@ -2,7 +2,7 @@ import * as R from 'ramda'; import React from 'react'; import { storiesOf } from '@storybook/react'; import random from 'core/math/random'; -import DataTable from 'dash-table/DataTable'; +import DataTable from 'dash-table/dash/DataTable'; const setProps = () => { }; diff --git a/tests/visual/percy-storybook/Width.min.percy.tsx b/tests/visual/percy-storybook/Width.min.percy.tsx index 29d91ddaa..ad9525100 100644 --- a/tests/visual/percy-storybook/Width.min.percy.tsx +++ b/tests/visual/percy-storybook/Width.min.percy.tsx @@ -2,7 +2,7 @@ import * as R from 'ramda'; import React from 'react'; import { storiesOf } from '@storybook/react'; import random from 'core/math/random'; -import DataTable from 'dash-table/DataTable'; +import DataTable from 'dash-table/dash/DataTable'; const setProps = () => { }; diff --git a/tests/visual/percy-storybook/Width.percentages.percy.tsx b/tests/visual/percy-storybook/Width.percentages.percy.tsx index a8bd0f85e..0bdea6f0f 100644 --- a/tests/visual/percy-storybook/Width.percentages.percy.tsx +++ b/tests/visual/percy-storybook/Width.percentages.percy.tsx @@ -2,7 +2,7 @@ import * as R from 'ramda'; import React from 'react'; import { storiesOf } from '@storybook/react'; import random from 'core/math/random'; -import DataTable from 'dash-table/DataTable'; +import DataTable from 'dash-table/dash/DataTable'; const setProps = () => { }; const columns = ['a', 'b', 'c']; diff --git a/tests/visual/percy-storybook/Width.width.percy.tsx b/tests/visual/percy-storybook/Width.width.percy.tsx index ad418fcb1..2957f66cd 100644 --- a/tests/visual/percy-storybook/Width.width.percy.tsx +++ b/tests/visual/percy-storybook/Width.width.percy.tsx @@ -2,7 +2,7 @@ import * as R from 'ramda'; import React from 'react'; import { storiesOf } from '@storybook/react'; import random from 'core/math/random'; -import DataTable from 'dash-table/DataTable'; +import DataTable from 'dash-table/dash/DataTable'; const setProps = () => { };