[improvement][semantic-fe] Added an editing component to set filtering rules for Q&A. Now, the SQL editor will be accompanied by a list for display and control, to resolve ambiguity when using comma-separated values.

[improvement][semantic-fe] Improved validation logic and prompt copywriting for data source/dimension/metric editing and creation.
[improvement][semantic-fe] Improved user experience for visual modeling. Now, when using the legend to control the display/hide of data sources and their associated metric dimensions, the canvas will be re-layout based on the activated data source in the legend.

Co-authored-by: tristanliu <tristanliu@tencent.com>
This commit is contained in:
tristanliu
2023-07-21 15:30:38 +08:00
committed by GitHub
parent 6492316e23
commit 078a81038f
39 changed files with 1541 additions and 1161 deletions

View File

@@ -1,137 +1,90 @@
import React, { useEffect, useState, useRef } from 'react';
import { connect } from 'umi';
import type { StateType } from '../model';
import { IGroup } from '@antv/g-base';
import type { Dispatch } from 'umi';
import { typeConfigs } from './utils';
import { message, Row, Col, Radio } from 'antd';
import { getDatasourceList, getDomainSchemaRela } from '../service';
import {
typeConfigs,
formatterRelationData,
loopNodeFindDataSource,
getNodeConfigByType,
flatGraphDataNode,
} from './utils';
import { message } from 'antd';
import { getDomainSchemaRela } from '../service';
import { Item, TreeGraphData, NodeConfig, IItemBaseConfig } from '@antv/g6-core';
import initToolBar from './components/ToolBar';
import initTooltips from './components/ToolTips';
import initContextMenu from './components/ContextMenu';
import initLegend from './components/Legend';
import { SemanticNodeType } from '../enum';
import G6 from '@antv/g6';
import { ISemantic, IDataSource } from '../data';
import DimensionInfoModal from '../components/DimensionInfoModal';
import MetricInfoCreateForm from '../components/MetricInfoCreateForm';
import DeleteConfirmModal from './components/DeleteConfirmModal';
import { cloneDeep } from 'lodash';
type Props = {
domainId: number;
graphShowType: SemanticNodeType;
domainManger: StateType;
dispatch: Dispatch;
};
const DomainManger: React.FC<Props> = ({ domainManger, domainId }) => {
const DomainManger: React.FC<Props> = ({
domainManger,
domainId,
graphShowType = SemanticNodeType.DIMENSION,
dispatch,
}) => {
const ref = useRef(null);
const [graphData, setGraphData] = useState<any>({});
const [dataSourceListData, setDataSourceListData] = useState<any[]>([]);
const [graphShowType, setGraphShowType] = useState<string>('dimension');
const [graphData, setGraphData] = useState<TreeGraphData>();
const [createDimensionModalVisible, setCreateDimensionModalVisible] = useState<boolean>(false);
const [createMetricModalVisible, setCreateMetricModalVisible] = useState<boolean>(false);
const legendDataRef = useRef<any[]>([]);
const graphRef = useRef<any>(null);
const legendDataFilterFunctions = useRef<any>({});
const [dimensionItem, setDimensionItem] = useState<ISemantic.IDimensionItem>();
// const { dimensionList } = domainManger;
const [metricItem, setMetricItem] = useState<ISemantic.IMetricItem>();
const toggleNodeVisibility = (graph, node, visible) => {
if (visible) {
graph.showItem(node);
} else {
graph.hideItem(node);
}
};
const [nodeDataSource, setNodeDataSource] = useState<any>();
const toggleChildrenVisibility = (graph, node, visible) => {
const model = node.getModel();
if (model.children) {
model.children.forEach((child) => {
const childNode = graph.findById(child.id);
toggleNodeVisibility(graph, childNode, visible);
toggleChildrenVisibility(graph, childNode, visible);
});
}
};
const { dimensionList, metricList } = domainManger;
const getDimensionChildren = (dimensions: any[], dataSourceId: string) => {
const dimensionChildrenList = dimensions.reduce((dimensionChildren: any[], dimension: any) => {
const {
id: dimensionId,
name: dimensionName,
bizName,
description,
createdBy,
updatedAt,
} = dimension;
// if (datasourceId === id) {
dimensionChildren.push({
nodeType: 'dimension',
legendType: dataSourceId,
id: `dimension-${dimensionId}`,
name: dimensionName,
bizName,
description,
createdBy,
updatedAt,
style: {
lineWidth: 2,
fill: '#f0f7ff',
stroke: '#a6ccff',
},
});
// }
return dimensionChildren;
}, []);
return dimensionChildrenList;
};
const dimensionListRef = useRef<ISemantic.IDimensionItem[]>([]);
const metricListRef = useRef<ISemantic.IMetricItem[]>([]);
const getMetricChildren = (metrics: any[], dataSourceId: string) => {
const metricsChildrenList = metrics.reduce((metricsChildren: any[], dimension: any) => {
const { id, name, bizName, description, createdBy, updatedAt } = dimension;
metricsChildren.push({
nodeType: 'metric',
legendType: dataSourceId,
id: `dimension-${id}`,
name,
bizName,
description,
createdBy,
updatedAt,
style: {
lineWidth: 2,
fill: '#f0f7ff',
stroke: '#a6ccff',
},
});
return metricsChildren;
}, []);
return metricsChildrenList;
};
const [confirmModalOpenState, setConfirmModalOpenState] = useState<boolean>(false);
const formatterRelationData = (dataSourceList: any[], type = graphShowType) => {
const relationData = dataSourceList.reduce((relationList: any[], item: any) => {
const { datasource, dimensions, metrics } = item;
const { id, name } = datasource;
const dataSourceId = `dataSource-${id}`;
let childrenList = [];
if (type === 'metric') {
childrenList = getMetricChildren(metrics, dataSourceId);
}
if (type === 'dimension') {
childrenList = getDimensionChildren(dimensions, dataSourceId);
}
relationList.push({
name,
legendType: dataSourceId,
id: dataSourceId,
nodeType: 'datasource',
size: 40,
children: [...childrenList],
style: {
lineWidth: 2,
fill: '#BDEFDB',
stroke: '#5AD8A6',
},
});
return relationList;
}, []);
return relationData;
};
// const toggleNodeVisibility = (graph: Graph, node: Item, visible: boolean) => {
// if (visible) {
// graph.showItem(node);
// } else {
// graph.hideItem(node);
// }
// };
const changeGraphData = (data: any, type?: string) => {
useEffect(() => {
dimensionListRef.current = dimensionList;
metricListRef.current = metricList;
}, [dimensionList, metricList]);
// const toggleChildrenVisibility = (graph: Graph, node: Item, visible: boolean) => {
// const model = node.getModel();
// if (Array.isArray(model.children)) {
// model.children.forEach((child) => {
// const childNode = graph.findById(child.id);
// toggleNodeVisibility(graph, childNode, visible);
// toggleChildrenVisibility(graph, childNode, visible);
// });
// }
// };
const changeGraphData = (data: IDataSource.IDataSourceItem[], type: SemanticNodeType) => {
const relationData = formatterRelationData(data, type);
const legendList = relationData.map((item: any) => {
const { id, name } = item;
@@ -148,31 +101,38 @@ const DomainManger: React.FC<Props> = ({ domainManger, domainId }) => {
name: domainManger.selectDomainName,
children: relationData,
};
setGraphData(graphRootData);
//
return graphRootData;
};
const queryDataSourceList = async (params: any) => {
const { code, data, msg } = await getDomainSchemaRela(params.domainId);
const queryDataSourceList = async (params: {
domainId: number;
graphShowType?: SemanticNodeType;
}) => {
const { code, data } = await getDomainSchemaRela(params.domainId);
if (code === 200) {
if (data) {
changeGraphData(data);
setDataSourceListData(data);
const graphRootData = changeGraphData(data, params.graphShowType || graphShowType);
setGraphData(graphRootData);
return graphRootData;
}
return false;
} else {
message.error(msg);
return false;
}
};
useEffect(() => {
graphRef.current = null;
queryDataSourceList({ domainId });
}, [domainId]);
}, [domainId, graphShowType]);
const getLegendDataFilterFunctions = () => {
legendDataRef.current.map((item: any) => {
const { id } = item;
legendDataFilterFunctions.current = {
...legendDataFilterFunctions.current,
[id]: (d) => {
[id]: (d: any) => {
if (d.legendType === id) {
return true;
}
@@ -184,6 +144,9 @@ const DomainManger: React.FC<Props> = ({ domainManger, domainId }) => {
const setAllActiveLegend = (legend: any) => {
const legendCanvas = legend._cfgs.legendCanvas;
if (!legendCanvas) {
return;
}
// 从图例中找出node-group节点;
const group = legendCanvas.find((e: any) => e.get('name') === 'node-group');
// 数据源的图例节点在node-group中的children中
@@ -195,211 +158,366 @@ const DomainManger: React.FC<Props> = ({ domainManger, domainId }) => {
legend.activateLegend(labelText);
});
};
// const [visible, setVisible] = useState(false);
useEffect(() => {
if (!(Array.isArray(graphData.children) && graphData.children.length > 0)) {
const handleContextMenuClickEdit = (item: IItemBaseConfig) => {
const targetData = item.model;
if (!targetData) {
return;
}
const container = document.getElementById('semanticGraph');
const width = container!.scrollWidth;
const height = container!.scrollHeight || 500;
const datasource = loopNodeFindDataSource(item);
if (datasource) {
setNodeDataSource({
id: datasource.uid,
name: datasource.name,
});
}
if (targetData.nodeType === SemanticNodeType.DIMENSION) {
const targetItem = dimensionListRef.current.find((item) => item.id === targetData.uid);
if (targetItem) {
setDimensionItem({ ...targetItem });
setCreateDimensionModalVisible(true);
} else {
message.error('获取维度初始化数据失败');
}
}
if (targetData.nodeType === SemanticNodeType.METRIC) {
const targetItem = metricListRef.current.find((item) => item.id === targetData.uid);
if (targetItem) {
setMetricItem({ ...targetItem });
setCreateMetricModalVisible(true);
} else {
message.error('获取指标初始化数据失败');
}
}
};
// if (!graphRef.current) {
getLegendDataFilterFunctions();
const toolbar = initToolBar();
const tooltip = initTooltips();
const contextMenu = initContextMenu();
const legend = new G6.Legend({
// container: 'legendContainer',
data: {
nodes: legendDataRef.current,
},
align: 'center',
layout: 'horizontal', // vertical
position: 'bottom-right',
vertiSep: 12,
horiSep: 24,
offsetY: -24,
padding: [4, 16, 8, 16],
containerStyle: {
fill: '#ccc',
lineWidth: 1,
},
title: '可见数据源',
titleConfig: {
position: 'center',
offsetX: 0,
offsetY: 12,
style: {
fontSize: 12,
fontWeight: 500,
fill: '#000',
},
},
filter: {
enable: true,
multiple: true,
trigger: 'click',
graphActiveState: 'activeByLegend',
graphInactiveState: 'inactiveByLegend',
filterFunctions: {
...legendDataFilterFunctions.current,
},
},
const handleContextMenuClickCreate = (item: IItemBaseConfig) => {
const datasource = item.model;
if (!datasource) {
return;
}
setNodeDataSource({
id: datasource.uid,
name: datasource.name,
});
if (graphShowType === SemanticNodeType.DIMENSION) {
setCreateDimensionModalVisible(true);
}
if (graphShowType === SemanticNodeType.METRIC) {
setCreateMetricModalVisible(true);
}
setDimensionItem(undefined);
setMetricItem(undefined);
};
graphRef.current = new G6.TreeGraph({
container: 'semanticGraph',
width,
height,
linkCenter: true,
modes: {
default: [
{
type: 'collapse-expand',
onChange: function onChange(item, collapsed) {
const data = item.get('model');
data.collapsed = collapsed;
return true;
},
},
'drag-node',
'drag-canvas',
// 'activate-relations',
'zoom-canvas',
{
type: 'activate-relations',
trigger: 'mouseenter', // 触发方式,可以是 'mouseenter' 或 'click'
resetSelected: true, // 点击空白处时,是否取消高亮
},
],
},
defaultNode: {
size: 26,
labelCfg: {
position: 'right',
offset: 5,
style: {
stroke: '#fff',
lineWidth: 4,
},
},
},
const handleContextMenuClickDelete = (item: IItemBaseConfig) => {
const targetData = item.model;
if (!targetData) {
return;
}
if (targetData.nodeType === SemanticNodeType.DIMENSION) {
const targetItem = dimensionListRef.current.find((item) => item.id === targetData.uid);
if (targetItem) {
setDimensionItem({ ...targetItem });
setConfirmModalOpenState(true);
} else {
message.error('获取维度初始化数据失败');
}
}
if (targetData.nodeType === SemanticNodeType.METRIC) {
const targetItem = metricListRef.current.find((item) => item.id === targetData.uid);
if (targetItem) {
setMetricItem({ ...targetItem });
setConfirmModalOpenState(true);
} else {
message.error('获取指标初始化数据失败');
}
}
};
const handleContextMenuClick = (key: string, item: Item) => {
if (!item?._cfg) {
return;
}
switch (key) {
case 'edit':
handleContextMenuClickEdit(item._cfg);
break;
case 'delete':
handleContextMenuClickDelete(item._cfg);
break;
case 'create':
handleContextMenuClickCreate(item._cfg);
break;
default:
break;
}
};
const graphConfigMap = {
dendrogram: {
defaultEdge: {
type: 'cubic-horizontal',
// type: 'flow-line',
// type: 'polyline',
// type: 'line',
/* configure the bending radius and min distance to the end nodes */
style: {
radius: 10,
offset: 30,
endArrow: true,
/* and other styles */
// stroke: '#F6BD16',
},
// style: {
// stroke: '#A3B1BF',
// },
},
layout: {
// type: 'mindmap',
// direction: 'H',
// getId: function getId(d) {
// return d.id;
// },
// getHeight: function getHeight() {
// return 16;
// },
// getWidth: function getWidth() {
// return 16;
// },
// getVGap: function getVGap() {
// return 30;
// },
// getHGap: function getHGap() {
// return 100;
// },
type: 'dendrogram',
direction: 'LR',
animate: false,
nodeSep: 200,
rankSep: 300,
radial: true,
},
plugins: [legend, tooltip, toolbar, contextMenu],
});
},
mindmap: {
defaultEdge: {
type: 'polyline',
},
layout: {
type: 'mindmap',
animate: false,
direction: 'H',
getHeight: () => {
return 50;
},
getWidth: () => {
return 50;
},
getVGap: () => {
return 10;
},
getHGap: () => {
return 50;
},
},
},
};
const legendCanvas = legend._cfgs.legendCanvas;
// legend模式事件方法bindEvents会有点击图例空白清空选中的逻辑在注册click事件前先将click事件队列清空
legend._cfgs.legendCanvas._events.click = [];
legendCanvas.on('click', (e) => {
const shape = e.target;
const shapeGroup = shape.get('parent');
const shapeGroupId = shapeGroup?.cfg?.id;
if (shapeGroupId) {
const isActive = shapeGroup.get('active');
const targetNode = graph.findById(shapeGroupId);
// const model = targetNode.getModel();
toggleNodeVisibility(graph, targetNode, isActive);
toggleChildrenVisibility(graph, targetNode, isActive);
}
});
useEffect(() => {
if (!Array.isArray(graphData?.children)) {
return;
}
const container = document.getElementById('semanticGraph');
const width = container!.scrollWidth;
const height = container!.scrollHeight || 500;
const graph = graphRef.current;
graph.node(function (node) {
return {
label: node.name,
labelCfg: { style: { fill: '#3c3c3c' } },
};
});
// graph.data(graphData);
graph.changeData(graphData);
graph.render();
graph.fitView();
if (!graph && graphData) {
const graphNodeList = flatGraphDataNode(graphData.children);
const graphConfigKey = graphNodeList.length > 20 ? 'dendrogram' : 'mindmap';
setAllActiveLegend(legend);
getLegendDataFilterFunctions();
const toolbar = initToolBar({ refreshGraphData });
const tooltip = initTooltips();
const contextMenu = initContextMenu({
graphShowType,
onMenuClick: handleContextMenuClick,
});
const legend = initLegend({
nodeData: legendDataRef.current,
filterFunctions: { ...legendDataFilterFunctions.current },
});
const rootNode = graph.findById('root');
graph.hideItem(rootNode);
if (typeof window !== 'undefined')
window.onresize = () => {
if (!graph || graph.get('destroyed')) return;
if (!container || !container.scrollWidth || !container.scrollHeight) return;
graph.changeSize(container.scrollWidth, container.scrollHeight);
};
// }
graphRef.current = new G6.TreeGraph({
container: 'semanticGraph',
width,
height,
modes: {
default: [
{
type: 'collapse-expand',
onChange: function onChange(item, collapsed) {
const data = item!.get('model');
data.collapsed = collapsed;
return true;
},
},
'drag-node',
'drag-canvas',
// 'activate-relations',
'zoom-canvas',
{
type: 'activate-relations',
trigger: 'mouseenter', // 触发方式,可以是 'mouseenter' 或 'click'
resetSelected: true, // 点击空白处时,是否取消高亮
},
],
},
defaultNode: {
size: 26,
anchorPoints: [
[0, 0.5],
[1, 0.5],
],
labelCfg: {
position: 'right',
offset: 5,
style: {
stroke: '#fff',
lineWidth: 4,
},
},
},
defaultEdge: {
type: graphConfigMap[graphConfigKey].defaultEdge.type,
},
layout: {
...graphConfigMap[graphConfigKey].layout,
},
plugins: [legend, tooltip, toolbar, contextMenu],
});
graphRef.current.set('initGraphData', graphData);
const legendCanvas = legend._cfgs.legendCanvas;
// legend模式事件方法bindEvents会有点击图例空白清空选中的逻辑在注册click事件前先将click事件队列清空
legend._cfgs.legendCanvas._events.click = [];
// legendCanvas.on('click', (e) => {
// const shape = e.target;
// const shapeGroup = shape.get('parent');
// const shapeGroupId = shapeGroup?.cfg?.id;
// if (shapeGroupId) {
// const isActive = shapeGroup.get('active');
// const targetNode = graphRef.current.findById(shapeGroupId);
// toggleNodeVisibility(graphRef.current, targetNode, isActive);
// toggleChildrenVisibility(graphRef.current, targetNode, isActive);
// }
// });
legendCanvas.on('click', () => {
// @ts-ignore findLegendItemsByState为Legend的 private方法忽略ts校验
const activedNodeList = legend.findLegendItemsByState('active');
// 获取当前所有激活节点后进行数据遍历筛选;
const activedNodeIds = activedNodeList.map((item: IGroup) => {
return item.cfg.id;
});
const graphDataClone = cloneDeep(graphData);
const filterGraphDataChildren = Array.isArray(graphDataClone?.children)
? graphDataClone.children.reduce((children: TreeGraphData[], item: TreeGraphData) => {
if (activedNodeIds.includes(item.id)) {
children.push(item);
}
return children;
}, [])
: [];
graphDataClone.children = filterGraphDataChildren;
refreshGraphData(graphDataClone);
});
graphRef.current.node(function (node: NodeConfig) {
return getNodeConfigByType(node, {
label: node.name,
});
});
graphRef.current.data(graphData);
graphRef.current.render();
graphRef.current.fitView([80, 80]);
setAllActiveLegend(legend);
const rootNode = graphRef.current.findById('root');
graphRef.current.hideItem(rootNode);
if (typeof window !== 'undefined')
window.onresize = () => {
if (!graphRef.current || graphRef.current.get('destroyed')) return;
if (!container || !container.scrollWidth || !container.scrollHeight) return;
graphRef.current.changeSize(container.scrollWidth, container.scrollHeight);
};
}
}, [graphData]);
const updateGraphData = async () => {
const graphRootData = await queryDataSourceList({ domainId });
if (graphRootData) {
refreshGraphData(graphRootData);
}
};
const refreshGraphData = (graphRootData: TreeGraphData) => {
graphRef.current.changeData(graphRootData);
const rootNode = graphRef.current.findById('root');
graphRef.current.hideItem(rootNode);
graphRef.current.fitView();
};
return (
<>
<Row>
<Col flex="auto" />
<Col flex="100px">
<Radio.Group
buttonStyle="solid"
size="small"
value={graphShowType}
onChange={(e) => {
const { value } = e.target;
setGraphShowType(value);
changeGraphData(dataSourceListData, value);
}}
>
<Radio.Button value="dimension"></Radio.Button>
<Radio.Button value="metric"></Radio.Button>
</Radio.Group>
</Col>
</Row>
<div
ref={ref}
key={`${domainId}-${graphShowType}`}
id="semanticGraph"
style={{ width: '100%', height: '100%' }}
/>
{createDimensionModalVisible && (
<DimensionInfoModal
domainId={domainId}
bindModalVisible={createDimensionModalVisible}
dimensionItem={dimensionItem}
dataSourceList={nodeDataSource ? [nodeDataSource] : []}
onSubmit={() => {
setCreateDimensionModalVisible(false);
updateGraphData();
dispatch({
type: 'domainManger/queryDimensionList',
payload: {
domainId,
},
});
}}
onCancel={() => {
setCreateDimensionModalVisible(false);
}}
/>
)}
{createMetricModalVisible && (
<MetricInfoCreateForm
domainId={domainId}
key={metricItem?.id}
datasourceId={nodeDataSource.id}
createModalVisible={createMetricModalVisible}
metricItem={metricItem}
onSubmit={() => {
setCreateMetricModalVisible(false);
updateGraphData();
dispatch({
type: 'domainManger/queryMetricList',
payload: {
domainId,
},
});
}}
onCancel={() => {
setCreateMetricModalVisible(false);
}}
/>
)}
{
<DeleteConfirmModal
open={confirmModalOpenState}
onOkClick={() => {
setConfirmModalOpenState(false);
updateGraphData();
graphShowType === SemanticNodeType.DIMENSION
? dispatch({
type: 'domainManger/queryDimensionList',
payload: {
domainId,
},
})
: dispatch({
type: 'domainManger/queryMetricList',
payload: {
domainId,
},
});
}}
onCancelClick={() => {
setConfirmModalOpenState(false);
}}
nodeType={graphShowType}
nodeData={graphShowType === SemanticNodeType.DIMENSION ? dimensionItem : metricItem}
/>
}
</>
);
};