-
-
- {title &&
{titleElement}
}
- {headerElement}
-
+
+ {title && (
+
+ )}
+ {headerElementRendered}
{actions &&
{actionsElement}
}
{draggable && (
@@ -126,6 +139,7 @@ const getQueryOperationRowStyles = stylesFactory((theme: GrafanaTheme) => {
`,
collapseIcon: css`
color: ${theme.colors.textWeak};
+ cursor: pointer;
&:hover {
color: ${theme.colors.text};
}
diff --git a/public/app/features/dashboard/components/TransformationsEditor/TransformationOperationRow.tsx b/public/app/features/dashboard/components/TransformationsEditor/TransformationOperationRow.tsx
index 310d23e2155..2ba7f96cdd4 100644
--- a/public/app/features/dashboard/components/TransformationsEditor/TransformationOperationRow.tsx
+++ b/public/app/features/dashboard/components/TransformationsEditor/TransformationOperationRow.tsx
@@ -3,7 +3,10 @@ import { DataFrame, DataTransformerConfig, TransformerRegistyItem } from '@grafa
import { HorizontalGroup } from '@grafana/ui';
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 { TransformationsEditorTransformation } from './types';
@@ -28,7 +31,7 @@ export const TransformationOperationRow: React.FC
{
const [showDebug, setShowDebug] = useState(false);
- const renderActions = ({ isOpen }: { isOpen: boolean }) => {
+ const renderActions = ({ isOpen }: QueryOperationRowRenderProps) => {
return (
{
return Data source plugin does not export any Query Editor component
;
};
- onToggleEditMode = (e: React.MouseEvent, { isOpen, openRow }: { isOpen: boolean; openRow: () => void }) => {
+ onToggleEditMode = (e: React.MouseEvent, props: QueryOperationRowRenderProps) => {
e.stopPropagation();
if (this.angularScope && this.angularScope.toggleEditorMode) {
this.angularScope.toggleEditorMode();
this.angularQueryEditor?.digest();
- if (!isOpen) {
- openRow();
+ if (!props.isOpen) {
+ props.onOpen();
}
}
};
@@ -237,7 +240,7 @@ export class QueryEditorRow extends PureComponent {
return null;
}
- renderActions = (props: { isOpen: boolean; openRow: () => void }) => {
+ renderActions = (props: QueryOperationRowRenderProps) => {
const { query } = this.props;
const { hasTextEditMode } = this.state;
const isDisabled = query.hide;
@@ -264,18 +267,20 @@ export class QueryEditorRow extends PureComponent {
);
};
- renderTitle = (props: { isOpen: boolean; openRow: () => void }) => {
- const { query, dsSettings } = this.props;
+ renderTitle = (props: QueryOperationRowRenderProps) => {
+ const { query, dsSettings, onChange, queries } = this.props;
const { datasource } = this.state;
const isDisabled = query.hide;
return (
this.onToggleEditMode(e, props)}
+ onChange={onChange}
collapsedText={!props.isOpen ? this.renderCollapsedText() : null}
/>
);
@@ -303,7 +308,7 @@ export class QueryEditorRow extends PureComponent {
id={id}
draggable={true}
index={index}
- title={this.renderTitle}
+ headerElement={this.renderTitle}
actions={this.renderActions}
onOpen={this.onOpen}
>
diff --git a/public/app/features/query/components/QueryEditorRowTitle.test.tsx b/public/app/features/query/components/QueryEditorRowTitle.test.tsx
new file mode 100644
index 00000000000..59c6bbc4266
--- /dev/null
+++ b/public/app/features/query/components/QueryEditorRowTitle.test.tsx
@@ -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) {
+ 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(),
+ };
+}
+
+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');
+ });
+});
diff --git a/public/app/features/query/components/QueryEditorRowTitle.tsx b/public/app/features/query/components/QueryEditorRowTitle.tsx
index 02dff1f9529..e6b5054486d 100644
--- a/public/app/features/query/components/QueryEditorRowTitle.tsx
+++ b/public/app/features/query/components/QueryEditorRowTitle.tsx
@@ -1,36 +1,125 @@
import React from 'react';
-import { css } from 'emotion';
-import { DataQuery, DataSourceApi, GrafanaTheme } from '@grafana/data';
-import { stylesFactory, useTheme } from '@grafana/ui';
+import { css, cx } from 'emotion';
+import { DataQuery, GrafanaTheme } from '@grafana/data';
+import { Icon, Input, stylesFactory, useTheme, FieldValidationMessage } from '@grafana/ui';
import { selectors } from '@grafana/e2e-selectors';
+import { useState } from 'react';
-interface QueryEditorRowTitleProps {
+export interface Props {
query: DataQuery;
- datasource: DataSourceApi;
+ queries: DataQuery[];
+ dataSourceName: string;
inMixedMode?: boolean;
disabled?: boolean;
+ onChange: (query: DataQuery) => void;
onClick: (e: React.MouseEvent) => void;
collapsedText: string | null;
}
-export const QueryEditorRowTitle: React.FC = ({
- datasource,
+export const QueryEditorRowTitle: React.FC = ({
+ dataSourceName,
inMixedMode,
disabled,
query,
+ queries,
onClick,
+ onChange,
collapsedText,
}) => {
const theme = useTheme();
const styles = getQueryEditorRowTitleStyles(theme);
+ const [isEditing, setIsEditing] = useState(false);
+ const [validationError, setValidationError] = useState(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) => {
+ 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) => {
+ 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) => {
+ event.target.select();
+ };
return (
-
- {query.refId}
- {inMixedMode && ({datasource.name})}
- {disabled && Disabled}
-
+ {!isEditing && (
+
+ )}
+ {isEditing && (
+ <>
+
+ {validationError &&
{validationError}}
+ >
+ )}
+ {inMixedMode &&
({dataSourceName})}
+ {disabled &&
Disabled}
+
{collapsedText && (
{collapsedText}
@@ -45,14 +134,58 @@ const getQueryEditorRowTitleStyles = stylesFactory((theme: GrafanaTheme) => {
wrapper: css`
display: flex;
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};
color: ${theme.colors.textBlue};
cursor: pointer;
- display: flex;
- align-items: center;
+ overflow: hidden;
+ 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`
font-weight: ${theme.typography.weight.regular};
diff --git a/public/app/features/query/components/QueryEditorRows.tsx b/public/app/features/query/components/QueryEditorRows.tsx
index 812bc12193c..5f9718fd659 100644
--- a/public/app/features/query/components/QueryEditorRows.tsx
+++ b/public/app/features/query/components/QueryEditorRows.tsx
@@ -30,8 +30,6 @@ export class QueryEditorRows extends PureComponent
{
const old = queries[index];
- // ensure refId & datasource are maintained
- query.refId = old.refId;
if (old.datasource) {
query.datasource = old.datasource;
}