From e54fc47462788e3f0d8055dbf9df40992d1ad9e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Thu, 5 Nov 2020 09:53:27 +0100 Subject: [PATCH] Variables: Adds variables inspection (#25214) * Chore: initial commit * Refactor: adds usages * Refactor: adds FeatureInfoBox * Refactor: uses graphs instead * Refactor: adds id, name, title to prop instead of key * Refactor: adds usages to VariablesList * Refactor: Moves unknowns to first page * Refactor: minor stylings and icons * Refactor: styling * Refactor: changed to button with modal * Refactor: adds VariablesDependenciesButton * Refactor: changes after UX feedback * Refactor: renames heading * Refactor: changes after PR comments * Refactor: small changes after PR comments --- package.json | 3 +- .../grafana-ui/src/components/Modal/Modal.tsx | 8 +- .../src/components/Modal/ModalHeader.tsx | 11 +- .../editor/VariableEditorContainer.tsx | 46 +-- .../variables/editor/VariableEditorList.tsx | 64 ++++- .../variables/inspect/NetworkGraph.tsx | 74 +++++ .../variables/inspect/NetworkGraphModal.tsx | 44 +++ .../inspect/VariableUsagesButton.tsx | 54 ++++ .../inspect/VariablesDependenciesButton.tsx | 46 +++ .../inspect/VariablesUnknownButton.tsx | 52 ++++ .../inspect/VariablesUnknownTable.tsx | 105 +++++++ .../features/variables/inspect/utils.test.ts | 47 ++++ .../app/features/variables/inspect/utils.ts | 264 ++++++++++++++++++ yarn.lock | 34 +++ 14 files changed, 825 insertions(+), 27 deletions(-) create mode 100644 public/app/features/variables/inspect/NetworkGraph.tsx create mode 100644 public/app/features/variables/inspect/NetworkGraphModal.tsx create mode 100644 public/app/features/variables/inspect/VariableUsagesButton.tsx create mode 100644 public/app/features/variables/inspect/VariablesDependenciesButton.tsx create mode 100644 public/app/features/variables/inspect/VariablesUnknownButton.tsx create mode 100644 public/app/features/variables/inspect/VariablesUnknownTable.tsx create mode 100644 public/app/features/variables/inspect/utils.test.ts create mode 100644 public/app/features/variables/inspect/utils.ts diff --git a/package.json b/package.json index ab451c78e39..08cbf7f06e8 100644 --- a/package.json +++ b/package.json @@ -288,7 +288,8 @@ "tinycolor2": "1.4.1", "tti-polyfill": "0.2.2", "uuid": "8.3.0", - "whatwg-fetch": "3.1.0" + "whatwg-fetch": "3.1.0", + "visjs-network": "4.25.0" }, "resolutions": { "caniuse-db": "1.0.30000772" diff --git a/packages/grafana-ui/src/components/Modal/Modal.tsx b/packages/grafana-ui/src/components/Modal/Modal.tsx index ef213e47abe..0e4a19c2eb9 100644 --- a/packages/grafana-ui/src/components/Modal/Modal.tsx +++ b/packages/grafana-ui/src/components/Modal/Modal.tsx @@ -2,14 +2,14 @@ import React from 'react'; import { Portal } from '../Portal/Portal'; import { cx } from 'emotion'; import { withTheme } from '../../themes'; -import { IconName } from '../../types'; -import { Themeable } from '../../types'; +import { IconName, Themeable } from '../../types'; import { getModalStyles } from './getModalStyles'; import { ModalHeader } from './ModalHeader'; import { IconButton } from '../IconButton/IconButton'; export interface Props extends Themeable { icon?: IconName; + iconTooltip?: string; /** Title for the modal or custom header element */ title: string | JSX.Element; className?: string; @@ -33,9 +33,9 @@ export class UnthemedModal extends React.PureComponent { }; renderDefaultHeader(title: string) { - const { icon } = this.props; + const { icon, iconTooltip } = this.props; - return ; + return ; } render() { diff --git a/packages/grafana-ui/src/components/Modal/ModalHeader.tsx b/packages/grafana-ui/src/components/Modal/ModalHeader.tsx index 73ee7e04aac..4a8d46e4f20 100644 --- a/packages/grafana-ui/src/components/Modal/ModalHeader.tsx +++ b/packages/grafana-ui/src/components/Modal/ModalHeader.tsx @@ -3,20 +3,27 @@ import { getModalStyles } from './getModalStyles'; import { IconName } from '../../types'; import { ThemeContext } from '../../themes'; import { Icon } from '../Icon/Icon'; +import { Tooltip } from '..'; interface Props { title: string; icon?: IconName; + iconTooltip?: string; } -export const ModalHeader: React.FC = ({ icon, title, children }) => { +export const ModalHeader: React.FC = ({ icon, iconTooltip, title, children }) => { const theme = useContext(ThemeContext); const styles = getModalStyles(theme); return ( <>

- {icon && } + {icon && !iconTooltip && } + {icon && iconTooltip && ( + + + + )} {title}

{children} diff --git a/public/app/features/variables/editor/VariableEditorContainer.tsx b/public/app/features/variables/editor/VariableEditorContainer.tsx index 65910544868..96fab774102 100644 --- a/public/app/features/variables/editor/VariableEditorContainer.tsx +++ b/public/app/features/variables/editor/VariableEditorContainer.tsx @@ -4,7 +4,6 @@ import { selectors } from '@grafana/e2e-selectors'; import { NEW_VARIABLE_ID, toVariableIdentifier, toVariablePayload, VariableIdentifier } from '../state/types'; import { StoreState } from '../../../types'; -import { VariableEditorList } from './VariableEditorList'; import { VariableEditorEditor } from './VariableEditorEditor'; import { MapDispatchToProps, MapStateToProps } from 'react-redux'; import { connectWithStore } from '../../../core/utils/connectWithReduxStore'; @@ -12,12 +11,17 @@ import { getEditorVariables } from '../state/selectors'; import { VariableModel } from '../types'; import { switchToEditMode, switchToListMode, switchToNewMode } from './actions'; import { changeVariableOrder, duplicateVariable, removeVariable } from '../state/sharedReducer'; +import { VariableEditorList } from './VariableEditorList'; +import { DashboardModel } from '../../dashboard/state'; +import { VariablesUnknownTable } from '../inspect/VariablesUnknownTable'; +import { VariablesDependenciesButton } from '../inspect/VariablesDependenciesButton'; interface OwnProps {} interface ConnectedProps { idInEditor: string | null; variables: VariableModel[]; + dashboard: DashboardModel | null; } interface DispatchProps { @@ -96,26 +100,33 @@ class VariableEditorContainerUnconnected extends PureComponent {
{this.props.variables.length > 0 && variableToEdit === null && ( - - New - + <> + + + New + + )}
{!variableToEdit && ( - + <> + + + )} {variableToEdit && } @@ -126,6 +137,7 @@ class VariableEditorContainerUnconnected extends PureComponent { const mapStateToProps: MapStateToProps = state => ({ variables: getEditorVariables(state), idInEditor: state.templating.editor.id, + dashboard: state.dashboard.getModel(), }); const mapDispatchToProps: MapDispatchToProps = { diff --git a/public/app/features/variables/editor/VariableEditorList.tsx b/public/app/features/variables/editor/VariableEditorList.tsx index 9999bd08867..df4f48c6953 100644 --- a/public/app/features/variables/editor/VariableEditorList.tsx +++ b/public/app/features/variables/editor/VariableEditorList.tsx @@ -1,13 +1,20 @@ -import React, { MouseEvent, PureComponent } from 'react'; -import { IconButton } from '@grafana/ui'; +import React, { FC, MouseEvent, PureComponent } from 'react'; +import { css } from 'emotion'; +import { Icon, IconButton, useStyles } from '@grafana/ui'; import { selectors } from '@grafana/e2e-selectors'; +import { GrafanaTheme } from '@grafana/data'; import EmptyListCTA from '../../../core/components/EmptyListCTA/EmptyListCTA'; import { QueryVariableModel, VariableModel } from '../types'; import { toVariableIdentifier, VariableIdentifier } from '../state/types'; +import { DashboardModel } from '../../dashboard/state'; +import { getVariableUsages } from '../inspect/utils'; +import { isAdHoc } from '../guard'; +import { VariableUsagesButton } from '../inspect/VariableUsagesButton'; export interface Props { variables: VariableModel[]; + dashboard: DashboardModel | null; onAddClick: (event: MouseEvent) => void; onEditClick: (identifier: VariableIdentifier) => void; onChangeVariableOrder: (identifier: VariableIdentifier, fromIndex: number, toIndex: number) => void; @@ -79,12 +86,14 @@ export class VariableEditorList extends PureComponent { Variable Definition - + {this.props.variables.map((state, index) => { const variable = state as QueryVariableModel; + const usages = getVariableUsages(variable.id, this.props.variables, this.props.dashboard); + const passed = usages > 0 || isAdHoc(variable); return ( @@ -109,6 +118,18 @@ export class VariableEditorList extends PureComponent { {variable.definition ? variable.definition : variable.query} + + + + + + + + {index > 0 && ( { /> )} + {index < this.props.variables.length - 1 && ( { /> )} + this.onDuplicateVariable(event, toVariableIdentifier(variable))} @@ -143,6 +166,7 @@ export class VariableEditorList extends PureComponent { )} /> + this.onRemoveVariable(event, toVariableIdentifier(variable))} @@ -165,3 +189,37 @@ export class VariableEditorList extends PureComponent { ); } } + +interface VariableCheckIndicatorProps { + passed: boolean; +} + +const VariableCheckIndicator: FC = ({ passed }) => { + const style = useStyles(getStyles); + if (passed) { + return ( + + ); + } + + return ( + + ); +}; + +const getStyles = (theme: GrafanaTheme) => ({ + iconPassed: css` + color: ${theme.palette.greenBase}; + `, + iconFailed: css` + color: ${theme.palette.orange}; + `, +}); diff --git a/public/app/features/variables/inspect/NetworkGraph.tsx b/public/app/features/variables/inspect/NetworkGraph.tsx new file mode 100644 index 00000000000..ef67aa56120 --- /dev/null +++ b/public/app/features/variables/inspect/NetworkGraph.tsx @@ -0,0 +1,74 @@ +import React, { FC, useCallback, useEffect, useRef } from 'react'; +// @ts-ignore +import vis from 'visjs-network'; +import { GraphEdge, GraphNode, toVisNetworkEdges, toVisNetworkNodes } from './utils'; + +interface OwnProps { + nodes: GraphNode[]; + edges: GraphEdge[]; + direction?: 'UD' | 'DU' | 'LR' | 'RL'; + onDoubleClick?: (node: string) => void; + width?: string; + height?: string; +} + +interface ConnectedProps {} + +interface DispatchProps {} + +export type Props = OwnProps & ConnectedProps & DispatchProps; + +export const NetworkGraph: FC = ({ nodes, edges, direction, width, height, onDoubleClick }) => { + let network: any = null; + const ref = useRef(null); + + const onNodeDoubleClick = useCallback( + (params: { nodes: string[] }) => { + if (onDoubleClick) { + onDoubleClick(params.nodes[0]); + } + }, + [onDoubleClick] + ); + + useEffect(() => { + const data = { + nodes: toVisNetworkNodes(nodes), + edges: toVisNetworkEdges(edges), + }; + + const options = { + width: '100%', + height: '100%', + autoResize: true, + layout: { + improvedLayout: true, + hierarchical: { + enabled: true, + direction: direction ?? 'DU', + sortMethod: 'directed', + }, + }, + interaction: { + navigationButtons: true, + dragNodes: false, + }, + }; + + network = new vis.Network(ref.current, data, options); + network.on('doubleClick', onNodeDoubleClick); + + return () => { + // unsubscribe event handlers + if (network) { + network.off('doubleClick'); + } + }; + }, []); + + return ( +
+
+
+ ); +}; diff --git a/public/app/features/variables/inspect/NetworkGraphModal.tsx b/public/app/features/variables/inspect/NetworkGraphModal.tsx new file mode 100644 index 00000000000..36a1ba21b54 --- /dev/null +++ b/public/app/features/variables/inspect/NetworkGraphModal.tsx @@ -0,0 +1,44 @@ +import { GraphEdge, GraphNode } from './utils'; +import React, { useCallback, useState } from 'react'; +import { Modal } from '@grafana/ui'; +import { NetworkGraph, Props as NetWorkGraphProps } from './NetworkGraph'; + +interface NetworkGraphModalApi { + showModal: () => void; +} + +interface OwnProps extends Pick { + show: boolean; + title: string; + nodes: GraphNode[]; + edges: GraphEdge[]; + children: (api: NetworkGraphModalApi) => JSX.Element; +} + +interface ConnectedProps {} + +interface DispatchProps {} + +type Props = OwnProps & ConnectedProps & DispatchProps; + +export function NetworkGraphModal({ edges, nodes, show: propsShow, title, children }: Props): JSX.Element { + const [show, setShow] = useState(propsShow); + const showModal = useCallback(() => setShow(true), [setShow]); + const onClose = useCallback(() => setShow(false), [setShow]); + + return ( + <> + + + + {children({ showModal })} + + ); +} diff --git a/public/app/features/variables/inspect/VariableUsagesButton.tsx b/public/app/features/variables/inspect/VariableUsagesButton.tsx new file mode 100644 index 00000000000..37139a33a7d --- /dev/null +++ b/public/app/features/variables/inspect/VariableUsagesButton.tsx @@ -0,0 +1,54 @@ +import React, { FC, useMemo } from 'react'; +import { Provider } from 'react-redux'; +import { IconButton } from '@grafana/ui'; + +import { createUsagesNetwork, transformUsagesToNetwork } from './utils'; +import { store } from '../../../store/store'; +import { isAdHoc } from '../guard'; +import { NetworkGraphModal } from './NetworkGraphModal'; +import { VariableModel } from '../types'; +import { DashboardModel } from '../../dashboard/state'; + +interface OwnProps { + variables: VariableModel[]; + variable: VariableModel; + dashboard: DashboardModel | null; +} + +interface ConnectedProps {} + +interface DispatchProps {} + +type Props = OwnProps & ConnectedProps & DispatchProps; + +export const UnProvidedVariableUsagesGraphButton: FC = ({ variables, variable, dashboard }) => { + const { id } = variable; + const { usages } = useMemo(() => createUsagesNetwork(variables, dashboard), [variables, dashboard]); + const network = useMemo(() => transformUsagesToNetwork(usages).find(n => n.variable.id === id), [usages, id]); + const adhoc = useMemo(() => isAdHoc(variable), [variable]); + + if (usages.length === 0 || adhoc || !network) { + return null; + } + + const nodes = network.nodes.map(n => { + if (n.label.includes(`$${id}`)) { + return { ...n, color: '#FB7E81' }; + } + return n; + }); + + return ( + + {({ showModal }) => { + return showModal()} name="code-branch" title="Show usages" />; + }} + + ); +}; + +export const VariableUsagesButton: FC = props => ( + + + +); diff --git a/public/app/features/variables/inspect/VariablesDependenciesButton.tsx b/public/app/features/variables/inspect/VariablesDependenciesButton.tsx new file mode 100644 index 00000000000..21a55aceab9 --- /dev/null +++ b/public/app/features/variables/inspect/VariablesDependenciesButton.tsx @@ -0,0 +1,46 @@ +import React, { FC, useMemo } from 'react'; +import { Provider } from 'react-redux'; +// @ts-ignore +import { Button } from '@grafana/ui'; +import { createDependencyEdges, createDependencyNodes, filterNodesWithDependencies } from './utils'; +import { store } from '../../../store/store'; +import { VariableModel } from '../types'; +import { NetworkGraphModal } from './NetworkGraphModal'; + +interface OwnProps { + variables: VariableModel[]; +} + +interface ConnectedProps {} + +interface DispatchProps {} + +type Props = OwnProps & ConnectedProps & DispatchProps; + +export const UnProvidedVariablesDependenciesButton: FC = ({ variables }) => { + const nodes = useMemo(() => createDependencyNodes(variables), [variables]); + const edges = useMemo(() => createDependencyEdges(variables), [variables]); + + return ( + + {({ showModal }) => { + return ( + + ); + }} + + ); +}; + +export const VariablesDependenciesButton: FC = props => ( + + + +); diff --git a/public/app/features/variables/inspect/VariablesUnknownButton.tsx b/public/app/features/variables/inspect/VariablesUnknownButton.tsx new file mode 100644 index 00000000000..adc06e0998b --- /dev/null +++ b/public/app/features/variables/inspect/VariablesUnknownButton.tsx @@ -0,0 +1,52 @@ +import React, { FC, useMemo } from 'react'; +import { Provider } from 'react-redux'; +import { IconButton } from '@grafana/ui'; +import { createUsagesNetwork, transformUsagesToNetwork } from './utils'; +import { store } from '../../../store/store'; +import { VariableModel } from '../types'; +import { DashboardModel } from '../../dashboard/state'; +import { NetworkGraphModal } from './NetworkGraphModal'; + +interface OwnProps { + variable: VariableModel; + variables: VariableModel[]; + dashboard: DashboardModel | null; +} + +interface ConnectedProps {} + +interface DispatchProps {} + +type Props = OwnProps & ConnectedProps & DispatchProps; + +export const UnProvidedVariablesUnknownGraphButton: FC = ({ variable, variables, dashboard }) => { + const { id } = variable; + const { unknown } = useMemo(() => createUsagesNetwork(variables, dashboard), [variables, dashboard]); + const network = useMemo(() => transformUsagesToNetwork(unknown).find(n => n.variable.id === id), [id, unknown]); + const unknownExist = useMemo(() => Object.keys(unknown).length > 0, [unknown]); + + if (!unknownExist || !network) { + return null; + } + + const nodes = network.nodes.map(n => { + if (n.label.includes(`$${id}`)) { + return { ...n, color: '#FB7E81' }; + } + return n; + }); + + return ( + + {({ showModal }) => { + return showModal()} name="code-branch" title="Show usages" />; + }} + + ); +}; + +export const VariablesUnknownButton: FC = props => ( + + + +); diff --git a/public/app/features/variables/inspect/VariablesUnknownTable.tsx b/public/app/features/variables/inspect/VariablesUnknownTable.tsx new file mode 100644 index 00000000000..27b937a2bee --- /dev/null +++ b/public/app/features/variables/inspect/VariablesUnknownTable.tsx @@ -0,0 +1,105 @@ +import React, { FC, useMemo } from 'react'; +import { Provider } from 'react-redux'; +import { css } from 'emotion'; +import { Icon, Tooltip, useStyles } from '@grafana/ui'; +import { GrafanaTheme } from '@grafana/data'; +import { createUsagesNetwork, transformUsagesToNetwork } from './utils'; +import { store } from '../../../store/store'; +import { VariablesUnknownButton } from './VariablesUnknownButton'; +import { VariableModel } from '../types'; +import { DashboardModel } from '../../dashboard/state'; + +interface OwnProps { + variables: VariableModel[]; + dashboard: DashboardModel | null; +} + +interface ConnectedProps {} + +interface DispatchProps {} + +type Props = OwnProps & ConnectedProps & DispatchProps; + +export const UnProvidedVariablesUnknownTable: FC = ({ variables, dashboard }) => { + const style = useStyles(getStyles); + const { unknown } = useMemo(() => createUsagesNetwork(variables, dashboard), [variables, dashboard]); + const networks = useMemo(() => transformUsagesToNetwork(unknown), [unknown]); + const unknownExist = useMemo(() => Object.keys(unknown).length > 0, [unknown]); + + if (!unknownExist) { + return null; + } + + return ( +
+
+ Unknown Variables + + + +
+ +
+ + + + + + + + {networks.map(network => { + const { variable } = network; + const { id, name } = variable; + return ( + + + + + ); + })} + +
Variable +
+ {name} + + + + + +
+
+
+ ); +}; + +const getStyles = (theme: GrafanaTheme) => ({ + container: css` + margin-top: ${theme.spacing.xl}; + padding-top: ${theme.spacing.xl}; + border-top: 1px solid ${theme.colors.panelBorder}; + `, + infoIcon: css` + margin-left: ${theme.spacing.sm}; + `, + defaultColumn: css` + width: 1%; + `, + firstColumn: css` + width: 1%; + vertical-align: top; + color: ${theme.colors.textStrong}; + `, + lastColumn: css` + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + width: 100%; + text-align: right; + `, +}); + +export const VariablesUnknownTable: FC = props => ( + + + +); diff --git a/public/app/features/variables/inspect/utils.test.ts b/public/app/features/variables/inspect/utils.test.ts new file mode 100644 index 00000000000..979056d53b1 --- /dev/null +++ b/public/app/features/variables/inspect/utils.test.ts @@ -0,0 +1,47 @@ +import { getPropsWithVariable } from './utils'; + +describe('getPropsWithVariable', () => { + it('when called it should return the correct graph', () => { + const result = getPropsWithVariable( + '$unknownVariable', + { + key: 'model', + value: { + templating: { + list: [ + { + current: { + selected: false, + text: 'No data sources found', + value: '', + }, + hide: 0, + includeAll: false, + label: null, + multi: false, + name: 'dependsOnUnknown', + options: [], + query: 'prometheus', + refresh: 1, + regex: '/.*$unknownVariable.*/', + skipUrlSync: false, + type: 'datasource', + }, + ], + }, + }, + }, + {} + ); + + expect(result).toEqual({ + templating: { + list: { + dependsOnUnknown: { + regex: '/.*$unknownVariable.*/', + }, + }, + }, + }); + }); +}); diff --git a/public/app/features/variables/inspect/utils.ts b/public/app/features/variables/inspect/utils.ts new file mode 100644 index 00000000000..e6fbb0d31fe --- /dev/null +++ b/public/app/features/variables/inspect/utils.ts @@ -0,0 +1,264 @@ +// @ts-ignore +import vis from 'visjs-network'; + +import { variableAdapters } from '../adapters'; +import { DashboardModel } from '../../dashboard/state'; +import { isAdHoc } from '../guard'; +import { safeStringifyValue } from '../../../core/utils/explore'; +import { VariableModel } from '../types'; +import { containsVariable, variableRegex } from '../utils'; + +export interface GraphNode { + id: string; + label: string; +} + +export interface GraphEdge { + from: string; + to: string; +} + +export const createDependencyNodes = (variables: VariableModel[]): GraphNode[] => { + const nodes: GraphNode[] = []; + + for (const variable of variables) { + nodes.push({ id: variable.id, label: `${variable.id}` }); + } + + return nodes; +}; + +export const filterNodesWithDependencies = (nodes: GraphNode[], edges: GraphEdge[]): GraphNode[] => { + return nodes.filter(node => edges.some(edge => edge.from === node.id || edge.to === node.id)); +}; + +export const createDependencyEdges = (variables: VariableModel[]): GraphEdge[] => { + const edges: GraphEdge[] = []; + + for (const variable of variables) { + for (const other of variables) { + if (variable === other) { + continue; + } + + const dependsOn = variableAdapters.get(variable.type).dependsOn(variable, other); + + if (dependsOn) { + edges.push({ from: variable.id, to: other.id }); + } + } + } + + return edges; +}; + +export const toVisNetworkNodes = (nodes: GraphNode[]): any[] => { + const nodesWithStyle: any[] = nodes.map(node => ({ + ...node, + shape: 'box', + })); + return new vis.DataSet(nodesWithStyle); +}; + +export const toVisNetworkEdges = (edges: GraphEdge[]): any[] => { + const edgesWithStyle: any[] = edges.map(edge => ({ ...edge, arrows: 'to', dashes: true })); + return new vis.DataSet(edgesWithStyle); +}; + +export const getUnknownVariableStrings = (variables: VariableModel[], model: any) => { + const unknownVariableNames: string[] = []; + const modelAsString = safeStringifyValue(model, 2); + const matches = modelAsString.match(variableRegex); + + if (!matches) { + return unknownVariableNames; + } + + for (const match of matches) { + if (!match) { + continue; + } + + if (match.indexOf('$__') !== -1) { + // ignore builtin variables + continue; + } + + if (match.indexOf('$hashKey') !== -1) { + // ignore Angular props + continue; + } + + const variableName = match.slice(1); + + if (variables.some(variable => variable.id === variableName)) { + // ignore defined variables + continue; + } + + if (unknownVariableNames.find(name => name === variableName)) { + continue; + } + + unknownVariableNames.push(variableName); + } + + return unknownVariableNames; +}; + +export const getPropsWithVariable = (variableId: string, parent: { key: string; value: any }, result: any) => { + const stringValues = Object.keys(parent.value).reduce((all, key) => { + const value = parent.value[key]; + if (value && typeof value === 'string' && containsVariable(value, variableId)) { + all = { + ...all, + [key]: value, + }; + } + + return all; + }, {}); + + const objectValues = Object.keys(parent.value).reduce((all, key) => { + const value = parent.value[key]; + if (value && typeof value === 'object' && Object.keys(value).length) { + const id = value.title || value.name || value.id || key; + const newResult = getPropsWithVariable(variableId, { key, value }, {}); + if (Object.keys(newResult).length) { + all = { + ...all, + [id]: newResult, + }; + } + } + + return all; + }, {}); + + if (Object.keys(stringValues).length || Object.keys(objectValues).length) { + result = { + ...result, + ...stringValues, + ...objectValues, + }; + } + + return result; +}; + +export interface VariableUsages { + unUsed: VariableModel[]; + unknown: Array<{ variable: VariableModel; tree: any }>; + usages: Array<{ variable: VariableModel; tree: any }>; +} + +export const createUsagesNetwork = (variables: VariableModel[], dashboard: DashboardModel | null): VariableUsages => { + if (!dashboard) { + return { unUsed: [], unknown: [], usages: [] }; + } + + const unUsed: VariableModel[] = []; + let usages: Array<{ variable: VariableModel; tree: any }> = []; + let unknown: Array<{ variable: VariableModel; tree: any }> = []; + const model = dashboard.getSaveModelClone(); + + const unknownVariables = getUnknownVariableStrings(variables, model); + for (const unknownVariable of unknownVariables) { + const props = getPropsWithVariable(unknownVariable, { key: 'model', value: model }, {}); + if (Object.keys(props).length) { + const variable = ({ id: unknownVariable, name: unknownVariable } as unknown) as VariableModel; + unknown.push({ variable, tree: props }); + } + } + + for (const variable of variables) { + const variableId = variable.id; + const props = getPropsWithVariable(variableId, { key: 'model', value: model }, {}); + if (!Object.keys(props).length && !isAdHoc(variable)) { + unUsed.push(variable); + } + + if (Object.keys(props).length) { + usages.push({ variable, tree: props }); + } + } + + return { unUsed, unknown, usages }; +}; + +export interface UsagesToNetwork { + variable: VariableModel; + nodes: GraphNode[]; + edges: GraphEdge[]; + showGraph: boolean; +} + +export const traverseTree = (usage: UsagesToNetwork, parent: { id: string; value: any }): UsagesToNetwork => { + const { id, value } = parent; + const { nodes, edges } = usage; + + if (value && typeof value === 'string') { + const leafId = `${parent.id}-${value}`; + nodes.push({ id: leafId, label: value }); + edges.push({ from: leafId, to: id }); + + return usage; + } + + if (value && typeof value === 'object') { + const keys = Object.keys(value); + for (const key of keys) { + const leafId = `${parent.id}-${key}`; + nodes.push({ id: leafId, label: key }); + edges.push({ from: leafId, to: id }); + usage = traverseTree(usage, { id: leafId, value: value[key] }); + } + + return usage; + } + + return usage; +}; + +export const transformUsagesToNetwork = (usages: Array<{ variable: VariableModel; tree: any }>): UsagesToNetwork[] => { + const results: UsagesToNetwork[] = []; + + for (const usage of usages) { + const { variable, tree } = usage; + const result: UsagesToNetwork = { + variable, + nodes: [{ id: 'dashboard', label: 'dashboard' }], + edges: [], + showGraph: false, + }; + results.push(traverseTree(result, { id: 'dashboard', value: tree })); + } + + return results; +}; + +const countLeaves = (object: any): number => { + const total = Object.values(object).reduce((count: number, value: any) => { + if (typeof value === 'object') { + return count + countLeaves(value); + } + + return count + 1; + }, 0); + + return (total as unknown) as number; +}; + +export const getVariableUsages = ( + variableId: string, + variables: VariableModel[], + dashboard: DashboardModel | null +): number => { + const { usages } = createUsagesNetwork(variables, dashboard); + const usage = usages.find(usage => usage.variable.id === variableId); + if (!usage) { + return 0; + } + + return countLeaves(usage.tree); +}; diff --git a/yarn.lock b/yarn.lock index abcb1a9be63..290aef47be9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12029,6 +12029,11 @@ emittery@^0.7.1: resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.7.1.tgz#c02375a927a40948c0345cc903072597f5270451" integrity sha512-d34LN4L6h18Bzz9xpoku2nPwKxCPlPMr3EEKTkoEBi+1/+b0lcRkRJ1UVyyZaKNeqGR3swcGl6s390DNO4YVgQ== +emitter-component@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/emitter-component/-/emitter-component-1.1.1.tgz#065e2dbed6959bf470679edabeaf7981d1003ab6" + integrity sha1-Bl4tvtaVm/RwZ57avq95gdEAOrY= + "emoji-regex@>=6.0.0 <=6.1.1": version "6.1.1" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-6.1.1.tgz#c6cd0ec1b0642e2a3c67a1137efc5e796da4f88e" @@ -14537,6 +14542,11 @@ gzip-size@5.1.1, gzip-size@^5.0.0: duplexer "^0.1.1" pify "^4.0.1" +hammerjs@^2.0.8: + version "2.0.8" + resolved "https://registry.yarnpkg.com/hammerjs/-/hammerjs-2.0.8.tgz#04ef77862cff2bb79d30f7692095930222bf60f1" + integrity sha1-BO93hiz/K7edMPdpIJWTAiK/YPE= + handle-thing@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-2.0.0.tgz#0e039695ff50c93fc288557d696f3c1dc6776754" @@ -17436,6 +17446,11 @@ just-extend@^4.0.2: resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-4.0.2.tgz#f3f47f7dfca0f989c55410a7ebc8854b07108afc" integrity sha512-FrLwOgm+iXrPV+5zDU6Jqu4gCRXbWEQg2O3SKONsWE4w7AXFRkryS53bpWdaL9cNol+AmR3AEYz6kn+o0fCPnw== +keycharm@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/keycharm/-/keycharm-0.2.0.tgz#fa6ea2e43b90a68028843d27f2075d35a8c3e6f9" + integrity sha1-+m6i5DuQpoAohD0n8gddNajD5vk= + killable@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/killable/-/killable-1.0.1.tgz#4c8ce441187a061c7474fb87ca08e2a638194892" @@ -21366,6 +21381,13 @@ prop-types@15.7.2, prop-types@15.x, prop-types@^15.0.0, prop-types@^15.5.0, prop object-assign "^4.1.1" react-is "^16.8.1" +propagating-hammerjs@^1.4.6: + version "1.4.7" + resolved "https://registry.yarnpkg.com/propagating-hammerjs/-/propagating-hammerjs-1.4.7.tgz#671ab1791a7f88b9dccce8fd8b14a9655950e7d6" + integrity sha512-oW9Wd+W2Tp5uOz6Fh4mEU7p+FoyU85smLH/mPga83Loh0pHa6AH4ZHGywvwMk3TWP31l7iUsvJyW265p4Ipwrg== + dependencies: + hammerjs "^2.0.8" + property-information@^5.0.0, property-information@^5.0.1, property-information@^5.3.0: version "5.3.0" resolved "https://registry.yarnpkg.com/property-information/-/property-information-5.3.0.tgz#bc87ac82dc4e72a31bb62040544b1bf9653da039" @@ -26722,6 +26744,18 @@ vfile@^4.0.0: unist-util-stringify-position "^2.0.0" vfile-message "^2.0.0" +visjs-network@4.25.0: + version "4.25.0" + resolved "https://registry.yarnpkg.com/visjs-network/-/visjs-network-4.25.0.tgz#a3f0a9ac490b49346a0284986fcd40f8c0622335" + integrity sha512-DL6RXd+G6XME2qHMrSh9VYPbkqrY4Hs/liBuNCA1E0wq6bUpQljrM7ePhZMy7zD0yA3wQziOQFB78tvSOevKEQ== + dependencies: + emitter-component "^1.1.1" + hammerjs "^2.0.8" + keycharm "^0.2.0" + moment "^2.20.1" + propagating-hammerjs "^1.4.6" + timsort "^0.3.0" + vm-browserify@^1.0.1: version "1.1.0" resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.0.tgz#bd76d6a23323e2ca8ffa12028dc04559c75f9019"