Allow users to convert View/Edit table into a Query tool to enable editing the SQL generated. #5908
parent
4db13facf7
commit
04580652ab
|
@ -222,3 +222,18 @@ To delete a row from the grid, click the trash icon.
|
|||
:maxdepth: 2
|
||||
|
||||
viewdata_filter
|
||||
|
||||
|
||||
|
||||
Promote View/Edit Data to Query Tool
|
||||
************************************
|
||||
|
||||
A View/Edit Data tab can be converted to a Query Tool Tab just by editing the query. Once you start editing, it will ask if you really want to move away from View/Edit.
|
||||
|
||||
.. image:: images/promote_view_edit_data_warning.png
|
||||
:alt: Promote View/Edit Data tab to Query Tool tab warning
|
||||
:align: center
|
||||
|
||||
You can disable the dialog by selecting the "Don't Ask again" checkbox. If you wish to resume the confirmation dialog, you can do it from "Prefrences -> Query Tool -> Editor -> Show View/Edit Data Promotion Warning?"
|
||||
|
||||
Once you chose to continue, you won't be able to use the features of View/Edit mode like the filter and sorting options, limit, etc. It is a one-way conversion. It will be a query tool now.
|
Binary file not shown.
After Width: | Height: | Size: 99 KiB |
|
@ -20,7 +20,7 @@ from flask_security import login_required
|
|||
from pgadmin.utils import PgAdminModule
|
||||
from pgadmin.utils.ajax import success_return, \
|
||||
make_response as ajax_response, internal_server_error
|
||||
from pgadmin.utils.menu import MenuItem
|
||||
from pgadmin.utils.ajax import make_json_response
|
||||
from pgadmin.utils.preferences import Preferences
|
||||
from pgadmin.utils.constants import MIMETYPE_APP_JS
|
||||
from pgadmin.browser.server_groups import ServerGroupModule as sgm
|
||||
|
@ -47,7 +47,9 @@ class PreferencesModule(PgAdminModule):
|
|||
return [
|
||||
'preferences.index',
|
||||
'preferences.get_by_name',
|
||||
'preferences.get_all'
|
||||
'preferences.get_all',
|
||||
'preferences.update_pref'
|
||||
|
||||
]
|
||||
|
||||
|
||||
|
@ -245,3 +247,30 @@ def save():
|
|||
**domain)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@blueprint.route("/update", methods=["PUT"], endpoint="update_pref")
|
||||
@login_required
|
||||
def update():
|
||||
"""
|
||||
Update a specific preference.
|
||||
"""
|
||||
pref_data = get_data()
|
||||
pref_data = json.loads(pref_data['pref_data'])
|
||||
|
||||
for data in pref_data:
|
||||
if data['name'] in ['vw_edt_tab_title_placeholder',
|
||||
'qt_tab_title_placeholder',
|
||||
'debugger_tab_title_placeholder'] \
|
||||
and data['value'].isspace():
|
||||
data['value'] = ''
|
||||
|
||||
pref_module = Preferences.module(data['module'])
|
||||
pref = pref_module.preference(data['name'])
|
||||
# set user preferences
|
||||
pref.set(data['value'])
|
||||
|
||||
return make_json_response(
|
||||
data={'data': 'Success'},
|
||||
status=200
|
||||
)
|
||||
|
|
|
@ -13,6 +13,12 @@ const usePreferences = create((set, get)=>({
|
|||
get().data, {'module': module, 'name': preference}
|
||||
);
|
||||
},
|
||||
setPreference: (data)=> {
|
||||
// Update Preferences and then refresh cache.
|
||||
getApiInstance().put(url_for('preferences.update_pref'), data).then(()=> {
|
||||
preferenceChangeBroadcast.postMessage('refresh');
|
||||
});
|
||||
},
|
||||
getPreferencesForModule: function(module) {
|
||||
let preferences = {};
|
||||
_.forEach(
|
||||
|
@ -62,6 +68,9 @@ export function setupPreferenceBroadcast() {
|
|||
if(ev.data == 'sync') {
|
||||
broadcast(usePreferences.getState());
|
||||
}
|
||||
if(ev.data == 'refresh') {
|
||||
usePreferences.getState().cache();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -18,10 +18,22 @@
|
|||
"url": "/preferences/",
|
||||
"is_positive_test": true,
|
||||
"mocking_required": false,
|
||||
"update_spec_pref": false,
|
||||
"mock_data": {},
|
||||
"expected_data": {
|
||||
"status_code": 200
|
||||
}
|
||||
},{
|
||||
"name": "Update specific preference",
|
||||
"url": "/preferences/update_pref",
|
||||
"is_positive_test": true,
|
||||
"mocking_required": false,
|
||||
"mock_data": {},
|
||||
"update_spec_pref": true,
|
||||
"expected_data": {
|
||||
"status_code": 200
|
||||
}
|
||||
}
|
||||
|
||||
]
|
||||
}
|
||||
|
|
|
@ -38,6 +38,9 @@ class GetPreferencesTest(BaseTestGenerator):
|
|||
parent_node_dict['preferences'] = response.data
|
||||
|
||||
def runTest(self):
|
||||
if self.update_spec_pref:
|
||||
self.update_preference()
|
||||
else:
|
||||
self.update_preferences()
|
||||
|
||||
def update_preferences(self):
|
||||
|
@ -58,3 +61,12 @@ class GetPreferencesTest(BaseTestGenerator):
|
|||
self.assertTrue(response.status_code, 200)
|
||||
else:
|
||||
self.fail('Preferences not found')
|
||||
|
||||
def update_preference(self):
|
||||
updated_data = [{'name': 'view_edit_promotion_warning',
|
||||
'value': False,
|
||||
'module': 'sqleditor'}]
|
||||
response = self.tester.put(self.url,
|
||||
data=json.dumps(updated_data),
|
||||
content_type='html/json')
|
||||
self.assertTrue(response.status_code, 200)
|
||||
|
|
|
@ -271,7 +271,7 @@ function ModalContainer({ id, title, content, dialogHeight, dialogWidth, onClose
|
|||
return;
|
||||
}
|
||||
useModalRef.closeModal(id);
|
||||
if(reason == 'escapeKeyDown') {
|
||||
if(reason == 'escapeKeyDown' || reason == undefined) {
|
||||
onClose?.();
|
||||
}
|
||||
};
|
||||
|
|
|
@ -415,7 +415,11 @@ def _connect(conn, **kwargs):
|
|||
|
||||
def _init_sqleditor(trans_id, connect, sgid, sid, did, dbname=None, **kwargs):
|
||||
# Create asynchronous connection using random connection id.
|
||||
conn_id = str(secrets.choice(range(1, 9999999)))
|
||||
conn_id = kwargs['conn_id'] if 'conn_id' in kwargs else str(
|
||||
secrets.choice(range(1, 9999999)))
|
||||
if 'conn_id' in kwargs:
|
||||
kwargs.pop('conn_id')
|
||||
|
||||
conn_id_ac = str(secrets.choice(range(1, 9999999)))
|
||||
|
||||
manager = get_driver(PG_DEFAULT_DRIVER).connection_manager(sid)
|
||||
|
@ -425,7 +429,7 @@ def _init_sqleditor(trans_id, connect, sgid, sid, did, dbname=None, **kwargs):
|
|||
try:
|
||||
command_obj = ObjectRegistry.get_object(
|
||||
'query_tool', conn_id=conn_id, sgid=sgid, sid=sid, did=did,
|
||||
conn_id_ac=conn_id_ac
|
||||
conn_id_ac=conn_id_ac, **kwargs
|
||||
)
|
||||
except Exception as e:
|
||||
current_app.logger.error(e)
|
||||
|
@ -868,6 +872,24 @@ def start_query_tool(trans_id):
|
|||
)
|
||||
|
||||
connect = 'connect' in request.args and request.args['connect'] == '1'
|
||||
if 'gridData' in session and str(trans_id) in session['gridData']:
|
||||
data = pickle.loads(session['gridData'][str(trans_id)]['command_obj'])
|
||||
if data.object_type == 'table':
|
||||
manager = get_driver(PG_DEFAULT_DRIVER).connection_manager(
|
||||
data.sid)
|
||||
default_conn = manager.connection(conn_id=data.conn_id,
|
||||
did=data.did)
|
||||
kwargs = {
|
||||
'user': default_conn.manager.user,
|
||||
'role': default_conn.manager.role,
|
||||
'password': default_conn.manager.password,
|
||||
'conn_id': data.conn_id
|
||||
}
|
||||
is_error, errmsg, conn_id, version = _init_sqleditor(
|
||||
trans_id, connect, data.sgid, data.sid, data.did, **kwargs)
|
||||
|
||||
if is_error:
|
||||
return errmsg
|
||||
|
||||
return StartRunningQuery(blueprint, current_app.logger).execute(
|
||||
sql, trans_id, session, connect
|
||||
|
|
|
@ -102,6 +102,7 @@ export default function QueryToolComponent({params, pgWindow, pgAdmin, selectedN
|
|||
title: _.unescape(params.title),
|
||||
is_query_tool: params.is_query_tool == 'true' ? true : false,
|
||||
node_name: retrieveNodeName(selectedNodeInfo),
|
||||
dbname: _.unescape(params.database_name) || getDatabaseLabel(selectedNodeInfo)
|
||||
},
|
||||
connection_list: [{
|
||||
sgid: params.sgid,
|
||||
|
@ -746,7 +747,37 @@ export default function QueryToolComponent({params, pgWindow, pgAdmin, selectedN
|
|||
modal: modal,
|
||||
params: qtState.params,
|
||||
preferences: qtState.preferences,
|
||||
mainContainerRef: containerRef
|
||||
mainContainerRef: containerRef,
|
||||
toggleQueryTool: () => setQtState((prev)=>{
|
||||
return {
|
||||
...prev,
|
||||
params: {
|
||||
...prev.params,
|
||||
is_query_tool: true
|
||||
}
|
||||
};
|
||||
}),
|
||||
updateTitle: (title) => {
|
||||
setPanelTitle(qtPanelDocker, qtPanelId, title, qtState, isDirtyRef.current);
|
||||
setQtState((prev) => {
|
||||
// Update connection Title
|
||||
let newConnList = [...prev.connection_list];
|
||||
newConnList.forEach((conn) => {
|
||||
if (conn.sgid == params.sgid && conn.sid == params.sid && conn.did == params.did) {
|
||||
conn.title = title;
|
||||
conn.conn_title = title;
|
||||
}
|
||||
});
|
||||
return {
|
||||
...prev,
|
||||
params: {
|
||||
...prev.params,
|
||||
title: title
|
||||
},
|
||||
connection_list: newConnList,
|
||||
};
|
||||
});
|
||||
},
|
||||
}), [qtState.params, qtState.preferences, containerRef.current]);
|
||||
|
||||
const queryToolConnContextValue = React.useMemo(()=>({
|
||||
|
|
|
@ -29,6 +29,7 @@ export const QUERY_TOOL_EVENTS = {
|
|||
|
||||
COPY_DATA: 'COPY_DATA',
|
||||
SET_LIMIT_VALUE: 'SET_LIMIT_VALUE',
|
||||
PROMOTE_TO_QUERY_TOOL: 'PROMOTE_TO_QUERY_TOOL',
|
||||
SET_CONNECTION_STATUS: 'SET_CONNECTION_STATUS',
|
||||
EXECUTION_START: 'EXECUTION_START',
|
||||
EXECUTION_END: 'EXECUTION_END',
|
||||
|
|
|
@ -0,0 +1,63 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useModalStyles } from '../../../../../../static/js/helpers/ModalProvider';
|
||||
import gettext from 'sources/gettext';
|
||||
import { Box, makeStyles } from '@material-ui/core';
|
||||
import { DefaultButton, PrimaryButton } from '../../../../../../static/js/components/Buttons';
|
||||
import CloseIcon from '@material-ui/icons/CloseRounded';
|
||||
import HTMLReactParser from 'html-react-parser';
|
||||
import PropTypes from 'prop-types';
|
||||
import CheckRounded from '@material-ui/icons/CheckRounded';
|
||||
import { InputCheckbox } from '../../../../../../static/js/components/FormComponents';
|
||||
|
||||
|
||||
const useStyles = makeStyles(() => ({
|
||||
saveChoice: {
|
||||
margin: '10px 0 10px 10px',
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
export default function ConfirmPromotionContent({ onContinue, onClose, closeModal, text }) {
|
||||
const [formData, setFormData] = useState({
|
||||
save_user_choice: false
|
||||
});
|
||||
|
||||
const onDataChange = (e, id) => {
|
||||
let val = e;
|
||||
if (e?.target) {
|
||||
val = e.target.value;
|
||||
}
|
||||
setFormData((prev) => ({ ...prev, [id]: val }));
|
||||
};
|
||||
const modalClasses = useModalStyles();
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<Box display="flex" flexDirection="column" height="100%">
|
||||
<Box flexGrow="1" p={2}>{typeof (text) == 'string' ? HTMLReactParser(text) : text}</Box>
|
||||
<Box className={classes.saveChoice}>
|
||||
<InputCheckbox controlProps={{ label: gettext('Don\'t ask again') }} value={formData['save_user_choice']}
|
||||
onChange={(e) => onDataChange(e.target.checked, 'save_user_choice')} />
|
||||
</Box>
|
||||
<Box className={modalClasses.footer}>
|
||||
<DefaultButton data-test="close" startIcon={<CloseIcon />} onClick={() => {
|
||||
onClose?.();
|
||||
closeModal();
|
||||
}} >{gettext('Cancel')}</DefaultButton>
|
||||
<PrimaryButton data-test="Continue" className={modalClasses.margin} startIcon={<CheckRounded />} onClick={() => {
|
||||
let postFormData = new FormData();
|
||||
postFormData.append('pref_data', JSON.stringify([{ 'name': 'view_edit_promotion_warning', 'value': !formData.save_user_choice, 'module': 'sqleditor' }]));
|
||||
onContinue?.(postFormData);
|
||||
closeModal();
|
||||
}} autoFocus={true} >{gettext('Continue')}</PrimaryButton>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
ConfirmPromotionContent.propTypes = {
|
||||
closeModal: PropTypes.func,
|
||||
text: PropTypes.string,
|
||||
onContinue: PropTypes.func,
|
||||
onClose: PropTypes.func
|
||||
};
|
|
@ -269,6 +269,16 @@ export function MainToolBar({containerRef, onFilterClick, onManageMacros}) {
|
|||
eventBus.registerListener(QUERY_TOOL_EVENTS.SET_LIMIT_VALUE, (l)=>{
|
||||
setLimit(l);
|
||||
});
|
||||
|
||||
eventBus.registerListener(QUERY_TOOL_EVENTS.PROMOTE_TO_QUERY_TOOL, ()=>{
|
||||
setDisableButton('filter', true);
|
||||
setDisableButton('limit', true);
|
||||
|
||||
setDisableButton('execute', false);
|
||||
setDisableButton('execute-options', false);
|
||||
});
|
||||
|
||||
|
||||
}, []);
|
||||
|
||||
useEffect(()=>{
|
||||
|
|
|
@ -20,6 +20,10 @@ import { isMac } from '../../../../../../static/js/keyboard_shortcuts';
|
|||
import { checkTrojanSource } from '../../../../../../static/js/utils';
|
||||
import { parseApiError } from '../../../../../../static/js/api_instance';
|
||||
import { usePgAdmin } from '../../../../../../static/js/BrowserComponent';
|
||||
import ConfirmPromotionContent from '../dialogs/ConfirmPromotionContent';
|
||||
import usePreferences from '../../../../../../preferences/static/js/store';
|
||||
import { getTitle } from '../../sqleditor_title';
|
||||
|
||||
|
||||
const useStyles = makeStyles(()=>({
|
||||
sql: {
|
||||
|
@ -246,6 +250,7 @@ export default function Query() {
|
|||
const markedLine = React.useRef(0);
|
||||
const marker = React.useRef();
|
||||
const pgAdmin = usePgAdmin();
|
||||
const preferencesStore = usePreferences();
|
||||
|
||||
const removeHighlightError = (cmObj)=>{
|
||||
// Remove already existing marker
|
||||
|
@ -340,7 +345,7 @@ export default function Query() {
|
|||
query = query || editor.current?.getValue() || '';
|
||||
}
|
||||
if(query) {
|
||||
eventBus.fireEvent(QUERY_TOOL_EVENTS.EXECUTION_START, query, explainObject, external);
|
||||
eventBus.fireEvent(QUERY_TOOL_EVENTS.EXECUTION_START, query, explainObject, external, null);
|
||||
}
|
||||
} else {
|
||||
eventBus.fireEvent(QUERY_TOOL_EVENTS.EXECUTION_START, null, null);
|
||||
|
@ -427,6 +432,9 @@ export default function Query() {
|
|||
});
|
||||
eventBus.registerListener(QUERY_TOOL_EVENTS.EDITOR_SET_SQL, (value, focus=true)=>{
|
||||
focus && editor.current?.focus();
|
||||
if(!queryToolCtx.params.is_query_tool){
|
||||
lastSavedText.current = value;
|
||||
}
|
||||
editor.current?.setValue(value);
|
||||
if (value == '' && editor.current) {
|
||||
editor.current.state.autoCompleteList = [];
|
||||
|
@ -470,7 +478,7 @@ export default function Query() {
|
|||
};
|
||||
eventBus.registerListener(QUERY_TOOL_EVENTS.EDITOR_LAST_FOCUS, lastFocus);
|
||||
setTimeout(()=>{
|
||||
editor.current.focus();
|
||||
(queryToolCtx.params.is_query_tool|| queryToolCtx.preferences.view_edit_promotion_warning) && editor.current.focus();
|
||||
}, 250);
|
||||
}, []);
|
||||
|
||||
|
@ -507,7 +515,7 @@ export default function Query() {
|
|||
);
|
||||
}, [queryToolCtx.params.trans_id]);
|
||||
|
||||
const isDirty = ()=>(queryToolCtx.params.is_query_tool && lastSavedText.current !== editor.current.getValue());
|
||||
const isDirty = ()=>(lastSavedText.current !== editor.current.getValue());
|
||||
|
||||
const cursorActivity = useCallback(_.debounce((cmObj)=>{
|
||||
const c = cmObj.getCursor();
|
||||
|
@ -517,8 +525,58 @@ export default function Query() {
|
|||
|
||||
const change = useCallback(()=>{
|
||||
eventBus.fireEvent(QUERY_TOOL_EVENTS.QUERY_CHANGED, isDirty());
|
||||
|
||||
if(!queryToolCtx.params.is_query_tool && isDirty()){
|
||||
if(queryToolCtx.preferences.sqleditor.view_edit_promotion_warning){
|
||||
checkViewEditDataPromotion();
|
||||
} else {
|
||||
promoteToQueryTool();
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const closePromotionWarning = (closeModal)=>{
|
||||
if(isDirty()) {
|
||||
editor.current.undo();
|
||||
closeModal?.();
|
||||
}
|
||||
};
|
||||
|
||||
const checkViewEditDataPromotion = () => {
|
||||
queryToolCtx.modal.showModal(gettext('Promote to Query Tool'), (closeModal) =>{
|
||||
return (<ConfirmPromotionContent
|
||||
closeModal={closeModal}
|
||||
text={'Manually editing the query will cause this View/Edit Data tab to be converted to a Query Tool tab. You will be able to edit the query text freely, but no longer be able to use the toolbar buttons for sorting and filtering data. </br> Do you wish to continue?'}
|
||||
onContinue={(formData)=>{
|
||||
promoteToQueryTool();
|
||||
let cursor = editor.current.getCursor();
|
||||
editor.current.setValue(editor.current.getValue());
|
||||
editor.current.setCursor(cursor);
|
||||
editor.current.focus();
|
||||
let title = getTitle(pgAdmin, queryToolCtx.preferences.browser, null,null,queryToolCtx.params.server_name, queryToolCtx.params.dbname, queryToolCtx.params.user);
|
||||
queryToolCtx.updateTitle(title);
|
||||
preferencesStore.setPreference(formData);
|
||||
return true;
|
||||
}}
|
||||
onClose={()=>{
|
||||
closePromotionWarning(closeModal);
|
||||
}}
|
||||
/>);
|
||||
}, {
|
||||
onClose:()=>{
|
||||
closePromotionWarning();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const promoteToQueryTool = () => {
|
||||
if(!queryToolCtx.params.is_query_tool){
|
||||
queryToolCtx.toggleQueryTool();
|
||||
queryToolCtx.params.is_query_tool = true;
|
||||
eventBus.fireEvent(QUERY_TOOL_EVENTS.PROMOTE_TO_QUERY_TOOL);
|
||||
}
|
||||
};
|
||||
|
||||
return <CodeMirror
|
||||
currEditor={(obj)=>{
|
||||
editor.current=obj;
|
||||
|
@ -530,7 +588,6 @@ export default function Query() {
|
|||
'cursorActivity': cursorActivity,
|
||||
'change': change,
|
||||
}}
|
||||
disabled={!queryToolCtx.params.is_query_tool}
|
||||
autocomplete={true}
|
||||
/>;
|
||||
}
|
||||
|
|
|
@ -182,7 +182,7 @@ export class ResultSetUtils {
|
|||
}
|
||||
|
||||
async startExecution(query, explainObject, onIncorrectSQL, flags={
|
||||
isQueryTool: true, external: false, reconnect: false,
|
||||
isQueryTool: true, external: false, reconnect: false
|
||||
}) {
|
||||
let startTime = new Date();
|
||||
this.eventBus.fireEvent(QUERY_TOOL_EVENTS.SET_MESSAGE, '');
|
||||
|
|
|
@ -47,7 +47,7 @@ function hasServerInformations(parentData) {
|
|||
return parentData.server === undefined;
|
||||
}
|
||||
|
||||
function generateTitle(pgBrowser, treeIdentifier) {
|
||||
export function generateTitle(pgBrowser, treeIdentifier) {
|
||||
return getPanelTitle(pgBrowser, treeIdentifier);
|
||||
}
|
||||
|
||||
|
|
|
@ -181,6 +181,17 @@ def register_query_tool_preferences(self):
|
|||
)
|
||||
)
|
||||
|
||||
self.view_edit_promotion_warning = self.preference.register(
|
||||
'Editor', 'view_edit_promotion_warning',
|
||||
gettext("Show View/Edit Data Promotion Warning?"),
|
||||
'boolean', True,
|
||||
category_label=PREF_LABEL_OPTIONS,
|
||||
help_str=gettext(
|
||||
'If set to True, View/Edit Data tool will show promote to '
|
||||
'Query tool confirm dialog on query edit.'
|
||||
)
|
||||
)
|
||||
|
||||
self.csv_quoting = self.preference.register(
|
||||
'CSV_output', 'csv_quoting',
|
||||
gettext("CSV quoting"), 'options', 'strings',
|
||||
|
|
|
@ -66,12 +66,14 @@ class StartRunningQuery:
|
|||
manager = get_driver(
|
||||
PG_DEFAULT_DRIVER).connection_manager(
|
||||
transaction_object.sid)
|
||||
conn = manager.connection(did=transaction_object.did,
|
||||
database=transaction_object.dbname,
|
||||
conn = manager.connection(
|
||||
did=transaction_object.did,
|
||||
conn_id=self.connection_id,
|
||||
auto_reconnect=False,
|
||||
use_binary_placeholder=True,
|
||||
array_to_string=True)
|
||||
array_to_string=True,
|
||||
**({"database": transaction_object.dbname} if hasattr(
|
||||
transaction_object,'dbname') else {}))
|
||||
except (ConnectionLost, SSHTunnelConnectionLost, CryptKeyMissing):
|
||||
raise
|
||||
except Exception as e:
|
||||
|
@ -126,6 +128,7 @@ class StartRunningQuery:
|
|||
def __execute_query(self, conn, session_obj, sql, trans_id, trans_obj):
|
||||
# on successful connection set the connection id to the
|
||||
# transaction object
|
||||
if hasattr(trans_obj, 'set_connection_id'):
|
||||
trans_obj.set_connection_id(self.connection_id)
|
||||
|
||||
StartRunningQuery.save_transaction_in_session(session_obj,
|
||||
|
|
|
@ -31,6 +31,7 @@ const fromTextAreaRet = {
|
|||
'scrollIntoView': jest.fn(),
|
||||
'getWrapperElement': ()=>document.createElement('div'),
|
||||
'on': jest.fn(),
|
||||
'off': jest.fn(),
|
||||
'toTextArea': jest.fn(),
|
||||
};
|
||||
module.exports = {
|
||||
|
|
Loading…
Reference in New Issue