diff --git a/package-lock.json b/package-lock.json index fcbb66b5ec..646205b787 100644 --- a/package-lock.json +++ b/package-lock.json @@ -72,6 +72,7 @@ "@types/lodash.throttle": "^4.1.1", "@types/memoizee": "^0.4.5", "@types/node": "^16.11.7", + "@types/papaparse": "^5.3.2", "@types/pouchdb-browser": "^6.1.3", "@types/prop-types": "^15.7.3", "@types/react": "^17.0.2", @@ -82,6 +83,7 @@ "@types/react-transition-group": "^4.4.0", "@types/react-virtualized-auto-sizer": "^1.0.1", "@types/react-window": "^1.8.5", + "@types/shell-quote": "^1.7.1", "@types/shortid": "0.0.29", "@vscode/codicons": "0.0.25", "babel-eslint": "^10.1.0", @@ -6997,6 +6999,15 @@ "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz", "integrity": "sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==" }, + "node_modules/@types/papaparse": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.3.2.tgz", + "integrity": "sha512-BNbCHJkTE4RwmAFkCxEalET4mDvGr/1ld7ZtQ4i/laWI/iiVt+GL07stdvufle4KfywyvloqqpIiJscXNCrKxA==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", @@ -7204,6 +7215,12 @@ "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==" }, + "node_modules/@types/shell-quote": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@types/shell-quote/-/shell-quote-1.7.1.tgz", + "integrity": "sha512-SWZ2Nom1pkyXCDohRSrkSKvDh8QOG9RfAsrt5/NsPQC4UQJ55eG0qClA40I+Gkez4KTQ0uDUT8ELRXThf3J5jw==", + "dev": true + }, "node_modules/@types/shortid": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/shortid/-/shortid-0.0.29.tgz", @@ -39232,7 +39249,8 @@ "memoize-one": "^5.1.1", "memoizee": "^0.4.15", "monaco-editor": "^0.27.0", - "papaparse": "^5.2.0", + "papaparse": "^5.3.2", + "popper.js": "^1.16.1", "prop-types": "^15.7.2", "shell-quote": "^1.7.2" }, @@ -41034,7 +41052,8 @@ "memoize-one": "^5.1.1", "memoizee": "^0.4.15", "monaco-editor": "^0.27.0", - "papaparse": "^5.2.0", + "papaparse": "^5.3.2", + "popper.js": "^1.16.1", "prop-types": "^15.7.2", "shell-quote": "^1.7.2" } @@ -45086,6 +45105,15 @@ "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz", "integrity": "sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==" }, + "@types/papaparse": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.3.2.tgz", + "integrity": "sha512-BNbCHJkTE4RwmAFkCxEalET4mDvGr/1ld7ZtQ4i/laWI/iiVt+GL07stdvufle4KfywyvloqqpIiJscXNCrKxA==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", @@ -45293,6 +45321,12 @@ "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==" }, + "@types/shell-quote": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@types/shell-quote/-/shell-quote-1.7.1.tgz", + "integrity": "sha512-SWZ2Nom1pkyXCDohRSrkSKvDh8QOG9RfAsrt5/NsPQC4UQJ55eG0qClA40I+Gkez4KTQ0uDUT8ELRXThf3J5jw==", + "dev": true + }, "@types/shortid": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/shortid/-/shortid-0.0.29.tgz", diff --git a/package.json b/package.json index f991445188..c8f284a2cd 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "@types/lodash.throttle": "^4.1.1", "@types/memoizee": "^0.4.5", "@types/node": "^16.11.7", + "@types/papaparse": "^5.3.2", "@types/pouchdb-browser": "^6.1.3", "@types/prop-types": "^15.7.3", "@types/react": "^17.0.2", @@ -86,6 +87,7 @@ "@types/react-transition-group": "^4.4.0", "@types/react-virtualized-auto-sizer": "^1.0.1", "@types/react-window": "^1.8.5", + "@types/shell-quote": "^1.7.1", "@types/shortid": "0.0.29", "@vscode/codicons": "0.0.25", "babel-eslint": "^10.1.0", diff --git a/packages/components/src/shortcuts/Shortcut.ts b/packages/components/src/shortcuts/Shortcut.ts index 0cd43b3bb8..57618a9608 100644 --- a/packages/components/src/shortcuts/Shortcut.ts +++ b/packages/components/src/shortcuts/Shortcut.ts @@ -145,7 +145,7 @@ export default class Shortcut extends EventTarget { private readonly defaultKeyState: ValidKeyState; - private keyState: ValidKeyState; + keyState: ValidKeyState; static NULL_KEY_STATE: ValidKeyState = { metaKey: false, diff --git a/packages/console/package.json b/packages/console/package.json index 7dacbec7ae..23ec1c36be 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -43,7 +43,8 @@ "memoize-one": "^5.1.1", "memoizee": "^0.4.15", "monaco-editor": "^0.27.0", - "papaparse": "^5.2.0", + "papaparse": "^5.3.2", + "popper.js": "^1.16.1", "prop-types": "^15.7.2", "shell-quote": "^1.7.2" }, diff --git a/packages/console/src/Console.test.jsx b/packages/console/src/Console.test.tsx similarity index 59% rename from packages/console/src/Console.test.jsx rename to packages/console/src/Console.test.tsx index 082b02db7b..a91ddd0143 100644 --- a/packages/console/src/Console.test.jsx +++ b/packages/console/src/Console.test.tsx @@ -2,30 +2,33 @@ import React from 'react'; import dh from '@deephaven/jsapi-shim'; import { render } from '@testing-library/react'; import { Console } from './Console'; +import { CommandHistoryStorage } from './command-history'; -function makeMockCommandHistoryStorage() { +function makeMockCommandHistoryStorage(): CommandHistoryStorage { return { addItem: jest.fn(), getTable: jest.fn(), updateItem: jest.fn(), + listenItem: jest.fn(), }; } jest.mock('./ConsoleInput', () => () => null); jest.mock('./Console', () => ({ - ...jest.requireActual('./Console'), + ...(jest.requireActual('./Console') as Record), commandHistory: jest.fn(), })); function makeConsoleWrapper() { - const session = new dh.IdeSession('test'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const session = new (dh as any).IdeSession('test'); const commandHistoryStorage = makeMockCommandHistoryStorage(); return render( {}} - openObject={() => {}} - closeObject={() => {}} + focusCommandHistory={() => undefined} + openObject={() => undefined} + closeObject={() => undefined} session={session} language="test" /> diff --git a/packages/console/src/Console.jsx b/packages/console/src/Console.tsx similarity index 71% rename from packages/console/src/Console.jsx rename to packages/console/src/Console.tsx index bbccc35027..0ac9314bd7 100644 --- a/packages/console/src/Console.jsx +++ b/packages/console/src/Console.tsx @@ -1,17 +1,29 @@ /** * Console display for use in the Iris environment. */ -import React, { PureComponent } from 'react'; -import PropTypes from 'prop-types'; -import { ContextActions } from '@deephaven/components'; +import React, { + DragEvent, + PureComponent, + ReactElement, + ReactNode, + RefObject, +} from 'react'; +import { ContextActions, DropdownAction } from '@deephaven/components'; import { vsCheck } from '@deephaven/icons'; import classNames from 'classnames'; import memoize from 'memoize-one'; import throttle from 'lodash.throttle'; -import dh, { PropTypes as APIPropTypes } from '@deephaven/jsapi-shim'; +import type { JSZipObject } from 'jszip'; +import dh, { + IdeSession, + LogItem, + VariableChanges, + VariableDefinition, +} from '@deephaven/jsapi-shim'; import Log from '@deephaven/log'; -import { Pending } from '@deephaven/utils'; +import { assertNotNull, Pending, PromiseUtils } from '@deephaven/utils'; import ConsoleHistory from './console-history/ConsoleHistory'; +import { ConsoleHistoryActionItem } from './console-history/ConsoleHistoryTypes'; import SHORTCUTS from './ConsoleShortcuts'; import LogLevel from './log/LogLevel'; import ConsoleInput from './ConsoleInput'; @@ -19,25 +31,102 @@ import CsvOverlay from './csv/CsvOverlay'; import CsvInputBar from './csv/CsvInputBar'; import './Console.scss'; import ConsoleStatusBar from './ConsoleStatusBar'; -import StoragePropTypes from './StoragePropTypes'; +import { + CommandHistoryStorage, + CommandHistoryStorageItem, +} from './command-history'; const log = Log.module('Console'); -const DEFAULT_SETTINGS = { +interface Settings { + isAutoLaunchPanelsEnabled: boolean; + isPrintStdOutEnabled: boolean; + isClosePanelsOnDisconnectEnabled: boolean; +} + +const DEFAULT_SETTINGS: Settings = { isAutoLaunchPanelsEnabled: true, isPrintStdOutEnabled: true, isClosePanelsOnDisconnectEnabled: true, -}; +} as const; + +interface ConsoleProps { + statusBarChildren: ReactNode; + settings: Partial; + focusCommandHistory: () => void; + openObject: (object: VariableDefinition) => void; + closeObject: (object: VariableDefinition) => void; + session: IdeSession; + language: string; + commandHistoryStorage: CommandHistoryStorage; + onSettingsChange: (settings: Record) => void; + scope: string; + actions: DropdownAction[]; + timeZone: string; + + // Children shown at the bottom of the console history + historyChildren: ReactNode; + + // Known object map + objectMap: Map; + + disabled: boolean; + + /** + * Function to unzip a zip file. If not provided, zip files will not be accepted + * (file:File) => Promise + */ + unzip: (file: File) => Promise; +} + +interface ConsoleState { + // Need separate histories as console history has stdout/stderr output + consoleHistory: ConsoleHistoryActionItem[]; + + // Height of the viewport of the console input and history + consoleHeight: number; + + isScrollDecorationShown: boolean; + + // Location of objects in the console history + objectHistoryMap: Map; + + // The object definitions, name/type + objectMap: Map; + + showCsvOverlay: boolean; + csvFile: File | null; + csvPaste: string | null; + dragError: string | null; + csvUploadInProgress: boolean; + isAutoLaunchPanelsEnabled: boolean; + isPrintStdOutEnabled: boolean; + isClosePanelsOnDisconnectEnabled: boolean; +} +export class Console extends PureComponent { + static defaultProps = { + statusBarChildren: null, + settings: {}, + onSettingsChange: (): void => undefined, + scope: null, + actions: [], + historyChildren: null, + timeZone: 'America/New_York', + objectMap: new Map(), + disabled: false, + unzip: null, + }; -export class Console extends PureComponent { static LOG_THROTTLE = 500; /** * Check if the provided log level is an error type - * @param {LogLevel} logLevel The LogLevel being checked - * @returns {boolean} true if the log level is an error level log + * @param logLevel The LogLevel being checked + * @returns true if the log level is an error level log */ - static isErrorLevel(logLevel) { + static isErrorLevel( + logLevel: typeof LogLevel[keyof typeof LogLevel] + ): boolean { return ( logLevel === LogLevel.STDERR || logLevel === LogLevel.ERROR || @@ -47,16 +136,16 @@ export class Console extends PureComponent { /** * Check if the provided log level is output level - * @param {LogLevel} logLevel The LogLevel being checked - * @returns {boolean} true if the log level should be output to the console + * @param logLevel The LogLevel being checked + * @return true if the log level should be output to the console */ - static isOutputLevel(logLevel) { + static isOutputLevel(logLevel: string): boolean { // We want all errors to be output, in addition to STDOUT. // That way the user is more likely to see them. return logLevel === LogLevel.STDOUT || Console.isErrorLevel(logLevel); } - constructor(props) { + constructor(props: ConsoleProps) { super(props); this.handleCommandResult = this.handleCommandResult.bind(this); @@ -74,12 +163,9 @@ export class Console extends PureComponent { this ); this.handleTogglePrintStdout = this.handleTogglePrintStdout.bind(this); - this.processLogMessageQueue = throttle( - this.processLogMessageQueue.bind(this), - Console.LOG_THROTTLE - ); this.handleUploadCsv = this.handleUploadCsv.bind(this); this.handleHideCsv = this.handleHideCsv.bind(this); + this.handleCsvFileCanceled = this.handleCsvFileCanceled.bind(this); this.handleCsvFileOpened = this.handleCsvFileOpened.bind(this); this.handleCsvPaste = this.handleCsvPaste.bind(this); this.handleDragEnter = this.handleDragEnter.bind(this); @@ -90,7 +176,6 @@ export class Console extends PureComponent { this.handleCsvError = this.handleCsvError.bind(this); this.handleCsvInProgress = this.handleCsvInProgress.bind(this); - this.cancelListener = null; this.consolePane = React.createRef(); this.consoleInput = React.createRef(); this.consoleHistoryScrollPane = React.createRef(); @@ -125,7 +210,7 @@ export class Console extends PureComponent { }; } - componentDidMount() { + componentDidMount(): void { this.initConsoleLogging(); const { session } = this.props; @@ -137,7 +222,7 @@ export class Console extends PureComponent { this.updateDimensions(); } - componentDidUpdate(prevProps, prevState) { + componentDidUpdate(prevProps: ConsoleProps, prevState: ConsoleState): void { const { props, state } = this; this.sendSettingsChange(prevState, state); @@ -146,7 +231,7 @@ export class Console extends PureComponent { } } - componentWillUnmount() { + componentWillUnmount(): void { const { session } = this.props; session.removeEventListener( @@ -160,34 +245,44 @@ export class Console extends PureComponent { this.deinitConsoleLogging(); } - initConsoleLogging() { + cancelListener?: () => void; + + consolePane: RefObject; + + consoleInput: RefObject; + + consoleHistoryScrollPane: RefObject; + + pending: Pending; + + queuedLogMessages: ConsoleHistoryActionItem[]; + + initConsoleLogging(): void { const { session } = this.props; this.cancelListener = session.onLogMessage(this.handleLogMessage); } - deinitConsoleLogging() { + deinitConsoleLogging(): void { if (this.cancelListener != null) { this.cancelListener(); - this.cancelListener = null; + this.cancelListener = undefined; } } - handleClearShortcut(event) { + handleClearShortcut(event: CustomEvent): void { event.preventDefault(); event.stopPropagation(); - this.consoleInput.current.clear(); + this.consoleInput.current?.clear(); } - handleCommandStarted(event) { + handleCommandStarted(event: CustomEvent): void { const { code, result } = event.detail; const wrappedResult = this.pending.add(result); const historyItem = { command: code, - result: null, disabledObjects: [], startTime: Date.now(), - endTime: null, cancelResult: () => { result.cancel(); }, @@ -202,8 +297,6 @@ export class Console extends PureComponent { { command: code, startTime: new Date().toJSON(), - endTime: null, - result: null, } ); workspaceItemPromise.catch(err => { @@ -228,10 +321,20 @@ export class Console extends PureComponent { }); } - handleCommandResult(result, historyItemParam, workspaceItemPromise) { + handleCommandResult( + result: + | { + message: string; + error?: string; + changes: VariableChanges; + } + | undefined, + historyItemParam: ConsoleHistoryActionItem, + workspaceItemPromise: Promise + ): void { const historyItem = historyItemParam; - historyItem.wrappedResult = null; - historyItem.cancelResult = null; + historyItem.wrappedResult = undefined; + historyItem.cancelResult = undefined; if (!result) { return; @@ -250,12 +353,16 @@ export class Console extends PureComponent { this.openUpdatedItems(result.changes); } - handleCommandError(error, historyItemParam, workspaceItemPromise) { + handleCommandError( + error: unknown, + historyItemParam: ConsoleHistoryActionItem, + workspaceItemPromise: Promise + ): void { const historyItem = historyItemParam; - historyItem.wrappedResult = null; - historyItem.cancelResult = null; + historyItem.wrappedResult = undefined; + historyItem.cancelResult = undefined; - if (error && error.isCanceled) { + if (PromiseUtils.isCanceled(error)) { log.debug('Called handleCommandError on a cancelled promise result'); return; } @@ -277,7 +384,7 @@ export class Console extends PureComponent { }); } - handleFocusHistory(event) { + handleFocusHistory(event: CustomEvent): void { event.preventDefault(); event.stopPropagation(); @@ -285,7 +392,7 @@ export class Console extends PureComponent { focusCommandHistory(); } - handleLogMessage(message) { + handleLogMessage(message: LogItem): void { const { isPrintStdOutEnabled } = this.state; if (!isPrintStdOutEnabled) { return; @@ -296,22 +403,22 @@ export class Console extends PureComponent { } } - queueLogMessage(message, logLevel) { - const result = {}; + queueLogMessage(message: string, logLevel: string): void { + const result: Record = {}; if (Console.isErrorLevel(logLevel)) { result.error = message; } else { result.message = message; } - const historyItem = { command: null, result }; + const historyItem = { command: undefined, result }; this.queuedLogMessages.push(historyItem); this.processLogMessageQueue(); } - processLogMessageQueue() { + processLogMessageQueue = throttle(() => { this.scrollConsoleHistoryToBottom(); this.setState(state => { @@ -327,9 +434,9 @@ export class Console extends PureComponent { return { consoleHistory }; }); - } + }, Console.LOG_THROTTLE); - openUpdatedItems(changes) { + openUpdatedItems(changes: VariableChanges): void { const { isAutoLaunchPanelsEnabled } = this.state; if (!changes || !isAutoLaunchPanelsEnabled) { return; @@ -341,7 +448,7 @@ export class Console extends PureComponent { ); } - closeRemovedItems(changes) { + closeRemovedItems(changes: VariableChanges): void { if (!changes || !changes.removed || changes.removed.length === 0) { return; } @@ -351,7 +458,10 @@ export class Console extends PureComponent { removed.forEach(object => closeObject(object)); } - updateHistory(result, historyItemParam) { + updateHistory( + result: { changes: unknown }, + historyItemParam: ConsoleHistoryActionItem + ): void { const historyItem = historyItemParam; if (!result || !result.changes || !historyItem) { return; @@ -368,8 +478,11 @@ export class Console extends PureComponent { }); } - updateKnownObjects(historyItem) { - const { changes } = historyItem.result; + updateKnownObjects(historyItem: ConsoleHistoryActionItem): void { + let changes: undefined | VariableChanges; + if (historyItem.result) { + changes = historyItem.result.changes; + } if ( !changes || ((!changes.created || changes.created.length === 0) && @@ -391,52 +504,61 @@ export class Console extends PureComponent { const objectHistoryMap = new Map(state.objectHistoryMap); const objectMap = new Map(state.objectMap); - const disableOldObject = (object, isRemoved = false) => { - const { name } = object; - const oldIndex = objectHistoryMap.get(name); + const disableOldObject = ( + object: VariableDefinition, + isRemoved = false + ) => { + const { title } = object; + assertNotNull(title); + const oldIndex = objectHistoryMap.get(title); // oldIndex can be -1 if a object is active but doesn't have a command in consoleHistory // this can happen after clearing the console using 'clear' or 'cls' command - if (oldIndex >= 0) { + + if (oldIndex != null && oldIndex >= 0) { // disable outdated object variable in the old consoleHistory item - history[oldIndex].disabledObjects = history[ - oldIndex - ].disabledObjects.concat(name); - history[oldIndex] = { ...history[oldIndex] }; + history[oldIndex].disabledObjects = [ + ...(history[oldIndex].disabledObjects ?? []), + title, + ]; } - objectHistoryMap.set(name, itemIndex); + objectHistoryMap.set(title, itemIndex); if (isRemoved) { - objectMap.delete(name); + objectMap.delete(title); } else { - objectMap.set(name, object); + objectMap.set(title, object); } }; - changes.updated.forEach(object => disableOldObject(object)); - changes.removed.forEach(object => disableOldObject(object, true)); + changes?.updated.forEach(object => disableOldObject(object)); + changes?.removed.forEach(object => disableOldObject(object, true)); // Created objects have to be processed after removed // in case the same object name is present in both removed and created - changes.created.forEach(object => { - const { name } = object; - objectHistoryMap.set(name, itemIndex); - objectMap.set(name, object); + changes?.created.forEach(object => { + const { title } = object; + assertNotNull(title); + objectHistoryMap.set(title, itemIndex); + objectMap.set(title, object); }); return { objectHistoryMap, objectMap, consoleHistory: history }; }); } - updateObjectMap() { + updateObjectMap(): void { const { objectMap } = this.props; this.setState({ objectMap }); } /** * Updates an existing workspace CommandHistoryItem - * @param {object} result The result to store with the history item. Could be empty object for success - * @param {Promise} workspaceItemPromise The workspace data row promise for the workspace item to be updated + * @param result The result to store with the history item. Could be empty object for success + * @param workspaceItemPromise The workspace data row promise for the workspace item to be updated */ - updateWorkspaceHistoryItem(result, workspaceItemPromise) { + updateWorkspaceHistoryItem( + result: { error?: string }, + workspaceItemPromise: Promise + ): void { const promise = this.pending.add(workspaceItemPromise); const endTime = new Date().toJSON(); @@ -461,8 +583,9 @@ export class Console extends PureComponent { }); } - scrollConsoleHistoryToBottom(force = false) { + scrollConsoleHistoryToBottom(force = false): void { const pane = this.consoleHistoryScrollPane.current; + assertNotNull(pane); if (!force && pane.scrollTop < pane.scrollHeight - pane.offsetHeight) { return; } @@ -472,8 +595,9 @@ export class Console extends PureComponent { }); } - handleScrollPaneScroll() { + handleScrollPaneScroll(): void { const scrollPane = this.consoleHistoryScrollPane.current; + assertNotNull(scrollPane); if ( scrollPane.scrollTop > 0 && scrollPane.scrollHeight > scrollPane.clientHeight @@ -484,25 +608,25 @@ export class Console extends PureComponent { } } - handleToggleAutoLaunchPanels() { + handleToggleAutoLaunchPanels(): void { this.setState(state => ({ isAutoLaunchPanelsEnabled: !state.isAutoLaunchPanelsEnabled, })); } - handleToggleClosePanelsOnDisconnect() { + handleToggleClosePanelsOnDisconnect(): void { this.setState(state => ({ isClosePanelsOnDisconnectEnabled: !state.isClosePanelsOnDisconnectEnabled, })); } - handleTogglePrintStdout() { + handleTogglePrintStdout(): void { this.setState(state => ({ isPrintStdOutEnabled: !state.isPrintStdOutEnabled, })); } - handleUploadCsv() { + handleUploadCsv(): void { this.setState({ showCsvOverlay: true, dragError: null, @@ -510,7 +634,7 @@ export class Console extends PureComponent { }); } - handleHideCsv() { + handleHideCsv(): void { this.setState({ showCsvOverlay: false, csvFile: null, @@ -520,15 +644,19 @@ export class Console extends PureComponent { }); } - handleCsvFileOpened(file) { + handleCsvFileCanceled(): void { + this.setState({ csvFile: null, csvPaste: null }); + } + + handleCsvFileOpened(file: File): void { this.setState({ csvFile: file, csvPaste: null }); } - handleCsvPaste(value) { + handleCsvPaste(value: string): void { this.setState({ csvFile: null, csvPaste: value }); } - handleDragEnter(e) { + handleDragEnter(e: DragEvent): void { if ( !e.dataTransfer || !e.dataTransfer.items || @@ -555,9 +683,14 @@ export class Console extends PureComponent { } } - handleDragLeave(e) { + handleDragLeave(e: DragEvent): void { // DragLeave gets fired for every child element, so make sure we're actually leaving the drop zone - if (!e.currentTarget || e.currentTarget.contains(e.relatedTarget)) { + if ( + !e.currentTarget || + (e.currentTarget instanceof Element && + e.relatedTarget instanceof Element && + e.currentTarget.contains(e.relatedTarget)) + ) { return; } e.preventDefault(); @@ -565,11 +698,11 @@ export class Console extends PureComponent { this.setState({ showCsvOverlay: false, dragError: null }); } - handleClearDragError() { + handleClearDragError(): void { this.setState({ dragError: null }); } - handleOpenCsvTable(name) { + handleOpenCsvTable(name: string): void { const { openObject, commandHistoryStorage, language, scope } = this.props; const { consoleHistory, objectMap } = this.state; const object = { name, type: dh.VariableType.TABLE }; @@ -598,11 +731,10 @@ export class Console extends PureComponent { command: name, startTime: new Date().toJSON(), endTime: new Date().toJSON(), - result: null, }); } - addConsoleHistoryMessage(message, error) { + addConsoleHistoryMessage(message?: string, error?: string): void { const { consoleHistory } = this.state; const historyItem = { command: '', @@ -616,19 +748,19 @@ export class Console extends PureComponent { }); } - handleCsvUpdate(message) { + handleCsvUpdate(message: string): void { this.addConsoleHistoryMessage(message); } - handleCsvError(error) { - this.addConsoleHistoryMessage(null, error); + handleCsvError(error: unknown): void { + this.addConsoleHistoryMessage(undefined, error ? `${error}` : undefined); } - handleCsvInProgress(csvUploadInProgress) { + handleCsvInProgress(csvUploadInProgress: boolean): void { this.setState({ csvUploadInProgress }); } - handleOverflowActions() { + handleOverflowActions(): DropdownAction[] { const { isAutoLaunchPanelsEnabled, isClosePanelsOnDisconnectEnabled, @@ -641,21 +773,21 @@ export class Console extends PureComponent { title: 'Print Stdout', action: this.handleTogglePrintStdout, group: ContextActions.groups.high, - icon: isPrintStdOutEnabled ? vsCheck : null, + icon: isPrintStdOutEnabled ? vsCheck : undefined, order: 10, }, { title: 'Auto Launch Panels', action: this.handleToggleAutoLaunchPanels, group: ContextActions.groups.high, - icon: isAutoLaunchPanelsEnabled ? vsCheck : null, + icon: isAutoLaunchPanelsEnabled ? vsCheck : undefined, order: 20, }, { title: 'Close Panels on Disconnect', action: this.handleToggleClosePanelsOnDisconnect, group: ContextActions.groups.high, - icon: isClosePanelsOnDisconnectEnabled ? vsCheck : null, + icon: isClosePanelsOnDisconnectEnabled ? vsCheck : undefined, order: 30, }, { @@ -667,7 +799,7 @@ export class Console extends PureComponent { ]; } - handleCommandSubmit(command) { + handleCommandSubmit(command: string): void { if (command === 'clear' || command === 'cls') { this.clearConsoleHistory(); } else if (command.length > 0) { @@ -693,7 +825,7 @@ export class Console extends PureComponent { } } - clearConsoleHistory() { + clearConsoleHistory(): void { this.pending.cancel(); this.setState(state => { @@ -720,7 +852,7 @@ export class Console extends PureComponent { }, ]); - addCommand(command, focus = true, execute = false) { + addCommand(command: string, focus = true, execute = false): void { if (!this.consoleInput.current) { return; } @@ -731,13 +863,17 @@ export class Console extends PureComponent { } } - focus() { + focus(): void { this.consoleInput.current?.focus(); } - sendSettingsChange(prevState, state, checkIfChanged = true) { - const keys = Object.keys(DEFAULT_SETTINGS); - const settings = {}; + sendSettingsChange( + prevState: ConsoleState, + state: ConsoleState, + checkIfChanged = true + ): void { + const keys = Object.keys(DEFAULT_SETTINGS) as Array; + const settings: Record = {}; let hasChanges = false; for (let i = 0; i < keys.length; i += 1) { const key = keys[i]; @@ -755,7 +891,7 @@ export class Console extends PureComponent { onSettingsChange(settings); } - updateDimensions() { + updateDimensions(): void { if (this.consolePane.current) { this.setState({ consoleHeight: this.consolePane.current.clientHeight, @@ -766,7 +902,7 @@ export class Console extends PureComponent { } } - render() { + render(): ReactElement { const { actions, historyChildren, @@ -803,7 +939,7 @@ export class Console extends PureComponent {
@@ -817,6 +953,7 @@ export class Console extends PureComponent { > {showCsvOverlay && ( Promise - */ - unzip: PropTypes.func, -}; - -Console.defaultProps = { - statusBarChildren: null, - settings: {}, - onSettingsChange: () => {}, - scope: null, - actions: [], - historyChildren: null, - timeZone: 'America/New_York', - objectMap: new Map(), - disabled: false, - unzip: null, -}; - export default Console; diff --git a/packages/console/src/ConsoleInput.jsx b/packages/console/src/ConsoleInput.tsx similarity index 66% rename from packages/console/src/ConsoleInput.jsx rename to packages/console/src/ConsoleInput.tsx index 496c329d90..0ee2f09845 100644 --- a/packages/console/src/ConsoleInput.jsx +++ b/packages/console/src/ConsoleInput.tsx @@ -1,12 +1,21 @@ -import React, { PureComponent } from 'react'; -import PropTypes from 'prop-types'; +import React, { PureComponent, ReactElement, RefObject } from 'react'; import classNames from 'classnames'; import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'; import Log from '@deephaven/log'; -import { PromiseUtils } from '@deephaven/utils'; +import { + assertNotNull, + CancelablePromise, + PromiseUtils, +} from '@deephaven/utils'; +import { ViewportData } from '@deephaven/storage'; +import { IdeSession } from '@deephaven/jsapi-shim'; +import { + CommandHistoryStorage, + CommandHistoryStorageItem, + CommandHistoryTable, +} from './command-history'; import { MonacoCompletionProvider, MonacoTheme, MonacoUtils } from './monaco'; import './ConsoleInput.scss'; -import StoragePropTypes from './StoragePropTypes'; const log = Log.module('ConsoleInput'); @@ -16,23 +25,44 @@ const BOTTOM_PADDING = 6; const MIN_INPUT_HEIGHT = LINE_HEIGHT + TOP_PADDING + BOTTOM_PADDING; const BUFFER_SIZE = 100; +interface ConsoleInputProps { + session: IdeSession; + language: string; + scope?: string; + commandHistoryStorage: CommandHistoryStorage; + onSubmit: (command: string) => void; + maxHeight?: number; + disabled?: boolean; +} + +interface ConsoleInputState { + commandEditorHeight: number; + isFocused: boolean; + model: monaco.editor.ITextModel | null; +} + /** * Component for input in a console session. Handles loading the recent command history */ -export class ConsoleInput extends PureComponent { +export class ConsoleInput extends PureComponent< + ConsoleInputProps, + ConsoleInputState +> { + static defaultProps = { + maxHeight: LINE_HEIGHT * 10, + scope: null, + disabled: false, + }; + static INPUT_CLASS_NAME = 'console-input'; - constructor(props) { + constructor(props: ConsoleInputProps) { super(props); this.handleWindowResize = this.handleWindowResize.bind(this); - this.cancelListener = null; this.commandContainer = React.createRef(); - this.commandEditor = null; this.commandHistoryIndex = null; - this.commandSuggestionContainer = null; - this.loadingPromise = null; this.timestamp = Date.now(); this.bufferIndex = 0; this.history = []; @@ -46,7 +76,7 @@ export class ConsoleInput extends PureComponent { }; } - componentDidMount() { + componentDidMount(): void { this.initCommandEditor(); window.addEventListener('resize', this.handleWindowResize); @@ -54,11 +84,11 @@ export class ConsoleInput extends PureComponent { this.loadMoreHistory(); } - componentDidUpdate() { + componentDidUpdate(): void { this.layoutEditor(); } - componentWillUnmount() { + componentWillUnmount(): void { window.removeEventListener('resize', this.handleWindowResize); if (this.loadingPromise != null) { @@ -68,15 +98,38 @@ export class ConsoleInput extends PureComponent { this.destroyCommandEditor(); } + cancelListener?: () => void; + + commandContainer: RefObject; + + commandEditor?: monaco.editor.IStandaloneCodeEditor; + + commandHistoryIndex: number | null; + + commandSuggestionContainer?: Element | null; + + loadingPromise?: + | CancelablePromise> + | CancelablePromise; + + timestamp: number; + + bufferIndex: number | null; + + history: string[]; + + // Tracks every command that has been modified by its commandHistoryIndex. Cleared on any command being executed + modifiedCommands: Map; + /** * Sets the console text from an external source. * Sets commandHistoryIndex to null since the source is not part of the history - * @param {string} text The text to set in the input - * @param {boolean} focus If the input should be focused - * @param {boolean} execute If the input should be executed + * @param text The text to set in the input + * @param focus If the input should be focused + * @param execute If the input should be executed * @returns void */ - setConsoleText(text, focus = true, execute = false) { + setConsoleText(text: string, focus = true, execute = false): void { if (!text) { return; } @@ -86,7 +139,7 @@ export class ConsoleInput extends PureComponent { // Need to set commandHistoryIndex before value // On value change, modified commands map updates this.commandHistoryIndex = null; - this.commandEditor.setValue(text); + this.commandEditor?.setValue(text); if (focus) { this.focusEnd(); @@ -99,10 +152,10 @@ export class ConsoleInput extends PureComponent { } } - initCommandEditor() { + initCommandEditor(): void { const { language, session } = this.props; const commandSettings = { - copyWithSyntaxHighlighting: 'false', + copyWithSyntaxHighlighting: false, cursorStyle: 'block', fixedOverflowWidgets: true, folding: false, @@ -127,21 +180,18 @@ export class ConsoleInput extends PureComponent { tabCompletion: 'on', value: '', wordWrap: 'on', - }; + } as const; - this.commandEditor = monaco.editor.create( - this.commandContainer.current, - commandSettings - ); + const element = this.commandContainer.current; + assertNotNull(element); + this.commandEditor = monaco.editor.create(element, commandSettings); MonacoUtils.setEOL(this.commandEditor); MonacoUtils.openDocument(this.commandEditor, session); this.commandEditor.onDidChangeModelContent(() => { - this.modifiedCommands.set( - this.commandHistoryIndex, - this.commandEditor.getValue() - ); + const value = this.commandEditor?.getValue(); + this.modifiedCommands.set(this.commandHistoryIndex, value ?? null); this.updateDimensions(); }); @@ -160,8 +210,10 @@ export class ConsoleInput extends PureComponent { */ this.commandEditor.onKeyDown(keyEvent => { const { commandEditor, commandHistoryIndex } = this; - const { lineNumber } = commandEditor.getPosition(); - const model = commandEditor.getModel(); + const position = commandEditor?.getPosition(); + assertNotNull(position); + const { lineNumber } = position; + const model = commandEditor?.getModel(); if ( keyEvent.keyCode === monaco.KeyCode.UpArrow && !this.isSuggestionMenuPopulated() && @@ -183,7 +235,7 @@ export class ConsoleInput extends PureComponent { if ( keyEvent.keyCode === monaco.KeyCode.DownArrow && !this.isSuggestionMenuPopulated() && - lineNumber === model.getLineCount() + lineNumber === model?.getLineCount() ) { if (commandHistoryIndex != null && commandHistoryIndex > 0) { this.loadCommand(commandHistoryIndex - 1); @@ -211,7 +263,7 @@ export class ConsoleInput extends PureComponent { keyEvent.stopPropagation(); keyEvent.preventDefault(); - this.commandEditor.trigger( + this.commandEditor?.trigger( 'Tab key trigger suggestions', 'editor.action.triggerSuggest', {} @@ -227,7 +279,7 @@ export class ConsoleInput extends PureComponent { // Override the Ctrl+F functionality so that the find window doesn't appear this.commandEditor.addCommand( monaco.KeyMod.CtrlCmd | monaco.KeyCode.KEY_F, // eslint-disable-line no-bitwise - () => {} + () => undefined ); MonacoUtils.removeConflictingKeybindings(this.commandEditor); @@ -241,70 +293,75 @@ export class ConsoleInput extends PureComponent { this.setState({ model: this.commandEditor.getModel() }); } - destroyCommandEditor() { + destroyCommandEditor(): void { const { session } = this.props; if (this.commandEditor) { MonacoUtils.closeDocument(this.commandEditor, session); this.commandEditor.dispose(); - this.commandEditor = null; + this.commandEditor = undefined; } } - handleWindowResize() { + handleWindowResize(): void { this.updateDimensions(); } - isSuggestionMenuActive() { + isSuggestionMenuActive(): boolean { if (!this.commandSuggestionContainer) { this.commandSuggestionContainer = this.commandEditor - .getDomNode() - .querySelector('.suggest-widget'); + ?.getDomNode() + ?.querySelector('.suggest-widget'); } return ( - this.commandSuggestionContainer && - this.commandSuggestionContainer.classList.contains('visible') + (this.commandSuggestionContainer && + this.commandSuggestionContainer.classList.contains('visible')) ?? + false ); } - isSuggestionMenuPopulated() { + isSuggestionMenuPopulated(): boolean { return ( this.isSuggestionMenuActive() && - this.commandSuggestionContainer.querySelector('.monaco-list-rows') - .childElementCount > 0 + (this.commandSuggestionContainer?.querySelector('.monaco-list-rows') + ?.childElementCount ?? 0) > 0 ); } - focus() { - this.commandEditor.focus(); + focus(): void { + this.commandEditor?.focus(); } - focusStart() { - const model = this.commandEditor.getModel(); + focusStart(): void { + const model = this.commandEditor?.getModel(); + assertNotNull(model); const column = model.getLineLength(1) + 1; // Length of 1st line - const firstCharTop = this.commandEditor.getTopForPosition(1, column); - this.commandEditor.setPosition({ lineNumber: 1, column }); - this.commandEditor.setScrollTop(firstCharTop); - this.commandEditor.focus(); + const firstCharTop = this.commandEditor?.getTopForPosition(1, column); + assertNotNull(firstCharTop); + this.commandEditor?.setPosition({ lineNumber: 1, column }); + this.commandEditor?.setScrollTop(firstCharTop); + this.commandEditor?.focus(); } - focusEnd() { - const model = this.commandEditor.getModel(); + focusEnd(): void { + const model = this.commandEditor?.getModel(); + assertNotNull(model); const lastLine = model.getLineCount(); const column = model.getLineLength(lastLine) + 1; - const lastCharTop = this.commandEditor.getTopForPosition(lastLine, column); - this.commandEditor.setPosition({ lineNumber: lastLine, column }); - this.commandEditor.setScrollTop(lastCharTop); - this.commandEditor.focus(); + const lastCharTop = this.commandEditor?.getTopForPosition(lastLine, column); + assertNotNull(lastCharTop); + this.commandEditor?.setPosition({ lineNumber: lastLine, column }); + this.commandEditor?.setScrollTop(lastCharTop); + this.commandEditor?.focus(); } - clear() { - this.commandEditor.focus(); - this.commandEditor.getModel().setValue(''); + clear(): void { + this.commandEditor?.focus(); + this.commandEditor?.getModel()?.setValue(''); this.commandHistoryIndex = null; } - layoutEditor() { + layoutEditor(): void { if (this.commandEditor) { this.commandEditor.layout(); } @@ -313,10 +370,10 @@ export class ConsoleInput extends PureComponent { /** * Loads the given command from history * If edits have been made to the command since last run command, loads the modified version - * @param {number | null} index The index to load. Null to load command started in the editor and not in the history + * @param index The index to load. Null to load command started in the editor and not in the history */ - loadCommand(index) { - if (index !== null && index >= this.history.length) { + loadCommand(index: number | null): void { + if (index === null || index >= this.history.length) { return; } @@ -325,14 +382,14 @@ export class ConsoleInput extends PureComponent { index === null ? '' : this.history[this.history.length - index - 1]; this.commandHistoryIndex = index; - this.commandEditor.getModel().setValue(modifiedValue ?? historyValue); + this.commandEditor?.getModel()?.setValue(modifiedValue ?? historyValue); if (index !== null && index > this.history.length - BUFFER_SIZE) { this.loadMoreHistory(); } } - async loadMoreHistory() { + async loadMoreHistory(): Promise { try { if (this.loadingPromise != null || this.bufferIndex == null) { return; @@ -341,7 +398,7 @@ export class ConsoleInput extends PureComponent { const { commandHistoryStorage, language, scope } = this.props; this.loadingPromise = PromiseUtils.makeCancelable( - commandHistoryStorage.getTable(language, scope, this.timestamp), + commandHistoryStorage.getTable(language, scope ?? '', this.timestamp), resolved => resolved.close() ); @@ -363,15 +420,15 @@ export class ConsoleInput extends PureComponent { this.bufferIndex = null; } this.history = [ - ...viewportData.items.map(({ name }) => name).reverse(), + ...viewportData?.items.map(({ name }) => name).reverse(), ...this.history, ]; - this.loadingPromise = null; + this.loadingPromise = undefined; table.close(); } catch (err) { - this.loadingPromise = null; + this.loadingPromise = undefined; if (PromiseUtils.isCanceled(err)) { log.debug2('Promise canceled, not loading history'); return; @@ -381,25 +438,27 @@ export class ConsoleInput extends PureComponent { } } - processCommand() { + processCommand(): void { this.commandHistoryIndex = null; this.modifiedCommands.clear(); - const command = this.commandEditor.getValue().trim(); + const command = this.commandEditor?.getValue().trim(); + assertNotNull(command); this.history.push(command); - this.commandEditor.setValue(''); + this.commandEditor?.setValue(''); this.updateDimensions(); const { onSubmit } = this.props; onSubmit(command); } - updateDimensions() { + updateDimensions(): void { if (!this.commandEditor) { return; } const { maxHeight } = this.props; + assertNotNull(maxHeight); const contentHeight = this.commandEditor.getContentHeight(); const commandEditorHeight = Math.max( Math.min(contentHeight, maxHeight), @@ -408,25 +467,21 @@ export class ConsoleInput extends PureComponent { // Only show the overview ruler (markings overlapping sroll bar area) if the scrollbar will show const shouldScroll = contentHeight > commandEditorHeight; - const options = this.commandEditor.getOptions(); - if (shouldScroll) { - options.overviewRulerLanes = undefined; // Resets to default - } else { - options.overviewRulerLanes = 0; - } + + const options = { overviewRulerLanes: shouldScroll ? undefined : 0 }; this.setState( { commandEditorHeight, }, () => { - this.commandEditor.updateOptions(options); - this.commandEditor.layout(); + this.commandEditor?.updateOptions(options); + this.commandEditor?.layout(); } ); } - render() { + render(): ReactElement { const { disabled, language, session } = this.props; const { commandEditorHeight, isFocused, model } = this.state; return ( @@ -454,20 +509,4 @@ export class ConsoleInput extends PureComponent { } } -ConsoleInput.propTypes = { - session: PropTypes.shape({}).isRequired, - language: PropTypes.string.isRequired, - scope: PropTypes.string, - commandHistoryStorage: StoragePropTypes.CommandHistoryStorage.isRequired, - onSubmit: PropTypes.func.isRequired, - maxHeight: PropTypes.number, - disabled: PropTypes.bool, -}; - -ConsoleInput.defaultProps = { - maxHeight: LINE_HEIGHT * 10, - scope: null, - disabled: false, -}; - export default ConsoleInput; diff --git a/packages/console/src/ConsoleMenu.jsx b/packages/console/src/ConsoleMenu.tsx similarity index 63% rename from packages/console/src/ConsoleMenu.jsx rename to packages/console/src/ConsoleMenu.tsx index 09470fc1db..a20c0ebb4f 100644 --- a/packages/console/src/ConsoleMenu.jsx +++ b/packages/console/src/ConsoleMenu.tsx @@ -1,7 +1,17 @@ -import React, { PureComponent } from 'react'; -import PropTypes from 'prop-types'; +import React, { + ChangeEvent, + ChangeEventHandler, + PureComponent, + ReactElement, +} from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { DropdownMenu, SearchInput, Tooltip } from '@deephaven/components'; +import { + DropdownAction, + DropdownMenu, + PopperOptions, + SearchInput, + Tooltip, +} from '@deephaven/components'; import { dhTable, vsGraph, @@ -9,21 +19,32 @@ import { vsTriangleDown, } from '@deephaven/icons'; import Log from '@deephaven/log'; -import { PropTypes as APIPropTypes } from '@deephaven/jsapi-shim'; +import { VariableDefinition } from '@deephaven/jsapi-shim'; import memoize from 'memoize-one'; import './ConsoleMenu.scss'; import ConsoleUtils from './common/ConsoleUtils'; const log = Log.module('ConsoleMenu'); -class ConsoleMenu extends PureComponent { +interface ConsoleMenuProps { + openObject: (object: VariableDefinition) => void; + objects: VariableDefinition[]; + overflowActions: DropdownAction[]; +} + +interface ConsoleMenuState { + tableFilterText: string; + widgetFilterText: string; +} + +class ConsoleMenu extends PureComponent { static makeItemActions( - objects, - filterText, - refCallback, - changeCallback, - openCallback - ) { + objects: VariableDefinition[], + filterText: string, + refCallback: (ref: SearchInput) => void, + changeCallback: ChangeEventHandler, + openCallback: (object: VariableDefinition) => void + ): DropdownAction[] { if (objects.length === 0) { return []; } @@ -41,11 +62,13 @@ class ConsoleMenu extends PureComponent { let filteredItems = objects; if (filterText) { filteredItems = filteredItems.filter( - ({ name }) => name.toLowerCase().indexOf(filterText.toLowerCase()) >= 0 + ({ title }: { title?: string }) => + title != null && + title.toLowerCase().indexOf(filterText.toLowerCase()) >= 0 ); } const openActions = filteredItems.map(object => ({ - title: object.name, + title: object.title, action: () => { openCallback(object); }, @@ -54,7 +77,7 @@ class ConsoleMenu extends PureComponent { return [searchAction, ...openActions]; } - constructor(props) { + constructor(props: ConsoleMenuProps) { super(props); this.handleTableFilterChange = this.handleTableFilterChange.bind(this); @@ -64,76 +87,89 @@ class ConsoleMenu extends PureComponent { this.handleWidgetMenuClosed = this.handleWidgetMenuClosed.bind(this); this.handleWidgetMenuOpened = this.handleWidgetMenuOpened.bind(this); - this.tableSearchField = null; - this.widgetSearchField = null; - this.state = { tableFilterText: '', widgetFilterText: '', }; } - makeTableActions = memoize((objects, filterText, openObject) => { - const tables = objects.filter(object => - ConsoleUtils.isTableType(object.type) - ); - return ConsoleMenu.makeItemActions( - tables, - filterText, - searchField => { - this.tableSearchField = searchField; - }, - this.handleTableFilterChange, - openObject - ); - }); + tableSearchField?: SearchInput; - makeWidgetActions = memoize((objects, filterText, openObject) => { - const widgets = objects.filter(object => - ConsoleUtils.isWidgetType(object.type) - ); - return ConsoleMenu.makeItemActions( - widgets, - filterText, - searchField => { - this.widgetSearchField = searchField; - }, - this.handleWidgetFilterChange, - openObject - ); - }); + widgetSearchField?: SearchInput; - handleTableFilterChange(e) { + makeTableActions = memoize( + ( + objects: VariableDefinition[], + filterText: string, + openObject: (object: VariableDefinition) => void + ): DropdownAction[] => { + const tables = objects.filter(object => + ConsoleUtils.isTableType(object.type) + ); + return ConsoleMenu.makeItemActions( + tables, + filterText, + searchField => { + this.tableSearchField = searchField; + }, + this.handleTableFilterChange, + openObject + ); + } + ); + + makeWidgetActions = memoize( + ( + objects: VariableDefinition[], + filterText: string, + openObject: (object: VariableDefinition) => void + ): DropdownAction[] => { + const widgets = objects.filter(object => + ConsoleUtils.isWidgetType(object.type) + ); + return ConsoleMenu.makeItemActions( + widgets, + filterText, + searchField => { + this.widgetSearchField = searchField; + }, + this.handleWidgetFilterChange, + openObject + ); + } + ); + + handleTableFilterChange(e: ChangeEvent): void { log.debug('filtering tables...'); this.setState({ tableFilterText: e.target.value }); } - handleTableMenuClosed() { + handleTableMenuClosed(): void { this.setState({ tableFilterText: '' }); } - handleTableMenuOpened() { + handleTableMenuOpened(): void { if (this.tableSearchField && this.tableSearchField.focus) { this.tableSearchField.focus(); } } - handleWidgetFilterChange(e) { + handleWidgetFilterChange(e: ChangeEvent): void { log.debug('filtering widgets...'); this.setState({ widgetFilterText: e.target.value }); } - handleWidgetMenuClosed() { + handleWidgetMenuClosed(): void { this.setState({ widgetFilterText: '' }); } - handleWidgetMenuOpened() { + handleWidgetMenuOpened(): void { if (this.widgetSearchField && this.widgetSearchField.focus) { this.widgetSearchField.focus(); } } - render() { + render(): ReactElement { const { overflowActions, objects, openObject } = this.props; const { tableFilterText, widgetFilterText } = this.state; const tableActions = this.makeTableActions( @@ -146,7 +182,7 @@ class ConsoleMenu extends PureComponent { widgetFilterText, openObject ); - const popperOptions = { placement: 'bottom-end' }; + const popperOptions: PopperOptions = { placement: 'bottom-end' }; return (
@@ -212,13 +248,4 @@ class ConsoleMenu extends PureComponent { } } -ConsoleMenu.propTypes = { - openObject: PropTypes.func.isRequired, - objects: PropTypes.arrayOf(APIPropTypes.VariableDefinition).isRequired, - overflowActions: PropTypes.oneOfType([ - PropTypes.func, - PropTypes.arrayOf(PropTypes.shape({})), - ]).isRequired, -}; - export default ConsoleMenu; diff --git a/packages/console/src/ConsolePropTypes.js b/packages/console/src/ConsolePropTypes.ts similarity index 100% rename from packages/console/src/ConsolePropTypes.js rename to packages/console/src/ConsolePropTypes.ts diff --git a/packages/console/src/ConsoleStatusBar.test.jsx b/packages/console/src/ConsoleStatusBar.test.tsx similarity index 73% rename from packages/console/src/ConsoleStatusBar.test.jsx rename to packages/console/src/ConsoleStatusBar.test.tsx index e1ba7f2aa1..4be9b1df14 100644 --- a/packages/console/src/ConsoleStatusBar.test.jsx +++ b/packages/console/src/ConsoleStatusBar.test.tsx @@ -4,13 +4,12 @@ import dh from '@deephaven/jsapi-shim'; import ConsoleStatusBar from './ConsoleStatusBar'; function makeConsoleStatusBarWrapper() { - const session = new dh.IdeSession('test'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const session = new (dh as any).IdeSession('test'); const wrapper = render( {}} - openObject={() => {}} + openObject={() => undefined} objects={[]} overflowActions={[]} /> diff --git a/packages/console/src/ConsoleStatusBar.jsx b/packages/console/src/ConsoleStatusBar.tsx similarity index 67% rename from packages/console/src/ConsoleStatusBar.jsx rename to packages/console/src/ConsoleStatusBar.tsx index 94782e6359..7ba04ca616 100644 --- a/packages/console/src/ConsoleStatusBar.jsx +++ b/packages/console/src/ConsoleStatusBar.tsx @@ -1,14 +1,33 @@ -import React, { PureComponent } from 'react'; -import PropTypes from 'prop-types'; +import React, { PureComponent, ReactElement, ReactNode } from 'react'; import classNames from 'classnames'; -import dh, { PropTypes as APIPropTypes } from '@deephaven/jsapi-shim'; -import { Tooltip } from '@deephaven/components'; +import dh, { IdeSession, VariableDefinition } from '@deephaven/jsapi-shim'; +import { DropdownAction, Tooltip } from '@deephaven/components'; import { CanceledPromiseError, Pending } from '@deephaven/utils'; import ConsoleMenu from './ConsoleMenu'; import './ConsoleStatusBar.scss'; -export class ConsoleStatusBar extends PureComponent { - constructor(props) { +interface ConsoleStatusBarProps { + children: ReactNode; + session: IdeSession; + openObject: (object: VariableDefinition) => void; + objects: VariableDefinition[]; + overflowActions: DropdownAction[]; +} + +interface ConsoleStatusBarState { + isDisconnected: boolean; + isCommandRunning: boolean; +} + +export class ConsoleStatusBar extends PureComponent< + ConsoleStatusBarProps, + ConsoleStatusBarState +> { + static defaultProps = { + children: null, + }; + + constructor(props: ConsoleStatusBarProps) { super(props); this.handleCommandStarted = this.handleCommandStarted.bind(this); @@ -22,16 +41,18 @@ export class ConsoleStatusBar extends PureComponent { }; } - componentDidMount() { + componentDidMount(): void { this.startListening(); } - componentWillUnmount() { + componentWillUnmount(): void { this.stopListening(); this.cancelPendingPromises(); } - startListening() { + pending: Pending; + + startListening(): void { const { session } = this.props; session.addEventListener( dh.IdeSession.EVENT_COMMANDSTARTED, @@ -39,7 +60,7 @@ export class ConsoleStatusBar extends PureComponent { ); } - stopListening() { + stopListening(): void { const { session } = this.props; session.removeEventListener( dh.IdeSession.EVENT_COMMANDSTARTED, @@ -47,24 +68,24 @@ export class ConsoleStatusBar extends PureComponent { ); } - cancelPendingPromises() { + cancelPendingPromises(): void { this.pending.cancel(); } - handleCommandStarted(event) { + handleCommandStarted(event: CustomEvent): void { const { result } = event.detail; this.pending .add(result) - .then(() => this.handleCommandCompleted(null, result)) - .catch(error => this.handleCommandCompleted(error, result)); + .then(() => this.handleCommandCompleted(null)) + .catch(error => this.handleCommandCompleted(error)); this.setState({ isCommandRunning: true, }); } - handleCommandCompleted(error) { + handleCommandCompleted(error: unknown): void { // Don't update state if the promise was canceled if (!(error instanceof CanceledPromiseError)) { this.setState({ @@ -73,7 +94,7 @@ export class ConsoleStatusBar extends PureComponent { } } - render() { + render(): ReactElement { const { children, openObject, overflowActions, objects } = this.props; const { isDisconnected, isCommandRunning } = this.state; @@ -110,19 +131,4 @@ export class ConsoleStatusBar extends PureComponent { } } -ConsoleStatusBar.propTypes = { - children: PropTypes.node, - session: APIPropTypes.IdeSession.isRequired, - openObject: PropTypes.func.isRequired, - objects: PropTypes.arrayOf(APIPropTypes.VariableDefinition).isRequired, - overflowActions: PropTypes.oneOfType([ - PropTypes.func, - PropTypes.arrayOf(PropTypes.shape({})), - ]).isRequired, -}; - -ConsoleStatusBar.defaultProps = { - children: null, -}; - export default ConsoleStatusBar; diff --git a/packages/console/src/StoragePropTypes.js b/packages/console/src/StoragePropTypes.ts similarity index 100% rename from packages/console/src/StoragePropTypes.js rename to packages/console/src/StoragePropTypes.ts diff --git a/packages/console/src/command-history/CommandHistory.test.jsx b/packages/console/src/command-history/CommandHistory.test.tsx similarity index 89% rename from packages/console/src/command-history/CommandHistory.test.jsx rename to packages/console/src/command-history/CommandHistory.test.tsx index 81b16c2959..1960c53b89 100644 --- a/packages/console/src/command-history/CommandHistory.test.jsx +++ b/packages/console/src/command-history/CommandHistory.test.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { fireEvent, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import CommandHistory from './CommandHistory'; +import { CommandHistoryTable } from './CommandHistoryStorage'; jest.mock('pouchdb-browser'); @@ -24,7 +25,7 @@ jest.mock('./CommandHistoryViewportUpdater', () => }) ); -function makeCommandHistoryTable(itemLength) { +function makeCommandHistoryTable(itemLength): CommandHistoryTable { return { onUpdate: jest.fn(), setSearch: jest.fn(), @@ -32,6 +33,10 @@ function makeCommandHistoryTable(itemLength) { setViewport: jest.fn(), getSnapshot: jest.fn(), size: itemLength, + getViewportData: jest.fn(), + setFilters: jest.fn(), + setSorts: jest.fn(), + close: jest.fn(), }; } @@ -41,9 +46,22 @@ function mountItems(itemLength = 10) { {}} - sendToNotebook={() => {}} - commandHistoryStorage={{ addItem() {}, updateItem() {}, getTable() {} }} + sendToConsole={() => undefined} + sendToNotebook={() => undefined} + commandHistoryStorage={{ + addItem() { + return undefined; + }, + updateItem() { + return undefined; + }, + getTable() { + return undefined; + }, + listenItem() { + return undefined; + }, + }} /> ); const items = makeItems(itemLength); diff --git a/packages/console/src/command-history/CommandHistory.jsx b/packages/console/src/command-history/CommandHistory.tsx similarity index 79% rename from packages/console/src/command-history/CommandHistory.jsx rename to packages/console/src/command-history/CommandHistory.tsx index 31dbbb72c9..4b243d1111 100644 --- a/packages/console/src/command-history/CommandHistory.jsx +++ b/packages/console/src/command-history/CommandHistory.tsx @@ -1,12 +1,13 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; +import React, { ChangeEvent, Component, ReactElement, RefObject } from 'react'; import { ContextActions, ContextActionUtils, ItemList, SearchInput, GLOBAL_SHORTCUTS, + RenderItemProps, } from '@deephaven/components'; +import { ViewportData } from '@deephaven/storage'; import { vsFileCode, vsFiles, @@ -14,20 +15,50 @@ import { vsPlay, vsTerminal, } from '@deephaven/icons'; -import { Pending } from '@deephaven/utils'; +import { Pending, Range } from '@deephaven/utils'; import Log from '@deephaven/log'; import CommandHistoryItem from './CommandHistoryItem'; import CommandHistoryActions from './CommandHistoryActions'; import ConsoleConstants from '../common/ConsoleConstants'; import './CommandHistory.scss'; -import StoragePropTypes from '../StoragePropTypes'; import CommandHistoryViewportUpdater from './CommandHistoryViewportUpdater'; import SHORTCUTS from '../ConsoleShortcuts'; +import CommandHistoryStorage, { + CommandHistoryStorageItem, + CommandHistoryTable, +} from './CommandHistoryStorage'; +import { ItemAction, HistoryAction } from './CommandHistoryTypes'; const log = Log.module('CommandHistory'); -class CommandHistory extends Component { +type Settings = { + value: string; + language: string; +}; +interface CommandHistoryProps { + language: string; + sendToConsole: (command: string, focus?: boolean, execute?: boolean) => void; + sendToNotebook: (settings: Settings, forceNewNotebook?: boolean) => void; + table: CommandHistoryTable; + commandHistoryStorage: CommandHistoryStorage; +} +interface CommandHistoryState { + actions: ItemAction[]; + historyActions: HistoryAction[]; + top: number; + bottom: number; + itemCount: number; + items: CommandHistoryStorageItem[]; + offset: number; + selectedRanges: Range[]; + searchText: string; +} + +class CommandHistory extends Component< + CommandHistoryProps, + CommandHistoryState +> { static ITEM_HEIGHT = 29; static MAX_SELECTION_COUNT = 10000; @@ -36,7 +67,11 @@ class CommandHistory extends Component { send: ContextActions.groups.medium + 100, }; - static getCommandsFromViewport(items, offset, sortedRanges) { + static getCommandsFromViewport( + items: CommandHistoryStorageItem[], + offset: number, + sortedRanges: Range[] + ): string[] { const commands = []; for (let i = 0; i < sortedRanges.length; i += 1) { const range = sortedRanges[i]; @@ -50,12 +85,15 @@ class CommandHistory extends Component { return commands; } - static async getCommandsFromSnapshot(table, sortedRanges) { + static async getCommandsFromSnapshot( + table: CommandHistoryTable, + sortedRanges: Range[] + ): Promise { const items = await table.getSnapshot(sortedRanges); return [...items.values()].map(item => item.name); } - constructor(props) { + constructor(props: CommandHistoryProps) { super(props); this.copySelectedCommands = this.copySelectedCommands.bind(this); @@ -151,16 +189,24 @@ class CommandHistory extends Component { }; } - componentWillUnmount() { + componentWillUnmount(): void { this.pending.cancel(); } + itemActions: ItemAction[]; + + historyActions: HistoryAction[]; + + pending: Pending; + + searchInputRef: RefObject; + /** * Retrieves the selected commands as an array. * If they're not within the current viewport, will fetch them from the table - * @returns {Promise} Array of selected commands + * @returns Array of selected commands */ - async getSelectedCommands() { + async getSelectedCommands(): Promise { const { items, offset, selectedRanges } = this.state; const ranges = selectedRanges.slice().sort((a, b) => a[0] - b[0]); @@ -182,13 +228,13 @@ class CommandHistory extends Component { /** * Retrieves the text of all the currently selected commands, joined by a new line char - * @returns {Promise} The commands joined by \n char + * @returns The commands joined by \n char */ - getSelectedCommandText() { + getSelectedCommandText(): Promise { return this.getSelectedCommands().then(commands => commands.join('\n')); } - updateActions() { + updateActions(): void { this.setState(state => { const { selectedRanges } = state; const selectedRowCount = selectedRanges.reduce( @@ -207,13 +253,13 @@ class CommandHistory extends Component { }); } - copySelectedCommands() { + copySelectedCommands(): void { this.getSelectedCommandText() .then(ContextActionUtils.copyToClipboard) .catch(log.error); } - createNotebook() { + createNotebook(): void { this.getSelectedCommandText() .then(commandText => { const { language, sendToNotebook } = this.props; @@ -222,7 +268,7 @@ class CommandHistory extends Component { .catch(log.error); } - sendToNotebook() { + sendToNotebook(): void { this.getSelectedCommandText() .then(commandText => { const { language, sendToNotebook } = this.props; @@ -231,12 +277,12 @@ class CommandHistory extends Component { .catch(log.error); } - sendToConsole() { + sendToConsole(): void { const { sendToConsole } = this.props; this.getSelectedCommandText().then(sendToConsole).catch(log.error); } - runInConsole() { + runInConsole(): void { this.getSelectedCommandText() .then(commandText => { const { sendToConsole } = this.props; @@ -245,7 +291,7 @@ class CommandHistory extends Component { .catch(log.error); } - handleSelect(index) { + handleSelect(index: number): void { const { sendToConsole } = this.props; const { items, offset } = this.state; if (index < offset || index >= offset + items.length) { @@ -257,31 +303,37 @@ class CommandHistory extends Component { sendToConsole(name); } - handleSelectionChange(selectedRanges) { + handleSelectionChange(selectedRanges: Range[]): void { this.setState({ selectedRanges }); this.updateActions(); } - handleViewportChange(top, bottom) { + handleViewportChange(top: number, bottom: number): void { this.setState({ top, bottom }); } - handleSearchChange(e) { + handleSearchChange(e: ChangeEvent): void { // clear selected range, as old selection could be filtered from list this.setState({ searchText: e.target.value, selectedRanges: [] }); } - handleViewportUpdate({ items, offset }) { + handleViewportUpdate({ + items, + offset, + }: ViewportData): void { const { table } = this.props; const itemCount = table.size; this.setState({ items, itemCount, offset }); } - renderItem({ item, itemIndex, isSelected }) { + renderItem({ + item, + itemIndex, + isSelected, + }: RenderItemProps): ReactElement { const { language, commandHistoryStorage } = this.props; return ( +> { + static itemKey(i: unknown, item: HistoryAction): string { return `${item.title}`; } - static renderContent(item) { - if (item.selectionRequired && item.icon) { + static renderContent(item: HistoryAction): JSX.Element { + if (item.selectionRequired) { return (
); } - - if (!item.selectionRequired && item.icon) { - return ; - } - - return item.title; + return ; } - render() { + render(): ReactElement { const { actions, hasSelection } = this.props; return ( @@ -56,17 +59,4 @@ class CommandHistoryActions extends Component { } } -CommandHistoryActions.propTypes = { - actions: PropTypes.arrayOf( - PropTypes.shape({ - action: PropTypes.func.isRequired, - title: PropTypes.string.isRequired, - description: PropTypes.string, - icon: PropTypes.FontAwesomeIcon, - selectionRequired: PropTypes.bool, - }) - ).isRequired, - hasSelection: PropTypes.bool.isRequired, -}; - export default CommandHistoryActions; diff --git a/packages/console/src/command-history/CommandHistoryItem.jsx b/packages/console/src/command-history/CommandHistoryItem.tsx similarity index 72% rename from packages/console/src/command-history/CommandHistoryItem.jsx rename to packages/console/src/command-history/CommandHistoryItem.tsx index 6bad29a5a1..ab58faac87 100644 --- a/packages/console/src/command-history/CommandHistoryItem.jsx +++ b/packages/console/src/command-history/CommandHistoryItem.tsx @@ -1,19 +1,26 @@ -import React, { useRef, useCallback } from 'react'; +import React, { useRef, useCallback, ReactElement } from 'react'; import classNames from 'classnames'; -import PropTypes from 'prop-types'; import { Tooltip } from '@deephaven/components'; import './CommandHistoryItem.scss'; import CommandHistoryItemTooltip from './CommandHistoryItemTooltip'; -import ConsolePropTypes from '../ConsolePropTypes'; -import StoragePropTypes from '../StoragePropTypes'; +import CommandHistoryStorage, { + CommandHistoryStorageItem, +} from './CommandHistoryStorage'; + +interface CommandHistoryItemProps { + item: CommandHistoryStorageItem; + language: string; + isSelected?: boolean; + commandHistoryStorage: CommandHistoryStorage; +} const MAX_TRUNCATE_LENGTH = 512; -const CommandHistoryItem = props => { +const CommandHistoryItem = (props: CommandHistoryItemProps): ReactElement => { const { item, language, isSelected, commandHistoryStorage } = props; const previewText = item.name.substring(0, MAX_TRUNCATE_LENGTH); - const tooltip = useRef(); + const tooltip = useRef(null); const handleUpdate = useCallback(() => { tooltip.current?.update(); }, [tooltip]); @@ -51,13 +58,6 @@ const CommandHistoryItem = props => { ); }; -CommandHistoryItem.propTypes = { - item: ConsolePropTypes.CommandHistoryItem.isRequired, - language: PropTypes.string.isRequired, - isSelected: PropTypes.bool, - commandHistoryStorage: StoragePropTypes.CommandHistoryStorage.isRequired, -}; - CommandHistoryItem.defaultProps = { isSelected: false, }; diff --git a/packages/console/src/command-history/CommandHistoryItemTooltip.test.jsx b/packages/console/src/command-history/CommandHistoryItemTooltip.test.tsx similarity index 92% rename from packages/console/src/command-history/CommandHistoryItemTooltip.test.jsx rename to packages/console/src/command-history/CommandHistoryItemTooltip.test.tsx index fcfbaa420f..d28aa1d7d3 100644 --- a/packages/console/src/command-history/CommandHistoryItemTooltip.test.jsx +++ b/packages/console/src/command-history/CommandHistoryItemTooltip.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import { CommandHistoryItemTooltip } from './CommandHistoryItemTooltip'; +import { CommandHistoryStorageItem } from './CommandHistoryStorage'; jest.mock('../common/Code', () => () => 'Code'); @@ -13,10 +14,14 @@ function makeCommandHistoryStorage() { }; } -function makeItem(id = 'TestId', name = 'Test command') { +function makeItem( + id = 'TestId', + name = 'Test command' +): CommandHistoryStorageItem { return { id, name, + data: { command: name, startTime: `${Date.now()}` }, }; } diff --git a/packages/console/src/command-history/CommandHistoryItemTooltip.jsx b/packages/console/src/command-history/CommandHistoryItemTooltip.tsx similarity index 68% rename from packages/console/src/command-history/CommandHistoryItemTooltip.jsx rename to packages/console/src/command-history/CommandHistoryItemTooltip.tsx index 24308ae563..64359e3bd5 100644 --- a/packages/console/src/command-history/CommandHistoryItemTooltip.jsx +++ b/packages/console/src/command-history/CommandHistoryItemTooltip.tsx @@ -1,27 +1,52 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; +import React, { Component, ReactElement } from 'react'; import debounce from 'lodash.debounce'; import memoize from 'memoizee'; import { LoadingSpinner } from '@deephaven/components'; import { TimeUtils } from '@deephaven/utils'; +import { StorageListenerRemover } from '@deephaven/storage'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { vsWarning } from '@deephaven/icons'; import Code from '../common/Code'; import './CommandHistoryItemTooltip.scss'; -import ConsolePropTypes from '../ConsolePropTypes'; -import StoragePropTypes from '../StoragePropTypes'; +import CommandHistoryStorage, { + CommandHistoryStorageData, + CommandHistoryStorageItem, +} from './CommandHistoryStorage'; + +interface CommandHistoryItemTooltipProps { + item: CommandHistoryStorageItem; + language: string; + onUpdate?: (data: CommandHistoryStorageData | null) => void; + commandHistoryStorage: CommandHistoryStorage; +} + +interface CommandHistoryItemTooltipState { + currentTime: number; + data?: CommandHistoryStorageData; + error?: string; +} const LOAD_DATA_DEBOUNCE = 250; const MAX_NUMBER_OF_LINES = 2500; -export class CommandHistoryItemTooltip extends Component { - static getTimeString(startTime, endTime) { +export class CommandHistoryItemTooltip extends Component< + CommandHistoryItemTooltipProps, + CommandHistoryItemTooltipState +> { + static defaultProps = { + onUpdate: (): void => undefined, + }; + + static getTimeString( + startTime: string | undefined, + endTime: string | number + ): string | null { if (!startTime || !endTime) { return null; } const deltaTime = Math.round( - (new Date(endTime) - new Date(startTime)) / 1000 + (new Date(endTime).valueOf() - new Date(startTime).valueOf()) / 1000 ); if (deltaTime < 1) return '<1s'; @@ -29,29 +54,26 @@ export class CommandHistoryItemTooltip extends Component { return TimeUtils.formatElapsedTime(deltaTime); } - constructor(props) { + constructor(props: CommandHistoryItemTooltipProps) { super(props); - this.loadData = debounce(this.loadData.bind(this), LOAD_DATA_DEBOUNCE); this.handleUpdate = this.handleUpdate.bind(this); this.handleError = this.handleError.bind(this); this.handleTimeout = this.handleTimeout.bind(this); - this.timer = null; - this.cleanup = null; - this.state = { currentTime: Date.now(), - data: null, - error: null, }; } - componentDidMount() { + componentDidMount(): void { this.loadData(); } - componentDidUpdate(prevProps, prevState) { + componentDidUpdate( + prevProps: CommandHistoryItemTooltipProps, + prevState: CommandHistoryItemTooltipState + ): void { const { data } = this.state; if ( @@ -70,7 +92,7 @@ export class CommandHistoryItemTooltip extends Component { } } - componentWillUnmount() { + componentWillUnmount(): void { this.loadData.cancel(); if (this.cleanup != null) { this.cleanup(); @@ -78,7 +100,11 @@ export class CommandHistoryItemTooltip extends Component { this.stopTimer(); } - loadData() { + timer?: NodeJS.Timer; + + cleanup?: StorageListenerRemover; + + loadData = debounce((): void => { const { commandHistoryStorage, item, language } = this.props; const { id } = item; this.cleanup = commandHistoryStorage.listenItem( @@ -87,40 +113,40 @@ export class CommandHistoryItemTooltip extends Component { this.handleUpdate, this.handleError ); - } + }, LOAD_DATA_DEBOUNCE); - startTimer() { + startTimer(): void { this.stopTimer(); this.timer = setInterval(this.handleTimeout, 1000); } - stopTimer() { + stopTimer(): void { if (this.timer) { clearInterval(this.timer); - this.timer = null; + this.timer = undefined; } } - updateTime() { + updateTime(): void { this.setState({ currentTime: Date.now(), }); } - handleError(error) { + handleError(error: string): void { this.setState({ error: `${error}` }); } - handleUpdate(item) { - const { data = null } = item ?? {}; + handleUpdate(item: CommandHistoryStorageItem): void { + const { data } = item ?? {}; this.setState({ data }); const { onUpdate } = this.props; - onUpdate(data); + onUpdate?.(data); } - handleTimeout() { + handleTimeout(): void { this.updateTime(); } @@ -129,14 +155,16 @@ export class CommandHistoryItemTooltip extends Component { { max: 1 } ); - render() { + render(): ReactElement { const { item: { name }, language, } = this.props; const { currentTime, data, error } = this.state; const { result, startTime, endTime } = data ?? {}; - const errorMessage = result?.error?.message ?? result?.error ?? error; + + const errorMessage = result?.error ?? error; + const timeString = CommandHistoryItemTooltip.getTimeString( startTime, endTime || currentTime @@ -176,15 +204,4 @@ export class CommandHistoryItemTooltip extends Component { } } -CommandHistoryItemTooltip.propTypes = { - item: ConsolePropTypes.CommandHistoryItem.isRequired, - language: PropTypes.string.isRequired, - onUpdate: PropTypes.func, - commandHistoryStorage: StoragePropTypes.CommandHistoryStorage.isRequired, -}; - -CommandHistoryItemTooltip.defaultProps = { - onUpdate: () => {}, -}; - export default CommandHistoryItemTooltip; diff --git a/packages/console/src/command-history/CommandHistoryTypes.tsx b/packages/console/src/command-history/CommandHistoryTypes.tsx new file mode 100644 index 0000000000..b9a4b44deb --- /dev/null +++ b/packages/console/src/command-history/CommandHistoryTypes.tsx @@ -0,0 +1,20 @@ +import { ContextAction } from '@deephaven/components'; +import { IconDefinition } from '@deephaven/icons'; + +export type ItemAction = ContextAction & { + title: string; + description: string; + icon: IconDefinition; + action: () => void; + group: number; + order?: number; +}; + +export type HistoryAction = ContextAction & { + action: () => void; + title: string; + description: string; + icon: IconDefinition; + selectionRequired?: boolean; + className?: string; +}; diff --git a/packages/console/src/command-history/index.jsx b/packages/console/src/command-history/index.tsx similarity index 100% rename from packages/console/src/command-history/index.jsx rename to packages/console/src/command-history/index.tsx diff --git a/packages/console/src/common/Code.jsx b/packages/console/src/common/Code.tsx similarity index 70% rename from packages/console/src/common/Code.jsx rename to packages/console/src/common/Code.tsx index b22133d88b..9d76da607e 100644 --- a/packages/console/src/common/Code.jsx +++ b/packages/console/src/common/Code.tsx @@ -1,19 +1,25 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; +import React, { Component, ReactElement, ReactNode } from 'react'; import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'; -class Code extends Component { - constructor(props) { +interface CodeProps { + children: ReactNode; + language: string; +} + +class Code extends Component> { + constructor(props: CodeProps) { super(props); this.container = null; } - componentDidMount() { + componentDidMount(): void { this.colorize(); } - colorize() { + container: HTMLDivElement | null; + + colorize(): void { const { children } = this.props; if (this.container && children) { monaco.editor.colorizeElement(this.container, { @@ -22,7 +28,7 @@ class Code extends Component { } } - render() { + render(): ReactElement { const { children, language } = this.props; return (
@@ -42,9 +48,4 @@ class Code extends Component { } } -Code.propTypes = { - children: PropTypes.node.isRequired, - language: PropTypes.string.isRequired, -}; - export default Code; diff --git a/packages/console/src/common/ConsoleUtils.js b/packages/console/src/common/ConsoleUtils.js deleted file mode 100644 index f5cd3c47b0..0000000000 --- a/packages/console/src/common/ConsoleUtils.js +++ /dev/null @@ -1,71 +0,0 @@ -import ShellQuote from 'shell-quote'; -import dh from '@deephaven/jsapi-shim'; - -class ConsoleUtils { - /** - * Given the provided text, parse out arguments using shell quoting rules. - * @param str The text to parse. - * @return string[] of the arguments. Empty if no arguments found. - */ - static parseArguments(str) { - if (!str || !(typeof str === 'string' || str instanceof String)) { - return []; - } - - // Parse can return an object, not just a string. See the `ParseEntry` type def for all types - // We must map them all to strings. Filter out comments that will not be needed as well. - return ShellQuote.parse(str) - .filter(arg => !arg.comment) - .map(arg => arg.pattern ?? arg.op ?? `${arg}`); - } - - static formatTimestamp(date) { - if (!date || !(date instanceof Date)) { - return null; - } - - const hours = `${date.getHours()}`.padStart(2, '0'); - const minutes = `${date.getMinutes()}`.padStart(2, '0'); - const seconds = `${date.getSeconds()}`.padStart(2, '0'); - const milliseconds = `${date.getMilliseconds()}`.padStart(3, '0'); - - return `${hours}:${minutes}:${seconds}.${milliseconds}`; - } - - static defaultHost() { - let defaultHost = null; - try { - const url = new URL(process.env.REACT_APP_CORE_API_URL); - defaultHost = url.hostname; - } catch (error) { - defaultHost = window.location.hostname; - } - return defaultHost; - } - - static isTableType(type) { - return type === dh.VariableType.TABLE || type === dh.VariableType.TREETABLE; - } - - static isWidgetType(type) { - return ( - type === dh.VariableType.FIGURE || - type === dh.VariableType.OTHERWIDGET || - type === dh.VariableType.PANDAS - ); - } - - static isOpenableType(type) { - return ConsoleUtils.isTableType(type) || ConsoleUtils.isWidgetType(type); - } - - static isFigureType(type) { - return type === dh.VariableType.FIGURE; - } - - static isPandas(type) { - return type === dh.VariableType.PANDAS; - } -} - -export default ConsoleUtils; diff --git a/packages/console/src/common/ConsoleUtils.test.js b/packages/console/src/common/ConsoleUtils.test.ts similarity index 76% rename from packages/console/src/common/ConsoleUtils.test.js rename to packages/console/src/common/ConsoleUtils.test.ts index fafac40262..937764cafa 100644 --- a/packages/console/src/common/ConsoleUtils.test.js +++ b/packages/console/src/common/ConsoleUtils.test.ts @@ -40,3 +40,12 @@ describe('parsing shell arguments from text', () => { testStr('foo # bar', ['foo']); }); }); + +describe('predicates', () => { + expect(ConsoleUtils.hasComment('asdf')).toBeFalsy(); + expect(ConsoleUtils.hasComment({ comment: '123' })).toBeTruthy(); + expect(ConsoleUtils.hasOp('asd')).toBeFalsy(); + expect(ConsoleUtils.hasOp({ op: '||' })).toBeTruthy(); + expect(ConsoleUtils.hasPattern('asd')).toBeFalsy(); + expect(ConsoleUtils.hasPattern({ op: 'glob', pattern: '||' })).toBeTruthy(); +}); diff --git a/packages/console/src/common/ConsoleUtils.ts b/packages/console/src/common/ConsoleUtils.ts new file mode 100644 index 0000000000..65f633b8f9 --- /dev/null +++ b/packages/console/src/common/ConsoleUtils.ts @@ -0,0 +1,97 @@ +import ShellQuote, { ParseEntry, ControlOperator } from 'shell-quote'; +import dh, { VariableTypeUnion } from '@deephaven/jsapi-shim'; +import Log from '@deephaven/log'; + +const log = Log.module('FigureChartModel'); + +class ConsoleUtils { + static hasComment(arg: ParseEntry): arg is { comment: string } { + return (arg as { comment: string }).comment !== undefined; + } + + static hasPattern(arg: ParseEntry): arg is { op: 'glob'; pattern: string } { + return (arg as { pattern: string }).pattern !== undefined; + } + + static hasOp(arg: ParseEntry): arg is { op: ControlOperator } { + return (arg as { op: ControlOperator }).op !== undefined; + } + + /** + * Given the provided text, parse out arguments using shell quoting rules. + * @param str The text to parse. + * @return string[] of the arguments. Empty if no arguments found. + */ + static parseArguments(str: unknown): string[] { + if (!str || !(typeof str === 'string' || str instanceof String)) { + return []; + } + + // Parse can return an object, not just a string. See the `ParseEntry` type def for all types + // We must map them all to strings. Filter out comments that will not be needed as well. + return ShellQuote.parse(str as string) + .filter(arg => !this.hasComment(arg)) + .map(arg => { + if (this.hasPattern(arg)) { + return arg.pattern; + } + if (this.hasOp(arg)) { + return arg.op; + } + return `${arg}`; + }); + } + + static formatTimestamp(date: Date): string | null { + if (!date || !(date instanceof Date)) { + return null; + } + + const hours = `${date.getHours()}`.padStart(2, '0'); + const minutes = `${date.getMinutes()}`.padStart(2, '0'); + const seconds = `${date.getSeconds()}`.padStart(2, '0'); + const milliseconds = `${date.getMilliseconds()}`.padStart(3, '0'); + + return `${hours}:${minutes}:${seconds}.${milliseconds}`; + } + + static defaultHost(): string { + let defaultHost = window.location.hostname; + const apiUrl = process.env.REACT_APP_CORE_API_URL; + if (apiUrl != null) { + try { + const url = new URL(apiUrl); + defaultHost = url.hostname; + } catch (error: unknown) { + log.error('API_URL failed', error); + } + } + return defaultHost; + } + + static isTableType(type: VariableTypeUnion): boolean { + return type === dh.VariableType.TABLE || type === dh.VariableType.TREETABLE; + } + + static isWidgetType(type: VariableTypeUnion): boolean { + return ( + type === dh.VariableType.FIGURE || + type === dh.VariableType.OTHERWIDGET || + type === dh.VariableType.PANDAS + ); + } + + static isOpenableType(type: VariableTypeUnion): boolean { + return ConsoleUtils.isTableType(type) || ConsoleUtils.isWidgetType(type); + } + + static isFigureType(type: VariableTypeUnion): boolean { + return type === dh.VariableType.FIGURE; + } + + static isPandas(type: VariableTypeUnion): boolean { + return type === dh.VariableType.PANDAS; + } +} + +export default ConsoleUtils; diff --git a/packages/console/src/console-history/ConsoleHistory.test.jsx b/packages/console/src/console-history/ConsoleHistory.test.tsx similarity index 97% rename from packages/console/src/console-history/ConsoleHistory.test.jsx rename to packages/console/src/console-history/ConsoleHistory.test.tsx index 6637eee0df..ed408e0cb2 100644 --- a/packages/console/src/console-history/ConsoleHistory.test.jsx +++ b/packages/console/src/console-history/ConsoleHistory.test.tsx @@ -5,7 +5,7 @@ import ConsoleHistory from './ConsoleHistory'; function makeHistoryItem( message, result, - cancelResult = () => {}, + cancelResult = () => undefined, disabledObjects = [] ) { return { diff --git a/packages/console/src/console-history/ConsoleHistory.jsx b/packages/console/src/console-history/ConsoleHistory.tsx similarity index 60% rename from packages/console/src/console-history/ConsoleHistory.jsx rename to packages/console/src/console-history/ConsoleHistory.tsx index 2ecb693964..a3db5da460 100644 --- a/packages/console/src/console-history/ConsoleHistory.jsx +++ b/packages/console/src/console-history/ConsoleHistory.tsx @@ -1,20 +1,35 @@ /** * Console display for use in the Iris environment. */ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; +import React, { Component, ReactElement } from 'react'; +import { VariableDefinition } from '@deephaven/jsapi-shim'; import ConsoleHistoryItem from './ConsoleHistoryItem'; import './ConsoleHistory.scss'; +import { ConsoleHistoryActionItem } from './ConsoleHistoryTypes'; -class ConsoleHistory extends Component { - static itemKey(i, item) { +interface ConsoleHistoryProps { + items: ConsoleHistoryActionItem[]; + language: string; + openObject: (object: VariableDefinition) => void; + disabled?: boolean; +} + +class ConsoleHistory extends Component< + ConsoleHistoryProps, + Record +> { + static defaultProps = { + disabled: false, + }; + + static itemKey(i: number, item: ConsoleHistoryActionItem): string { return `${i}.${item.command}.${item.result && item.result.message}.${ item.result && item.result.error }`; } - render() { + render(): ReactElement { const { disabled, items, language, openObject } = this.props; const historyElements = []; for (let i = 0; i < items.length; i += 1) { @@ -37,15 +52,4 @@ class ConsoleHistory extends Component { } } -ConsoleHistory.propTypes = { - items: PropTypes.arrayOf(PropTypes.shape({})).isRequired, - language: PropTypes.string.isRequired, - openObject: PropTypes.func.isRequired, - disabled: PropTypes.bool, -}; - -ConsoleHistory.defaultProps = { - disabled: false, -}; - export default ConsoleHistory; diff --git a/packages/console/src/console-history/ConsoleHistoryItem.test.jsx b/packages/console/src/console-history/ConsoleHistoryItem.test.tsx similarity index 79% rename from packages/console/src/console-history/ConsoleHistoryItem.test.jsx rename to packages/console/src/console-history/ConsoleHistoryItem.test.tsx index bbdd908130..dcac9473b4 100644 --- a/packages/console/src/console-history/ConsoleHistoryItem.test.jsx +++ b/packages/console/src/console-history/ConsoleHistoryItem.test.tsx @@ -4,12 +4,7 @@ import ConsoleHistoryItem from './ConsoleHistoryItem'; const DEFAULT_ITEM = { message: 'Test item', - result: { - then: callback => { - callback('Command result!'); - }, - }, - cancelResult: () => {}, + cancelResult: () => undefined, disabledObjects: [], }; diff --git a/packages/console/src/console-history/ConsoleHistoryItem.jsx b/packages/console/src/console-history/ConsoleHistoryItem.tsx similarity index 72% rename from packages/console/src/console-history/ConsoleHistoryItem.jsx rename to packages/console/src/console-history/ConsoleHistoryItem.tsx index cbac386036..a981946843 100644 --- a/packages/console/src/console-history/ConsoleHistoryItem.jsx +++ b/packages/console/src/console-history/ConsoleHistoryItem.tsx @@ -1,35 +1,49 @@ /** * Console display for use in the Iris environment. */ -import React, { PureComponent } from 'react'; -import PropTypes from 'prop-types'; +import React, { PureComponent, ReactElement } from 'react'; import { ButtonOld } from '@deephaven/components'; -import { PropTypes as APIPropTypes } from '@deephaven/jsapi-shim'; import Log from '@deephaven/log'; +import { VariableDefinition } from '@deephaven/jsapi-shim'; import { Code, ObjectIcon } from '../common'; import ConsoleHistoryItemResult from './ConsoleHistoryItemResult'; import ConsoleHistoryResultInProgress from './ConsoleHistoryResultInProgress'; import ConsoleHistoryResultErrorMessage from './ConsoleHistoryResultErrorMessage'; import './ConsoleHistoryItem.scss'; +import { ConsoleHistoryActionItem } from './ConsoleHistoryTypes'; const log = Log.module('ConsoleHistoryItem'); -class ConsoleHistoryItem extends PureComponent { - constructor(props) { +interface ConsoleHistoryItemProps { + item: ConsoleHistoryActionItem; + language: string; + openObject: (object: VariableDefinition) => void; + disabled?: boolean; +} + +class ConsoleHistoryItem extends PureComponent< + ConsoleHistoryItemProps, + Record +> { + static defaultProps = { + disabled: false, + }; + + constructor(props: ConsoleHistoryItemProps) { super(props); this.handleCancelClick = this.handleCancelClick.bind(this); this.handleObjectClick = this.handleObjectClick.bind(this); } - handleObjectClick(object) { + handleObjectClick(object: VariableDefinition): void { log.debug('handleObjectClick', object); const { openObject } = this.props; openObject(object); } - handleCancelClick() { + handleCancelClick(): void { const { item } = this.props; if (item && item.cancelResult) { log.debug(`Cancelling command: ${item.command}`); @@ -37,7 +51,7 @@ class ConsoleHistoryItem extends PureComponent { } } - render() { + render(): ReactElement { const { disabled, item, language } = this.props; const { disabledObjects, result } = item; @@ -61,10 +75,10 @@ class ConsoleHistoryItem extends PureComponent { if (changes) { const { created, updated } = changes; [...created, ...updated].forEach(object => { - const { name } = object; - const key = `${name}`; + const { title } = object; + const key = `${title}`; const btnDisabled = - disabled || (disabledObjects ?? []).indexOf(name) >= 0; + disabled || (disabledObjects ?? []).indexOf(key) >= 0; const element = ( - {name} + {title} ); resultElements.push(element); @@ -81,9 +95,9 @@ class ConsoleHistoryItem extends PureComponent { // If the error has an associated command, we'll actually get a separate ERROR item printed out, so only print an error if there isn't an associated command if (error && !item.command) { - let errorMessage = error.message; + let errorMessage = `${(error as { message: string }).message ?? error}`; if (!errorMessage) { - errorMessage = error; + errorMessage = error as string; } const element = ( ( +const ConsoleHistoryItemResult = ({ + children, +}: { + children: ReactNode; +}): ReactElement => (
-
{children}
diff --git a/packages/console/src/console-history/ConsoleHistoryResultErrorMessage.jsx b/packages/console/src/console-history/ConsoleHistoryResultErrorMessage.tsx similarity index 79% rename from packages/console/src/console-history/ConsoleHistoryResultErrorMessage.jsx rename to packages/console/src/console-history/ConsoleHistoryResultErrorMessage.tsx index bcb846e004..ade57b5553 100644 --- a/packages/console/src/console-history/ConsoleHistoryResultErrorMessage.jsx +++ b/packages/console/src/console-history/ConsoleHistoryResultErrorMessage.tsx @@ -1,16 +1,37 @@ /** * Error message that can be expanded */ -import React, { PureComponent } from 'react'; +import React, { + KeyboardEvent, + MouseEvent, + PureComponent, + ReactElement, +} from 'react'; import classNames from 'classnames'; -import PropTypes from 'prop-types'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { vsTriangleRight, vsTriangleDown } from '@deephaven/icons'; +import { assertNotNull } from '@deephaven/utils'; + +interface ConsoleHistoryResultErrorMessageProps { + message?: string; +} + +interface ConsoleHistoryResultErrorMessageState { + isExpanded: boolean; + isTriggerHovered: boolean; +} + +class ConsoleHistoryResultErrorMessage extends PureComponent< + ConsoleHistoryResultErrorMessageProps, + ConsoleHistoryResultErrorMessageState +> { + static defaultProps = { + message: '', + }; -class ConsoleHistoryResultErrorMessage extends PureComponent { static mouseDragThreshold = 5; - constructor(props) { + constructor(props: ConsoleHistoryResultErrorMessageProps) { super(props); this.handleKeyPress = this.handleKeyPress.bind(this); @@ -31,7 +52,13 @@ class ConsoleHistoryResultErrorMessage extends PureComponent { }; } - handleKeyPress(event) { + mouseX: number | null; + + mouseY: number | null; + + isClicking: boolean; + + handleKeyPress(event: KeyboardEvent): void { switch (event.key) { case 'Enter': case ' ': @@ -45,13 +72,13 @@ class ConsoleHistoryResultErrorMessage extends PureComponent { } } - handleMouseDown(event) { + handleMouseDown(event: MouseEvent): void { this.mouseX = event.clientX; this.mouseY = event.clientY; this.isClicking = true; } - handleMouseMove(event) { + handleMouseMove(event: MouseEvent): void { if (this.mouseX != null && this.mouseY != null) { if ( Math.abs(event.clientX - this.mouseX) >= @@ -67,7 +94,7 @@ class ConsoleHistoryResultErrorMessage extends PureComponent { } } - handleMouseUp(event) { + handleMouseUp(event: MouseEvent): void { // We don't want to expand/collapse the error if user is holding shift or an alt key // They may be trying to adjust their selection if (this.isClicking && !event.shiftKey && !event.metaKey && !event.altKey) { @@ -78,21 +105,22 @@ class ConsoleHistoryResultErrorMessage extends PureComponent { this.isClicking = false; } - handleToggleError() { + handleToggleError(): void { this.setState(state => ({ isExpanded: !state.isExpanded })); } - handleMouseEnter() { + handleMouseEnter(): void { this.setState({ isTriggerHovered: true }); } - handleMouseLeave() { + handleMouseLeave(): void { this.setState({ isTriggerHovered: false }); } - render() { + render(): ReactElement { const { isExpanded, isTriggerHovered } = this.state; const { message: messageProp } = this.props; + assertNotNull(messageProp); const lineBreakIndex = messageProp.indexOf('\n'); const isMultiline = lineBreakIndex > -1; let topLineOfMessage = messageProp; @@ -118,7 +146,7 @@ class ConsoleHistoryResultErrorMessage extends PureComponent { type="button" onClick={this.handleToggleError} className={arrowBtnClasses} - tabIndex="-1" + tabIndex={-1} > void; + disabled: boolean; +} + +interface ConsoleHistoryResultInProgressState { + elapsed: number; +} /** * A spinner shown when a command is taking a while. */ -class ConsoleHistoryResultInProgress extends Component { - constructor(props) { +class ConsoleHistoryResultInProgress extends Component< + ConsoleHistoryResultInProgressProps, + ConsoleHistoryResultInProgressState +> { + static defaultProps = { + disabled: false, + }; + + constructor(props: ConsoleHistoryResultInProgressProps) { super(props); this.updateElapsed = this.updateElapsed.bind(this); - this.timer = null; this.startTime = Date.now(); this.state = { @@ -24,25 +37,29 @@ class ConsoleHistoryResultInProgress extends Component { }; } - componentDidMount() { + componentDidMount(): void { this.timer = setInterval(this.updateElapsed, 1000); } - componentWillUnmount() { + componentWillUnmount(): void { if (this.timer) { clearInterval(this.timer); } - this.timer = null; + this.timer = undefined; } - updateElapsed() { + timer?: NodeJS.Timer; + + startTime: number; + + updateElapsed(): void { this.setState({ elapsed: Math.round((Date.now() - this.startTime) / 1000), }); } - render() { + render(): ReactElement { const { disabled, onCancelClick } = this.props; const { elapsed } = this.state; return ( @@ -64,13 +81,4 @@ class ConsoleHistoryResultInProgress extends Component { } } -ConsoleHistoryResultInProgress.propTypes = { - onCancelClick: PropTypes.func.isRequired, - disabled: PropTypes.bool, -}; - -ConsoleHistoryResultInProgress.defaultProps = { - disabled: false, -}; - export default ConsoleHistoryResultInProgress; diff --git a/packages/console/src/console-history/ConsoleHistoryTypes.tsx b/packages/console/src/console-history/ConsoleHistoryTypes.tsx new file mode 100644 index 0000000000..22a2889a49 --- /dev/null +++ b/packages/console/src/console-history/ConsoleHistoryTypes.tsx @@ -0,0 +1,23 @@ +import { CancelablePromise } from '@deephaven/utils'; +import { VariableChanges } from '@deephaven/jsapi-shim'; + +export type ConsoleHistoryError = + | string + | { + message: string; + } + | undefined; + +export interface ConsoleHistoryActionItem { + command?: string; + result?: { + message?: string; + error?: unknown; + changes?: VariableChanges; + }; + disabledObjects?: string[]; + startTime?: number; + endTime?: number; + cancelResult?: () => void; + wrappedResult?: CancelablePromise; +} diff --git a/packages/console/src/console-history/index.jsx b/packages/console/src/console-history/index.tsx similarity index 100% rename from packages/console/src/console-history/index.jsx rename to packages/console/src/console-history/index.tsx diff --git a/packages/console/src/csv/CsvFormats.js b/packages/console/src/csv/CsvFormats.ts similarity index 82% rename from packages/console/src/csv/CsvFormats.js rename to packages/console/src/csv/CsvFormats.ts index ec0533b24f..67ec46df7e 100644 --- a/packages/console/src/csv/CsvFormats.js +++ b/packages/console/src/csv/CsvFormats.ts @@ -1,9 +1,11 @@ +export type CsvTypes = typeof CsvFormats.TYPES[keyof typeof CsvFormats.TYPES]; + class CsvFormats { - static DEFAULT_TYPE = 'DEFAULT_CSV'; + static DEFAULT_TYPE = 'DEFAULT_CSV' as const; - static AUTO = 'AUTODETECT'; + static AUTO = 'AUTODETECT' as const; - static fromExtension(fileName) { + static fromExtension(fileName: string): keyof typeof CsvFormats.TYPES { if (fileName.endsWith('.csv')) { return 'DEFAULT_CSV'; } @@ -20,7 +22,7 @@ class CsvFormats { DEFAULT_CSV: { name: 'Default csv (trimmed)', delimiter: ',', - newline: '', // autodetect + newline: undefined, // autodetect escapeChar: '"', shouldTrim: true, skipEmptyLines: true, @@ -30,7 +32,7 @@ class CsvFormats { TSV: { name: 'Tab seperated (tsv)', delimiter: '\t', - newline: '', // autodetect + newline: undefined, // autodetect escapeChar: '"', shouldTrim: true, skipEmptyLines: true, @@ -84,7 +86,7 @@ class CsvFormats { COLON_SV: { name: ': colon sv', delimiter: ':', - newline: '', // autodetect + newline: undefined, // autodetect escapeChar: '"', shouldTrim: true, skipEmptyLines: true, @@ -94,7 +96,7 @@ class CsvFormats { SEMI_COLON_SV: { name: '; semi-colon sv', delimiter: ';', - newline: '', // autodetect + newline: undefined, // autodetect escapeChar: '"', shouldTrim: true, skipEmptyLines: true, @@ -104,7 +106,7 @@ class CsvFormats { PIPE_SV: { name: '| pipe separated (psv)', delimiter: '|', - newline: '', // autodetect + newline: undefined, // autodetect escapeChar: '"', shouldTrim: true, skipEmptyLines: true, @@ -114,7 +116,7 @@ class CsvFormats { SPACE_SV: { name: '" " space sv', delimiter: ' ', - newline: '', // autodetect + newline: undefined, // autodetect escapeChar: '"', shouldTrim: true, skipEmptyLines: true, @@ -123,8 +125,8 @@ class CsvFormats { AUTODETECT: { name: 'autodetect', - delimiter: '', // autodetect - newline: '', // autodetect + delimiter: undefined, // autodetect + newline: undefined, // autodetect escapeChar: '"', shouldTrim: true, skipEmptyLines: true, diff --git a/packages/console/src/csv/CsvInputBar.jsx b/packages/console/src/csv/CsvInputBar.tsx similarity index 81% rename from packages/console/src/csv/CsvInputBar.jsx rename to packages/console/src/csv/CsvInputBar.tsx index d9ddf9f413..5151d41ad8 100644 --- a/packages/console/src/csv/CsvInputBar.jsx +++ b/packages/console/src/csv/CsvInputBar.tsx @@ -1,7 +1,14 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; +import React, { + ChangeEvent, + Component, + FormEvent, + ReactElement, + RefObject, +} from 'react'; import classNames from 'classnames'; +import type { JSZipObject } from 'jszip'; import { Button, Checkbox } from '@deephaven/components'; +import { IdeSession, Table } from '@deephaven/jsapi-shim'; import Log from '@deephaven/log'; import { DbNameValidator } from '@deephaven/utils'; import CsvOverlay from './CsvOverlay'; @@ -17,11 +24,39 @@ const TYPE_OPTIONS = Object.entries(CsvFormats.TYPES).map(([key, value]) => ( )); +interface CsvInputBarProps { + session: IdeSession; + onOpenTable: (name: string) => void; + onClose: () => void; + onUpdate: (update: string) => void; + onError: (e: unknown) => void; + file: File; + paste?: string; + onInProgress: (boolean: boolean) => void; + timeZone: string; + unzip?: (zipFile: File) => Promise; +} + +interface CsvInputBarState { + tableName: string; + tableNameSet: boolean; + isFirstRowHeaders: boolean; + showProgress: boolean; + progressValue: number; + type: keyof typeof CsvFormats.TYPES; + parser: CsvParser | null; +} /** * Input controls for CSV upload. */ -class CsvInputBar extends Component { - constructor(props) { +class CsvInputBar extends Component { + static defaultProps = { + file: null, + paste: null, + unzip: null, + }; + + constructor(props: CsvInputBarProps) { super(props); this.handleUpload = this.handleUpload.bind(this); @@ -48,7 +83,7 @@ class CsvInputBar extends Component { // React documentation says it is fine to update state inside an if statment /* eslint-disable react/no-did-update-set-state */ - componentDidUpdate(prevProps) { + componentDidUpdate(prevProps: CsvInputBarProps): void { const { file, paste } = this.props; const { tableName, tableNameSet } = this.state; // Set the table name from a file @@ -61,7 +96,7 @@ class CsvInputBar extends Component { tableName: fileTableName, tableNameSet: true, }); - this.inputRef.current.focus(); + this.inputRef.current?.focus(); } else if ((!file && prevProps.file) || (!paste && prevProps.paste)) { // The file or paste was unstaged this.setState({ @@ -87,35 +122,37 @@ class CsvInputBar extends Component { } } - componentWillUnmount() { + componentWillUnmount(): void { const { parser } = this.state; if (parser) { parser.cancel(); } } - handleCancel() { + inputRef: RefObject; + + handleCancel(): void { const { onClose } = this.props; onClose(); } - handleError(e) { + handleError(e: unknown): void { const { onClose, onError } = this.props; log.error(e); onError(e); onClose(); } - handleTableName(event) { + handleTableName(event: ChangeEvent): void { this.setState({ tableName: event.target.value, tableNameSet: true }); } - toggleFirstRowHeaders() { + toggleFirstRowHeaders(): void { const { isFirstRowHeaders } = this.state; this.setState({ isFirstRowHeaders: !isFirstRowHeaders }); } - handleUpload(event) { + handleUpload(event: FormEvent): void { event.stopPropagation(); event.preventDefault(); const { file, paste } = this.props; @@ -134,15 +171,15 @@ class CsvInputBar extends Component { } } - handleFile(file, isZip = false) { + handleFile(file: Blob | JSZipObject, isZip = false): void { log.info( - `Starting CSV parser for ${file.name} ${ - isZip ? '' : `${file.size} bytes` - }` + `Starting CSV parser for ${ + file instanceof File ? file.name : 'pasted values' + } ${isZip ? '' : (file as Blob).size} bytes` ); const { session, timeZone, onInProgress } = this.props; const { tableName, isFirstRowHeaders, type } = this.state; - const handleParseDone = tables => { + const handleParseDone = (tables: Table[]) => { // Do not bother merging just one table if (tables.length === 1) { session @@ -182,7 +219,7 @@ class CsvInputBar extends Component { onInProgress(true); } - handleZipFile(zipFile) { + handleZipFile(zipFile: File): void { const { onUpdate, unzip } = this.props; if (unzip == null) { this.handleError(new Error('No support for zip files available.')); @@ -209,7 +246,7 @@ class CsvInputBar extends Component { .catch(e => this.handleError(e)); } - handleProgress(progressValue) { + handleProgress(progressValue: number): boolean { const { showProgress } = this.state; if (showProgress) { this.setState({ @@ -221,7 +258,7 @@ class CsvInputBar extends Component { } // Cancels an in progress upload - handleCancelInProgress() { + handleCancelInProgress(): void { const { onInProgress } = this.props; const { parser } = this.state; if (parser) { @@ -234,20 +271,20 @@ class CsvInputBar extends Component { onInProgress(false); } - openTable() { + openTable(): void { const { onOpenTable, onClose } = this.props; const { tableName } = this.state; onOpenTable(tableName); onClose(); } - handleQueryTypeChange(event) { + handleQueryTypeChange(event: ChangeEvent): void { this.setState({ - type: event.target.value, + type: event.target.value as keyof typeof CsvFormats.TYPES, }); } - render() { + render(): ReactElement { const { file, paste } = this.props; const { tableName, @@ -318,8 +355,8 @@ class CsvInputBar extends Component { className="progress-bar bg-primary" style={{ width: `${progressValue}%` }} aria-valuenow={progressValue} - aria-valuemin="0" - aria-valuemax="100" + aria-valuemin={0} + aria-valuemax={100} />
@@ -337,26 +374,4 @@ class CsvInputBar extends Component { } } -CsvInputBar.propTypes = { - session: PropTypes.shape({ - bindTableToVariable: PropTypes.func.isRequired, - mergeTables: PropTypes.func.isRequired, - }).isRequired, - onOpenTable: PropTypes.func.isRequired, - onClose: PropTypes.func.isRequired, - onUpdate: PropTypes.func.isRequired, - onError: PropTypes.func.isRequired, - file: PropTypes.instanceOf(File), - paste: PropTypes.string, - onInProgress: PropTypes.func.isRequired, - timeZone: PropTypes.string.isRequired, - unzip: PropTypes.func, -}; - -CsvInputBar.defaultProps = { - file: null, - paste: null, - unzip: null, -}; - export default CsvInputBar; diff --git a/packages/console/src/csv/CsvOverlay.test.jsx b/packages/console/src/csv/CsvOverlay.test.tsx similarity index 100% rename from packages/console/src/csv/CsvOverlay.test.jsx rename to packages/console/src/csv/CsvOverlay.test.tsx diff --git a/packages/console/src/csv/CsvOverlay.jsx b/packages/console/src/csv/CsvOverlay.tsx similarity index 73% rename from packages/console/src/csv/CsvOverlay.jsx rename to packages/console/src/csv/CsvOverlay.tsx index 776992fade..bd6a9c07fa 100644 --- a/packages/console/src/csv/CsvOverlay.jsx +++ b/packages/console/src/csv/CsvOverlay.tsx @@ -1,19 +1,46 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; +import React, { + ChangeEvent, + Component, + DragEvent, + MouseEvent, + ReactElement, + RefObject, +} from 'react'; import memoize from 'memoize-one'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { ContextActions, GLOBAL_SHORTCUTS } from '@deephaven/components'; +import { + ContextAction, + ContextActions, + GLOBAL_SHORTCUTS, +} from '@deephaven/components'; import { dhFileCsv, dhFileDownload, dhFileSpreadsheet, + IconDefinition, vsClippy, vsFileZip, vsTrash, vsWarning, } from '@deephaven/icons'; import './CsvOverlay.scss'; -import { TextUtils } from '@deephaven/utils'; +import { assertNotNull, TextUtils } from '@deephaven/utils'; + +interface CsvOverlayProps { + allowZip: boolean; + onFileOpened: (file: File) => void; + onCancel: () => void; + onPaste: (clipText: string) => void; + clearDragError: () => void; + dragError: string | null; + onError: (e: unknown) => void; + uploadInProgress: boolean; +} + +interface CsvOverlayState { + selectedFileName: string; + dropError?: string; +} const PASTED_VALUES = 'pasted values'; @@ -26,12 +53,17 @@ const ZIP_EXTENSIONS = ['.zip']; /** * Overlay that is displayed when uploading a CSV file. */ -class CsvOverlay extends Component { +class CsvOverlay extends Component { + static defaultProps = { + allowZip: false, + dragError: null, + }; + static MULTIPLE_FILE_ERROR = 'Please select only one file'; static FILE_TYPE_ERROR = 'Filetype not supported.'; - static isValidDropItem(item) { + static isValidDropItem(item: DataTransferItem): boolean { return ( item && item.kind === 'file' && @@ -39,19 +71,19 @@ class CsvOverlay extends Component { ); } - static isValidExtension(name, allowZip = false) { + static isValidExtension(name: string, allowZip = false): boolean { return ( VALID_EXTENSIONS.some(ext => name.endsWith(ext)) || (allowZip && ZIP_EXTENSIONS.some(ext => name.endsWith(ext))) ); } - static handleDragOver(e) { + static handleDragOver(e: DragEvent): void { e.preventDefault(); e.stopPropagation(); } - static getIcon(fileName) { + static getIcon(fileName: string): IconDefinition { if (fileName === PASTED_VALUES) { return vsClippy; } @@ -64,7 +96,7 @@ class CsvOverlay extends Component { return dhFileSpreadsheet; } - constructor(props) { + constructor(props: CsvOverlayProps) { super(props); this.handleSelectFile = this.handleSelectFile.bind(this); @@ -79,31 +111,39 @@ class CsvOverlay extends Component { this.state = { selectedFileName: '', - dropError: null, }; } - componentDidMount() { - this.divElem.current.addEventListener('paste', this.handlePasteEvent); - this.divElem.current.focus(); + componentDidMount(): void { + this.divElem.current?.addEventListener('paste', this.handlePasteEvent); + this.divElem.current?.focus(); } - componentWillUnmount() { - this.divElem.current.removeEventListener('paste', this.handlePasteEvent); + componentWillUnmount(): void { + this.divElem.current?.removeEventListener('paste', this.handlePasteEvent); } - handleSelectFile() { - this.fileElem.current.value = null; - this.fileElem.current.click(); + fileElem: RefObject; + + divElem: RefObject; + + handleSelectFile(): void { + if (this.fileElem.current) { + this.fileElem.current.value = ''; + this.fileElem.current?.click(); + } } - handleFiles(event) { + handleFiles(event: ChangeEvent): void { event.stopPropagation(); event.preventDefault(); - this.handleFile(event.target.files[0]); + const { files } = event.target; + if (files != null) { + this.handleFile(files[0]); + } } - handleDrop(e) { + handleDrop(e: DragEvent): void { const { allowZip, clearDragError, dragError } = this.props; e.preventDefault(); e.stopPropagation(); @@ -118,6 +158,7 @@ class CsvOverlay extends Component { } const file = e.dataTransfer.items[0].getAsFile(); + assertNotNull(file); if (CsvOverlay.isValidExtension(file.name, allowZip)) { this.handleFile(file); } else { @@ -127,27 +168,27 @@ class CsvOverlay extends Component { } } - unstageFile(event) { - const { onFileOpened } = this.props; + unstageFile(event: MouseEvent): void { + const { onCancel } = this.props; event.stopPropagation(); event.preventDefault(); - onFileOpened(null); + onCancel(); this.setState({ selectedFileName: '', - dropError: null, + dropError: undefined, }); } - handleFile(file) { + handleFile(file: File): void { const { onFileOpened } = this.props; onFileOpened(file); this.setState({ selectedFileName: file.name, - dropError: null, + dropError: undefined, }); } - handleMenuPaste() { + handleMenuPaste(): void { const { onPaste, onError, uploadInProgress } = this.props; if (uploadInProgress) { return; @@ -158,19 +199,19 @@ class CsvOverlay extends Component { onPaste(clipText); this.setState({ selectedFileName: PASTED_VALUES, - dropError: null, + dropError: undefined, }); }) - .catch(e => onError(e)); + .catch((e: unknown) => onError(e)); } - handlePasteEvent(event) { + handlePasteEvent(event: ClipboardEvent): void { event.stopPropagation(); event.preventDefault(); this.handleMenuPaste(); } - makeContextMenuItems() { + makeContextMenuItems(): ContextAction[] { const { uploadInProgress } = this.props; return [ { @@ -195,7 +236,7 @@ class CsvOverlay extends Component { TextUtils.join(this.getValidExtensions(allowZip), 'or') ); - render() { + render(): ReactElement { const { allowZip, dragError, uploadInProgress } = this.props; const { selectedFileName, dropError } = this.state; const error = dragError || dropError; @@ -206,7 +247,7 @@ class CsvOverlay extends Component { className="csv-overlay fill-parent-absolute" onDragOver={CsvOverlay.handleDragOver} onDrop={this.handleDrop} - tabIndex="-1" + tabIndex={-1} > new CsvParser({ - onFileCompleted: () => {}, + onFileCompleted: () => undefined, session: null, file: null, - type: CsvFormats.DEFAULT_TYPE, + type: CsvFormats.TYPES[CsvFormats.DEFAULT_TYPE], readHeaders: true, - onProgress: () => {}, - onError: () => {}, + onProgress: () => undefined, + onError: () => undefined, timeZone: '', isZip: true, }); @@ -54,40 +54,40 @@ it('transposes correctly', () => { // 3x3 expect( csvParser.transpose(3, [ - [1, 2, 3], - [4, 5, 6], - [7, 8, 9], + [`1`, `2`, `3`], + [`4`, `5`, `6`], + [`7`, `8`, `9`], ]) ).toStrictEqual([ - [1, 4, 7], - [2, 5, 8], - [3, 6, 9], + ['1', '4', '7'], + ['2', '5', '8'], + ['3', '6', '9'], ]); // 3x4 expect( csvParser.transpose(4, [ - [1, 2, 3, 4], - [5, 6, 7, 8], - [9, 10, 11, 12], + ['1', '2', '3', '4'], + ['5', '6', '7', '8'], + ['9', '10', '11', '12'], ]) ).toStrictEqual([ - [1, 5, 9], - [2, 6, 10], - [3, 7, 11], - [4, 8, 12], + ['1', '5', '9'], + ['2', '6', '10'], + ['3', '7', '11'], + ['4', '8', '12'], ]); // 4x3 expect( csvParser.transpose(3, [ - [1, 2, 3], - [4, 5, 6], - [7, 8, 9], - [10, 11, 12], + ['1', '2', '3'], + ['4', '5', '6'], + ['7', '8', '9'], + ['10', '11', '12'], ]) ).toStrictEqual([ - [1, 4, 7, 10], - [2, 5, 8, 11], - [3, 6, 9, 12], + ['1', '4', '7', '10'], + ['2', '5', '8', '11'], + ['3', '6', '9', '12'], ]); }); @@ -96,27 +96,27 @@ it('drops extra columns', () => { expect( csvParser.transpose(3, [ ['col1', 'col2', 'col3', 'col4'], - [1, 2, 3, 4], - [1, 2, 3], - [1, 2, 3, 4, 5], + ['1', '2', '3', '4'], + ['1', '2', '3'], + ['1', '2', '3', '4', '5'], ]) ).toStrictEqual([ - ['col1', 1, 1, 1], - ['col2', 2, 2, 2], - ['col3', 3, 3, 3], + ['col1', '1', '1', '1'], + ['col2', '2', '2', '2'], + ['col3', '3', '3', '3'], ]); expect( csvParser.transpose(3, [ ['col1', 'col2', 'col3'], - [1, 2, 3, 4], - [1, 2, 3], - [1, 2, 3, 4, 5], + ['1', '2', '3', '4'], + ['1', '2', '3'], + ['1', '2', '3', '4', '5'], ]) ).toStrictEqual([ - ['col1', 1, 1, 1], - ['col2', 2, 2, 2], - ['col3', 3, 3, 3], + ['col1', `1`, `1`, `1`], + ['col2', '2', '2', `2`], + ['col3', '3', '3', '3'], ]); }); @@ -125,9 +125,9 @@ it('throws an error for insufficient columns', () => { expect(() => csvParser.transpose(3, [ ['col1', 'col2', 'col3'], - [1, 2, 3, 4], - [1, 2, 3], - [1, 2], + ['1', '2', '3', '4'], + ['1', '2', '3'], + ['1', '2'], ]) ).toThrow(new Error('Insufficient columns. Expected 3 but found 2\n1,2')); }); diff --git a/packages/console/src/csv/CsvParser.js b/packages/console/src/csv/CsvParser.ts similarity index 70% rename from packages/console/src/csv/CsvParser.js rename to packages/console/src/csv/CsvParser.ts index ee49372cbf..5c3dbbcf9e 100644 --- a/packages/console/src/csv/CsvParser.js +++ b/packages/console/src/csv/CsvParser.ts @@ -1,7 +1,10 @@ -import Papa from 'papaparse'; +import Papa, { ParseLocalConfig, Parser, ParseResult } from 'papaparse'; import Log from '@deephaven/log'; -import { DbNameValidator } from '@deephaven/utils'; +import { assertNotNull, DbNameValidator } from '@deephaven/utils'; +import { IdeSession, Table } from '@deephaven/jsapi-shim'; +import type { JSZipObject } from 'jszip'; import CsvTypeParser from './CsvTypeParser'; +import { CsvTypes } from './CsvFormats'; const log = Log.module('CsvParser'); @@ -9,12 +12,24 @@ const log = Log.module('CsvParser'); // Want to consolidate to ~10 MB chunks const ZIP_CONSOLIDATE_CHUNKS = 650; +interface CsvParserConstructor { + onFileCompleted: (tables: Table[]) => void; + session: IdeSession; + file: Blob | JSZipObject; + type: CsvTypes; + readHeaders: boolean; + onProgress: (progressValue: number) => boolean; + onError: (e: unknown) => void; + timeZone: string; + isZip: boolean; +} + /** * Parser a CSV file in chunks and returns a table handle for each chunk. */ class CsvParser { // Generates column names A-Z, AA-AZ, BA-BZ, etc... - static generateHeaders = numColumns => { + static generateHeaders = (numColumns: number): string[] => { const headers = []; for (let i = 0; i < numColumns; i += 1) { headers.push(CsvParser.generateHeaderRecursive(i)); @@ -22,7 +37,7 @@ class CsvParser { return headers; }; - static generateHeaderRecursive(n) { + static generateHeaderRecursive(n: number): string { let header = ''; let char = n; if (n >= 26) { @@ -44,7 +59,7 @@ class CsvParser { onError, timeZone, isZip, - }) { + }: CsvParserConstructor) { this.onFileCompleted = onFileCompleted; this.session = session; this.file = file; @@ -55,13 +70,12 @@ class CsvParser { this.onProgress = onProgress; this.onError = onError; this.tables = []; - this.headers = null; - this.types = null; this.chunks = 0; - this.totalChunks = isZip ? 0 : Math.ceil(file.size / Papa.LocalChunkSize); + this.totalChunks = isZip + ? 0 + : Math.ceil((file as Blob).size / Papa.LocalChunkSize); this.isComplete = false; this.zipProgress = 0; - this.consolidatedChunks = null; this.numConsolidated = 0; this.isCancelled = false; @@ -73,7 +87,7 @@ class CsvParser { this.config = { delimiter: type.delimiter, - newline: type.newline, + newline: type.newline as '\r\n' | '\n' | '\r' | undefined, escapeChar: type.escapeChar, dynamicTyping: false, error: this.handleError, @@ -84,11 +98,51 @@ class CsvParser { }; } - cancel() { + onFileCompleted: (tables: Table[]) => void; + + session: IdeSession; + + file: Blob | JSZipObject; + + isZip: boolean; + + type: CsvTypes; + + readHeaders: boolean; + + timeZone: string; + + onProgress: (progressValue: number) => boolean; + + onError: (e: unknown) => void; + + tables: Table[]; + + headers?: string[]; + + types?: string[]; + + chunks: number; + + totalChunks: number; + + isComplete: boolean; + + zipProgress: number; + + consolidatedChunks?: string[][]; + + numConsolidated: number; + + isCancelled: boolean; + + config: ParseLocalConfig; + + cancel(): void { this.isCancelled = true; } - transpose(numColumns, array) { + transpose(numColumns: number, array: string[][]): string[][] { const numRows = array.length; const columns = new Array(numColumns) .fill(null) @@ -105,18 +159,23 @@ class CsvParser { columns[c][r] = this.nullCheck(value); } } - return columns; + return columns as string[][]; } - nullCheck(value) { + nullCheck(value: string): string { return value === this.type.nullString ? '' : value; } - parse() { - const handleParseDone = types => { + parse(): void { + const handleParseDone = (types: string[]) => { const toParse = this.isZip - ? this.file.nodeStream('nodebuffer', this.handleNodeUpdate) - : this.file; + ? (this.file as JSZipObject).nodeStream( + // JsZip types are incorrect, thus the funny casting + // Actual parameter is 'nodebuffer' + 'nodebuffer' as 'nodestream', + this.handleNodeUpdate + ) + : (this.file as Blob); this.types = types; Papa.parse(toParse, this.config); }; @@ -135,7 +194,7 @@ class CsvParser { typeParser.parse(); } - handleChunk(result, parser) { + handleChunk(result: ParseResult, parser: Parser): void { const { readHeaders, onError, handleCreateTable, isZip, tables } = this; if (this.isCancelled) { log.debug2('CSV parser cancelled.'); @@ -153,7 +212,7 @@ class CsvParser { } } - let columns = []; + let columns: string[][] = []; try { columns = this.transpose(this.headers.length, data); if (isZip) { @@ -164,12 +223,12 @@ class CsvParser { this.chunks += 1; handleCreateTable(index, columns, parser); } - } catch (e) { + } catch (e: unknown) { onError(e); } } - consolidateChunks(columns, parser) { + consolidateChunks(columns: string[][], parser: Parser): void { if (!this.consolidatedChunks) { this.consolidatedChunks = columns.slice(); } else { @@ -185,17 +244,22 @@ class CsvParser { } } - uploadConsolidatedChunks(parser) { + uploadConsolidatedChunks(parser: Parser | null): void { const { handleCreateTable } = this; const index = this.chunks; this.chunks += 1; - const toUpload = this.consolidatedChunks.slice(); - this.consolidatedChunks = null; + const toUpload = this.consolidatedChunks?.slice(); + this.consolidatedChunks = undefined; this.numConsolidated = 0; + assertNotNull(toUpload); handleCreateTable(index, toUpload, parser); } - handleCreateTable(index, columns, parser) { + handleCreateTable( + index: number, + columns: string[][], + parser: Parser | null + ): void { const { session, tables, @@ -208,6 +272,8 @@ class CsvParser { if (parser) { parser.pause(); } + assertNotNull(this.headers); + assertNotNull(types); session .newTable(this.headers, types, columns, this.timeZone) .then(table => { @@ -245,7 +311,7 @@ class CsvParser { }); } - handleComplete(results) { + handleComplete(results: ParseResult): void { // results is undefined for a succesful parse, but has meta data for an abort if (!results || !results.meta.aborted) { this.isComplete = true; @@ -256,12 +322,12 @@ class CsvParser { } } - handleError(error) { + handleError(error: unknown): void { const { onError } = this; onError(error); } - handleNodeUpdate(metadata) { + handleNodeUpdate(metadata: { percent: number }): void { this.zipProgress = metadata.percent; } } diff --git a/packages/console/src/csv/CsvTypeParser.test.js b/packages/console/src/csv/CsvTypeParser.test.ts similarity index 100% rename from packages/console/src/csv/CsvTypeParser.test.js rename to packages/console/src/csv/CsvTypeParser.test.ts diff --git a/packages/console/src/csv/CsvTypeParser.js b/packages/console/src/csv/CsvTypeParser.ts similarity index 71% rename from packages/console/src/csv/CsvTypeParser.js rename to packages/console/src/csv/CsvTypeParser.ts index b6d6470fb3..f64c7410c8 100644 --- a/packages/console/src/csv/CsvTypeParser.js +++ b/packages/console/src/csv/CsvTypeParser.ts @@ -1,6 +1,8 @@ +import type { JSZipObject } from 'jszip'; +import { assertNotNull } from '@deephaven/utils'; +import Papa, { Parser, ParseResult, ParseLocalConfig } from 'papaparse'; // Intentionally using isNaN rather than Number.isNaN /* eslint-disable no-restricted-globals */ -import Papa from 'papaparse'; import NewTableColumnTypes from './NewTableColumnTypes'; // Initially column types start al unknown @@ -16,7 +18,11 @@ const LOCAL_TIME_REGEX = /^([0-9]+T)?([0-9]+):([0-9]+)(:[0-9]+)?(?:\.[0-9]{1,9}) * Determines the type of each column in a CSV file by parsing it and looking at every value. */ class CsvTypeParser { - static determineType(value, type, nullString) { + static determineType( + value: string, + type: string, + nullString: string | null + ): string { if (!value || value === nullString) { // A null tells us nothing about the type return type; @@ -44,11 +50,13 @@ class CsvTypeParser { } // Allows for cusomt rules in addition to isNaN - static isNotParsableNumber(s) { - return isNaN(s) || s === 'Infinity' || s === '-Infinity'; + static isNotParsableNumber(s: string): boolean { + return ( + isNaN((s as unknown) as number) || s === 'Infinity' || s === '-Infinity' + ); } - static checkInteger(value) { + static checkInteger(value: string): string { const noCommas = value.replace(/,/g, ''); if (CsvTypeParser.isNotParsableNumber(noCommas)) { return NewTableColumnTypes.STRING; @@ -57,7 +65,7 @@ class CsvTypeParser { return CsvTypeParser.getNumberType(noCommas); } - static checkLong(value) { + static checkLong(value: string): string { const noCommas = value.replace(/,/g, ''); if (CsvTypeParser.isNotParsableNumber(noCommas)) { return NewTableColumnTypes.STRING; @@ -70,7 +78,7 @@ class CsvTypeParser { return NewTableColumnTypes.LONG; } - static checkDouble(value) { + static checkDouble(value: string): string { const noCommas = value.replace(/,/g, ''); if (CsvTypeParser.isNotParsableNumber(noCommas)) { return NewTableColumnTypes.STRING; @@ -79,7 +87,7 @@ class CsvTypeParser { return NewTableColumnTypes.DOUBLE; } - static checkBoolean(value) { + static checkBoolean(value: string): string { const lower = value.toLowerCase(); if (lower === 'true' || lower === 'false') { return NewTableColumnTypes.BOOLEAN; @@ -87,7 +95,7 @@ class CsvTypeParser { return NewTableColumnTypes.STRING; } - static checkDateTime(value) { + static checkDateTime(value: string): string { if (DATE_TIME_REGEX.test(value)) { return NewTableColumnTypes.DATE_TIME; } @@ -95,7 +103,7 @@ class CsvTypeParser { return NewTableColumnTypes.STRING; } - static checkLocalTime(value) { + static checkLocalTime(value: string): string { if (LOCAL_TIME_REGEX.test(value)) { return NewTableColumnTypes.LOCAL_TIME; } @@ -103,7 +111,7 @@ class CsvTypeParser { return NewTableColumnTypes.STRING; } - static getTypeFromUnknown(value) { + static getTypeFromUnknown(value: string): string { const noCommas = value.replace(/,/g, ''); if (CsvTypeParser.isNotParsableNumber(noCommas)) { const lower = value.toLowerCase(); @@ -125,7 +133,7 @@ class CsvTypeParser { return CsvTypeParser.getNumberType(noCommas); } - static getNumberType(value) { + static getNumberType(value: string): string { if (value.includes('.')) { return NewTableColumnTypes.DOUBLE; } @@ -146,16 +154,16 @@ class CsvTypeParser { } constructor( - onFileCompleted, - file, - readHeaders, - parentConfig, - nullString, - onProgress, - onError, - totalChunks, - isZip, - shouldTrim + onFileCompleted: (types: string[]) => void, + file: Blob | JSZipObject, + readHeaders: boolean, + parentConfig: ParseLocalConfig, + nullString: string | null, + onProgress: (progressValue: number) => boolean, + onError: (e: unknown) => void, + totalChunks: number, + isZip: boolean, + shouldTrim: boolean ) { this.onFileCompleted = onFileCompleted; this.file = file; @@ -163,7 +171,6 @@ class CsvTypeParser { this.nullString = nullString; this.onProgress = onProgress; this.onError = onError; - this.types = null; this.chunks = 0; this.totalChunks = totalChunks; this.isZip = isZip; @@ -183,14 +190,45 @@ class CsvTypeParser { }; } - parse() { + onFileCompleted: (types: string[]) => void; + + file: Blob | JSZipObject; + + readHeaders: boolean; + + nullString: string | null; + + onProgress: (progressValue: number) => boolean; + + onError: (e: unknown) => void; + + types?: string[]; + + chunks: number; + + totalChunks: number; + + isZip: boolean; + + shouldTrim: boolean; + + zipProgress: number; + + config: ParseLocalConfig; + + parse(): void { const toParse = this.isZip - ? this.file.nodeStream('nodebuffer', this.handleNodeUpdate) - : this.file; + ? (this.file as JSZipObject).nodeStream( + // JsZip types are incorrect, thus the funny casting + // Actual parameter is 'nodebuffer' + 'nodebuffer' as 'nodestream', + this.handleNodeUpdate + ) + : (this.file as Blob); Papa.parse(toParse, this.config); } - handleChunk(result, parser) { + handleChunk(result: ParseResult, parser: Parser): void { let { data } = result; if (!this.types) { if (!data || data.length === 0) { @@ -205,19 +243,24 @@ class CsvTypeParser { } } + assertNotNull(this.types); + + const cloneTypes = [...this.types]; + data.forEach(row => { - if (row.length >= this.types.length) { - for (let i = 0; i < this.types.length; i += 1) { - this.types[i] = CsvTypeParser.determineType( + if (row.length >= cloneTypes.length) { + for (let i = 0; i < cloneTypes.length; i += 1) { + cloneTypes[i] = CsvTypeParser.determineType( this.shouldTrim ? row[i].trim() : row[i], - this.types[i], + cloneTypes[i], this.nullString ); } + this.types = cloneTypes; } else { parser.abort(); this.onError( - `Error parsing CSV: Insufficient data in row.\nExpected length ${this.types.length} but found ${row.length}.\nRow = ${row}` + `Error parsing CSV: Insufficient data in row.\nExpected length ${cloneTypes.length} but found ${row.length}.\nRow = ${row}` ); } }); @@ -236,9 +279,10 @@ class CsvTypeParser { } } - handleComplete(results) { + handleComplete(results: ParseResult): void { const { types, onFileCompleted } = this; // results is undefined for a succesful parse, but has meta data for an abort + assertNotNull(types); if (!results || !results.meta.aborted) { onFileCompleted( types.map(type => @@ -248,12 +292,12 @@ class CsvTypeParser { } } - handleError(error) { + handleError(error: unknown): void { const { onError } = this; onError(error); } - handleNodeUpdate(metadata) { + handleNodeUpdate(metadata: { percent: number }): void { this.zipProgress = metadata.percent; } } diff --git a/packages/console/src/csv/NewTableColumnTypes.js b/packages/console/src/csv/NewTableColumnTypes.ts similarity index 100% rename from packages/console/src/csv/NewTableColumnTypes.js rename to packages/console/src/csv/NewTableColumnTypes.ts diff --git a/packages/console/src/declaration.d.ts b/packages/console/src/declaration.d.ts new file mode 100644 index 0000000000..a10272fac6 --- /dev/null +++ b/packages/console/src/declaration.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.scss' { + const content: Record; + export default content; +} + +declare module '*.scss'; diff --git a/packages/console/src/index.js b/packages/console/src/index.ts similarity index 100% rename from packages/console/src/index.js rename to packages/console/src/index.ts diff --git a/packages/console/src/log/LogLevel.js b/packages/console/src/log/LogLevel.ts similarity index 100% rename from packages/console/src/log/LogLevel.js rename to packages/console/src/log/LogLevel.ts diff --git a/packages/console/src/log/LogLevelMenuItem.jsx b/packages/console/src/log/LogLevelMenuItem.tsx similarity index 63% rename from packages/console/src/log/LogLevelMenuItem.jsx rename to packages/console/src/log/LogLevelMenuItem.tsx index d637c57d27..ce1a4e4289 100644 --- a/packages/console/src/log/LogLevelMenuItem.jsx +++ b/packages/console/src/log/LogLevelMenuItem.tsx @@ -1,23 +1,31 @@ // Port of https://github.com/react-bootstrap/react-bootstrap/blob/master/src/Collapse.js -import React, { PureComponent } from 'react'; +import React, { PureComponent, ReactElement } from 'react'; import classNames from 'classnames'; -import PropTypes from 'prop-types'; import { UISwitch } from '@deephaven/components'; import './LogLevelMenuItem.scss'; -class LogLevelMenuItem extends PureComponent { - constructor(props) { +interface LogLevelMenuItemProps { + logLevel: string; + on: boolean; + onClick: (logLevel: string) => void; +} + +class LogLevelMenuItem extends PureComponent< + LogLevelMenuItemProps, + Record +> { + constructor(props: LogLevelMenuItemProps) { super(props); this.handleSwitchClick = this.handleSwitchClick.bind(this); } - handleSwitchClick() { + handleSwitchClick(): void { const { logLevel, onClick } = this.props; onClick(logLevel); } - render() { + render(): ReactElement { const { logLevel, on } = this.props; return (
@@ -28,10 +36,4 @@ class LogLevelMenuItem extends PureComponent { } } -LogLevelMenuItem.propTypes = { - logLevel: PropTypes.string.isRequired, - on: PropTypes.bool.isRequired, - onClick: PropTypes.func.isRequired, -}; - export default LogLevelMenuItem; diff --git a/packages/console/src/log/LogView.jsx b/packages/console/src/log/LogView.tsx similarity index 75% rename from packages/console/src/log/LogView.jsx rename to packages/console/src/log/LogView.tsx index 132a643e69..46ba303a28 100644 --- a/packages/console/src/log/LogView.jsx +++ b/packages/console/src/log/LogView.tsx @@ -1,18 +1,27 @@ -import React, { PureComponent } from 'react'; +import React, { PureComponent, ReactElement } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { DropdownMenu, Tooltip } from '@deephaven/components'; +import { DropdownActions, DropdownMenu, Tooltip } from '@deephaven/components'; import { vsGear, dhTrashUndo } from '@deephaven/icons'; -import { PropTypes as APIPropTypes } from '@deephaven/jsapi-shim'; +import { assertNotNull } from '@deephaven/utils'; +import { IdeSession, LogItem } from '@deephaven/jsapi-shim'; +import { Placement } from 'popper.js'; import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'; import ConsoleUtils from '../common/ConsoleUtils'; import LogLevel from './LogLevel'; import './LogView.scss'; import LogLevelMenuItem from './LogLevelMenuItem'; +interface LogViewProps { + session: IdeSession; +} + +interface LogViewState { + shownLogLevels: Record; +} /** * Log view contents. Uses a monaco editor to display/search the contents of the log. */ -class LogView extends PureComponent { +class LogView extends PureComponent { static DefaultLogLevels = [ LogLevel.STDOUT, LogLevel.ERROR, @@ -40,13 +49,13 @@ class LogView extends PureComponent { static truncateSize = 65536; - static getLogText(logItem) { + static getLogText(logItem: LogItem): string { const date = new Date(logItem.micros / 1000); const timestamp = ConsoleUtils.formatTimestamp(date); return `${timestamp} ${logItem.logLevel} ${logItem.message}`; } - constructor(props) { + constructor(props: LogViewProps) { super(props); this.handleClearClick = this.handleClearClick.bind(this); @@ -57,11 +66,9 @@ class LogView extends PureComponent { this.handleResize = this.handleResize.bind(this); this.handleToggleAllClick = this.handleToggleAllClick.bind(this); - this.cancelListener = null; - this.editor = null; - this.editorContainer = null; this.logLevelMenuItems = {}; - this.flushTimer = null; + + this.editorContainer = null; this.bufferedMessages = []; this.messages = []; @@ -71,7 +78,7 @@ class LogView extends PureComponent { }; } - componentDidMount() { + componentDidMount(): void { this.resetLogLevels(); this.initMonaco(); this.startListening(); @@ -79,7 +86,7 @@ class LogView extends PureComponent { window.addEventListener('resize', this.handleResize); } - componentDidUpdate(prevProps, prevState) { + componentDidUpdate(prevProps: LogViewProps, prevState: LogViewState): void { this.updateDimensions(); const { shownLogLevels } = this.state; @@ -96,7 +103,7 @@ class LogView extends PureComponent { } } - componentWillUnmount() { + componentWillUnmount(): void { this.stopFlushTimer(); this.stopListening(); this.destroyMonaco(); @@ -104,7 +111,21 @@ class LogView extends PureComponent { window.removeEventListener('resize', this.handleResize); } - getMenuActions(shownLogLevels) { + cancelListener?: () => void | null; + + editor?: monaco.editor.IStandaloneCodeEditor; + + editorContainer: HTMLDivElement | null; + + logLevelMenuItems: Record; + + flushTimer?: ReturnType; + + bufferedMessages: LogItem[]; + + messages: LogItem[]; + + getMenuActions(shownLogLevels: Record): DropdownActions { const actions = []; actions.push({ @@ -130,7 +151,9 @@ class LogView extends PureComponent { on={on} onClick={this.handleMenuItemClick} ref={element => { - this.logLevelMenuItems[logLevel] = element; + if (element != null) { + this.logLevelMenuItems[logLevel] = element; + } }} /> ), @@ -163,8 +186,8 @@ class LogView extends PureComponent { return actions; } - resetLogLevels() { - const shownLogLevels = {}; + resetLogLevels(): void { + const shownLogLevels: Record = {}; for (let i = 0; i < LogView.AllLogLevels.length; i += 1) { const logLevel = LogView.AllLogLevels[i]; const isEnabled = LogView.DefaultLogLevels.indexOf(logLevel) >= 0; @@ -174,28 +197,30 @@ class LogView extends PureComponent { this.setState({ shownLogLevels }); } - startListening() { + startListening(): void { const { session } = this.props; this.cancelListener = session.onLogMessage(this.handleLogMessage); } - stopListening() { + stopListening(): void { if (this.cancelListener != null) { this.cancelListener(); - this.cancelListener = null; + this.cancelListener = undefined; } } - initMonaco() { + initMonaco(): void { + assertNotNull(this.editorContainer); this.editor = monaco.editor.create(this.editorContainer, { - copyWithSyntaxHighlighting: 'false', + copyWithSyntaxHighlighting: false, fixedOverflowWidgets: true, folding: false, fontFamily: 'Fira Mono', glyphMargin: false, language: 'log', lineDecorationsWidth: 0, - lineNumbers: '', + // I commented this out since '' is not a valid parameter for line Numbers + // lineNumbers: '', lineNumbersMinChars: 0, minimap: { enabled: false }, readOnly: true, @@ -207,18 +232,20 @@ class LogView extends PureComponent { // When find widget is open, escape key closes it. // Instead, capture it and do nothing. Same for shift-escape. - this.editor.addCommand(monaco.KeyCode.Escape, () => {}); + this.editor.addCommand(monaco.KeyCode.Escape, () => undefined); this.editor.addCommand( // eslint-disable-next-line no-bitwise monaco.KeyMod.Shift | monaco.KeyCode.Escape, - () => {} + () => undefined ); // Restore regular escape to clear selection, when editorText has focus. this.editor.addCommand( monaco.KeyCode.Escape, () => { - this.editor.setPosition(this.editor.getPosition()); + const position = this.editor?.getPosition(); + assertNotNull(position); + this.editor?.setPosition(position); }, 'findWidgetVisible && editorTextFocus' ); @@ -227,28 +254,30 @@ class LogView extends PureComponent { // eslint-disable-next-line no-bitwise monaco.KeyMod.Shift | monaco.KeyCode.Escape, () => { - this.editor.setPosition(this.editor.getPosition()); + const position = this.editor?.getPosition(); + assertNotNull(position); + this.editor?.setPosition(position); }, 'findWidgetVisible && editorTextFocus' ); } - destroyMonaco() { + destroyMonaco(): void { if (this.editor) { this.editor.dispose(); - this.editor = null; + this.editor = undefined; } } - triggerFindWidget() { + triggerFindWidget(): void { // The actions.find action can no longer be triggered when the editor is not in focus, with monaco 0.22.x. // As a workaround, just focus the editor before triggering the action // https://github.com/microsoft/monaco-editor/issues/2355 - this.editor.focus(); - this.editor.trigger('keyboard', 'actions.find'); + this.editor?.focus(); + this.editor?.trigger('keyboard', 'actions.find', undefined); } - toggleAll() { + toggleAll(): void { const { shownLogLevels } = this.state; let isAllEnabled = true; for (let i = 0; i < LogView.AllLogLevels.length; i += 1) { @@ -262,7 +291,7 @@ class LogView extends PureComponent { if (isAllEnabled) { this.setState({ shownLogLevels: {} }); } else { - const updatedLogLevels = {}; + const updatedLogLevels: Record = {}; for (let i = 0; i < LogView.AllLogLevels.length; i += 1) { const logLevel = LogView.AllLogLevels[i]; updatedLogLevels[logLevel] = true; @@ -271,28 +300,30 @@ class LogView extends PureComponent { } } - toggleLogLevel(logLevel) { + toggleLogLevel(logLevel: string): void { const { shownLogLevels } = this.state; const isEnabled = shownLogLevels[logLevel]; - const updatedLogLevels = {}; + const updatedLogLevels: Record = {}; updatedLogLevels[logLevel] = !isEnabled; this.updateLogLevels(updatedLogLevels); } - updateLogLevels(updatedLogLevels) { + updateLogLevels(updatedLogLevels: Record): void { let { shownLogLevels } = this.state; shownLogLevels = { ...shownLogLevels, ...updatedLogLevels }; this.setState({ shownLogLevels }); } - appendLogText(text) { + appendLogText(text: string): void { if (!this.editor) { return; } const model = this.editor.getModel(); - let line = model.getLineCount(); - let column = model.getLineLength(line); + let line = model?.getLineCount(); + assertNotNull(line); + let column = model?.getLineLength(line); + assertNotNull(column); const isBottomVisible = this.isBottomVisible(); const edits = []; @@ -322,17 +353,19 @@ class LogView extends PureComponent { forceMoveMarkers: true, }); - model.applyEdits(edits); + model?.applyEdits(edits); if (isBottomVisible) { - this.editor.revealLine(model.getLineCount(), 1); + const lineCount = model?.getLineCount(); + assertNotNull(lineCount); + this.editor.revealLine(lineCount, 1); } } /** * Refresh the contents of the log component with the updated filter text */ - refreshLogText() { + refreshLogText(): void { if (!this.editor) { return; } @@ -360,7 +393,8 @@ class LogView extends PureComponent { this.editor.setValue(text); if (isBottomVisible) { - const line = this.editor.getModel().getLineCount(); + const line = this.editor.getModel()?.getLineCount(); + assertNotNull(line); this.editor.revealLine(line, 1); } @@ -368,7 +402,7 @@ class LogView extends PureComponent { this.bufferedMessages = []; } - truncateLogIfNecessary() { + truncateLogIfNecessary(): void { if (this.messages.length > LogView.maxLogSize) { this.messages = this.messages.splice( this.messages.length - LogView.truncateSize @@ -376,28 +410,30 @@ class LogView extends PureComponent { } } - scrollToBottom() { + scrollToBottom(): void { if (!this.editor) { return; } - const line = this.editor.getModel().getLineCount(); + const line = this.editor?.getModel?.()?.getLineCount(); + assertNotNull(line); this.editor.revealLine(line, 1); } - isBottomVisible() { + isBottomVisible(): boolean { if (!this.editor) { return true; } const model = this.editor.getModel(); - const line = model.getLineCount(); + const line = model?.getLineCount(); + assertNotNull(line); return this.isLineVisible(line); } - isLineVisible(line) { - const visibleRanges = this.editor.getVisibleRanges(); + isLineVisible(line: number): boolean { + const visibleRanges = this.editor?.getVisibleRanges(); if (visibleRanges == null || visibleRanges.length === 0) { return false; } @@ -413,12 +449,12 @@ class LogView extends PureComponent { } /** Checks if the given log message is visible with the current filters */ - isLogItemVisible(message) { + isLogItemVisible(message: LogItem): boolean { const { shownLogLevels } = this.state; return shownLogLevels[message.logLevel]; } - flush() { + flush(): void { let text = ''; for (let i = 0; i < this.bufferedMessages.length; i += 1) { const message = this.bufferedMessages[i]; @@ -434,7 +470,7 @@ class LogView extends PureComponent { this.appendLogText(text); } - queue(message) { + queue(message: LogItem): void { this.bufferedMessages.push(message); if (this.bufferedMessages.length === 1) { this.flushTimer = setTimeout( @@ -444,34 +480,34 @@ class LogView extends PureComponent { } } - stopFlushTimer() { + stopFlushTimer(): void { if (this.flushTimer) { clearTimeout(this.flushTimer); - this.flushTimer = null; + this.flushTimer = undefined; } } - updateDimensions() { + updateDimensions(): void { if (this.editor) { this.editor.layout(); } } - handleClearClick() { + handleClearClick(): void { this.clearLogs(); } - clearLogs() { + clearLogs(): void { this.messages = []; this.refreshLogText(); } - handleFlushTimeout() { + handleFlushTimeout(): void { this.stopFlushTimer(); this.flush(); } - handleLogMessage(message) { + handleLogMessage(message: LogItem): void { this.messages.push(message); if (this.editor && this.isLogItemVisible(message)) { @@ -479,24 +515,24 @@ class LogView extends PureComponent { } } - handleMenuItemClick(logLevel) { + handleMenuItemClick(logLevel: string): void { this.toggleLogLevel(logLevel); } - handleResetClick() { + handleResetClick(): void { this.resetLogLevels(); } - handleResize() { + handleResize(): void { this.updateDimensions(); } - handleToggleAllClick() { + handleToggleAllClick(): void { this.toggleAll(); } - render() { - const popperOptions = { placement: 'bottom-end' }; + render(): ReactElement { + const popperOptions = { placement: 'bottom-end' as Placement }; const { shownLogLevels } = this.state; const actions = this.getMenuActions(shownLogLevels); return ( @@ -534,8 +570,4 @@ class LogView extends PureComponent { } } -LogView.propTypes = { - session: APIPropTypes.IdeSession.isRequired, -}; - export default LogView; diff --git a/packages/console/src/monaco/MonacoCompletionProvider.test.jsx b/packages/console/src/monaco/MonacoCompletionProvider.test.tsx similarity index 72% rename from packages/console/src/monaco/MonacoCompletionProvider.test.jsx rename to packages/console/src/monaco/MonacoCompletionProvider.test.tsx index fef126cb62..0b448085c5 100644 --- a/packages/console/src/monaco/MonacoCompletionProvider.test.jsx +++ b/packages/console/src/monaco/MonacoCompletionProvider.test.tsx @@ -8,12 +8,13 @@ const DEFAULT_LANGUAGE = 'test'; function makeCompletionProvider( language = DEFAULT_LANGUAGE, - session = new dh.IdeSession(language), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + session = new (dh as any).IdeSession(language), model = { uri: {} } ) { const wrapper = render( @@ -66,7 +67,8 @@ it('provides completion items properly', () => { ]; const promiseItems = Promise.resolve(items); const language = DEFAULT_LANGUAGE; - const session = new dh.IdeSession(language); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const session = new (dh as any).IdeSession(language); session.getCompletionItems = jest.fn(() => promiseItems); const model = { uri: { path: 'test' } }; @@ -74,16 +76,23 @@ it('provides completion items properly', () => { const position = { lineNumber: 1, column: 1 }; expect(myRegister).toHaveBeenCalledTimes(1); expect.assertions(4); - return myRegister.mock.calls[0][1] - .provideCompletionItems(model, position) - .then(resultItems => { - expect(session.getCompletionItems).toHaveBeenCalled(); - const { suggestions } = resultItems; - expect(suggestions.length).toBe(items.length); - expect(suggestions[0]).toMatchObject({ - insertText: newText, - label: newText, - }); + const calls: { + provideCompletionItems: ( + model: unknown, + position: unknown + ) => Promise<{ suggestions: unknown[] }>; + }[] = myRegister.mock.calls[0]; + const fn = calls[1]; + + return fn.provideCompletionItems(model, position).then(resultItems => { + expect(session.getCompletionItems).toHaveBeenCalled(); + + const { suggestions } = resultItems; + expect(suggestions.length).toBe(items.length); + expect(suggestions[0]).toMatchObject({ + insertText: newText, + label: newText, }); + }); }); diff --git a/packages/console/src/monaco/MonacoCompletionProvider.jsx b/packages/console/src/monaco/MonacoCompletionProvider.tsx similarity index 75% rename from packages/console/src/monaco/MonacoCompletionProvider.jsx rename to packages/console/src/monaco/MonacoCompletionProvider.tsx index 9952ba987f..f79f54acde 100644 --- a/packages/console/src/monaco/MonacoCompletionProvider.jsx +++ b/packages/console/src/monaco/MonacoCompletionProvider.tsx @@ -2,25 +2,32 @@ * Completion provider for a code session */ import { PureComponent } from 'react'; -import PropTypes from 'prop-types'; import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'; import Log from '@deephaven/log'; +import { IdeSession } from '@deephaven/jsapi-shim'; const log = Log.module('MonacoCompletionProvider'); +interface MonacoCompletionProviderProps { + model: monaco.editor.ITextModel; + session: IdeSession; + language: string; +} + /** * Registers a completion provider with monaco for the language and session provided. */ -class MonacoCompletionProvider extends PureComponent { - constructor(props) { +class MonacoCompletionProvider extends PureComponent< + MonacoCompletionProviderProps, + Record +> { + constructor(props: MonacoCompletionProviderProps) { super(props); this.handleCompletionRequest = this.handleCompletionRequest.bind(this); - - this.registeredCompletionProvider = null; } - componentDidMount() { + componentDidMount(): void { const { language } = this.props; this.registeredCompletionProvider = monaco.languages.registerCompletionItemProvider( language, @@ -31,11 +38,17 @@ class MonacoCompletionProvider extends PureComponent { ); } - componentWillUnmount() { - this.registeredCompletionProvider.dispose(); + componentWillUnmount(): void { + this.registeredCompletionProvider?.dispose(); } - handleCompletionRequest(model, position, context) { + registeredCompletionProvider?: monaco.IDisposable; + + handleCompletionRequest( + model: monaco.editor.ITextModel, + position: monaco.Position, + context: monaco.languages.CompletionContext + ): monaco.languages.ProviderResult { const { model: propModel, session } = this.props; if (model !== propModel) { return null; @@ -54,11 +67,11 @@ class MonacoCompletionProvider extends PureComponent { context, }; - let completionItems = session.getCompletionItems(params); + const completionItems = session.getCompletionItems(params); log.debug('Completion items received: ', params, completionItems); - completionItems = completionItems + const monacoCompletionItems = completionItems .then(items => { // Annoying that the LSP protocol returns completion items with a range that's slightly different than what Monaco expects // Need to remap the items here @@ -104,23 +117,17 @@ class MonacoCompletionProvider extends PureComponent { suggestions, }; }) - .catch(error => { + .catch((error: unknown) => { log.error('There was an error retrieving completion items', error); return { suggestions: [] }; }); - return completionItems; + return monacoCompletionItems; } - render() { + render(): null { return null; } } -MonacoCompletionProvider.propTypes = { - model: PropTypes.shape({ uri: PropTypes.shape({}) }).isRequired, - session: PropTypes.shape({ getCompletionItems: PropTypes.func }).isRequired, - language: PropTypes.string.isRequired, -}; - export default MonacoCompletionProvider; diff --git a/packages/console/src/monaco/MonacoUtils.js b/packages/console/src/monaco/MonacoUtils.ts similarity index 87% rename from packages/console/src/monaco/MonacoUtils.js rename to packages/console/src/monaco/MonacoUtils.ts index 5e05e13e53..35e9c57465 100644 --- a/packages/console/src/monaco/MonacoUtils.js +++ b/packages/console/src/monaco/MonacoUtils.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ /** * Exports a function for initializing monaco with the deephaven theme/config */ @@ -5,6 +6,10 @@ // Default list of features here: https://github.com/microsoft/monaco-editor-webpack-plugin // Mapping to paths here: https://github.com/microsoft/monaco-editor-webpack-plugin/blob/main/src/features.ts // Importing this way rather than using the plugin because I don't want to hook up react-app-rewired for the build + +import { Shortcut } from '@deephaven/components'; +import { IdeSession } from '@deephaven/jsapi-shim'; +import { assertNotNull } from '@deephaven/utils'; import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'; import 'monaco-editor/esm/vs/editor/standalone/browser/accessibilityHelp/accessibilityHelp.js'; import 'monaco-editor/esm/vs/editor/contrib/anchorSelect/anchorSelect.js'; @@ -57,7 +62,9 @@ import 'monaco-editor/esm/vs/editor/contrib/wordHighlighter/wordHighlighter.js'; import 'monaco-editor/esm/vs/editor/contrib/wordOperations/wordOperations.js'; import 'monaco-editor/esm/vs/editor/contrib/wordPartOperations/wordPartOperations.js'; import 'monaco-editor/esm/vs/basic-languages/typescript/typescript.contribution.js'; +// @ts-ignore import { KeyCodeUtils } from 'monaco-editor/esm/vs/base/common/keyCodes.js'; +// @ts-ignore import { KeyMod } from 'monaco-editor/esm/vs/editor/common/standalone/standaloneBase.js'; import Log from '@deephaven/log'; import MonacoTheme from './MonacoTheme.module.scss'; @@ -66,11 +73,11 @@ import GroovyLang from './lang/groovy'; import ScalaLang from './lang/scala'; import DbLang from './lang/db'; import LogLang from './lang/log'; +import { Language } from './lang/Language'; const log = Log.module('MonacoUtils'); - class MonacoUtils { - static init() { + static init(): void { log.debug('Initializing Monaco...'); const { registerLanguages, removeHashtag } = MonacoUtils; @@ -191,13 +198,13 @@ class MonacoUtils { /** * Remove the hashtag prefix from a CSS color string. * Monaco expects colors to be the value only, no hashtag. - * @param {String} color The hex color string to remove the hashtag from, eg. '#ffffff' + * @param color The hex color string to remove the hashtag from, eg. '#ffffff' */ - static removeHashtag(color) { + static removeHashtag(color: string): string { return color.substring(1); } - static registerLanguages(languages) { + static registerLanguages(languages: Language[]): void { // First override the default loader for any language we have a custom definition for // https://github.com/Microsoft/monaco-editor/issues/252#issuecomment-482786867 const languageIds = languages.map(({ id }) => id); @@ -207,9 +214,6 @@ class MonacoUtils { .forEach(languageParam => { const language = languageParam; log.debug2('Overriding default language loader:', language.id); - language.loader = () => ({ - then: () => {}, - }); }); // Then register our language definitions @@ -218,7 +222,7 @@ class MonacoUtils { }); } - static registerLanguage(language) { + static registerLanguage(language: Language): void { log.debug2('Registering language: ', language.id); monaco.languages.register(language); @@ -230,21 +234,28 @@ class MonacoUtils { /** * Set EOL preference for the editor - * @param {monaco.editor.IEditor} editor The editor to set the EOL for - * @param {monaco.editor.EndOfLineSequence} eolSequence EOL sequence + * @param editor The editor to set the EOL for + * @param eolSequence EOL sequence */ - static setEOL(editor, eolSequence = monaco.editor.EndOfLineSequence.LF) { - editor.getModel().setEOL(eolSequence); + static setEOL( + editor: monaco.editor.IStandaloneCodeEditor, + eolSequence = monaco.editor.EndOfLineSequence.LF + ): void { + editor.getModel()?.setEOL(eolSequence); } /** * Links an editor with a provided session to provide completion items. - * @param {dh.IdeSession} session The IdeSession to link - * @param {monaco.editor.IEditor} editor The editor to link the session to + * @param session The IdeSession to link + * @param editor The editor to link the session to * @return A cleanup function for disposing of the created listeners */ - static openDocument(editor, session) { + static openDocument( + editor: monaco.editor.IStandaloneCodeEditor, + session: IdeSession + ): monaco.IDisposable { const model = editor.getModel(); + assertNotNull(model); const didOpenDocumentParams = { textDocument: { uri: `${model.uri}`, @@ -298,8 +309,12 @@ class MonacoUtils { return dispose; } - static closeDocument(editor, session) { + static closeDocument( + editor: monaco.editor.IStandaloneCodeEditor, + session: IdeSession + ): void { const model = editor.getModel(); + assertNotNull(model); const didCloseDocumentParams = { textDocument: { uri: `${model.uri}`, @@ -312,13 +327,16 @@ class MonacoUtils { * Register a paste handle to clean up any garbage code pasted. * Most of this comes from copying from Slack, which has a bad habit of injecting their own characters in your code snippets. * I've emailed Slack about the issue and they're working on it. I can't reference a ticket number because their ticket system is entirely internally facing. - * @param {Monaco.editor} editor The editor the register the paste handler for + * @param editor The editor the register the paste handler for */ - static registerPasteHandler(editor) { + static registerPasteHandler( + editor: monaco.editor.IStandaloneCodeEditor + ): void { editor.onDidPaste(pasteEvent => { const smartQuotes = /“|”/g; const invalidChars = /\u200b/g; const editorModel = editor.getModel(); + assertNotNull(editorModel); const pastedText = editorModel.getValueInRange(pasteEvent.range); if (smartQuotes.test(pastedText) || invalidChars.test(pastedText)) { editorModel.applyEdits([ @@ -333,7 +351,7 @@ class MonacoUtils { }); } - static isMacPlatform() { + static isMacPlatform(): boolean { const { platform } = window.navigator; return platform.startsWith('Mac'); } @@ -341,9 +359,11 @@ class MonacoUtils { /** * Remove any keybindings which are used for our own shortcuts. * This allows the key events to bubble up so a component higher up can capture them - * @param {Monaco.editor} editor The editor to remove the keybindings from + * @param editor The editor to remove the keybindings from */ - static removeConflictingKeybindings(editor) { + static removeConflictingKeybindings( + editor: monaco.editor.IStandaloneCodeEditor + ): void { // Multi-mod key events have a specific order // E.g. ctrl+alt+UpArrow is not found, but alt+ctrl+UpArrow is found // meta is WindowsKey on Windows and cmd on Mac @@ -360,12 +380,20 @@ class MonacoUtils { ]; try { - keybindings.forEach(keybinding => + keybindings.forEach(keybinding => { + if ( + (MonacoUtils.isMacPlatform() && keybinding.mac === '') || + (!MonacoUtils.isMacPlatform() && keybinding.windows === '') + ) { + return; + } MonacoUtils.removeKeybinding( editor, - MonacoUtils.isMacPlatform() ? keybinding.mac : keybinding.windows - ) - ); + (MonacoUtils.isMacPlatform() + ? keybinding.mac + : keybinding.windows) as string + ); + }); } catch (err) { // This is probably only caused by Monaco changing private methods used here log.error(err); @@ -381,28 +409,30 @@ class MonacoUtils { * https://github.com/microsoft/monaco-editor/issues/287#issuecomment-331447475 * The issue for an API for this has apparently been open since 2016. Link below * https://github.com/microsoft/monaco-editor/issues/102 - * @param {Monaco.editor} editor The editor to remove the keybinding from - * @param {string} keybinding The key string to remove. E.g. 'ctrl+C' for copy on Windows + * @param editor The editor to remove the keybinding from + * @param keybinding The key string to remove. E.g. 'ctrl+C' for copy on Windows */ - static removeKeybinding(editor, keybinding) { - if (!keybinding) { - return; - } + static removeKeybinding( + editor: monaco.editor.IStandaloneCodeEditor, + keybinding: string + ): void { /* eslint-disable no-underscore-dangle */ // It's possible a single keybinding has multiple commands depending on context + // @ts-ignore const keybindings = editor._standaloneKeybindingService ._getResolver() ._map.get(keybinding); if (keybindings) { - keybindings.forEach(elem => { + keybindings.forEach((elem: { command: unknown }) => { log.debug2( `Removing Monaco keybinding ${keybinding} for ${elem.command}` ); + // @ts-ignore editor._standaloneKeybindingService.addDynamicKeybinding( `-${elem.command}`, null, - () => {} + () => undefined ); }); } else { @@ -411,7 +441,7 @@ class MonacoUtils { /* eslint-enable no-underscore-dangle */ } - static getMonacoKeyCodeFromShortcut(shortcut) { + static getMonacoKeyCodeFromShortcut(shortcut: Shortcut): number { const { keyState } = shortcut; const { keyValue } = keyState; if (keyValue === null) { diff --git a/packages/console/src/monaco/index.js b/packages/console/src/monaco/index.ts similarity index 100% rename from packages/console/src/monaco/index.js rename to packages/console/src/monaco/index.ts diff --git a/packages/console/src/monaco/lang/Language.ts b/packages/console/src/monaco/lang/Language.ts new file mode 100644 index 0000000000..51e0db4f44 --- /dev/null +++ b/packages/console/src/monaco/lang/Language.ts @@ -0,0 +1,9 @@ +import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'; + +export type Language = { + id: string; + conf: monaco.languages.LanguageConfiguration; + language: + | monaco.languages.IMonarchLanguage + | monaco.Thenable; +}; diff --git a/packages/console/src/monaco/lang/db.js b/packages/console/src/monaco/lang/db.ts similarity index 91% rename from packages/console/src/monaco/lang/db.js rename to packages/console/src/monaco/lang/db.ts index 03e18f15ee..44423be0c2 100644 --- a/packages/console/src/monaco/lang/db.js +++ b/packages/console/src/monaco/lang/db.ts @@ -1,7 +1,10 @@ +import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'; +import { Language } from './Language'; + /* eslint no-useless-escape: "off" */ const id = 'deephavenDb'; -const conf = { +const conf: monaco.languages.LanguageConfiguration = { comments: { lineComment: '#', blockComment: ["'''", "'''"], @@ -42,7 +45,7 @@ const conf = { }, }; -const language = { +const language: monaco.languages.IMonarchLanguage = { tokenPostfix: '.js', keywords: [ @@ -150,9 +153,9 @@ const language = { // define our own brackets as '<' and '>' do not match in javascript brackets: [ - ['(', ')', 'bracket.parenthesis'], - ['{', '}', 'bracket.curly'], - ['[', ']', 'bracket.square'], + { open: '{', close: '}', token: 'delimiter.curly' }, + { open: '[', close: ']', token: 'delimiter.bracket' }, + { open: '(', close: ')', token: 'delimiter.parenthesis' }, ], // common regular expressions @@ -257,7 +260,7 @@ const language = { [ /(\[)(\^?)(?=(?:[^\]\\\/]|\\.)+)/, [ - '@brackets.regexp.escape.control', + { token: '@brackets.regexp.escape.control' }, { token: 'regexp.escape.control', next: '@regexrange', @@ -286,4 +289,5 @@ const language = { }, }; -export default { id, conf, language }; +const lang: Language = { id, conf, language }; +export default lang; diff --git a/packages/console/src/monaco/lang/groovy.js b/packages/console/src/monaco/lang/groovy.ts similarity index 93% rename from packages/console/src/monaco/lang/groovy.js rename to packages/console/src/monaco/lang/groovy.ts index cdbb49706e..d16c116191 100644 --- a/packages/console/src/monaco/lang/groovy.js +++ b/packages/console/src/monaco/lang/groovy.ts @@ -1,6 +1,9 @@ +import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'; +import { Language } from './Language'; + const id = 'groovy'; -const conf = { +const conf: monaco.languages.LanguageConfiguration = { comments: { lineComment: '//', blockComment: ['/*', '*/'], @@ -41,7 +44,7 @@ const conf = { }, }; -const language = { +const language: monaco.languages.IMonarchLanguage = { // Set defaultToken to invalid to see what you do not tokenize yet // defaultToken: 'invalid', @@ -120,9 +123,9 @@ const language = { ], brackets: [ - ['(', ')', 'delimiter.parenthesis'], - ['{', '}', 'delimiter.curly'], - ['[', ']', 'delimiter.square'], + { open: '{', close: '}', token: 'delimiter.curly' }, + { open: '[', close: ']', token: 'delimiter.square' }, + { open: '(', close: ')', token: 'delimiter.parenthesis' }, ], // operator symbols @@ -339,4 +342,5 @@ const language = { }, }; -export default { id, conf, language }; +const lang: Language = { id, conf, language }; +export default lang; diff --git a/packages/console/src/monaco/lang/log.js b/packages/console/src/monaco/lang/log.ts similarity index 80% rename from packages/console/src/monaco/lang/log.js rename to packages/console/src/monaco/lang/log.ts index e0fb89eccc..2d68ce7617 100644 --- a/packages/console/src/monaco/lang/log.js +++ b/packages/console/src/monaco/lang/log.ts @@ -1,10 +1,12 @@ /* eslint no-useless-escape: "off" */ +import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'; +import { Language } from './Language'; const id = 'log'; const conf = {}; -const language = { +const language: monaco.languages.IMonarchLanguage = { tokenizer: { root: [ [/ FATAL[\s\S]*/, { token: 'error', next: '@error' }], @@ -30,4 +32,5 @@ const language = { }, }; -export default { id, conf, language }; +const lang: Language = { id, conf, language }; +export default lang; diff --git a/packages/console/src/monaco/lang/python.js b/packages/console/src/monaco/lang/python.ts similarity index 93% rename from packages/console/src/monaco/lang/python.js rename to packages/console/src/monaco/lang/python.ts index bcee3d69d4..dc5c6eb0cc 100644 --- a/packages/console/src/monaco/lang/python.js +++ b/packages/console/src/monaco/lang/python.ts @@ -1,6 +1,9 @@ +import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'; +import { Language } from './Language'; + const id = 'python'; -const conf = { +const conf: monaco.languages.LanguageConfiguration = { comments: { lineComment: '#', blockComment: ["'''", "'''"], @@ -41,7 +44,7 @@ const conf = { }, }; -const language = { +const language: monaco.languages.IMonarchLanguage = { // Set defaultToken to invalid to see what you do not tokenize yet // defaultToken: 'invalid', @@ -116,11 +119,10 @@ const language = { '<<=', '**=', ], - brackets: [ - ['(', ')', 'delimiter.parenthesis'], - ['{', '}', 'delimiter.curly'], - ['[', ']', 'delimiter.square'], + { open: '{', close: '}', token: 'delimiter.curly' }, + { open: '[', close: ']', token: 'delimiter.square' }, + { open: '(', close: ')', token: 'delimiter.parenthesis' }, ], // operator symbols @@ -347,4 +349,5 @@ const language = { }, }; -export default { id, conf, language }; +const lang: Language = { id, conf, language }; +export default lang; diff --git a/packages/console/src/monaco/lang/scala.js b/packages/console/src/monaco/lang/scala.ts similarity index 97% rename from packages/console/src/monaco/lang/scala.js rename to packages/console/src/monaco/lang/scala.ts index dec9ead0b3..c0127caaa2 100644 --- a/packages/console/src/monaco/lang/scala.js +++ b/packages/console/src/monaco/lang/scala.ts @@ -26,9 +26,12 @@ * - https://github.com/microsoft/monaco-languages/blob/main/src/scala/scala.ts *--------------------------------------------------------------------------------------------*/ +import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'; +import { Language } from './Language'; + const id = 'scala'; -const conf = { +const conf: monaco.languages.LanguageConfiguration = { /* * `...` is allowed as an identifier. * $ is allowed in identifiers. @@ -67,7 +70,7 @@ const conf = { }, }; -const language = { +const language: monaco.languages.IMonarchLanguage = { // tokenPostfix: '.scala', // We can't easily add everything from Dotty, but we can at least add some of its keywords @@ -194,7 +197,7 @@ const language = { [ /(\.)(@name|@symbols)/, [ - 'operator', + { token: 'operator' }, { token: '@rematch', next: '@allowMethod', @@ -249,8 +252,8 @@ const language = { [ /(')(@escapes)(')/, [ - 'string', - 'string.escape', + { token: 'string' }, + { token: 'string.escape' }, { token: 'string', next: '@allowMethod', @@ -482,4 +485,5 @@ const language = { }, }; -export default { id, conf, language }; +const lang: Language = { id, conf, language }; +export default lang; diff --git a/packages/console/src/notebook/Editor.jsx b/packages/console/src/notebook/Editor.tsx similarity index 62% rename from packages/console/src/notebook/Editor.jsx rename to packages/console/src/notebook/Editor.tsx index 451aafee0e..6a80c12808 100644 --- a/packages/console/src/notebook/Editor.jsx +++ b/packages/console/src/notebook/Editor.tsx @@ -1,61 +1,79 @@ /** * Editor editor for large blocks of code */ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; +import React, { Component, ReactElement } from 'react'; import classNames from 'classnames'; import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'; +import { assertNotNull } from '@deephaven/utils'; import MonacoUtils from '../monaco/MonacoUtils'; -class Editor extends Component { - constructor(props) { +interface EditorProps { + className: string; + onEditorInitialized: (editor: monaco.editor.IStandaloneCodeEditor) => void; + onEditorWillDestroy: (editor: monaco.editor.IStandaloneCodeEditor) => void; + settings: Record; +} + +class Editor extends Component> { + static defaultProps = { + className: 'fill-parent-absolute', + onEditorInitialized: (): void => undefined, + onEditorWillDestroy: (): void => undefined, + settings: {}, + }; + + constructor(props: EditorProps) { super(props); this.handleResize = this.handleResize.bind(this); this.container = null; - this.editor = null; - this.state = {}; } - componentDidMount() { + componentDidMount(): void { this.initEditor(); window.addEventListener('resize', this.handleResize); } - componentWillUnmount() { + componentWillUnmount(): void { window.removeEventListener('resize', this.handleResize); this.destroyEditor(); } - setLanguage(language) { + container: HTMLDivElement | null; + + editor?: monaco.editor.IStandaloneCodeEditor; + + setLanguage(language: string): void { if (this.editor) { - monaco.editor.setModelLanguage(this.editor.getModel(), language); + const model = this.editor.getModel(); + assertNotNull(model); + monaco.editor.setModelLanguage(model, language); } } - handleResize() { + handleResize(): void { this.updateDimensions(); } - toggleFind() { + toggleFind(): void { if (this.editor) { // The actions.find action can no longer be triggered when the editor is not in focus, with monaco 0.22.x. // As a workaround, just focus the editor before triggering the action // https://github.com/microsoft/monaco-editor/issues/2355 this.editor.focus(); - this.editor.trigger('toggleFind', 'actions.find'); + this.editor.trigger('toggleFind', 'actions.find', undefined); } } - updateDimensions() { - this.editor.layout(); + updateDimensions(): void { + this.editor?.layout(); } - initEditor() { + initEditor(): void { const { onEditorInitialized } = this.props; let { settings } = this.props; settings = { @@ -73,6 +91,7 @@ class Editor extends Component { wordWrap: 'off', ...settings, }; + assertNotNull(this.container); this.editor = monaco.editor.create(this.container, settings); this.editor.addAction({ id: 'find', @@ -81,14 +100,13 @@ class Editor extends Component { // eslint-disable-next-line no-bitwise monaco.KeyMod.CtrlCmd | monaco.KeyCode.KEY_F, ], - precondition: null, - keybindingContext: null, + precondition: undefined, + keybindingContext: undefined, contextMenuGroupId: 'navigation', contextMenuOrder: 1.0, run: () => { this.toggleFind(); - return null; }, }); this.editor.layout(); @@ -97,14 +115,15 @@ class Editor extends Component { onEditorInitialized(this.editor); } - destroyEditor() { + destroyEditor(): void { const { onEditorWillDestroy } = this.props; + assertNotNull(this.editor); onEditorWillDestroy(this.editor); this.editor.dispose(); - this.editor = null; + this.editor = undefined; } - render() { + render(): ReactElement { const { className } = this.props; return (
{}, - onEditorWillDestroy: () => {}, - settings: {}, -}; - export default Editor; diff --git a/packages/console/src/notebook/ScriptEditor.jsx b/packages/console/src/notebook/ScriptEditor.tsx similarity index 73% rename from packages/console/src/notebook/ScriptEditor.jsx rename to packages/console/src/notebook/ScriptEditor.tsx index d1fccae662..118c153785 100644 --- a/packages/console/src/notebook/ScriptEditor.jsx +++ b/packages/console/src/notebook/ScriptEditor.tsx @@ -1,10 +1,12 @@ /** * Script editor for large blocks of code */ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; +import React, { Component, ReactElement, RefObject } from 'react'; import { LoadingOverlay, ShortcutRegistry } from '@deephaven/components'; import Log from '@deephaven/log'; +import { IdeSession } from '@deephaven/jsapi-shim'; +import { assertNotNull } from '@deephaven/utils'; +import { editor, IDisposable } from 'monaco-editor'; import Editor from './Editor'; import { MonacoCompletionProvider, MonacoUtils } from '../monaco'; import './ScriptEditor.scss'; @@ -12,8 +14,38 @@ import SHORTCUTS from '../ConsoleShortcuts'; const log = Log.module('ScriptEditor'); -class ScriptEditor extends Component { - constructor(props) { +interface ScriptEditorProps { + error?: { message?: string }; + isLoading: boolean; + isLoaded: boolean; + focusOnMount?: boolean; + onChange: (e: editor.IModelContentChangedEvent) => void; + onRunCommand: (command: string) => void; + session: IdeSession; + sessionLanguage?: string; + settings?: { + language: string; + value?: string; + }; +} + +interface ScriptEditorState { + model: editor.ITextModel | null; +} + +class ScriptEditor extends Component { + static defaultProps = { + error: null, + isLoading: false, + isLoaded: false, + focusOnMount: true, + onChange: (): void => undefined, + session: null, + sessionLanguage: null, + settings: null, + }; + + constructor(props: ScriptEditorProps) { super(props); this.handleEditorInitialized = this.handleEditorInitialized.bind(this); this.handleEditorWillDestroy = this.handleEditorWillDestroy.bind(this); @@ -22,8 +54,6 @@ class ScriptEditor extends Component { this.updateShortcuts = this.updateShortcuts.bind(this); this.contextActionCleanups = []; - this.completionCleanup = null; - this.editor = null; this.editorComponent = React.createRef(); this.state = { @@ -31,15 +61,16 @@ class ScriptEditor extends Component { }; } - componentDidMount() { + componentDidMount(): void { ShortcutRegistry.addEventListener('onUpdate', this.updateShortcuts); } - componentDidUpdate(prevProps) { + componentDidUpdate(prevProps: ScriptEditorProps): void { const { sessionLanguage, settings } = this.props; - const { language } = settings; - const languageChanged = language !== prevProps.settings.language; + const language = settings?.language; + + const languageChanged = language !== prevProps.settings?.language; if (languageChanged) { log.debug('Set language', language); this.setLanguage(language); @@ -49,7 +80,7 @@ class ScriptEditor extends Component { sessionLanguage == null && prevProps.sessionLanguage != null; const languageMatch = language === sessionLanguage; const prevLanguageMatch = - prevProps.settings.language === prevProps.sessionLanguage; + prevProps.settings?.language === prevProps.sessionLanguage; if ( sessionDisconnected || (sessionLanguage && prevLanguageMatch && !languageMatch) @@ -73,11 +104,19 @@ class ScriptEditor extends Component { } } - componentWillUnmount() { + componentWillUnmount(): void { ShortcutRegistry.removeEventListener('onUpdate', this.updateShortcuts); } - getValue() { + contextActionCleanups: IDisposable[]; + + completionCleanup?: IDisposable; + + editor?: editor.IStandaloneCodeEditor; + + editorComponent: RefObject; + + getValue(): string | null { if (this.editor) { return this.editor.getValue(); } @@ -85,23 +124,25 @@ class ScriptEditor extends Component { return null; } - getSelectedCommand() { - const range = this.editor.getSelection(); - const model = this.editor.getModel(); + getSelectedCommand(): string { + const range = this.editor?.getSelection(); + assertNotNull(range); + const model = this.editor?.getModel(); + assertNotNull(model); const { startLineNumber, endColumn } = range; let { endLineNumber } = range; if (endColumn === 1 && endLineNumber > startLineNumber) { endLineNumber -= 1; } - const startLineMinColumn = model.getLineMinColumn(startLineNumber); - const endLineMaxColumn = model.getLineMaxColumn(endLineNumber); + const startLineMinColumn = model?.getLineMinColumn(startLineNumber); + const endLineMaxColumn = model?.getLineMaxColumn(endLineNumber); const wholeLineRange = range .setStartPosition(startLineNumber, startLineMinColumn) .setEndPosition(endLineNumber, endLineMaxColumn); - return model.getValueInRange(wholeLineRange); + return model?.getValueInRange(wholeLineRange); } - handleEditorInitialized(editor) { + handleEditorInitialized(innerEditor: editor.IStandaloneCodeEditor): void { const { focusOnMount, onChange, @@ -112,44 +153,46 @@ class ScriptEditor extends Component { log.debug('handleEditorInitialized', sessionLanguage, session, settings); - this.editor = editor; + this.editor = innerEditor; this.setState({ model: this.editor.getModel() }); - MonacoUtils.setEOL(editor); - MonacoUtils.registerPasteHandler(editor); + MonacoUtils.setEOL(innerEditor); + MonacoUtils.registerPasteHandler(innerEditor); if (session && settings && sessionLanguage === settings.language) { this.initContextActions(); this.initCodeCompletion(); } - editor.onDidChangeModelContent(onChange); + innerEditor.onDidChangeModelContent(onChange); if (focusOnMount) { - editor.focus(); + innerEditor.focus(); } } - handleEditorWillDestroy() { + handleEditorWillDestroy(): void { log.debug('handleEditorWillDestroy'); this.deInitContextActions(); this.deInitCodeCompletion(); this.setState({ model: null }); - this.editor = null; + this.editor = undefined; } - handleRun() { + handleRun(): void { const { onRunCommand } = this.props; const command = this.getValue(); - onRunCommand(command); + if (command != null) { + onRunCommand(command); + } } - handleRunSelected() { + handleRunSelected(): void { const { onRunCommand } = this.props; const command = this.getSelectedCommand(); onRunCommand(command); } - initContextActions() { + initContextActions(): void { if (this.contextActionCleanups.length > 0) { log.error('Context actions already initialized.'); return; @@ -167,15 +210,11 @@ class ScriptEditor extends Component { keybindings: [ MonacoUtils.getMonacoKeyCodeFromShortcut(SHORTCUTS.NOTEBOOK.RUN), ], - precondition: null, - - keybindingContext: null, contextMenuGroupId: 'navigation', contextMenuOrder: 1.5, run: () => { this.handleRun(); - return null; }, }) ); @@ -189,14 +228,11 @@ class ScriptEditor extends Component { SHORTCUTS.NOTEBOOK.RUN_SELECTED ), ], - precondition: null, - keybindingContext: null, contextMenuGroupId: 'navigation', contextMenuOrder: 1.5, run: () => { this.handleRunSelected(); - return null; }, }) ); @@ -204,19 +240,19 @@ class ScriptEditor extends Component { this.contextActionCleanups = cleanups; } - deInitContextActions() { + deInitContextActions(): void { if (this.contextActionCleanups.length > 0) { this.contextActionCleanups.forEach(cleanup => cleanup.dispose()); this.contextActionCleanups = []; } } - updateShortcuts() { + updateShortcuts(): void { this.deInitContextActions(); this.initContextActions(); } - initCodeCompletion() { + initCodeCompletion(): void { if (this.completionCleanup != null) { log.error('Code completion already initialized.'); return; @@ -228,20 +264,22 @@ class ScriptEditor extends Component { } } - deInitCodeCompletion() { + deInitCodeCompletion(): void { const { session } = this.props; log.debug('deInitCodeCompletion', this.editor, session); if (this.completionCleanup) { this.completionCleanup.dispose(); - this.completionCleanup = null; + this.completionCleanup = undefined; } if (this.editor && session) { MonacoUtils.closeDocument(this.editor, session); } } - append(text, focus = true) { + append(text: string, focus = true): void { + assertNotNull(this.editor); const model = this.editor.getModel(); + assertNotNull(model); const currentText = model.getValue(); if (currentText) { model.setValue(`${currentText}\n${text}`); @@ -259,33 +297,33 @@ class ScriptEditor extends Component { } } - updateDimensions() { + updateDimensions(): void { log.debug('updateDimensions'); if (this.editor) { this.editor.layout(); } } - focus() { + focus(): void { log.debug('focus'); if (this.editor) { this.editor.focus(); } } - toggleFind() { + toggleFind(): void { if (this.editorComponent.current) { this.editorComponent.current.toggleFind(); } } - setLanguage(language) { - if (this.editorComponent.current) { + setLanguage(language?: string): void { + if (this.editorComponent.current && language) { this.editorComponent.current.setLanguage(language); } } - render() { + render(): ReactElement { const { error, isLoaded, @@ -338,30 +376,4 @@ class ScriptEditor extends Component { } } -ScriptEditor.propTypes = { - error: PropTypes.shape({ message: PropTypes.string }), - isLoading: PropTypes.bool, - isLoaded: PropTypes.bool, - focusOnMount: PropTypes.bool, - onChange: PropTypes.func, - onRunCommand: PropTypes.func.isRequired, - session: PropTypes.shape({}), - sessionLanguage: PropTypes.string, - settings: PropTypes.shape({ - language: PropTypes.string, - value: PropTypes.string, - }), -}; - -ScriptEditor.defaultProps = { - error: null, - isLoading: false, - isLoaded: false, - focusOnMount: true, - onChange: () => {}, - session: null, - sessionLanguage: null, - settings: null, -}; - export default ScriptEditor; diff --git a/packages/console/src/notebook/ScriptEditorUtils.js b/packages/console/src/notebook/ScriptEditorUtils.js deleted file mode 100644 index 809913fe97..0000000000 --- a/packages/console/src/notebook/ScriptEditorUtils.js +++ /dev/null @@ -1,36 +0,0 @@ -const LANGUAGES = { groovy: 'Groovy', python: 'Python', scala: 'Scala' }; - -class ScriptEditorUtils { - /** Get PQ script language from Monaco language - * @param {string} language Monaco language - * @returns {string} PQ script language - */ - static normalizeScriptLanguage(language) { - return LANGUAGES[language] || null; - } - - /** - * Get a tooltip for disabled button based on the session status and language - * @param {boolean} isSessionConnected True if console session connected - * @param {boolean} isLanguageMatching True if the script language is matching the session language - * @param {string} scriptLanguageLabel Language label to use in the tooltip message - * @param {string} buttonLabel Button label to use in the tooltip message - * @returns {string} Tooltip message or `null` if the session is connected and language is matching - */ - static getDisabledRunTooltip( - isSessionConnected, - isLanguageMatching, - scriptLanguageLabel, - buttonLabel - ) { - if (!isSessionConnected) { - return `Console session not connected – ${buttonLabel} disabled`; - } - if (!isLanguageMatching) { - return `${scriptLanguageLabel} doesn't match the session language – ${buttonLabel} disabled`; - } - return null; - } -} - -export default ScriptEditorUtils; diff --git a/packages/console/src/notebook/ScriptEditorUtils.ts b/packages/console/src/notebook/ScriptEditorUtils.ts new file mode 100644 index 0000000000..ae0bcc7ab8 --- /dev/null +++ b/packages/console/src/notebook/ScriptEditorUtils.ts @@ -0,0 +1,40 @@ +const LANGUAGES = { + groovy: 'Groovy', + python: 'Python', + scala: 'Scala', +} as const; + +class ScriptEditorUtils { + /** Get PQ script language from Monaco language + * @paramlanguage Monaco language + * @returns PQ script language + */ + static normalizeScriptLanguage(language: keyof typeof LANGUAGES): string { + return LANGUAGES[language] || null; + } + + /** + * Get a tooltip for disabled button based on the session status and language + * @param isSessionConnected True if console session connected + * @param isLanguageMatching True if the script language is matching the session language + * @param scriptLanguageLabel Language label to use in the tooltip message + * @param buttonLabel Button label to use in the tooltip message + * @returns Tooltip message or `null` if the session is connected and language is matching + */ + static getDisabledRunTooltip( + isSessionConnected: boolean, + isLanguageMatching: boolean, + scriptLanguageLabel: string, + buttonLabel: string + ): string | null { + if (!isSessionConnected) { + return `Console session not connected – ${buttonLabel} disabled`; + } + if (!isLanguageMatching) { + return `${scriptLanguageLabel} doesn't match the session language – ${buttonLabel} disabled`; + } + return null; + } +} + +export default ScriptEditorUtils; diff --git a/packages/jsapi-shim/src/dh.types.ts b/packages/jsapi-shim/src/dh.types.ts index 6334c59526..9a3f4dc2d6 100644 --- a/packages/jsapi-shim/src/dh.types.ts +++ b/packages/jsapi-shim/src/dh.types.ts @@ -26,6 +26,7 @@ export interface dh { Column: Column; SearchDisplayMode?: SearchDisplayModeStatic; RangeSet: RangeSet; + IdeSession: IdeSessionStatic; } const VariableType = { @@ -37,18 +38,70 @@ const VariableType = { TREETABLE: 'TreeTable', } as const; -type VariableTypeUnion = typeof VariableType[keyof typeof VariableType]; +export type VariableTypeUnion = typeof VariableType[keyof typeof VariableType]; export interface VariableDefinition< T extends VariableTypeUnion = VariableTypeUnion > { type: T; + + /** + * @deprecated + */ name?: string; + title?: string; id?: string; } -export interface IdeSession { +export interface LogItem { + micros: number; + logLevel: string; + message: string; +} + +export interface VariableChanges { + created: VariableDefinition[]; + updated: VariableDefinition[]; + removed: VariableDefinition[]; +} + +export interface CommandResult { + changes: VariableChanges; + error: string; +} + +export interface Position { + line: number; + character: number; +} + +export interface DocumentRange { + start: Position; + end: Position; +} + +export interface TextEdit { + text: string; + range: DocumentRange; +} + +export interface CompletionItem { + label: string; + kind: number; + detail: string; + documentation: string; + sortText: string; + filterText: string; + textEdit: TextEdit; + insertTextFormat: number; +} + +export interface IdeSessionStatic { + EVENT_COMMANDSTARTED: 'commandstarted'; +} + +export interface IdeSession extends Evented { getTable(name: string): Promise; getFigure(name: string): Promise
; getTreeTable(name: string): Promise; @@ -62,6 +115,20 @@ export interface IdeSession { definition: VariableDefinition ): Promise; getObject(definition: VariableDefinition): Promise; + onLogMessage(logHandler: (logItem: LogItem) => void): () => void; + runCode(code: string): Promise; + bindTableToVariable(table: Table, variableName: string): Promise; + mergeTables(tables: Table[]): Promise
; + newTable( + columnNames: string[], + columnTypes: string[], + data: string[][], + userTimeZone: string + ): Promise
; + getCompletionItems(params: unknown): Promise; + closeDocument(params: unknown): void; + openDocument(params: unknown): void; + changeDocument(params: unknown): void; } export interface Evented {