[feature] Init sql editor (#11)
* [feature] init sql editor
diff --git a/frontend/package.json b/frontend/package.json
index 3d04969..b0b96d5 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -19,10 +19,12 @@
"@ant-design/pro-list": "^1.17.7",
"@ant-design/pro-table": "^2.56.7",
"@babel/core": "^7.12.10",
+ "@monaco-editor/react": "^4.3.1",
"ahooks": "^3.1.10",
"antd": "^4.16.11",
"axios": "^0.24.0",
"babel-loader": "^8.2.2",
+ "bignumber.js": "^9.0.2",
"buffer": "^6.0.3",
"cross-env": "^7.0.3",
"crypto-browserify": "^3.12.0",
@@ -32,6 +34,7 @@
"highlight.js": "^11.0.1",
"i18next": "^20.4.0",
"i18next-browser-languagedetector": "^6.1.2",
+ "immer": "^9.0.12",
"js-cookie": "^3.0.1",
"lodash-es": "^4.17.21",
"password-generator": "^2.3.2",
@@ -45,6 +48,7 @@
"react-i18next": "^11.11.4",
"react-router-cache-route": "^1.12.1",
"react-router-dom": "^5.2.0",
+ "react-spring": "^9.4.4",
"recoil": "^0.5.0",
"stream-browserify": "^3.0.0",
"sweetalert2": "^11.1.9",
@@ -52,6 +56,7 @@
"swr": "^0.5.6",
"ttag": "1.7.15",
"typescript": "^4.3.5",
+ "use-immer": "^0.6.0",
"webpack": "^5.11.0",
"webpack-merge": "^5.7.3"
},
@@ -93,7 +98,7 @@
"postcss-flexbugs-fixes": "^5.0.2",
"postcss-loader": "^6.1.1",
"postcss-normalize": "^10.0.0",
- "postcss-preset-env": "^6.7.0",
+ "postcss-preset-env": "^7.4.3",
"prettier": "^2.3.2",
"react-router-config": "^5.1.1",
"style-loader": "^2.0.0",
diff --git a/frontend/src/routes/container.tsx b/frontend/src/routes/container.tsx
index 4849d8a..6792a7d 100644
--- a/frontend/src/routes/container.tsx
+++ b/frontend/src/routes/container.tsx
@@ -37,6 +37,7 @@
import { Cluster } from './cluster/cluster';
import { UserSetting } from './user-setting';
import { useUserInfo } from '@src/hooks/use-userinfo.hooks';
+import VisualQuery from './visual-query/visual-query';
export function Container(props: any) {
const [userInfo] = useUserInfo();
const history = useHistory();
@@ -62,6 +63,7 @@
<Route path="/query" component={Query} />
<Route path="/details/:queryId" component={QueryDetails} />
<Route path="/user-setting" component={UserSetting} />
+ <Route path="/visual-query" component={VisualQuery} />
<Redirect to="/cluster" />
</Switch>
</div>
diff --git a/frontend/src/routes/visual-query/components/loading-layout/index.tsx b/frontend/src/routes/visual-query/components/loading-layout/index.tsx
new file mode 100644
index 0000000..2cea538
--- /dev/null
+++ b/frontend/src/routes/visual-query/components/loading-layout/index.tsx
@@ -0,0 +1,28 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import React, { PropsWithChildren } from 'react';
+import ScreenLoading from './screen-loading';
+
+interface LoadingLayoutProps {
+ loading: boolean;
+}
+
+export default function LoadingLayout(props: PropsWithChildren<LoadingLayoutProps>) {
+ const { loading, children } = props;
+ return <>{loading ? <ScreenLoading /> : children}</>;
+}
diff --git a/frontend/src/routes/visual-query/components/loading-layout/screen-loading.tsx b/frontend/src/routes/visual-query/components/loading-layout/screen-loading.tsx
new file mode 100644
index 0000000..573e2c6
--- /dev/null
+++ b/frontend/src/routes/visual-query/components/loading-layout/screen-loading.tsx
@@ -0,0 +1,28 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import React from 'react';
+import { Spin } from 'antd';
+import styles from './style.module.less';
+
+export default function ScreenLoading() {
+ return (
+ <div className={styles.loadingWrapper}>
+ <Spin />
+ </div>
+ );
+}
diff --git a/frontend/src/routes/visual-query/components/loading-layout/style.module.less b/frontend/src/routes/visual-query/components/loading-layout/style.module.less
new file mode 100644
index 0000000..e90e951
--- /dev/null
+++ b/frontend/src/routes/visual-query/components/loading-layout/style.module.less
@@ -0,0 +1,27 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+.loadingWrapper {
+ position: fixed;
+ display: flex;
+ top: 65px;
+ left: 0;
+ width: 100%;
+ height: 80%;
+ align-items: center;
+ justify-content: center;
+}
diff --git a/frontend/src/routes/visual-query/components/save-dashboard-modal/index.module.less b/frontend/src/routes/visual-query/components/save-dashboard-modal/index.module.less
new file mode 100644
index 0000000..39a2ce6
--- /dev/null
+++ b/frontend/src/routes/visual-query/components/save-dashboard-modal/index.module.less
@@ -0,0 +1,20 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+.cascaderInput {
+ width: 100%;
+}
diff --git a/frontend/src/routes/visual-query/components/save-dashboard-modal/index.tsx b/frontend/src/routes/visual-query/components/save-dashboard-modal/index.tsx
new file mode 100644
index 0000000..000826f
--- /dev/null
+++ b/frontend/src/routes/visual-query/components/save-dashboard-modal/index.tsx
@@ -0,0 +1,75 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import React, { useEffect } from 'react';
+import { useImmer } from 'use-immer';
+import { Modal, Form, Cascader } from 'antd';
+import styles from './index.module.less';
+import LoadingLayout from '@src/components/loading-layout';
+import { CascaderItem } from '../../types';
+import { processCollections } from '../../utils';
+import { fetchChildOfCollectionAPI } from '../../visual-query.api';
+
+interface SaveDashboardModalProps {
+ visible: boolean;
+ loading?: boolean;
+ collections: CascaderItem[];
+ setSaveDashboardModalVisible: (v: boolean) => void;
+}
+
+export default function SaveDashboardModal(props: SaveDashboardModalProps) {
+ const { visible, loading = false, collections: defaultCollections, setSaveDashboardModalVisible } = props;
+ const [collections, setCollections] = useImmer([...processCollections(defaultCollections)]);
+
+ useEffect(() => {
+ setCollections([...processCollections(defaultCollections)]);
+ }, [defaultCollections]);
+
+ const [form] = Form.useForm();
+
+ const handleCancel = () => {
+ setSaveDashboardModalVisible(false);
+ };
+
+ const loadData = (path: any[]) => {
+ const currentCollection = path[path.length - 1];
+ currentCollection.loading = true;
+ fetchChildOfCollectionAPI({
+ collectionId: currentCollection.id,
+ model: 'dashboard',
+ }).then(res => {
+ // Todo
+ });
+ };
+
+ return (
+ <Modal visible={visible} onCancel={handleCancel} title="把查询添加到仪表盘">
+ <LoadingLayout loading={loading} wrapperStyle={{ textAlign: 'center' }}>
+ <Form form={form} autoComplete="off" labelCol={{ span: 5 }} wrapperCol={{ span: 17 }}>
+ <Form.Item
+ name="dashboard"
+ label="仪表盘"
+ required
+ rules={[{ required: true, message: '请选择仪表盘' }]}
+ >
+ <Cascader options={collections} fieldNames={{ value: 'id' }} loadData={loadData} />
+ </Form.Item>
+ </Form>
+ </LoadingLayout>
+ </Modal>
+ );
+}
diff --git a/frontend/src/routes/visual-query/components/save-query-modal/index.tsx b/frontend/src/routes/visual-query/components/save-query-modal/index.tsx
new file mode 100644
index 0000000..d091b63
--- /dev/null
+++ b/frontend/src/routes/visual-query/components/save-query-modal/index.tsx
@@ -0,0 +1,155 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import React, { useContext } from 'react';
+import { Modal, Form, Input, Cascader, message } from 'antd';
+import LoadingLayout from '@src/components/loading-layout';
+import { CascaderItem } from '../../types';
+import { DataContext } from '../../context';
+import { useAsync } from '@src/hooks/use-async';
+import { addCardAPI } from '../../visual-query.api';
+
+interface SaveQueryModalProps {
+ visible: boolean;
+ loading?: boolean;
+ collections: CascaderItem[];
+ setSaveQueryModalVisible: (v: boolean) => void;
+ setSaveDashboardModalVisible: (v: boolean) => void;
+}
+
+interface FormProps {
+ name: string;
+ description?: string;
+ collection: number[];
+}
+
+export default function SaveQueryModal(props: SaveQueryModalProps) {
+ const { visible, setSaveQueryModalVisible, loading = false, collections, setSaveDashboardModalVisible } = props;
+ const { columns, data } = useContext(DataContext);
+
+ const [form] = Form.useForm<FormProps>();
+
+ const { loading: confirmLoading, run: runAddCard } = useAsync();
+
+ const handleCancel = () => {
+ setSaveQueryModalVisible(false);
+ };
+
+ const filter = (inputValue: string, path: any[]) => {
+ return path.some(option => option.name.toLowerCase().indexOf(inputValue.toLowerCase()) > -1);
+ };
+
+ const displayRender = (label: string[], path?: any[]) => {
+ return path?.map(item => item.name).join(' / ') || '';
+ };
+
+ const handleOk = async () => {
+ try {
+ const { collection, name, description } = await form.validateFields();
+ const resultData = data!;
+ const visualization_settings = {
+ columns,
+ }
+ try {
+ await runAddCard(
+ addCardAPI({
+ collection_id: collection[collection.length - 1],
+ dataset_query: {
+ database: resultData.database_id,
+ native: {
+ query: resultData.data.native_form.query,
+ },
+ type: 'NATIVE',
+ },
+ description: description || null,
+ display: 'table',
+ name,
+ result_metadata: {
+ columns: resultData.data.cols,
+ },
+ original_definition: {
+ database: resultData.database_id,
+ native: {
+ query: resultData.data.native_form.query,
+ },
+ type: 'NATIVE',
+ },
+ visualization_settings,
+ }),
+ );
+ message.success('保存查询成功');
+ setSaveQueryModalVisible(false);
+ Modal.confirm({
+ title: '是否将此查询保存到dashboard',
+ onOk: () => {
+ setSaveDashboardModalVisible(true);
+ },
+ });
+ } catch (e) {
+ message.error('保存查询失败');
+ }
+ } catch (e) {
+ console.log(e);
+ }
+ };
+
+ return (
+ <Modal
+ title="保存查询"
+ visible={visible}
+ onCancel={handleCancel}
+ onOk={handleOk}
+ confirmLoading={confirmLoading}
+ destroyOnClose
+ >
+ <LoadingLayout loading={loading} wrapperStyle={{ textAlign: 'center' }}>
+ <Form form={form} autoComplete="off" labelCol={{ span: 5 }} wrapperCol={{ span: 17 }}>
+ <Form.Item
+ name="name"
+ label="名字"
+ rules={[{ required: true, message: '请为该查询命名' }]}
+ required
+ >
+ <Input placeholder="请为该查询命名" />
+ </Form.Item>
+ <Form.Item name="description" label="描述">
+ <Input.TextArea />
+ </Form.Item>
+ <Form.Item
+ name="collection"
+ label="所属文件夹"
+ required
+ rules={[{ required: true, message: '请选择所属文件夹' }]}
+ >
+ <Cascader
+ options={collections}
+ fieldNames={{ value: 'id' }}
+ showSearch={{
+ filter,
+ render: (inputValue: string, path: any[]) => {
+ return path.map(item => item.name).join(' / ');
+ },
+ }}
+ displayRender={displayRender}
+ changeOnSelect
+ />
+ </Form.Item>
+ </Form>
+ </LoadingLayout>
+ </Modal>
+ );
+}
diff --git a/frontend/src/routes/visual-query/components/visual-content/constants.ts b/frontend/src/routes/visual-query/components/visual-content/constants.ts
new file mode 100644
index 0000000..85a2cc7
--- /dev/null
+++ b/frontend/src/routes/visual-query/components/visual-content/constants.ts
@@ -0,0 +1,221 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+export const MONACO_EDITOR_THEME = {
+ base: 'vs',
+ inherit: true,
+ rules: [],
+ colors: {
+ 'editor.background': '#F9FBFC',
+ },
+};
+
+export const RESIZEABLE_ENABLE = {
+ top: false,
+ right: false,
+ bottom: true,
+ left: false,
+ topRight: false,
+ bottomRight: false,
+ bottomLeft: false,
+ topLeft: false,
+};
+
+export const THEME_NAME = 'theme-name';
+
+export const DEFAULT_SUGGESTIONS = [
+ { label: 'add', insertText: 'add', detail: '提示' },
+ { label: 'after', insertText: 'after', detail: '提示' },
+ { label: 'all', insertText: 'all', detail: '提示' },
+ { label: 'alter', insertText: 'alter', detail: '提示' },
+ { label: 'and', insertText: 'and', detail: '提示' },
+ { label: 'append', insertText: 'append', detail: '提示' },
+ { label: 'as', insertText: 'as', detail: '提示' },
+ { label: 'authors', insertText: 'authors', detail: '提示' },
+ { label: 'begin', insertText: 'begin', detail: '提示' },
+ { label: 'between', insertText: 'between', detail: '提示' },
+ { label: 'bigint', insertText: 'bigint', detail: '提示' },
+ { label: 'boolean', insertText: 'boolean', detail: '提示' },
+ { label: 'hdfs', insertText: 'hdfs', detail: '提示' },
+ { label: 'by', insertText: 'by', detail: '提示' },
+ { label: 'char', insertText: 'char', detail: '提示' },
+ { label: 'character', insertText: 'character', detail: '提示' },
+ { label: 'column', insertText: 'column', detail: '提示' },
+ { label: 'columns', insertText: 'columns', detail: '提示' },
+ { label: 'comment', insertText: 'comment', detail: '提示' },
+ { label: 'commit', insertText: 'commit', detail: '提示' },
+ { label: 'committed', insertText: 'committed', detail: '提示' },
+ { label: 'config', insertText: 'config', detail: '提示' },
+ { label: 'count', insertText: 'count', detail: '提示' },
+ { label: 'create', insertText: 'create', detail: '提示' },
+ { label: 'cross', insertText: 'cross', detail: '提示' },
+ { label: 'database', insertText: 'database', detail: '提示' },
+ { label: 'databases', insertText: 'databases', detail: '提示' },
+ { label: 'date', insertText: 'date', detail: '提示' },
+ { label: 'datetime', insertText: 'datetime', detail: '提示' },
+ { label: 'decimal', insertText: 'decimal', detail: '提示' },
+ { label: 'delete', insertText: 'delete', detail: '提示' },
+ { label: 'desc', insertText: 'desc', detail: '提示' },
+ { label: 'double', insertText: 'double', detail: '提示' },
+ { label: 'drop', insertText: 'drop', detail: '提示' },
+ { label: 'else', insertText: 'else', detail: '提示' },
+ { label: 'enable', insertText: 'enable', detail: '提示' },
+ { label: 'end', insertText: 'end', detail: '提示' },
+ { label: 'engine', insertText: 'engine', detail: '提示' },
+ { label: 'engines', insertText: 'engines', detail: '提示' },
+ { label: 'except', insertText: 'except', detail: '提示' },
+ { label: 'exclude', insertText: 'exclude', detail: '提示' },
+ { label: 'exists', insertText: 'exists', detail: '提示' },
+ { label: 'explain', insertText: 'explain', detail: '提示' },
+ { label: 'external', insertText: 'external', detail: '提示' },
+ { label: 'float', insertText: 'float', detail: '提示' },
+ { label: 'format', insertText: 'format', detail: '提示' },
+ { label: 'from', insertText: 'from', detail: '提示' },
+ { label: 'grant', insertText: 'grant', detail: '提示' },
+ { label: 'grants', insertText: 'grants', detail: '提示' },
+ { label: 'group', insertText: 'group', detail: '提示' },
+ { label: 'hash', insertText: 'hash', detail: '提示' },
+ { label: 'having', insertText: 'having', detail: '提示' },
+ { label: 'inner', insertText: 'inner', detail: '提示' },
+ { label: 'insert', insertText: 'insert', detail: '提示' },
+ { label: 'int', insertText: 'int', detail: '提示' },
+ { label: 'integer', insertText: 'integer', detail: '提示' },
+ { label: 'into', insertText: 'into', detail: '提示' },
+ { label: 'is', insertText: 'is', detail: '提示' },
+ { label: 'isnull', insertText: 'isnull', detail: '提示' },
+ { label: 'join', insertText: 'join', detail: '提示' },
+ { label: 'key', insertText: 'key', detail: '提示' },
+ { label: 'keys', insertText: 'keys', detail: '提示' },
+ { label: 'last', insertText: 'last', detail: '提示' },
+ { label: 'left', insertText: 'left', detail: '提示' },
+ { label: 'less', insertText: 'less', detail: '提示' },
+ { label: 'like', insertText: 'like', detail: '提示' },
+ { label: 'limit', insertText: 'limit', detail: '提示' },
+ { label: 'list', insertText: 'list', detail: '提示' },
+ { label: 'load', insertText: 'load', detail: '提示' },
+ { label: 'max', insertText: 'max', detail: '提示' },
+ { label: 'merge', insertText: 'merge', detail: '提示' },
+ { label: 'min', insertText: 'min', detail: '提示' },
+ { label: 'modify', insertText: 'modify', detail: '提示' },
+ { label: 'month', insertText: 'month', detail: '提示' },
+ { label: 'no', insertText: 'no', detail: '提示' },
+ { label: 'not', insertText: 'not', detail: '提示' },
+ { label: 'null', insertText: 'null', detail: '提示' },
+ { label: 'nulls', insertText: 'nulls', detail: '提示' },
+ { label: 'offset', insertText: 'offset', detail: '提示' },
+ { label: 'on', insertText: 'on', detail: '提示' },
+ { label: 'only', insertText: 'only', detail: '提示' },
+ { label: 'open', insertText: 'open', detail: '提示' },
+ { label: 'or', insertText: 'or', detail: '提示' },
+ { label: 'order', insertText: 'order', detail: '提示' },
+ { label: 'partition', insertText: 'partition', detail: '提示' },
+ { label: 'partitions', insertText: 'partitions', detail: '提示' },
+ { label: 'properties', insertText: 'properties', detail: '提示' },
+ { label: 'property', insertText: 'property', detail: '提示' },
+ { label: 'range', insertText: 'range', detail: '提示' },
+ { label: 'range', insertText: 'range', detail: '提示' },
+ { label: 'revoke', insertText: 'revoke', detail: '提示' },
+ { label: 'right', insertText: 'right', detail: '提示' },
+ { label: 'role', insertText: 'role', detail: '提示' },
+ { label: 'roles', insertText: 'roles', detail: '提示' },
+ { label: 'row', insertText: 'row', detail: '提示' },
+ { label: 'rows', insertText: 'rows', detail: '提示' },
+ { label: 'schema', insertText: 'schema', detail: '提示' },
+ { label: 'schemas', insertText: 'schemas', detail: '提示' },
+ { label: 'second', insertText: 'second', detail: '提示' },
+ { label: 'select', insertText: 'select', detail: '提示' },
+ { label: 'set', insertText: 'set', detail: '提示' },
+ { label: 'sets', insertText: 'sets', detail: '提示' },
+ { label: 'show', insertText: 'show', detail: '提示' },
+ { label: 'stream', insertText: 'stream', detail: '提示' },
+ { label: 'string', insertText: 'string', detail: '提示' },
+ { label: 'sum', insertText: 'sum', detail: '提示' },
+ { label: 'table', insertText: 'table', detail: '提示' },
+ { label: 'tables', insertText: 'tables', detail: '提示' },
+ { label: 'than', insertText: 'than', detail: '提示' },
+ { label: 'then', insertText: 'then', detail: '提示' },
+ { label: 'timestamp', insertText: 'timestamp', detail: '提示' },
+ { label: 'true', insertText: 'true', detail: '提示' },
+ { label: 'truncate', insertText: 'truncate', detail: '提示' },
+ { label: 'type', insertText: 'type', detail: '提示' },
+ { label: 'types', insertText: 'types', detail: '提示' },
+ { label: 'union', insertText: 'union', detail: '提示' },
+ { label: 'unique', insertText: 'unique', detail: '提示' },
+ { label: 'unsigned', insertText: 'unsigned', detail: '提示' },
+ { label: 'use', insertText: 'use', detail: '提示' },
+ { label: 'user', insertText: 'user', detail: '提示' },
+ { label: 'using', insertText: 'using', detail: '提示' },
+ { label: 'update', insertText: 'update', detail: '提示' },
+ { label: 'value', insertText: 'value', detail: '提示' },
+ { label: 'values', insertText: 'values', detail: '提示' },
+ { label: 'varchar', insertText: 'varchar', detail: '提示' },
+ { label: 'variables', insertText: 'variables', detail: '提示' },
+ { label: 'view', insertText: 'view', detail: '提示' },
+ { label: 'when', insertText: 'when', detail: '提示' },
+ { label: 'where', insertText: 'where', detail: '提示' },
+ { label: 'with', insertText: 'with', detail: '提示' },
+ { label: 'write', insertText: 'write', detail: '提示' },
+ { label: 'year', insertText: 'year', detail: '提示' },
+ { label: 'matched', insertText: 'matched', detail: '提示' },
+ { label: 'run', insertText: 'run', detail: '提示' },
+ { label: 'tasks', insertText: 'tasks', detail: '提示' },
+ { label: 'update', insertText: 'update', detail: '提示' },
+ { label: 'stage', insertText: 'stage', detail: '提示' },
+ { label: 'stages', insertText: 'stages', detail: '提示' },
+ { label: 'url', insertText: 'url', detail: '提示' },
+ { label: 'storage_options', insertText: 'storage_options', detail: '提示' },
+ { label: 'file_format', insertText: 'file_format', detail: '提示' },
+ { label: 'copy_options', insertText: 'copy_options', detail: '提示' },
+ { label: 'jobflow', insertText: 'jobflow', detail: '提示' },
+ { label: 'schedule', insertText: 'schedule', detail: '提示' },
+ { label: 'copy', insertText: 'copy', detail: '提示' },
+ { label: 'grants', insertText: 'grants', detail: '提示' },
+ { label: 'cron', insertText: 'cron', detail: '提示' },
+ { label: 'options', insertText: 'options', detail: '提示' },
+ { label: 'job', insertText: 'job', detail: '提示' },
+ { label: 'unset', insertText: 'unset', detail: '提示' },
+ { label: 'remove', insertText: 'remove', detail: '提示' },
+ { label: 'terse', insertText: 'terse', detail: '提示' },
+ { label: 'jobs', insertText: 'jobs', detail: '提示' },
+ { label: 'starts', insertText: 'starts', detail: '提示' },
+ { label: 'account', insertText: 'account', detail: '提示' },
+ { label: 'jobflows', insertText: 'jobflows', detail: '提示' },
+ { label: 'call', insertText: 'call', detail: '提示' },
+ { label: 'at', insertText: 'at', detail: '提示' },
+ { label: 'history', insertText: 'history', detail: '提示' },
+ { label: 'snapshots', insertText: 'snapshots', detail: '提示' },
+ { label: 'clone', insertText: 'clone', detail: '提示' },
+ { label: 'stream', insertText: 'stream', detail: '提示' },
+ { label: 'before', insertText: 'before', detail: '提示' },
+ { label: 'append_only', insertText: 'append_only', detail: '提示' },
+ { label: 'streams', insertText: 'streams', detail: '提示' },
+ { label: 'dropped', insertText: 'dropped', detail: '提示' },
+ { label: 'array', insertText: 'array', detail: '提示' },
+ { label: 'map', insertText: 'map', detail: '提示' },
+ { label: 'struct', insertText: 'struct', detail: '提示' },
+ { label: 'field', insertText: 'field', detail: '提示' },
+ { label: 'refresh', insertText: 'refresh', detail: '提示' },
+ { label: 'mount', insertText: 'mount', detail: '提示' },
+ { label: 'files', insertText: 'files', detail: '提示' },
+ { label: 'cache', insertText: 'cache', detail: '提示' },
+ { label: 'granted', insertText: 'granted', detail: '提示' },
+ { label: 'cached', insertText: 'cached', detail: '提示' },
+ { label: 'queue', insertText: 'queue', detail: '提示' },
+ { label: 'queues', insertText: 'queues', detail: '提示' },
+ { label: 'sessionlabel', insertText: 'sessionlabel', detail: '提示' },
+ { label: 'extended', insertText: 'extended', detail: '提示' },
+];
diff --git a/frontend/src/routes/visual-query/components/visual-content/hooks.ts b/frontend/src/routes/visual-query/components/visual-content/hooks.ts
new file mode 100644
index 0000000..ee48d41
--- /dev/null
+++ b/frontend/src/routes/visual-query/components/visual-content/hooks.ts
@@ -0,0 +1,73 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import { useEffect, useContext, useCallback, useRef } from 'react';
+import { useMonaco } from '@monaco-editor/react';
+import { THEME_NAME, MONACO_EDITOR_THEME, DEFAULT_SUGGESTIONS } from './constants';
+import { DatabasesContext } from '../../context';
+import { useCallbackRef } from '../../hooks';
+
+export function useMonacoEditor() {
+ const { selectedDatabaseId, databases } = useContext(DatabasesContext);
+ const monaco = useMonaco();
+
+ useEffect(() => {
+ if (monaco) {
+ monaco.editor.defineTheme(THEME_NAME, MONACO_EDITOR_THEME as any);
+ monaco.editor.setTheme(THEME_NAME);
+ }
+ }, [monaco]);
+
+ const provideCompletionItems = useCallbackRef((model: any, position: any) => {
+ const textUntilPosition = model.getValueInRange({
+ startLineNumber: position.lineNumber,
+ startColumn: 1,
+ endLineNumber: position.lineNumber,
+ endColumn: position.column,
+ });
+ const textArr = textUntilPosition.split(' ');
+ const lastText = textArr[textArr.length - 1];
+ const currentDatabase = databases?.find(database => database.id === selectedDatabaseId);
+ const SUGGESTIONS = [
+ ...DEFAULT_SUGGESTIONS,
+ ...currentDatabase.tables.map((table: any) => ({
+ label: table.name,
+ insertText: table.name,
+ detail: '表',
+ })),
+ ];
+ const match = SUGGESTIONS.some((item: any) => {
+ // 循环判断是否包含在补全数组中
+ return item.label.indexOf(lastText) > 0;
+ });
+ const suggestion = match ? SUGGESTIONS : [];
+ return {
+ suggestions: suggestion.map(item => ({
+ ...item,
+ kind: monaco?.languages.CompletionItemKind.Variable,
+ })),
+ } as any;
+ });
+
+ useEffect(() => {
+ if (monaco) {
+ monaco.languages.registerCompletionItemProvider('sql', {
+ provideCompletionItems,
+ });
+ }
+ }, [monaco, provideCompletionItems]);
+}
diff --git a/frontend/src/routes/visual-query/components/visual-content/index.tsx b/frontend/src/routes/visual-query/components/visual-content/index.tsx
new file mode 100644
index 0000000..aebd5e6
--- /dev/null
+++ b/frontend/src/routes/visual-query/components/visual-content/index.tsx
@@ -0,0 +1,91 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import React, { useContext, useImperativeHandle, useMemo, useRef } from 'react';
+import * as _ from 'lodash-es';
+import classnames from 'classnames';
+import { Typography, Row, Spin } from 'antd';
+import styles from './style.module.less';
+import VisualEditor from './visual-editor';
+import { DatabasesContext, DataContext } from '../../context';
+import LoadingLayout from '../loading-layout';
+
+function VisualContent(props: any, ref: any) {
+ const { databasesLoading } = useContext(DatabasesContext);
+ const { data: resultData, dataLoading, dataError } = useContext(DataContext);
+ const visualWrapperRef = useRef<HTMLDivElement | null>(null);
+ const visualTableRef =
+ useRef<{ refreshTableHeight: (val: number) => void; refreshTableWidth: (val: number) => void }>(null);
+
+ const isFetchingError = dataError != null || (resultData && resultData.error);
+
+ const content = useMemo(() => {
+ if (dataLoading)
+ return (
+ <Row align="middle" justify="center" style={{ height: '100%' }}>
+ <Spin />
+ </Row>
+ );
+ if (isFetchingError)
+ return (
+ <Row align="middle" justify="center" style={{ height: '100%' }}>
+ <Typography.Text type="danger">
+ 您的查询出现错误:{dataError ? '请检查您的网络' : resultData?.error}
+ </Typography.Text>
+ </Row>
+ );
+ if (resultData == null)
+ return (
+ <Row align="middle" justify="center" style={{ height: '100%' }}>
+ <Typography.Text>您的查询结果将在这里展示</Typography.Text>
+ </Row>
+ );
+ return (
+ <>
+ </>
+ );
+ }, [dataLoading, isFetchingError, resultData, dataError]);
+
+ const onResize = _.throttle(() => {
+ if (visualTableRef.current && visualWrapperRef.current) {
+ visualTableRef.current.refreshTableHeight(visualWrapperRef.current.clientHeight * 0.8 - 120);
+ }
+ });
+
+ const refreshTableWidth = () => {
+ if (visualTableRef.current && visualWrapperRef.current) {
+ visualTableRef.current.refreshTableWidth(visualWrapperRef.current.clientWidth);
+ }
+ };
+
+ useImperativeHandle(ref, () => ({
+ refreshTableWidth,
+ }));
+
+ return (
+ <LoadingLayout loading={databasesLoading}>
+ <div className={classnames(styles.content)}>
+ <VisualEditor onResize={onResize} />
+ <div className={styles.visualWrapper} ref={visualWrapperRef}>
+ {content}
+ </div>
+ </div>
+ </LoadingLayout>
+ );
+}
+
+export default React.forwardRef(VisualContent);
diff --git a/frontend/src/routes/visual-query/components/visual-content/style.module.less b/frontend/src/routes/visual-query/components/visual-content/style.module.less
new file mode 100644
index 0000000..bc4139b
--- /dev/null
+++ b/frontend/src/routes/visual-query/components/visual-content/style.module.less
@@ -0,0 +1,51 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+.content {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ position: relative;
+ height: calc(100vh - 128px);
+ &::before {
+ content: '';
+ width: 1px;
+ background-color: #ddd;
+ position: absolute;
+ top: 0;
+ left: -20px;
+ height: calc(100% + 40px);
+ z-index: 99;
+ }
+ &.contentRight {
+ &::before {
+ content: '';
+ width: 1px;
+ background-color: #ddd;
+ position: absolute;
+ top: 0;
+ left: 0;
+ height: calc(100% + 40px);
+ z-index: 99;
+ }
+ }
+}
+
+.visualWrapper {
+ flex: 1;
+ width: 95%;
+}
diff --git a/frontend/src/routes/visual-query/components/visual-content/visual-editor.tsx b/frontend/src/routes/visual-query/components/visual-content/visual-editor.tsx
new file mode 100644
index 0000000..2e247da
--- /dev/null
+++ b/frontend/src/routes/visual-query/components/visual-content/visual-editor.tsx
@@ -0,0 +1,104 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import React, { useContext, useState } from 'react';
+import { Select, Row, Col, Button, message } from 'antd';
+import { Resizable } from 're-resizable';
+import Editor from '@monaco-editor/react';
+import { DatabasesContext, DataContext, EditorContext } from '../../context';
+import { THEME_NAME, RESIZEABLE_ENABLE } from './constants';
+import { useMonacoEditor } from './hooks';
+import { fetchData } from '../../visual-query.api';
+
+const { Option } = Select;
+
+interface VisualEditorProps {
+ onResize: () => void;
+}
+
+export default function VisualEditor(props: VisualEditorProps) {
+ const { onResize } = props;
+ const { selectedDatabaseId, setSelectedDatabaseId, databases } = useContext(DatabasesContext);
+ const { runFetchData } = useContext(DataContext);
+ const { editorValue, setEditorValue } = useContext(EditorContext);
+ useMonacoEditor();
+
+ const runSqlQuery = () => {
+ if (selectedDatabaseId == null) {
+ message.error('请选择一个数据库');
+ return;
+ }
+ if (editorValue === '') {
+ message.error('请输入查询语句');
+ return;
+ }
+ runFetchData(fetchData({ database: selectedDatabaseId, native: { query: editorValue }, type: 'NATIVE' })).catch(
+ () => message.error('查询失败'),
+ );
+ };
+
+ return (
+ <>
+ <Select
+ value={selectedDatabaseId || undefined}
+ style={{ width: 200, margin: '20px 0 0 20px' }}
+ placeholder="请选择一个数据库"
+ onChange={v => setSelectedDatabaseId(v)}
+ >
+ {databases?.map(database => (
+ <Option value={database.id} key={database.id}>
+ {database.name}
+ </Option>
+ ))}
+ </Select>
+ <Resizable
+ style={{ border: '1px solid #ccc', margin: 20 }}
+ enable={RESIZEABLE_ENABLE}
+ defaultSize={{
+ width: '95%',
+ height: 150,
+ }}
+ minHeight={150}
+ onResize={onResize}
+ >
+ <Editor
+ height="100%"
+ defaultLanguage="sql"
+ theme={THEME_NAME}
+ options={{
+ minimap: {
+ enabled: false,
+ },
+ }}
+ value={editorValue}
+ onChange={v => setEditorValue(v || '')}
+ />
+ </Resizable>
+ <Row justify="end">
+ <Col style={{ marginRight: '4%' }}>
+ <Button
+ type="primary"
+ disabled={selectedDatabaseId == null || editorValue === ''}
+ onClick={runSqlQuery}
+ >
+ 运行
+ </Button>
+ </Col>
+ </Row>
+ </>
+ );
+}
diff --git a/frontend/src/routes/visual-query/components/visual-content/visual-table.tsx b/frontend/src/routes/visual-query/components/visual-content/visual-table.tsx
new file mode 100644
index 0000000..53a25cc
--- /dev/null
+++ b/frontend/src/routes/visual-query/components/visual-content/visual-table.tsx
@@ -0,0 +1,58 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import React, { useContext, useEffect, useImperativeHandle, useRef, useState } from 'react';
+import { Table } from 'antd';
+import { DataContext } from '../../context';
+
+function VisualTable(props: any, ref: any) {
+ const { columns, dataSource } = useContext(DataContext);
+
+ const tableWrapperRef = useRef<HTMLDivElement | null>(null);
+
+ const [tableWidth, setTableWidth] = useState(1100);
+ const [scrollY, setScrollY] = useState(200);
+
+ useEffect(() => {
+ if (tableWrapperRef.current) {
+ setTableWidth(tableWrapperRef.current.clientWidth);
+ setScrollY(tableWrapperRef.current.clientHeight - 120);
+ }
+ }, []);
+
+ useImperativeHandle(ref, () => ({
+ refreshTableHeight: setScrollY,
+ refreshTableWidth: setTableWidth,
+ }));
+
+ return (
+ <div style={{ height: '100%', margin: '20px 0 0 20px' }} ref={tableWrapperRef}>
+ <div style={{ width: tableWidth, height: '100%' }}>
+ <Table
+ style={{ width: '100%', height: '100%' }}
+ columns={columns}
+ dataSource={dataSource}
+ scroll={{ y: scrollY, x: tableWidth - 20 }}
+ pagination={{ size: 'small', pageSize: 50, showSizeChanger: false }}
+ size="small"
+ />
+ </div>
+ </div>
+ );
+}
+
+export default React.forwardRef(VisualTable);
diff --git a/frontend/src/routes/visual-query/components/visual-header/index.module.less b/frontend/src/routes/visual-query/components/visual-header/index.module.less
new file mode 100644
index 0000000..4456d55
--- /dev/null
+++ b/frontend/src/routes/visual-query/components/visual-header/index.module.less
@@ -0,0 +1,22 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+.container {
+ height: 62px;
+ border-bottom: 1px solid #f0f0f0;
+ background-color: #fff;
+ padding: 0 20px;
+}
diff --git a/frontend/src/routes/visual-query/components/visual-header/index.tsx b/frontend/src/routes/visual-query/components/visual-header/index.tsx
new file mode 100644
index 0000000..3b82a9b
--- /dev/null
+++ b/frontend/src/routes/visual-query/components/visual-header/index.tsx
@@ -0,0 +1,48 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import React, { useContext } from 'react';
+import { Button, message, Row, Typography } from 'antd';
+import styles from './index.module.less';
+import { DataContext } from '../../context';
+
+interface VisualHeaderProps {
+ setSaveQueryModalVisible: (v: boolean) => void;
+}
+
+export default function VisualHeader(props: VisualHeaderProps) {
+ const { setSaveQueryModalVisible } = props;
+ const { data } = useContext(DataContext);
+
+ const handleSave = () => {
+ if (data == null || data.error != null) {
+ return message.info('请先进行有效查询');
+ }
+ setSaveQueryModalVisible(true);
+ };
+
+ return (
+ <Row justify="space-between" align="middle" className={styles.container}>
+ <Typography.Title level={4}>SQL编辑区</Typography.Title>
+ <div>
+ <Button type="link" onClick={handleSave}>
+ 保存
+ </Button>
+ </div>
+ </Row>
+ );
+}
diff --git a/frontend/src/routes/visual-query/context/data-context.tsx b/frontend/src/routes/visual-query/context/data-context.tsx
new file mode 100644
index 0000000..040255a
--- /dev/null
+++ b/frontend/src/routes/visual-query/context/data-context.tsx
@@ -0,0 +1,100 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import React, { PropsWithChildren, useEffect, useState, useMemo } from 'react';
+import { useAsync } from '../hooks';
+import { DataType } from '../types';
+
+export interface Column {
+ dataIndex: string;
+ title: string;
+}
+
+interface ResultData {
+ data: DataType;
+ error?: any;
+ database_id: number;
+}
+
+interface DataContextProps {
+ columns: Column[];
+ dataSource: Record<string, any>[];
+ setColumns: (val: Column[]) => void;
+ data: ResultData | null;
+ dataLoading: boolean;
+ dataError: Error | null;
+ runFetchData: (promise: Promise<ResultData>) => Promise<ResultData>;
+}
+
+export const DataContext = React.createContext<DataContextProps>({
+ columns: [],
+ dataSource: [],
+ setColumns: () => {},
+ data: null,
+ dataLoading: false,
+ dataError: null,
+ runFetchData: () => Promise.resolve({ data: { cols: [], rows: [], native_form: { query: '' } }, database_id: 0 }),
+});
+
+export default function DataContextProvider(props: PropsWithChildren<{}>) {
+ const [columns, setColumns] = useState<{ dataIndex: string; title: string }[]>([]);
+ const { data: resultData, loading: dataLoading, error: dataError, run: runFetchData } = useAsync<ResultData>();
+
+ const isFetchingError = dataError != null || (resultData && resultData.error);
+
+ const dataSource = useMemo(() => {
+ if (!resultData || isFetchingError) return [];
+ return resultData.data.rows.map((item, index) => {
+ return columns.reduce(
+ (memo, current) => {
+ const columnIndex = resultData.data.cols.findIndex(col => col.name === current.dataIndex);
+ memo[current.dataIndex] = item[columnIndex];
+ return memo;
+ },
+ { key: index } as Record<string, any>,
+ );
+ });
+ }, [resultData, columns, isFetchingError]);
+
+ useEffect(() => {
+ if (isFetchingError || resultData == null) {
+ setColumns([]);
+ return;
+ }
+ const newColumns = resultData.data.cols.map(item => ({
+ title: item.display_name,
+ dataIndex: item.name,
+ }));
+ setColumns(newColumns);
+ }, [resultData, isFetchingError]);
+
+ return (
+ <DataContext.Provider
+ value={{
+ columns,
+ dataSource,
+ setColumns,
+ data: resultData,
+ dataLoading,
+ dataError,
+ runFetchData,
+ }}
+ >
+ {props.children}
+ </DataContext.Provider>
+ );
+}
diff --git a/frontend/src/routes/visual-query/context/databases-context.tsx b/frontend/src/routes/visual-query/context/databases-context.tsx
new file mode 100644
index 0000000..4befc27
--- /dev/null
+++ b/frontend/src/routes/visual-query/context/databases-context.tsx
@@ -0,0 +1,63 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import React, { PropsWithChildren, useEffect, useState } from 'react';
+import { message } from 'antd';
+import { fetchDatabases } from '../visual-query.api';
+import { useAsync } from '../hooks';
+
+interface DatabasesContextProps {
+ databases: any[] | null;
+ databasesLoading: boolean;
+ selectedDatabaseId: number | null;
+ setSelectedDatabaseId: (id: number) => void;
+}
+
+export const DatabasesContext = React.createContext<DatabasesContextProps>({
+ databases: [],
+ databasesLoading: true,
+ selectedDatabaseId: null,
+ setSelectedDatabaseId: () => {},
+});
+
+export default function QueryContextProvider(props: PropsWithChildren<{}>) {
+ const {
+ data: databases,
+ loading: databasesLoading,
+ run: runFetchDatabases,
+ } = useAsync<any[]>({ loading: true, data: [] });
+ const [selectedDatabaseId, setSelectedDatabaseId] = useState<number | null>(null);
+
+ useEffect(() => {
+ runFetchDatabases(fetchDatabases())
+ .then(res => res && res.length > 0 && setSelectedDatabaseId(res[0].id))
+ .catch(() => message.error('获取数据库列表失败'));
+ }, [runFetchDatabases]);
+
+ return (
+ <DatabasesContext.Provider
+ value={{
+ databases,
+ databasesLoading,
+ selectedDatabaseId,
+ setSelectedDatabaseId,
+ }}
+ >
+ {props.children}
+ </DatabasesContext.Provider>
+ );
+}
diff --git a/frontend/src/routes/visual-query/context/editor-context.tsx b/frontend/src/routes/visual-query/context/editor-context.tsx
new file mode 100644
index 0000000..7a39f2d
--- /dev/null
+++ b/frontend/src/routes/visual-query/context/editor-context.tsx
@@ -0,0 +1,33 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import React, { PropsWithChildren, useState } from 'react';
+
+interface EditorContextProps {
+ editorValue: string;
+ setEditorValue: (v: string) => void;
+}
+
+export const EditorContext = React.createContext<EditorContextProps>({
+ editorValue: '',
+ setEditorValue: () => {},
+});
+
+export default function EditorContextProvider(props: PropsWithChildren<{}>) {
+ const [editorValue, setEditorValue] = useState('');
+ return <EditorContext.Provider value={{ editorValue, setEditorValue }}>{props.children}</EditorContext.Provider>;
+}
diff --git a/frontend/src/routes/visual-query/context/index.tsx b/frontend/src/routes/visual-query/context/index.tsx
new file mode 100644
index 0000000..19e0c4e
--- /dev/null
+++ b/frontend/src/routes/visual-query/context/index.tsx
@@ -0,0 +1,41 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import React, { PropsWithChildren } from 'react';
+import DatabasesContextProvider from './databases-context';
+import DataContextProvider from './data-context';
+import EditorContextProvider from './editor-context';
+
+const providers = [
+ DatabasesContextProvider,
+ DataContextProvider,
+ EditorContextProvider,
+];
+
+export default function VisualQueryProvider(props: PropsWithChildren<{}>) {
+ return (
+ <>
+ {providers.reduce((memo, Provider) => {
+ return <Provider>{memo}</Provider>;
+ }, props.children)}
+ </>
+ );
+}
+
+export * from './databases-context';
+export * from './data-context';
+export * from './editor-context';
diff --git a/frontend/src/routes/visual-query/hooks/index.ts b/frontend/src/routes/visual-query/hooks/index.ts
new file mode 100644
index 0000000..dd0e843
--- /dev/null
+++ b/frontend/src/routes/visual-query/hooks/index.ts
@@ -0,0 +1,21 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+export * from './use-async';
+export * from './use-callback-ref';
+export * from './use-click-outside';
+export * from './use-collections';
diff --git a/frontend/src/routes/visual-query/hooks/use-async.ts b/frontend/src/routes/visual-query/hooks/use-async.ts
new file mode 100644
index 0000000..2164ced
--- /dev/null
+++ b/frontend/src/routes/visual-query/hooks/use-async.ts
@@ -0,0 +1,59 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import { useReducer, useCallback } from 'react';
+
+interface State<T> {
+ loading: boolean;
+ data: T | null;
+ error: Error | null;
+}
+
+const defaultState = {
+ loading: false,
+ data: null,
+ error: null,
+};
+
+export function useAsync<T>(initialState?: Partial<State<T>>) {
+ const [state, dispatch] = useReducer((state: State<T>, action: Partial<State<T>>) => ({ ...state, ...action }), {
+ ...defaultState,
+ ...initialState,
+ });
+
+ const run = useCallback(
+ (promise: Promise<T>) => {
+ dispatch({ loading: true });
+ return promise
+ .then(data => {
+ dispatch({ loading: false, data, error: null });
+ return data;
+ })
+ .catch(error => {
+ dispatch({ loading: false, data: null, error });
+ return Promise.reject(error);
+ });
+ },
+ [dispatch],
+ );
+
+ return {
+ run,
+ dispatch,
+ ...state,
+ };
+}
diff --git a/frontend/src/routes/visual-query/hooks/use-callback-ref.ts b/frontend/src/routes/visual-query/hooks/use-callback-ref.ts
new file mode 100644
index 0000000..bf274b9
--- /dev/null
+++ b/frontend/src/routes/visual-query/hooks/use-callback-ref.ts
@@ -0,0 +1,24 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import { useRef, useCallback } from 'react';
+
+export function useCallbackRef(fn: (...args: any[]) => any) {
+ const fnRef = useRef(fn);
+ fnRef.current = fn;
+ return useCallback((...args: any[]) => fnRef.current(...args), []);
+}
diff --git a/frontend/src/routes/visual-query/hooks/use-click-outside.ts b/frontend/src/routes/visual-query/hooks/use-click-outside.ts
new file mode 100644
index 0000000..b299278
--- /dev/null
+++ b/frontend/src/routes/visual-query/hooks/use-click-outside.ts
@@ -0,0 +1,34 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import { useEffect } from 'react';
+
+export function useClickOutside(domRef: React.MutableRefObject<HTMLElement | null>, callback: () => void) {
+ useEffect(() => {
+ const dom = domRef.current;
+ const handler = (e: MouseEvent) => {
+ if (dom && dom.contains(e.target as HTMLElement)) {
+ return;
+ }
+ callback();
+ };
+ document.addEventListener('click', handler);
+ return () => {
+ document.removeEventListener('click', handler);
+ };
+ }, []);
+}
diff --git a/frontend/src/routes/visual-query/hooks/use-collections.ts b/frontend/src/routes/visual-query/hooks/use-collections.ts
new file mode 100644
index 0000000..e171310
--- /dev/null
+++ b/frontend/src/routes/visual-query/hooks/use-collections.ts
@@ -0,0 +1,42 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import { useEffect, useMemo } from 'react';
+import { message } from 'antd';
+import { useAsync } from '@src/hooks/use-async';
+import { CascaderItem } from '../types';
+import { fetchCollectionAPI } from '../visual-query.api';
+import { getCollections } from '../utils';
+
+export function useCollections() {
+ const { data, loading, run: runFetchCollections } = useAsync<CascaderItem[]>({ data: [] });
+
+ useEffect(() => {
+ runFetchCollections(fetchCollectionAPI()).catch(() => {
+ message.error('获取文件夹列表失败');
+ });
+ }, [runFetchCollections]);
+
+ const collections = useMemo(() => {
+ return getCollections(data as CascaderItem[]);
+ }, [data]);
+
+ return {
+ loading,
+ collections,
+ };
+}
diff --git a/frontend/src/routes/visual-query/types/common.ts b/frontend/src/routes/visual-query/types/common.ts
new file mode 100644
index 0000000..a361dbb
--- /dev/null
+++ b/frontend/src/routes/visual-query/types/common.ts
@@ -0,0 +1,64 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import React from 'react';
+
+export interface DataType {
+ cols: {
+ source: string;
+ name: string;
+ display_name: string;
+ base_type: string;
+ }[];
+ rows: any[][];
+ native_form: {
+ query: string;
+ };
+}
+
+export type Dataset = { source: any[][] }[];
+
+export const enum SidebarTypeEnum {
+ TYPE = 'echartsType',
+ SETTINGS = 'echartsSettings',
+}
+
+export const enum ChartTypeEnum {
+ BAR_CHART = 'bar',
+ LINE_CHART = 'line',
+ AREA_CHART = 'area',
+ SCATTER_CHART = 'scatter',
+ HORIZONTAL_BAR_CHART = 'horizontal-bar',
+ TABLE = 'table',
+}
+
+export const enum AxisTypeEnum {
+ CATEGORY = 'category',
+ VALUE = 'value',
+}
+
+export interface CascaderItem {
+ name: string;
+ id: number;
+ archived: boolean;
+ can_write: boolean;
+ location: string;
+ children: CascaderItem[];
+ label: React.ReactNode;
+ isLeaf?: boolean;
+ isFetched?: boolean;
+}
diff --git a/frontend/src/routes/visual-query/types/index.ts b/frontend/src/routes/visual-query/types/index.ts
new file mode 100644
index 0000000..6aaf4db
--- /dev/null
+++ b/frontend/src/routes/visual-query/types/index.ts
@@ -0,0 +1,18 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+export * from './common';
\ No newline at end of file
diff --git a/frontend/src/routes/visual-query/utils/color-picker.ts b/frontend/src/routes/visual-query/utils/color-picker.ts
new file mode 100644
index 0000000..69a0db5
--- /dev/null
+++ b/frontend/src/routes/visual-query/utils/color-picker.ts
@@ -0,0 +1,59 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+const COLORS = ['#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de', '#3ba272', '#fc8452', '#9a60b4', '#ea7ccc'];
+
+const COLORS_MAP = COLORS.reduce((memo, current) => {
+ memo[current] = 0;
+ return memo;
+}, {});
+
+class ColorPicker {
+ colors: string[] = COLORS;
+ colorsMap: Record<string, number> = COLORS_MAP;
+ index: number = 0;
+ restCount: number = COLORS.length;
+ pickInOrder() {
+ if (this.restCount === 0) {
+ const color = this.colors[this.index % this.colors.length];
+ this.colorsMap[color]++;
+ this.index++;
+ return color;
+ }
+ const color = Object.keys(this.colorsMap).find(color => this.colorsMap[color] === 0) as string;
+ this.colorsMap[color]++;
+ this.restCount--;
+ this.index = this.colors.indexOf(color) + 1;
+ return color;
+ }
+ pick(color: string) {
+ this.colorsMap[color]++;
+ if (this.colorsMap[color] === 1) {
+ this.restCount--;
+ }
+ this.index = this.colors.indexOf(color) + 1;
+ }
+ reset() {
+ Object.keys(this.colorsMap).forEach(key => {
+ this.colorsMap[key] = 0;
+ });
+ this.index = 0;
+ this.restCount = this.colors.length;
+ }
+}
+
+export const colorPicker = new ColorPicker();
diff --git a/frontend/src/routes/visual-query/utils/get-collections.tsx b/frontend/src/routes/visual-query/utils/get-collections.tsx
new file mode 100644
index 0000000..e2777b3
--- /dev/null
+++ b/frontend/src/routes/visual-query/utils/get-collections.tsx
@@ -0,0 +1,60 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import React from 'react';
+import { FolderOutlined } from '@ant-design/icons';
+import { CascaderItem } from '../types';
+
+function getLocationPaths(location: string) {
+ return location
+ .split('/')
+ .filter(item => item !== '')
+ .map(item => Number(item));
+}
+
+function getParent(map: Map<number, CascaderItem>, locationPaths: number[]) {
+ return locationPaths.reduce((memo, current) => {
+ return memo.children.find(item => item.id === current)!;
+ }, map.get(locationPaths.shift()!) as CascaderItem);
+}
+
+export function getCollections(data: CascaderItem[]) {
+ const filteredData = data
+ .filter(item => !item.archived && item.can_write)
+ .map(item => ({
+ ...item,
+ children: [] as CascaderItem[],
+ label: (
+ <div>
+ <FolderOutlined style={{ marginRight: 5 }} />
+ {item.name}
+ </div>
+ ),
+ }))
+ .sort((a, b) => getLocationPaths(a.location).length - getLocationPaths(b.location).length);
+ const map = new Map<number, CascaderItem>();
+ filteredData.forEach(item => {
+ const locationPaths = getLocationPaths(item.location);
+ if (locationPaths.length === 0) {
+ map.set(item.id, item);
+ return;
+ }
+ const parent = getParent(map, locationPaths);
+ parent.children.push(item);
+ });
+ return Array.from(map).map(item => item[1]);
+}
diff --git a/frontend/src/routes/visual-query/utils/index.ts b/frontend/src/routes/visual-query/utils/index.ts
new file mode 100644
index 0000000..b453c0b
--- /dev/null
+++ b/frontend/src/routes/visual-query/utils/index.ts
@@ -0,0 +1,20 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+export * from './color-picker';
+export * from './get-collections';
+export * from './process-collections';
diff --git a/frontend/src/routes/visual-query/utils/process-collections.ts b/frontend/src/routes/visual-query/utils/process-collections.ts
new file mode 100644
index 0000000..3526f6b
--- /dev/null
+++ b/frontend/src/routes/visual-query/utils/process-collections.ts
@@ -0,0 +1,29 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import { CascaderItem } from '../types';
+
+export function processCollections(collections: CascaderItem[]): CascaderItem[] {
+ return collections.map(collection => {
+ return {
+ ...collection,
+ isLeaf: false,
+ isFetched: false,
+ children: processCollections(collection.children),
+ };
+ });
+}
diff --git a/frontend/src/routes/visual-query/visual-query.api.ts b/frontend/src/routes/visual-query/visual-query.api.ts
new file mode 100644
index 0000000..e312395
--- /dev/null
+++ b/frontend/src/routes/visual-query/visual-query.api.ts
@@ -0,0 +1,91 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import { http, isSuccess } from '@src/utils/http';
+import { DataType } from './types';
+
+export function fetchDatabases() {
+ return http.get('/api/database/', { include: 'tables' }).then(res => res.data) as Promise<any[]>;
+}
+
+interface NativeSqlQueryParams {
+ database: number;
+ native: { query: string };
+ type: 'NATIVE';
+}
+
+export function fetchData(params: NativeSqlQueryParams) {
+ return http.post('/api/dataset/', params).then(res => res.data) as Promise<{
+ data: DataType;
+ error?: any;
+ database_id: number;
+ }>;
+}
+
+export function fetchCollectionAPI() {
+ return http.get('/api/collection/').then(res => {
+ if (isSuccess(res)) {
+ return res.data;
+ }
+ return Promise.reject(res);
+ });
+}
+
+interface AddCardParams {
+ collection_id: number;
+ dataset_query: {
+ database: number;
+ native: {
+ query: string;
+ };
+ type: 'NATIVE';
+ };
+ description: string | null;
+ display: 'table' | 'chart';
+ name: string;
+ result_metadata: {
+ columns: any[];
+ };
+ original_definition: {
+ database: number;
+ native: {
+ query: string;
+ };
+ type: 'NATIVE';
+ };
+ visualization_settings: any;
+}
+
+export function addCardAPI(params: AddCardParams) {
+ return http.post('/api/card/', params).then(res => {
+ if (isSuccess(res)) return res.data;
+ return Promise.reject(res);
+ });
+}
+
+interface FetchChildOfCollectionParams {
+ collectionId: number;
+ model: 'collection' | 'dashboard' | 'card';
+}
+
+export function fetchChildOfCollectionAPI(params: FetchChildOfCollectionParams) {
+ const { collectionId, ...restParams } = params;
+ return http.get(`/api/collection/${collectionId}/items`, restParams).then(res => {
+ if (isSuccess(res)) return res.data;
+ return Promise.reject(res);
+ });
+}
diff --git a/frontend/src/routes/visual-query/visual-query.tsx b/frontend/src/routes/visual-query/visual-query.tsx
new file mode 100644
index 0000000..97ce4a0
--- /dev/null
+++ b/frontend/src/routes/visual-query/visual-query.tsx
@@ -0,0 +1,46 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+import React, { useRef, useState } from 'react';
+import VisualQueryProvider from './context';
+import VisualContent from './components/visual-content';
+import VisualHeader from './components/visual-header';
+import SaveQueryModal from './components/save-query-modal';
+import SaveDashboardModal from './components/save-dashboard-modal';
+import { useCollections } from './hooks';
+
+export default function VisualQuery() {
+ const [saveQueryModalVisible, setSaveQueryModalVisible] = useState(false);
+ const [saveDashboardModalVisible, setSaveDashboardModalVisible] = useState(false);
+ const visualContentRef = useRef<{ refreshTableWidth: () => void }>(null);
+ const { collections, loading } = useCollections();
+
+ const onAnimated = () => {
+ if (visualContentRef.current) {
+ visualContentRef.current.refreshTableWidth();
+ }
+ };
+
+ return (
+ <VisualQueryProvider>
+ <VisualHeader setSaveQueryModalVisible={setSaveQueryModalVisible} />
+ <div style={{ display: 'flex' }}>
+ <VisualContent ref={visualContentRef} />
+ </div>
+ </VisualQueryProvider>
+ );
+}