diff --git a/packages/grafana-ui/src/components/Forms/FieldValidationMessage.story.tsx b/packages/grafana-ui/src/components/Forms/FieldValidationMessage.story.tsx index 062e58d4719..5f8efe8de66 100644 --- a/packages/grafana-ui/src/components/Forms/FieldValidationMessage.story.tsx +++ b/packages/grafana-ui/src/components/Forms/FieldValidationMessage.story.tsx @@ -19,8 +19,14 @@ export default { }, }; -export const simple = () => { +export const vertical = () => { const { message } = getKnobs(); return {message}; }; + +export const horizontal = () => { + const { message } = getKnobs(); + + return {message}; +}; diff --git a/packages/grafana-ui/src/components/Forms/FieldValidationMessage.tsx b/packages/grafana-ui/src/components/Forms/FieldValidationMessage.tsx index e7c4bfc7242..c7ccbf74138 100644 --- a/packages/grafana-ui/src/components/Forms/FieldValidationMessage.tsx +++ b/packages/grafana-ui/src/components/Forms/FieldValidationMessage.tsx @@ -8,31 +8,52 @@ export interface FieldValidationMessageProps { children: string; /** Override component style */ className?: string; + horizontal?: boolean; } export const getFieldValidationMessageStyles = stylesFactory((theme: GrafanaTheme) => { - return { - fieldValidationMessage: css` + const baseStyle = ` font-size: ${theme.typography.size.sm}; font-weight: ${theme.typography.weight.semibold}; - margin: ${theme.spacing.formValidationMessageMargin}; padding: ${theme.spacing.formValidationMessagePadding}; color: ${theme.colors.formValidationMessageText}; background: ${theme.colors.formValidationMessageBg}; border-radius: ${theme.border.radius.sm}; position: relative; display: inline-block; + `; + + return { + vertical: css` + ${baseStyle} + margin: ${theme.spacing.formValidationMessageMargin}; &:before { content: ''; position: absolute; left: 9px; - top: -4px; + top: -5px; width: 0; height: 0; - border-left: 4px solid transparent; - border-right: 4px solid transparent; - border-bottom: 4px solid ${theme.colors.formValidationMessageBg}; + border-width: 0 4px 5px 4px; + border-color: transparent transparent ${theme.colors.formValidationMessageBg} transparent; + 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` @@ -41,12 +62,13 @@ export const getFieldValidationMessageStyles = stylesFactory((theme: GrafanaThem }; }); -export const FieldValidationMessage: React.FC = ({ children, className }) => { +export const FieldValidationMessage: React.FC = ({ children, horizontal, className }) => { const theme = useTheme(); const styles = getFieldValidationMessageStyles(theme); + const cssName = cx(horizontal ? styles.horizontal : styles.vertical, className); return ( -
+
{children}
diff --git a/public/app/core/components/QueryOperationRow/QueryOperationRow.test.tsx b/public/app/core/components/QueryOperationRow/QueryOperationRow.test.tsx index e16f110d2bb..400cc554ae5 100644 --- a/public/app/core/components/QueryOperationRow/QueryOperationRow.test.tsx +++ b/public/app/core/components/QueryOperationRow/QueryOperationRow.test.tsx @@ -32,7 +32,7 @@ describe('QueryOperationRow', () => { const onOpenSpy = jest.fn(); const onCloseSpy = jest.fn(); const wrapper = mount( - +
Test
); @@ -56,11 +56,11 @@ describe('QueryOperationRow', () => { }); }); - describe('title rendering', () => { - it('should render title provided as element', () => { + describe('headerElement rendering', () => { + it('should render headerElement provided as element', () => { const title =
Test
; const wrapper = shallow( - +
Test
); @@ -68,10 +68,11 @@ describe('QueryOperationRow', () => { const titleEl = wrapper.find({ 'aria-label': 'test title' }); expect(titleEl).toHaveLength(1); }); - it('should render title provided as function', () => { + + it('should render headerElement provided as function', () => { const title = () =>
Test
; const wrapper = shallow( - +
Test
); @@ -80,14 +81,14 @@ describe('QueryOperationRow', () => { 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 title = (props: any) => { propsSpy(props); return
Test
; }; shallow( - +
Test
); @@ -132,9 +133,7 @@ describe('QueryOperationRow', () => {
); - expect(Object.keys(propsSpy.mock.calls[0][0])).toContainEqual('isOpen'); - expect(Object.keys(propsSpy.mock.calls[0][0])).toContainEqual('openRow'); - expect(Object.keys(propsSpy.mock.calls[0][0])).toContainEqual('closeRow'); + expect(Object.keys(propsSpy.mock.calls[0][0])).toEqual(['isOpen', 'onOpen', 'onClose']); }); }); }); diff --git a/public/app/core/components/QueryOperationRow/QueryOperationRow.tsx b/public/app/core/components/QueryOperationRow/QueryOperationRow.tsx index 210aa877ea6..a7c05babfcf 100644 --- a/public/app/core/components/QueryOperationRow/QueryOperationRow.tsx +++ b/public/app/core/components/QueryOperationRow/QueryOperationRow.tsx @@ -8,11 +8,9 @@ import { Draggable } from 'react-beautiful-dnd'; interface QueryOperationRowProps { index: number; id: string; - title?: ((props: { isOpen: boolean }) => React.ReactNode) | React.ReactNode; - headerElement?: React.ReactNode; - actions?: - | ((props: { isOpen: boolean; openRow: () => void; closeRow: () => void }) => React.ReactNode) - | React.ReactNode; + title?: string; + headerElement?: QueryOperationRowRenderProp; + actions?: QueryOperationRowRenderProp; onOpen?: () => void; onClose?: () => void; children: React.ReactNode; @@ -20,6 +18,14 @@ interface QueryOperationRowProps { 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 = ({ children, actions, @@ -51,26 +57,33 @@ export const QueryOperationRow: React.FC = ({ } }, [isContentVisible]); - const titleElement = title && renderOrCallToRender(title, { isOpen: isContentVisible }); - const actionsElement = - actions && - renderOrCallToRender(actions, { - isOpen: isContentVisible, - openRow: () => { - setIsContentVisible(true); - }, - closeRow: () => { - setIsContentVisible(false); - }, - }); + const renderPropArgs: QueryOperationRowRenderProps = { + isOpen: isContentVisible, + onOpen: () => { + setIsContentVisible(true); + }, + onClose: () => { + setIsContentVisible(false); + }, + }; + + const titleElement = title && renderOrCallToRender(title, renderPropArgs); + const actionsElement = actions && renderOrCallToRender(actions, renderPropArgs); + const headerElementRendered = headerElement && renderOrCallToRender(headerElement, renderPropArgs); const rowHeader = (
-
- - {title &&
{titleElement}
} - {headerElement} -
+ + {title && ( +
+
{titleElement}
+
+ )} + {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; }