From 9f813ca1c0562240cb5c368edbbba01baaeec51e Mon Sep 17 00:00:00 2001 From: tristanliu <37809633+sevenliu1896@users.noreply.github.com> Date: Thu, 2 Nov 2023 06:11:12 -0500 Subject: [PATCH] [improvement][semantic-fe] Adding batch operations for indicators/dimensions/models (#313) * [improvement][semantic-fe] Add model alias setting & Add view permission restrictions to the model permission management tab. [improvement][semantic-fe] Add permission control to the action buttons for the main domain; apply high sensitivity filtering to the authorization of metrics/dimensions. [improvement][semantic-fe] Optimize the editing mode in the dimension/metric/datasource components to use the modelId stored in the database for data, instead of relying on the data from the state manager. * [improvement][semantic-fe] Add time granularity setting in the data source configuration. * [improvement][semantic-fe] Dictionary import for dimension values supported in Q&A visibility * [improvement][semantic-fe] Modification of data source creation prompt wording" * [improvement][semantic-fe] metric market experience optimization * [improvement][semantic-fe] enhance the analysis of metric trends * [improvement][semantic-fe] optimize the presentation of metric trend permissions * [improvement][semantic-fe] add metric trend download functionality * [improvement][semantic-fe] fix the dimension initialization issue in metric correlation * [improvement][semantic-fe] Fix the issue of database changes not taking effect when creating based on an SQL data source. * [improvement][semantic-fe] Optimizing pagination logic and some CSS styles * [improvement][semantic-fe] Fixing the API for the indicator list by changing "current" to "pageNum" * [improvement][semantic-fe] Fixing the default value setting for the indicator list * [improvement][semantic-fe] Adding batch operations for indicators/dimensions/models --- .../src/components/RemoteSelect/index.tsx | 139 ++++++++++ .../Metric/components/MetricTrend.tsx | 2 +- .../components/MetricTrendDimensionFilter.tsx | 88 +++++++ .../Metric/components/MetricTrendSection.tsx | 148 ++++++++++- .../src/pages/SemanticModel/Metric/index.tsx | 115 +++++++- .../pages/SemanticModel/OverviewContainer.tsx | 2 +- .../components/ClassDimensionTable.tsx | 178 ++++++++++++- .../components/ClassMetricTable.tsx | 168 ++++++++++-- .../DimensionMetricRelationTableTransfer.tsx | 5 +- .../components/ModelCreateFormModal.tsx | 61 ++++- .../SemanticModel/components/ModelTable.tsx | 245 ++++++++++++++++++ .../SemanticModel/components/OverView.tsx | 156 +++++------ .../pages/SemanticModel/components/style.less | 8 + .../src/pages/SemanticModel/data.d.ts | 9 +- .../src/pages/SemanticModel/enum.ts | 8 + .../src/pages/SemanticModel/service.ts | 33 ++- 16 files changed, 1232 insertions(+), 133 deletions(-) create mode 100644 webapp/packages/supersonic-fe/src/components/RemoteSelect/index.tsx create mode 100644 webapp/packages/supersonic-fe/src/pages/SemanticModel/Metric/components/MetricTrendDimensionFilter.tsx create mode 100644 webapp/packages/supersonic-fe/src/pages/SemanticModel/components/ModelTable.tsx diff --git a/webapp/packages/supersonic-fe/src/components/RemoteSelect/index.tsx b/webapp/packages/supersonic-fe/src/components/RemoteSelect/index.tsx new file mode 100644 index 000000000..7c8a97edd --- /dev/null +++ b/webapp/packages/supersonic-fe/src/components/RemoteSelect/index.tsx @@ -0,0 +1,139 @@ +import React, { + useState, + useRef, + useMemo, + useEffect, + forwardRef, + useImperativeHandle, + Ref, +} from 'react'; +import { Select, Spin, Empty } from 'antd'; +import debounce from 'lodash/debounce'; +// import type { ValueTextType } from '@/constants'; +import isFunction from 'lodash/isFunction'; + +type Props = { + fetchOptions: (...restParams: any[]) => Promise<{ label: any; value: any }[]>; + debounceTimeout?: number; + formatPropsValue?: (value: any) => any; + formatFetchOptionsParams?: (inputValue: string, ctx?: any) => any[]; + formatOptions?: (data: any, ctx: any) => any[]; + autoInit?: boolean; + disabledSearch?: boolean; + [key: string]: any; +}; +type SelectOptions = { + label: string; +} & { + text: string; +} & { + value: any; +}; + +export type RemoteSelectImperativeHandle = { + emitSearch: (value: string) => void; +}; + +const { Option } = Select; + +const DebounceSelect = forwardRef( + ( + { + autoInit = false, + fetchOptions, + debounceTimeout = 500, + formatPropsValue, + formatFetchOptionsParams, + formatOptions, + disabledSearch = false, + ...restProps + }: Props, + ref: Ref, + ) => { + const props = { ...restProps }; + const { ctx, filterOption } = props; + if (isFunction(formatPropsValue)) { + props.value = formatPropsValue(props.value); + } + const [fetching, setFetching] = useState(false); + const [options, setOptions] = useState(props.options || props.source || []); + + useImperativeHandle(ref, () => ({ + emitSearch: (value: string) => { + loadOptions(value, true); + }, + })); + + useEffect(() => { + if (autoInit) { + loadOptions('', true); + } + }, []); + useEffect(() => { + setOptions(props.source || []); + }, [props.source]); + + const fetchRef = useRef(0); + + const loadOptions = (value: string, allowEmptyValue?: boolean) => { + setOptions([]); + if (disabledSearch) { + return; + } + if (!allowEmptyValue && !value) return; + fetchRef.current += 1; + const fetchId = fetchRef.current; + setFetching(true); + const fetchParams = formatFetchOptionsParams ? formatFetchOptionsParams(value, ctx) : [value]; + // eslint-disable-next-line prefer-spread + fetchOptions.apply(null, fetchParams).then((newOptions) => { + if (fetchId !== fetchRef.current || !Array.isArray(newOptions)) { + return; + } + let finalOptions = newOptions; + if (formatOptions && isFunction(formatOptions)) { + finalOptions = formatOptions(newOptions, ctx); + } + finalOptions = + filterOption && Array.isArray(finalOptions) + ? filterOption?.(finalOptions, ctx) + : finalOptions; + setOptions(finalOptions); + setFetching(false); + }); + }; + + const debounceFetcher = useMemo(() => { + return debounce(loadOptions, debounceTimeout, { + trailing: true, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fetchOptions, debounceTimeout]); + + return ( + + ); + }, +); +export default DebounceSelect; diff --git a/webapp/packages/supersonic-fe/src/pages/SemanticModel/Metric/components/MetricTrend.tsx b/webapp/packages/supersonic-fe/src/pages/SemanticModel/Metric/components/MetricTrend.tsx index 8a94bb01d..2aabfceae 100644 --- a/webapp/packages/supersonic-fe/src/pages/SemanticModel/Metric/components/MetricTrend.tsx +++ b/webapp/packages/supersonic-fe/src/pages/SemanticModel/Metric/components/MetricTrend.tsx @@ -145,7 +145,7 @@ const TrendChart: React.FC = ({ }, tooltip: { trigger: 'axis', - formatter: function (params: any[]) { + formatter: function (params: any) { const param = params[0]; const valueLabels = params .map( diff --git a/webapp/packages/supersonic-fe/src/pages/SemanticModel/Metric/components/MetricTrendDimensionFilter.tsx b/webapp/packages/supersonic-fe/src/pages/SemanticModel/Metric/components/MetricTrendDimensionFilter.tsx new file mode 100644 index 000000000..029ef6f96 --- /dev/null +++ b/webapp/packages/supersonic-fe/src/pages/SemanticModel/Metric/components/MetricTrendDimensionFilter.tsx @@ -0,0 +1,88 @@ +import { Form, Input, Select, Space, Row, Col, Switch, Tag } from 'antd'; +import StandardFormRow from '@/components/StandardFormRow'; +import TagSelect from '@/components/TagSelect'; +import React, { useEffect, useState, useRef } from 'react'; +import { SENSITIVE_LEVEL_OPTIONS } from '../../constant'; +import { SearchOutlined } from '@ant-design/icons'; +import RemoteSelect, { RemoteSelectImperativeHandle } from '@/components/RemoteSelect'; +import { queryDimValue } from '@/pages/SemanticModel/service'; +import styles from '../style.less'; + +const FormItem = Form.Item; + +type Props = { + dimensionOptions: { value: string; label: string }[]; + modelId: number; + initFilterValues?: any; + onFiltersChange: (_: any, values: any) => void; +}; + +const MetricTrendDimensionFilter: React.FC = ({ + dimensionOptions, + modelId, + initFilterValues = {}, + onFiltersChange, +}) => { + // const [form] = Form.useForm(); + const dimensionValueSearchRef = useRef(); + const queryParams = useRef<{ dimensionBizName?: string }>({}); + const [dimensionValue, setDimensionValue] = useState(''); + // const [queryParams, setQueryParams] = useState({}); + const loadSiteName = async (searchValue: string) => { + if (!queryParams.current?.dimensionBizName) { + // return []; + return; + } + const { dimensionBizName } = queryParams.current; + const { code, data } = await queryDimValue({ + ...queryParams.current, + value: searchValue, + modelId, + limit: 50, + }); + if (code === 200 && Array.isArray(data?.resultList)) { + return data.resultList.slice(0, 50).map((item: any) => ({ + value: item[dimensionBizName], + label: item[dimensionBizName], + })); + } + return []; + }; + + return ( + + + ((option?.label ?? '') as string).toLowerCase().includes(input.toLowerCase()) + } + mode="multiple" + placeholder="请选择下钻维度" + onChange={(value) => { + const params = { ...queryParams, dimensionGroup: value || [] }; + setQueryParams(params); + getMetricTrendData({ ...params }); + }} + /> + + */} + {/* + + + 维度下钻: + + ((option?.label ?? '') as string).toLowerCase().includes(input.toLowerCase()) + } + mode="multiple" + placeholder="请选择下钻维度" + onChange={(value) => { + const params = { ...queryParams, dimensionGroup: value || [] }; + setQueryParams(params); + getMetricTrendData({ ...params }); + }} + /> + + diff --git a/webapp/packages/supersonic-fe/src/pages/SemanticModel/components/ModelTable.tsx b/webapp/packages/supersonic-fe/src/pages/SemanticModel/components/ModelTable.tsx new file mode 100644 index 000000000..16137b889 --- /dev/null +++ b/webapp/packages/supersonic-fe/src/pages/SemanticModel/components/ModelTable.tsx @@ -0,0 +1,245 @@ +import type { ActionType, ProColumns } from '@ant-design/pro-table'; +import ProTable from '@ant-design/pro-table'; +import { message, Button, Space, Popconfirm, Input, Tag } from 'antd'; +import React, { useRef, useState, useEffect } from 'react'; +import { StatusEnum } from '../enum'; +import type { Dispatch } from 'umi'; +import { connect } from 'umi'; +import type { StateType } from '../model'; +import { deleteModel, updateModel } from '../service'; + +import ModelCreateFormModal from './ModelCreateFormModal'; + +import moment from 'moment'; +import styles from './style.less'; +import { ISemantic } from '../data'; + +type Props = { + disabledEdit?: boolean; + modelList: ISemantic.IModelItem[]; + onModelChange?: (model?: ISemantic.IModelItem) => void; + dispatch: Dispatch; + domainManger: StateType; +}; + +const ModelTable: React.FC = ({ + modelList, + domainManger, + disabledEdit = false, + onModelChange, + dispatch, +}) => { + const { selectModelId: modelId, selectDomainId } = domainManger; + const [modelCreateFormModalVisible, setModelCreateFormModalVisible] = useState(false); + const [modelItem, setModelItem] = useState(); + const [saveLoading, setSaveLoading] = useState(false); + const actionRef = useRef(); + + const updateModelStatus = async (modelData: ISemantic.IModelItem) => { + setSaveLoading(true); + const { code, msg } = await updateModel({ + ...modelData, + }); + setSaveLoading(false); + if (code === 200) { + onModelChange?.(); + } else { + message.error(msg); + } + }; + + const columns: ProColumns[] = [ + { + dataIndex: 'id', + title: 'ID', + width: 80, + search: false, + }, + { + dataIndex: 'name', + title: '指标名称', + search: false, + render: (_, record) => { + return ( + { + onModelChange?.(record); + }} + > + {_} + + ); + }, + }, + { + dataIndex: 'key', + title: '指标搜索', + hideInTable: true, + renderFormItem: () => , + }, + { + dataIndex: 'alias', + title: '别名', + width: 150, + ellipsis: true, + search: false, + }, + { + dataIndex: 'bizName', + title: '英文名称', + search: false, + }, + { + dataIndex: 'status', + title: '状态', + search: false, + render: (status) => { + switch (status) { + case StatusEnum.ONLINE: + return 已启用; + case StatusEnum.OFFLINE: + return 未启用; + case StatusEnum.INITIALIZED: + return 初始化; + case StatusEnum.DELETED: + return 已删除; + default: + return 未知; + } + }, + }, + { + dataIndex: 'createdBy', + title: '创建人', + search: false, + }, + { + dataIndex: 'description', + title: '描述', + search: false, + }, + { + dataIndex: 'updatedAt', + title: '更新时间', + search: false, + render: (value: any) => { + return value && value !== '-' ? moment(value).format('YYYY-MM-DD HH:mm:ss') : '-'; + }, + }, + ]; + + if (!disabledEdit) { + columns.push({ + title: '操作', + dataIndex: 'x', + valueType: 'option', + render: (_, record) => { + return ( + + { + setModelItem(record); + setModelCreateFormModalVisible(true); + }} + > + 编辑 + + {record.status === StatusEnum.ONLINE ? ( + + ) : ( + + )} + { + const { code, msg } = await deleteModel(record.id); + if (code === 200) { + onModelChange?.(); + } else { + message.error(msg); + } + }} + > + 删除 + + + ); + }, + }); + } + + return ( + <> + { + return false; + }} + size="small" + options={{ reload: false, density: false, fullScreen: false }} + toolBarRender={() => + disabledEdit + ? [<>] + : [ + , + ] + } + /> + {modelCreateFormModalVisible && ( + { + setModelCreateFormModalVisible(false); + onModelChange?.(); + }} + onCancel={() => { + setModelCreateFormModalVisible(false); + }} + /> + )} + + ); +}; +export default connect(({ domainManger }: { domainManger: StateType }) => ({ + domainManger, +}))(ModelTable); diff --git a/webapp/packages/supersonic-fe/src/pages/SemanticModel/components/OverView.tsx b/webapp/packages/supersonic-fe/src/pages/SemanticModel/components/OverView.tsx index 3c4f63e1f..ea8613b19 100644 --- a/webapp/packages/supersonic-fe/src/pages/SemanticModel/components/OverView.tsx +++ b/webapp/packages/supersonic-fe/src/pages/SemanticModel/components/OverView.tsx @@ -10,6 +10,7 @@ import type { StateType } from '../model'; import { formatNumber } from '../../../utils/utils'; import { deleteModel } from '../service'; import ModelCreateFormModal from './ModelCreateFormModal'; +import ModelTable from './ModelTable'; import styles from './style.less'; type Props = { @@ -26,85 +27,86 @@ const OverView: React.FC = ({ onModelChange, domainManger, }) => { - const { selectDomainId, selectModelId } = domainManger; - const [currentModel, setCurrentModel] = useState({}); - const [modelCreateFormModalVisible, setModelCreateFormModalVisible] = useState(false); + // const { selectDomainId, selectModelId } = domainManger; + // const [currentModel, setCurrentModel] = useState({}); + // const [modelCreateFormModalVisible, setModelCreateFormModalVisible] = useState(false); - const descNode = (model: ISemantic.IDomainItem) => { - const { metricCnt, dimensionCnt } = model; - return ( -
-
-
-
维度数
-
- {formatNumber(dimensionCnt || 0)} -
-
-
-
-
-
指标数
-
- {formatNumber(metricCnt || 0)} -
-
-
-
- ); - }; + // const descNode = (model: ISemantic.IDomainItem) => { + // const { metricCnt, dimensionCnt } = model; + // return ( + //
+ //
+ //
+ //
维度数
+ //
+ // {formatNumber(dimensionCnt || 0)} + //
+ //
+ //
+ //
+ //
+ //
指标数
+ //
+ // {formatNumber(metricCnt || 0)} + //
+ //
+ //
+ //
+ // ); + // }; - const extraNode = (model: ISemantic.IDomainItem) => { - return ( - { - domEvent.stopPropagation(); - if (key === 'edit') { - setCurrentModel(model); - setModelCreateFormModalVisible(true); - } - }, - items: [ - { - label: '编辑', - key: 'edit', - }, - { - label: ( - { - const { code, msg } = await deleteModel(model.id); - if (code === 200) { - onModelChange?.(); - } else { - message.error(msg); - } - }} - > - 删除 - - ), - key: 'delete', - }, - ], - }} - > - e.stopPropagation()} - /> - - ); - }; + // const extraNode = (model: ISemantic.IDomainItem) => { + // return ( + // { + // domEvent.stopPropagation(); + // if (key === 'edit') { + // setCurrentModel(model); + // setModelCreateFormModalVisible(true); + // } + // }, + // items: [ + // { + // label: '编辑', + // key: 'edit', + // }, + // { + // label: ( + // { + // const { code, msg } = await deleteModel(model.id); + // if (code === 200) { + // onModelChange?.(); + // } else { + // message.error(msg); + // } + // }} + // > + // 删除 + // + // ), + // key: 'delete', + // }, + // ], + // }} + // > + // e.stopPropagation()} + // /> + // + // ); + // }; return (
- {!disabledEdit && ( + + {/* {!disabledEdit && (
); }; diff --git a/webapp/packages/supersonic-fe/src/pages/SemanticModel/components/style.less b/webapp/packages/supersonic-fe/src/pages/SemanticModel/components/style.less index d5c023dc0..a72f4075e 100644 --- a/webapp/packages/supersonic-fe/src/pages/SemanticModel/components/style.less +++ b/webapp/packages/supersonic-fe/src/pages/SemanticModel/components/style.less @@ -359,4 +359,12 @@ &:hover { color: #296DF3; } +} + +.ctrlBtnContainer { + :global { + .ant-btn-link { + padding: 0; + } + } } \ No newline at end of file diff --git a/webapp/packages/supersonic-fe/src/pages/SemanticModel/data.d.ts b/webapp/packages/supersonic-fe/src/pages/SemanticModel/data.d.ts index 0a4198e4e..8434ab62c 100644 --- a/webapp/packages/supersonic-fe/src/pages/SemanticModel/data.d.ts +++ b/webapp/packages/supersonic-fe/src/pages/SemanticModel/data.d.ts @@ -1,4 +1,5 @@ import { TreeGraphData } from '@antv/g6-core'; +import { StatusEnum } from './enum'; export type ISODateString = `${number}-${number}-${number}T${number}:${number}:${number}.${number}+${number}:${number}`; @@ -105,7 +106,7 @@ export declare namespace ISemantic { name: string; bizName: string; description: any; - status?: number; + status?: StatusEnum; typeEnum?: any; sensitiveLevel?: number; parentId: number; @@ -118,6 +119,7 @@ export declare namespace ISemantic { entity?: { entityId: number; names: string[] }; dimensionCnt?: number; metricCnt?: number; + drillDownDimensions: IDrillDownDimensionItem[]; } interface IDimensionItem { @@ -169,7 +171,7 @@ export declare namespace ISemantic { interface IDrillDownDimensionItem { dimensionId: number; - necessary: boolean; + necessary?: boolean; } interface IRelateDimension { @@ -185,13 +187,14 @@ export declare namespace ISemantic { name: string; bizName: string; description: string; - status: number; + status: StatusEnum; typeEnum: string; sensitiveLevel: number; domainId: number; domainName: string; modelName: string; modelId: number; + hasAdminRes?: boolean; type: string; typeParams: ITypeParams; fullPath: string; diff --git a/webapp/packages/supersonic-fe/src/pages/SemanticModel/enum.ts b/webapp/packages/supersonic-fe/src/pages/SemanticModel/enum.ts index 35f77e288..e416fa1be 100644 --- a/webapp/packages/supersonic-fe/src/pages/SemanticModel/enum.ts +++ b/webapp/packages/supersonic-fe/src/pages/SemanticModel/enum.ts @@ -26,3 +26,11 @@ export enum DictTaskState { SUCCESS = '成功', UNKNOWN = '未知', } + +export enum StatusEnum { + INITIALIZED = 0, + ONLINE = 1, + OFFLINE = 2, + DELETED = 3, + UNKNOWN = -1, +} diff --git a/webapp/packages/supersonic-fe/src/pages/SemanticModel/service.ts b/webapp/packages/supersonic-fe/src/pages/SemanticModel/service.ts index 1eb2abcaf..bf6835f58 100644 --- a/webapp/packages/supersonic-fe/src/pages/SemanticModel/service.ts +++ b/webapp/packages/supersonic-fe/src/pages/SemanticModel/service.ts @@ -116,6 +116,18 @@ export function updateExprMetric(data: any): Promise { }); } +export function batchUpdateMetricStatus(data: any): Promise { + return request.post(`${process.env.API_BASE_URL}metric/batchUpdateStatus`, { + data, + }); +} + +export function batchUpdateDimensionStatus(data: any): Promise { + return request.post(`${process.env.API_BASE_URL}dimension/batchUpdateStatus`, { + data, + }); +} + export function mockMetricAlias(data: any): Promise { return request.post(`${process.env.API_BASE_URL}metric/mockMetricAlias`, { data, @@ -126,6 +138,12 @@ export function getMetricTags(): Promise { return request.get(`${process.env.API_BASE_URL}metric/getMetricTags`); } +export function getDrillDownDimension(metricId: number): Promise { + return request.get(`${process.env.API_BASE_URL}metric/getDrillDownDimension`, { + params: { metricId }, + }); +} + export function getMeasureListByModelId(modelId: number): Promise { return request.get(`${process.env.API_BASE_URL}datasource/getMeasureListOfModel/${modelId}`); } @@ -380,6 +398,13 @@ const downloadStruct = (blob: Blob) => { document.body.removeChild(link); }; +export function queryDimValue(data: any): Promise { + return request(`${process.env.API_BASE_URL}query/queryDimValue`, { + method: 'POST', + data, + }); +} + export async function queryStruct({ modelId, bizName, @@ -387,6 +412,8 @@ export async function queryStruct({ startDate, endDate, download = false, + groups = [], + dimensionFilters = [], }: { modelId: number; bizName: string; @@ -394,6 +421,8 @@ export async function queryStruct({ startDate: string; endDate: string; download?: boolean; + groups?: string[]; + dimensionFilters?: string[]; }): Promise { const response = await request( `${process.env.API_BASE_URL}query/${download ? 'download/' : ''}struct`, @@ -402,7 +431,8 @@ export async function queryStruct({ ...(download ? { responseType: 'blob', getResponse: true } : {}), data: { modelId, - groups: [dateField], + groups: [dateField, ...groups], + dimensionFilters, aggregators: [ { column: bizName, @@ -412,7 +442,6 @@ export async function queryStruct({ }, ], orders: [], - dimensionFilters: [], metricFilters: [], params: [], dateInfo: {