mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
4c8735d2ff
commit
1c808be0f3
@ -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>;
|
||||||
|
};
|
||||||
|
@ -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>
|
||||||
|
@ -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');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -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 =
|
|
||||||
actions &&
|
|
||||||
renderOrCallToRender(actions, {
|
|
||||||
isOpen: isContentVisible,
|
isOpen: isContentVisible,
|
||||||
openRow: () => {
|
onOpen: () => {
|
||||||
setIsContentVisible(true);
|
setIsContentVisible(true);
|
||||||
},
|
},
|
||||||
closeRow: () => {
|
onClose: () => {
|
||||||
setIsContentVisible(false);
|
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}>
|
||||||
|
<Icon
|
||||||
|
name={isContentVisible ? 'angle-down' : 'angle-right'}
|
||||||
|
className={styles.collapseIcon}
|
||||||
|
onClick={onRowToggle}
|
||||||
|
/>
|
||||||
|
{title && (
|
||||||
<div className={styles.titleWrapper} onClick={onRowToggle} aria-label="Query operation row title">
|
<div className={styles.titleWrapper} onClick={onRowToggle} aria-label="Query operation row title">
|
||||||
<Icon name={isContentVisible ? 'angle-down' : 'angle-right'} className={styles.collapseIcon} />
|
<div className={styles.title}>{titleElement}</div>
|
||||||
{title && <div className={styles.title}>{titleElement}</div>}
|
|
||||||
{headerElement}
|
|
||||||
</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};
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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}
|
||||||
>
|
>
|
||||||
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
@ -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}
|
||||||
|
aria-label={selectors.components.QueryEditorRow.title(query.refId)}
|
||||||
|
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>}
|
{disabled && <em className={styles.contextInfo}> Disabled</em>}
|
||||||
</div>
|
|
||||||
{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};
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user