From a5df187b4ca1d1209584e5ccfc75515406036f38 Mon Sep 17 00:00:00 2001 From: Vlad Babich Date: Thu, 11 Mar 2021 18:24:12 +0000 Subject: [PATCH 1/7] IDS-5875 Fix errors on shrinking tables stuck to bottom - Grid metrics were out of sync, change when they are generated to fix the issue --- packages/grid/src/Grid.jsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/grid/src/Grid.jsx b/packages/grid/src/Grid.jsx index 80b7b621a5..4a48e19abe 100644 --- a/packages/grid/src/Grid.jsx +++ b/packages/grid/src/Grid.jsx @@ -240,6 +240,8 @@ class Grid extends PureComponent { this.isStuckToRight = false; } + this.updateMetrics(); + this.requestUpdateCanvas(); if (!this.metrics || !this.prevMetrics) { @@ -454,12 +456,12 @@ class Grid extends PureComponent { this.animationFrame = requestAnimationFrame(() => { this.animationFrame = null; - this.updateCanvas(); + this.updateCanvas(this.metrics); }); } - updateCanvas() { - const metrics = this.updateMetrics(); + updateCanvas(metrics = this.updateMetrics()) { + this.updateCanvasScale(); const { onViewChanged } = this.props; onViewChanged(metrics); From d890f18594168c9d374e4435507ead76d30a2e5e Mon Sep 17 00:00:00 2001 From: Vlad Babich Date: Wed, 24 Mar 2021 17:10:02 +0000 Subject: [PATCH 2/7] IDS-7941 Fix conditional formatting for cells --- .../code-studio/src/iris-grid/IrisGrid.jsx | 23 +--- .../src/iris-grid/IrisGridModel.js | 7 -- .../src/iris-grid/IrisGridTableModel.js | 103 ++++++++---------- .../formatters/DateTimeColumnFormatter.js | 8 -- .../formatters/DecimalColumnFormatter.js | 9 -- .../formatters/IntegerColumnFormatter.js | 9 -- .../formatters/TableColumnFormatter.js | 2 - 7 files changed, 46 insertions(+), 115 deletions(-) diff --git a/packages/code-studio/src/iris-grid/IrisGrid.jsx b/packages/code-studio/src/iris-grid/IrisGrid.jsx index cf2b2f6a5e..effdeb24ab 100644 --- a/packages/code-studio/src/iris-grid/IrisGrid.jsx +++ b/packages/code-studio/src/iris-grid/IrisGrid.jsx @@ -302,8 +302,6 @@ export class IrisGrid extends Component { isMenuShown: false, customColumnFormatMap: new Map(customColumnFormatMap), - queryColumnFormatMap: new Map(), - // Column user is hovering over for selection hoverSelectColumn: null, @@ -960,15 +958,13 @@ export class IrisGrid extends Component { } updateFormatter(updatedFormats, forceUpdate = true) { - const { customColumnFormatMap, queryColumnFormatMap } = this.state; + const { customColumnFormatMap } = this.state; const update = { customColumnFormatMap, - queryColumnFormatMap, ...updatedFormats, }; const mergedColumnFormats = [ ...this.globalColumnFormats, - ...update.queryColumnFormatMap.values(), ...update.customColumnFormatMap.values(), ]; const formatter = new Formatter( @@ -986,21 +982,8 @@ export class IrisGrid extends Component { } initFormatter() { - const { model, settings } = this.props; - this.updateFormatterSettings(settings, false); - this.pending - .add(model.columnFormatMap()) - .then(queryColumnFormatMap => { - if (queryColumnFormatMap.size) { - this.updateFormatter({ queryColumnFormatMap }); - } - }) - .catch(error => { - if (PromiseUtils.isCanceled(error)) { - return; - } - log.error('getColumnFormatMap error', error); - }); + const { settings } = this.props; + this.updateFormatterSettings(settings); } initState() { diff --git a/packages/code-studio/src/iris-grid/IrisGridModel.js b/packages/code-studio/src/iris-grid/IrisGridModel.js index 803e184ba0..372d0093e7 100644 --- a/packages/code-studio/src/iris-grid/IrisGridModel.js +++ b/packages/code-studio/src/iris-grid/IrisGridModel.js @@ -321,13 +321,6 @@ class IrisGridModel extends GridModel { throw new Error('columnStatistics not implemented'); } - /** - * @returns {Promise} Map of column name to the formatting rule for them - */ - async columnFormatMap() { - throw new Error('columnFormatMap not implemented'); - } - /** * Close this model. It can no longer be used after being closed */ diff --git a/packages/code-studio/src/iris-grid/IrisGridTableModel.js b/packages/code-studio/src/iris-grid/IrisGridTableModel.js index 1dd2c265f5..8938980a21 100644 --- a/packages/code-studio/src/iris-grid/IrisGridTableModel.js +++ b/packages/code-studio/src/iris-grid/IrisGridTableModel.js @@ -8,11 +8,7 @@ import { PromiseUtils } from '@deephaven/utils'; import memoizeClear from '../include/memoizeClear'; import TableUtils from './TableUtils'; import Formatter from './Formatter'; -import { - DecimalColumnFormatter, - DateTimeColumnFormatter, - IntegerColumnFormatter, -} from './formatters'; +import { TableColumnFormatter } from './formatters'; import IrisGridModel from './IrisGridModel'; import AggregationOperation from './sidebar/aggregations/AggregationOperation.ts'; import IrisGridUtils from './IrisGridUtils'; @@ -277,13 +273,30 @@ class IrisGridTableModel extends IrisGridModel { // Use a separate cache from memoization just for the strings that are currently displayed if (this.formattedStringData[x]?.[y] === undefined) { - const data = this.dataForCell(x, y); - if (data == null) { + const value = this.valueForCell(x, y); + if (value == null) { return null; } const column = this.columns[x]; - const text = this.displayString(data.value, column.type, column.name); + const hasCustomColumnFormat = this.getCachedCustomColumnFormatFlag( + this.formatter, + column.type, + column.name + ); + let formatOverride = null; + if (!hasCustomColumnFormat) { + const formatForCell = this.formatForCell(x, y); + if (formatForCell?.formatString != null) { + formatOverride = formatForCell; + } + } + const text = this.displayString( + value, + column.type, + column.name, + formatOverride + ); this.cacheFormattedValue(x, y, text); } @@ -529,12 +542,13 @@ class IrisGridTableModel extends IrisGridModel { this.dispatchEvent(new CustomEvent(IrisGridModel.EVENT.FORMATTER_UPDATED)); } - displayString(value, columnType, columnName = '') { + displayString(value, columnType, columnName = '', formatOverride = null) { return this.getCachedFormattedString( this.formatter, value, columnType, - columnName + columnName, + formatOverride ); } @@ -854,8 +868,25 @@ class IrisGridTableModel extends IrisGridModel { ); getCachedFormattedString = memoizeClear( - (formatter, value, columnType, columnName) => - formatter.getFormattedString(value, columnType, columnName), + (formatter, value, columnType, columnName, formatOverride) => + formatter.getFormattedString( + value, + columnType, + columnName, + formatOverride + ), + { max: 10000 } + ); + + getCachedCustomColumnFormatFlag = memoizeClear( + (formatter, columnType, columnName) => { + const columnFormat = formatter.getColumnFormat(columnType, columnName); + return ( + columnFormat != null && + (columnFormat.type === TableColumnFormatter.TYPE_CONTEXT_PRESET || + columnFormat.type === TableColumnFormatter.TYPE_CONTEXT_CUSTOM) + ); + }, { max: 10000 } ); @@ -870,54 +901,6 @@ class IrisGridTableModel extends IrisGridModel { return [viewportTop, viewportBottom]; }); - columnFormatMap() { - return new Promise(resolve => { - const { table } = this; - let cleanup = null; - cleanup = table.addEventListener(dh.Table.EVENT_UPDATED, event => { - const { rows } = event.detail; - if (rows.length < 1) { - return; - } - - const row = rows[0]; - const map = new Map(); - table.columns.forEach(column => { - const columnFormat = row.getFormat(column); - const { formatString } = columnFormat; - const { name, type } = column; - if (formatString) { - let format = null; - const normalizedType = TableUtils.getNormalizedType(type); - switch (normalizedType) { - case TableUtils.dataType.DECIMAL: - format = DecimalColumnFormatter.makeQueryFormat(formatString); - break; - case TableUtils.dataType.INT: - format = IntegerColumnFormatter.makeQueryFormat(formatString); - break; - case TableUtils.dataType.DATETIME: - format = DateTimeColumnFormatter.makeQueryFormat(formatString); - break; - default: - } - if (format) { - const columnFormattingRule = Formatter.makeColumnFormattingRule( - normalizedType, - name, - format - ); - map.set(name, columnFormattingRule); - } - } - }); - - resolve(map); - cleanup(); - }); - }); - } - isColumnMovable(column) { return column >= (this.inputTable?.keyColumns.length ?? 0); } diff --git a/packages/code-studio/src/iris-grid/formatters/DateTimeColumnFormatter.js b/packages/code-studio/src/iris-grid/formatters/DateTimeColumnFormatter.js index f26630eb4c..dfe1cbb83f 100644 --- a/packages/code-studio/src/iris-grid/formatters/DateTimeColumnFormatter.js +++ b/packages/code-studio/src/iris-grid/formatters/DateTimeColumnFormatter.js @@ -32,14 +32,6 @@ class DateTimeColumnFormatter extends TableColumnFormatter { }; } - static makeQueryFormat(formatString = null) { - return DateTimeColumnFormatter.makeFormat( - 'Query Format', - formatString, - TableColumnFormatter.TYPE_QUERY - ); - } - /** * Check if the given formats match * @param {?Object} formatA format object to check diff --git a/packages/code-studio/src/iris-grid/formatters/DecimalColumnFormatter.js b/packages/code-studio/src/iris-grid/formatters/DecimalColumnFormatter.js index bd4be1d5ee..35745ca8d2 100644 --- a/packages/code-studio/src/iris-grid/formatters/DecimalColumnFormatter.js +++ b/packages/code-studio/src/iris-grid/formatters/DecimalColumnFormatter.js @@ -43,15 +43,6 @@ class DecimalTableColumnFormatter extends TableColumnFormatter { ); } - static makeQueryFormat(formatString = null, multiplier = null) { - return DecimalTableColumnFormatter.makeFormat( - 'Query Format', - formatString, - multiplier, - TableColumnFormatter.TYPE_QUERY - ); - } - static DEFAULT_FORMAT_STRING = '###,##0.0000'; static FORMAT_PERCENT = DecimalTableColumnFormatter.makeFormat( diff --git a/packages/code-studio/src/iris-grid/formatters/IntegerColumnFormatter.js b/packages/code-studio/src/iris-grid/formatters/IntegerColumnFormatter.js index de3ea93694..c74886fb0b 100644 --- a/packages/code-studio/src/iris-grid/formatters/IntegerColumnFormatter.js +++ b/packages/code-studio/src/iris-grid/formatters/IntegerColumnFormatter.js @@ -44,15 +44,6 @@ class IntegerColumnFormatter extends TableColumnFormatter { ); } - static makeQueryFormat(formatString = null, multiplier = null) { - return IntegerColumnFormatter.makeFormat( - 'Query Format', - formatString, - multiplier, - TableColumnFormatter.TYPE_QUERY - ); - } - /** * Check if the given formats match * @param {?Object} formatA format object to check diff --git a/packages/code-studio/src/iris-grid/formatters/TableColumnFormatter.js b/packages/code-studio/src/iris-grid/formatters/TableColumnFormatter.js index 35ba817bee..bfd5ad85d4 100644 --- a/packages/code-studio/src/iris-grid/formatters/TableColumnFormatter.js +++ b/packages/code-studio/src/iris-grid/formatters/TableColumnFormatter.js @@ -11,8 +11,6 @@ class TableColumnFormatter { static TYPE_CONTEXT_CUSTOM = 'type-context-custom'; - static TYPE_QUERY = 'type-query'; - /** * Validates format object * @param {Object} format Format object From 742d93ca87419a87d8b043128ea0658346f75728 Mon Sep 17 00:00:00 2001 From: Don McKenzie Date: Tue, 30 Mar 2021 14:20:07 +0000 Subject: [PATCH 3/7] IDS-8096: Fix bubbling context menu position, added tab nav --- .../src/context-actions/ContextActions.scss | 11 +++- .../src/context-actions/ContextMenu.jsx | 53 ++++++++++++------- .../src/context-actions/ContextMenuItem.jsx | 4 +- .../ContextActions.test.jsx.snap | 9 ---- 4 files changed, 48 insertions(+), 29 deletions(-) diff --git a/packages/components/src/context-actions/ContextActions.scss b/packages/components/src/context-actions/ContextActions.scss index d6a80b06f6..7069034713 100644 --- a/packages/components/src/context-actions/ContextActions.scss +++ b/packages/components/src/context-actions/ContextActions.scss @@ -79,11 +79,13 @@ $hr-bg-color: $gray-700; // firefox can misbehave with buttons as display: flex display: flex; align-items: center; + justify-content: flex-start; } .icon { margin-right: 0.5rem; width: 1rem; + flex-grow: 0; text-align: center; } .icon.outline { @@ -92,17 +94,24 @@ $hr-bg-color: $gray-700; } .title { - flex-grow: 1; + flex: 1 1 auto; + text-align: left; + white-space: nowrap; } .shortcut { color: $text-muted; margin-left: 0.5rem; + justify-self: flex-end; + flex: 1 1 auto; text-align: right; + white-space: nowrap; } .submenu-indicator { color: $contextmenu-color; + flex-grow: 0; + justify-self: flex-end; } .submenu-indicator.disabled { color: $contextmenu-disabled-color; diff --git a/packages/components/src/context-actions/ContextMenu.jsx b/packages/components/src/context-actions/ContextMenu.jsx index a548245649..1ae936e480 100644 --- a/packages/components/src/context-actions/ContextMenu.jsx +++ b/packages/components/src/context-actions/ContextMenu.jsx @@ -38,13 +38,15 @@ class ContextMenu extends PureComponent { this.subMenuTimer = null; this.rAF = null; + this.initialPosition = { top: props.top, left: props.left }; + this.state = { menuItems: [], pendingItems: [], activeSubMenu: null, hasOverflow: false, - subMenuTop: 0, - subMenuLeft: 0, + subMenuTop: null, + subMenuLeft: null, subMenuParentWidth: null, subMenuParentHeight: null, keyboardIndex: -1, @@ -75,19 +77,22 @@ class ContextMenu extends PureComponent { const { actions } = this.props; const { activeSubMenu } = this.state; - if (prevProps.actions !== actions) { - this.initMenu(); - if (!this.container.contains(document.activeElement)) { + if (activeSubMenu !== prevState.activeSubMenu) { + if (activeSubMenu == null) { + // close sub menu, refocus parent menu this.container.focus(); + } else { + // open sub menu, set its initial position + this.setActiveSubMenuPosition(); } } - if (activeSubMenu !== prevState.activeSubMenu) { - this.setActiveSubMenuPosition(); - } + if (prevProps.actions !== actions) { + this.initMenu(); - if (prevState.activeSubMenu !== activeSubMenu) { - this.container.focus(); + if (!this.container.contains(document.activeElement)) { + this.container.focus(); + } } this.verifyPosition(); @@ -256,19 +261,22 @@ class ContextMenu extends PureComponent { updatePosition, subMenuParentWidth, subMenuParentHeight, - left: oldLeft, top: oldTop, + left: oldLeft, } = this.props; if (!this.container || options.doNotVerifyPosition) { return; } - let { top, left } = this.container.getBoundingClientRect(); + // initial position is used rather than current position, + // as the number of menu items can change (actions can bubble) + // and menu should always be positioned relative to spawn point + let { top, left } = this.initialPosition; const { width, height } = this.container.getBoundingClientRect(); const hasOverflow = this.container.scrollHeight > window.innerHeight; - if (height === 0 && width === 0) { + if (height === 0 || width === 0) { // We don't have a height or width yet, don't bother doing anything return; } @@ -357,9 +365,9 @@ class ContextMenu extends PureComponent { } } else if (this.isEscapeKey(e.key)) { newFocus = null; - } else if (e.key === 'ArrowUp') { + } else if (e.key === 'ArrowUp' || (e.shiftKey && e.key === 'Tab')) { newFocus = ContextActionUtils.getNextMenuItem(newFocus, -1, menuItems); - } else if (e.key === 'ArrowDown') { + } else if (e.key === 'ArrowDown' || e.key === 'Tab') { newFocus = ContextActionUtils.getNextMenuItem(newFocus, 1, menuItems); } @@ -386,9 +394,12 @@ class ContextMenu extends PureComponent { } openSubMenu(index) { - const { menuItems } = this.state; + const { menuItems, activeSubMenu } = this.state; + if (activeSubMenu === index) return; this.setState({ activeSubMenu: menuItems[index].actions ? index : null, + subMenuTop: null, + subMenuLeft: null, }); } @@ -402,7 +413,9 @@ class ContextMenu extends PureComponent { } closeSubMenu() { - this.setState({ activeSubMenu: null }); + this.setState({ + activeSubMenu: null, + }); } handleCloseSubMenu(closeAllMenus) { @@ -504,6 +517,10 @@ class ContextMenu extends PureComponent { const { menuStyle } = this.props; + // don't show submenu until it has an position initialized + const showSubmenu = + activeSubMenu !== null && subMenuTop !== null && subMenuLeft !== null; + return ( <>
- {activeSubMenu !== null && ( + {showSubmenu && ( { {icon} {menuItem.title} - {displayShortcut} + {displayShortcut && ( + {displayShortcut} + )} {subMenuIndicator && ( Test1 -
@@ -147,9 +144,6 @@ exports[`renders a promise returning menu items properly 1`] = ` > Test2 - @@ -175,9 +169,6 @@ exports[`renders a promise returning menu items properly 1`] = ` > Test3 - From 7c9d8686120b7f0d170f99e9ee435f5312d843c9 Mon Sep 17 00:00:00 2001 From: Mike Bender Date: Wed, 7 Apr 2021 18:14:33 +0000 Subject: [PATCH 4/7] DH-9597 Fix sorts/filters on rollup tables with custom columns --- .../src/dashboard/panels/IrisGridPanel.jsx | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/packages/code-studio/src/dashboard/panels/IrisGridPanel.jsx b/packages/code-studio/src/dashboard/panels/IrisGridPanel.jsx index 1daeddcf85..0ece50605f 100644 --- a/packages/code-studio/src/dashboard/panels/IrisGridPanel.jsx +++ b/packages/code-studio/src/dashboard/panels/IrisGridPanel.jsx @@ -299,20 +299,31 @@ export class IrisGridPanel extends PureComponent { } = irisGridState; if (customColumns.length > 0) { - modelQueue.push({ customColumns }); + modelQueue.push(m => { + // eslint-disable-next-line no-param-reassign + m.customColumns = customColumns; + }); } if (rollupConfig != null && rollupConfig.columns.length > 0) { - const rollupModelConfig = IrisGridUtils.getModelRollupConfig( - model.originalColumns, - rollupConfig, - aggregationSettings - ); - modelQueue.push({ rollupConfig: rollupModelConfig }); + // originalColumns might change by the time this model queue item is applied. + // Instead of pushing a static object, push the function + // that calculates the config based on the updated model state. + modelQueue.push(m => { + // eslint-disable-next-line no-param-reassign + m.rollupConfig = IrisGridUtils.getModelRollupConfig( + m.originalColumns, + rollupConfig, + aggregationSettings + ); + }); } if (selectDistinctColumns.length > 0) { - modelQueue.push({ selectDistinctColumns }); + modelQueue.push(m => { + // eslint-disable-next-line no-param-reassign + m.selectDistinctColumns = selectDistinctColumns; + }); } } @@ -328,10 +339,8 @@ export class IrisGridPanel extends PureComponent { } const modelChange = modelQueue.shift(); log.debug('initModelQueue', modelChange); - // Model update triggers columnschanged event - Object.keys(modelChange).forEach(key => { - model[key] = modelChange[key]; - }); + // Apply next model change. Triggers columnschanged event. + modelChange(model); this.setState({ modelQueue }); } From 06e26a9beba04a81e9a50d992a1566d15a0d612e Mon Sep 17 00:00:00 2001 From: David Godinez Date: Fri, 16 Apr 2021 14:37:16 +0000 Subject: [PATCH 5/7] DH-11023: Add Method to JS API to Get an Input Table --- packages/code-studio/src/dashboard/panels/IrisGridPanel.jsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/code-studio/src/dashboard/panels/IrisGridPanel.jsx b/packages/code-studio/src/dashboard/panels/IrisGridPanel.jsx index 0ece50605f..74b82edab1 100644 --- a/packages/code-studio/src/dashboard/panels/IrisGridPanel.jsx +++ b/packages/code-studio/src/dashboard/panels/IrisGridPanel.jsx @@ -35,12 +35,13 @@ import LayoutUtils from '../../layout/LayoutUtils'; import IrisGridModel from '../../iris-grid/IrisGridModel'; import AdvancedSettings from '../../iris-grid/sidebar/AdvancedSettings'; import IrisGridTableModel from '../../iris-grid/IrisGridTableModel'; +import ContextMenuRoot from '../../context-actions/ContextMenuRoot'; const log = Log.module('IrisGridPanel'); const DEBOUNCE_PANEL_STATE_UPDATE = 500; -const PLUGIN_COMPONENTS = { IrisGrid, IrisGridTableModel }; +const PLUGIN_COMPONENTS = { IrisGrid, IrisGridTableModel, ContextMenuRoot }; export class IrisGridPanel extends PureComponent { constructor(props) { From fc0751a16252a5ef08365eafb4917c13ed6c2793 Mon Sep 17 00:00:00 2001 From: Mike Bender Date: Mon, 19 Apr 2021 21:11:31 +0000 Subject: [PATCH 6/7] DH-9618 Add new row functionality - Displaying blank new rows on input tables - Can input values appropriately - Press Ctrl+S to commit, Ctrl+Alt+S to discard - Right click and select "Delete Rows" to delete a pending row --- .../src/dashboard/panels/IrisGridPanel.jsx | 14 +- .../code-studio/src/iris-grid/IrisGrid.jsx | 166 +++++++- .../src/iris-grid/IrisGridBottomBar.scss | 90 ++++ .../src/iris-grid/IrisGridBottomBar.tsx | 54 +++ .../src/iris-grid/IrisGridCopyHandler.jsx | 9 + .../src/iris-grid/IrisGridModel.js | 53 ++- .../src/iris-grid/IrisGridModel.test.js | 64 +++ .../src/iris-grid/IrisGridModelUpdater.jsx | 12 + .../src/iris-grid/IrisGridProxyModel.js | 24 ++ .../src/iris-grid/IrisGridRenderer.js | 29 ++ .../src/iris-grid/IrisGridTableModel.js | 397 ++++++++++++++++-- .../src/iris-grid/IrisGridTestUtils.js | 16 +- .../src/iris-grid/IrisGridTheme.js | 2 + .../src/iris-grid/IrisGridUtils.js | 79 ++++ .../src/iris-grid/IrisGridUtils.test.js | 71 ++++ .../src/iris-grid/MissingKeyError.ts | 15 + .../src/iris-grid/PendingDataBottomBar.scss | 14 + .../src/iris-grid/PendingDataBottomBar.tsx | 148 +++++++ .../code-studio/src/iris-grid/TableUtils.js | 10 +- .../mousehandlers/PendingMouseHandler.js | 30 ++ .../src/iris-grid/mousehandlers/index.js | 1 + packages/grid/src/Grid.jsx | 48 +-- packages/grid/src/GridUtils.js | 66 +++ 23 files changed, 1316 insertions(+), 96 deletions(-) create mode 100644 packages/code-studio/src/iris-grid/IrisGridBottomBar.scss create mode 100644 packages/code-studio/src/iris-grid/IrisGridBottomBar.tsx create mode 100644 packages/code-studio/src/iris-grid/MissingKeyError.ts create mode 100644 packages/code-studio/src/iris-grid/PendingDataBottomBar.scss create mode 100644 packages/code-studio/src/iris-grid/PendingDataBottomBar.tsx create mode 100644 packages/code-studio/src/iris-grid/mousehandlers/PendingMouseHandler.js diff --git a/packages/code-studio/src/dashboard/panels/IrisGridPanel.jsx b/packages/code-studio/src/dashboard/panels/IrisGridPanel.jsx index 74b82edab1..c3f3dd4c3b 100644 --- a/packages/code-studio/src/dashboard/panels/IrisGridPanel.jsx +++ b/packages/code-studio/src/dashboard/panels/IrisGridPanel.jsx @@ -117,6 +117,7 @@ export class IrisGridPanel extends PureComponent { pluginFilters: [], pluginFetchColumns: [], modelQueue: [], + pendingDataMap: new Map(), // eslint-disable-next-line react/no-unused-state panelState, // Dehydrated panel state that can load this panel @@ -235,7 +236,8 @@ export class IrisGridPanel extends PureComponent { userColumnWidths, userRowHeights, aggregationSettings, - advancedSettings + advancedSettings, + pendingDataMap ) => IrisGridUtils.dehydrateIrisGridState(table, { advancedFilters, @@ -257,6 +259,7 @@ export class IrisGridPanel extends PureComponent { selectedSearchColumns, sorts, invertSearchColumns, + pendingDataMap, }) ); @@ -641,6 +644,7 @@ export class IrisGridPanel extends PureComponent { selectDistinctColumns, selectedSearchColumns, invertSearchColumns, + pendingDataMap, } = IrisGridUtils.hydrateIrisGridState(table, irisGridState); const { movedColumns, movedRows } = IrisGridUtils.hydrateGridState( table, @@ -670,6 +674,7 @@ export class IrisGridPanel extends PureComponent { selectDistinctColumns, selectedSearchColumns, invertSearchColumns, + pendingDataMap, }); } catch (error) { log.error('loadPanelState failed to load panelState', panelState, error); @@ -702,6 +707,7 @@ export class IrisGridPanel extends PureComponent { sorts, invertSearchColumns, metrics, + pendingDataMap, } = irisGridState; const { userColumnWidths, userRowHeights } = metrics; const { movedColumns, movedRows } = gridState; @@ -733,7 +739,8 @@ export class IrisGridPanel extends PureComponent { userColumnWidths, userRowHeights, aggregationSettings, - advancedSettings + advancedSettings, + pendingDataMap ), this.getDehydratedGridState(table, movedColumns, movedRows) ); @@ -800,6 +807,7 @@ export class IrisGridPanel extends PureComponent { Plugin, pluginFilters, pluginFetchColumns, + pendingDataMap, } = this.state; const errorMessage = error ? `Unable to open table. ${error}` : null; const { table: name, querySerial } = metadata; @@ -872,6 +880,7 @@ export class IrisGridPanel extends PureComponent { onContextMenu={this.handleContextMenu} onAdvancedSettingsChange={this.handleAdvancedSettingsChange} customFilters={pluginFilters} + pendingDataMap={pendingDataMap} ref={this.irisGrid} > {childrenContent} @@ -902,6 +911,7 @@ IrisGridPanel.propTypes = { rollupConfig: PropTypes.shape({ columns: PropTypes.arrayOf(PropTypes.string).isRequired, }), + pendingDataMap: PropTypes.arrayOf(PropTypes.array), }), irisGridPanelState: PropTypes.shape({}), }), diff --git a/packages/code-studio/src/iris-grid/IrisGrid.jsx b/packages/code-studio/src/iris-grid/IrisGrid.jsx index effdeb24ab..bb4ff7e6c7 100644 --- a/packages/code-studio/src/iris-grid/IrisGrid.jsx +++ b/packages/code-studio/src/iris-grid/IrisGrid.jsx @@ -34,7 +34,7 @@ import { vsTools, } from '@deephaven/icons'; import dh from '@deephaven/jsapi-shim'; -import { Pending, PromiseUtils } from '@deephaven/utils'; +import { Pending, PromiseUtils, ValidationError } from '@deephaven/utils'; import throttle from 'lodash.throttle'; import debounce from 'lodash.debounce'; import IrisGridCopyHandler from './IrisGridCopyHandler'; @@ -47,6 +47,7 @@ import { IrisGridDataSelectMouseHandler, IrisGridFilterMouseHandler, IrisGridSortMouseHandler, + PendingMouseHandler, } from './mousehandlers'; import IrisGridMetricCalculator from './IrisGridMetricCalculator'; import IrisGridModelUpdater from './IrisGridModelUpdater'; @@ -154,6 +155,13 @@ export class IrisGrid extends Component { this.handleSelectDistinctChanged = this.handleSelectDistinctChanged.bind( this ); + this.handlePendingDataUpdated = this.handlePendingDataUpdated.bind(this); + this.handlePendingCommitClicked = this.handlePendingCommitClicked.bind( + this + ); + this.handlePendingDiscardClicked = this.handlePendingDiscardClicked.bind( + this + ); this.handleDownloadTable = this.handleDownloadTable.bind(this); this.handleDownloadTableStart = this.handleDownloadTableStart.bind(this); @@ -206,12 +214,24 @@ export class IrisGrid extends Component { }; this.toggleSearchBarAction = { action: () => this.toggleSearchBar(), + shortcut: '⌃⇧F', + macShortcut: '⌘⇧F', + }; + this.discardAction = { + action: () => this.discardPending().catch(log.error), + shortcut: '⌃⌥S', + macShortcut: '⌘⌥S', + }; + this.commitAction = { + action: () => this.commitPending().catch(log.error), shortcut: '⌃S', macShortcut: '⌘S', }; this.contextActions = [ this.toggleFilterBarAction, this.toggleSearchBarAction, + this.discardAction, + this.commitAction, ]; const keyHandlers = [new CopyKeyHandler(this), new ReverseKeyHandler(this)]; @@ -222,6 +242,7 @@ export class IrisGrid extends Component { new IrisGridFilterMouseHandler(this), new IrisGridContextMenuHandler(this), new IrisGridDataSelectMouseHandler(this), + new PendingMouseHandler(this), ]; const { aggregationSettings, @@ -243,6 +264,7 @@ export class IrisGrid extends Component { advancedFilters, quickFilters, selectDistinctColumns, + pendingDataMap, } = props; const metricCalculator = new IrisGridMetricCalculator({ userColumnWidths: new Map(userColumnWidths), @@ -323,6 +345,12 @@ export class IrisGrid extends Component { selectedAggregation: null, openOptions: [], + + pendingRowCount: 0, + pendingDataMap, + pendingDataErrors: new Map(), + pendingSavePromise: null, + pendingSaveError: null, }; } @@ -1205,6 +1233,10 @@ export class IrisGrid extends Component { IrisGridModel.EVENT.COLUMNS_CHANGED, this.handleCustomColumnsChanged ); + model.addEventListener( + IrisGridModel.EVENT.PENDING_DATA_UPDATED, + this.handlePendingDataUpdated + ); } stopListening(model) { @@ -1217,6 +1249,10 @@ export class IrisGrid extends Component { IrisGridModel.EVENT.COLUMNS_CHANGED, this.handleCustomColumnsChanged ); + model.removeEventListener( + IrisGridModel.EVENT.PENDING_DATA_UPDATED, + this.handlePendingDataUpdated + ); } focusFilterBar(column) { @@ -1466,6 +1502,50 @@ export class IrisGrid extends Component { ); } + async commitPending() { + const { pendingSavePromise } = this.state; + if (pendingSavePromise != null) { + throw new Error('Save already in progress'); + } + + if (document.activeElement.classList.contains('grid-cell-input-field')) { + if (document.activeElement.classList.contains('error')) { + throw new ValidationError('Current input is invalid'); + } + + // Focus the grid again to commit any pending input changes + this.grid.focus(); + } + + const { model } = this.props; + const newPendingSavePromise = this.pending + .add(model.commitPending()) + .then(() => { + this.setState({ pendingSaveError: null, pendingSavePromise: null }); + }) + .catch(err => { + if (!PromiseUtils.isCanceled(err)) { + this.setState({ pendingSaveError: err, pendingSavePromise: null }); + } + }); + this.setState({ pendingSavePromise: newPendingSavePromise }); + return newPendingSavePromise; + } + + async discardPending() { + const { pendingSavePromise } = this.state; + if (pendingSavePromise != null) { + throw new Error('Cannot cancel a save in progress'); + } + + this.setState({ + pendingSavePromise: null, + pendingSaveError: null, + pendingDataMap: new Map(), + pendingDataErrors: new Map(), + }); + } + /** * Select the passed in column and notify listener * @param {dh.Column} column The column in this table to link @@ -1681,7 +1761,34 @@ export class IrisGrid extends Component { } handleViewChanged(metrics) { - this.setState({ metrics }); + const { model } = this.props; + const { bottomViewport } = metrics; + const { selectionEndRow = 0 } = this.grid?.state ?? {}; + let pendingRowCount = 0; + if (model.isEditable) { + // We have an editable table that we can add new rows to + // Display empty rows beneath the table rows that user can fill in + const bottomNonFloating = model.rowCount - model.floatingBottomRowCount; + if (selectionEndRow === bottomNonFloating - 1) { + // Selection is in the last row, add another blank row + pendingRowCount = model.pendingRowCount + 1; + } else if (selectionEndRow === bottomNonFloating - 2) { + // We may have just added a row based on selection moving, so just leave it as is + pendingRowCount = model.pendingRowCount; + } else { + // Otherwise fill up the viewport with empty cells + pendingRowCount = Math.max( + 0, + bottomViewport - + (model.rowCount - model.pendingRowCount) - + model.floatingTopRowCount - + model.floatingBottomRowCount - + 1 + ); + } + } + + this.setState({ metrics, pendingRowCount }); } handleSelectionChanged(selectedRanges) { @@ -1794,6 +1901,22 @@ export class IrisGrid extends Component { } } + handlePendingCommitClicked() { + return this.commitPending(); + } + + handlePendingDiscardClicked() { + return this.discardPending(); + } + + handlePendingDataUpdated() { + log.debug('pending data updated'); + const { model } = this.props; + const { pendingDataMap, pendingDataErrors } = model; + this.setState({ pendingDataMap, pendingDataErrors }); + this.grid.forceUpdate(); + } + /** * User added, removed, or changed the order of aggregations, or position * @param {AggregationSettings} aggregationSettings The new aggregation settings @@ -2062,6 +2185,11 @@ export class IrisGrid extends Component { selectedAggregation, rollupConfig, openOptions, + pendingSavePromise, + pendingSaveError, + pendingRowCount, + pendingDataErrors, + pendingDataMap, } = this.state; if (!isReady) { return null; @@ -2570,7 +2698,7 @@ export class IrisGrid extends Component { ref={grid => { this.grid = grid; }} - isStickyBottom + isStickyBottom={!model.isEditable} metricCalculator={metricCalculator} model={model} keyHandlers={keyHandlers} @@ -2611,6 +2739,8 @@ export class IrisGrid extends Component { aggregationSettings )} selectDistinctColumns={selectDistinctColumns} + pendingRowCount={pendingRowCount} + pendingDataMap={pendingDataMap} /> )}
- + + { this.tableSaver = tableSaver; @@ -2768,6 +2923,8 @@ IrisGrid.propTypes = { // eslint-disable-next-line react/no-unused-prop-types onContextMenu: PropTypes.func, + + pendingDataMap: PropTypes.instanceOf(Map), }; IrisGrid.defaultProps = { @@ -2813,6 +2970,7 @@ IrisGrid.defaultProps = { selectedSearchColumns: null, invertSearchColumns: true, onContextMenu: () => [], + pendingDataMap: new Map(), }; const mapStateToProps = state => ({ diff --git a/packages/code-studio/src/iris-grid/IrisGridBottomBar.scss b/packages/code-studio/src/iris-grid/IrisGridBottomBar.scss new file mode 100644 index 0000000000..9d4908dffe --- /dev/null +++ b/packages/code-studio/src/iris-grid/IrisGridBottomBar.scss @@ -0,0 +1,90 @@ +@import '../custom.scss'; + +$bottom-bar-height: 50px; +$ease-out-bounce-back: cubic-bezier(0.175, 0.885, 0.32, 1.275); + +.iris-grid-bottom-bar { + position: absolute; + bottom: -$bottom-bar-height; + left: 0; + right: 0; + height: calc(#{$bottom-bar-height} * 2); + background-color: $toast-bg; + color: $toast-color; + display: flex; + flex-direction: row; + align-content: center; + padding-left: 1em; + padding-right: 1em; + padding-bottom: 50px; + + .status-message, + .error-message { + flex-grow: 1; + flex-shrink: 1; + font-weight: 600; + margin-right: 1em; + overflow: hidden; + display: flex; + flex-direction: row; + align-items: center; + margin-top: -4px; + user-select: none; + span { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + } + .svg-inline--fa { + margin-right: 0.5rem; + } + } + + .error-message { + color: $danger; + } + + .buttons-container { + flex-grow: 0; + display: flex; + flex-direction: row; + align-items: center; + + .btn { + min-width: 10rem; + .svg-inline--fa { + margin-right: 0.5rem; + } + } + + .btn:not(:last-child) { + margin-right: 0.5em; + } + } +} + +.iris-grid-bottom-bar-slide-up-enter { + transform: translate3d(0, 100%, 0); +} + +.iris-grid-bottom-bar-slide-up-enter-active { + transform: initial; + transition: transform $transition-long $ease-out-bounce-back; +} + +.iris-grid-bottom-bar-slide-up-enter-done { + position: relative; + bottom: 0; + padding-bottom: 0; + height: $bottom-bar-height; + transition: transform $transition-long $ease-out-bounce-back; +} + +.iris-grid-bottom-bar-slide-up-exit { + transform: initial; +} + +.iris-grid-bottom-bar-slide-up-exit-active { + transform: translate3d(0, 100%, 0); + transition: transform $transition-long $ease-out-bounce-back; +} diff --git a/packages/code-studio/src/iris-grid/IrisGridBottomBar.tsx b/packages/code-studio/src/iris-grid/IrisGridBottomBar.tsx new file mode 100644 index 0000000000..36962c2d83 --- /dev/null +++ b/packages/code-studio/src/iris-grid/IrisGridBottomBar.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import classNames from 'classnames'; +import { CSSTransition } from 'react-transition-group'; +import ThemeExport from '../ThemeExport'; +import './IrisGridBottomBar.scss'; + +export type IrisGridBottomBarProps = { + animation?: string; + children: React.ReactNode; + onClick?: () => void; + onEntering?: () => void; + onEntered?: () => void; + onExiting?: () => void; + onExited?: () => void; + isShown: boolean; + className?: string; +}; + +export const IrisGridBottomBar = ({ + animation = 'iris-grid-bottom-bar-slide-up', + children, + className, + isShown, + onClick, + onEntering, + onEntered, + onExiting, + onExited, +}: IrisGridBottomBarProps): JSX.Element => { + return ( + +
+ {children} +
+
+ ); +}; + +export default IrisGridBottomBar; diff --git a/packages/code-studio/src/iris-grid/IrisGridCopyHandler.jsx b/packages/code-studio/src/iris-grid/IrisGridCopyHandler.jsx index 86ed2d4a6b..4deec3d58a 100644 --- a/packages/code-studio/src/iris-grid/IrisGridCopyHandler.jsx +++ b/packages/code-studio/src/iris-grid/IrisGridCopyHandler.jsx @@ -303,6 +303,7 @@ class IrisGridCopyHandler extends Component { } render() { + const { onEntering, onEntered, onExiting, onExited } = this.props; const { buttonState, copyState, isShown, rowCount, error } = this.state; const animation = @@ -386,10 +387,18 @@ IrisGridCopyHandler.propTypes = { includeHeaders: PropTypes.bool, error: PropTypes.string, }), + onEntering: PropTypes.func, + onEntered: PropTypes.func, + onExiting: PropTypes.func, + onExited: PropTypes.func, }; IrisGridCopyHandler.defaultProps = { copyOperation: null, + onEntering: () => {}, + onEntered: () => {}, + onExiting: () => {}, + onExited: () => {}, }; export default IrisGridCopyHandler; diff --git a/packages/code-studio/src/iris-grid/IrisGridModel.js b/packages/code-studio/src/iris-grid/IrisGridModel.js index 372d0093e7..24fab16f3d 100644 --- a/packages/code-studio/src/iris-grid/IrisGridModel.js +++ b/packages/code-studio/src/iris-grid/IrisGridModel.js @@ -23,6 +23,7 @@ class IrisGridModel extends GridModel { DISCONNECT: 'DISCONNECT', RECONNECT: 'RECONNECT', TOTALS_UPDATED: 'TOTALS_UPDATED', + PENDING_DATA_UPDATED: 'PENDING_DATA_UPDATED', }); constructor() { @@ -258,6 +259,52 @@ class IrisGridModel extends GridModel { return false; } + /** + * The pending data for this model + * @returns {Map>} A map of row index to a map of column name/value pairs + */ + get pendingDataMap() { + throw new Error('get pendingDataMap not implemented'); + } + + /** + * Set the pending data for this model + * @param {Map>} A map of row index to a map of column name/value pairs + */ + set pendingDataMap(map) { + throw new Error('set pendingDataMap not implemented'); + } + + /** + * @returns {number} The count of pending rows to show + */ + get pendingRowCount() { + throw new Error('get pendingRowCount not implemented'); + } + + /** + * Set the count of pending rows to show + * @param {number} count The count of pending rows to show + */ + set pendingRowCount(count) { + throw new Error('set pendingRowCount not implemented'); + } + + /** + * Errors for the pending data + * @returns {Map} Map from row number to the error + */ + get pendingDataErrors() { + throw new Error('get pendingDataErrors not implemented'); + } + + /** + * Commit pending data and save all data to the table + */ + async commitPending() { + throw new Error('commitPending not implemented'); + } + /** * Check if a column is filterable * @param columnIndex {number} The column index to check for filterability @@ -271,11 +318,9 @@ class IrisGridModel extends GridModel { * Set the indices of the viewport * @param {number} top Top of viewport * @param {number} bottom Bottom of viewport - * @param {number} left Left of viewport - * @param {number} right Right of viewport - * @param {number[][]} movedColumns The movedColumns in the viewport + * @param {Iris.Column[]} columns The columns in the viewport. `null` for all columns */ - setViewport(top, bottom, left, right, movedColumns) { + setViewport(top, bottom, columns) { throw new Error('setViewport not implemented'); } diff --git a/packages/code-studio/src/iris-grid/IrisGridModel.test.js b/packages/code-studio/src/iris-grid/IrisGridModel.test.js index deedb4b223..f59ee474fb 100644 --- a/packages/code-studio/src/iris-grid/IrisGridModel.test.js +++ b/packages/code-studio/src/iris-grid/IrisGridModel.test.js @@ -1,5 +1,6 @@ import { TestUtils } from '@deephaven/utils'; import dh from '@deephaven/jsapi-shim'; +import Formatter from './Formatter'; import IrisGridModel from './IrisGridModel'; import IrisGridTestUtils from './IrisGridTestUtils'; @@ -202,3 +203,66 @@ describe('totals table tests', () => { expect(listener).toHaveBeenCalled(); }); }); + +describe('pending new rows tests', () => { + const TABLE_SIZE = 100; + const PENDING_ROW_COUNT = 50; + let table = null; + let inputTable = null; + let model = null; + + beforeEach(() => { + table = IrisGridTestUtils.makeTable( + IrisGridTestUtils.makeColumns(), + TABLE_SIZE + ); + table.close = jest.fn(); + + inputTable = IrisGridTestUtils.makeInputTable(table.columns.slice(0, 3)); + + model = IrisGridTestUtils.makeModel(table, new Formatter(), inputTable); + model.pendingRowCount = PENDING_ROW_COUNT; + }); + + it('has the correct number of total rows', () => { + expect(model.rowCount).toBe(TABLE_SIZE + PENDING_ROW_COUNT); + model.pendingRowCount = 250; + expect(model.rowCount).toBe(TABLE_SIZE + 250); + }); + + describe('setting values', () => { + const x = 3; + const pendingY = 4; + const y = TABLE_SIZE + pendingY; + const value = 'Testing'; + + beforeEach(() => { + model.setValueForCell(x, y, value); + }); + + it('updates the pending data map when setting pending ranges', () => { + expect(model.pendingDataMap.get(pendingY).data.get(x)).toEqual({ value }); + }); + + it('writes pending data to the input table', async () => { + inputTable.addRows = jest.fn(); + await model.commitPending(); + expect(inputTable.addRows).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ 3: value })]) + ); + expect(model.pendingDataMap.size).toBe(0); + }); + }); + + it('validates pendingDataMap when being set', () => { + expect(() => { + model.pendingDataMap = new Map(); + }).not.toThrow(); + expect(() => { + model.pendingDataMap = new Map([[4, { data: new Map([[5, 'value']]) }]]); + }).not.toThrow(); + expect(() => { + model.pendingDataMap = new Map([['invalid', 'data']]); + }).toThrow(); + }); +}); diff --git a/packages/code-studio/src/iris-grid/IrisGridModelUpdater.jsx b/packages/code-studio/src/iris-grid/IrisGridModelUpdater.jsx index 8a5c493a38..0382928e87 100644 --- a/packages/code-studio/src/iris-grid/IrisGridModelUpdater.jsx +++ b/packages/code-studio/src/iris-grid/IrisGridModelUpdater.jsx @@ -32,6 +32,8 @@ const IrisGridModelUpdater = React.memo( rollupConfig, totalsConfig, selectDistinctColumns, + pendingRowCount, + pendingDataMap, }) => { const columns = useMemo( () => @@ -90,6 +92,12 @@ const IrisGridModelUpdater = React.memo( model.totalsConfig = totalsConfig; } }, [model, model.isTotalsAvailable, totalsConfig]); + useEffect(() => { + model.pendingRowCount = pendingRowCount; + }, [model, pendingRowCount]); + useEffect(() => { + model.pendingDataMap = pendingDataMap; + }, [model, pendingDataMap]); return null; } @@ -115,6 +123,8 @@ IrisGridModelUpdater.propTypes = { rollupConfig: IrisPropTypes.RollupConfig, totalsConfig: PropTypes.shape({}), selectDistinctColumns: PropTypes.arrayOf(PropTypes.string), + pendingRowCount: PropTypes.number, + pendingDataMap: PropTypes.instanceOf(Map), }; IrisGridModelUpdater.defaultProps = { @@ -124,6 +134,8 @@ IrisGridModelUpdater.defaultProps = { rollupConfig: null, totalsConfig: null, selectDistinctColumns: [], + pendingRowCount: 0, + pendingDataMap: new Map(), }; export default IrisGridModelUpdater; diff --git a/packages/code-studio/src/iris-grid/IrisGridProxyModel.js b/packages/code-studio/src/iris-grid/IrisGridProxyModel.js index e02c604b99..0ce3dfc0a7 100644 --- a/packages/code-studio/src/iris-grid/IrisGridProxyModel.js +++ b/packages/code-studio/src/iris-grid/IrisGridProxyModel.js @@ -469,6 +469,30 @@ class IrisGridProxyModel extends IrisGridModel { delete(...args) { return this.model.delete(...args); } + + get pendingDataMap() { + return this.model.pendingDataMap; + } + + set pendingDataMap(map) { + this.model.pendingDataMap = map; + } + + get pendingRowCount() { + return this.model.pendingRowCount; + } + + set pendingRowCount(count) { + this.model.pendingRowCount = count; + } + + get pendingDataErrors() { + return this.model.pendingDataErrors; + } + + commitPending(...args) { + return this.model.commitPending(...args); + } } export default IrisGridProxyModel; diff --git a/packages/code-studio/src/iris-grid/IrisGridRenderer.js b/packages/code-studio/src/iris-grid/IrisGridRenderer.js index b4cfbf6112..ca13592b2f 100644 --- a/packages/code-studio/src/iris-grid/IrisGridRenderer.js +++ b/packages/code-studio/src/iris-grid/IrisGridRenderer.js @@ -77,6 +77,8 @@ class IrisGridRenderer extends GridRenderer { super.drawGridLines(context, state); this.drawGroupedColumnLine(context, state); + + this.drawPendingRowLine(context, state); } drawGroupedColumnLine(context, state) { @@ -107,6 +109,33 @@ class IrisGridRenderer extends GridRenderer { context.stroke(); } + drawPendingRowLine(context, state) { + const { metrics, model, theme } = state; + const { visibleRowYs, maxX } = metrics; + const { pendingRowCount } = model; + if (pendingRowCount <= 0) { + return; + } + + const firstPendingRow = + model.rowCount - model.pendingRowCount - model.floatingBottomRowCount; + let y = visibleRowYs.get(firstPendingRow); + if (y == null) { + return; + } + + y -= 0.5; + context.save(); + context.setLineDash([4, 2]); + context.strokeStyle = theme.pendingTextColor; + context.lineWidth = 1; + context.beginPath(); + context.moveTo(0, y); + context.lineTo(maxX, y); + context.stroke(); + context.restore(); + } + drawMouseColumnHover(context, state) { const { theme, metrics, hoverSelectColumn } = state; if (hoverSelectColumn == null || !theme.linkerColumnHoverBackgroundColor) { diff --git a/packages/code-studio/src/iris-grid/IrisGridTableModel.js b/packages/code-studio/src/iris-grid/IrisGridTableModel.js index 8938980a21..0802b87a84 100644 --- a/packages/code-studio/src/iris-grid/IrisGridTableModel.js +++ b/packages/code-studio/src/iris-grid/IrisGridTableModel.js @@ -61,11 +61,18 @@ class IrisGridTableModel extends IrisGridModel { this.viewportData = null; this.formattedStringData = []; this.pendingStringData = []; + this.isSaveInProgress = false; this.totalsTable = null; this.totalsTablePromise = null; this.totals = null; this.totalsDataMap = null; + + // Map from new row index to their values. Only for input tables that can have new rows added. + // The index of these rows start at 0, and they are appended at the end of the regular table data. + // These rows can be sparse, so using a map instead of an array. + this.pendingNewDataMap = new Map(); + this.pendingNewRowCount = 0; } close() { @@ -189,7 +196,75 @@ class IrisGridTableModel extends IrisGridModel { } get rowCount() { - return this.table.size + (this.totals?.operationOrder?.length ?? 0); + return ( + this.table.size + + (this.totals?.operationOrder?.length ?? 0) + + this.pendingNewRowCount + ); + } + + get pendingDataErrors() { + return this.getCachedPendingErrors( + this.pendingNewDataMap, + this.columns, + this.inputTable?.keyColumns.length ?? 0 + ); + } + + get pendingDataMap() { + return this.pendingNewDataMap; + } + + set pendingDataMap(map) { + if (map === this.pendingNewDataMap) { + return; + } + + map.forEach((row, rowIndex) => { + if (!IrisGridUtils.isValidIndex(rowIndex)) { + throw new Error('Invalid rowIndex', rowIndex); + } + + const { data } = row; + data.forEach((value, columnIndex) => { + if (!IrisGridUtils.isValidIndex(columnIndex)) { + throw new Error('Invalid columnIndex', columnIndex); + } + }); + }); + + this.pendingNewDataMap = map; + + this.pendingNewRowCount = Math.max( + this.pendingNewRowCount, + this.maxPendingDataRow + ); + + this.formattedStringData = []; + + this.dispatchEvent( + new CustomEvent(IrisGridModel.EVENT.PENDING_DATA_UPDATED) + ); + } + + get pendingRowCount() { + return this.pendingNewRowCount; + } + + set pendingRowCount(count) { + if (count === this.pendingNewRowCount) { + return; + } + + this.pendingNewRowCount = Math.max(0, count, this.maxPendingDataRow); + + this.dispatchEvent(new CustomEvent(IrisGridModel.EVENT.UPDATED)); + } + + get maxPendingDataRow() { + return this.pendingNewDataMap.size > 0 + ? Math.max(...this.pendingNewDataMap.keys()) + 1 + : 0; } get columnCount() { @@ -241,7 +316,7 @@ class IrisGridTableModel extends IrisGridModel { } get isEditable() { - return this.inputTable != null; + return !this.isSaveInProgress && this.inputTable != null; } cacheFormattedValue(x, y, text) { @@ -265,7 +340,7 @@ class IrisGridTableModel extends IrisGridModel { } } - textForCell(x, y) { + textValueForCell(x, y) { // First check if there's any pending values we should read from if (this.pendingStringData[x]?.[y] !== undefined) { return this.pendingStringData[x][y]; @@ -303,6 +378,18 @@ class IrisGridTableModel extends IrisGridModel { return this.formattedStringData[x][y]; } + textForCell(x, y) { + const text = this.textValueForCell(x, y); + if (text == null && this.isKeyColumn(x)) { + const pendingRow = this.pendingRow(y); + if (this.pendingDataMap.has(pendingRow)) { + // Asterisk to show a value is required for a key column on a row that has some data entered + return '*'; + } + } + return text; + } + colorForCell(x, y, theme) { const data = this.dataForCell(x, y); if (data) { @@ -311,6 +398,11 @@ class IrisGridTableModel extends IrisGridModel { return format.color; } + if (this.isPendingRow(y)) { + // Data entered in a pending row + return theme.pendingTextColor; + } + // Fallback to formatting based on the value/type of the cell const { value } = data; if (value != null) { @@ -328,6 +420,8 @@ class IrisGridTableModel extends IrisGridModel { return theme.zeroNumberColor; } } + } else if (this.isPendingRow(y) && this.isKeyColumn(x)) { + return theme.errorTextColor; } return theme.textColor; @@ -383,6 +477,10 @@ class IrisGridTableModel extends IrisGridModel { const operation = this.totals.operationOrder[totalsRow]; return this.totalsDataMap?.get(operation) ?? null; } + const pendingRow = this.pendingRow(y); + if (pendingRow != null) { + return this.pendingNewDataMap.get(pendingRow) ?? null; + } const offset = this.viewportData?.offset ?? 0; const viewportY = (showOnTop ? y - totalsRowCount : y) - offset; return this.viewportData?.rows?.[viewportY] ?? null; @@ -404,6 +502,21 @@ class IrisGridTableModel extends IrisGridModel { return null; } + /** + * Translate from the row in the model to a pending input row. + * If the row is not a pending input row, return null + * @param {number} y The row in the model to get the pending row for + * @returns {number|null} The row within the pending input rows if it's a pending row, null otherwise + */ + pendingRow(y) { + const pendingRow = y - this.floatingTopRowCount - this.table.size; + if (pendingRow >= 0 && pendingRow < this.pendingNewRowCount) { + return pendingRow; + } + + return null; + } + /** * Check if a row is a totals table row * @param {number} y The row in the model to check if it's a totals table row @@ -413,6 +526,15 @@ class IrisGridTableModel extends IrisGridModel { return this.totalsRow(y) != null; } + /** + * Check if a row is a pending input row + * @param {number} y The row in the model to check if it's a pending new row + * @returns {boolean} True if the row is a pending new row, false if not + */ + isPendingRow(y) { + return this.pendingRow(y) != null; + } + dataForCell(x, y) { return this.row(y)?.data.get(x); } @@ -680,7 +802,7 @@ class IrisGridTableModel extends IrisGridModel { } applyBufferedViewport(viewportTop, viewportBottom, columns) { - log.debug2('applyBufferedViewport', columns); + log.debug2('applyBufferedViewport', viewportTop, viewportBottom, columns); if (this.subscription == null) { log.debug2('applyBufferedViewport creating new subscription'); this.subscription = this.table.setViewport( @@ -901,8 +1023,32 @@ class IrisGridTableModel extends IrisGridModel { return [viewportTop, viewportBottom]; }); - isColumnMovable(column) { - return column >= (this.inputTable?.keyColumns.length ?? 0); + getCachedPendingErrors = memoize( + (pendingDataMap, columns, keyColumnCount) => { + const map = new Map(); + pendingDataMap.forEach((row, rowIndex) => { + const { data: rowData } = row; + for (let i = 0; i < keyColumnCount; i += 1) { + if (!rowData.has(i)) { + if (!map.has(rowIndex)) { + map.set(rowIndex, []); + } + map + .get(rowIndex) + .push(new MissingKeyError(rowIndex, columns[i].name)); + } + } + }); + return map; + } + ); + + isColumnMovable(x) { + return !this.isKeyColumn(x); + } + + isKeyColumn(x) { + return x < (this.inputTable?.keyColumns.length ?? 0); } isRowMovable() { @@ -913,11 +1059,27 @@ class IrisGridTableModel extends IrisGridModel { return ( this.inputTable != null && GridRange.isBounded(range) && - range.startColumn >= this.inputTable.keyColumns.length && - range.endColumn >= this.inputTable.keyColumns.length && + ((this.isPendingRow(range.startRow) && this.isPendingRow(range.endRow)) || + (range.startColumn >= this.inputTable.keyColumns.length && + range.endColumn >= this.inputTable.keyColumns.length)) && range.startRow >= this.floatingTopRowCount && - range.startRow < this.floatingTopRowCount + this.table.size && - range.endRow < this.floatingTopRowCount + this.table.size + range.startRow < + this.floatingTopRowCount + this.table.size + this.pendingRowCount && + range.endRow < + this.floatingTopRowCount + this.table.size + this.pendingRowCount + ); + } + + isDeletableRange(range) { + return ( + this.inputTable != null && + range.startRow != null && + range.endRow != null && + range.startRow >= this.floatingTopRowCount && + range.startRow < + this.floatingTopRowCount + this.table.size + this.pendingRowCount && + range.endRow < + this.floatingTopRowCount + this.table.size + this.pendingRowCount ); } @@ -925,6 +1087,34 @@ class IrisGridTableModel extends IrisGridModel { return ranges.every(range => this.isEditableRange(range)); } + isDeletableRanges(ranges) { + return ranges.every(range => this.isDeletableRange(range)); + } + + /** + * @returns {GridRange} A range corresponding to the underlying table + */ + getTableAreaRange() { + return new GridRange( + null, + this.floatingTopRowCount, + null, + this.floatingTopRowCount + this.table.size - 1 + ); + } + + /** + * @returns {GridRange} A range corresponding to the pending new rows + */ + getPendingAreaRange() { + return new GridRange( + null, + this.floatingTopRowCount + this.table.size, + null, + this.floatingTopRowCount + this.table.size + this.pendingNewRowCount - 1 + ); + } + /** * Set value in an editable table * @param {number} x The column to set @@ -960,40 +1150,94 @@ class IrisGridTableModel extends IrisGridModel { columnSet.add(column); if (formattedText[x] === undefined) { const value = TableUtils.makeValue(column.type, text); - formattedText[x] = this.displayString( - value, - column.type, - column.name - ); + formattedText[x] = + value != null + ? this.displayString(value, column.type, column.name) + : null; } this.cachePendingValue(x, y, formattedText[x]); }); + // Take care of updates to the pending new area first, as they can be updated synchronously + const pendingAreaRange = this.getPendingAreaRange(); + const pendingRanges = ranges + .map(range => GridRange.intersection(pendingAreaRange, range)) + .filter(range => range != null) + .map(range => + GridRange.offset( + range, + 0, + -(this.floatingTopRowCount + this.table.size) + ) + ); + if (pendingRanges.length > 0) { + const newDataMap = new Map(this.pendingNewDataMap); + GridRange.forEachCell(pendingRanges, (columnIndex, rowIndex) => { + if (!newDataMap.has(rowIndex)) { + newDataMap.set(rowIndex, { data: new Map() }); + } + + const column = this.columns[columnIndex]; + const row = newDataMap.get(rowIndex); + const { data: rowData } = row; + const newRowData = new Map(rowData); + const value = TableUtils.makeValue(column.type, text); + if (value != null) { + newRowData.set(columnIndex, { + value: TableUtils.makeValue(column.type, text), + }); + } else { + newRowData.delete(columnIndex); + } + if (newRowData.size > 0) { + newDataMap.set(rowIndex, { ...row, data: newRowData }); + } else { + newDataMap.delete(rowIndex); + } + }); + this.pendingDataMap = newDataMap; + } + this.dispatchEvent(new CustomEvent(IrisGridModel.EVENT.UPDATED)); - // Get a snapshot of the full rows, as we need to write a full row when editing - const data = await this.snapshot( - ranges.map( - range => new GridRange(null, range.startRow, null, range.endRow) - ) - ); - const newRows = data.map(row => { - const newRow = {}; - for (let c = 0; c < this.columns.length; c += 1) { - newRow[this.columns[c].name] = row[c]; - } + const tableAreaRange = this.getTableAreaRange(); + const tableRanges = ranges + .map(range => GridRange.intersection(tableAreaRange, range)) + .filter(range => range != null); + if (tableRanges.length > 0) { + // Get a snapshot of the full rows, as we need to write a full row when editing + const data = await this.snapshot( + tableRanges.map( + range => new GridRange(null, range.startRow, null, range.endRow) + ) + ); + const newRows = data.map(row => { + const newRow = {}; + for (let c = 0; c < this.columns.length; c += 1) { + newRow[this.columns[c].name] = row[c]; + } - columnSet.forEach(column => { - newRow[column.name] = TableUtils.makeValue(column.type, text); + columnSet.forEach(column => { + newRow[column.name] = TableUtils.makeValue(column.type, text); + }); + return newRow; }); - return newRow; - }); - - const result = await this.inputTable.addRows(newRows); - log.debug('setValueForRanges(', ranges, ',', text, ') result', result); + const result = await this.inputTable.addRows(newRows); + + log.debug( + 'setValueForRanges(', + ranges, + ',', + text, + ') set tableRanges', + tableRanges, + 'result', + result + ); + } - // Add it to the formatted cache so it's still displayed until the update event is received + // Add the changes to the formatted cache so it's still displayed until the update event is received // The update event could be received on the next tick, after the input rows have been committed, // so make sure we don't display stale data GridRange.forEachCell(ranges, (x, y) => { @@ -1008,13 +1252,52 @@ class IrisGridTableModel extends IrisGridModel { } } + async commitPending() { + if (this.pendingNewDataMap.size <= 0) { + throw new Error('No pending changes to commit'); + } + + try { + this.isSaveInProgress = true; + + const newRows = []; + this.pendingNewDataMap.forEach(row => { + const newRow = {}; + row.data.forEach(({ value }, columnIndex) => { + const column = this.columns[columnIndex]; + newRow[column.name] = value; + }); + newRows.push(newRow); + }); + const result = await this.inputTable.addRows(newRows); + + log.debug('commitPending()', this.pendingNewDataMap, 'result', result); + + this.pendingNewDataMap = new Map(); + this.pendingNewDataErrors = new Map(); + this.pendingNewRowCount = Math.max( + 0, + (this.viewport?.bottom ?? 0) - this.table.size + ); + this.formattedStringData = []; + + this.dispatchEvent( + new CustomEvent(IrisGridModel.EVENT.PENDING_DATA_UPDATED) + ); + + return result; + } finally { + this.isSaveInProgress = false; + } + } + editValueForCell(x, y) { - return this.textForCell(x, y); + return this.textValueForCell(x, y); } async delete(ranges) { - if (!this.isEditableRanges(ranges)) { - throw new Error('Uneditable ranges', ranges); + if (!this.isDeletableRanges(ranges)) { + throw new Error('Undeletable ranges', ranges); } const { keyColumns } = this.inputTable; @@ -1022,10 +1305,48 @@ class IrisGridTableModel extends IrisGridModel { throw new Error('No key columns to allow deletion'); } + const pendingAreaRange = this.getPendingAreaRange(); + const pendingRanges = ranges + .map(range => GridRange.intersection(pendingAreaRange, range)) + .filter(range => range != null) + .map(range => + GridRange.offset( + range, + 0, + -(this.floatingTopRowCount + this.table.size) + ) + ); + + if (pendingRanges.length > 0) { + const newDataMap = new Map(this.pendingNewDataMap); + for (let i = 0; i < pendingRanges.length; i += 1) { + const pendingRange = pendingRanges[i]; + for (let r = pendingRange.startRow; r <= pendingRange.endRow; r += 1) { + newDataMap.delete(r); + } + } + this.pendingNewDataMap = newDataMap; + + this.formattedStringData = []; + + this.dispatchEvent( + new CustomEvent(IrisGridModel.EVENT.PENDING_DATA_UPDATED) + ); + + this.dispatchEvent(new CustomEvent(IrisGridModel.EVENT.UPDATED)); + } + + const tableAreaRange = this.getTableAreaRange(); + const tableRanges = ranges + .map(range => GridRange.intersection(tableAreaRange, range)) + .filter(range => range != null); + if (tableRanges.length <= 0) { + return null; + } const [data, deleteTable] = await Promise.all([ // Need to get the key values of each row this.snapshot( - ranges.map( + tableRanges.map( range => new GridRange( 0, diff --git a/packages/code-studio/src/iris-grid/IrisGridTestUtils.js b/packages/code-studio/src/iris-grid/IrisGridTestUtils.js index 99097436ab..761f438e69 100644 --- a/packages/code-studio/src/iris-grid/IrisGridTestUtils.js +++ b/packages/code-studio/src/iris-grid/IrisGridTestUtils.js @@ -51,21 +51,29 @@ class IrisGridTestUtils { return new dh.Sort(); } - static makeTable(columns = IrisGridTestUtils.makeColumns()) { - const table = new dh.Table({ columns }); + static makeTable( + columns = IrisGridTestUtils.makeColumns(), + size = 1000000000 + ) { + const table = new dh.Table({ columns, size }); table.copy = jest.fn(() => Promise.resolve(table)); return table; } + static makeInputTable(keyColumns = []) { + return new dh.InputTable(keyColumns); + } + static makeSubscription(table = IrisGridTestUtils.makeTable()) { return new dh.TableViewportSubscription({ table }); } static makeModel( table = IrisGridTestUtils.makeTable(), - formatter = new Formatter() + formatter = new Formatter(), + inputTable = null ) { - return new IrisGridProxyModel(table, formatter); + return new IrisGridProxyModel(table, formatter, inputTable); } } diff --git a/packages/code-studio/src/iris-grid/IrisGridTheme.js b/packages/code-studio/src/iris-grid/IrisGridTheme.js index aa261603c0..377bc1d8d2 100644 --- a/packages/code-studio/src/iris-grid/IrisGridTheme.js +++ b/packages/code-studio/src/iris-grid/IrisGridTheme.js @@ -31,6 +31,8 @@ export default Object.freeze({ negativeNumberColor: IrisGridTheme['negative-number-color'], zeroNumberColor: IrisGridTheme['zero-number-color'], dateColor: IrisGridTheme['date-color'], + pendingTextColor: IrisGridTheme['pending-text-color'], + errorTextColor: IrisGridTheme['error-text-color'], filterBarActiveBackgroundColor: IrisGridTheme['filter-bar-active-bg'], filterBarExpandedBackgroundColor: IrisGridTheme['filter-bar-expanded-bg'], filterBarExpandedActiveBackgroundColor: diff --git a/packages/code-studio/src/iris-grid/IrisGridUtils.js b/packages/code-studio/src/iris-grid/IrisGridUtils.js index 652e06dc61..25b46ebba4 100644 --- a/packages/code-studio/src/iris-grid/IrisGridUtils.js +++ b/packages/code-studio/src/iris-grid/IrisGridUtils.js @@ -101,6 +101,7 @@ class IrisGridUtils { selectedSearchColumns, sorts, invertSearchColumns, + pendingDataMap = new Map(), } = irisGridState; const { userColumnWidths, userRowHeights } = metrics; @@ -133,6 +134,10 @@ class IrisGridUtils { selectDistinctColumns: [...selectDistinctColumns], selectedSearchColumns, invertSearchColumns, + pendingDataMap: IrisGridUtils.dehydratePendingDataMap( + columns, + pendingDataMap + ), }; } @@ -160,6 +165,7 @@ class IrisGridUtils { selectDistinctColumns, selectedSearchColumns, invertSearchColumns = true, + pendingDataMap = [], } = irisGridState; const { columns } = table; @@ -199,6 +205,10 @@ class IrisGridUtils { selectDistinctColumns, selectedSearchColumns, invertSearchColumns, + pendingDataMap: IrisGridUtils.hydratePendingDataMap( + columns, + pendingDataMap + ), }; } @@ -352,6 +362,48 @@ class IrisGridUtils { }; } + static dehydratePendingDataMap(columns, pendingDataMap) { + return [...pendingDataMap].map(([rowIndex, { data }]) => [ + rowIndex, + { + data: [...data].map(([c, value]) => [ + columns[c].name, + IrisGridUtils.dehydrateValue(value, columns[c].type), + ]), + }, + ]); + } + + static hydratePendingDataMap(columns, pendingDataMap) { + const columnMap = new Map(); + const getColumnIndex = columnName => { + if (!columnMap.has(columnName)) { + columnMap.set( + columnName, + columns.findIndex(({ name }) => name === columnName) + ); + } + return columnMap.get(columnName); + }; + + return new Map( + pendingDataMap.map(([rowIndex, { data }]) => [ + rowIndex, + { + data: new Map( + data.map(([columnName, value]) => [ + getColumnIndex(columnName), + IrisGridUtils.hydrateValue( + value, + columns[getColumnIndex(columnName)].type + ), + ]) + ), + }, + ]) + ); + } + /** * Dehydrates/serializes a value for storage. * @param {Any} value The value to dehydrate @@ -844,6 +896,14 @@ class IrisGridUtils { return true; } + /** + * Check if the provided value is a valid table index + * @param {any} value A value to check if it's a valid table index + */ + static isValidIndex(value) { + return Number.isInteger(value) && value >= 0; + } + /** * Returns all columns used in any of the ranges provided * @param {GridRange[]} ranges The model ranges to get columns for @@ -959,6 +1019,25 @@ class IrisGridUtils { aggregations: aggregationMap, }; } + + /** + * @param {Map} pendingDataMap Map of pending data + * @returns {Map} A map with the errors in the pending data + */ + static getPendingErrors(pendingDataMap) { + pendingDataMap.forEach((row, rowIndex) => { + if (!IrisGridUtils.isValidIndex(rowIndex)) { + throw new Error('Invalid rowIndex', rowIndex); + } + + const { data } = row; + data.forEach((value, columnIndex) => { + if (!IrisGridUtils.isValidIndex(columnIndex)) { + throw new Error('Invalid columnIndex', columnIndex); + } + }); + }); + } } export default IrisGridUtils; diff --git a/packages/code-studio/src/iris-grid/IrisGridUtils.test.js b/packages/code-studio/src/iris-grid/IrisGridUtils.test.js index da6f6eec33..db4322e66c 100644 --- a/packages/code-studio/src/iris-grid/IrisGridUtils.test.js +++ b/packages/code-studio/src/iris-grid/IrisGridUtils.test.js @@ -160,6 +160,77 @@ describe('sort exporting/importing', () => { }); }); +describe('pendingDataMap hydration/dehydration', () => { + it('dehydrates/hydrates empty map', () => { + const pendingDataMap = new Map(); + const columns = makeColumns(); + const dehydratedMap = IrisGridUtils.dehydratePendingDataMap( + columns, + pendingDataMap + ); + expect(dehydratedMap).toEqual([]); + + const hydratedMap = IrisGridUtils.hydratePendingDataMap( + columns, + dehydratedMap + ); + expect(hydratedMap.size).toBe(0); + }); + + it('dehydrates/hydrates pending data', () => { + const pendingDataMap = new Map([ + [ + 1, + { + data: new Map([ + [3, 'Foo'], + [4, 'Bar'], + ]), + }, + ], + [ + 10, + { + data: new Map([[7, 'Baz']]), + }, + ], + ]); + const columns = makeColumns(); + const dehydratedMap = IrisGridUtils.dehydratePendingDataMap( + columns, + pendingDataMap + ); + expect(dehydratedMap).toEqual([ + [ + 1, + expect.objectContaining({ + data: [ + ['3', 'Foo'], + ['4', 'Bar'], + ], + }), + ], + [ + 10, + expect.objectContaining({ + data: [['7', 'Baz']], + }), + ], + ]); + + const hydratedMap = IrisGridUtils.hydratePendingDataMap( + columns, + dehydratedMap + ); + expect(hydratedMap.size).toBe(2); + expect(hydratedMap.get(1).data.size).toBe(2); + expect(hydratedMap.get(1).data.get(3)).toEqual('Foo'); + expect(hydratedMap.get(1).data.get(4)).toEqual('Bar'); + expect(hydratedMap.get(10).data.size).toBe(1); + expect(hydratedMap.get(10).data.get(7)).toEqual('Baz'); + }); +}); + describe('remove columns in moved columns', () => { it('delete the move when the move origin column is removed', () => { const table = makeTable(); diff --git a/packages/code-studio/src/iris-grid/MissingKeyError.ts b/packages/code-studio/src/iris-grid/MissingKeyError.ts new file mode 100644 index 0000000000..c58fd6cffe --- /dev/null +++ b/packages/code-studio/src/iris-grid/MissingKeyError.ts @@ -0,0 +1,15 @@ +class MissingKeyError extends Error { + isMissingKey = true; + + rowIndex: number; + + columnName: string; + + constructor(rowIndex: number, columnName: string) { + super(`${columnName} can't be empty (on pending row ${rowIndex + 1})`); + this.rowIndex = rowIndex; + this.columnName = columnName; + } +} + +export default MissingKeyError; diff --git a/packages/code-studio/src/iris-grid/PendingDataBottomBar.scss b/packages/code-studio/src/iris-grid/PendingDataBottomBar.scss new file mode 100644 index 0000000000..4c7cd24150 --- /dev/null +++ b/packages/code-studio/src/iris-grid/PendingDataBottomBar.scss @@ -0,0 +1,14 @@ +@import '../custom.scss'; + +.pending-data-bottom-bar { + background-color: $interfacegray; + .buttons-container { + .btn-outline-primary { + color: $gray-100; + border-color: $gray-100; + &:hover { + background-color: $danger-hover; + } + } + } +} diff --git a/packages/code-studio/src/iris-grid/PendingDataBottomBar.tsx b/packages/code-studio/src/iris-grid/PendingDataBottomBar.tsx new file mode 100644 index 0000000000..ec1d504aa8 --- /dev/null +++ b/packages/code-studio/src/iris-grid/PendingDataBottomBar.tsx @@ -0,0 +1,148 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + faCheckCircle, + faExclamationTriangle, +} from '@fortawesome/pro-light-svg-icons'; +import { faCircle, faSpinnerThird } from '@fortawesome/pro-regular-svg-icons'; +import IrisGridBottomBar from './IrisGridBottomBar'; +import { Button } from '../components'; +import './PendingDataBottomBar.scss'; +import { usePrevious } from '../react-hooks'; + +const HIDE_TIMEOUT = 3000; + +const MAX_NUMBER_ROWS_SHOWN = 5; + +export type PendingDataBottomBarProps = { + onSave: () => Promise; + onDiscard: () => Promise; + discardTooltip?: string; + saveTooltip?: string; + isSaving?: boolean; + pendingDataErrors: Map; + pendingDataMap: Map }>; + onEntering?: () => void; + onEntered?: () => void; + onExiting?: () => void; + onExited?: () => void; +}; + +export const PendingDataBottomBar = ({ + isSaving = false, + onSave, + onDiscard, + discardTooltip, + saveTooltip, + pendingDataErrors, + pendingDataMap, + onEntering, + onEntered, + onExiting, + onExited, +}: PendingDataBottomBarProps): JSX.Element => { + const [isSuccessShown, setIsSuccessShown] = useState(false); + const [wasSuccessShown, setWasSuccessShown] = useState(false); + const successTimeout = useRef(); + const prevIsSaving = usePrevious(isSaving); + const error = useMemo(() => { + if (pendingDataErrors.size === 0) { + return null; + } + if (pendingDataErrors.size <= MAX_NUMBER_ROWS_SHOWN) { + return `Key can't be empty (on pending row${ + pendingDataErrors.size > 1 ? 's' : '' + } ${Array.from(pendingDataErrors.keys()).join(', ').trim()})`; + } + return `Key can't be empty (on ${pendingDataErrors.size} rows)`; + }, [pendingDataErrors]); + + useEffect(() => { + if (prevIsSaving && !isSaving && error == null) { + setIsSuccessShown(true); + setWasSuccessShown(true); + successTimeout.current = setTimeout(() => { + setIsSuccessShown(false); + }, HIDE_TIMEOUT); + } + }, [error, isSaving, prevIsSaving]); + + useEffect(() => { + if (successTimeout.current && pendingDataMap.size > 0) { + // A change just occurred while the success message was still being shown, just hide the success message + clearTimeout(successTimeout.current); + setIsSuccessShown(false); + setWasSuccessShown(false); + } + }, [pendingDataMap]); + + useEffect(() => { + return () => + successTimeout.current ? clearTimeout(successTimeout.current) : undefined; + }, []); + + const pendingRowCount = pendingDataMap.size; + let commitIcon; + if (isSaving) { + commitIcon = ( +
+ + +
+ ); + } else if (wasSuccessShown) { + commitIcon = faCheckCircle; + } + + return ( + 0 || isSuccessShown} + onEntering={onEntering} + onEntered={onEntered} + onExiting={onExiting} + onExited={() => { + setWasSuccessShown(false); + if (onExited) { + onExited(); + } + }} + > + {error && ( +
+ + {`${error}`} +
+ )} + {!error && ( +
+ {pendingRowCount > 0 && ( + {`${pendingRowCount} row${ + pendingRowCount > 1 ? 's' : '' + } pending`} + )} +
+ )} +
+ {!isSaving && !wasSuccessShown && ( + + )} + +
+
+ ); +}; + +export default IrisGridBottomBar; diff --git a/packages/code-studio/src/iris-grid/TableUtils.js b/packages/code-studio/src/iris-grid/TableUtils.js index 8b6d91797d..c97b155760 100644 --- a/packages/code-studio/src/iris-grid/TableUtils.js +++ b/packages/code-studio/src/iris-grid/TableUtils.js @@ -1036,7 +1036,7 @@ class TableUtils { return dh.LongWrapper.ofString(TableUtils.removeCommas(text)); } if (TableUtils.isBooleanType(columnType)) { - return TableUtils.makeBooleanValue(text); + return TableUtils.makeBooleanValue(text, true); } if (TableUtils.isDateType(columnType)) { const [date] = DateUtils.parseDateRange(text); @@ -1051,7 +1051,11 @@ class TableUtils { return null; } - static makeBooleanValue(text) { + static makeBooleanValue(text, allowEmpty = false) { + if (text === '' && allowEmpty) { + return null; + } + switch (text?.toLowerCase()) { case 'null': return null; @@ -1079,7 +1083,7 @@ class TableUtils { } static makeNumberValue(text) { - if (text == null || text === 'null') { + if (text == null || text === 'null' || text === '') { return null; } diff --git a/packages/code-studio/src/iris-grid/mousehandlers/PendingMouseHandler.js b/packages/code-studio/src/iris-grid/mousehandlers/PendingMouseHandler.js new file mode 100644 index 0000000000..1a9c7e70bd --- /dev/null +++ b/packages/code-studio/src/iris-grid/mousehandlers/PendingMouseHandler.js @@ -0,0 +1,30 @@ +/* eslint class-methods-use-this: "off" */ +import { GridMouseHandler, GridUtils } from '../../grid'; + +/** + * Handles sending data selected via double click + */ +class PendingMouseHandler extends GridMouseHandler { + constructor(irisGrid) { + super(); + + this.irisGrid = irisGrid; + } + + onWheel(gridPoint, grid, wheelEvent) { + const { irisGrid } = this; + const { model } = irisGrid.props; + const { metrics, pendingRowCount } = irisGrid.state; + const { bottom, rowCount, rowHeight } = metrics; + const { deltaY } = GridUtils.getScrollDelta(wheelEvent); + if (model.isEditable && bottom >= rowCount - 1 && deltaY > 0) { + // We add new rows onto the bottom, but we don't consume the event + irisGrid.setState({ + pendingRowCount: pendingRowCount + Math.ceil(deltaY / rowHeight), + }); + } + return false; + } +} + +export default PendingMouseHandler; diff --git a/packages/code-studio/src/iris-grid/mousehandlers/index.js b/packages/code-studio/src/iris-grid/mousehandlers/index.js index 45896e9381..0bef02b4c2 100644 --- a/packages/code-studio/src/iris-grid/mousehandlers/index.js +++ b/packages/code-studio/src/iris-grid/mousehandlers/index.js @@ -4,3 +4,4 @@ export { default as IrisGridContextMenuHandler } from './IrisGridContextMenuHand export { default as IrisGridDataSelectMouseHandler } from './IrisGridDataSelectMouseHandler'; export { default as IrisGridFilterMouseHandler } from './IrisGridFilterMouseHandler'; export { default as IrisGridSortMouseHandler } from './IrisGridSortMouseHandler'; +export { default as PendingMouseHandler } from './PendingMouseHandler'; diff --git a/packages/grid/src/Grid.jsx b/packages/grid/src/Grid.jsx index 4a48e19abe..d03ece2098 100644 --- a/packages/grid/src/Grid.jsx +++ b/packages/grid/src/Grid.jsx @@ -1238,8 +1238,6 @@ class Grid extends PureComponent { return; } - let { deltaX, deltaY } = e; - const { metricCalculator, metrics } = this; const metricState = this.getMetricState(); @@ -1248,45 +1246,13 @@ class Grid extends PureComponent { const theme = this.getTheme(); - // Flip scroll direction if shiftKey is held on windows/linux. - // On mac, deltaX/Y values are switched at the event level when shiftKey=true. - // Guard on strictly Y only changing, to ignore trackpad diagonal motion, - // through that guard may not be necessary, but it is difficult to determine for - // all platforms/browser/scroll method combos. - if ( - !GridUtils.isMacPlatform() && - e.shiftKey && - e.deltaX === 0 && - e.deltaY !== 0 - ) { - deltaX = e.deltaY; - deltaY = e.deltaX; - } - - // Normalize other deltaMode values to pixel units - // deltaMode 0, is already in pixel units - if (e?.deltaMode === WheelEvent.DOM_DELTA_PAGE) { - // Users can set OS to be in deltaMode page - // scrolly by page units as pixels - deltaX *= metrics.barWidth; - deltaY *= metrics.barHeight; - } else if (e?.deltaMode === WheelEvent.DOM_DELTA_LINE) { - // Firefox reports deltaMode line - // Normalize distance travelled between browsers - // but remain ~platform/browser combo consistent - if (GridUtils.isMacPlatform()) { - // for mac treat lines as a standard row height - // on mac, firefox travels less distance then chrome per tick - deltaX = Math.round(deltaX * metrics.rowHeight); - deltaY = Math.round(deltaY * metrics.rowHeight); - } else { - // for windows convert to pixels using the same method as chrome - // chrome goes 100 per 3 lines, and firefox would go 102 per 3 (17 lineheight * 3 lines * 2) - // make the behaviour the same between as it's close enough - deltaX = Math.round(deltaX * Grid.pixelsPerLine); - deltaY = Math.round(deltaY * Grid.pixelsPerLine); - } - } + let { deltaX, deltaY } = GridUtils.getScrollDelta( + e, + metrics.barWidth, + metrics.barHeight, + metrics.rowHeight, + metrics.rowHeight + ); // iterate through each column to determine column width and figure out how far to scroll // get column width of next column to scroll to, and subract it from the remaining distance to travel diff --git a/packages/grid/src/GridUtils.js b/packages/grid/src/GridUtils.js index f42beb818c..fa224e0b24 100644 --- a/packages/grid/src/GridUtils.js +++ b/packages/grid/src/GridUtils.js @@ -1,6 +1,10 @@ import GridRange from './GridRange'; class GridUtils { + // use same constant as chrome source for windows + // https://github.com/chromium/chromium/blob/973af9d461b6b5dc60208c8d3d66adc27e53da78/ui/events/blink/web_input_event_builders_win.cc#L285 + static PIXELS_PER_LINE = 100 / 3; + static getGridPointFromXY(x, y, metrics) { const column = GridUtils.getColumnAtX(x, metrics); const row = GridUtils.getRowAtY(y, metrics); @@ -707,6 +711,68 @@ class GridUtils { } return { x, y, x2, y2 }; } + + /** + * Converts the delta coordinates from the provided wheel event to pixels + * Different platforms have different ways of providing the delta so this normalizes it + * @param {WheelEvent} wheelEvent The mouse wheel event to get the scrolling delta for + * @param {number?} pageWidth The width of the page that is scrolling + * @param {number?} pageHeight The height of the page that is scrolling + * @param {number?} lineWidth The width of the line scrolling in line mode + * @param {number?} lineHeight The height of the line scrolling in line mode + * @returns {{deltaX:number, deltaY: number}} The delta coordinates normalized to pixels + */ + static getScrollDelta( + wheelEvent, + pageWidth = 1024, + pageHeight = 768, + lineWidth = 20, + lineHeight = 20 + ) { + let { deltaX, deltaY } = wheelEvent; + + // Flip scroll direction if shiftKey is held on windows/linux. + // On mac, deltaX/Y values are switched at the event level when shiftKey=true. + // Guard on strictly Y only changing, to ignore trackpad diagonal motion, + // through that guard may not be necessary, but it is difficult to determine for + // all platforms/browser/scroll method combos. + if ( + !GridUtils.isMacPlatform() && + wheelEvent.shiftKey && + wheelEvent.deltaX === 0 && + wheelEvent.deltaY !== 0 + ) { + deltaX = wheelEvent.deltaY; + deltaY = wheelEvent.deltaX; + } + + // Normalize other deltaMode values to pixel units + // deltaMode 0, is already in pixel units + if (wheelEvent?.deltaMode === WheelEvent.DOM_DELTA_PAGE) { + // Users can set OS to be in deltaMode page + // scrolly by page units as pixels + deltaX *= pageWidth; + deltaY *= pageHeight; + } else if (wheelEvent?.deltaMode === WheelEvent.DOM_DELTA_LINE) { + // Firefox reports deltaMode line + // Normalize distance travelled between browsers + // but remain ~platform/browser combo consistent + if (GridUtils.isMacPlatform()) { + // for mac treat lines as a standard row height + // on mac, firefox travels less distance then chrome per tick + deltaX = Math.round(deltaX * lineWidth); + deltaY = Math.round(deltaY * lineHeight); + } else { + // for windows convert to pixels using the same method as chrome + // chrome goes 100 per 3 lines, and firefox would go 102 per 3 (17 lineheight * 3 lines * 2) + // make the behaviour the same between as it's close enough + deltaX = Math.round(deltaX * this.PIXELS_PER_LINE); + deltaY = Math.round(deltaY * this.PIXELS_PER_LINE); + } + } + + return { deltaX, deltaY }; + } } export default GridUtils; From 7ff8c89b63e48b7e39ca0f84d8a699c6e6e52635 Mon Sep 17 00:00:00 2001 From: mikebender Date: Fri, 30 Apr 2021 13:48:39 -0400 Subject: [PATCH 7/7] Resolve conflicts from cherry-picks --- packages/code-studio/package.json | 1 + .../code-studio/public/__mocks__/dh-core.js | 17 ++++ .../src/dashboard/panels/IrisGridPanel.jsx | 2 +- .../code-studio/src/iris-grid/IrisGrid.jsx | 1 + .../src/iris-grid/IrisGridBottomBar.scss | 2 +- .../src/iris-grid/IrisGridBottomBar.tsx | 46 +++++----- .../src/iris-grid/IrisGridCopyHandler.jsx | 91 +++++++++---------- .../src/iris-grid/IrisGridTableModel.js | 1 + .../src/iris-grid/PendingDataBottomBar.scss | 2 +- .../src/iris-grid/PendingDataBottomBar.tsx | 30 +++--- .../mousehandlers/PendingMouseHandler.js | 2 +- packages/components/src/Button.tsx | 3 + 12 files changed, 104 insertions(+), 94 deletions(-) diff --git a/packages/code-studio/package.json b/packages/code-studio/package.json index 68231d1b06..7f81e6e0df 100644 --- a/packages/code-studio/package.json +++ b/packages/code-studio/package.json @@ -14,6 +14,7 @@ "@deephaven/icons": "^0.1.0", "@deephaven/jsapi-shim": "^0.1.0", "@deephaven/log": "^0.1.0", + "@deephaven/react-hooks": "^0.1.0", "@deephaven/utils": "^0.1.0", "@fortawesome/fontawesome-svg-core": "^1.2.32", "@fortawesome/react-fontawesome": "^0.1.12", diff --git a/packages/code-studio/public/__mocks__/dh-core.js b/packages/code-studio/public/__mocks__/dh-core.js index 5caad108cd..83de9cfc4c 100644 --- a/packages/code-studio/public/__mocks__/dh-core.js +++ b/packages/code-studio/public/__mocks__/dh-core.js @@ -962,6 +962,22 @@ TotalsTableConfig.FIRST = 'First'; TotalsTableConfig.LAST = 'Last'; TotalsTableConfig.SKIP = 'Skip'; +class InputTable extends DeephavenObject { + constructor({ keyColumns = [] } = {}) { + super(); + + this.keyColumns = keyColumns; + } + + addRows() { + return Promise.resolve(); + } + + deleteTable() { + return Promise.resolve(); + } +} + class RollupTableConfig {} class Client extends DeephavenObject { @@ -1727,6 +1743,7 @@ const dh = { RollupTableConfig: RollupTableConfig, Table: Table, TotalsTable: TotalsTable, + InputTable: InputTable, TotalsTableConfig: TotalsTableConfig, TableViewportSubscription, TableSubscription, diff --git a/packages/code-studio/src/dashboard/panels/IrisGridPanel.jsx b/packages/code-studio/src/dashboard/panels/IrisGridPanel.jsx index c3f3dd4c3b..e59b92d68d 100644 --- a/packages/code-studio/src/dashboard/panels/IrisGridPanel.jsx +++ b/packages/code-studio/src/dashboard/panels/IrisGridPanel.jsx @@ -7,6 +7,7 @@ import { connect } from 'react-redux'; import debounce from 'lodash.debounce'; import Log from '@deephaven/log'; import { PromiseUtils } from '@deephaven/utils'; +import { ContextMenuRoot } from '@deephaven/components'; import { IrisGridUtils, IrisGrid } from '../../iris-grid'; import { ChartUtils, ChartModelFactory } from '../../chart'; import { @@ -35,7 +36,6 @@ import LayoutUtils from '../../layout/LayoutUtils'; import IrisGridModel from '../../iris-grid/IrisGridModel'; import AdvancedSettings from '../../iris-grid/sidebar/AdvancedSettings'; import IrisGridTableModel from '../../iris-grid/IrisGridTableModel'; -import ContextMenuRoot from '../../context-actions/ContextMenuRoot'; const log = Log.module('IrisGridPanel'); diff --git a/packages/code-studio/src/iris-grid/IrisGrid.jsx b/packages/code-studio/src/iris-grid/IrisGrid.jsx index bb4ff7e6c7..fca084c049 100644 --- a/packages/code-studio/src/iris-grid/IrisGrid.jsx +++ b/packages/code-studio/src/iris-grid/IrisGrid.jsx @@ -37,6 +37,7 @@ import dh from '@deephaven/jsapi-shim'; import { Pending, PromiseUtils, ValidationError } from '@deephaven/utils'; import throttle from 'lodash.throttle'; import debounce from 'lodash.debounce'; +import PendingDataBottomBar from './PendingDataBottomBar'; import IrisGridCopyHandler from './IrisGridCopyHandler'; import FilterInputField from './FilterInputField'; import { CopyKeyHandler, ReverseKeyHandler } from './key-handlers'; diff --git a/packages/code-studio/src/iris-grid/IrisGridBottomBar.scss b/packages/code-studio/src/iris-grid/IrisGridBottomBar.scss index 9d4908dffe..79b5cd4025 100644 --- a/packages/code-studio/src/iris-grid/IrisGridBottomBar.scss +++ b/packages/code-studio/src/iris-grid/IrisGridBottomBar.scss @@ -1,4 +1,4 @@ -@import '../custom.scss'; +@import '~@deephaven/components/scss/custom.scss'; $bottom-bar-height: 50px; $ease-out-bounce-back: cubic-bezier(0.175, 0.885, 0.32, 1.275); diff --git a/packages/code-studio/src/iris-grid/IrisGridBottomBar.tsx b/packages/code-studio/src/iris-grid/IrisGridBottomBar.tsx index 36962c2d83..d2c1c7669e 100644 --- a/packages/code-studio/src/iris-grid/IrisGridBottomBar.tsx +++ b/packages/code-studio/src/iris-grid/IrisGridBottomBar.tsx @@ -1,7 +1,7 @@ import React from 'react'; import classNames from 'classnames'; import { CSSTransition } from 'react-transition-group'; -import ThemeExport from '../ThemeExport'; +import { ThemeExport } from '@deephaven/components'; import './IrisGridBottomBar.scss'; export type IrisGridBottomBarProps = { @@ -26,29 +26,27 @@ export const IrisGridBottomBar = ({ onEntered, onExiting, onExited, -}: IrisGridBottomBarProps): JSX.Element => { - return ( - ( + +
-
- {children} -
- - ); -}; + {children} +
+
+); export default IrisGridBottomBar; diff --git a/packages/code-studio/src/iris-grid/IrisGridCopyHandler.jsx b/packages/code-studio/src/iris-grid/IrisGridCopyHandler.jsx index 4deec3d58a..83402bd67d 100644 --- a/packages/code-studio/src/iris-grid/IrisGridCopyHandler.jsx +++ b/packages/code-studio/src/iris-grid/IrisGridCopyHandler.jsx @@ -12,6 +12,7 @@ import { CanceledPromiseError, PromiseUtils } from '@deephaven/utils'; import Log from '@deephaven/log'; import IrisGridModel from './IrisGridModel'; import IrisGridUtils from './IrisGridUtils'; +import IrisGridBottomBar from './IrisGridBottomBar'; import './IrisGridCopyHandler.scss'; const log = Log.module('IrisGridCopyHandler'); @@ -320,56 +321,52 @@ class IrisGridCopyHandler extends Component { const isDone = copyState === IrisGridCopyHandler.COPY_STATES.DONE; return ( - -
+ {statusMessageText} +
+ -
- {statusMessageText} +
+ +
- -
- - -
-
-
-
+
+ ); } } diff --git a/packages/code-studio/src/iris-grid/IrisGridTableModel.js b/packages/code-studio/src/iris-grid/IrisGridTableModel.js index 0802b87a84..1f3115537b 100644 --- a/packages/code-studio/src/iris-grid/IrisGridTableModel.js +++ b/packages/code-studio/src/iris-grid/IrisGridTableModel.js @@ -12,6 +12,7 @@ import { TableColumnFormatter } from './formatters'; import IrisGridModel from './IrisGridModel'; import AggregationOperation from './sidebar/aggregations/AggregationOperation.ts'; import IrisGridUtils from './IrisGridUtils'; +import MissingKeyError from './MissingKeyError'; const log = Log.module('IrisGridTableModel'); diff --git a/packages/code-studio/src/iris-grid/PendingDataBottomBar.scss b/packages/code-studio/src/iris-grid/PendingDataBottomBar.scss index 4c7cd24150..ed50bbac0f 100644 --- a/packages/code-studio/src/iris-grid/PendingDataBottomBar.scss +++ b/packages/code-studio/src/iris-grid/PendingDataBottomBar.scss @@ -1,4 +1,4 @@ -@import '../custom.scss'; +@import '~@deephaven/components/scss/custom.scss'; .pending-data-bottom-bar { background-color: $interfacegray; diff --git a/packages/code-studio/src/iris-grid/PendingDataBottomBar.tsx b/packages/code-studio/src/iris-grid/PendingDataBottomBar.tsx index ec1d504aa8..3b4345e020 100644 --- a/packages/code-studio/src/iris-grid/PendingDataBottomBar.tsx +++ b/packages/code-studio/src/iris-grid/PendingDataBottomBar.tsx @@ -1,14 +1,10 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { - faCheckCircle, - faExclamationTriangle, -} from '@fortawesome/pro-light-svg-icons'; -import { faCircle, faSpinnerThird } from '@fortawesome/pro-regular-svg-icons'; +import { dhCheckSquare, vsWarning } from '@deephaven/icons'; +import { Button, LoadingSpinner } from '@deephaven/components'; +import { usePrevious } from '@deephaven/react-hooks'; import IrisGridBottomBar from './IrisGridBottomBar'; -import { Button } from '../components'; import './PendingDataBottomBar.scss'; -import { usePrevious } from '../react-hooks'; const HIDE_TIMEOUT = 3000; @@ -76,22 +72,18 @@ export const PendingDataBottomBar = ({ } }, [pendingDataMap]); - useEffect(() => { - return () => - successTimeout.current ? clearTimeout(successTimeout.current) : undefined; - }, []); + useEffect( + () => () => + successTimeout.current ? clearTimeout(successTimeout.current) : undefined, + [] + ); const pendingRowCount = pendingDataMap.size; let commitIcon; if (isSaving) { - commitIcon = ( -
- - -
- ); + commitIcon = ; } else if (wasSuccessShown) { - commitIcon = faCheckCircle; + commitIcon = dhCheckSquare; } return ( @@ -110,7 +102,7 @@ export const PendingDataBottomBar = ({ > {error && (
- + {`${error}`}
)} diff --git a/packages/code-studio/src/iris-grid/mousehandlers/PendingMouseHandler.js b/packages/code-studio/src/iris-grid/mousehandlers/PendingMouseHandler.js index 1a9c7e70bd..9335e73854 100644 --- a/packages/code-studio/src/iris-grid/mousehandlers/PendingMouseHandler.js +++ b/packages/code-studio/src/iris-grid/mousehandlers/PendingMouseHandler.js @@ -1,5 +1,5 @@ /* eslint class-methods-use-this: "off" */ -import { GridMouseHandler, GridUtils } from '../../grid'; +import { GridMouseHandler, GridUtils } from '@deephaven/grid'; /** * Handles sending data selected via double click diff --git a/packages/components/src/Button.tsx b/packages/components/src/Button.tsx index 6cccb40ce0..6a41d13b1d 100644 --- a/packages/components/src/Button.tsx +++ b/packages/components/src/Button.tsx @@ -9,6 +9,7 @@ const BUTTON_KINDS = [ 'primary', 'secondary', 'tertiary', + 'success', 'danger', 'inline', 'ghost', @@ -54,6 +55,8 @@ function getClassName(kind: ButtonKind, iconOnly: boolean): string { return 'btn-outline-primary'; case 'tertiary': return 'btn-secondary'; + case 'success': + return 'btn-success'; case 'danger': return 'btn-danger'; case 'inline':