Variables: Make renamed or missing variable section expandable (#41964) (#42025)

* Variables: Make renamed or missing variable section expandable

* Chore: feedback after PR comments

(cherry picked from commit 44d7d6546f)

Co-authored-by: Hugo Häggmark <hugo.haggmark@grafana.com>
This commit is contained in:
Grot (@grafanabot) 2021-11-22 02:04:34 -05:00 committed by GitHub
parent 272f850fb2
commit fcd0c382b6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 309 additions and 96 deletions

View File

@ -5,20 +5,26 @@ import { Icon } from '..';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
export interface Props { export interface Props {
label: string; label: ReactNode;
isOpen: boolean; isOpen: boolean;
/** Callback for the toggle functionality */
onToggle?: (isOpen: boolean) => void;
children: ReactNode; children: ReactNode;
} }
export const CollapsableSection: FC<Props> = ({ label, isOpen, children }) => { export const CollapsableSection: FC<Props> = ({ label, isOpen, onToggle, children }) => {
const [open, toggleOpen] = useState<boolean>(isOpen); const [open, toggleOpen] = useState<boolean>(isOpen);
const styles = useStyles2(collapsableSectionStyles); const styles = useStyles2(collapsableSectionStyles);
const headerStyle = open ? styles.header : styles.headerCollapsed; const headerStyle = open ? styles.header : styles.headerCollapsed;
const tooltip = `Click to ${open ? 'collapse' : 'expand'}`; const tooltip = `Click to ${open ? 'collapse' : 'expand'}`;
const onClick = () => {
onToggle?.(!open);
toggleOpen(!open);
};
return ( return (
<div> <div>
<div onClick={() => toggleOpen(!open)} className={headerStyle} title={tooltip}> <div onClick={onClick} className={headerStyle} title={tooltip}>
{label} {label}
<Icon name={open ? 'angle-down' : 'angle-right'} size="xl" className={styles.icon} /> <Icon name={open ? 'angle-down' : 'angle-right'} size="xl" className={styles.icon} />
</div> </div>

View File

@ -17,10 +17,7 @@ const mapStateToProps = (state: StoreState) => ({
variables: getEditorVariables(state), variables: getEditorVariables(state),
idInEditor: state.templating.editor.id, idInEditor: state.templating.editor.id,
dashboard: state.dashboard.getModel(), dashboard: state.dashboard.getModel(),
unknownsNetwork: state.templating.inspect.unknownsNetwork,
unknownExists: state.templating.inspect.unknownExits,
usagesNetwork: state.templating.inspect.usagesNetwork, usagesNetwork: state.templating.inspect.usagesNetwork,
unknown: state.templating.inspect.unknown,
usages: state.templating.inspect.usages, usages: state.templating.inspect.usages,
}); });
@ -119,7 +116,7 @@ class VariableEditorContainerUnconnected extends PureComponent<Props> {
usages={this.props.usages} usages={this.props.usages}
usagesNetwork={this.props.usagesNetwork} usagesNetwork={this.props.usagesNetwork}
/> />
{this.props.unknownExists ? <VariablesUnknownTable usages={this.props.unknownsNetwork} /> : null} <VariablesUnknownTable variables={this.props.variables} dashboard={this.props.dashboard} />
</> </>
)} )}
{variableToEdit && <VariableEditorEditor identifier={toVariableIdentifier(variableToEdit)} />} {variableToEdit && <VariableEditorEditor identifier={toVariableIdentifier(variableToEdit)} />}

View File

