diff --git a/web/package.json b/web/package.json index aa28f05da..fafedd090 100644 --- a/web/package.json +++ b/web/package.json @@ -11,6 +11,7 @@ "@babel/eslint-parser": "^7.12.13", "@babel/eslint-plugin": "^7.12.13", "@babel/plugin-proposal-object-rest-spread": "^7.10.1", + "@babel/plugin-syntax-jsx": "^7.16.0", "@babel/preset-env": "^7.10.2", "@babel/preset-typescript": "^7.8.3", "@emotion/core": "^10.0.14", @@ -18,6 +19,7 @@ "@emotion/react": "^11.1.5", "@emotion/styled": "^10.0.14", "@emotion/utils": "^1.0.0", + "@svgr/webpack": "^5.5.0", "@wojtekmaj/enzyme-adapter-react-17": "^0.4.1", "autoprefixer": "^10.2.4", "axios-mock-adapter": "^1.17.0", @@ -42,7 +44,7 @@ "is-docker": "^2.1.1", "jasmine-core": "^3.6.0", "jasmine-enzyme": "^7.1.2", - "karma": "^6.3.2", + "karma": "^6.3.15", "karma-babel-preprocessor": "^8.0.0", "karma-browserify": "^8.0.0", "karma-chrome-launcher": "^3.1.0", @@ -63,7 +65,7 @@ "sass-resources-loader": "^2.2.1", "style-loader": "^2.0.0", "stylis": "^4.0.7", - "svgo": "^1.1.1", + "svgo": "^2.7.0", "svgo-loader": "^2.2.0", "terser-webpack-plugin": "^5.1.1", "typescript": "^3.2.2", @@ -86,11 +88,13 @@ "@material-ui/pickers": "^3.2.10", "@projectstorm/react-diagrams": "^6.6.1", "@simonwep/pickr": "^1.5.1", + "@szhsin/react-menu": "^2.2.0", "@tippyjs/react": "^4.2.0", "@types/classnames": "^2.2.6", "@types/react": "^16.7.18", "@types/react-dom": "^16.0.11", "acitree": "git+https://github.com/imsurinder90/jquery-aciTree.git#rc.7", + "ajv": "^8.8.2", "alertifyjs": "git+https://github.com/EnterpriseDB/AlertifyJS/#72c1d794f5b6d4ec13a68d123c08f19021afe263", "aspen-decorations": "^1.0.2", "axios": "^0.21.1", @@ -103,12 +107,14 @@ "bootstrap": "^4.3.1", "bootstrap-datepicker": "^1.8.0", "bootstrap4-toggle": "^3.6.1", + "brace": "^0.11.1", "browserfs": "^1.4.3", "chart.js": "^2.9.3", "classnames": "^2.2.6", "closest": "^0.0.1", "codemirror": "^5.59.2", "context-menu": "^2.0.0", + "copy-to-clipboard": "^3.3.1", "css-loader": "^5.0.1", "cssnano": "^5.0.2", "dagre": "^0.8.4", @@ -126,6 +132,7 @@ "jquery-ui": "^1.13.0", "json-bignumber": "^1.0.1", "jsoneditor": "^9.5.4", + "jsoneditor-react": "^3.1.1", "karma-coverage": "^2.0.3", "leaflet": "^1.5.1", "lodash": "4.*", @@ -141,6 +148,7 @@ "pgadmin4-tree": "git+https://github.com/EnterpriseDB/pgadmin4-treeview/#bf7ac7be65898883e3e05c9733426152a1da6422", "postcss": "^8.2.15", "raf": "^3.4.1", + "rc-dock": "^3.2.9", "react": "^17.0.1", "react-aspen": "^1.1.0", "react-checkbox-tree": "^1.7.2", @@ -148,6 +156,7 @@ "react-draggable": "^4.4.4", "react-select": "^4.2.1", "react-table": "^7.6.3", + "react-timer-hook": "^3.0.5", "react-virtualized-auto-sizer": "^1.0.6", "react-window": "^1.8.5", "select2": "^4.0.13", diff --git a/web/pgadmin/browser/static/js/node_view.jsx b/web/pgadmin/browser/static/js/node_view.jsx index eb8eeb386..b9ec79e90 100644 --- a/web/pgadmin/browser/static/js/node_view.jsx +++ b/web/pgadmin/browser/static/js/node_view.jsx @@ -47,14 +47,24 @@ export function getNodeView(nodeType, treeNodeInfo, actionType, itemNodeData, fo /* Called when dialog is opened in edit mode, promise required */ let initData = ()=>new Promise((resolve, reject)=>{ - api.get(url(false)) - .then((res)=>{ - resolve(res.data); - }) - .catch((err)=>{ - onError(err); - reject(err); - }); + if(actionType === 'create') { + resolve({}); + } else { + api.get(url(false)) + .then((res)=>{ + resolve(res.data); + }) + .catch((err)=>{ + if(err.response){ + console.error('error resp', err.response); + } else if(err.request){ + console.error('error req', err.request); + } else if(err.message){ + console.error('error msg', err.message); + } + reject(err); + }); + } }); /* on save button callback, promise required */ diff --git a/web/pgadmin/static/img/cleaning_services_black.svg b/web/pgadmin/static/img/cleaning_services_black.svg new file mode 100644 index 000000000..43348f080 --- /dev/null +++ b/web/pgadmin/static/img/cleaning_services_black.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/pgadmin/static/img/content_paste.svg b/web/pgadmin/static/img/content_paste.svg new file mode 100644 index 000000000..5d39a51a2 --- /dev/null +++ b/web/pgadmin/static/img/content_paste.svg @@ -0,0 +1 @@ + diff --git a/web/pgadmin/static/img/filter_alt_black.svg b/web/pgadmin/static/img/filter_alt_black.svg new file mode 100644 index 000000000..b00a570cc --- /dev/null +++ b/web/pgadmin/static/img/filter_alt_black.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/pgadmin/static/img/fonticon/commit.svg b/web/pgadmin/static/img/fonticon/commit.svg index b38409158..26f7d40bc 100644 --- a/web/pgadmin/static/img/fonticon/commit.svg +++ b/web/pgadmin/static/img/fonticon/commit.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/web/pgadmin/static/img/fonticon/format_case.svg b/web/pgadmin/static/img/fonticon/format_case.svg new file mode 100644 index 000000000..aa1b4f74c --- /dev/null +++ b/web/pgadmin/static/img/fonticon/format_case.svg @@ -0,0 +1 @@ + diff --git a/web/pgadmin/static/img/fonticon/regex.svg b/web/pgadmin/static/img/fonticon/regex.svg new file mode 100644 index 000000000..8be677bbb --- /dev/null +++ b/web/pgadmin/static/img/fonticon/regex.svg @@ -0,0 +1 @@ + diff --git a/web/pgadmin/static/img/fonticon/rollback.svg b/web/pgadmin/static/img/fonticon/rollback.svg index 8d7b9bb17..b0dbc508f 100644 --- a/web/pgadmin/static/img/fonticon/rollback.svg +++ b/web/pgadmin/static/img/fonticon/rollback.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/web/pgadmin/static/js/SchemaView/FormView.jsx b/web/pgadmin/static/js/SchemaView/FormView.jsx index 3d90f03e0..8c8e5b9fb 100644 --- a/web/pgadmin/static/js/SchemaView/FormView.jsx +++ b/web/pgadmin/static/js/SchemaView/FormView.jsx @@ -186,6 +186,9 @@ export default function FormView({ if(field.depChange) { depListener.addDepListener(source, accessPath.concat(field.id), field.depChange); } + if(field.depChange || field.deferredDepChange) { + depListener.addDepListener(source, accessPath.concat(field.id), field.depChange, field.deferredDepChange); + } }); }); return ()=>{ @@ -288,7 +291,7 @@ export default function FormView({ if(visible && !disabled && !firstEleID.current) { firstEleID.current = field.id; } - + tabs[group].push( useMemo(()=>{ diff --git a/web/pgadmin/static/js/SchemaView/MappedControl.jsx b/web/pgadmin/static/js/SchemaView/MappedControl.jsx index c0ff2bb10..bce913961 100644 --- a/web/pgadmin/static/js/SchemaView/MappedControl.jsx +++ b/web/pgadmin/static/js/SchemaView/MappedControl.jsx @@ -11,7 +11,7 @@ import React, { useCallback } from 'react'; import _ from 'lodash'; import { FormInputText, FormInputSelect, FormInputSwitch, FormInputCheckbox, FormInputColor, - FormInputFileSelect, FormInputToggle, InputSwitch, FormInputSQL, FormNote, FormInputDateTimePicker, PlainString, + FormInputFileSelect, FormInputToggle, InputSwitch, FormInputSQL, FormNote, FormInputDateTimePicker, PlainString, InputSQL, InputSelect, InputText, InputCheckbox, InputDateTimePicker } from '../components/FormComponents'; import Privilege from '../components/Privilege'; import { evalFunc } from 'sources/utils'; @@ -111,6 +111,10 @@ function MappedCellControlBase({cell, value, id, optionsLoaded, onCellChange, vi onCellChange && onCellChange(val); }, []); + const onSqlChange = useCallback((val) => { + onCellChange && onCellChange(val); + }, []); + /* Some grid cells are based on options selected in other cells. * lets trigger a re-render for the row if optionsLoaded */ @@ -146,6 +150,8 @@ function MappedCellControlBase({cell, value, id, optionsLoaded, onCellChange, vi return ; case 'datetimepicker': return ; + case 'sql': + return ; default: return ; } diff --git a/web/pgadmin/static/js/SchemaView/base_schema.ui.js b/web/pgadmin/static/js/SchemaView/base_schema.ui.js index 68badeced..256a67f22 100644 --- a/web/pgadmin/static/js/SchemaView/base_schema.ui.js +++ b/web/pgadmin/static/js/SchemaView/base_schema.ui.js @@ -16,7 +16,7 @@ export default class BaseUISchema { constructor(defaults) { /* Pass the initial data to constructor so that they will set to defaults */ - this._defaults = defaults; + this._defaults = defaults || {}; this.keys = null; // If set, other fields except keys will be filtered this.filterGroups = []; // If set, these groups will be filtered out diff --git a/web/pgadmin/static/js/SchemaView/index.jsx b/web/pgadmin/static/js/SchemaView/index.jsx index 11f2a0849..8b20d5f5d 100644 --- a/web/pgadmin/static/js/SchemaView/index.jsx +++ b/web/pgadmin/static/js/SchemaView/index.jsx @@ -487,42 +487,34 @@ function SchemaDialogView({ }, [sessData.__deferred__?.length]); useEffect(()=>{ + let unmounted = false; /* Docker on load focusses itself, so our focus should execute later */ let focusTimeout = setTimeout(()=>{ firstEleRef.current && firstEleRef.current.focus(); }, 250); - /* Re-triggering focus on already focussed loses the focus */ - if(viewHelperProps.mode === 'edit') { - setLoaderText('Loading...'); - /* If its an edit mode, get the initial data using getInitData - getInitData should be a promise */ - if(!getInitData) { - throw new Error('getInitData must be passed for edit'); + setLoaderText('Loading...'); + /* Get the initial data using getInitData */ + /* If its an edit mode, getInitData should be present and a promise */ + if(!getInitData && viewHelperProps.mode === 'edit') { + throw new Error('getInitData must be passed for edit'); + } + let initDataPromise = (getInitData && getInitData()) || Promise.resolve({}); + initDataPromise.then((data)=>{ + if(unmounted) { + return; } - getInitData && getInitData().then((data)=>{ - data = data || {}; + data = data || {}; + if(viewHelperProps.mode === 'edit') { /* Set the origData to incoming data, useful for comparing and reset */ schema.origData = prepareData(data || {}); - schema.initialise(schema.origData); - sessDispatch({ - type: SCHEMA_STATE_ACTIONS.INIT, - payload: schema.origData, - }); - setFormReady(true); - setLoaderText(''); - }).catch((err)=>{ - setLoaderText(''); - if (err.response && err.response.data && err.response.data.errormsg) { - Notify.alert( - gettext(err.response.statusText), - gettext(err.response.data.errormsg) - ); - } - }); - } else { - /* Use the defaults as the initital data */ - schema.origData = prepareData(schema.defaults, true); + } else { + /* In create mode, merge with defaults */ + schema.origData = prepareData({ + ...schema.defaults, + ...data, + }, true); + } schema.initialise(schema.origData); sessDispatch({ type: SCHEMA_STATE_ACTIONS.INIT, @@ -530,10 +522,23 @@ function SchemaDialogView({ }); setFormReady(true); setLoaderText(''); - } - + }).catch((err)=>{ + if(unmounted) { + return; + } + setLoaderText(''); + if (err.response && err.response.data && err.response.data.errormsg) { + Notify.alert( + gettext(err.response.statusText), + gettext(err.response.data.errormsg) + ); + } + }); /* Clear the focus timeout if unmounted */ - return ()=>clearTimeout(focusTimeout); + return ()=>{ + unmounted = true; + clearTimeout(focusTimeout); + }; }, []); useEffect(()=>{ @@ -700,7 +705,7 @@ function SchemaDialogView({ + hasSQLTab={props.hasSQL} getSQLValue={getSQLValue} firstEleRef={firstEleRef} isTabView={isTabView} className={props.formClassName} /> @@ -754,6 +759,7 @@ SchemaDialogView.propTypes = { resetKey: PropTypes.any, customSaveBtnName: PropTypes.string, customSaveBtnIconType: PropTypes.string, + formClassName: CustomPropTypes.className, }; const usePropsStyles = makeStyles((theme)=>({ diff --git a/web/pgadmin/static/js/Theme/index.jsx b/web/pgadmin/static/js/Theme/index.jsx index 265690ab4..e133693a7 100644 --- a/web/pgadmin/static/js/Theme/index.jsx +++ b/web/pgadmin/static/js/Theme/index.jsx @@ -72,12 +72,12 @@ basicSettings = createMuiTheme(basicSettings, { }, MuiButton: { root: { - textTransform: 'none,', + textTransform: 'none', padding: basicSettings.spacing(0.5, 1.5), '&.Mui-disabled': { - opacity: 0.65, + opacity: 0.60, }, - '&.MuiButton-outlinedSizeSmall': { + '&.MuiButton-sizeSmall, &.MuiButton-outlinedSizeSmall, &.MuiButton-containedSizeSmall': { height: '28px', fontSize: '0.875rem', '& .MuiSvgIcon-root': { @@ -111,7 +111,7 @@ basicSettings = createMuiTheme(basicSettings, { resize: 'vertical', }, adornedEnd: { - paddingRight: basicSettings.spacing(1.5), + paddingRight: basicSettings.spacing(0.75), } }, MuiAccordion: { @@ -184,6 +184,16 @@ basicSettings = createMuiTheme(basicSettings, { root: { fontSize: 14, } + }, + MuiSelect: { + selectMenu: { + minHeight: 'unset', + }, + select:{ + '&:focus':{ + backgroundColor: 'unset', + } + } } }, transitions: { @@ -220,7 +230,7 @@ basicSettings = createMuiTheme(basicSettings, { }, MuiListItem: { disableGutters: true, - } + }, }, }); @@ -321,7 +331,10 @@ function getFinalTheme(baseTheme) { color: baseTheme.palette.text.muted, backgroundColor: baseTheme.otherVars.inputDisabledBg, }, - } + '&:focus': { + outline: '0 !important', + } + }, }, MuiIconButton: { root: { diff --git a/web/pgadmin/static/js/Theme/standard.js b/web/pgadmin/static/js/Theme/standard.js index d87394b8c..ddc9cd735 100644 --- a/web/pgadmin/static/js/Theme/standard.js +++ b/web/pgadmin/static/js/Theme/standard.js @@ -92,13 +92,16 @@ export default function(basicSettings) { inputBorderColor: '#dde0e6', inputDisabledBg: '#f3f5f9', headerBg: '#fff', + activeBorder: '#326690', activeColor: '#326690', tableBg: '#fff', activeStepBg: '#326690', activeStepFg: '#FFFFFF', stepBg: '#ddd', stepFg: '#000', - toggleBtnBg: '#000' + toggleBtnBg: '#000', + editorToolbarBg: '#ebeef3', + datagridBg: '#fff', } }); } diff --git a/web/pgadmin/static/js/api_instance.js b/web/pgadmin/static/js/api_instance.js index 9aea50d18..30b9aa3e8 100644 --- a/web/pgadmin/static/js/api_instance.js +++ b/web/pgadmin/static/js/api_instance.js @@ -28,7 +28,7 @@ export function parseApiError(error) { // The request was made and the server responded with a status code // that falls out of the range of 2xx if(error.response.headers['content-type'] == 'application/json') { - return error.response.data.errormsg; + return `INTERNAL SERVER ERROR: ${error.response.data.errormsg}`; } else { return error.response.statusText; } @@ -37,8 +37,10 @@ export function parseApiError(error) { // `error.request` is an instance of XMLHttpRequest in the browser and an instance of // http.ClientRequest in node.js return gettext('Connection to pgAdmin server has been lost'); - } else { + } else if(error.message) { // Something happened in setting up the request that triggered an Error return error.message; + } else { + return error; } } diff --git a/web/pgadmin/static/js/components/Buttons.jsx b/web/pgadmin/static/js/components/Buttons.jsx index b28bbec81..3eaf7c155 100644 --- a/web/pgadmin/static/js/components/Buttons.jsx +++ b/web/pgadmin/static/js/components/Buttons.jsx @@ -7,20 +7,16 @@ // ////////////////////////////////////////////////////////////// -import { Button, makeStyles, Tooltip } from '@material-ui/core'; +import { Button, ButtonGroup, makeStyles, Tooltip } from '@material-ui/core'; import React, { forwardRef } from 'react'; import clsx from 'clsx'; import PropTypes from 'prop-types'; import CustomPropTypes from '../custom_prop_types'; +import ShortcutTitle from './ShortcutTitle'; const useStyles = makeStyles((theme)=>({ primaryButton: { - '&.MuiButton-outlinedSizeSmall': { - height: '28px', - '& .MuiSvgIcon-root': { - height: '0.8em', - } - }, + border: '1px solid '+theme.palette.primary.main, '&.Mui-disabled': { color: theme.palette.primary.contrastText, backgroundColor: theme.palette.primary.disabledMain, @@ -35,12 +31,6 @@ const useStyles = makeStyles((theme)=>({ color: theme.palette.default.contrastText, border: '1px solid '+theme.palette.default.borderColor, whiteSpace: 'nowrap', - '&.MuiButton-outlinedSizeSmall': { - height: '28px', - '& .MuiSvgIcon-root': { - height: '0.8em', - } - }, '&.Mui-disabled': { color: theme.palette.default.disabledContrastText, borderColor: theme.palette.default.disabledBorderColor @@ -53,6 +43,11 @@ const useStyles = makeStyles((theme)=>({ }, iconButton: { padding: '3px 6px', + '&.MuiButton-sizeSmall, &.MuiButton-outlinedSizeSmall, &.MuiButton-containedSizeSmall': { + padding: '1px 4px', + }, + }, + iconButtonDefault: { borderColor: theme.custom.icon.borderColor, color: theme.custom.icon.contrastText, backgroundColor: theme.custom.icon.main, @@ -64,7 +59,15 @@ const useStyles = makeStyles((theme)=>({ '&:hover': { backgroundColor: theme.custom.icon.hoverMain, color: theme.custom.icon.hoverContrastText, - } + }, + }, + splitButton: { + '&.MuiButton-sizeSmall, &.MuiButton-outlinedSizeSmall, &.MuiButton-containedSizeSmall': { + width: '20px', + '& svg': { + height: '0.8em', + } + }, }, xsButton: { padding: '2px 1px', @@ -89,7 +92,7 @@ export const PrimaryButton = forwardRef((props, ref)=>{ } noBorder && allClassName.push(classes.noBorder); return ( - + ); }); PrimaryButton.displayName = 'PrimaryButton'; @@ -111,7 +114,7 @@ export const DefaultButton = forwardRef((props, ref)=>{ } noBorder && allClassName.push(classes.noBorder); return ( - + ); }); DefaultButton.displayName = 'DefaultButton'; @@ -122,24 +125,77 @@ DefaultButton.propTypes = { className: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), }; + /* pgAdmin Icon button, takes Icon component as input */ -export const PgIconButton = forwardRef(({icon, title, className, ...props}, ref)=>{ +export const PgIconButton = forwardRef(({icon, title, shortcut, accessKey, className, splitButton, style, color, ...props}, ref)=>{ const classes = useStyles(); + let shortcutTitle = null; + if(accessKey || shortcut) { + shortcutTitle = ; + } + /* Tooltip does not work for disabled items */ - return ( - - - + if(props.disabled) { + if(color == 'primary') { + return ( + + {icon} + + ); + } else { + return ( + {icon} - - - ); + ); + } + } else { + if(color == 'primary') { + return ( + + + {icon} + + + ); + } else { + return ( + + + {icon} + + + ); + } + } }); PgIconButton.displayName = 'PgIconButton'; PgIconButton.propTypes = { icon: CustomPropTypes.children, title: PropTypes.string.isRequired, + shortcut: CustomPropTypes.shortcut, + accessKey: PropTypes.string, className: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + style: PropTypes.object, + color: PropTypes.oneOf(['primary', 'default', undefined]), + disabled: PropTypes.bool, + splitButton: PropTypes.bool, +}; + +export const PgButtonGroup = forwardRef(({children, ...props}, ref)=>{ + /* Tooltip does not work for disabled items */ + return ( + + {children} + + ); +}); +PgButtonGroup.displayName = 'PgButtonGroup'; +PgButtonGroup.propTypes = { + children: CustomPropTypes.children, }; diff --git a/web/pgadmin/static/js/components/CodeMirror.jsx b/web/pgadmin/static/js/components/CodeMirror.jsx index de5e41b0c..7e3c0cdda 100644 --- a/web/pgadmin/static/js/components/CodeMirror.jsx +++ b/web/pgadmin/static/js/components/CodeMirror.jsx @@ -7,11 +7,258 @@ // ////////////////////////////////////////////////////////////// -import React, { useEffect, useMemo, useRef } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import {default as OrigCodeMirror} from 'bundled_codemirror'; import {useOnScreen} from 'sources/custom_hooks'; import PropTypes from 'prop-types'; import CustomPropTypes from '../custom_prop_types'; +import pgAdmin from 'sources/pgadmin'; +import gettext from 'sources/gettext'; +import { Box, InputAdornment, makeStyles } from '@material-ui/core'; +import clsx from 'clsx'; +import { InputText } from './FormComponents'; +import { PgIconButton } from './Buttons'; +import CloseIcon from '@material-ui/icons/CloseRounded'; +import ArrowDownwardRoundedIcon from '@material-ui/icons/ArrowDownwardRounded'; +import ArrowUpwardRoundedIcon from '@material-ui/icons/ArrowUpwardRounded'; +import SwapHorizRoundedIcon from '@material-ui/icons/SwapHorizRounded'; +import SwapCallsRoundedIcon from '@material-ui/icons/SwapCallsRounded'; +import _ from 'lodash'; +import { RegexIcon, FormatCaseIcon } from './ExternalIcon'; +import { isMac } from '../keyboard_shortcuts'; + +const useStyles = makeStyles((theme)=>({ + root: { + position: 'relative', + }, + findDialog: { + position: 'absolute', + zIndex: 99, + right: '4px', + ...theme.mixins.panelBorder.all, + borderTop: 'none', + padding: '2px 4px', + width: '250px', + backgroundColor: theme.palette.background.default, + }, + marginTop: { + marginTop: '0.25rem', + } +})); + +function parseString(string) { + return string.replace(/\\([nrt\\])/g, function(match, ch) { + if (ch == 'n') return '\n'; + if (ch == 'r') return '\r'; + if (ch == 't') return '\t'; + if (ch == '\\') return '\\'; + return match; + }); +} + +function parseQuery(query, useRegex=false, matchCase=false) { + if (useRegex) { + query = new RegExp(query, matchCase ? 'g': 'gi'); + } else { + query = parseString(query); + if(!matchCase) { + query = query.toLowerCase(); + } + } + if (typeof query == 'string' ? query == '' : query.test('')) + query = /x^/; + return query; +} + +function searchOverlay(query, matchCase) { + return { + token: typeof query == 'string' ? + (stream) =>{ + var matchIndex = (matchCase ? stream.string : stream.string.toLowerCase()).indexOf(query, stream.pos); + if(matchIndex == -1) { + stream.skipToEnd(); + } else if(matchIndex == stream.pos) { + stream.pos += query.length; + return 'searching'; + } else { + stream.pos = matchIndex; + } + } : (stream) => { + query.lastIndex = stream.pos; + var match = query.exec(stream.string); + if (match && match.index == stream.pos) { + stream.pos += match[0].length || 1; + return 'searching'; + } else if (match) { + stream.pos = match.index; + } else { + stream.skipToEnd(); + } + } + }; +} + +export const CodeMirrorInstancType = PropTypes.shape({ + getCursor: PropTypes.func, + getSearchCursor: PropTypes.func, + removeOverlay: PropTypes.func, + addOverlay: PropTypes.func, + setSelection: PropTypes.func, + scrollIntoView: PropTypes.func, +}); + +export function FindDialog({editor, show, replace, onClose}) { + const [findVal, setFindVal] = useState(''); + const [replaceVal, setReplaceVal] = useState(''); + const [useRegex, setUseRegex] = useState(false); + const [matchCase, setMatchCase] = useState(false); + const findInputRef = useRef(); + const highlightsearch = useRef(); + const searchCursor = useRef(); + const classes = useStyles(); + + const search = ()=>{ + if(editor) { + let query = parseQuery(findVal, useRegex, matchCase); + searchCursor.current = editor.getSearchCursor(query, editor.getCursor(true), !matchCase); + if(findVal != '') { + editor.removeOverlay(highlightsearch.current); + highlightsearch.current = searchOverlay(query, matchCase); + editor.addOverlay(highlightsearch.current); + onFindNext(); + } else { + editor.removeOverlay(highlightsearch.current); + } + } + }; + + useEffect(()=>{ + if(show) { + findInputRef.current && findInputRef.current.select(); + search(); + } + }, [show]); + + useEffect(()=>{ + search(); + }, [findVal, useRegex, matchCase]); + + const clearAndClose = ()=>{ + editor.removeOverlay(highlightsearch.current); + onClose(); + }; + + const toggle = (name)=>{ + if(name == 'regex') { + setUseRegex((prev)=>!prev); + } else if(name == 'case') { + setMatchCase((prev)=>!prev); + } + }; + + const onFindEnter = (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + if(e.shiftKey) { + onFindPrev(); + } else { + onFindNext(); + } + } + }; + + const onReplaceEnter = (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + onReplace(); + } + }; + + const onEscape = (e)=>{ + if (e.key === 'Escape') { + e.preventDefault(); + clearAndClose(); + } + }; + + const onFindNext = ()=>{ + if(searchCursor.current && searchCursor.current.find()) { + editor.setSelection(searchCursor.current.from(), searchCursor.current.to()); + editor.scrollIntoView({ + from: searchCursor.current.from(), + to: searchCursor.current.to() + }, 20); + } + }; + + const onFindPrev = ()=>{ + if(searchCursor.current && searchCursor.current.find(true)) { + editor.setSelection(searchCursor.current.from(), searchCursor.current.to()); + editor.scrollIntoView({ + from: searchCursor.current.from(), + to: searchCursor.current.to() + }, 20); + } + }; + + const onReplace = ()=>{ + searchCursor.current.replace(replaceVal); + onFindNext(); + }; + + const onReplaceAll = ()=>{ + while(searchCursor.current.from()) { + onReplace(); + } + }; + + if(!editor) { + return <>; + } + + return ( + + {findInputRef.current = ele;}} + onChange={(value)=>setFindVal(value)} + onKeyPress={onFindEnter} + endAdornment={ + + } size="xs" noBorder + onClick={()=>toggle('case')} color={matchCase ? 'primary' : 'default'} style={{marginRight: '2px'}}/> + } size="xs" noBorder + onClick={()=>toggle('regex')} color={useRegex ? 'primary' : 'default'}/> + + } + /> + {replace && + setReplaceVal(value)} + onKeyPress={onReplaceEnter} + />} + + + } size="xs" noBorder onClick={onFindPrev} /> + } size="xs" noBorder onClick={onFindNext}/> + {replace && <> + } size="xs" noBorder onClick={onReplace} /> + } size="xs" noBorder onClick={onReplaceAll}/> + } + + } size="xs" noBorder onClick={clearAndClose}/> + + + + ); +} + +FindDialog.propTypes = { + editor: CodeMirrorInstancType, + show: PropTypes.bool, + replace: PropTypes.bool, + onClose: PropTypes.func, +}; /* React wrapper for CodeMirror */ export default function CodeMirror({currEditor, name, value, options, events, readonly, disabled, className}) { @@ -19,13 +266,34 @@ export default function CodeMirror({currEditor, name, value, options, events, re const editor = useRef(); const cmWrapper = useRef(); const isVisibleTrack = useRef(); + const classes = useStyles(); + const [[showFind, isReplace], setShowFind] = useState([false, false]); + const defaultOptions = { + tabindex: '0', + lineNumbers: true, + styleSelectedText: true, + mode: 'text/x-pgsql', + foldOptions: { + widget: '\u2026', + }, + foldGutter: true, + gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'], + extraKeys: pgAdmin.Browser.editor_shortcut_keys, + dragDrop: false, + screenReaderLabel: gettext('SQL editor'), + }; useEffect(()=>{ + const finalOptions = {...defaultOptions, ...options}; /* Create the object only once on mount */ editor.current = new OrigCodeMirror.fromTextArea( - taRef.current, options); + taRef.current, finalOptions); - editor.current.setValue(value); + if(!_.isEmpty(value)) { + editor.current.setValue(value); + } else { + editor.current.setValue(''); + } currEditor && currEditor(editor.current); if(editor.current) { try { @@ -33,11 +301,33 @@ export default function CodeMirror({currEditor, name, value, options, events, re } catch(e) { cmWrapper.current = null; } + + let findKey = 'Ctrl-F', replaceKey = 'Shift-Ctrl-F'; + if(isMac()) { + findKey = 'Cmd-F'; + replaceKey = 'Cmd-Alt-F'; + } + editor.current.addKeyMap({ + [findKey]: ()=>{ + setShowFind([false, false]); + setShowFind([true, false]); + }, + [replaceKey]: ()=>{ + if(!finalOptions.readOnly) { + setShowFind([false, false]); + setShowFind([true, true]); + } + } + }); } Object.keys(events||{}).forEach((eventName)=>{ editor.current.on(eventName, events[eventName]); }); + + return ()=>{ + editor.current?.toTextArea(); + }; }, []); useEffect(()=>{ @@ -62,7 +352,11 @@ export default function CodeMirror({currEditor, name, value, options, events, re useMemo(() => { if(editor.current) { if(value != editor.current.getValue()) { - editor.current.setValue(value); + if(!_.isEmpty(value)) { + editor.current.setValue(value); + } else { + editor.current.setValue(''); + } } } }, [value]); @@ -75,8 +369,16 @@ export default function CodeMirror({currEditor, name, value, options, events, re isVisibleTrack.current = false; } + const closeFind = ()=>{ + setShowFind([false, false]); + editor.current?.focus(); + }; + return ( -