QueryEditorRow: Ability to change query name (#29779)

* QueryEditorRow: Ability to change query name

* Style tweaks

* Updated UX

* Fixed tests

* Added validation messages

* Fixed keyboard navigation

* Updated tests
This commit is contained in:
Torkel Ödegaard 2021-01-07 15:33:15 +01:00 committed by GitHub
parent 4c8735d2ff
commit 1c808be0f3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 319 additions and 72 deletions

View File

@ -19,8 +19,14 @@ export default {
}, },
}; };
export const simple = () => { export const vertical = () => {
const { message } = getKnobs(); const { message } = getKnobs();
return <FieldValidationMessage>{message}</FieldValidationMessage>; return <FieldValidationMessage>{message}</FieldValidationMessage>;
}; };
export const horizontal = () => {
const { message } = getKnobs();
return <FieldValidationMessage horizontal>{message}</FieldValidationMessage>;
};

View File

@ -8,31 +8,52 @@ export interface FieldValidationMessageProps {
children: string; children: string;
/** Override component style */ /** Override component style */
className?: string; className?: string;
horizontal?: boolean;
} }
export const getFieldValidationMessageStyles = stylesFactory((theme: GrafanaTheme) => { export const getFieldValidationMessageStyles = stylesFactory((theme: GrafanaTheme) => {
return { const baseStyle = `
fieldValidationMessage: css`
font-size: ${theme.typography.size.sm}; font-size: ${theme.typography.size.sm};
font-weight: ${theme.typography.weight.semibold}; font-weight: ${theme.typography.weight.semibold};
margin: ${theme.spacing.formValidationMessageMargin};
padding: ${theme.spacing.formValidationMessagePadding}; padding: ${theme.spacing.formValidationMessagePadding};
color: ${theme.colors.formValidationMessageText}; color: ${theme.colors.formValidationMessageText};
background: ${theme.colors.formValidationMessageBg}; background: ${theme.colors.formValidationMessageBg};
border-radius: ${theme.border.radius.sm}; border-radius: ${theme.border.radius.sm};
position: relative; position: relative;
display: inline-block; display: inline-block;
`;
return {
vertical: css`
${baseStyle}
margin: ${theme.spacing.formValidationMessageMargin};
&:before { &:before {
content: ''; content: '';
position: absolute; position: absolute;
left: 9px; left: 9px;
top: -4px; top: -5px;
width: 0; width: 0;
height: 0; height: 0;
border-left: 4px solid transparent; border-width: 0 4px 5px 4px;
border-right: 4px solid transparent; border-color: transparent transparent ${theme.colors.formValidationMessageBg} transparent;
border-bottom: 4px solid ${theme.colors.formValidationMessageBg}; border-style: solid;
}
`,
horizontal: css`
${baseStyle}
margin-left: 10px;
&:before {
content: '';
position: absolute;
left: -5px;
top: 9px;
width: 0;
height: 0;
border-width: 4px 5px 4px 0;
border-color: transparent #e02f44 transparent transparent;
border-style: solid;
} }
`, `,
fieldValidationMessageIcon: css` fieldValidationMessageIcon: css`
@ -41,12 +62,13 @@ export const getFieldValidationMessageStyles = stylesFactory((theme: GrafanaThem
}; };
}); });
export const FieldValidationMessage: React.FC<FieldValidationMessageProps> = ({ children, className }) => { export const FieldValidationMessage: React.FC<FieldValidationMessageProps> = ({ children, horizontal, className }) => {
const theme = useTheme(); const theme = useTheme();
const styles = getFieldValidationMessageStyles(theme); const styles = getFieldValidationMessageStyles(theme);
const cssName = cx(horizontal ? styles.horizontal : styles.vertical, className);
return ( return (
<div role="alert" className={cx(styles.fieldValidationMessage, className)}> <div role="alert" className={cssName}>
<Icon className={styles.fieldValidationMessageIcon} name="exclamation-triangle" /> <Icon className={styles.fieldValidationMessageIcon} name="exclamation-triangle" />
{children} {children}
</div> </div>

View File

@ -32,7 +32,7 @@ describe('QueryOperationRow', () => {
const onOpenSpy = jest.fn(); const onOpenSpy = jest.fn();
const onCloseSpy = jest.fn(); const onCloseSpy = jest.fn();
const wrapper = mount( const wrapper = mount(
<QueryOperationRow onOpen={onOpenSpy} onClose={onCloseSpy} isOpen={false} id="test-id" index={0}> <QueryOperationRow title="title" onOpen={onOpenSpy} onClose={onCloseSpy} isOpen={false} id="test-id" index={0}>
<div>Test</div> <div>Test</div>
</QueryOperationRow> </QueryOperationRow>
); );
@ -56,11 +56,11 @@ describe('QueryOperationRow', () => {
}); });
}); });
describe('title rendering', () => { describe('headerElement rendering', () => {
it('should render title provided as element', () => { it('should render headerElement provided as element', () => {
const title = <div aria-label="test title">Test</div>; const title = <div aria-label="test title">Test</div>;
const wrapper = shallow( const wrapper = shallow(
<QueryOperationRow title={title} id="test-id" index={0}> <QueryOperationRow headerElement={title} id="test-id" index={0}>
<div>Test</div> <div>Test</div>
</QueryOperationRow> </QueryOperationRow>
); );
@ -68,10 +68,11 @@ describe('QueryOperationRow', () => {
const titleEl = wrapper.find({ 'aria-label': 'test title' }); const titleEl = wrapper.find({ 'aria-label': 'test title' });
expect(titleEl).toHaveLength(1); expect(titleEl).toHaveLength(1);
}); });
it('should render title provided as function', () => {
it('should render headerElement provided as function', () => {
const title = () => <div aria-label="test title">Test</div>; const title = () => <div aria-label="test title">Test</div>;
const wrapper = shallow( const wrapper = shallow(
<QueryOperationRow title={title} id="test-id" index={0}> <QueryOperationRow headerElement={title} id="test-id" index={0}>
<div>Test</div> <div>Test</div>
</QueryOperationRow> </QueryOperationRow>
); );
@ -80,14 +81,14 @@ describe('QueryOperationRow', () => {
expect(titleEl).toHaveLength(1); expect(titleEl).toHaveLength(1);
}); });
it('should expose api to title rendered as function', () => { it('should expose api to headerElement rendered as function', () => {
const propsSpy = jest.fn(); const propsSpy = jest.fn();
const title = (props: any) => { const title = (props: any) => {
propsSpy(props); propsSpy(props);
return <div aria-label="test title">Test</div>; return <div aria-label="test title">Test</div>;
}; };
shallow( shallow(
<QueryOperationRow title={title} id="test-id" index={0}> <QueryOperationRow headerElement={title} id="test-id" index={0}>
<div>Test</div> <div>Test</div>
</QueryOperationRow> </QueryOperationRow>
); );
@ -132,9 +133,7 @@ describe('QueryOperationRow', () => {
</QueryOperationRow> </QueryOperationRow>
); );
expect(Object.keys(propsSpy.mock.calls[0][0])).toContainEqual('isOpen'); expect(Object.keys(propsSpy.mock.calls[0][0])).toEqual(['isOpen', 'onOpen', 'onClose']);
expect(Object.keys(propsSpy.mock.calls[0][0])).toContainEqual('openRow');
expect(Object.keys(propsSpy.mock.calls[0][0])).toContainEqual('closeRow');
}); });
}); });
}); });

View File

@ -8,11 +8,9 @@ import { Draggable } from 'react-beautiful-dnd';
interface QueryOperationRowProps { interface QueryOperationRowProps {
index: number; index: number;
id: string; id: string;
title?: ((props: { isOpen: boolean }) => React.ReactNode) | React.ReactNode; title?: string;
headerElement?: React.ReactNode; headerElement?: QueryOperationRowRenderProp;
actions?: actions?: QueryOperationRowRenderProp;
| ((props: { isOpen: boolean; openRow: () => void; closeRow: () => void }) => React.ReactNode)
| React.ReactNode;
onOpen?: () => void; onOpen?: () => void;
onClose?: () => void; onClose?: () => void;
children: React.ReactNode; children: React.ReactNode;
@ -20,6 +18,14 @@ interface QueryOperationRowProps {
draggable?: boolean; draggable?: boolean;
} }
export type QueryOperationRowRenderProp = ((props: QueryOperationRowRenderProps) => React.ReactNode) | React.ReactNode;
export interface QueryOperationRowRenderProps {
isOpen: boolean;
onOpen: () => void;
onClose: () => void;
}
export const QueryOperationRow: React.FC<QueryOperationRowProps> = ({ export const QueryOperationRow: React.FC<QueryOperationRowProps> = ({
children, children,
actions, actions,
@ -51,26 +57,33 @@ export const QueryOperationRow: React.FC<QueryOperationRowProps> = ({
} }
}, [isContentVisible]); }, [isContentVisible]);
const titleElement = title && renderOrCallToRender(title, { isOpen: isContentVisible }); const renderPropArgs: QueryOperationRowRenderProps = {
const actionsElement = isOpen: isContentVisible,
actions && onOpen: () => {
renderOrCallToRender(actions, { setIsContentVisible(true);
isOpen: isContentVisible, },
openRow: () => { onClose: () => {
setIsContentVisible(true); setIsContentVisible(false);
}, },
closeRow: () => { };
setIsContentVisible(false);
}, const titleElement = title && renderOrCallToRender(title, renderPropArgs);
}); const actionsElement = actions && renderOrCallToRender(actions, renderPropArgs);
const headerElementRendered = headerElement && renderOrCallToRender(headerElement, renderPropArgs);
const rowHeader = ( const rowHeader = (
<div className={styles.header}> <div className={styles.header}>
<div className={styles.titleWrapper} onClick={onRowToggle} aria-label="Query operation row title"> <Icon
<Icon name={isContentVisible ? 'angle-down' : 'angle-right'} className={styles.collapseIcon} /> name={isContentVisible ? 'angle-down' : 'angle-right'}
{title && <div className={styles.title}>{titleElement}</div>} className={styles.collapseIcon}
{headerElement} onClick={onRowToggle}
</div> />
{title && (
<div className={styles.titleWrapper} onClick={onRowToggle} aria-label="Query operation row title">
<div className={styles.title}>{titleElement}</div>
</div>
)}
{headerElementRendered}
{actions && <div>{actionsElement}</div>} {actions && <div>{actionsElement}</div>}
{draggable && ( {draggable && (
<Icon title="Drag and drop to reorder" name="draggabledots" size="lg" className={styles.dragIcon} /> <Icon title="Drag and drop to reorder" name="draggabledots" size="lg" className={styles.dragIcon} />
@ -126,6 +139,7 @@ const getQueryOperationRowStyles = stylesFactory((theme: GrafanaTheme) => {
`, `,
collapseIcon: css` collapseIcon: css`
color: ${theme.colors.textWeak}; color: ${theme.colors.textWeak};
cursor: pointer;
&:hover { &:hover {
color: ${theme.colors.text}; color: ${theme.colors.text};
} }

View File

@ -3,7 +3,10 @@ import { DataFrame, DataTransformerConfig, TransformerRegistyItem } from '@grafa
import { HorizontalGroup } from '@grafana/ui'; import { HorizontalGroup } from '@grafana/ui';
import { TransformationEditor } from './TransformationEditor'; import { TransformationEditor } from './TransformationEditor';
import { QueryOperationRow } from 'app/core/components/QueryOperationRow/QueryOperationRow'; import {
QueryOperationRow,
QueryOperationRowRenderProps,
} from 'app/core/components/QueryOperationRow/QueryOperationRow';
import { QueryOperationAction } from 'app/core/components/QueryOperationRow/QueryOperationAction'; import { QueryOperationAction } from 'app/core/components/QueryOperationRow/QueryOperationAction';
import { TransformationsEditorTransformation } from './types'; import { TransformationsEditorTransformation } from './types';
@ -28,7 +31,7 @@ export const TransformationOperationRow: React.FC<TransformationOperationRowProp
}) => { }) => {
const [showDebug, setShowDebug] = useState(false); const [showDebug, setShowDebug] = useState(false);
const renderActions = ({ isOpen }: { isOpen: boolean }) => { const renderActions = ({ isOpen }: QueryOperationRowRenderProps) => {
return ( return (
<HorizontalGroup align="center" width="auto"> <HorizontalGroup align="center" width="auto">
<QueryOperationAction <QueryOperationAction

View File

@ -19,7 +19,10 @@ import {
DataSourceInstanceSettings, DataSourceInstanceSettings,
} from '@grafana/data'; } from '@grafana/data';
import { QueryEditorRowTitle } from './QueryEditorRowTitle'; import { QueryEditorRowTitle } from './QueryEditorRowTitle';
import { QueryOperationRow } from 'app/core/components/QueryOperationRow/QueryOperationRow'; import {
QueryOperationRow,
QueryOperationRowRenderProps,
} from 'app/core/components/QueryOperationRow/QueryOperationRow';
import { QueryOperationAction } from 'app/core/components/QueryOperationRow/QueryOperationAction'; import { QueryOperationAction } from 'app/core/components/QueryOperationRow/QueryOperationAction';
import { DashboardModel } from '../../dashboard/state/DashboardModel'; import { DashboardModel } from '../../dashboard/state/DashboardModel';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
@ -199,13 +202,13 @@ export class QueryEditorRow extends PureComponent<Props, State> {
return <div>Data source plugin does not export any Query Editor component</div>; return <div>Data source plugin does not export any Query Editor component</div>;
}; };
onToggleEditMode = (e: React.MouseEvent, { isOpen, openRow }: { isOpen: boolean; openRow: () => void }) => { onToggleEditMode = (e: React.MouseEvent, props: QueryOperationRowRenderProps) => {
e.stopPropagation(); e.stopPropagation();
if (this.angularScope && this.angularScope.toggleEditorMode) { if (this.angularScope && this.angularScope.toggleEditorMode) {
this.angularScope.toggleEditorMode(); this.angularScope.toggleEditorMode();
this.angularQueryEditor?.digest(); this.angularQueryEditor?.digest();
if (!isOpen) { if (!props.isOpen) {
openRow(); props.onOpen();
} }
} }
}; };
@ -237,7 +240,7 @@ export class QueryEditorRow extends PureComponent<Props, State> {
return null; return null;
} }
renderActions = (props: { isOpen: boolean; openRow: () => void }) => { renderActions = (props: QueryOperationRowRenderProps) => {
const { query } = this.props; const { query } = this.props;
const { hasTextEditMode } = this.state; const { hasTextEditMode } = this.state;
const isDisabled = query.hide; const isDisabled = query.hide;
@ -264,18 +267,20 @@ export class QueryEditorRow extends PureComponent<Props, State> {
); );
}; };
renderTitle = (props: { isOpen: boolean; openRow: () => void }) => { renderTitle = (props: QueryOperationRowRenderProps) => {
const { query, dsSettings } = this.props; const { query, dsSettings, onChange, queries } = this.props;
const { datasource } = this.state; const { datasource } = this.state;
const isDisabled = query.hide; const isDisabled = query.hide;
return ( return (
<QueryEditorRowTitle <QueryEditorRowTitle
query={query} query={query}
queries={queries}
inMixedMode={dsSettings.meta.mixed} inMixedMode={dsSettings.meta.mixed}
datasource={datasource!} dataSourceName={datasource!.name}
disabled={isDisabled} disabled={isDisabled}
onClick={e => this.onToggleEditMode(e, props)} onClick={e => this.onToggleEditMode(e, props)}
onChange={onChange}
collapsedText={!props.isOpen ? this.renderCollapsedText() : null} collapsedText={!props.isOpen ? this.renderCollapsedText() : null}
/> />
); );
@ -303,7 +308,7 @@ export class QueryEditorRow extends PureComponent<Props, State> {
id={id} id={id}
draggable={true} draggable={true}
index={index} index={index}
title={this.renderTitle} headerElement={this.renderTitle}
actions={this.renderActions} actions={this.renderActions}
onOpen={this.onOpen} onOpen={this.onOpen}
> >

View File

@ -0,0 +1,67 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { QueryEditorRowTitle, Props } from './QueryEditorRowTitle';
function renderScenario(overrides: Partial<Props>) {
const props: Props = {
query: {
refId: 'A',
},
queries: [
{
refId: 'A',
},
{
refId: 'B',
},
],
dataSourceName: 'hello',
inMixedMode: false,
disabled: false,
onChange: jest.fn(),
onClick: jest.fn(),
collapsedText: '',
};
Object.assign(props, overrides);
return {
props,
renderResult: render(<QueryEditorRowTitle {...props} />),
};
}
describe('QueryEditorRowTitle', () => {
it('Can edit title', () => {
const scenario = renderScenario({});
screen.getByTestId('query-name-div').click();
const input = screen.getByTestId('query-name-input');
fireEvent.change(input, { target: { value: 'new name' } });
fireEvent.blur(input);
expect((scenario.props.onChange as any).mock.calls[0][0].refId).toBe('new name');
});
it('Show error when other query with same name exists', async () => {
renderScenario({});
screen.getByTestId('query-name-div').click();
const input = screen.getByTestId('query-name-input');
fireEvent.change(input, { target: { value: 'B' } });
const alert = await screen.findByRole('alert');
expect(alert.textContent).toBe('Query name already exists');
});
it('Show error when empty name is specified', async () => {
renderScenario({});
screen.getByTestId('query-name-div').click();
const input = screen.getByTestId('query-name-input');
fireEvent.change(input, { target: { value: '' } });
const alert = await screen.findByRole('alert');
expect(alert.textContent).toBe('An empty query name is not allowed');
});
});

View File

@ -1,36 +1,125 @@
import React from 'react'; import React from 'react';
import { css } from 'emotion'; import { css, cx } from 'emotion';
import { DataQuery, DataSourceApi, GrafanaTheme } from '@grafana/data'; import { DataQuery, GrafanaTheme } from '@grafana/data';
import { stylesFactory, useTheme } from '@grafana/ui'; import { Icon, Input, stylesFactory, useTheme, FieldValidationMessage } from '@grafana/ui';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { useState } from 'react';
interface QueryEditorRowTitleProps { export interface Props {
query: DataQuery; query: DataQuery;
datasource: DataSourceApi; queries: DataQuery[];
dataSourceName: string;
inMixedMode?: boolean; inMixedMode?: boolean;
disabled?: boolean; disabled?: boolean;
onChange: (query: DataQuery) => void;
onClick: (e: React.MouseEvent) => void; onClick: (e: React.MouseEvent) => void;
collapsedText: string | null; collapsedText: string | null;
} }
export const QueryEditorRowTitle: React.FC<QueryEditorRowTitleProps> = ({ export const QueryEditorRowTitle: React.FC<Props> = ({
datasource, dataSourceName,
inMixedMode, inMixedMode,
disabled, disabled,
query, query,
queries,
onClick, onClick,
onChange,
collapsedText, collapsedText,
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
const styles = getQueryEditorRowTitleStyles(theme); const styles = getQueryEditorRowTitleStyles(theme);
const [isEditing, setIsEditing] = useState<boolean>(false);
const [validationError, setValidationError] = useState<string | null>(null);
const onEditQuery = (event: React.SyntheticEvent) => {
setIsEditing(true);
};
const onEndEditName = (newName: string) => {
setIsEditing(false);
// Ignore change if invalid
if (validationError) {
setValidationError(null);
return;
}
if (query.refId !== newName) {
onChange({
...query,
refId: newName,
});
}
};
const onInputChange = (event: React.SyntheticEvent<HTMLInputElement>) => {
const newName = event.currentTarget.value.trim();
if (newName.length === 0) {
setValidationError('An empty query name is not allowed');
return;
}
for (const otherQuery of queries) {
if (otherQuery !== query && newName === otherQuery.refId) {
setValidationError('Query name already exists');
return;
}
}
if (validationError) {
setValidationError(null);
}
};
const onEditQueryBlur = (event: React.SyntheticEvent<HTMLInputElement>) => {
onEndEditName(event.currentTarget.value.trim());
};
const onKeyDown = (event: React.KeyboardEvent) => {
if (event.key === 'Enter') {
onEndEditName((event.target as any).value);
}
};
const onFocus = (event: React.FocusEvent<HTMLInputElement>) => {
event.target.select();
};
return ( return (
<div className={styles.wrapper}> <div className={styles.wrapper}>
<div className={styles.refId} aria-label={selectors.components.QueryEditorRow.title(query.refId)}> {!isEditing && (
<span>{query.refId}</span> <button
{inMixedMode && <em className={styles.contextInfo}> ({datasource.name})</em>} className={styles.queryNameWrapper}
{disabled && <em className={styles.contextInfo}> Disabled</em>} aria-label={selectors.components.QueryEditorRow.title(query.refId)}
</div> title="Edit query name"
onClick={onEditQuery}
data-testid="query-name-div"
>
<span className={styles.queryName}>{query.refId}</span>
<Icon name="pen" className={styles.queryEditIcon} size="sm" />
</button>
)}
{isEditing && (
<>
<Input
type="text"
defaultValue={query.refId}
onBlur={onEditQueryBlur}
autoFocus
onKeyDown={onKeyDown}
onFocus={onFocus}
invalid={validationError !== null}
onChange={onInputChange}
className={styles.queryNameInput}
data-testid="query-name-input"
/>
{validationError && <FieldValidationMessage horizontal>{validationError}</FieldValidationMessage>}
</>
)}
{inMixedMode && <em className={styles.contextInfo}> ({dataSourceName})</em>}
{disabled && <em className={styles.contextInfo}> Disabled</em>}
{collapsedText && ( {collapsedText && (
<div className={styles.collapsedText} onClick={onClick}> <div className={styles.collapsedText} onClick={onClick}>
{collapsedText} {collapsedText}
@ -45,14 +134,58 @@ const getQueryEditorRowTitleStyles = stylesFactory((theme: GrafanaTheme) => {
wrapper: css` wrapper: css`
display: flex; display: flex;
align-items: center; align-items: center;
`, flex-grow: 1;
margin-left: ${theme.spacing.xs};
refId: css` &:hover {
.query-name-wrapper {
background: ${theme.colors.bg3};
border: 1px dashed ${theme.colors.border3};
}
.query-name-edit-icon {
visibility: visible;
}
}
`,
queryNameWrapper: cx(
css`
display: flex;
cursor: pointer;
border: 1px solid transparent;
border-radius: ${theme.border.radius.md};
align-items: center;
padding: 0 0 0 ${theme.spacing.xs};
margin: 0;
background: transparent;
&:focus {
border: 2px solid ${theme.colors.formInputBorderActive};
.query-name-edit-icon {
visibility: visible;
}
}
`,
'query-name-wrapper'
),
queryName: css`
font-weight: ${theme.typography.weight.semibold}; font-weight: ${theme.typography.weight.semibold};
color: ${theme.colors.textBlue}; color: ${theme.colors.textBlue};
cursor: pointer; cursor: pointer;
display: flex; overflow: hidden;
align-items: center; margin-left: ${theme.spacing.xs};
`,
queryEditIcon: cx(
css`
margin-left: ${theme.spacing.md};
visibility: hidden;
`,
'query-name-edit-icon'
),
queryNameInput: css`
max-width: 300px;
margin: -4px 0;
`, `,
collapsedText: css` collapsedText: css`
font-weight: ${theme.typography.weight.regular}; font-weight: ${theme.typography.weight.regular};

View File

@ -30,8 +30,6 @@ export class QueryEditorRows extends PureComponent<Props> {
const old = queries[index]; const old = queries[index];
// ensure refId & datasource are maintained
query.refId = old.refId;
if (old.datasource) { if (old.datasource) {
query.datasource = old.datasource; query.datasource = old.datasource;
} }