diff --git a/packages/web/src/datagrid/DataGridCore.js b/packages/web/src/datagrid/DataGridCore.js index fc91e5aa..d9c5f86c 100644 --- a/packages/web/src/datagrid/DataGridCore.js +++ b/packages/web/src/datagrid/DataGridCore.js @@ -596,7 +596,19 @@ export default function DataGridCore(props) { } function exportGrid() { - showModal((modalState) => ); + showModal((modalState) => ( + + )); } function setCellValue(chs, cell, value) { diff --git a/packages/web/src/impexp/ImportExportConfigurator.js b/packages/web/src/impexp/ImportExportConfigurator.js index ca53e4d3..cd6e8376 100644 --- a/packages/web/src/impexp/ImportExportConfigurator.js +++ b/packages/web/src/impexp/ImportExportConfigurator.js @@ -4,7 +4,14 @@ import { Formik, Form, useFormik, useFormikContext } from 'formik'; import styled from 'styled-components'; import Select from 'react-select'; import { FontIcon } from '../icons'; -import { FormButtonRow, FormSubmit, FormReactSelect, FormConnectionSelect, FormDatabaseSelect } from '../utility/forms'; +import { + FormButtonRow, + FormSubmit, + FormReactSelect, + FormConnectionSelect, + FormDatabaseSelect, + FormTablesSelect, +} from '../utility/forms'; import { useConnectionList, useDatabaseList } from '../utility/metadataLoaders'; const Wrapper = styled.div` @@ -62,6 +69,7 @@ function SourceTargetConfig({ storageTypeField, connectionIdField, databaseNameField, + tablesField, }) { const types = [ { value: 'database', label: 'Database' }, @@ -80,6 +88,8 @@ function SourceTargetConfig({ + + )} @@ -88,23 +98,21 @@ function SourceTargetConfig({ export default function ImportExportConfigurator() { return ( - -
- - - - -
-
+ + + + ); } diff --git a/packages/web/src/impexp/ScriptCreator.js b/packages/web/src/impexp/ScriptCreator.js new file mode 100644 index 00000000..8067fe55 --- /dev/null +++ b/packages/web/src/impexp/ScriptCreator.js @@ -0,0 +1,49 @@ +import ScriptWriter from './ScriptWriter'; + +export default class ScriptCreator { + constructor() { + this.varCount = 0; + this.commands = []; + } + allocVariable(prefix = 'var') { + this.varCount += 1; + return `${prefix}${this.varCount}`; + } + getCode() { + const writer = new ScriptWriter(); + for (const command of this.commands) { + const { type } = command; + switch (type) { + case 'assign': + { + const { variableName, functionName, props } = command; + writer.assign(variableName, functionName, props); + } + break; + case 'copyStream': + { + const { sourceVar, targetVar } = command; + writer.copyStream(sourceVar, targetVar); + } + break; + } + } + writer.finish(); + return writer.s; + } + assign(variableName, functionName, props) { + this.commands.push({ + type: 'assign', + variableName, + functionName, + props, + }); + } + copyStream(sourceVar, targetVar) { + this.commands.push({ + type: 'copyStream', + sourceVar, + targetVar, + }); + } +} diff --git a/packages/web/src/impexp/ScriptWriter.js b/packages/web/src/impexp/ScriptWriter.js new file mode 100644 index 00000000..5da762f3 --- /dev/null +++ b/packages/web/src/impexp/ScriptWriter.js @@ -0,0 +1,26 @@ +export default class ScriptWriter { + constructor() { + this.s = ''; + this.put('const dbgateApi = require("@dbgate/api");'); + this.put('async function run() {'); + } + + put(s) { + this.s += s; + this.s += '\n'; + } + + finish() { + this.put('await dbgateApi.copyStream(queryReader, csvWriter);'); + this.put('dbgateApi.runScript(run);'); + this.put('}'); + } + + assign(variableName, functionName, props) { + this.put(`const ${variableName} = await dbgateApi.${functionName}(${JSON.stringify(props)});`); + } + + copyStream(sourceVar, targetVar) { + this.put(`await dbgateApi.copyStream(${sourceVar}, ${targetVar});`); + } +} diff --git a/packages/web/src/impexp/createImpExpScript.js b/packages/web/src/impexp/createImpExpScript.js new file mode 100644 index 00000000..39cb61fa --- /dev/null +++ b/packages/web/src/impexp/createImpExpScript.js @@ -0,0 +1,50 @@ +import ScriptCreator from './ScriptCreator'; +import getAsArray from '../utility/getAsArray'; +import { getConnectionInfo } from '../utility/metadataLoaders'; +import engines from '@dbgate/engines'; + +function splitFullName(name) { + const i = name.indexOf('.'); + if (i >= 0) + return { + schemaName: name.substr(0, i), + pureName: name.substr(i + 1), + }; + return { + schemaName: null, + pureName: name, + }; +} +function quoteFullName(dialect, { schemaName, pureName }) { + if (schemaName) return `${dialect.quoteIdentifier(schemaName)}.${dialect.quoteIdentifier(pureName)}`; + return `${dialect.quoteIdentifier(pureName)}`; +} + +export default async function createImpExpScript(values) { + const script = new ScriptCreator(); + if (values.sourceStorageType == 'database') { + const tables = getAsArray(values.sourceTables); + for (const table of tables) { + const sourceVar = script.allocVariable(); + const connection = await getConnectionInfo({ conid: values.sourceConnectionId }); + const driver = engines(connection); + + const fullName = splitFullName(table); + script.assign(sourceVar, 'queryReader', { + connection: { + ...connection, + database: values.sourceDatabaseName, + }, + sql: `select * from ${quoteFullName(driver.dialect, fullName)}`, + }); + + const targetVar = script.allocVariable(); + script.assign(targetVar, 'csvWriter', { + fileName: `${fullName.pureName}.csv`, + }); + + script.copyStream(sourceVar, targetVar); + } + } + return script.getCode(); +} diff --git a/packages/web/src/index.js b/packages/web/src/index.js index b6607d8e..7adc4114 100644 --- a/packages/web/src/index.js +++ b/packages/web/src/index.js @@ -9,6 +9,7 @@ import 'ace-builds/src-noconflict/mode-sql'; import 'ace-builds/src-noconflict/mode-mysql'; import 'ace-builds/src-noconflict/mode-pgsql'; import 'ace-builds/src-noconflict/mode-sqlserver'; +import 'ace-builds/src-noconflict/mode-javascript'; import 'ace-builds/src-noconflict/theme-github'; import 'ace-builds/src-noconflict/ext-searchbox'; import 'ace-builds/src-noconflict/ext-language_tools'; diff --git a/packages/web/src/modals/ImportExportModal.js b/packages/web/src/modals/ImportExportModal.js index 6d2f49d5..042e1dc9 100644 --- a/packages/web/src/modals/ImportExportModal.js +++ b/packages/web/src/modals/ImportExportModal.js @@ -10,17 +10,43 @@ import ModalFooter from './ModalFooter'; import ModalContent from './ModalContent'; import { useConnectionList, useDatabaseList } from '../utility/metadataLoaders'; import ImportExportConfigurator from '../impexp/ImportExportConfigurator'; +import createImpExpScript from '../impexp/createImpExpScript'; +import { openNewTab } from '../utility/common'; +import { useSetOpenedTabs } from '../utility/globalState'; +import { Formik, Form, useFormik, useFormikContext } from 'formik'; -export default function ImportExportModal({ modalState }) { +export default function ImportExportModal({ modalState, initialValues }) { + const setOpenedTabs = useSetOpenedTabs(); + + const handleSubmit = async (values) => { + const code = await createImpExpScript(values); + openNewTab(setOpenedTabs, { + title: 'Shell', + icon: 'trigger.svg', + tabComponent: 'ShellTab', + props: { + initialScript: code, + }, + }); + modalState.close(); + }; return ( - Import/Export - - - - - - + +
+ Import/Export + + + + + + + +
+
); } diff --git a/packages/web/src/sqleditor/JavaScriptEditor.js b/packages/web/src/sqleditor/JavaScriptEditor.js new file mode 100644 index 00000000..40f03bca --- /dev/null +++ b/packages/web/src/sqleditor/JavaScriptEditor.js @@ -0,0 +1,69 @@ +import React from 'react'; +import styled from 'styled-components'; +import AceEditor from 'react-ace'; +import useDimensions from '../utility/useDimensions'; + +const Wrapper = styled.div` + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; +`; + +export default function JavaScriptEditor({ + value = undefined, + readOnly = false, + onChange = undefined, + tabVisible = false, + onKeyDown = undefined, + editorRef = undefined, + focusOnCreate = false, +}) { + const [containerRef, { height, width }] = useDimensions(); + const ownEditorRef = React.useRef(null); + + const currentEditorRef = editorRef || ownEditorRef; + + React.useEffect(() => { + if ((tabVisible || focusOnCreate) && currentEditorRef.current && currentEditorRef.current.editor) + currentEditorRef.current.editor.focus(); + }, [tabVisible, focusOnCreate]); + + const handleKeyDown = React.useCallback( + async (data, hash, keyString, keyCode, event) => { + if (onKeyDown) onKeyDown(data, hash, keyString, keyCode, event); + }, + [onKeyDown] + ); + + React.useEffect(() => { + if ((onKeyDown || !readOnly) && currentEditorRef.current) { + currentEditorRef.current.editor.keyBinding.addKeyboardHandler(handleKeyDown); + } + return () => { + currentEditorRef.current.editor.keyBinding.removeKeyboardHandler(handleKeyDown); + }; + }, [handleKeyDown]); + + return ( + + + + ); +} diff --git a/packages/web/src/tabs/ShellTab.js b/packages/web/src/tabs/ShellTab.js new file mode 100644 index 00000000..b3652dc5 --- /dev/null +++ b/packages/web/src/tabs/ShellTab.js @@ -0,0 +1,108 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import _ from 'lodash'; +import axios from '../utility/axios'; +import engines from '@dbgate/engines'; + +import { useConnectionInfo, getTableInfo, getConnectionInfo, getSqlObjectInfo } from '../utility/metadataLoaders'; +import SqlEditor from '../sqleditor/SqlEditor'; +import { useUpdateDatabaseForTab, useSetOpenedTabs, useOpenedTabs } from '../utility/globalState'; +import QueryToolbar from '../query/QueryToolbar'; +import SessionMessagesView from '../query/SessionMessagesView'; +import { TabPage } from '../widgets/TabControl'; +import ResultTabs from '../sqleditor/ResultTabs'; +import { VerticalSplitter } from '../widgets/Splitter'; +import keycodes from '../utility/keycodes'; +import { changeTab } from '../utility/common'; +import useSocket from '../utility/SocketProvider'; +import SaveSqlFileModal from '../modals/SaveSqlFileModal'; +import useModalState from '../modals/useModalState'; +import sqlFormatter from 'sql-formatter'; +import JavaScriptEditor from '../sqleditor/JavaScriptEditor'; + +export default function ShellTab({ + tabid, + conid, + database, + initialArgs, + tabVisible, + toolbarPortalRef, + initialScript, + storageKey, + ...other +}) { + const localStorageKey = storageKey || `shell_${tabid}`; + const [shellText, setShellText] = React.useState(() => localStorage.getItem(localStorageKey) || initialScript || ''); + const shellTextRef = React.useRef(shellText); + const [busy, setBusy] = React.useState(false); + + const saveToStorage = React.useCallback(() => localStorage.setItem(localStorageKey, shellTextRef.current), [ + localStorageKey, + shellTextRef, + ]); + const saveToStorageDebounced = React.useMemo(() => _.debounce(saveToStorage, 5000), [saveToStorage]); + const setOpenedTabs = useSetOpenedTabs(); + + React.useEffect(() => { + window.addEventListener('beforeunload', saveToStorage); + return () => { + saveToStorage(); + window.removeEventListener('beforeunload', saveToStorage); + }; + }, []); + + React.useEffect(() => { + if (!storageKey) + changeTab(tabid, setOpenedTabs, (tab) => ({ + ...tab, + props: { + ...tab.props, + storageKey: localStorageKey, + }, + })); + }, [storageKey]); + + React.useEffect(() => { + changeTab(tabid, setOpenedTabs, (tab) => ({ ...tab, busy })); + }, [busy]); + + const editorRef = React.useRef(null); + + useUpdateDatabaseForTab(tabVisible, conid, database); + const connection = useConnectionInfo({ conid }); + + const handleChange = (text) => { + if (text != null) shellTextRef.current = text; + setShellText(text); + saveToStorageDebounced(); + }; + + const handleExecute = async () => {}; + + const handleCancel = () => { + // axios.post('sessions/cancel', { + // sesid: sessionId, + // }); + }; + + const handleKeyDown = (data, hash, keyString, keyCode, event) => { + if (keyCode == keycodes.f5) { + event.preventDefault(); + handleExecute(); + } + }; + + return ( + <> + + + + + ); +} diff --git a/packages/web/src/tabs/index.js b/packages/web/src/tabs/index.js index 6a674165..5b3d72ab 100644 --- a/packages/web/src/tabs/index.js +++ b/packages/web/src/tabs/index.js @@ -2,6 +2,7 @@ import TableDataTab from './TableDataTab'; import ViewDataTab from './ViewDataTab'; import TableStructureTab from './TableStructureTab'; import QueryTab from './QueryTab'; +import ShellTab from './ShellTab'; import InfoPageTab from './InfoPageTab'; export default { @@ -10,4 +11,5 @@ export default { TableStructureTab, QueryTab, InfoPageTab, + ShellTab, }; diff --git a/packages/web/src/utility/forms.js b/packages/web/src/utility/forms.js index ba3f3e74..e9ad0300 100644 --- a/packages/web/src/utility/forms.js +++ b/packages/web/src/utility/forms.js @@ -4,8 +4,9 @@ import Select from 'react-select'; import { TextField, SelectField } from './inputs'; import { Field, useFormikContext } from 'formik'; import FormStyledButton from '../widgets/FormStyledButton'; -import { useConnectionList, useDatabaseList } from './metadataLoaders'; +import { useConnectionList, useDatabaseList, useDatabaseInfo } from './metadataLoaders'; import useSocket from './SocketProvider'; +import getAsArray from './getAsArray'; export const FormRow = styled.div` display: flex; @@ -84,14 +85,23 @@ export function FormRadioGroupItem({ name, text, value }) { ); } -export function FormReactSelect({ options, name }) { +export function FormReactSelect({ options, name, isMulti = false }) { const { setFieldValue, values } = useFormikContext(); + return (