@ -109,12 +109,10 @@ export const switchToListMode = (): ThunkResult<void> => (dispatch, getState) =>
const state = getState(); const state = getState();
const variables = getEditorVariables(state); const variables = getEditorVariables(state);
const dashboard = state.dashboard.getModel(); const dashboard = state.dashboard.getModel();
const { unknown, usages } = createUsagesNetwork(variables, dashboard); const { usages } = createUsagesNetwork(variables, dashboard);
const unknownsNetwork = transformUsagesToNetwork(unknown);
const unknownExits = Object.keys(unknown).length > 0;
const usagesNetwork = transformUsagesToNetwork(usages); const usagesNetwork = transformUsagesToNetwork(usages);
dispatch(initInspect({ unknown, usages, usagesNetwork, unknownsNetwork, unknownExits })); dispatch(initInspect({ usages, usagesNetwork }));
}; };
export function getNextAvailableId(type: VariableType, variables: VariableModel[]): string { export function getNextAvailableId(type: VariableType, variables: VariableModel[]): string {

View File

@ -25,7 +25,14 @@ export const VariablesUnknownButton: FC<Props> = ({ id, usages }) => {
return ( return (
<NetworkGraphModal show={false} title={`Showing usages for: $${id}`} nodes={nodes} edges={network.edges}> <NetworkGraphModal show={false} title={`Showing usages for: $${id}`} nodes={nodes} edges={network.edges}>
{({ showModal }) => { {({ showModal }) => {
return <IconButton onClick={() => showModal()} name="code-branch" title="Show usages" />; return (
<IconButton
onClick={() => showModal()}
name="code-branch"
title="Show usages"
data-testid="VariablesUnknownButton"
/>
);
}} }}
</NetworkGraphModal> </NetworkGraphModal>
); );

View File

@ -0,0 +1,137 @@
import React from 'react';
import * as runtime from '@grafana/runtime';
import { render, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { VariablesUnknownTable, VariablesUnknownTableProps } from './VariablesUnknownTable';
import { customBuilder } from '../shared/testing/builders';
import * as utils from './utils';
import { UsagesToNetwork } from './utils';
async function getTestContext(
overrides: Partial<VariablesUnknownTableProps> | undefined = {},
usages: UsagesToNetwork[] = []
) {
jest.clearAllMocks();
const reportInteractionSpy = jest.spyOn(runtime, 'reportInteraction').mockImplementation();
const getUnknownsNetworkSpy = jest.spyOn(utils, 'getUnknownsNetwork').mockResolvedValue(usages);
const defaults: VariablesUnknownTableProps = {
variables: [],
dashboard: null,
};
const props = { ...defaults, ...overrides };
const { rerender } = render(<VariablesUnknownTable {...props} />);
await waitFor(() =>
expect(screen.getByRole('heading', { name: /renamed or missing variables/i })).toBeInTheDocument()
);
return { reportInteractionSpy, getUnknownsNetworkSpy, rerender };
}
describe('VariablesUnknownTable', () => {
describe('when rendered', () => {
it('then it should render the section header', async () => {
await getTestContext();
});
});
describe('when expanding the section', () => {
it('then it should show loading spinner', async () => {
await getTestContext();
userEvent.click(screen.getByRole('heading', { name: /renamed or missing variables/i }));
await waitFor(() => expect(screen.getByText('Loading...')).toBeInTheDocument());
});
it('then it should call getUnknownsNetwork', async () => {
const { getUnknownsNetworkSpy } = await getTestContext();
userEvent.click(screen.getByRole('heading', { name: /renamed or missing variables/i }));
await waitFor(() => expect(getUnknownsNetworkSpy).toHaveBeenCalledTimes(1));
});
it('then it should report the interaction', async () => {
const { reportInteractionSpy } = await getTestContext();
userEvent.click(screen.getByRole('heading', { name: /renamed or missing variables/i }));
await waitFor(() => expect(screen.getByText('Loading...')).toBeInTheDocument());
expect(reportInteractionSpy).toHaveBeenCalledTimes(1);
expect(reportInteractionSpy).toHaveBeenCalledWith('Unknown variables section expanded');
});
describe('but when expanding it again without changes to variables or dashboard', () => {
it('then it should not call getUnknownsNetwork', async () => {
const { getUnknownsNetworkSpy } = await getTestContext();
userEvent.click(screen.getByRole('heading', { name: /renamed or missing variables/i }));
await waitFor(() => expect(screen.getByTitle('Click to collapse')).toBeInTheDocument());
expect(getUnknownsNetworkSpy).toHaveBeenCalledTimes(1);
userEvent.click(screen.getByRole('heading', { name: /renamed or missing variables/i }));
await waitFor(() => expect(screen.getByTitle('Click to expand')).toBeInTheDocument());
userEvent.click(screen.getByRole('heading', { name: /renamed or missing variables/i }));
await waitFor(() => expect(screen.getByTitle('Click to collapse')).toBeInTheDocument());
expect(getUnknownsNetworkSpy).toHaveBeenCalledTimes(1);
});
});
describe('and there are no renamed or missing variables', () => {
it('then it should render the correct message', async () => {
await getTestContext();
userEvent.click(screen.getByRole('heading', { name: /renamed or missing variables/i }));
await waitForElementToBeRemoved(() => screen.getByText('Loading...'));
expect(screen.getByText('No renamed or missing variables found.')).toBeInTheDocument();
});
});
describe('and there are renamed or missing variables', () => {
it('then it should render the table', async () => {
const variable = customBuilder().withId('Renamed Variable').withName('Renamed Variable').build();
const usages = [{ variable, nodes: [], edges: [], showGraph: false }];
const { reportInteractionSpy } = await getTestContext({}, usages);
userEvent.click(screen.getByRole('heading', { name: /renamed or missing variables/i }));
await waitForElementToBeRemoved(() => screen.getByText('Loading...'));
expect(screen.queryByText('No renamed or missing variables found.')).not.toBeInTheDocument();
expect(screen.getByText('Renamed Variable')).toBeInTheDocument();
expect(screen.getAllByTestId('VariablesUnknownButton')).toHaveLength(1);
// make sure we don't report the interaction for slow expansion
expect(reportInteractionSpy).toHaveBeenCalledTimes(1);
expect(reportInteractionSpy).toHaveBeenCalledWith('Unknown variables section expanded');
});
describe('but when the unknown processing takes a while', () => {
const origDateNow = Date.now;
afterEach(() => {
Date.now = origDateNow;
});
it('then it should report slow expansion', async () => {
const variable = customBuilder().withId('Renamed Variable').withName('Renamed Variable').build();
const usages = [{ variable, nodes: [], edges: [], showGraph: false }];
const { reportInteractionSpy } = await getTestContext({}, usages);
const dateNowStart = 1000;
const dateNowStop = 2000;
Date.now = jest.fn().mockReturnValueOnce(dateNowStart).mockReturnValue(dateNowStop);
userEvent.click(screen.getByRole('heading', { name: /renamed or missing variables/i }));
await waitForElementToBeRemoved(() => screen.getByText('Loading...'));
// make sure we report the interaction for slow expansion
expect(reportInteractionSpy).toHaveBeenCalledTimes(2);
expect(reportInteractionSpy.mock.calls[0][0]).toEqual('Unknown variables section expanded');
expect(reportInteractionSpy.mock.calls[1][0]).toEqual('Slow unknown variables expansion');
expect(reportInteractionSpy.mock.calls[1][1]).toEqual({ elapsed: 1000 });
});
});
});
});
});

View File

@ -1,26 +1,95 @@
import React, { FC } from 'react'; import React, { ReactElement, useEffect, useState } from 'react';
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { Icon, Tooltip, useStyles } from '@grafana/ui'; import { useAsync } from 'react-use';
import { CollapsableSection, HorizontalGroup, Icon, Spinner, Tooltip, useStyles, VerticalGroup } from '@grafana/ui';
import { GrafanaTheme } from '@grafana/data'; import { GrafanaTheme } from '@grafana/data';
import { UsagesToNetwork } from './utils'; import { reportInteraction } from '@grafana/runtime';
import { VariablesUnknownButton } from './VariablesUnknownButton';
interface Props { import { VariableModel } from '../types';
usages: UsagesToNetwork[]; import { DashboardModel } from '../../dashboard/state';
import { VariablesUnknownButton } from './VariablesUnknownButton';
import { getUnknownsNetwork, UsagesToNetwork } from './utils';
export const SLOW_VARIABLES_EXPANSION_THRESHOLD = 1000;
export interface VariablesUnknownTableProps {
variables: VariableModel[];
dashboard: DashboardModel | null;
} }
export const VariablesUnknownTable: FC<Props> = ({ usages }) => { export function VariablesUnknownTable({ variables, dashboard }: VariablesUnknownTableProps): ReactElement {
const [open, setOpen] = useState(false);
const [changed, setChanged] = useState(0);
const [usages, setUsages] = useState<UsagesToNetwork[]>([]);
const style = useStyles(getStyles); const style = useStyles(getStyles);
useEffect(() => setChanged((prevState) => prevState + 1), [variables, dashboard]);
const { loading } = useAsync(async () => {
if (open && changed > 0) {
// make sure we only fetch when opened and variables or dashboard have changed
const start = Date.now();
const unknownsNetwork = await getUnknownsNetwork(variables, dashboard);
const stop = Date.now();
const elapsed = stop - start;
if (elapsed >= SLOW_VARIABLES_EXPANSION_THRESHOLD) {
reportInteraction('Slow unknown variables expansion', { elapsed });
}
setChanged(0);
setUsages(unknownsNetwork);
return unknownsNetwork;
}
return [];
}, [variables, dashboard, open, changed]);
const onToggle = (isOpen: boolean) => {
if (isOpen) {
reportInteraction('Unknown variables section expanded');
}
setOpen(isOpen);
};
return ( return (
<div className={style.container}> <div className={style.container}>
<CollapsableSection label={<CollapseLabel />} isOpen={open} onToggle={onToggle}>
{loading && (
<VerticalGroup justify="center">
<HorizontalGroup justify="center">
<span>Loading...</span>
<Spinner size={16} />
</HorizontalGroup>
</VerticalGroup>
)}
{!loading && usages && (
<>
{usages.length === 0 && <NoUnknowns />}
{usages.length > 0 && <UnknownTable usages={usages} />}
</>
)}
</CollapsableSection>
</div>
);
}
function CollapseLabel(): ReactElement {
const style = useStyles(getStyles);
return (
<h5> <h5>
Unknown Variables Renamed or missing variables
<Tooltip content="This table lists all variable references that no longer exist in this dashboard."> <Tooltip content="Click to expand a list with all variable references that have been renamed or are missing from the dashboard.">
<Icon name="info-circle" className={style.infoIcon} /> <Icon name="info-circle" className={style.infoIcon} />
</Tooltip> </Tooltip>
</h5> </h5>
);
}
<div> function NoUnknowns(): ReactElement {
return <span>No renamed or missing variables found.</span>;
}
function UnknownTable({ usages }: { usages: UsagesToNetwork[] }): ReactElement {
const style = useStyles(getStyles);
return (
<table className="filter-table filter-table--hover"> <table className="filter-table filter-table--hover">
<thead> <thead>
<tr> <tr>
@ -48,16 +117,13 @@ export const VariablesUnknownTable: FC<Props> = ({ usages }) => {
})} })}
</tbody> </tbody>
</table> </table>
</div>
</div>
); );
}; }
const getStyles = (theme: GrafanaTheme) => ({ const getStyles = (theme: GrafanaTheme) => ({
container: css` container: css`
margin-top: ${theme.spacing.xl}; margin-top: ${theme.spacing.xl};
padding-top: ${theme.spacing.xl}; padding-top: ${theme.spacing.xl};
border-top: 1px solid ${theme.colors.panelBorder};
`, `,
infoIcon: css` infoIcon: css`
margin-left: ${theme.spacing.sm}; margin-left: ${theme.spacing.sm};

View File

@ -10,19 +10,13 @@ describe('variableInspectReducer', () => {
.givenReducer(variableInspectReducer, { ...initialVariableInspectState }) .givenReducer(variableInspectReducer, { ...initialVariableInspectState })
.whenActionIsDispatched( .whenActionIsDispatched(
initInspect({ initInspect({
unknownExits: true,
unknownsNetwork: [{ edges: [], nodes: [], showGraph: true, variable }],
usagesNetwork: [{ edges: [], nodes: [], showGraph: true, variable }], usagesNetwork: [{ edges: [], nodes: [], showGraph: true, variable }],
usages: [{ variable, tree: {} }], usages: [{ variable, tree: {} }],
unknown: [{ variable, tree: {} }],
}) })
) )
.thenStateShouldEqual({ .thenStateShouldEqual({
unknownExits: true,
unknownsNetwork: [{ edges: [], nodes: [], showGraph: true, variable }],
usagesNetwork: [{ edges: [], nodes: [], showGraph: true, variable }], usagesNetwork: [{ edges: [], nodes: [], showGraph: true, variable }],
usages: [{ variable, tree: {} }], usages: [{ variable, tree: {} }],
unknown: [{ variable, tree: {} }],
}); });
}); });
}); });

