diff --git a/app/assets/components/CodeMirror.tsx b/app/assets/components/CodeMirror.tsx index 36864e7e..8f8d56c5 100644 --- a/app/assets/components/CodeMirror.tsx +++ b/app/assets/components/CodeMirror.tsx @@ -40,7 +40,7 @@ export default class ReactCodeMirror extends React.PureComponent<IProps, any> { if (stream.eatSpace()) { return null; } - stream.eatWhile(/[\$\w\u4e00-\u9fa5]/); + stream.eatWhile(/[\$:\w\u4e00-\u9fa5]/); const cur = stream.current(); if (keyWords.some(item => item === cur)) { return 'keyword'; @@ -127,6 +127,7 @@ export default class ReactCodeMirror extends React.PureComponent<IProps, any> { change.preventDefault(); } }; + codemirrorValueChange = (doc, change) => { if (change.origin !== 'setValue') { if (this.props.onChange) { diff --git a/app/assets/components/OutputBox/index.tsx b/app/assets/components/OutputBox/index.tsx index cf59cd9e..8fa774be 100644 --- a/app/assets/components/OutputBox/index.tsx +++ b/app/assets/components/OutputBox/index.tsx @@ -85,12 +85,22 @@ class OutputBox extends React.Component<IProps> { }; render() { const { value, result = {} } = this.props; - let columns = []; + let columns = [] as any; let showSubgraphs = false; - const dataSource = - result.data && result.data.tables ? result.data.tables : []; + let dataSource = [] as any; + if (result.data && result.data.tables.length > 0) { + dataSource = result.data.tables; + } else if (result.data?.localParams) { + const params = {}; + { + Object.entries(result.data?.localParams).forEach( + ([k, v]) => (params[k] = JSON.stringify(v)), + ); + } + dataSource = [{ ...params }]; + } if (result.code === 0) { - if (result.data && result.data.headers) { + if (result.data && result.data.headers.length > 0) { columns = result.data.headers.map(column => { return { title: column, @@ -121,6 +131,24 @@ class OutputBox extends React.Component<IProps> { item._edgesParsedList || item._pathsParsedList, ).length > 0; + } else if (result.data?.localParams) { + columns = Object.keys(result.data?.localParams).map(column => { + return { + title: column, + dataIndex: column, + render: value => { + if (typeof value === 'boolean') { + return value.toString(); + } else if ( + typeof value === 'number' || + BigNumber.isBigNumber(value) + ) { + return value.toString(); + } + return value; + }, + }; + }); } } return ( diff --git a/app/assets/config/locale/en-US.json b/app/assets/config/locale/en-US.json index fa336ed0..4f50862e 100644 --- a/app/assets/config/locale/en-US.json +++ b/app/assets/config/locale/en-US.json @@ -127,7 +127,8 @@ "exportVertex": "Please choose the column representing vertex IDs from the table", "exportEdge": "Please choose the columns representing source vertex ID, destination vertex ID, and rank of an edge", "showSubgraphs": "View Subgraphs", - "deleteHistory": "Clear History" + "deleteHistory": "Clear History", + "parameterDisplay": "Custom Parameters Display" }, "explore": { "clear": "Clear", diff --git a/app/assets/config/locale/zh-CN.json b/app/assets/config/locale/zh-CN.json index fd4f2b20..9742d2b5 100644 --- a/app/assets/config/locale/zh-CN.json +++ b/app/assets/config/locale/zh-CN.json @@ -126,7 +126,8 @@ "exportVertex": "请选择表中代表点VID的列", "exportEdge": "请选择结果中分别代表边的起点(src_vid)、终点(dst_vid)和权重(rank)的列", "showSubgraphs": "查看子图", - "deleteHistory": "清除历史" + "deleteHistory": "清除历史", + "parameterDisplay": "自定义参数 展示" }, "explore": { "clear": "清除", diff --git a/app/assets/config/nebulaQL.ts b/app/assets/config/nebulaQL.ts index 547536a8..950fdbaf 100644 --- a/app/assets/config/nebulaQL.ts +++ b/app/assets/config/nebulaQL.ts @@ -133,6 +133,9 @@ const nebulaWordsUppercase = [ 'USERS', 'UUID', 'VALUES', + 'COMMENT', + ':PARAM', + ':PARAMS', ]; export const ban = ['use', 'USE']; @@ -217,6 +220,8 @@ export const operators = [ 'MINUS', // uuid 'uuid', + // assignment + '=>', ]; const nebulaWordsLowercase = nebulaWordsUppercase.map(w => w.toLowerCase()); diff --git a/app/assets/modules/Console/index.less b/app/assets/modules/Console/index.less index ef66e093..4dee001a 100644 --- a/app/assets/modules/Console/index.less +++ b/app/assets/modules/Console/index.less @@ -16,10 +16,22 @@ flex-direction: column; align-items: center; - > .CodeMirror { + .mirror-content { width: 100%; + display: flex; + align-items: center; + + .btn-drawer { + cursor: pointer; + } + + > .CodeMirror { + min-height: 240px; + width: 100%; + } } + .mirror-nav { display: flex; align-items: center; @@ -79,7 +91,7 @@ flex: 1; display: flex; flex-direction: column; - margin: 32px 0; + margin: 15px 0; } } @@ -124,3 +136,18 @@ } } +.param-box { + padding: 10px; + background-color: white; + height: 100%; + margin-left: 5px; + max-width: 320px; + overflow: auto; + max-height: 240px; + + p { + font-size: 12px; + word-break: break-all; + margin-bottom: 8px; + } +} diff --git a/app/assets/modules/Console/index.tsx b/app/assets/modules/Console/index.tsx index 2e040ead..961e6682 100644 --- a/app/assets/modules/Console/index.tsx +++ b/app/assets/modules/Console/index.tsx @@ -15,17 +15,20 @@ import SpaceSearchInput from './SpaceSearchInput'; interface IState { isUpDown: boolean; history: boolean; + visible: boolean; } const mapState = (state: IRootState) => ({ result: state._console.result, currentGQL: state._console.currentGQL, + paramsMap: state._console.paramsMap, currentSpace: state.nebula.currentSpace, runGQLLoading: state.loading.effects._console.asyncRunGQL, }); const mapDispatch = (dispatch: IDispatch) => ({ asyncRunGQL: dispatch._console.asyncRunGQL, + asyncGetParams: dispatch._console.asyncGetParams, updateCurrentGQL: gql => dispatch._console.update({ currentGQL: gql, @@ -41,6 +44,8 @@ interface IProps ReturnType<typeof mapDispatch>, RouteComponentProps {} +// split from semicolon out of quotation marks +const SEMICOLON_REG = /((?:[^;'"]*(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*')[^;'"]*)+)|;/; class Console extends React.Component<IProps, IState> { codemirror; editor; @@ -51,11 +56,13 @@ class Console extends React.Component<IProps, IState> { this.state = { isUpDown: true, history: false, + visible: false, }; } componentDidMount() { trackPageView('/console'); + this.props.asyncGetParams(); } getLocalStorage = () => { @@ -66,31 +73,35 @@ class Console extends React.Component<IProps, IState> { return []; }; + handleSaveQuery = (query: string) => { + if (query !== '') { + const history = this.getLocalStorage(); + history.push(query); + localStorage.setItem('history', JSON.stringify(history)); + } + }; + + checkSwitchSpaceGql = (query: string) => { + const queryList = query.split(SEMICOLON_REG).filter(Boolean); + const reg = /^USE `?[0-9a-zA-Z_]+`?(?=[\s*;?]?)/gim; + if (queryList.some(sentence => sentence.trim().match(reg))) { + return intl.get('common.disablesUseToSwitchSpace'); + } + }; handleRun = async () => { - const gql = this.editor.getValue(); - if (!gql) { + const query = this.editor.getValue(); + if (!query) { message.error(intl.get('common.sorryNGQLCannotBeEmpty')); return; } - // Hack: - // replace the string in quotes with a constant, avoid special symbols in quotation marks from affecting regex match - // then split the gql entered by the user into sentences based on semicolons - // use regex to determine whether each sentence is a 'use space' statement - const _gql = gql - .replace(/[\r\n]/g, '') - .replace(/(["'])[^]*?\1/g, '_CONTENT_') - .toUpperCase(); - const sentenceList = _gql.split(';'); - const reg = /^USE `?[0-9a-zA-Z_]+`?(?=[\s*;?]?)/gm; - if (sentenceList.some(sentence => sentence.match(reg))) { - return message.error(intl.get('common.disablesUseToSwitchSpace')); + const errInfo = this.checkSwitchSpaceGql(query); + if (errInfo) { + return message.error(errInfo); } - this.editor.execCommand('goDocEnd'); - const history = this.getLocalStorage(); - history.push(gql); - localStorage.setItem('history', JSON.stringify(history)); - await this.props.asyncRunGQL(gql); + this.editor.execCommand('goDocEnd'); + this.handleSaveQuery(query); + await this.props.asyncRunGQL(query); this.setState({ isUpDown: true, }); @@ -147,9 +158,22 @@ class Console extends React.Component<IProps, IState> { return str.substring(0, 300) + '...'; }; + toggleDrawer = () => { + const { visible } = this.state; + this.setState({ + visible: !visible, + }); + }; + render() { const { isUpDown, history } = this.state; - const { currentSpace, currentGQL, result, runGQLLoading } = this.props; + const { + currentSpace, + currentGQL, + result, + runGQLLoading, + paramsMap, + } = this.props; return ( <div className="nebula-console padding-page"> <div className="ngql-content"> @@ -191,19 +215,38 @@ class Console extends React.Component<IProps, IState> { </Tooltip> </div> </div> - <CodeMirror - value={currentGQL} - onBlur={value => this.props.updateCurrentGQL(value)} - onChangeLine={this.handleLineCount} - ref={this.getInstance} - height={isUpDown ? '240px' : 24 * maxLineNum + 'px'} - onShiftEnter={this.handleRun} - options={{ - keyMap: 'sublime', - fullScreen: true, - mode: 'nebula', - }} - /> + <div className="mirror-content"> + <Tooltip + title={intl.get('console.parameterDisplay')} + placement="right" + > + <Icon + type="file-search" + className="btn-drawer" + onClick={this.toggleDrawer} + /> + </Tooltip> + {this.state.visible && ( + <div className="param-box"> + {Object.entries(paramsMap).map(([k, v]) => ( + <p key={k}>{`${k} => ${JSON.stringify(v)}`}</p> + ))} + </div> + )} + <CodeMirror + value={currentGQL} + onBlur={value => this.props.updateCurrentGQL(value)} + onChangeLine={this.handleLineCount} + ref={this.getInstance} + height={isUpDown ? '240px' : 24 * maxLineNum + 'px'} + onShiftEnter={this.handleRun} + options={{ + keyMap: 'sublime', + fullScreen: true, + mode: 'nebula', + }} + /> + </div> </div> </div> <div className="result-wrap"> diff --git a/app/assets/store/models/console.ts b/app/assets/store/models/console.ts index 99c65942..ad9713ff 100644 --- a/app/assets/store/models/console.ts +++ b/app/assets/store/models/console.ts @@ -3,14 +3,37 @@ import { createModel } from '@rematch/core'; import service from '#assets/config/service'; interface IState { - version: string; + currentGQL: string; + result: any; + paramsMap: any; } +// split from semicolon out of quotation marks +const SEMICOLON_REG = /((?:[^;'"]*(?:"(?:\\.|[^"])*"|'(?:\\.|[^'])*')[^;'"]*)+)|;/; +const splitQuery = (query: string) => { + const queryList = query.split(SEMICOLON_REG).filter(Boolean); + const paramList: string[] = []; + const gqlList: string[] = []; + queryList.forEach(query => { + const _query = query.trim(); + if (_query.startsWith(':')) { + paramList.push(_query); + } else { + gqlList.push(_query); + } + }); + return { + paramList, + gqlList, + }; +}; + export const _console = createModel({ state: { currentGQL: 'SHOW SPACES;', result: {}, - }, + paramsMap: null, + } as IState, reducers: { update: (state: IState, payload: any) => { return { @@ -21,8 +44,12 @@ export const _console = createModel({ }, effects: { async asyncRunGQL(gql) { + const { gqlList, paramList } = splitQuery(gql); const result = (await service.execNGQL( - { gql }, + { + gql: gqlList.join(';'), + paramList, + }, { trackEventConfig: { category: 'console', @@ -30,10 +57,34 @@ export const _console = createModel({ }, }, )) as any; + const updateQuerys = paramList.filter(item => { + const reg = /^\s*:params/gim; + return !reg.test(item); + }); + if (updateQuerys.length > 0) { + await this.asyncGetParams(); + } this.update({ result, currentGQL: gql, }); }, + async asyncGetParams() { + const result = (await service.execNGQL( + { + gql: '', + paramList: [':params'], + }, + { + trackEventConfig: { + category: 'console', + action: 'run_gql', + }, + }, + )) as any; + this.update({ + paramsMap: result.data?.localParams || {}, + }); + }, }, });