diff --git a/packages/web/src/modals/InsertJoinModal.js b/packages/web/src/modals/InsertJoinModal.js new file mode 100644 index 000000000..5c83d031d --- /dev/null +++ b/packages/web/src/modals/InsertJoinModal.js @@ -0,0 +1,189 @@ +import React from 'react'; +import ModalBase from './ModalBase'; +import { FormButtonRow } from '../utility/forms'; +import FormStyledButton from '../widgets/FormStyledButton'; +import SqlEditor from '../sqleditor/SqlEditor'; +import styled from 'styled-components'; +import keycodes from '../utility/keycodes'; +import ModalHeader from './ModalHeader'; +import ModalContent from './ModalContent'; +import ModalFooter from './ModalFooter'; +import analyseQuerySources from '../sqleditor/analyseQuerySources'; +import TableControl, { TableColumn } from '../utility/TableControl'; +import { TextField } from '../utility/inputs'; + +const FlexLine = styled.div` + display: flex; + margin-bottom: 15px; +`; + +const FlexColumn = styled.div` + margin: 5px; +`; + +const Label = styled.div` + margin: 5px; +`; + +const SqlWrapper = styled.div` + position: relative; + height: 80px; + width: 40vw; +`; + +const JOIN_TYPES = ['INNER JOIN', 'LEFT JOIN', 'RIGHT JOIN']; + +export default function InsertJoinModal({ sql, modalState, engine, dbinfo, onInsert }) { + const sources = React.useMemo( + () => analyseQuerySources(sql, [...dbinfo.tables.map((x) => x.pureName), ...dbinfo.views.map((x) => x.pureName)]), + [sql, dbinfo] + ); + + const [sourceIndex, setSourceIndex] = React.useState(0); + const [targetIndex, setTargetIndex] = React.useState(0); + const [joinIndex, setJoinIndex] = React.useState(0); + const [alias, setAlias] = React.useState(''); + const sourceRef = React.useRef(null); + const targetRef = React.useRef(null); + const aliasRef = React.useRef(null); + const joinRef = React.useRef(null); + + const targets = React.useMemo(() => { + const source = sources[sourceIndex]; + if (!source) return []; + /** @type {import('@dbgate/types').TableInfo} */ + const table = dbinfo.tables.find((x) => x.pureName == sources[sourceIndex].name); + if (!table) return []; + return [ + ...table.foreignKeys.map((fk) => ({ + baseColumns: fk.columns.map((x) => x.columnName).join(', '), + refTable: fk.refTableName, + refColumns: fk.columns.map((x) => x.refColumnName).join(', '), + constraintName: fk.constraintName, + columnMap: fk.columns, + })), + ...table.dependencies.map((fk) => ({ + baseColumns: fk.columns.map((x) => x.refColumnName).join(', '), + refTable: fk.pureName, + refColumns: fk.columns.map((x) => x.columnName).join(', '), + constraintName: fk.constraintName, + columnMap: fk.columns.map((x) => ({ + columnName: x.refColumnName, + refColumnName: x.columnName, + })), + })), + ]; + }, [sourceIndex, sources]); + + const sqlPreview = React.useMemo(() => { + const source = sources[sourceIndex]; + const target = targets[targetIndex]; + if (source && target) { + return `${JOIN_TYPES[joinIndex]} ${target.refTable}${alias ? ` ${alias}` : ''} ON ${target.columnMap + .map((col) => `${source.name}.${col.columnName} = ${alias || target.refTable}.${col.refColumnName}`) + .join(' AND ')}`; + } + return ''; + }, [joinIndex, sources, targets, sourceIndex, targetIndex, alias]); + + const sourceKeyDown = React.useCallback((event) => { + if (event.keyCode == keycodes.enter || event.keyCode == keycodes.rightArrow) { + targetRef.current.focus(); + } + }, []); + const targetKeyDown = React.useCallback((event) => { + if (event.keyCode == keycodes.leftArrow) { + sourceRef.current.focus(); + } + if (event.keyCode == keycodes.enter || event.keyCode == keycodes.rightArrow) { + joinRef.current.focus(); + } + }, []); + const joinKeyDown = React.useCallback((event) => { + if (event.keyCode == keycodes.leftArrow) { + targetRef.current.focus(); + } + if (event.keyCode == keycodes.enter || event.keyCode == keycodes.rightArrow) { + aliasRef.current.focus(); + } + }, []); + const aliasKeyDown = React.useCallback((event) => { + if (event.keyCode == keycodes.enter) { + event.preventDefault(); + modalState.close(); + onInsert(sqlPreview); + } + }, []); + + return ( + + Insert join + + + + + + + + + + + + + + + + {/* */} + + + + + ({ name }))} + selectedIndex={joinIndex} + setSelectedIndex={setJoinIndex} + tableRef={joinRef} + onKeyDown={joinKeyDown} + > + + + + setAlias(e.target.value)} + editorRef={aliasRef} + onKeyDown={aliasKeyDown} + /> + + + + + + + + + { + modalState.close(); + onInsert(sqlPreview); + }} + /> + + + + ); +} diff --git a/packages/web/src/sqleditor/SqlEditor.js b/packages/web/src/sqleditor/SqlEditor.js index 1dc3b8a5c..fc19b31ba 100644 --- a/packages/web/src/sqleditor/SqlEditor.js +++ b/packages/web/src/sqleditor/SqlEditor.js @@ -2,9 +2,12 @@ import React from 'react'; import styled from 'styled-components'; import AceEditor from 'react-ace'; import useDimensions from '../utility/useDimensions'; -import { addCompleter, setCompleters } from 'ace-builds/src-noconflict/ext-language_tools'; -import { getDatabaseInfo } from '../utility/metadataLoaders'; import analyseQuerySources from './analyseQuerySources'; +import keycodes from '../utility/keycodes'; +import useCodeCompletion from './useCodeCompletion'; +import showModal from '../modals/showModal'; +import InsertJoinModal from '../modals/InsertJoinModal'; +import { getDatabaseInfo } from '../utility/metadataLoaders'; const Wrapper = styled.div` position: absolute; @@ -55,118 +58,50 @@ export default function SqlEditor({ const currentEditorRef = editorRef || ownEditorRef; - React.useEffect(() => { - if (!tabVisible) return; - - setCompleters([]); - addCompleter({ - getCompletions: async function (editor, session, pos, prefix, callback) { - const cursor = session.selection.cursor; - const line = session.getLine(cursor.row).slice(0, cursor.column); - const dbinfo = await getDatabaseInfo({ conid, database }); - - let list = COMMON_KEYWORDS.map((word) => ({ - name: word, - value: word, - caption: word, - meta: 'keyword', - score: 800, - })); - - if (/from\s*([a-zA-Z0-9_]*)?$/i.test(line)) { - if (dbinfo) { - list = [ - ...list, - ...dbinfo.tables.map((x) => ({ - name: x.pureName, - value: x.pureName, - caption: x.pureName, - meta: 'table', - score: 1000, - })), - ...dbinfo.views.map((x) => ({ - name: x.pureName, - value: x.pureName, - caption: x.pureName, - meta: 'view', - score: 1000, - })), - ]; - } - } - - const colMatch = line.match(/([a-zA-Z0-9_]+)\.([a-zA-Z0-9_]*)?$/); - if (colMatch && dbinfo) { - const table = colMatch[1]; - const sources = analyseQuerySources(editor.getValue(), [ - ...dbinfo.tables.map((x) => x.pureName), - ...dbinfo.views.map((x) => x.pureName), - ]); - const source = sources.find((x) => (x.alias || x.name) == table); - if (source) { - const table = dbinfo.tables.find((x) => x.pureName == source.name); - if (table) { - list = [ - ...list, - ...table.columns.map((x) => ({ - name: x.columnName, - value: x.columnName, - caption: x.columnName, - meta: 'column', - score: 1000, - })), - ]; - } - } - } - - callback(null, list); - }, - }); - - const doLiveAutocomplete = function (e) { - const editor = e.editor; - var hasCompleter = editor.completer && editor.completer.activated; - const session = editor.session; - const cursor = session.selection.cursor; - const line = session.getLine(cursor.row).slice(0, cursor.column); - - // We don't want to autocomplete with no prefix - if (e.command.name === 'backspace') { - // do not hide after backspace - } else if (e.command.name === 'insertstring') { - if (!hasCompleter || e.args == '.') { - editor.execCommand('startAutocomplete'); - } - - // if (e.args == ' ' || e.args == '.') { - // if (/from\s*$/i.test(line)) { - // currentEditorRef.current.editor.execCommand('startAutocomplete'); - // } - // } - } - }; - - currentEditorRef.current.editor.commands.on('afterExec', doLiveAutocomplete); - - return () => { - currentEditorRef.current.editor.commands.removeListener('afterExec', doLiveAutocomplete); - }; - }, [tabVisible, conid, database, currentEditorRef.current]); + useCodeCompletion({ + conid, + database, + tabVisible, + currentEditorRef, + }); 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 (keyCode == keycodes.j && event.ctrlKey && !readOnly && tabVisible) { + event.preventDefault(); + const dbinfo = await getDatabaseInfo({ conid, database }); + showModal((modalState) => ( + { + const editor = currentEditorRef.current.editor; + editor.session.insert(editor.getCursorPosition(), text); + }} + /> + )); + } + + if (onKeyDown) onKeyDown(data, hash, keyString, keyCode, event); + }, + [onKeyDown] + ); + React.useEffect(() => { - if (onKeyDown && currentEditorRef.current) { - currentEditorRef.current.editor.keyBinding.addKeyboardHandler(onKeyDown); + if ((onKeyDown || !readOnly) && currentEditorRef.current) { + currentEditorRef.current.editor.keyBinding.addKeyboardHandler(handleKeyDown); } return () => { - currentEditorRef.current.editor.keyBinding.removeKeyboardHandler(onKeyDown); + currentEditorRef.current.editor.keyBinding.removeKeyboardHandler(handleKeyDown); }; - }, [onKeyDown]); + }, [handleKeyDown]); return ( diff --git a/packages/web/src/sqleditor/analyseQuerySources.js b/packages/web/src/sqleditor/analyseQuerySources.js index f01ca9d70..bf19db833 100644 --- a/packages/web/src/sqleditor/analyseQuerySources.js +++ b/packages/web/src/sqleditor/analyseQuerySources.js @@ -19,7 +19,7 @@ export default function analyseQuerySources(sql, sourceNames) { name: word, }); } - if (/^(where)|(inner)|(left)|(right)$/i.test(postWord)) { + if (/^(where)|(inner)|(left)|(right)|(on)$/i.test(postWord)) { continue; } res.push({ diff --git a/packages/web/src/sqleditor/useCodeCompletion.js b/packages/web/src/sqleditor/useCodeCompletion.js new file mode 100644 index 000000000..285b9436e --- /dev/null +++ b/packages/web/src/sqleditor/useCodeCompletion.js @@ -0,0 +1,123 @@ +import React from 'react'; +import { addCompleter, setCompleters } from 'ace-builds/src-noconflict/ext-language_tools'; +import { getDatabaseInfo } from '../utility/metadataLoaders'; +import analyseQuerySources from './analyseQuerySources'; + +const COMMON_KEYWORDS = [ + 'select', + 'where', + 'update', + 'delete', + 'group', + 'order', + 'from', + 'by', + 'create', + 'table', + 'drop', + 'alter', + 'view', + 'execute', + 'procedure', +]; + +export default function useCodeCompletion({ conid, database, tabVisible, currentEditorRef }) { + React.useEffect(() => { + if (!tabVisible) return; + + setCompleters([]); + addCompleter({ + getCompletions: async function (editor, session, pos, prefix, callback) { + const cursor = session.selection.cursor; + const line = session.getLine(cursor.row).slice(0, cursor.column); + const dbinfo = await getDatabaseInfo({ conid, database }); + + let list = COMMON_KEYWORDS.map((word) => ({ + name: word, + value: word, + caption: word, + meta: 'keyword', + score: 800, + })); + + if (/(join)|(from)|(update)|(delete)|(insert)\s*([a-zA-Z0-9_]*)?$/i.test(line)) { + if (dbinfo) { + list = [ + ...list, + ...dbinfo.tables.map((x) => ({ + name: x.pureName, + value: x.pureName, + caption: x.pureName, + meta: 'table', + score: 1000, + })), + ...dbinfo.views.map((x) => ({ + name: x.pureName, + value: x.pureName, + caption: x.pureName, + meta: 'view', + score: 1000, + })), + ]; + } + } + + const colMatch = line.match(/([a-zA-Z0-9_]+)\.([a-zA-Z0-9_]*)?$/); + if (colMatch && dbinfo) { + const table = colMatch[1]; + const sources = analyseQuerySources(editor.getValue(), [ + ...dbinfo.tables.map((x) => x.pureName), + ...dbinfo.views.map((x) => x.pureName), + ]); + const source = sources.find((x) => (x.alias || x.name) == table); + if (source) { + const table = dbinfo.tables.find((x) => x.pureName == source.name); + if (table) { + list = [ + ...list, + ...table.columns.map((x) => ({ + name: x.columnName, + value: x.columnName, + caption: x.columnName, + meta: 'column', + score: 1000, + })), + ]; + } + } + } + + callback(null, list); + }, + }); + + const doLiveAutocomplete = function (e) { + const editor = e.editor; + var hasCompleter = editor.completer && editor.completer.activated; + const session = editor.session; + const cursor = session.selection.cursor; + const line = session.getLine(cursor.row).slice(0, cursor.column); + + // We don't want to autocomplete with no prefix + if (e.command.name === 'backspace') { + // do not hide after backspace + } else if (e.command.name === 'insertstring') { + if (!hasCompleter || e.args == '.') { + editor.execCommand('startAutocomplete'); + } + + // if (e.args == ' ' || e.args == '.') { + // if (/from\s*$/i.test(line)) { + // currentEditorRef.current.editor.execCommand('startAutocomplete'); + // } + // } + } + }; + + currentEditorRef.current.editor.commands.on('afterExec', doLiveAutocomplete); + + return () => { + currentEditorRef.current.editor.commands.removeListener('afterExec', doLiveAutocomplete); + }; + }, [tabVisible, conid, database, currentEditorRef.current]); +} diff --git a/packages/web/src/utility/ObjectListControl.js b/packages/web/src/utility/ObjectListControl.js index 899c2c949..3a2f853ed 100644 --- a/packages/web/src/utility/ObjectListControl.js +++ b/packages/web/src/utility/ObjectListControl.js @@ -37,7 +37,7 @@ export default function ObjectListControl({ collection = [], title, showIfEmpty } + formatter={(col) => } /> {children} diff --git a/packages/web/src/utility/TableControl.js b/packages/web/src/utility/TableControl.js index 8ed20be9a..521de062f 100644 --- a/packages/web/src/utility/TableControl.js +++ b/packages/web/src/utility/TableControl.js @@ -1,15 +1,24 @@ import React from 'react'; import _ from 'lodash'; import styled from 'styled-components'; +import keycodes from './keycodes'; const Table = styled.table` border-collapse: collapse; width: 100%; + user-select: ${(props) => + // @ts-ignore + props.focusable ? 'none' : ''}; + // outline: none; `; const TableHead = styled.thead``; const TableBody = styled.tbody``; const TableHeaderRow = styled.tr``; -const TableBodyRow = styled.tr``; +const TableBodyRow = styled.tr` + background-color: ${(props) => + // @ts-ignore + props.isSelected ? '#ccccff' : ''}; +`; const TableHeaderCell = styled.td` border: 1px solid #e8eef4; background-color: #e8eef4; @@ -30,26 +39,70 @@ function format(row, col) { return row[fieldName]; } -export default function TableControl({ rows = [], children }) { - console.log('children', children); - +export default function TableControl({ + rows = [], + children, + focusOnCreate = false, + onKeyDown = undefined, + tabIndex = -1, + setSelectedIndex = undefined, + selectedIndex = undefined, + tableRef = undefined, +}) { const columns = (children instanceof Array ? _.flatten(children) : [children]) - .filter(child => child && child.props && child.props.fieldName) - .map(child => child.props); + .filter((child) => child && child.props && child.props.fieldName) + .map((child) => child.props); + + const myTableRef = React.useRef(null); + const currentTableRef = tableRef || myTableRef; + + React.useEffect(() => { + if (focusOnCreate) { + currentTableRef.current.focus(); + } + }, []); + + const handleKeyDown = React.useCallback((event) => { + if (event.keyCode == keycodes.downArrow) { + setSelectedIndex((i) => Math.min(i + 1, rows.length - 1)); + } + if (event.keyCode == keycodes.upArrow) { + setSelectedIndex((i) => Math.max(0, i - 1)); + } + if (onKeyDown) onKeyDown(event); + }, []); return ( - +
- {columns.map(x => ( + {columns.map((x) => ( {x.header} ))} {rows.map((row, index) => ( - - {columns.map(col => ( + { + setSelectedIndex(index); + currentTableRef.current.focus(); + } + : undefined + } + > + {columns.map((col) => ( {format(row, col)} ))}