View File

@ -2,40 +2,22 @@ import { UsagesToNetwork, VariableUsageTree } from './utils';
import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { createSlice, PayloadAction } from '@reduxjs/toolkit';
export interface VariableInspectState { export interface VariableInspectState {
unknown: VariableUsageTree[];
usages: VariableUsageTree[]; usages: VariableUsageTree[];
unknownsNetwork: UsagesToNetwork[];
usagesNetwork: UsagesToNetwork[]; usagesNetwork: UsagesToNetwork[];
unknownExits: boolean;
} }
export const initialVariableInspectState: VariableInspectState = { export const initialVariableInspectState: VariableInspectState = {
unknown: [],
usages: [], usages: [],
unknownsNetwork: [],
usagesNetwork: [], usagesNetwork: [],
unknownExits: false,
}; };
const variableInspectReducerSlice = createSlice({ const variableInspectReducerSlice = createSlice({
name: 'templating/inspect', name: 'templating/inspect',
initialState: initialVariableInspectState, initialState: initialVariableInspectState,
reducers: { reducers: {
initInspect: ( initInspect: (state, action: PayloadAction<{ usages: VariableUsageTree[]; usagesNetwork: UsagesToNetwork[] }>) => {
state, const { usages, usagesNetwork } = action.payload;
action: PayloadAction<{
unknown: VariableUsageTree[];
usages: VariableUsageTree[];
unknownsNetwork: UsagesToNetwork[];
usagesNetwork: UsagesToNetwork[];
unknownExits: boolean;
}>
) => {
const { unknown, usages, unknownExits, unknownsNetwork, usagesNetwork } = action.payload;
state.usages = usages; state.usages = usages;
state.unknown = unknown;
state.unknownsNetwork = unknownsNetwork;
state.unknownExits = unknownExits;
state.usagesNetwork = usagesNetwork; state.usagesNetwork = usagesNetwork;
}, },
}, },

View File

@ -179,29 +179,18 @@ export interface VariableUsageTree {
export interface VariableUsages { export interface VariableUsages {
unUsed: VariableModel[]; unUsed: VariableModel[];
unknown: VariableUsageTree[];
usages: VariableUsageTree[]; usages: VariableUsageTree[];
} }
export const createUsagesNetwork = (variables: VariableModel[], dashboard: DashboardModel | null): VariableUsages => { export const createUsagesNetwork = (variables: VariableModel[], dashboard: DashboardModel | null): VariableUsages => {
if (!dashboard) { if (!dashboard) {
return { unUsed: [], unknown: [], usages: [] }; return { unUsed: [], usages: [] };
} }
const unUsed: VariableModel[] = []; const unUsed: VariableModel[] = [];
let usages: VariableUsageTree[] = []; let usages: VariableUsageTree[] = [];
let unknown: VariableUsageTree[] = [];
const model = dashboard.getSaveModelClone(); 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) { for (const variable of variables) {
const variableId = variable.id; const variableId = variable.id;
const props = getPropsWithVariable(variableId, { key: 'model', value: model }, {}); const props = getPropsWithVariable(variableId, { key: 'model', value: model }, {});
@ -214,9 +203,46 @@ export const createUsagesNetwork = (variables: VariableModel[], dashboard: Dashb
} }
} }
return { unUsed, unknown, usages }; return { unUsed, usages };
}; };
export async function getUnknownsNetwork(
variables: VariableModel[],
dashboard: DashboardModel | null
): Promise<UsagesToNetwork[]> {
return new Promise((resolve, reject) => {
// can be an expensive call so we avoid blocking the main thread
setTimeout(() => {
try {
const unknowns = createUnknownsNetwork(variables, dashboard);
resolve(transformUsagesToNetwork(unknowns));
} catch (e) {
reject(e);
}
}, 200);
});
}
function createUnknownsNetwork(variables: VariableModel[], dashboard: DashboardModel | null): VariableUsageTree[] {
if (!dashboard) {
return [];
}
let unknown: VariableUsageTree[] = [];
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 });
}
}
return unknown;
}
/* /*
getAllAffectedPanelIdsForVariableChange is a function that extracts all the panel ids that are affected by a single variable getAllAffectedPanelIdsForVariableChange is a function that extracts all the panel ids that are affected by a single variable
change. It will traverse all chained variables to identify all cascading changes too. change. It will traverse all chained variables to identify all cascading changes too.