Merge branch 'master' into feature/graph-table-time-format

pull/2968/head
Iris Scholten 2018-03-07 17:52:28 -08:00
commit 2f985b56dd
61 changed files with 2370 additions and 1382 deletions

View File

@ -1,5 +1,5 @@
[bumpversion]
current_version = 1.4.2.1
current_version = 1.4.2.2
files = README.md server/swagger.json
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\.(?P<release>\d+)
serialize = {major}.{minor}.{patch}.{release}

View File

@ -2,7 +2,12 @@
### Features
### UI Improvements
### Bug Fixes
1. [#2866](https://github.com/influxdata/chronograf/pull/2866): Change hover text on delete mappings confirmation button to 'Delete'
1. [#2911](https://github.com/influxdata/chronograf/pull/2911): Fix Heroku OAuth
## v1.4.2.2 [2018-03-07]
### Bug Fixes
1. [#2933](https://github.com/influxdata/chronograf/pull/2933): Include url in Kapacitor connection creation requests
## v1.4.2.1 [2018-02-28]
### Features

View File

@ -136,7 +136,7 @@ option.
## Versions
The most recent version of Chronograf is
[v1.4.2.1](https://www.influxdata.com/downloads/).
[v1.4.2.2](https://www.influxdata.com/downloads/).
Spotted a bug or have a feature request? Please open
[an issue](https://github.com/influxdata/chronograf/issues/new)!
@ -178,7 +178,7 @@ By default, chronograf runs on port `8888`.
To get started right away with Docker, you can pull down our latest release:
```sh
docker pull chronograf:1.4.2.1
docker pull chronograf:1.4.2.2
```
### From Source

View File

@ -2,6 +2,7 @@ package oauth2
import (
"encoding/json"
"fmt"
"net/http"
"github.com/influxdata/chronograf"
@ -61,7 +62,19 @@ func (h *Heroku) PrincipalID(provider *http.Client) (string, error) {
DefaultOrganization DefaultOrg `json:"default_organization"`
}
resp, err := provider.Get(HerokuAccountRoute)
req, err := http.NewRequest("GET", HerokuAccountRoute, nil)
// Requests fail to Heroku unless this Accept header is set.
req.Header.Set("Accept", "application/vnd.heroku+json; version=3")
resp, err := provider.Do(req)
if resp.StatusCode/100 != 2 {
err := fmt.Errorf(
"Unable to GET user data from %s. Status: %s",
HerokuAccountRoute,
resp.Status,
)
h.Logger.Error("", err)
return "", err
}
if err != nil {
h.Logger.Error("Unable to communicate with Heroku. err:", err)
return "", err

View File

@ -3,7 +3,7 @@
"info": {
"title": "Chronograf",
"description": "API endpoints for Chronograf",
"version": "1.4.2.1"
"version": "1.4.2.2"
},
"schemes": ["http"],
"basePath": "/chronograf/v1",

View File

@ -2,10 +2,19 @@ module.exports = {
projects: [
{
displayName: 'test',
testPathIgnorePatterns: ['/build/'],
testPathIgnorePatterns: [
'build',
'<rootDir>/node_modules/(?!(jest-test))',
],
modulePaths: ['<rootDir>', '<rootDir>/node_modules/'],
moduleDirectories: ['src'],
setupFiles: ['<rootDir>/test/setupTests.js'],
transform: {
'^.+\\.tsx?$': 'ts-jest',
'^.+\\.js$': 'babel-jest',
},
testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
},
{
runner: 'jest-runner-eslint',

View File

@ -1,6 +1,6 @@
{
"name": "chronograf-ui",
"version": "1.4.2-1",
"version": "1.4.2-2",
"private": false,
"license": "AGPL-3.0",
"description": "",
@ -16,7 +16,7 @@
"start:fast": "webpack --watch --config ./webpack/dev.config.js",
"start:hmr": "webpack-dev-server --open --config ./webpack/dev.config.js",
"lint": "esw src/",
"test": "jest --runInBand",
"test": "jest",
"test:lint": "yarn run lint; yarn run test",
"test:watch": "jest --watch",
"clean": "rm -rf ./build/*",
@ -31,9 +31,11 @@
},
"devDependencies": {
"@types/chai": "^4.1.2",
"@types/enzyme": "^3.1.9",
"@types/jest": "^22.1.4",
"@types/lodash": "^4.14.104",
"@types/mocha": "^2.2.48",
"@types/node": "^9.4.6",
"@types/prop-types": "^15.5.2",
"@types/react": "^16.0.38",
"autoprefixer": "^6.3.1",
"babel-core": "^6.5.1",
@ -51,7 +53,6 @@
"babel-preset-react": "^6.5.0",
"babel-preset-stage-0": "^6.16.0",
"babel-runtime": "^6.5.0",
"bower": "^1.7.7",
"compression-webpack-plugin": "^1.1.8",
"concurrently": "^3.5.0",
"core-js": "^2.1.3",
@ -71,7 +72,6 @@
"file-loader": "^1.1.7",
"fork-ts-checker-webpack-plugin": "^0.3.0",
"hanson": "^1.1.1",
"hson-loader": "^1.0.0",
"html-webpack-include-assets-plugin": "^1.0.2",
"html-webpack-plugin": "^2.30.1",
"imports-loader": "^0.6.5",
@ -79,7 +79,6 @@
"jest-runner-eslint": "^0.4.0",
"jsdom": "^9.0.0",
"json-loader": "^0.5.7",
"mustache": "^2.2.1",
"node-sass": "^4.5.3",
"on-build-webpack": "^0.1.0",
"postcss-browser-reporter": "^0.4.0",
@ -93,8 +92,8 @@
"resolve-url-loader": "^2.2.1",
"sass-loader": "^6.0.6",
"style-loader": "^0.13.0",
"testem": "^1.2.1",
"thread-loader": "^1.1.4",
"thread-loader": "^1.1.5",
"ts-jest": "^22.4.1",
"ts-loader": "^3.5.0",
"tslib": "^1.9.0",
"typescript": "^2.7.2",

View File

@ -111,6 +111,7 @@ class OrganizationsTableRow extends Component {
onConfirm={this.handleDeleteOrg}
onClickOutside={this.handleDismissDeleteConfirmation}
confirmLeft={true}
confirmTitle="Delete"
/>
: <OrganizationsTableRowDeleteButton
organization={organization}

View File

@ -109,6 +109,7 @@ class ProvidersTableRow extends Component {
onCancel={this.handleDismissDeleteConfirmation}
onConfirm={this.handleDeleteMap}
onClickOutside={this.handleDismissDeleteConfirmation}
confirmTitle="Delete"
/>
: <button
className="btn btn-sm btn-default btn-square"

View File

@ -90,7 +90,7 @@ class AxesOptions extends Component {
<h5 className="display-options--header">
{menuOption} Controls
</h5>
<form autoComplete="off" style={{margin: '0 -6px'}}>
<form autoComplete="off" className="form-group-wrapper">
<div className="form-group col-sm-12">
<label htmlFor="prefix">Title</label>
<OptIn

View File

@ -190,7 +190,7 @@ class GaugeOptions extends Component {
/>
)}
</div>
<div className="single-stat-controls">
<div className="graph-options-group form-group-wrapper">
<div className="form-group col-xs-6">
<label>Prefix</label>
<input

View File

@ -8,15 +8,16 @@ const GraphOptionsCustomizableColumn = ({
onColumnRename,
}) => {
return (
<div className="gauge-controls--section">
<div className="gauge-controls--label">
<div className="column-controls--section">
<div className="column-controls--label">
{originalColumnName}
</div>
<InputClickToEdit
value={newColumnName}
wrapperClass="fancytable--td orgs-table--name"
wrapperClass="column-controls-input"
onUpdate={onColumnRename}
placeholder="Rename..."
appearAsNormalInput={true}
/>
</div>
)

View File

@ -5,8 +5,8 @@ import uuid from 'uuid'
const GraphOptionsCustomizeColumns = ({columns, onColumnRename}) => {
return (
<div>
<label>Customize Columns</label>
<div className="graph-options-group">
<label className="form-label">Customize Columns</label>
{columns.map(col => {
return (
<GraphOptionsCustomizableColumn

View File

@ -7,8 +7,8 @@ const GraphOptionsSortBy = ({sortByOptions, onChooseSortBy}) =>
<Dropdown
items={sortByOptions}
selected={sortByOptions[0].text}
buttonColor="btn-primary"
buttonSize="btn-xs"
buttonColor="btn-default"
buttonSize="btn-sm"
className="dropdown-stretch"
onChoose={onChooseSortBy}
/>

View File

@ -8,7 +8,7 @@ import {
// TODO: Needs major refactoring to make thresholds a much more general component to be shared between single stat, gauge, and table.
const GraphOptionsTextWrapping = ({singleStatType, onToggleTextWrapping}) => {
return (
<div>
<div className="form-group col-xs-12">
<label>Text Wrapping</label>
<ul className="nav nav-tablist nav-tablist-sm">
<li

View File

@ -11,7 +11,7 @@ const GraphOptionsThresholdColoring = ({
singleStatType,
}) => {
return (
<div>
<div className="form-group col-xs-12 col-md-6">
<label>Threshold Coloring</label>
<ul className="nav nav-tablist nav-tablist-sm">
<li

View File

@ -19,8 +19,8 @@ const GraphOptionsThresholds = ({
onDeleteThreshold,
}) => {
return (
<div>
<label>Thresholds</label>
<div className="gauge-controls graph-options-group">
<label className="form-label">Thresholds</label>
<button
className="btn btn-sm btn-primary gauge-controls--add-threshold"
onClick={onAddThreshold}

View File

@ -3,7 +3,7 @@ import React, {PropTypes} from 'react'
const VERTICAL = 'VERTICAL'
const HORIZONTAL = 'HORIZONTAL'
const GraphOptionsTimeAxis = ({TimeAxis, onToggleTimeAxis}) =>
<div className="form-group col-xs-6">
<div className="form-group col-xs-12 col-sm-6">
<label>Time Axis</label>
<ul className="nav nav-tablist nav-tablist-sm">
<li

View File

@ -1,6 +1,7 @@
import React, {Component} from 'react'
import PropTypes from 'prop-types'
import moment from 'moment'
import classnames from 'classnames'
import InputClickToEdit from 'shared/components/InputClickToEdit'
import {Dropdown} from 'src/shared/components/Dropdown'
@ -43,9 +44,9 @@ class GraphOptionsTimeFormat extends Component {
'For information on formatting, see http://momentjs.com/docs/#/parsing/string-format/'
return (
<div className="gauge-controls--section">
<div className="form-group col-xs-12">
<label>
Time Format{' '}
Time Format
{customFormat &&
<QuestionMarkTooltip
tipID="Time Axis Format"
@ -55,18 +56,21 @@ class GraphOptionsTimeFormat extends Component {
<Dropdown
items={formatOptions}
selected={customFormat ? 'Custom' : format}
buttonColor="btn-primary"
buttonColor="btn-default"
buttonSize="btn-xs"
className="dropdown-stretch"
onChoose={this.handleChooseFormat}
/>
{customFormat &&
<InputClickToEdit
wrapperClass="fancytable--td"
value={format}
onUpdate={this.handleInputChange}
placeholder="Enter custom format..."
/>}
<div className="column-controls--section">
<InputClickToEdit
wrapperClass="column-controls-input "
value={format}
onUpdate={this.handleInputChange}
placeholder="Enter custom format..."
appearAsNormalInput={true}
/>
</div>}
</div>
)
}

View File

@ -186,7 +186,7 @@ class SingleStatOptions extends Component {
/>
)}
</div>
<div className="single-stat-controls">
<div className="graph-options-group form-group-wrapper">
<div className="form-group col-xs-6">
<label>Prefix</label>
<input

View File

@ -66,12 +66,20 @@ class TableOptions extends Component {
const sortedColors = _.sortBy(singleStatColors, color => color.value)
const columns = ['hey', 'yo', 'what'].map(col => ({
const columns = [
'cpu.mean_usage_system',
'cpu.mean_usage_idle',
'cpu.mean_usage_user',
].map(col => ({
text: col,
name: col,
newName: '',
}))
const tableSortByOptions = ['hey', 'yo', 'what'].map(col => ({text: col}))
const tableSortByOptions = [
'cpu.mean_usage_system',
'cpu.mean_usage_idle',
'cpu.mean_usage_user',
].map(col => ({text: col}))
return (
<FancyScrollbar
@ -80,7 +88,7 @@ class TableOptions extends Component {
>
<div className="display-options--cell-wrapper">
<h5 className="display-options--header">Table Controls</h5>
<div className="gauge-controls">
<div className="form-group-wrapper">
<GraphOptionsTimeFormat
timeFormat={timeFormat}
onTimeFormatChange={this.handleTimeFormatChange}
@ -97,20 +105,22 @@ class TableOptions extends Component {
singleStatType={singleStatType}
onToggleTextWrapping={this.handleToggleTextWrapping}
/>
<GraphOptionsCustomizeColumns
columns={columns}
onColumnRename={this.handleColumnRename}
/>
<GraphOptionsThresholds
onAddThreshold={this.handleAddThreshold}
disableAddThreshold={disableAddThreshold}
sortedColors={sortedColors}
formatColor={formatColor}
onChooseColor={this.handleChooseColor}
onValidateColorValue={this.handleValidateColorValue}
onUpdateColorValue={this.handleUpdateColorValue}
onDeleteThreshold={this.handleDeleteThreshold}
/>
</div>
<GraphOptionsCustomizeColumns
columns={columns}
onColumnRename={this.handleColumnRename}
/>
<GraphOptionsThresholds
onAddThreshold={this.handleAddThreshold}
disableAddThreshold={disableAddThreshold}
sortedColors={sortedColors}
formatColor={formatColor}
onChooseColor={this.handleChooseColor}
onValidateColorValue={this.handleValidateColorValue}
onUpdateColorValue={this.handleUpdateColorValue}
onDeleteThreshold={this.handleDeleteThreshold}
/>
<div className="form-group-wrapper graph-options-group">
<GraphOptionsThresholdColoring
onToggleSingleStatType={this.handleToggleSingleStatType}
singleStatColors={singleStatType}

View File

@ -439,29 +439,71 @@ const GRAPH_SVGS = {
y="0px"
width="100%"
height="100%"
viewBox="0 0 550 550"
viewBox="0 0 150 150"
>
<g>
<path
className="viz-type-selector--graphic-line graphic-line-a"
d="M430.274,23.861H16.698C7.48,23.861,0,31.357,0,40.559v365.86c0,5.654,2.834,10.637,7.155,13.663v2.632h5.986 c1.146,0.252,2.332,0.401,3.557,0.401h413.576c1.214,0,2.396-0.149,3.545-0.401h0.821v-0.251 c7.082-1.938,12.336-8.362,12.336-16.044V40.564C446.977,31.357,439.478,23.861,430.274,23.861z M66,408.4H15.458 c-0.676-0.416-1.146-1.132-1.146-1.98v-43.35H66V408.4z M66,348.755H14.312v-47.01H66V348.755z M66,287.436H14.312v-49.632H66 V287.436z M66,223.491H14.312v-53.687H66V223.491z M66,155.49H14.312v-52.493H66V155.49z M186.497,408.4H80.318v-45.33h106.179 V408.4z M186.497,348.755H80.318v-47.01h106.179V348.755z M186.497,287.436H80.318v-49.632h106.179V287.436z M186.497,223.491 H80.318v-53.687h106.179V223.491z M186.497,155.49H80.318v-52.493h106.179V155.49z M186.497,88.68H80.318V38.17h106.179V88.68z M308.195,408.4H200.812v-45.33h107.383V408.4z M308.195,348.755H200.812v-47.01h107.383V348.755z M308.195,287.436H200.812 v-49.632h107.383V287.436z M308.195,223.491H200.812v-53.687h107.383V223.491z M308.195,155.49H200.812v-52.493h107.383V155.49z M308.195,88.68H200.812V38.17h107.383V88.68z M432.66,406.419c0,0.845-0.48,1.56-1.149,1.98h-109v-45.33H432.66V406.419z M432.66,348.755H322.511v-47.01H432.66V348.755z M432.66,287.436H322.511v-49.632H432.66V287.436z M432.66,223.491H322.511 v-53.687H432.66V223.491z M432.66,155.49H322.511v-52.493H432.66V155.49z M432.66,88.68H322.511V38.17h107.764 c1.312,0,2.386,1.073,2.386,2.389V88.68z M175.854,276.251H89.938V246.37h85.915V276.251z M297.261,277.378h-85.915v-29.883h85.915 V277.378z M421.661,276.721h-85.914v-29.883h85.914V276.721z"
/>
</g>
<g />
<g />
<g />
<g />
<g />
<g />
<g />
<g />
<g />
<g />
<g />
<g />
<g />
<g />
<g />
<path
className="viz-type-selector--graphic-fill graphic-fill-c"
d="M55.5,115H19.7c-1.7,0-3.1-1.4-3.1-3.1V61.7h38.9V115z"
/>
<path
className="viz-type-selector--graphic-fill graphic-fill-b"
d="M133.4,61.7H55.5V35h74.8c1.7,0,3.1,1.4,3.1,3.1V61.7z"
/>
<path
className="viz-type-selector--graphic-fill graphic-fill-a"
d="M55.5,61.7H16.6V38.1c0-1.7,1.4-3.1,3.1-3.1h35.9V61.7z"
/>
<path
className="viz-type-selector--graphic-line graphic-line-c"
d="M16.6,88.3v23.6c0,1.7,1.4,3.1,3.1,3.1h35.9V88.3H16.6z"
/>
<rect
className="viz-type-selector--graphic-line graphic-line-c"
x="16.6"
y="61.7"
width="38.9"
height="26.7"
/>
<path
className="viz-type-selector--graphic-line graphic-line-b"
d="M94.5,35v26.7h38.9V38.1c0-1.7-1.4-3.1-3.1-3.1H94.5z"
/>
<rect
className="viz-type-selector--graphic-line graphic-line-b"
x="55.5"
y="35"
width="38.9"
height="26.7"
/>
<path
className="viz-type-selector--graphic-line graphic-line-d"
d="M94.5,115h35.9c1.7,0,3.1-1.4,3.1-3.1V88.3H94.5V115z"
/>
<rect
className="viz-type-selector--graphic-line graphic-line-d"
x="55.5"
y="88.3"
width="38.9"
height="26.7"
/>
<rect
className="viz-type-selector--graphic-line graphic-line-d"
x="94.5"
y="61.7"
width="38.9"
height="26.7"
/>
<rect
className="viz-type-selector--graphic-line graphic-line-d"
x="55.5"
y="61.7"
width="38.9"
height="26.7"
/>
<path
className="viz-type-selector--graphic-line graphic-line-a"
d="M55.5,35H19.7c-1.7,0-3.1,1.4-3.1,3.1v23.6h38.9V35z"
/>
</svg>
</div>
),

View File

@ -1,139 +0,0 @@
import React, {PropTypes} from 'react'
import _ from 'lodash'
import TagListItem from './TagListItem'
import {showTagKeys, showTagValues} from 'shared/apis/metaQuery'
import showTagKeysParser from 'shared/parsing/showTagKeys'
import showTagValuesParser from 'shared/parsing/showTagValues'
const {string, shape, func, bool} = PropTypes
const TagList = React.createClass({
propTypes: {
query: shape({
database: string,
measurement: string,
retentionPolicy: string,
areTagsAccepted: bool.isRequired,
}).isRequired,
onChooseTag: func.isRequired,
onGroupByTag: func.isRequired,
querySource: shape({
links: shape({
proxy: string.isRequired,
}).isRequired,
}),
},
contextTypes: {
source: shape({
links: shape({
proxy: string.isRequired,
}).isRequired,
}).isRequired,
},
getDefaultProps() {
return {
querySource: null,
}
},
getInitialState() {
return {
tags: {},
}
},
_getTags() {
const {database, measurement, retentionPolicy} = this.props.query
const {source} = this.context
const {querySource} = this.props
const proxy =
_.get(querySource, ['links', 'proxy'], null) || source.links.proxy
showTagKeys({source: proxy, database, retentionPolicy, measurement})
.then(resp => {
const {errors, tagKeys} = showTagKeysParser(resp.data)
if (errors.length) {
// do something
}
return showTagValues({
source: proxy,
database,
retentionPolicy,
measurement,
tagKeys,
})
})
.then(resp => {
const {errors: errs, tags} = showTagValuesParser(resp.data)
if (errs.length) {
// do something
}
this.setState({tags})
})
},
componentDidMount() {
const {database, measurement, retentionPolicy} = this.props.query
if (!database || !measurement || !retentionPolicy) {
return
}
this._getTags()
},
componentDidUpdate(prevProps) {
const {query, querySource} = this.props
const {database, measurement, retentionPolicy} = query
const {
database: prevDB,
measurement: prevMeas,
retentionPolicy: prevRP,
} = prevProps.query
if (!database || !measurement || !retentionPolicy) {
return
}
if (
database === prevDB &&
measurement === prevMeas &&
retentionPolicy === prevRP &&
_.isEqual(prevProps.querySource, querySource)
) {
return
}
this._getTags()
},
render() {
const {query} = this.props
return (
<div className="query-builder--sub-list">
{_.map(this.state.tags, (tagValues, tagKey) => {
return (
<TagListItem
key={tagKey}
tagKey={tagKey}
tagValues={tagValues}
selectedTagValues={query.tags[tagKey] || []}
isUsingGroupBy={query.groupBy.tags.indexOf(tagKey) > -1}
onChooseTag={this.props.onChooseTag}
onGroupByTag={this.props.onGroupByTag}
/>
)
})}
</div>
)
},
})
export default TagList

View File

@ -60,8 +60,8 @@ class KapacitorPage extends Component {
})
}
handleChangeUrl = ({value}) => {
this.setState({kapacitor: {...this.state.kapacitor, url: value}})
handleChangeUrl = e => {
this.setState({kapacitor: {...this.state.kapacitor, url: e.target.value}})
}
handleSubmit = e => {

View File

@ -1,4 +1,5 @@
import React, {Component, PropTypes} from 'react'
import React, {Component} from 'react'
import PropTypes from 'prop-types'
import OnClickOutside from 'shared/components/OnClickOutside'

View File

@ -1,86 +0,0 @@
import React, {PropTypes, Component} from 'react'
import classnames from 'classnames'
import OnClickOutside from 'shared/components/OnClickOutside'
class ConfirmButtons extends Component {
constructor(props) {
super(props)
}
handleConfirm = item => () => {
this.props.onConfirm(item)
}
handleCancel = item => () => {
this.props.onCancel(item)
}
handleClickOutside = () => {
this.props.onClickOutside(this.props.item)
}
render() {
const {item, buttonSize, isDisabled, confirmLeft} = this.props
return confirmLeft
? <div className="confirm-buttons">
<button
className={classnames('btn btn-success btn-square', {
[buttonSize]: buttonSize,
})}
disabled={isDisabled}
title={isDisabled ? 'Cannot Save' : 'Save'}
onClick={this.handleConfirm(item)}
>
<span className="icon checkmark" />
</button>
<button
className={classnames('btn btn-info btn-square', {
[buttonSize]: buttonSize,
})}
onClick={this.handleCancel(item)}
>
<span className="icon remove" />
</button>
</div>
: <div className="confirm-buttons">
<button
className={classnames('btn btn-info btn-square', {
[buttonSize]: buttonSize,
})}
onClick={this.handleCancel(item)}
>
<span className="icon remove" />
</button>
<button
className={classnames('btn btn-success btn-square', {
[buttonSize]: buttonSize,
})}
disabled={isDisabled}
title={isDisabled ? 'Cannot Save' : 'Save'}
onClick={this.handleConfirm(item)}
>
<span className="icon checkmark" />
</button>
</div>
}
}
const {func, oneOfType, shape, string, bool} = PropTypes
ConfirmButtons.propTypes = {
onConfirm: func.isRequired,
item: oneOfType([shape(), string]),
onCancel: func.isRequired,
buttonSize: string,
isDisabled: bool,
onClickOutside: func,
confirmLeft: bool,
}
ConfirmButtons.defaultProps = {
buttonSize: 'btn-sm',
onClickOutside: () => {},
}
export default OnClickOutside(ConfirmButtons)

View File

@ -0,0 +1,120 @@
import React, {PureComponent, SFC} from 'react'
import classnames from 'classnames'
import OnClickOutside from 'src/shared/components/OnClickOutside'
type Item = Object | string
interface ConfirmProps {
buttonSize: string
isDisabled: boolean
onConfirm: () => void
icon: string
title: string
}
interface CancelProps {
buttonSize: string
onCancel: () => void
icon: string
}
interface ConfirmButtonsProps {
onConfirm: (item: Item) => void
item: Item
onCancel: (item: Item) => void
buttonSize?: string
isDisabled?: boolean
onClickOutside?: (item: Item) => void
confirmLeft?: boolean
confirmTitle?: string
}
export const Confirm: SFC<ConfirmProps> = ({
buttonSize,
isDisabled,
onConfirm,
icon,
title,
}) =>
<button
data-test="confirm"
className={classnames('btn btn-success btn-square', {
[buttonSize]: buttonSize,
})}
disabled={isDisabled}
title={isDisabled ? `Cannot ${title}` : title}
onClick={onConfirm}
>
<span className={icon} />
</button>
export const Cancel: SFC<CancelProps> = ({buttonSize, onCancel, icon}) =>
<button
data-test="cancel"
className={classnames('btn btn-info btn-square', {
[buttonSize]: buttonSize,
})}
onClick={onCancel}
>
<span className={icon} />
</button>
class ConfirmButtons extends PureComponent<ConfirmButtonsProps, {}> {
constructor(props) {
super(props)
}
public static defaultProps: Partial<ConfirmButtonsProps> = {
buttonSize: 'btn-sm',
onClickOutside: () => {},
confirmTitle: 'Save',
}
handleConfirm = item => () => {
this.props.onConfirm(item)
}
handleCancel = item => () => {
this.props.onCancel(item)
}
handleClickOutside = () => {
this.props.onClickOutside(this.props.item)
}
render() {
const {item, buttonSize, isDisabled, confirmLeft, confirmTitle} = this.props
return confirmLeft
? <div className="confirm-buttons">
<Confirm
buttonSize={buttonSize}
isDisabled={isDisabled}
onConfirm={this.handleConfirm(item)}
icon="icon checkmark"
title={confirmTitle}
/>
<Cancel
buttonSize={buttonSize}
onCancel={this.handleCancel(item)}
icon="icon remove"
/>
</div>
: <div className="confirm-buttons">
<Cancel
buttonSize={buttonSize}
onCancel={this.handleCancel(item)}
icon="icon remove"
/>
<Confirm
buttonSize={buttonSize}
isDisabled={isDisabled}
onConfirm={this.handleConfirm(item)}
icon="icon checkmark"
title={confirmTitle}
/>
</div>
}
}
export default OnClickOutside(ConfirmButtons)

View File

@ -1,137 +0,0 @@
import React, {PropTypes} from 'react'
import classnames from 'classnames'
import _ from 'lodash'
import {showDatabases, showRetentionPolicies} from 'shared/apis/metaQuery'
import showDatabasesParser from 'shared/parsing/showDatabases'
import showRetentionPoliciesParser from 'shared/parsing/showRetentionPolicies'
import FancyScrollbar from 'shared/components/FancyScrollbar'
const {func, shape, string} = PropTypes
const DatabaseList = React.createClass({
propTypes: {
query: shape({}).isRequired,
onChooseNamespace: func.isRequired,
querySource: shape({
links: shape({
proxy: string.isRequired,
}).isRequired,
}),
},
getDefaultProps() {
return {
source: null,
}
},
contextTypes: {
source: shape({
links: shape({
proxy: string.isRequired,
}).isRequired,
}).isRequired,
},
getInitialState() {
return {
namespaces: [],
}
},
componentDidMount() {
this.getDbRp()
},
componentDidUpdate({querySource: prevSource, query: prevQuery}) {
const {querySource: nextSource, query: nextQuery} = this.props
const differentSource = !_.isEqual(prevSource, nextSource)
if (prevQuery.rawText === nextQuery.rawText) {
return
}
const newMetaQuery =
nextQuery.rawText && nextQuery.rawText.match(/^(create|drop)/i)
if (differentSource || newMetaQuery) {
setTimeout(this.getDbRp, 100)
}
},
getDbRp() {
const {source} = this.context
const {querySource} = this.props
const proxy =
_.get(querySource, ['links', 'proxy'], null) || source.links.proxy
showDatabases(proxy).then(resp => {
const {errors, databases} = showDatabasesParser(resp.data)
if (errors.length) {
// do something
}
const namespaces = []
showRetentionPolicies(proxy, databases).then(res => {
res.data.results.forEach((result, index) => {
const {errors: errs, retentionPolicies} = showRetentionPoliciesParser(
result
)
if (errs.length) {
// do something
}
retentionPolicies.forEach(rp => {
namespaces.push({
database: databases[index],
retentionPolicy: rp.name,
})
})
})
this.setState({namespaces})
})
})
},
render() {
const {query, onChooseNamespace} = this.props
const {namespaces} = this.state
const sortedNamespaces = namespaces.length
? _.sortBy(namespaces, n => n.database.toLowerCase())
: namespaces
return (
<div className="query-builder--column query-builder--column-db">
<div className="query-builder--heading">DB.RetentionPolicy</div>
<div className="query-builder--list">
<FancyScrollbar>
{sortedNamespaces.map(namespace => {
const {database, retentionPolicy} = namespace
const isActive =
database === query.database &&
retentionPolicy === query.retentionPolicy
return (
<div
className={classnames('query-builder--list-item', {
active: isActive,
})}
key={`${database}..${retentionPolicy}`}
onClick={_.wrap(namespace, onChooseNamespace)}
data-test={`query-builder-list-item-database-${database}`}
>
{database}.{retentionPolicy}
</div>
)
})}
</FancyScrollbar>
</div>
</div>
)
},
})
export default DatabaseList

View File

@ -0,0 +1,131 @@
import React, {PureComponent} from 'react'
import PropTypes from 'prop-types'
import _ from 'lodash'
import {Source, Query} from 'src/types'
import {Namespace} from 'src/types/query'
import {showDatabases, showRetentionPolicies} from 'src/shared/apis/metaQuery'
import showDatabasesParser from 'src/shared/parsing/showDatabases'
import showRetentionPoliciesParser from 'src/shared/parsing/showRetentionPolicies'
import FancyScrollbar from 'src/shared/components/FancyScrollbar'
import DatabaseListItem from 'src/shared/components/DatabaseListItem'
interface DatabaseListProps {
query: Query
querySource: Source
onChooseNamespace: (namespace: Namespace) => void
source: Source
}
interface DatabaseListState {
namespaces: Namespace[]
}
const {shape, string} = PropTypes
class DatabaseList extends PureComponent<DatabaseListProps, DatabaseListState> {
constructor(props) {
super(props)
this.getDbRp = this.getDbRp.bind(this)
this.handleChooseNamespace = this.handleChooseNamespace.bind(this)
this.state = {
namespaces: [],
}
}
public static defaultProps: Partial<DatabaseListProps> = {
source: null,
}
public static contextTypes = {
source: shape({
links: shape({
proxy: string.isRequired,
}).isRequired,
}).isRequired,
}
componentDidMount() {
this.getDbRp()
}
componentDidUpdate({querySource: prevSource, query: prevQuery}) {
const {querySource: nextSource, query: nextQuery} = this.props
const differentSource = !_.isEqual(prevSource, nextSource)
if (prevQuery.rawText === nextQuery.rawText) {
return
}
const newMetaQuery =
nextQuery.rawText && nextQuery.rawText.match(/^(create|drop)/i)
if (differentSource || newMetaQuery) {
setTimeout(this.getDbRp, 100)
}
}
async getDbRp() {
const {source} = this.context
const {querySource} = this.props
const proxy = _.get(querySource, ['links', 'proxy'], source.links.proxy)
try {
const {data} = await showDatabases(proxy)
const {databases} = showDatabasesParser(data)
const rps = await showRetentionPolicies(proxy, databases)
const namespaces = rps.data.results.reduce((acc, result, index) => {
const {retentionPolicies} = showRetentionPoliciesParser(result)
const dbrp = retentionPolicies.map(rp => ({
database: databases[index],
retentionPolicy: rp.name,
}))
return [...acc, ...dbrp]
}, [])
const sorted = _.sortBy(namespaces, ({database}: Namespace) =>
database.toLowerCase()
)
this.setState({namespaces: sorted})
} catch (err) {
console.error(err)
}
}
handleChooseNamespace(namespace: Namespace) {
return () => this.props.onChooseNamespace(namespace)
}
isActive(query: Query, {database, retentionPolicy}: Namespace) {
return (
database === query.database && retentionPolicy === query.retentionPolicy
)
}
render() {
return (
<div className="query-builder--column query-builder--column-db">
<div className="query-builder--heading">DB.RetentionPolicy</div>
<div className="query-builder--list">
<FancyScrollbar>
{this.state.namespaces.map(namespace =>
<DatabaseListItem
isActive={this.isActive(this.props.query, namespace)}
namespace={namespace}
onChooseNamespace={this.handleChooseNamespace}
key={namespace.database + namespace.retentionPolicy}
/>
)}
</FancyScrollbar>
</div>
</div>
)
}
}
export default DatabaseList

View File

@ -0,0 +1,26 @@
import React, {SFC} from 'react'
import classnames from 'classnames'
import {Namespace} from 'src/types/query'
export interface DatabaseListItemProps {
isActive: boolean
namespace: Namespace
onChooseNamespace: (namespace: Namespace) => () => void
}
const DatabaseListItem: SFC<DatabaseListItemProps> = ({
isActive,
namespace,
namespace: {database, retentionPolicy},
onChooseNamespace,
}) =>
<div
className={classnames('query-builder--list-item', {
active: isActive,
})}
onClick={onChooseNamespace(namespace)}
>
{database}.{retentionPolicy}
</div>
export default DatabaseListItem

View File

@ -46,9 +46,18 @@ class InputClickToEdit extends Component {
render() {
const {isEditing, value} = this.state
const {wrapperClass, disabled, tabIndex, placeholder} = this.props
const {
wrapperClass: wrapper,
disabled,
tabIndex,
placeholder,
appearAsNormalInput,
} = this.props
const divStyle = value ? 'input-cte' : 'input-cte__empty'
const wrapperClass = `${wrapper}${appearAsNormalInput
? ' input-cte__normal'
: ''}`
const defaultStyle = value ? 'input-cte' : 'input-cte__empty'
return disabled
? <div className={wrapperClass}>
@ -68,16 +77,16 @@ class InputClickToEdit extends Component {
onFocus={this.handleFocus}
ref={r => (this.inputRef = r)}
tabIndex={tabIndex}
placeholder={placeholder}
spellCheck={false}
/>
: <div
className={divStyle}
className={defaultStyle}
onClick={this.handleInputClick}
onFocus={this.handleInputClick}
tabIndex={tabIndex}
>
{value || placeholder}
<span className="icon pencil" />
{appearAsNormalInput || <span className="icon pencil" />}
</div>}
</div>
}
@ -92,6 +101,7 @@ InputClickToEdit.propTypes = {
disabled: bool,
tabIndex: number,
placeholder: string,
appearAsNormalInput: bool,
}
export default InputClickToEdit

View File

@ -1,219 +0,0 @@
import React, {PropTypes} from 'react'
import classnames from 'classnames'
import _ from 'lodash'
import {showMeasurements} from 'shared/apis/metaQuery'
import showMeasurementsParser from 'shared/parsing/showMeasurements'
import TagList from 'src/data_explorer/components/TagList'
import FancyScrollbar from 'shared/components/FancyScrollbar'
const {func, shape, string} = PropTypes
const MeasurementList = React.createClass({
propTypes: {
query: shape({
database: string,
measurement: string,
}).isRequired,
onChooseMeasurement: func.isRequired,
onChooseTag: func.isRequired,
onToggleTagAcceptance: func.isRequired,
onGroupByTag: func.isRequired,
querySource: shape({
links: shape({
proxy: string.isRequired,
}).isRequired,
}),
},
contextTypes: {
source: shape({
links: shape({
proxy: string.isRequired,
}).isRequired,
}).isRequired,
},
getInitialState() {
return {
measurements: [],
filterText: '',
}
},
getDefaultProps() {
return {
querySource: null,
}
},
componentDidMount() {
if (!this.props.query.database) {
return
}
this._getMeasurements()
},
componentDidUpdate(prevProps) {
const {query, querySource} = this.props
if (!query.database) {
return
}
if (
prevProps.query.database === query.database &&
_.isEqual(prevProps.querySource, querySource)
) {
return
}
this._getMeasurements()
},
handleFilterText(e) {
e.stopPropagation()
this.setState({
filterText: this.refs.filterText.value,
})
},
handleEscape(e) {
if (e.key !== 'Escape') {
return
}
e.stopPropagation()
this.setState({
filterText: '',
})
},
handleAcceptReject(e) {
e.stopPropagation()
this.props.onToggleTagAcceptance()
},
render() {
return (
<div className="query-builder--column">
<div className="query-builder--heading">
<span>Measurements & Tags</span>
{this.props.query.database
? <div className="query-builder--filter">
<input
className="form-control input-sm"
ref="filterText"
placeholder="Filter"
type="text"
value={this.state.filterText}
onChange={this.handleFilterText}
onKeyUp={this.handleEscape}
spellCheck={false}
autoComplete={false}
/>
<span className="icon search" />
</div>
: null}
</div>
{this.renderList()}
</div>
)
},
renderList() {
if (!this.props.query.database) {
return (
<div className="query-builder--list-empty">
<span>
No <strong>Database</strong> selected
</span>
</div>
)
}
const filterText = this.state.filterText.toLowerCase()
const measurements = this.state.measurements.filter(m =>
m.toLowerCase().includes(filterText)
)
return (
<div className="query-builder--list">
<FancyScrollbar>
{measurements.map(measurement => {
const isActive = measurement === this.props.query.measurement
const numTagsActive = Object.keys(this.props.query.tags).length
return (
<div
key={measurement}
onClick={
isActive
? () => {}
: () => this.props.onChooseMeasurement(measurement)
}
>
<div
className={classnames('query-builder--list-item', {
active: isActive,
})}
data-test={`query-builder-list-item-measurement-${measurement}`}
>
<span>
<div className="query-builder--caret icon caret-right" />
{measurement}
</span>
{isActive && numTagsActive >= 1
? <div
className={classnames('flip-toggle', {
flipped: this.props.query.areTagsAccepted,
})}
onClick={this.handleAcceptReject}
>
<div className="flip-toggle--container">
<div className="flip-toggle--front">!=</div>
<div className="flip-toggle--back">=</div>
</div>
</div>
: null}
</div>
{isActive
? <TagList
query={this.props.query}
querySource={this.props.querySource}
onChooseTag={this.props.onChooseTag}
onGroupByTag={this.props.onGroupByTag}
/>
: null}
</div>
)
})}
</FancyScrollbar>
</div>
)
},
_getMeasurements() {
const {source} = this.context
const {querySource} = this.props
const proxy =
_.get(querySource, ['links', 'proxy'], null) || source.links.proxy
showMeasurements(proxy, this.props.query.database).then(resp => {
const {errors, measurementSets} = showMeasurementsParser(resp.data)
if (errors.length) {
// TODO: display errors in the UI.
return console.error('InfluxDB returned error(s): ', errors) // eslint-disable-line no-console
}
this.setState({
measurements: measurementSets[0].measurements,
})
})
},
})
export default MeasurementList

View File

@ -0,0 +1,180 @@
import React, {PureComponent} from 'react'
import PropTypes from 'prop-types'
import _ from 'lodash'
import {showMeasurements} from 'src/shared/apis/metaQuery'
import showMeasurementsParser from 'src/shared/parsing/showMeasurements'
import {Query, Source} from 'src/types'
import MeasurementListFilter from 'src/shared/components/MeasurementListFilter'
import MeasurementListItem from 'src/shared/components/MeasurementListItem'
import FancyScrollbar from 'src/shared/components/FancyScrollbar'
interface Props {
query: Query
querySource: Source
onChooseTag: () => void
onGroupByTag: () => void
onToggleTagAcceptance: () => void
onChooseMeasurement: (measurement: string) => void
}
interface State {
measurements: string[]
filterText: string
filtered: string[]
}
const {shape, string} = PropTypes
class MeasurementList extends PureComponent<Props, State> {
constructor(props) {
super(props)
this.state = {
filtered: [],
measurements: [],
filterText: '',
}
this.handleEscape = this.handleEscape.bind(this)
this.handleFilterText = this.handleFilterText.bind(this)
this.handleAcceptReject = this.handleAcceptReject.bind(this)
this.handleFilterMeasuremet = this.handleFilterMeasuremet.bind(this)
this.handleChoosemeasurement = this.handleChoosemeasurement.bind(this)
}
public static defaultProps: Partial<Props> = {
querySource: null,
}
public static contextTypes = {
source: shape({
links: shape({
proxy: string.isRequired,
}).isRequired,
}).isRequired,
}
componentDidMount() {
if (!this.props.query.database) {
return
}
this.getMeasurements()
}
componentDidUpdate(prevProps) {
const {query, querySource} = this.props
if (!query.database) {
return
}
if (
prevProps.query.database === query.database &&
_.isEqual(prevProps.querySource, querySource)
) {
return
}
this.getMeasurements()
}
handleFilterText(e) {
e.stopPropagation()
const filterText = e.target.value
this.setState({
filterText,
filtered: this.handleFilterMeasuremet(filterText),
})
}
handleFilterMeasuremet(filter) {
return this.state.measurements.filter(m =>
m.toLowerCase().includes(filter.toLowerCase())
)
}
handleEscape(e) {
if (e.key !== 'Escape') {
return
}
e.stopPropagation()
this.setState({
filterText: '',
})
}
handleAcceptReject() {
this.props.onToggleTagAcceptance()
}
handleChoosemeasurement(measurement) {
return () => this.props.onChooseMeasurement(measurement)
}
render() {
const {query, querySource, onChooseTag, onGroupByTag} = this.props
const {database, areTagsAccepted} = query
const {filtered} = this.state
return (
<div className="query-builder--column">
<div className="query-builder--heading">
<span>Measurements & Tags</span>
{database &&
<MeasurementListFilter
onEscape={this.handleEscape}
onFilterText={this.handleFilterText}
filterText={this.state.filterText}
/>}
</div>
{database
? <div className="query-builder--list">
<FancyScrollbar>
{filtered.map(measurement =>
<MeasurementListItem
query={query}
key={measurement}
measurement={measurement}
querySource={querySource}
onChooseTag={onChooseTag}
onGroupByTag={onGroupByTag}
areTagsAccepted={areTagsAccepted}
onAcceptReject={this.handleAcceptReject}
isActive={measurement === query.measurement}
numTagsActive={Object.keys(query.tags).length}
onChooseMeasurement={this.handleChoosemeasurement}
/>
)}
</FancyScrollbar>
</div>
: <div className="query-builder--list-empty">
<span>
No <strong>Database</strong> selected
</span>
</div>}
</div>
)
}
async getMeasurements() {
const {source} = this.context
const {querySource, query} = this.props
const proxy = _.get(querySource, ['links', 'proxy'], source.links.proxy)
try {
const {data} = await showMeasurements(proxy, query.database)
const {measurementSets} = showMeasurementsParser(data)
const measurements = measurementSets[0].measurements
this.setState({measurements, filtered: measurements})
} catch (err) {
console.error(err)
}
}
}
export default MeasurementList

View File

@ -0,0 +1,28 @@
import React, {SFC} from 'react'
interface Props {
filterText: string
onEscape: (e: React.KeyboardEvent<HTMLInputElement>) => void
onFilterText: (e: React.InputHTMLAttributes<HTMLInputElement>) => void
}
const MeasurementListFilter: SFC<Props> = ({
onEscape,
onFilterText,
filterText,
}) =>
<div className="query-builder--filter">
<input
className="form-control input-sm"
placeholder="Filter"
type="text"
value={filterText}
onChange={onFilterText}
onKeyUp={onEscape}
spellCheck={false}
autoComplete="false"
/>
<span className="icon search" />
</div>
export default MeasurementListFilter

View File

@ -0,0 +1,64 @@
import React, {SFC} from 'react'
import classnames from 'classnames'
import TagList from 'src/shared/components/TagList'
import {Query, Source} from 'src/types'
interface Props {
query: Query
querySource: Source
isActive: boolean
measurement: string
numTagsActive: number
areTagsAccepted: boolean
onChooseTag: () => void
onGroupByTag: () => void
onAcceptReject: () => void
onChooseMeasurement: (measurement: string) => () => void
}
const noop = () => {}
const MeasurementListItem: SFC<Props> = ({
query,
isActive,
querySource,
measurement,
onChooseTag,
onGroupByTag,
numTagsActive,
onAcceptReject,
areTagsAccepted,
onChooseMeasurement,
}) =>
<div
key={measurement}
onClick={isActive ? noop : onChooseMeasurement(measurement)}
>
<div className={classnames('query-builder--list-item', {active: isActive})}>
<span>
<div className="query-builder--caret icon caret-right" />
{measurement}
</span>
{isActive &&
numTagsActive >= 1 &&
<div
className={classnames('flip-toggle', {flipped: areTagsAccepted})}
onClick={onAcceptReject}
>
<div className="flip-toggle--container">
<div className="flip-toggle--front">!=</div>
<div className="flip-toggle--back">=</div>
</div>
</div>}
</div>
{isActive &&
<TagList
query={query}
querySource={querySource}
onChooseTag={onChooseTag}
onGroupByTag={onGroupByTag}
/>}
</div>
export default MeasurementListItem

View File

@ -1,6 +1,7 @@
import React, {Component} from 'react'
import PropTypes from 'prop-types'
import _ from 'lodash'
import classnames from 'classnames'
import {timeSeriesToTable} from 'src/utils/timeSeriesToDygraph'
import {MultiGrid} from 'react-virtualized'
import moment from 'moment'
@ -22,11 +23,27 @@ class TableGraph extends Component {
cellRenderer = ({columnIndex, key, rowIndex, style}) => {
const data = this._data
const columnCount = _.get(data, ['0', 'length'], 0)
const rowCount = data.length
const {timeFormat} = this.state
const isTimeCell = columnIndex === 0 && rowIndex > 0
const isFixedRow = rowIndex === 0 && columnIndex > 0
const isFixedColumn = rowIndex > 0 && columnIndex === 0
const isFixedCorner = rowIndex === 0 && columnIndex === 0
const isLastRow = rowIndex === rowCount - 1
const isLastColumn = columnIndex === columnCount - 1
const cellClass = classnames('table-graph-cell', {
'table-graph-cell__fixed-row': isFixedRow,
'table-graph-cell__fixed-column': isFixedColumn,
'table-graph-cell__fixed-corner': isFixedCorner,
'table-graph-cell__last-row': isLastRow,
'table-graph-cell__last-column': isLastColumn,
})
return (
<div key={key} style={style}>
<div key={key} className={cellClass} style={style}>
{isTimeCell
? moment(data[rowIndex][columnIndex]).format(timeFormat)
: data[rowIndex][columnIndex]}
@ -39,13 +56,13 @@ class TableGraph extends Component {
const columnCount = _.get(data, ['0', 'length'], 0)
const rowCount = data.length
const COLUMN_WIDTH = 300
const ROW_HEIGHT = 50
const ROW_HEIGHT = 30
const tableWidth = this.gridContainer ? this.gridContainer.clientWidth : 0
const tableHeight = this.gridContainer ? this.gridContainer.clientHeight : 0
return (
<div
className="graph-container"
className="table-graph-container"
ref={gridContainer => (this.gridContainer = gridContainer)}
>
{data.length > 1 &&
@ -58,8 +75,9 @@ class TableGraph extends Component {
height={tableHeight}
rowCount={rowCount}
rowHeight={ROW_HEIGHT}
width={tableWidth - 32}
onScroll={this.handleScroll}
width={tableWidth}
enableFixedColumnScroll={true}
enableFixedRowScroll={true}
/>}
</div>
)

View File

@ -0,0 +1,130 @@
import React, {PureComponent} from 'react'
import PropTypes from 'prop-types'
import _ from 'lodash'
import TagListItem from 'src/shared/components/TagListItem'
import {Query, Source} from 'src/types'
import {showTagKeys, showTagValues} from 'src/shared/apis/metaQuery'
import showTagKeysParser from 'src/shared/parsing/showTagKeys'
import showTagValuesParser from 'src/shared/parsing/showTagValues'
const {string, shape} = PropTypes
interface Props {
query: Query
querySource: Source
onChooseTag: () => void
onGroupByTag: () => void
}
interface State {
tags: {}
}
class TagList extends PureComponent<Props, State> {
constructor(props) {
super(props)
this.state = {
tags: {},
}
}
public static contextTypes = {
source: shape({
links: shape({
proxy: string.isRequired,
}).isRequired,
}).isRequired,
}
public static defaultProps = {
querySource: null,
}
componentDidMount() {
const {database, measurement, retentionPolicy} = this.props.query
if (!database || !measurement || !retentionPolicy) {
return
}
this.getTags()
}
componentDidUpdate(prevProps) {
const {query, querySource} = this.props
const {database, measurement, retentionPolicy} = query
const {
database: prevDB,
measurement: prevMeas,
retentionPolicy: prevRP,
} = prevProps.query
if (!database || !measurement || !retentionPolicy) {
return
}
if (
database === prevDB &&
measurement === prevMeas &&
retentionPolicy === prevRP &&
_.isEqual(prevProps.querySource, querySource)
) {
return
}
this.getTags()
}
async getTags() {
const {source} = this.context
const {querySource} = this.props
const {database, measurement, retentionPolicy} = this.props.query
const proxy = _.get(querySource, ['links', 'proxy'], source.links.proxy)
const {data} = await showTagKeys({
source: proxy,
database,
retentionPolicy,
measurement,
})
const {tagKeys} = showTagKeysParser(data)
const response = await showTagValues({
source: proxy,
database,
retentionPolicy,
measurement,
tagKeys,
})
const {tags} = showTagValuesParser(response.data)
this.setState({tags})
}
render() {
const {query, onChooseTag, onGroupByTag} = this.props
return (
<div className="query-builder--sub-list">
{_.map(this.state.tags, (tagValues: string[], tagKey: string) =>
<TagListItem
key={tagKey}
tagKey={tagKey}
tagValues={tagValues}
onChooseTag={onChooseTag}
onGroupByTag={onGroupByTag}
selectedTagValues={query.tags[tagKey] || []}
isUsingGroupBy={query.groupBy.tags.indexOf(tagKey) > -1}
/>
)}
</div>
)
}
}
export default TagList

View File

@ -1,39 +1,50 @@
import React, {PropTypes} from 'react'
import React, {PureComponent} from 'react'
import _ from 'lodash'
import classnames from 'classnames'
const {string, arrayOf, func, bool} = PropTypes
const TagListItem = React.createClass({
propTypes: {
tagKey: string.isRequired,
tagValues: arrayOf(string.isRequired).isRequired,
selectedTagValues: arrayOf(string.isRequired).isRequired,
isUsingGroupBy: bool,
onChooseTag: func.isRequired,
onGroupByTag: func.isRequired,
},
interface Props {
tagKey: string
tagValues: string[]
selectedTagValues: string[]
isUsingGroupBy?: boolean
onChooseTag: ({key: string, value}) => void
onGroupByTag: (tagKey: string) => void
}
getInitialState() {
return {
interface State {
isOpen: boolean
filterText: string
}
class TagListItem extends PureComponent<Props, State> {
constructor(props) {
super(props)
this.state = {
isOpen: false,
filterText: '',
}
},
handleChoose(tagValue) {
this.handleEscape = this.handleEscape.bind(this)
this.handleChoose = this.handleChoose.bind(this)
this.handleGroupBy = this.handleGroupBy.bind(this)
this.handleClickKey = this.handleClickKey.bind(this)
this.handleFilterText = this.handleFilterText.bind(this)
}
handleChoose(tagValue: string) {
this.props.onChooseTag({key: this.props.tagKey, value: tagValue})
},
}
handleClickKey() {
this.setState({isOpen: !this.state.isOpen})
},
}
handleFilterText(e) {
e.stopPropagation()
this.setState({
filterText: this.refs.filterText.value,
filterText: e.target.value,
})
},
}
handleEscape(e) {
if (e.key !== 'Escape') {
@ -44,7 +55,12 @@ const TagListItem = React.createClass({
this.setState({
filterText: '',
})
},
}
handleGroupBy(e) {
e.stopPropagation()
this.props.onGroupByTag(this.props.tagKey)
}
renderTagValues() {
const {tagValues, selectedTagValues} = this.props
@ -67,7 +83,7 @@ const TagListItem = React.createClass({
onChange={this.handleFilterText}
onKeyUp={this.handleEscape}
spellCheck={false}
autoComplete={false}
autoComplete="false"
/>
<span className="icon search" />
</div>
@ -91,12 +107,7 @@ const TagListItem = React.createClass({
})}
</div>
)
},
handleGroupBy(e) {
e.stopPropagation()
this.props.onGroupByTag(this.props.tagKey)
},
}
render() {
const {tagKey, tagValues, isUsingGroupBy} = this.props
@ -127,7 +138,7 @@ const TagListItem = React.createClass({
{isOpen ? this.renderTagValues() : null}
</div>
)
},
})
}
}
export default TagListItem

View File

@ -67,6 +67,7 @@
@import 'components/info-indicators';
@import 'components/source-selector';
@import 'components/tables';
@import 'components/table-graph';
@import 'components/kapacitor-logs-table';
// Pages

View File

@ -206,46 +206,50 @@ $graph-type--gutter: 4px;
width: 100%;
}
.gauge-controls--section {
.gauge-controls--section,
.column-controls--section {
width: 100%;
display: flex;
flex-wrap: nowrap;
align-items: center;
height: 30px;
margin-top: 8px;
> * {
margin-left: 4px;
&:first-child {
margin-left: 0;
}
}
}
button.btn.btn-primary.btn-sm.gauge-controls--add-threshold {
width: 100%;
}
.gauge-controls--label {
%gauge-controls-label-styles {
height: 30px;
background-color: $g4-onyx;
line-height: 30px;
font-weight: 600;
color: $g11-sidewalk;
font-size: 13px;
padding: 0 11px;
border-radius: 4px;
line-height: 30px;
@include no-user-select();
}
.gauge-controls--label {
@extend %gauge-controls-label-styles;
color: $g11-sidewalk;
background-color: $g4-onyx;
width: 120px;
}
.gauge-controls--label-editable {
height: 30px;
font-weight: 600;
@extend %gauge-controls-label-styles;
color: $g16-pearl;
padding: 0 11px;
border-radius: 4px;
line-height: 30px;
@include no-user-select();
width: 90px;
}
.gauge-controls--input {
flex: 1 0 0;
margin: 0 0 0 4px;
}
.gauge-controls--section .color-dropdown {
margin-left: 4px;
}
.gauge-controls--section .color-dropdown.color-dropdown--stretch {
width: auto;
@ -253,17 +257,27 @@ button.btn.btn-primary.btn-sm.gauge-controls--add-threshold {
}
.column-controls--label {
@extend %gauge-controls-label-styles;
color: $g16-pearl;
background-color: $g4-onyx;
flex: 2 0 0;
}
.column-controls-input {
flex: 1 0 0;
display: flex;
align-items: center;
}
/*
Cell Editor Overlay - Single-Stat Controls
------------------------------------------------------------------------------
*/
.single-stat-controls {
.graph-options-group {
margin-top: 30px;
}
.form-group-wrapper {
display: inline-block;
width: calc(100% + 12px);
margin: 30px -6px 0 -6px;
> div.form-group {
padding-left: 6px;
padding-right: 6px;
}
margin-left: -6px;
margin-right: -6px;
}

View File

@ -14,6 +14,7 @@
border-radius: 4px;
border-style: solid;
border-width: 2px;
letter-spacing: 0;
}
.input-cte {
@ -55,10 +56,34 @@
.input-cte__empty {
@extend .input-cte;
font-style: italic;
color: $g9-mountain;
font-weight: 500;
font-style: italic;
line-height: 27px;
&:hover {
color: $g9-mountain;
}
}
}
// Appear as Normal Input
// ----------------------------------------------------------------------------
.input-cte__normal {
.input-cte {
border-color: $g5-pepper;
}
.input-cte:hover {
border-color: $g6-smoke;
background-color: $g2-kevlar;
}
.input-cte__empty {
background-color: $g2-kevlar;
}
.input-cte__disabled,
.input-cte__disabled:hover {
border-color: $g5-pepper;
background-color: $g3-castle;
}
}

View File

@ -0,0 +1,48 @@
/*
Table Type Graphs in Dashboards
----------------------------------------------------------------------------
*/
.table-graph-container {
position: absolute;
width: calc(100% - 32px);
height: calc(100% - 16px);
top: 8px;
left: 16px;
border: 2px solid $g5-pepper;
border-radius: 3px;
overflow: hidden;
}
.table-graph-cell {
line-height: 30px;
padding: 0 6px;
font-size: 13px;
color: $g13-mist;
border: 1px solid $g5-pepper;
&__fixed-row,
&__fixed-column {
font-weight: 700;
color: $g16-pearl;
background-color: $g4-onyx;
}
&__fixed-row {
border-top: 0;
}
&__fixed-column {
border-left: 0;
}
&__fixed-corner {
border-top: 0;
border-left: 0;
color: $g18-cloud;
background-color: $g5-pepper;
}
&__last-row {
border-bottom: 0;
}
&__last-column {
border-right: 0;
}
}

View File

@ -8,10 +8,14 @@
border: 2px solid $g5-pepper;
border-radius: $radius;
color: $g15-platinum;
letter-spacing: 0px;
background-color: $g2-kevlar;
font-weight: 600;
box-shadow: none;
outline: none;
-moz-appearance: none;
-webkit-appearance: none;
appearance: none;
transition:
color 0.25s ease,
background-color 0.25s ease,
@ -188,7 +192,8 @@ textarea.form-control {
.form-group > .btn {
display: inline-block;
}
.form-group > label {
.form-group > label,
label.form-label {
display: inline-block;
font-size: 12px;
font-weight: 600;

4
ui/src/types/index.tsx Normal file
View File

@ -0,0 +1,4 @@
import {Query} from './query'
import {Source} from './sources'
export {Query, Source}

59
ui/src/types/query.tsx Normal file
View File

@ -0,0 +1,59 @@
export interface Query {
id: QueryID
database: string
measurement: string
retentionPolicy: string
fields: Field[]
tags: Tags
groupBy: GroupBy
areTagsAccepted: boolean
rawText: string | null
range?: TimeRange | null
source?: string
fill: string
status?: Status
}
export interface Field {
value: string
type: string
alias?: string
args?: Args[]
}
export type QueryID = string
export interface Args {
value: string
type: string
alias?: string
args?: Args[]
}
export type TagValues = string[]
export interface Tags {
[key: string]: TagValues
}
export interface GroupBy {
time?: string
tags?: string[]
}
export interface Namespace {
database: string
retentionPolicy: string
}
export interface Status {
loading?: string
error?: string
warn?: string
success?: string
}
export interface TimeRange {
lower: string
upper?: string
}

38
ui/src/types/sources.tsx Normal file
View File

@ -0,0 +1,38 @@
export interface Source {
id: string
name: string
url: string
type: string
default: boolean
organization: string
insecureSkipVerify: boolean
role: string
telegraf: string
links: SourceLinks
kapacitors?: Kapacitor[]
metaUrl?: string
}
export interface SourceLinks {
self: string
kapacitors: string
proxy: string
queries: string
write: string
permissions: string
users: string
databases: string
roles?: string
}
export interface Kapacitor {
id?: string
url: string
name: string
username?: string
password?: string
active: boolean
links: {
self: string
}
}

View File

@ -17,8 +17,6 @@ const labels = ['time', 'test.label']
const div = document.createElement('div')
const graph = new Dygraph(div, timeSeries, {labels})
const oneHourMs = '3600000'
const a1 = {
group: '',
name: 'a1',
@ -43,14 +41,14 @@ describe('Shared.Annotations.Helpers', () => {
const actual = visibleAnnotations(undefined, annotations)
const expected = []
expect(actual).to.deep.equal(expected)
expect(actual).toEqual(expected)
})
it('returns an annotation if it is in the time range', () => {
const actual = visibleAnnotations(graph, annotations)
const expected = annotations
expect(actual).to.deep.equal(expected)
expect(actual).toEqual(expected)
})
it('removes an annotation if it is out of the time range', () => {
@ -65,7 +63,7 @@ describe('Shared.Annotations.Helpers', () => {
const actual = visibleAnnotations(graph, newAnnos)
const expected = annotations
expect(actual).to.deep.equal(expected)
expect(actual).toEqual(expected)
})
describe('with a duration', () => {
@ -79,7 +77,7 @@ describe('Shared.Annotations.Helpers', () => {
}
const expected = [...withDurations, expectedAnnotation]
expect(actual).to.deep.equal(expected)
expect(actual).toEqual(expected)
})
it('does not add a duration annotation if it is out of bounds', () => {
@ -96,7 +94,7 @@ describe('Shared.Annotations.Helpers', () => {
const actual = visibleAnnotations(graph, withDurations)
const expected = withDurations
expect(actual).to.deep.equal(expected)
expect(actual).toEqual(expected)
})
})
})

View File

@ -30,12 +30,12 @@ const state = {
annotations: [],
}
describe.only('Shared.Reducers.annotations', () => {
describe('Shared.Reducers.annotations', () => {
it('can load the annotations', () => {
const expected = [{time: '0', duration: ''}]
const actual = reducer(state, loadAnnotations(expected))
expect(actual.annotations).to.deep.equal(expected)
expect(actual.annotations).toEqual(expected)
})
it('can update an annotation', () => {
@ -45,7 +45,7 @@ describe.only('Shared.Reducers.annotations', () => {
updateAnnotation(expected[0])
)
expect(actual.annotations).to.deep.equal(expected)
expect(actual.annotations).toEqual(expected)
})
it('can delete an annotation', () => {
@ -55,13 +55,13 @@ describe.only('Shared.Reducers.annotations', () => {
deleteAnnotation(a1)
)
expect(actual.annotations).to.deep.equal(expected)
expect(actual.annotations).toEqual(expected)
})
it('can add an annotation', () => {
const expected = [a1]
const actual = reducer(state, addAnnotation(a1))
expect(actual.annotations).to.deep.equal(expected)
expect(actual.annotations).toEqual(expected)
})
})

View File

@ -54,31 +54,31 @@ describe('Dashboards.Reducers.cellEditorOverlay', () => {
singleStatType: defaultSingleStatType,
}
expect(actual.cell).to.equal(expected.cell)
expect(actual.gaugeColors).to.equal(expected.gaugeColors)
expect(actual.singleStatColors).to.equal(expected.singleStatColors)
expect(actual.singleStatType).to.equal(expected.singleStatType)
expect(actual.cell).toBe(expected.cell)
expect(actual.gaugeColors).toBe(expected.gaugeColors)
expect(actual.singleStatColors).toBe(expected.singleStatColors)
expect(actual.singleStatType).toBe(expected.singleStatType)
})
it('should hide cell editor overlay', () => {
const actual = reducer(initialState, hideCellEditorOverlay)
const expected = null
expect(actual.cell).to.equal(expected)
expect(actual.cell).toBe(expected)
})
it('should change the cell editor visualization type', () => {
const actual = reducer(initialState, changeCellType(defaultCellType))
const expected = defaultCellType
expect(actual.cell.type).to.equal(expected)
expect(actual.cell.type).toBe(expected)
})
it('should change the name of the cell', () => {
const actual = reducer(initialState, renameCell(defaultCellName))
const expected = defaultCellName
expect(actual.cell.name).to.equal(expected)
expect(actual.cell.name).toBe(expected)
})
it('should update the cell single stat colors', () => {
@ -88,7 +88,7 @@ describe('Dashboards.Reducers.cellEditorOverlay', () => {
)
const expected = defaultSingleStatColors
expect(actual.singleStatColors).to.equal(expected)
expect(actual.singleStatColors).toBe(expected)
})
it('should toggle the single stat type', () => {
@ -98,20 +98,20 @@ describe('Dashboards.Reducers.cellEditorOverlay', () => {
)
const expected = defaultSingleStatType
expect(actual.singleStatType).to.equal(expected)
expect(actual.singleStatType).toBe(expected)
})
it('should update the cell gauge colors', () => {
const actual = reducer(initialState, updateGaugeColors(defaultGaugeColors))
const expected = defaultGaugeColors
expect(actual.gaugeColors).to.equal(expected)
expect(actual.gaugeColors).toBe(expected)
})
it('should update the cell axes', () => {
const actual = reducer(initialState, updateAxes(defaultCellAxes))
const expected = defaultCellAxes
expect(actual.cell.axes).to.equal(expected)
expect(actual.cell.axes).toBe(expected)
})
})

View File

@ -1,3 +0,0 @@
const context = require.context('./', true, /Spec\.js$/)
context.keys().forEach(context)
module.exports = context

49
ui/test/resources.ts Normal file
View File

@ -0,0 +1,49 @@
export const source = {
id: '16',
name: 'ssl',
type: 'influx',
username: 'admin',
url: 'https://localhost:9086',
insecureSkipVerify: true,
default: false,
telegraf: 'telegraf',
organization: '0',
role: 'viewer',
links: {
self: '/chronograf/v1/sources/16',
kapacitors: '/chronograf/v1/sources/16/kapacitors',
proxy: '/chronograf/v1/sources/16/proxy',
queries: '/chronograf/v1/sources/16/queries',
write: '/chronograf/v1/sources/16/write',
permissions: '/chronograf/v1/sources/16/permissions',
users: '/chronograf/v1/sources/16/users',
databases: '/chronograf/v1/sources/16/dbs',
},
}
export const query = {
id: '0',
database: 'db1',
measurement: 'm1',
retentionPolicy: 'r1',
fill: 'null',
fields: [
{
value: 'f1',
type: 'field',
alias: 'foo',
args: [],
},
],
tags: {
tk1: ['tv1', 'tv2'],
},
groupBy: {
time: null,
tags: [],
},
areTagsAccepted: true,
rawText: null,
status: null,
shifts: [],
}

View File

@ -0,0 +1,118 @@
import React from 'react'
import ConfirmButtons, {
Confirm,
Cancel,
} from 'src/shared/components/ConfirmButtons'
import {shallow} from 'enzyme'
const setup = (override = {}) => {
const props = {
item: '',
buttonSize: '',
isDisabled: false,
confirmLeft: false,
confirmTitle: '',
onConfirm: () => {},
onCancel: () => {},
onClickOutside: () => {},
...override,
}
const wrapper = shallow(<ConfirmButtons {...props} />)
return {
props,
wrapper,
}
}
describe('Componenets.Shared.ConfirmButtons', () => {
describe('rendering', () => {
it('has a confirm and cancel button', () => {
const {wrapper} = setup()
const confirm = wrapper.dive().find(Confirm)
const cancel = wrapper.dive().find(Cancel)
expect(confirm.exists()).toBe(true)
expect(cancel.exists()).toBe(true)
})
describe('confirmLeft is true', () => {
it('has a confirm button to the left of the cancel button', () => {
const {wrapper} = setup({confirmLeft: true})
const buttons = wrapper.dive().children()
const firstButton = buttons.first()
const lastButton = buttons.last()
expect(firstButton.find(Confirm).exists()).toBe(true)
expect(lastButton.find(Cancel).exists()).toBe(true)
})
})
describe('confirmLeft is false', () => {
it('has a confirm button to the right of the cancel button', () => {
const {wrapper} = setup({confirmLeft: false})
const buttons = wrapper.dive().children()
const firstButton = buttons.first()
const lastButton = buttons.last()
expect(firstButton.find(Cancel).exists()).toBe(true)
expect(lastButton.find(Confirm).exists()).toBe(true)
})
})
describe('confirmTitle', () => {
describe('is not defined', () => {
it('has a default title', () => {
const {wrapper} = setup({confirmTitle: undefined})
const confirm = wrapper.dive().find(Confirm)
const title = confirm.prop('title')
expect(title.length).toBeTruthy()
})
})
describe('is defined', () => {
it('has the title passed in as props', () => {
const confirmTitle = 'delete'
const {wrapper} = setup({confirmTitle})
const confirm = wrapper.dive().find(Confirm)
const title = confirm.prop('title')
expect(title).toContain(confirmTitle)
})
})
})
})
describe('user interaction', () => {
describe('when clicking confirm', () => {
it('should fire onConfirm', () => {
const onConfirm = jest.fn()
const item = 'test-item'
const {wrapper} = setup({onConfirm, item})
const confirm = wrapper.dive().find(Confirm)
const button = confirm.dive().find({'data-test': 'confirm'})
button.simulate('click')
expect(onConfirm).toHaveBeenCalledTimes(1)
expect(onConfirm).toHaveBeenCalledWith(item)
})
})
describe('when clicking cancel', () => {
it('should fire onCancel', () => {
const onCancel = jest.fn()
const item = 'test-item'
const {wrapper} = setup({onCancel, item})
const cancel = wrapper.dive().find(Cancel)
const button = cancel.dive().find({'data-test': 'cancel'})
button.simulate('click')
expect(onCancel).toHaveBeenCalledTimes(1)
expect(onCancel).toHaveBeenCalledWith(item)
})
})
})
})

View File

@ -0,0 +1,92 @@
import React from 'react'
import DatabaseList from 'src/shared/components/DatabaseList'
import DatabaseListItem from 'src/shared/components/DatabaseListItem'
import {shallow} from 'enzyme'
import {query, source} from 'test/resources'
// mock data
const dbrp1 = {database: 'db1', retentionPolicy: 'rp1'}
const dbrp2 = {database: 'db2', retentionPolicy: 'rp2'}
const namespaces = [dbrp1, dbrp2]
const setup = (override = {}) => {
const props = {
query,
source,
querySource: source,
onChooseNamespace: () => {},
...override,
}
DatabaseList.prototype.getDbRp = jest.fn(() => Promise.resolve())
const dbList = shallow(<DatabaseList {...props} />, {
context: {source},
})
return {
dbList,
props,
}
}
describe('Shared.Components.DatabaseList', () => {
describe('rendering', () => {
it('can display the <DatabaseList/>', () => {
const {dbList} = setup()
expect(dbList.exists()).toBe(true)
})
it('renders the correct number of <DatabaseListItems', () => {
const {dbList} = setup()
// replace with mock of getDbRp()?
dbList.setState({namespaces})
const list = dbList.find(DatabaseListItem)
expect(list.length).toBe(2)
})
})
describe('instance methods', () => {
describe('handleChooseNamespace', () => {
it('fires onChooseNamespace with a namspace arg', () => {
const onChooseNamespace = jest.fn()
const {dbList} = setup({onChooseNamespace})
const instance = dbList.instance() as DatabaseList
instance.handleChooseNamespace(dbrp1)()
expect(onChooseNamespace).toHaveBeenCalledTimes(1)
expect(onChooseNamespace).toHaveBeenCalledWith(dbrp1)
})
})
describe('isActive', () => {
describe('if the query does not match the db and rp', () => {
it('returns false', () => {
const {dbList} = setup()
const instance = dbList.instance() as DatabaseList
expect(instance.isActive(query, dbrp1)).toBe(false)
})
describe('if the query matches the db and rp', () => {
it('returns true', () => {
const {database, retentionPolicy} = dbrp1
const matchingQuery = {
...query,
database,
retentionPolicy,
}
const {dbList} = setup()
const instance = dbList.instance() as DatabaseList
expect(instance.isActive(matchingQuery, dbrp1)).toBe(true)
})
})
})
})
})
})

View File

@ -0,0 +1,58 @@
import React from 'react'
import DatabaseListItem from 'src/shared/components/DatabaseListItem'
import {shallow} from 'enzyme'
const namespace = {database: 'db1', retentionPolicy: 'rp1'}
const setup = (override = {}) => {
const props = {
isActive: false,
namespace,
onChooseNamespace: () => () => {},
...override,
}
const item = shallow(<DatabaseListItem {...props} />)
return {
item,
props,
}
}
describe('Shared.Components.DatabaseListItem', () => {
describe('rendering', () => {
it('renders the <DatabaseListItem />', () => {
const {item} = setup()
expect(item.exists()).toBe(true)
})
describe('if isActive is false', () => {
it('does not have the `.active` class', () => {
const {item} = setup()
expect(item.hasClass('active')).toBe(false)
})
it('does have the `.active` class', () => {
const {item} = setup({isActive: true})
expect(item.hasClass('active')).toBe(true)
})
})
})
describe('callbacks', () => {
describe('onChooseNamespace', () => {
it('calls onChooseNamespace with the items namespace when clicked', () => {
const onChooseNamespace = jest.fn()
const {item} = setup({onChooseNamespace})
item.simulate('click')
expect(onChooseNamespace).toHaveBeenCalledTimes(1)
expect(onChooseNamespace).toBeCalledWith(namespace)
})
})
})
})

View File

@ -1,6 +1,7 @@
import React from 'react'
import {Dropdown} from 'shared/components/Dropdown'
import DropdownMenu from 'shared/components/DropdownMenu'
import DropdownMenu, {DropdownMenuEmpty} from 'shared/components/DropdownMenu'
import DropdownHead from 'shared/components/DropdownHead'
import DropdownInput from 'shared/components/DropdownInput'
import {mount} from 'enzyme'
@ -38,13 +39,13 @@ const setup = (override = {}) => {
describe('Components.Shared.Dropdown', () => {
describe('rendering', () => {
describe('initial render', () => {
it('renders the dropdown menu button', () => {
it('renders the <Dropdown/> button', () => {
const {dropdown} = setup()
expect(dropdown.exists()).toBe(true)
})
it('does not show the list', () => {
it('does not show the <DropdownMenu/> list', () => {
const {dropdown} = setup({items})
const menu = dropdown.find(DropdownMenu)
@ -52,6 +53,43 @@ describe('Components.Shared.Dropdown', () => {
})
})
describe('the <DropdownHead />', () => {
const {dropdown} = setup()
const head = dropdown.find(DropdownHead)
expect(head.exists()).toBe(true)
})
describe('when there are no items in the dropdown', () => {
it('renders the <DropdownMenuEmpty/> component', () => {
const {dropdown} = setup()
const empty = dropdown.find(DropdownMenuEmpty)
expect(empty.exists()).toBe(true)
})
})
describe('the <DropdownInput/>', () => {
it('does not display the input by default', () => {
const {dropdown} = setup()
const input = dropdown.find(DropdownInput)
expect(input.exists()).toBe(false)
})
it('displays the input when provided useAutoCompelete is true', () => {
const {dropdown} = setup({items, useAutoComplete: true})
let input = dropdown.find(DropdownInput)
expect(input.exists()).toBe(false)
dropdown.simulate('click')
input = dropdown.find(DropdownInput)
expect(input.exists()).toBe(true)
})
})
describe('user interactions', () => {
describe('opening the <DropdownMenu/>', () => {
it('shows the menu when clicked', () => {
@ -77,22 +115,6 @@ describe('Components.Shared.Dropdown', () => {
expect(menu.exists()).toBe(false)
})
})
describe('the <DropdownInput/>', () => {
it('does not display the input by default', () => {
const {dropdown} = setup()
const input = dropdown.find(DropdownInput)
expect(input.exists()).toBe(false)
})
it('displays the input when provided useAutoCompelete is true', () => {
const {dropdown} = setup({items, useAutoComplete: true})
const input = dropdown.find(DropdownInput)
expect(input.exists()).toBe(false)
})
})
})
})
@ -202,5 +224,93 @@ describe('Components.Shared.Dropdown', () => {
expect(dropdown.state().highlightedItemIndex).toBe(highlightedItemIndex)
})
})
describe('handleFilterKeyPress', () => {
describe('when Enter is pressed and there are items', () => {
it('sets state of isOpen to false', () => {
const {dropdown} = setup({items})
dropdown.setState({isOpen: true})
expect(dropdown.state().isOpen).toBe(true)
dropdown.instance().handleFilterKeyPress({key: 'Enter'})
expect(dropdown.state().isOpen).toBe(false)
})
it('fires onChoose with the items at the highlighted index', () => {
const onChoose = jest.fn(item => item)
const highlightedItemIndex = 1
const {dropdown} = setup({items, onChoose})
dropdown.setState({highlightedItemIndex})
dropdown.instance().handleFilterKeyPress({key: 'Enter'})
expect(onChoose).toHaveBeenCalledTimes(1)
expect(onChoose.mock.calls[0][0]).toEqual(items[highlightedItemIndex])
})
})
describe('when Escape is pressed', () => {
it('sets isOpen state to false', () => {
const {dropdown} = setup({items})
dropdown.setState({isOpen: true})
expect(dropdown.state().isOpen).toBe(true)
dropdown.instance().handleFilterKeyPress({key: 'Escape'})
expect(dropdown.state().isOpen).toBe(false)
})
})
describe('when ArrowUp is pressed', () => {
it('decrements the highlightedItemIndex', () => {
const {dropdown} = setup({items})
dropdown.setState({highlightedItemIndex: 1})
dropdown.instance().handleFilterKeyPress({key: 'ArrowUp'})
expect(dropdown.state().highlightedItemIndex).toBe(0)
})
it('does not decrement highlightedItemIndex past 0', () => {
const {dropdown} = setup({items})
dropdown.setState({highlightedItemIndex: 0})
dropdown.instance().handleFilterKeyPress({key: 'ArrowUp'})
expect(dropdown.state().highlightedItemIndex).toBe(0)
})
})
describe('when ArrowDown is pressed', () => {
describe('if no highlight has been set', () => {
it('starts highlighted index at 0', () => {
const {dropdown} = setup({items})
expect(dropdown.state().highlightedItemIndex).toBe(null)
dropdown.instance().handleFilterKeyPress({key: 'ArrowDown'})
expect(dropdown.state().highlightedItemIndex).toBe(0)
})
})
describe('if highlightedItemIndex has been set', () => {
it('it increments the index', () => {
const {dropdown} = setup({items})
dropdown.setState({highlightedItemIndex: 0})
dropdown.instance().handleFilterKeyPress({key: 'ArrowDown'})
expect(dropdown.state().highlightedItemIndex).toBe(1)
})
describe('when highilghtedItemIndex is at the end of the list', () => {
it('does not exceed the list length', () => {
const {dropdown} = setup({items})
dropdown.setState({highlightedItemIndex: 1})
const expectedIndex = items.length - 1
dropdown.instance().handleFilterKeyPress({key: 'ArrowDown'})
expect(dropdown.state().highlightedItemIndex).toBe(expectedIndex)
})
})
})
})
})
})
})

View File

@ -0,0 +1,197 @@
import React from 'react'
import MeasurementList from 'src/shared/components/MeasurementList'
import MeasurementListItem from 'src/shared/components/MeasurementListItem'
import MeasurementListFilter from 'src/shared/components/MeasurementListFilter'
import {shallow} from 'enzyme'
import {query, source} from 'test/resources'
const setup = (override = {}) => {
const props = {
query,
querySource: source,
onChooseTag: () => {},
onGroupByTag: () => {},
onToggleTagAcceptance: () => {},
onChooseMeasurement: () => {},
...override,
}
MeasurementList.prototype.getMeasurements = jest.fn(() => Promise.resolve())
const wrapper = shallow(<MeasurementList {...props} />, {
context: {source},
})
const instance = wrapper.instance() as MeasurementList
return {
props,
wrapper,
instance,
}
}
describe('Shared.Components.MeasurementList', () => {
describe('rendering', () => {
it('renders to the page', () => {
const {wrapper} = setup()
expect(wrapper.exists()).toBe(true)
})
describe('<MeasurementListItem/>', () => {
it('renders <MeasurementListItem/>`s to the page', () => {
const {wrapper} = setup()
wrapper.setState({filtered: ['foo', 'bar']})
const items = wrapper.find(MeasurementListItem)
expect(items.length).toBe(2)
expect(items.first().dive().text()).toContain('foo')
expect(items.last().dive().text()).toContain('bar')
})
it('renders <MeasurementListFilter/> to the page', () => {
const {wrapper} = setup()
const filter = wrapper.find(MeasurementListFilter)
expect(filter.exists()).toBe(true)
})
})
})
describe('user interractions', () => {
it('can filter the measurement list', () => {
const {wrapper} = setup()
const measurements = ['foo', 'bar']
const event = {target: {value: 'f'}, stopPropagation: () => {}}
wrapper.setState({filtered: measurements, measurements})
const filter = wrapper.find(MeasurementListFilter)
filter.dive().find('input').simulate('change', event)
wrapper.update()
const items = wrapper.find(MeasurementListItem)
expect(items.length).toBe(1)
expect(items.dive().text()).toBe('foo')
})
})
describe('lifecycle methods', () => {
describe('componentDidMount', () => {
it('does not fire getMeasurements if there is no database', () => {
const getMeasurements = jest.fn()
const {instance} = setup({query: {...query, database: ''}})
instance.getMeasurements = getMeasurements
instance.componentDidMount()
expect(getMeasurements).not.toHaveBeenCalled()
})
it('does fire getMeasurements if there is a database', () => {
const getMeasurements = jest.fn()
const {instance} = setup()
instance.getMeasurements = getMeasurements
instance.componentDidMount()
expect(getMeasurements).toHaveBeenCalled()
})
})
describe('componentDidUpdate', () => {
it('does not fire getMeasurements if there is no database', () => {
const getMeasurements = jest.fn()
const {instance, props} = setup({query: {...query, database: ''}})
instance.getMeasurements = getMeasurements
instance.componentDidUpdate(props)
expect(getMeasurements).not.toHaveBeenCalled()
})
it('does not fire getMeasurements if the database does not change and the sources are equal', () => {
const getMeasurements = jest.fn()
const {instance, props} = setup()
instance.getMeasurements = getMeasurements
instance.componentDidUpdate(props)
expect(getMeasurements).not.toHaveBeenCalled()
})
it('it fires getMeasurements if there is a change in database', () => {
const getMeasurements = jest.fn()
const {instance, props} = setup()
instance.getMeasurements = getMeasurements
instance.componentDidUpdate({
...props,
query: {...query, database: 'diffDb'},
})
expect(getMeasurements).toHaveBeenCalled()
})
it('it calls getMeasurements if there is a change in source', () => {
const getMeasurements = jest.fn()
const {instance, props} = setup()
instance.getMeasurements = getMeasurements
instance.componentDidUpdate({
...props,
querySource: {...source, id: 'newSource'},
})
expect(getMeasurements).toHaveBeenCalled()
})
})
})
describe('instance methods', () => {
describe('handleFilterText', () => {
it('sets the filterText state to the event targets value', () => {
const {instance} = setup()
const value = 'spectacs'
const stopPropagation = () => {}
const event = {target: {value}, stopPropagation}
instance.handleFilterText(event)
expect(instance.state.filterText).toBe(value)
})
})
describe('handleEscape', () => {
it('resets fiterText when escape is pressed', () => {
const {instance} = setup()
const key = 'Escape'
const stopPropagation = () => {}
const event = {key, stopPropagation}
instance.setState({filterText: 'foo'})
expect(instance.state.filterText).toBe('foo')
instance.handleEscape(event)
expect(instance.state.filterText).toBe('')
})
it('does not reset fiterText when escape is pressed', () => {
const {instance} = setup()
const key = 'Enter'
const stopPropagation = () => {}
const event = {key, stopPropagation}
instance.setState({filterText: 'foo'})
instance.handleEscape(event)
expect(instance.state.filterText).toBe('foo')
})
})
describe('handleAcceptReject', () => {
it('it calls onToggleTagAcceptance', () => {
const onToggleTagAcceptance = jest.fn()
const {instance} = setup({onToggleTagAcceptance})
instance.handleAcceptReject()
expect(onToggleTagAcceptance).toHaveBeenCalledTimes(1)
})
})
})
})

View File

@ -0,0 +1,170 @@
import React from 'react'
import TagList from 'src/shared/components/TagList'
import TagListItem from 'src/shared/components/TagListItem'
import {shallow} from 'enzyme'
import {query, source} from 'test/resources'
const setup = (override = {}) => {
const props = {
query,
querySource: source,
onChooseTag: () => {},
onGroupByTag: () => {},
...override,
}
TagList.prototype.getTags = jest.fn(() => Promise.resolve)
const wrapper = shallow(<TagList {...props} />, {context: {source}})
const instance = wrapper.instance() as TagList
return {
instance,
wrapper,
props,
}
}
describe('Shared.Components.TagList', () => {
describe('lifecycle methods', () => {
describe('componentDidMount', () => {
it('gets the tags', () => {
const getTags = jest.fn()
const {instance} = setup()
instance.getTags = getTags
instance.componentDidMount()
expect(getTags).toHaveBeenCalledTimes(1)
})
describe('if there is no database', () => {
it('does not get the tags', () => {
const getTags = jest.fn()
const {instance} = setup({query: {...query, database: ''}})
instance.getTags = getTags
instance.componentDidMount()
expect(getTags).not.toHaveBeenCalled()
})
})
describe('if there is no measurement', () => {
it('does not get the tags', () => {
const getTags = jest.fn()
const {instance} = setup({query: {...query, measurement: ''}})
instance.getTags = getTags
instance.componentDidMount()
expect(getTags).not.toHaveBeenCalled()
})
})
describe('if there is no retention policy', () => {
it('does not get the tags', () => {
const getTags = jest.fn()
const {instance} = setup({query: {...query, retentionPolicy: ''}})
instance.getTags = getTags
instance.componentDidMount()
expect(getTags).not.toHaveBeenCalled()
})
})
})
describe('componentDidUpdate', () => {
describe('if the db, rp, measurement, and source change and exist', () => {
it('gets the tags', () => {
const getTags = jest.fn()
const updates = {
database: 'newDb',
retentionPolicy: 'newRp',
measurement: 'newMeasurement',
}
const prevQuery = {...query, ...updates}
const prevSource = {...source, id: 'prevID'}
const {instance} = setup()
instance.getTags = getTags
instance.componentDidUpdate({
query: prevQuery,
querySource: prevSource,
})
expect(getTags).toHaveBeenCalledTimes(1)
})
describe('if there is no database', () => {
it('does not get the tags', () => {
const getTags = jest.fn()
const {instance} = setup({query: {...query, database: ''}})
instance.getTags = getTags
instance.componentDidMount()
expect(getTags).not.toHaveBeenCalled()
})
})
describe('if there is no retentionPolicy', () => {
it('does not get the tags', () => {
const getTags = jest.fn()
const {instance} = setup({query: {...query, retentionPolicy: ''}})
instance.getTags = getTags
instance.componentDidMount()
expect(getTags).not.toHaveBeenCalled()
})
})
describe('if there is no measurement', () => {
it('does not get the tags', () => {
const getTags = jest.fn()
const {instance} = setup({query: {...query, measurement: ''}})
instance.getTags = getTags
instance.componentDidMount()
expect(getTags).not.toHaveBeenCalled()
})
})
})
})
})
describe('rendering', () => {
it('renders', () => {
const {wrapper} = setup()
expect(wrapper.exists()).toBe(true)
})
describe('when there are no tags', () => {
it('does not render the <TagListItem/>', () => {
const {wrapper} = setup()
const tagListItems = wrapper.find(TagListItem)
expect(tagListItems.length).toBe(0)
})
})
describe('when there are tags', () => {
it('does renders the <TagListItem/>', () => {
const {wrapper} = setup()
const values = ['tv1', 'tv2']
const tags = {
tk1: values,
tk2: values,
}
wrapper.setState({tags})
const tagListItems = wrapper.find(TagListItem)
const first = tagListItems.first()
const last = tagListItems.last()
expect(tagListItems.length).toBe(2)
expect(first.props().tagKey).toBe('tk1')
expect(first.props().tagValues).toBe(values)
expect(last.props().tagKey).toBe('tk2')
expect(last.props().tagValues).toBe(values)
})
})
})
})

View File

@ -1,6 +1,6 @@
{
"compilerOptions": {
"types": ["node", "mocha", "chai", "lodash"],
"types": ["node", "chai", "lodash", "enzyme", "react", "prop-types", "jest"],
"target": "es6",
"module": "es2015",
"moduleResolution": "node",

View File

@ -17,15 +17,10 @@ const babelLoader = {
loader: 'babel-loader',
options: {
cacheDirectory: true,
presets: ['env', 'react', 'stage-0'],
presets: [['env', {modules: false}], 'react', 'stage-0'],
},
}
const log = function(x) {
console.log('IM LOGGIN HERE: ', x)
return x
}
module.exports = {
node: {
fs: 'empty',
@ -96,6 +91,7 @@ module.exports = {
include: path.resolve(__dirname, '..', 'src'),
exclude: /node_modules/,
use: [
{loader: 'thread-loader'},
{
loader: 'babel-loader',
options: {

View File

@ -5,7 +5,6 @@ const ExtractTextPlugin = require('extract-text-webpack-plugin')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const UglifyJsPlugin = require('uglifyjs-webpack-plugin')
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin')
const CompressionPlugin = require('compression-webpack-plugin')
const package = require('../package.json')
const dependencies = package.dependencies
@ -14,7 +13,7 @@ const babelLoader = {
loader: 'babel-loader',
options: {
cacheDirectory: false,
presets: ['env', 'react', 'stage-0'],
presets: [['env', {modules: false}], 'react', 'stage-0'],
},
}
@ -149,13 +148,6 @@ const config = {
new webpack.optimize.CommonsChunkPlugin({
name: 'manifest',
}),
new CompressionPlugin({
asset: '[path].gz[query]',
algorithm: 'gzip',
test: /\.js$|\.css$/,
threshold: 0,
minRatio: 0.8,
}),
new HtmlWebpackPlugin({
template: path.resolve(__dirname, '..', 'src', 'index.template.html'),
inject: 'body',

File diff suppressed because it is too large Load Diff