feat(ui): add name to dashboard query (#1794)

pull/10616/head
Delmer 2018-12-10 13:16:27 -05:00 committed by GitHub
parent d64dbbc034
commit 27adc0ff91
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 638 additions and 31 deletions

View File

@ -407,6 +407,9 @@ components:
description: Optional URI for data source for this query
queryConfig:
$ref: '#/components/schemas/QueryConfig'
name:
type: string
description: An optional word or phrase that refers to the query
Axis:
type: object
description: A description of a particular axis for a visualization

View File

@ -4132,6 +4132,9 @@ components:
description: Optional URI for data source for this query
queryConfig:
$ref: '#/components/schemas/QueryConfig'
name:
type: string
description: An optional word or phrase that refers to the query
Axis:
type: object
description: A description of a particular axis for a visualization

View File

@ -1,5 +1,6 @@
import React, {SFC, ReactChildren} from 'react'
import RightClickLayer from 'src/clockface/components/right_click_menu/RightClickLayer'
import Nav from 'src/pageLayout'
import Notifications from 'src/shared/components/notifications/Notifications'
@ -10,6 +11,7 @@ interface Props {
const App: SFC<Props> = ({children}) => (
<div className="chronograf-root">
<Notifications />
<RightClickLayer />
<Nav />
{children}
</div>

View File

@ -668,6 +668,12 @@ export interface DashboardQuery {
* @memberof DashboardQuery
*/
label?: string;
/**
* An optional word or phrase that refers to the query
* @type {string}
* @memberof DashboardQuery
*/
name?: string;
/**
*
* @type {string}

View File

@ -0,0 +1,32 @@
import React, {SFC} from 'react'
interface WrapperProps<T> {
type: JSX.Element['type']
children: T[]
}
interface Options {
count?: number
strict?: boolean
}
type WrapSelection<T> = SFC<WrapperProps<T> & Options>
const Select: WrapSelection<JSX.Element> = ({
type,
children,
strict = false,
count = Infinity,
}): JSX.Element => (
<>
{React.Children.map(children, (child: JSX.Element) => {
if (child.type === type && count-- > 0) {
return child
} else if (strict && child.type !== type) {
throw new Error(`Expected ${type} but received ${child.type}`)
}
})}
</>
)
export default Select

View File

@ -0,0 +1,67 @@
/*
Right Click Menu Styles
------------------------------------------------------------------------------
*/
@import 'src/style/modules';
.right-click--layer {
position: relative;
z-index: $z--right-click-layer;
}
.right-click--menu {
border-radius: $radius;
@include gradient-h($g0-obsidian, $g2-kevlar);
}
.right-click--menu .fancy-scroll--track-h {
display: none;
}
.right-click--list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
align-items: stretch;
flex-direction: column;
}
.right-click--menu-item {
margin: 0;
user-select: none;
display: flex;
align-items: center;
white-space: nowrap;
font-size: 13px;
line-height: 13px;
font-weight: 600;
transition: color 0.25s ease, background-color 0.25s ease;
color: $g13-mist;
padding: 5px 11px;
&:hover {
color: $g18-cloud;
background-color: $g5-pepper;
cursor: pointer;
}
&.disabled,
&.disabled:hover {
background-color: transparent;
cursor: default !important;
color: $g9-mountain;
font-style: italic;
}
&:first-child {
border-top-left-radius: $radius;
border-top-right-radius: $radius;
}
&:last-child {
border-bottom-left-radius: $radius;
border-bottom-right-radius: $radius;
}
}

View File

@ -0,0 +1,166 @@
// Libraries
import React, {SFC, Component} from 'react'
import ReactDOM from 'react-dom'
// Components
import RightClickMenu from 'src/clockface/components/right_click_menu/RightClickMenu'
import RightClickMenuItem from 'src/clockface/components/right_click_menu/RightClickMenuItem'
import {ClickOutside} from 'src/shared/components/ClickOutside'
import Select from 'src/clockface/components/Select'
interface Props {
className?: string
children: JSX.Element[]
}
interface ChildProps {
children: JSX.Element
}
interface State {
expanded: boolean
mouseX: number
mouseY: number
}
/**
* Handles bubbling onContextMenu events from elements
* wrapped in Trigger and displays the MenuContainer
* contents. Portals the RightClickMenu into the
* RightClickLayer.
*
* @example Using RightClickMenu and RightClickMenuItem
*
* <RightClick>
* <RightClick.Trigger>
* <button>Right click me</button>
* </RightClick.Trigger>
* <RightClick.MenuContainer>
* <RightClick.Menu>
* <RightClick.MenuItem onClick={this.handleClickA}>
* Test Item A
* </RightClick.MenuItem>
* <RightClick.MenuItem onClick={this.handleClickB}>
* Test Item B
* </RightClick.MenuItem>
* <RightClick.MenuItem
* onClick={this.handleClickC}
* disabled={true}
* >
* Test Item B
* </RightClick.MenuItem>
* </RightClick.Menu>
* </RightClick.MenuContainer>
* </RightClick>
*
* @example Using a custom menu
*
* <RightClick>
* <RightClick.Trigger>
* <button>Right click me</button>
* </RightClick.Trigger>
* <RightClick.MenuContainer>
* <ul className="custom-menu">
* <li onClick={this.handleClickA}>Test A</li>
* <li onClick={this.handleClickB}>Test B</li>
* </ul>
* </RightClick.MenuContainer>
* </RightClick>
*/
class RightClick extends Component<Props, State> {
public static Menu = RightClickMenu
public static MenuItem = RightClickMenuItem
public static MenuContainer: SFC<ChildProps> = ({children}) => children
public static Trigger: SFC<ChildProps> = ({children}) => children
private static Only: typeof Select = props => <Select count={1} {...props} />
public state: State = {
expanded: false,
mouseX: null,
mouseY: null,
}
public componentDidMount() {
document.addEventListener('contextmenu', this.handleRightClick, true)
}
public componentWillUnmount() {
document.removeEventListener('contextmenu', this.handleRightClick, true)
}
public render() {
this.validateChildren()
return (
<>
<RightClick.Only type={RightClick.Trigger}>
{this.props.children}
</RightClick.Only>
<div className="right-click--wrapper">{this.menu}</div>
</>
)
}
private handleRightClick = (e: MouseEvent): void => {
const domNode = ReactDOM.findDOMNode(this)
if (!domNode || domNode.contains(e.target)) {
e.preventDefault()
const {pageX: mouseX, pageY: mouseY} = e
this.setState({
expanded: true,
mouseX,
mouseY,
})
}
}
private handleCollapseMenu = (): void => {
this.setState({expanded: false})
}
private get menu() {
return ReactDOM.createPortal(
this.menuElement,
document.getElementById('right-click--layer')
)
}
private get menuElement(): JSX.Element {
const {expanded, mouseX, mouseY} = this.state
if (!expanded) {
return null
}
return (
<div
style={{
position: 'fixed',
left: mouseX,
top: mouseY,
}}
onClick={this.handleCollapseMenu}
>
<ClickOutside onClickOutside={this.handleCollapseMenu}>
<RightClick.Only type={RightClick.MenuContainer}>
{this.props.children}
</RightClick.Only>
</ClickOutside>
</div>
)
}
private validateChildren = (): void => {
const {children} = this.props
if (React.Children.count(children) > 2) {
throw new Error('<RightClick> has more than 2 children')
}
}
}
export default RightClick

View File

@ -0,0 +1,11 @@
// Libraries
import React, {SFC} from 'react'
// Styles
import './RightClick.scss'
const RightClickLayer: SFC = () => (
<div id="right-click--layer" className="right-click--layer" />
)
export default RightClickLayer

View File

@ -0,0 +1,27 @@
// Libraries
import React, {Component, MouseEvent} from 'react'
// Components
import FancyScrollbar from 'src/shared/components/fancy_scrollbar/FancyScrollbar'
interface Props {
children: JSX.Element[] | JSX.Element
}
class RightClickMenu extends Component<Props> {
public render() {
return (
<div className="right-click--menu" onContextMenu={this.handleRightClick}>
<FancyScrollbar autoHeight={true} maxHeight={250}>
<ul className="right-click--list">{this.props.children}</ul>
</FancyScrollbar>
</div>
)
}
private handleRightClick = (e: MouseEvent<HTMLDivElement>) => {
e.preventDefault()
}
}
export default RightClickMenu

View File

@ -0,0 +1,40 @@
// Libraries
import React, {Component, MouseEvent} from 'react'
import classnames from 'classnames'
interface Props {
disabled?: boolean
onClick: (e?: MouseEvent<HTMLLIElement>) => void
children: JSX.Element[] | JSX.Element | string
}
class RightClickMenuItem extends Component<Props> {
public render() {
const {children} = this.props
return (
<li className={this.className} onClick={this.handleClick}>
{children}
</li>
)
}
private handleClick = (e: MouseEvent<HTMLLIElement>) => {
const {onClick, disabled} = this.props
if (disabled) {
e.stopPropagation()
return
}
onClick(e)
}
private get className(): string {
const {disabled} = this.props
return classnames('right-click--menu-item', {disabled})
}
}
export default RightClickMenuItem

View File

@ -37,6 +37,7 @@ export type Action =
| EditActiveQueryAsInfluxQLAction
| EditActiveQueryWithBuilderAction
| BuildQueryAction
| UpdateActiveQueryNameAction
interface SetActiveTimeMachineAction {
type: 'SET_ACTIVE_TIME_MACHINE'
@ -349,3 +350,15 @@ export const buildQuery = (builderConfig: BuilderConfig): BuildQueryAction => ({
type: 'BUILD_QUERY',
payload: {builderConfig},
})
interface UpdateActiveQueryNameAction {
type: 'UPDATE_ACTIVE_QUERY_NAME'
payload: {queryName: string}
}
export const updateActiveQueryName = (
queryName: string
): UpdateActiveQueryNameAction => ({
type: 'UPDATE_ACTIVE_QUERY_NAME',
payload: {queryName},
})

View File

@ -1,7 +1,6 @@
// Libraries
import React, {SFC} from 'react'
import {connect} from 'react-redux'
import {range} from 'lodash'
// Components
import TimeMachineFluxEditor from 'src/shared/components/TimeMachineFluxEditor'
@ -41,7 +40,7 @@ import {RemoteDataState} from 'src/types'
interface StateProps {
activeQuery: DashboardQuery
queryCount: number
draftQueries: DashboardQuery[]
}
interface DispatchProps {
@ -55,7 +54,7 @@ interface OwnProps {
type Props = StateProps & DispatchProps & OwnProps
const TimeMachineQueries: SFC<Props> = props => {
const {activeQuery, queryStatus, queryCount, onAddQuery} = props
const {activeQuery, queryStatus, draftQueries, onAddQuery} = props
let queryEditor
@ -71,8 +70,12 @@ const TimeMachineQueries: SFC<Props> = props => {
<div className="time-machine-queries">
<div className="time-machine-queries--controls">
<div className="time-machine-queries--tabs">
{range(queryCount).map(i => (
<TimeMachineQueryTab key={i} queryIndex={i} />
{draftQueries.map((query, queryIndex) => (
<TimeMachineQueryTab
key={queryIndex}
queryIndex={queryIndex}
query={query}
/>
))}
<Button
customClass="time-machine-queries--new"
@ -95,10 +98,9 @@ const TimeMachineQueries: SFC<Props> = props => {
const mstp = (state: AppState) => {
const {draftQueries, activeQueryIndex} = getActiveTimeMachine(state)
const queryCount = draftQueries.length
const activeQuery = getActiveQuery(state)
return {activeQuery, activeQueryIndex, queryCount}
return {activeQuery, activeQueryIndex, draftQueries}
}
const mdtp = {

View File

@ -2,10 +2,15 @@
import React, {PureComponent, MouseEvent} from 'react'
import {connect} from 'react-redux'
// Components
import TimeMachineQueryTabName from 'src/shared/components/TimeMachineQueryTabName'
import RightClick from 'src/clockface/components/right_click_menu/RightClick'
// Actions
import {
setActiveQueryIndex,
removeQuery,
updateActiveQueryName,
} from 'src/shared/actions/v2/timeMachines'
// Utils
@ -15,7 +20,7 @@ import {getActiveTimeMachine} from 'src/shared/selectors/timeMachines'
import 'src/shared/components/TimeMachineQueryTab.scss'
// Types
import {AppState} from 'src/types/v2'
import {AppState, DashboardQuery} from 'src/types/v2'
interface StateProps {
activeQueryIndex: number
@ -24,42 +29,107 @@ interface StateProps {
interface DispatchProps {
onSetActiveQueryIndex: typeof setActiveQueryIndex
onRemoveQuery: typeof removeQuery
onUpdateActiveQueryName: typeof updateActiveQueryName
}
interface OwnProps {
queryIndex: number
query: DashboardQuery
}
type Props = StateProps & DispatchProps & OwnProps
class TimeMachineQueryTab extends PureComponent<Props> {
interface State {
isEditingName: boolean
}
class TimeMachineQueryTab extends PureComponent<Props, State> {
public static getDerivedStateFromProps(props: Props): Partial<State> {
if (props.queryIndex !== props.activeQueryIndex) {
return {isEditingName: false}
}
return null
}
public state: State = {isEditingName: false}
public render() {
const {queryIndex, activeQueryIndex} = this.props
const {queryIndex, activeQueryIndex, query} = this.props
const isActive = queryIndex === activeQueryIndex
const activeClass = queryIndex === activeQueryIndex ? 'active' : ''
return (
<div
className={`time-machine-query-tab ${activeClass}`}
onClick={this.handleSetActive}
>
Query {queryIndex + 1}
<div
className="time-machine-query-tab--close"
onClick={this.handleRemove}
>
<span className="icon remove" />
</div>
</div>
<RightClick>
<RightClick.Trigger>
<div
className={`time-machine-query-tab ${activeClass}`}
onClick={this.handleSetActive}
>
<TimeMachineQueryTabName
isActive={isActive}
name={query.name}
queryIndex={queryIndex}
isEditing={this.state.isEditingName}
onUpdate={this.handleUpdateName}
onEdit={this.handleEditName}
onCancelEdit={this.handleCancelEditName}
/>
{this.removeButton}
</div>
</RightClick.Trigger>
<RightClick.MenuContainer>
<RightClick.Menu>
<RightClick.MenuItem onClick={this.handleEditActiveQueryName}>
Edit
</RightClick.MenuItem>
<RightClick.MenuItem onClick={this.handleRemove}>
Remove
</RightClick.MenuItem>
</RightClick.Menu>
</RightClick.MenuContainer>
</RightClick>
)
}
private handleEditActiveQueryName = () => {
this.handleSetActive()
this.handleEditName()
}
private handleUpdateName = (queryName: string) => {
this.props.onUpdateActiveQueryName(queryName)
}
private handleSetActive = (): void => {
const {queryIndex, onSetActiveQueryIndex} = this.props
onSetActiveQueryIndex(queryIndex)
}
private handleRemove = (e: MouseEvent<HTMLDivElement>): void => {
private handleCancelEditName = () => {
this.setState({isEditingName: false})
}
private handleEditName = (): void => {
this.setState({isEditingName: true})
}
private get removeButton(): JSX.Element {
if (this.state.isEditingName) {
return null
}
return (
<div
className="time-machine-query-tab--close"
onClick={this.handleRemove}
>
<span className="icon remove" />
</div>
)
}
private handleRemove = (e: MouseEvent): void => {
const {queryIndex, onRemoveQuery} = this.props
e.stopPropagation()
@ -69,12 +139,14 @@ class TimeMachineQueryTab extends PureComponent<Props> {
const mstp = (state: AppState) => {
const {activeQueryIndex} = getActiveTimeMachine(state)
return {activeQueryIndex}
}
const mdtp = {
onSetActiveQueryIndex: setActiveQueryIndex,
onRemoveQuery: removeQuery,
onUpdateActiveQueryName: updateActiveQueryName,
}
export default connect<StateProps, DispatchProps, OwnProps>(

View File

@ -0,0 +1,89 @@
// Libraries
import React, {PureComponent, KeyboardEvent, ChangeEvent} from 'react'
// Components
import {Input, InputType} from 'src/clockface'
// Styles
import 'src/shared/components/TimeMachineQueryTab.scss'
interface Props {
isActive: boolean
isEditing: boolean
queryIndex: number
name?: string
onEdit: () => void
onCancelEdit: () => void
onUpdate: (newName: string) => void
}
interface State {
newName?: string
}
class TimeMachineQueryTabName extends PureComponent<Props, State> {
public state: State = {newName: null}
public render() {
const {queryIndex, name, isEditing, onCancelEdit} = this.props
const queryName = !!name ? name : `Query ${queryIndex + 1}`
if (isEditing) {
return (
<Input
type={InputType.Text}
placeholder="Name your query"
value={this.state.newName || ''}
onChange={this.handleChange}
onBlur={onCancelEdit}
onKeyUp={this.handleEnterKey}
autoFocus={true}
/>
)
}
return (
<span
className="time-machine-query-tab-name"
onDoubleClick={this.handleDoubleClick}
>
{queryName}
</span>
)
}
private handleDoubleClick = () => {
if (this.props.isActive) {
this.props.onEdit()
this.setState({newName: this.props.name || ''})
}
}
private handleChange = (e: ChangeEvent<HTMLInputElement>) => {
this.setState({newName: e.target.value})
}
private handleEnterKey = (e: KeyboardEvent<HTMLInputElement>) => {
switch (e.key) {
case 'Enter':
return this.handleUpdate()
case 'Escape':
return this.props.onCancelEdit()
}
}
private handleUpdate() {
const {onUpdate, onCancelEdit} = this.props
const {newName} = this.state
if (newName !== null) {
onUpdate(newName)
onCancelEdit()
}
this.setState({newName: null})
}
}
export default TimeMachineQueryTabName

View File

@ -18,6 +18,7 @@ import {
editActiveQueryAsInfluxQL,
addQuery,
removeQuery,
updateActiveQueryName,
} from 'src/shared/actions/v2/timeMachines'
// Utils
@ -597,4 +598,58 @@ describe('timeMachineReducer', () => {
expect(nextState.activeQueryIndex).toEqual(1)
})
})
describe('UPDATE_ACTIVE_QUERY_NAME', () => {
test('sets the name for the activeQueryIndex', () => {
const state = initialStateHelper()
state.activeQueryIndex = 1
const builderConfig = {
buckets: [],
measurements: [],
fields: [],
functions: [],
}
state.draftQueries = [
{
text: 'foo',
type: InfluxLanguage.Flux,
sourceID: '',
editMode: QueryEditMode.Advanced,
builderConfig,
},
{
text: 'bar',
type: InfluxLanguage.Flux,
sourceID: '',
editMode: QueryEditMode.Builder,
builderConfig,
},
]
const nextState = timeMachineReducer(
state,
updateActiveQueryName('test query')
)
expect(nextState.draftQueries).toEqual([
{
text: 'foo',
type: InfluxLanguage.Flux,
sourceID: '',
editMode: QueryEditMode.Advanced,
builderConfig,
},
{
text: 'bar',
type: InfluxLanguage.Flux,
sourceID: '',
editMode: QueryEditMode.Builder,
name: 'test query',
builderConfig,
},
])
})
})
})

View File

@ -16,17 +16,17 @@ import {TimeRange} from 'src/types/v2'
import {
View,
ViewType,
NewView,
QueryViewProperties,
DashboardQuery,
InfluxLanguage,
QueryEditMode,
QueryView,
QueryViewProperties,
} from 'src/types/v2/dashboards'
import {Action} from 'src/shared/actions/v2/timeMachines'
import {TimeMachineTab} from 'src/types/v2/timeMachine'
export interface TimeMachineState {
view: View<QueryViewProperties> | NewView<QueryViewProperties>
view: QueryView
timeRange: TimeRange
draftQueries: DashboardQuery[]
isViewingRawData: boolean
@ -404,6 +404,18 @@ export const timeMachineReducer = (
builderConfig,
}
return {...state, draftQueries}
}
case 'UPDATE_ACTIVE_QUERY_NAME': {
const {activeQueryIndex} = state
const {queryName} = action.payload
const draftQueries = [...state.draftQueries]
draftQueries[activeQueryIndex] = {
...draftQueries[activeQueryIndex],
name: queryName,
}
return {...state, draftQueries}
}
}
@ -437,7 +449,7 @@ const setYAxis = (state: TimeMachineState, update: {[key: string]: any}) => {
}
const convertView = (
view: View<QueryViewProperties> | NewView<QueryViewProperties>,
view: QueryView,
outType: ViewType
): View<QueryViewProperties> => {
const newView: any = createView(outType)

View File

@ -17,6 +17,7 @@ $sidebar--width: 50px; //delete this later
/* Z Variables */
$z--notifications: 9999;
$z--right-click-layer: 9995;
$z--overlays: 9990;
$z--drag-n-drop: 5000;
$z--dygraph-legend: 4000;

View File

@ -59,6 +59,7 @@ export interface DashboardQuery {
editMode: QueryEditMode
builderConfig: BuilderConfig
sourceID: string // Which source to use when running the query; may be empty, which means “use the dynamic source”
name?: string
}
export interface URLQuery {
@ -100,19 +101,23 @@ export interface ViewLinks {
export type DygraphViewProperties = XYView | LinePlusSingleStatView
export type QueryViewProperties =
export type ViewProperties =
| XYView
| LinePlusSingleStatView
| SingleStatView
| TableView
| GaugeView
export type ViewProperties =
| QueryViewProperties
| MarkdownView
| EmptyView
| LogViewerView
export type QueryViewProperties = Extract<
ViewProperties,
{queries: DashboardQuery[]}
>
export type QueryView = View<QueryViewProperties> | NewView<QueryViewProperties>
export interface EmptyView {
type: ViewShape.Empty
shape: ViewShape.Empty

View File

@ -411,6 +411,7 @@ type DashboardQuery struct {
Type string `json:"type"`
SourceID string `json:"sourceID"`
EditMode string `json:"editMode"` // Either "builder" or "advanced"
Name string `json:"name"` // Term or phrase that refers to the query
BuilderConfig BuilderConfig `json:"builderConfig"`
}