chore(master): Merge branch 'master' into sgc/tsm1

pull/19446/head
Brett Buddin 2020-08-27 14:43:50 -04:00
commit 09b0258ab4
No known key found for this signature in database
GPG Key ID: C51265E441C4C5AC
99 changed files with 6208 additions and 226 deletions

View File

@ -205,10 +205,9 @@ jobs:
- attach_workspace:
at: ~/project
- run: docker login -u=$QUAY_USER -p=$QUAY_PASS quay.io
- run: docker run --net host -v /var/run/docker.sock:/var/run/docker.sock -v ~/project:/grace/grace-results quay.io/influxdb/grace:latest
- run: docker run --net host -v /var/run/docker.sock:/var/run/docker.sock -e TEST_RESULTS=~/project quay.io/influxdb/grace:latest
- store_artifacts:
path: ~/project
destination: grace-nightly-output
- store_test_results:
path: ~/project
jstest:

View File

@ -6,6 +6,8 @@
1. [19334](https://github.com/influxdata/influxdb/pull/19334): Add --active-config flag to influx to set config for single command
1. [19219](https://github.com/influxdata/influxdb/pull/19219): List buckets via the API now supports after (ID) parameter as an alternative to offset.
1. [19390](https://github.com/influxdata/influxdb/pull/19390): Record last success and failure run times in the Task
1. [19402](https://github.com/influxdata/influxdb/pull/19402): Inject Task's LatestSuccess Timestamp In Flux Extern
1. [19433](https://github.com/influxdata/influxdb/pull/19433): Add option to dump raw query results in CLI
### Bug Fixes

View File

@ -23,6 +23,7 @@ import (
var queryFlags struct {
org organization
file string
raw bool
}
func cmdQuery(f *globalFlags, opts genericCLIOpts) *cobra.Command {
@ -34,6 +35,7 @@ func cmdQuery(f *globalFlags, opts genericCLIOpts) *cobra.Command {
f.registerFlags(cmd)
queryFlags.org.register(cmd, true)
cmd.Flags().StringVarP(&queryFlags.file, "file", "f", "", "Path to Flux query file")
cmd.Flags().BoolVarP(&queryFlags.raw, "raw", "r", false, "Display raw query results")
return cmd
}
@ -123,6 +125,11 @@ func fluxQueryF(cmd *cobra.Command, args []string) error {
return err
}
if queryFlags.raw {
io.Copy(os.Stdout, resp.Body)
return nil
}
dec := csv.NewMultiResultDecoder(csv.ResultDecoderConfig{})
results, err := dec.Decode(resp.Body)
if err != nil {
@ -141,6 +148,8 @@ func fluxQueryF(cmd *cobra.Command, args []string) error {
return err
}
}
// It is safe and appropriate to call Release multiple times and must be
// called before checking the error on the next line.
results.Release()
return results.Err()
}

View File

@ -1114,16 +1114,60 @@ func (p *Template) graphTasks() *parseErr {
}
prefix := fmt.Sprintf("tasks[%s].spec", t.MetaName())
t.query, _ = p.parseQuery(prefix, o.Spec.stringShort(fieldQuery), o.Spec.slcResource(fieldParams))
params := o.Spec.slcResource(fieldParams)
task := o.Spec.slcResource("task")
failures := p.parseNestedLabels(o.Spec, func(l *label) error {
var (
err error
failures []validationErr
)
t.query, err = p.parseQuery(prefix, o.Spec.stringShort(fieldQuery), params, task)
if err != nil {
failures = append(failures, validationErr{
Field: fieldQuery,
Msg: err.Error(),
})
}
if o.APIVersion == APIVersion2 {
for _, ref := range t.query.task {
switch ref.EnvRef {
case prefix + ".task.name", prefix + ".params.name":
t.displayName = ref
case prefix + ".task.every":
every, ok := ref.defaultVal.(time.Duration)
if ok {
t.every = every
} else {
failures = append(failures, validationErr{
Field: fieldTask,
Msg: "field every is not duration",
})
}
case prefix + ".task.offset":
offset, ok := ref.defaultVal.(time.Duration)
if ok {
t.offset = offset
} else {
failures = append(failures, validationErr{
Field: fieldTask,
Msg: "field every is not duration",
})
}
}
}
}
failures = append(failures, p.parseNestedLabels(o.Spec, func(l *label) error {
t.labels = append(t.labels, l)
p.mLabels[l.MetaName()].setMapping(t, false)
return nil
})
})...)
sort.Sort(t.labels)
p.mTasks[t.MetaName()] = t
p.setRefs(t.refs()...)
return append(failures, t.valid()...)
})
@ -1218,14 +1262,14 @@ func (p *Template) eachResource(resourceKind Kind, fn func(o Object) []validatio
continue
}
if k.APIVersion != APIVersion {
if k.APIVersion != APIVersion && k.APIVersion != APIVersion2 {
pErr.append(resourceErr{
Kind: k.Kind.String(),
Idx: intPtr(i),
ValidationErrs: []validationErr{
{
Field: fieldAPIVersion,
Msg: fmt.Sprintf("invalid API version provided %q; must be 1 in [%s]", k.APIVersion, APIVersion),
Msg: fmt.Sprintf("invalid API version provided %q; must be 1 in [%s, %s]", k.APIVersion, APIVersion, APIVersion2),
},
},
})
@ -1533,7 +1577,7 @@ func (p *Template) parseChartQueries(dashMetaName string, chartIdx int, resource
continue
}
prefix := fmt.Sprintf("dashboards[%s].spec.charts[%d].queries[%d]", dashMetaName, chartIdx, i)
qq, err := p.parseQuery(prefix, source, rq.slcResource(fieldParams))
qq, err := p.parseQuery(prefix, source, rq.slcResource(fieldParams), nil)
if err != nil {
vErrs = append(vErrs, validationErr{
Field: "query",
@ -1546,7 +1590,7 @@ func (p *Template) parseChartQueries(dashMetaName string, chartIdx int, resource
return q, vErrs
}
func (p *Template) parseQuery(prefix, source string, params []Resource) (query, error) {
func (p *Template) parseQuery(prefix, source string, params, task []Resource) (query, error) {
files := parser.ParseSource(source).Files
if len(files) != 1 {
return query{}, influxErr(influxdb.EInvalid, "invalid query source")
@ -1556,29 +1600,52 @@ func (p *Template) parseQuery(prefix, source string, params []Resource) (query,
Query: strings.TrimSpace(source),
}
opt, err := edit.GetOption(files[0], "params")
if err != nil {
return q, nil
}
obj, ok := opt.(*ast.ObjectExpression)
if !ok {
return q, nil
}
mParams := make(map[string]*references)
for _, p := range obj.Properties {
sl, ok := p.Key.(*ast.Identifier)
if !ok {
continue
}
tParams := make(map[string]*references)
mParams[sl.Name] = &references{
EnvRef: sl.Name,
defaultVal: valFromExpr(p.Value),
valType: p.Value.Type(),
paramsOpt, paramsErr := edit.GetOption(files[0], "params")
taskOpt, taskErr := edit.GetOption(files[0], "task")
if paramsErr != nil && taskErr != nil {
return q, nil
}
if paramsErr == nil {
obj, ok := paramsOpt.(*ast.ObjectExpression)
if ok {
for _, p := range obj.Properties {
sl, ok := p.Key.(*ast.Identifier)
if !ok {
continue
}
mParams[sl.Name] = &references{
EnvRef: sl.Name,
defaultVal: valFromExpr(p.Value),
valType: p.Value.Type(),
}
}
}
}
if taskErr == nil {
tobj, ok := taskOpt.(*ast.ObjectExpression)
if ok {
for _, p := range tobj.Properties {
sl, ok := p.Key.(*ast.Identifier)
if !ok {
continue
}
tParams[sl.Name] = &references{
EnvRef: sl.Name,
defaultVal: valFromExpr(p.Value),
valType: p.Value.Type(),
}
}
}
}
// override defaults here maybe?
for _, pr := range params {
field := pr.stringShort(fieldKey)
if field == "" {
@ -1588,7 +1655,6 @@ func (p *Template) parseQuery(prefix, source string, params []Resource) (query,
if _, ok := mParams[field]; !ok {
mParams[field] = &references{EnvRef: field}
}
if def, ok := pr[fieldDefault]; ok {
mParams[field].defaultVal = def
}
@ -1597,6 +1663,39 @@ func (p *Template) parseQuery(prefix, source string, params []Resource) (query,
}
}
var err error
for _, pr := range task {
field := pr.stringShort(fieldKey)
if field == "" {
continue
}
if _, ok := tParams[field]; !ok {
tParams[field] = &references{EnvRef: field}
}
if valtype, ok := pr.string(fieldType); ok {
tParams[field].valType = valtype
}
if def, ok := pr[fieldDefault]; ok {
switch tParams[field].valType {
case "duration":
switch defDur := def.(type) {
case string:
tParams[field].defaultVal, err = time.ParseDuration(defDur)
if err != nil {
return query{}, influxErr(influxdb.EInvalid, err.Error())
}
case time.Duration:
tParams[field].defaultVal = defDur
}
default:
tParams[field].defaultVal = def
}
}
}
for _, ref := range mParams {
envRef := fmt.Sprintf("%s.params.%s", prefix, ref.EnvRef)
q.params = append(q.params, &references{
@ -1606,6 +1705,16 @@ func (p *Template) parseQuery(prefix, source string, params []Resource) (query,
valType: ref.valType,
})
}
for _, ref := range tParams {
envRef := fmt.Sprintf("%s.task.%s", prefix, ref.EnvRef)
q.task = append(q.task, &references{
EnvRef: envRef,
defaultVal: ref.defaultVal,
val: p.mEnvVals[envRef],
valType: ref.valType,
})
}
return q, nil
}

View File

@ -1045,10 +1045,11 @@ func (c colors) valid() []validationErr {
type query struct {
Query string `json:"query" yaml:"query"`
params []*references
task []*references
}
func (q query) DashboardQuery() string {
if len(q.params) == 0 {
if len(q.params) == 0 && len(q.task) == 0 {
return q.Query
}
@ -1057,25 +1058,37 @@ func (q query) DashboardQuery() string {
return q.Query
}
opt, err := edit.GetOption(files[0], "params")
if err != nil {
// no params option present in query
paramsOpt, paramsErr := edit.GetOption(files[0], "params")
taskOpt, taskErr := edit.GetOption(files[0], "task")
if taskErr != nil && paramsErr != nil {
return q.Query
}
obj, ok := opt.(*ast.ObjectExpression)
if !ok {
// params option present is invalid. Should always be an Object.
return q.Query
if paramsErr == nil {
obj, ok := paramsOpt.(*ast.ObjectExpression)
if ok {
for _, ref := range q.params {
parts := strings.Split(ref.EnvRef, ".")
key := parts[len(parts)-1]
edit.SetProperty(obj, key, ref.expression())
}
edit.SetOption(files[0], "params", obj)
}
}
for _, ref := range q.params {
parts := strings.Split(ref.EnvRef, ".")
key := parts[len(parts)-1]
edit.SetProperty(obj, key, ref.expression())
}
if taskErr == nil {
tobj, ok := taskOpt.(*ast.ObjectExpression)
if ok {
for _, ref := range q.task {
parts := strings.Split(ref.EnvRef, ".")
key := parts[len(parts)-1]
edit.SetProperty(tobj, key, ref.expression())
}
edit.SetOption(files[0], "params", obj)
edit.SetOption(files[0], "task", tobj)
}
}
return ast.Format(files[0])
}
@ -1806,6 +1819,7 @@ func toSummaryTagRules(tagRules []struct{ k, v, op string }) []SummaryTagRule {
const (
fieldTaskCron = "cron"
fieldTask = "task"
)
type task struct {
@ -1858,9 +1872,15 @@ func (t *task) summarize() SummaryTask {
field := fmt.Sprintf("spec.params.%s", parts[len(parts)-1])
refs = append(refs, convertRefToRefSummary(field, ref))
}
for _, ref := range t.query.task {
parts := strings.Split(ref.EnvRef, ".")
field := fmt.Sprintf("spec.task.%s", parts[len(parts)-1])
refs = append(refs, convertRefToRefSummary(field, ref))
}
sort.Slice(refs, func(i, j int) bool {
return refs[i].EnvRefKey < refs[j].EnvRefKey
})
return SummaryTask{
SummaryIdentifier: SummaryIdentifier{
Kind: KindTask,
@ -1884,6 +1904,7 @@ func (t *task) valid() []validationErr {
if err, ok := isValidName(t.Name(), 1); !ok {
vErrs = append(vErrs, err)
}
if t.cron == "" && t.every == 0 {
vErrs = append(vErrs,
validationErr{
@ -2170,7 +2191,7 @@ const (
)
type references struct {
EnvRef string
EnvRef string // key used to reference parameterized field
Secret string
val interface{}
@ -2296,7 +2317,11 @@ func astBoolFromIface(v interface{}) *ast.BooleanLiteral {
func astDurationFromIface(v interface{}) *ast.DurationLiteral {
s, ok := v.(string)
if !ok {
return nil
d, ok := v.(time.Duration)
if !ok {
return nil
}
s = d.String()
}
dur, _ := parser.ParseSignedDuration(s)
return dur

View File

@ -2403,6 +2403,7 @@ spec:
require.Len(t, props.Queries, 1)
// parmas
queryText := `option params = {
bucket: "bar",
start: -24h0m0s,
@ -3467,6 +3468,121 @@ from(bucket: params.bucket)
})
})
t.Run("with task option should be valid", func(t *testing.T) {
testfileRunner(t, "testdata/task_v2.yml", func(t *testing.T, template *Template) {
actual := template.Summary().Tasks
require.Len(t, actual, 1)
expectedEnvRefs := []SummaryReference{
{
Field: "spec.task.every",
EnvRefKey: "tasks[task-1].spec.task.every",
ValType: "duration",
DefaultValue: time.Minute,
},
{
Field: "spec.name",
EnvRefKey: "tasks[task-1].spec.task.name",
ValType: "string",
DefaultValue: "bar",
},
{
Field: "spec.task.name",
EnvRefKey: "tasks[task-1].spec.task.name",
ValType: "string",
DefaultValue: "bar",
},
{
Field: "spec.task.offset",
EnvRefKey: "tasks[task-1].spec.task.offset",
ValType: "duration",
DefaultValue: time.Minute * 3,
},
}
assert.Equal(t, expectedEnvRefs, actual[0].EnvReferences)
})
})
t.Run("with task spec should be valid", func(t *testing.T) {
testfileRunner(t, "testdata/task_v2_taskSpec.yml", func(t *testing.T, template *Template) {
actual := template.Summary().Tasks
require.Len(t, actual, 1)
expectedEnvRefs := []SummaryReference{
{
Field: "spec.task.every",
EnvRefKey: "tasks[task-1].spec.task.every",
ValType: "duration",
DefaultValue: time.Minute,
},
{
Field: "spec.name",
EnvRefKey: "tasks[task-1].spec.task.name",
ValType: "string",
DefaultValue: "foo",
},
{
Field: "spec.task.name",
EnvRefKey: "tasks[task-1].spec.task.name",
ValType: "string",
DefaultValue: "foo",
},
{
Field: "spec.task.offset",
EnvRefKey: "tasks[task-1].spec.task.offset",
ValType: "duration",
DefaultValue: time.Minute,
},
}
queryText := `option task = {name: "foo", every: 1m0s, offset: 1m0s}
from(bucket: "rucket_1")
|> range(start: -5d, stop: -1h)
|> filter(fn: (r) =>
(r._measurement == "cpu"))
|> filter(fn: (r) =>
(r._field == "usage_idle"))
|> aggregateWindow(every: 1m, fn: mean)
|> yield(name: "mean")`
assert.Equal(t, queryText, actual[0].Query)
assert.Equal(t, expectedEnvRefs, actual[0].EnvReferences)
})
})
t.Run("with params option should be valid", func(t *testing.T) {
testfileRunner(t, "testdata/task_v2_params.yml", func(t *testing.T, template *Template) {
actual := template.Summary().Tasks
require.Len(t, actual, 1)
expectedEnvRefs := []SummaryReference{
{
Field: "spec.params.this",
EnvRefKey: "tasks[task-1].spec.params.this",
ValType: "string",
DefaultValue: "foo",
},
}
queryText := `option params = {this: "foo"}
from(bucket: "rucket_1")
|> range(start: -5d, stop: -1h)
|> filter(fn: (r) =>
(r._measurement == params.this))
|> filter(fn: (r) =>
(r._field == "usage_idle"))
|> aggregateWindow(every: 1m, fn: mean)
|> yield(name: "mean")`
assert.Equal(t, queryText, actual[0].Query)
assert.Equal(t, expectedEnvRefs, actual[0].EnvReferences)
})
})
t.Run("with env refs should be valid", func(t *testing.T) {
testfileRunner(t, "testdata/task_ref.yml", func(t *testing.T, template *Template) {
actual := template.Summary().Tasks
@ -4662,7 +4778,7 @@ func newParsedTemplate(t *testing.T, fn ReaderFn, encoding Encoding, opts ...Val
require.NoError(t, err)
for _, k := range template.Objects {
require.Equal(t, APIVersion, k.APIVersion)
require.Contains(t, k.APIVersion, "influxdata.com/v2alpha")
}
require.True(t, template.isParsed)

View File

@ -24,6 +24,7 @@ import (
// APIVersion marks the current APIVersion for influx packages.
const APIVersion = "influxdata.com/v2alpha1"
const APIVersion2 = "influxdata.com/v2alpha2"
// Stack is an identifier for stateful application of a package(s). This stack
// will map created resources from the template(s) to existing resources on the

14
pkger/testdata/task_v2.yml vendored Normal file
View File

@ -0,0 +1,14 @@
apiVersion: influxdata.com/v2alpha2
kind: Task
metadata:
name: task-1
spec:
description: desc_1
query: >
option task = { name: "bar", every: 1m, offset: 3m }
from(bucket: "rucket_1")
|> range(start: -5d, stop: -1h)
|> filter(fn: (r) => r._measurement == "cpu")
|> filter(fn: (r) => r._field == "usage_idle")
|> aggregateWindow(every: 1m, fn: mean)
|> yield(name: "mean")

15
pkger/testdata/task_v2_params.yml vendored Normal file
View File

@ -0,0 +1,15 @@
apiVersion: influxdata.com/v2alpha2
kind: Task
metadata:
name: task-1
spec:
description: desc_1
every: 1m
query: >
option params = { this: "foo" }
from(bucket: "rucket_1")
|> range(start: -5d, stop: -1h)
|> filter(fn: (r) => r._measurement == params.this)
|> filter(fn: (r) => r._field == "usage_idle")
|> aggregateWindow(every: 1m, fn: mean)
|> yield(name: "mean")

24
pkger/testdata/task_v2_taskSpec.yml vendored Normal file
View File

@ -0,0 +1,24 @@
apiVersion: influxdata.com/v2alpha2
kind: Task
metadata:
name: task-1
spec:
task:
- key: name
default: "foo"
type: string
- key: every
default: 1m0s
type: duration
- key: offset
default: 1m0s
type: duration
description: desc_1
query: >
option task = { name: "bar", every: 1m, offset: 3m }
from(bucket: "rucket_1")
|> range(start: -5d, stop: -1h)
|> filter(fn: (r) => r._measurement == "cpu")
|> filter(fn: (r) => r._field == "usage_idle")
|> aggregateWindow(every: 1m, fn: mean)
|> yield(name: "mean")

View File

@ -70,6 +70,10 @@ describe('Community Templates', () => {
//and check that 0 resources pluralization is correct
cy.getByTestID('template-install-title').should('contain', 'resources')
//check that valid read me appears
cy.getByTestID('community-templates-readme-tab').click()
cy.get('.markdown-format').should('contain', 'Downsampling Template')
})
describe('Opening the install overlay', () => {

View File

@ -28,7 +28,7 @@ describe('Dashboards', () => {
"Looks like you don't have any Dashboards, why not create one?"
)
})
cy.getByTestID('add-resource-button').should($b => {
cy.getByTestID('add-resource-dropdown--button').should($b => {
expect($b).to.have.length(1)
expect($b).to.contain('Create Dashboard')
})
@ -39,16 +39,18 @@ describe('Dashboards', () => {
it('can CRUD dashboards from empty state, header, and a Template', () => {
// Create from empty state
cy.getByTestID('empty-dashboards-list').within(() => {
cy.getByTestID('add-resource-button')
.click()
.then(() => {
cy.fixture('routes').then(({orgs}) => {
cy.get('@org').then(({id}: Organization) => {
cy.visit(`${orgs}/${id}/dashboards-list`)
})
cy.getByTestID('add-resource-dropdown--button').click()
})
cy.getByTestID('add-resource-dropdown--new')
.click()
.then(() => {
cy.fixture('routes').then(({orgs}) => {
cy.get('@org').then(({id}: Organization) => {
cy.visit(`${orgs}/${id}/dashboards-list`)
})
})
})
})
const newName = 'new 🅱ashboard'
@ -68,8 +70,14 @@ describe('Dashboards', () => {
cy.getByTestID('dashboard-card').should('contain', newName)
// Open Export overlay
cy.getByTestID('context-menu-item-export').click({force: true})
cy.getByTestID('export-overlay--text-area').should('exist')
cy.get('.cf-overlay--dismiss').click()
// Create from header
cy.getByTestID('add-resource-button').click()
cy.getByTestID('add-resource-dropdown--button').click()
cy.getByTestID('add-resource-dropdown--new').click()
cy.fixture('routes').then(({orgs}) => {
cy.get('@org').then(({id}: Organization) => {
@ -77,6 +85,21 @@ describe('Dashboards', () => {
})
})
// Create from Template
cy.get('@org').then(({id}: Organization) => {
cy.createDashboardTemplate(id)
})
cy.getByTestID('empty-dashboards-list').within(() => {
cy.getByTestID('add-resource-dropdown--button').click()
cy.getByTestID('add-resource-dropdown--template').click()
})
cy.getByTestID('template--Bashboard-Template').click()
cy.getByTestID('template-panel').should('exist')
cy.getByTestID('create-dashboard-button').click()
cy.getByTestID('dashboard-card').should('have.length', 3)
// Delete dashboards
cy.getByTestID('dashboard-card')
.first()
@ -94,9 +117,42 @@ describe('Dashboards', () => {
cy.getByTestID('context-delete-dashboard').click()
})
cy.getByTestID('dashboard-card')
.first()
.trigger('mouseover')
.within(() => {
cy.getByTestID('context-delete-menu').click()
cy.getByTestID('context-delete-dashboard').click()
})
cy.getByTestID('empty-dashboards-list').should('exist')
})
it('keeps user input in text area when attempting to import invalid JSON', () => {
cy.getByTestID('page-control-bar').within(() => {
cy.getByTestID('add-resource-dropdown--button').click()
})
cy.getByTestID('add-resource-dropdown--import').click()
cy.contains('Paste').click()
cy.getByTestID('import-overlay--textarea')
.click()
.type('this is invalid JSON')
cy.get('button[title*="Import JSON"]').click()
cy.getByTestID('import-overlay--textarea--error').should('have.length', 1)
cy.getByTestID('import-overlay--textarea').should($s =>
expect($s).to.contain('this is invalid JSON')
)
cy.getByTestID('import-overlay--textarea').type(
'{backspace}{backspace}{backspace}{backspace}{backspace}'
)
cy.get('button[title*="Import JSON"]').click()
cy.getByTestID('import-overlay--textarea--error').should('have.length', 1)
cy.getByTestID('import-overlay--textarea').should($s =>
expect($s).to.contain('this is invalid')
)
})
describe('Dashboard List', () => {
beforeEach(() => {
cy.get('@org').then(({id}: Organization) => {

View File

@ -0,0 +1,26 @@
describe('Flows', () => {
beforeEach(() => {
cy.flush()
cy.signin().then(({body}) => {
const {
org: {id},
bucket,
} = body
cy.wrap(body.org).as('org')
cy.wrap(bucket).as('bucket')
cy.fixture('routes').then(({orgs, flows}) => {
cy.visit(`${orgs}/${id}${flows}`)
})
})
})
// TODO: unskip when no longer blocked by feature flag
it.skip('CRUD a flow from the index page', () => {
cy.getByTestID('create-flow--button')
.first()
.click()
cy.getByTestID('page-title').click()
cy.getByTestID('renamable-page-title--input').type('My Flow {enter}')
})
})

View File

@ -60,7 +60,19 @@ from(bucket: "${name}"{rightarrow}
.should('have.length', 1)
.and('contain', taskName)
cy.getByTestID('add-resource-button').click()
// TODO: extend to create from template overlay
cy.getByTestID('add-resource-dropdown--button').click()
cy.getByTestID('add-resource-dropdown--template').click()
cy.getByTestID('task-import-template--overlay').within(() => {
cy.get('.cf-overlay--dismiss').click()
})
// TODO: extend to create a template from JSON
cy.getByTestID('add-resource-dropdown--button').click()
cy.getByTestID('add-resource-dropdown--import').click()
cy.getByTestID('task-import--overlay').within(() => {
cy.get('.cf-overlay--dismiss').click()
})
})
// this test is broken due to a failure on the post route
it.skip('can create a task using http.post', () => {
@ -80,6 +92,31 @@ http.post(
.and('contain', taskName)
})
it('keeps user input in text area when attempting to import invalid JSON', () => {
cy.getByTestID('page-control-bar').within(() => {
cy.getByTestID('add-resource-dropdown--button').click()
})
cy.getByTestID('add-resource-dropdown--import').click()
cy.contains('Paste').click()
cy.getByTestID('import-overlay--textarea')
.click()
.type('this is invalid JSON')
cy.get('button[title*="Import JSON"]').click()
cy.getByTestID('import-overlay--textarea--error').should('have.length', 1)
cy.getByTestID('import-overlay--textarea').should($s =>
expect($s).to.contain('this is invalid JSON')
)
cy.getByTestID('import-overlay--textarea').type(
'{backspace}{backspace}{backspace}{backspace}{backspace}'
)
cy.get('button[title*="Import JSON"]').click()
cy.getByTestID('import-overlay--textarea--error').should('have.length', 1)
cy.getByTestID('import-overlay--textarea').should($s =>
expect($s).to.contain('this is invalid')
)
})
describe('When tasks already exist', () => {
beforeEach(() => {
cy.get('@org').then(({id}: Organization) => {
@ -358,9 +395,11 @@ function createFirstTask(
offset: string = '20m'
) {
cy.getByTestID('empty-tasks-list').within(() => {
cy.getByTestID('add-resource-button').click()
cy.getByTestID('add-resource-dropdown--button').click()
})
cy.getByTestID('add-resource-dropdown--new').click()
cy.get<Bucket>('@bucket').then(bucket => {
cy.getByTestID('flux-editor').within(() => {
cy.get('textarea.inputarea')

View File

@ -32,7 +32,9 @@ describe('Variables', () => {
'windowPeriod'
)
cy.getByTestID('add-resource-button').click()
cy.getByTestID('add-resource-dropdown--button').click()
cy.getByTestID('add-resource-dropdown--new').click()
cy.getByTestID('variable-type-dropdown--button').click()
cy.getByTestID('variable-type-dropdown-constant').click()
@ -143,7 +145,9 @@ describe('Variables', () => {
)
// Create a Map variable from scratch
cy.getByTestID('add-resource-button').click()
cy.getByTestID('add-resource-dropdown--button').click()
cy.getByTestID('add-resource-dropdown--new').click()
cy.getByTestID('variable-type-dropdown--button').click()
cy.getByTestID('variable-type-dropdown-map').click()
@ -167,7 +171,9 @@ describe('Variables', () => {
cy.getByTestID(`variable-card--name ${mapVariableName}`).should('exist')
// Create a Query variable from scratch
cy.getByTestID('add-resource-button').click()
cy.getByTestID('add-resource-dropdown--button').click()
cy.getByTestID('add-resource-dropdown--new').click()
cy.getByTestID('variable-type-dropdown--button').click()
cy.getByTestID('variable-type-dropdown-map').click()
@ -195,6 +201,45 @@ describe('Variables', () => {
cy.getByTestID(`variable-card--name ${queryVariableName}`).contains(
queryVariableName
)
//create variable by uploader
cy.getByTestID('add-resource-dropdown--button').click()
cy.getByTestID('add-resource-dropdown--import').click()
const yourFixturePath = 'data-for-variable.json'
cy.get('.drag-and-drop').attachFile(yourFixturePath, {
subjectType: 'drag-n-drop',
})
cy.getByTestID('submit-button Variable').click()
cy.getByTestID('resource-card variable')
.should('have.length', 4)
.contains('agent_host')
})
it('keeps user input in text area when attempting to import invalid JSON', () => {
cy.getByTestID('tabbed-page--header').within(() => {
cy.contains('Create').click()
})
cy.getByTestID('add-resource-dropdown--import').click()
cy.contains('Paste').click()
cy.getByTestID('import-overlay--textarea')
.click()
.type('this is invalid JSON')
cy.get('button[title*="Import JSON"]').click()
cy.getByTestID('import-overlay--textarea--error').should('have.length', 1)
cy.getByTestID('import-overlay--textarea').should($s =>
expect($s).to.contain('this is invalid JSON')
)
cy.getByTestID('import-overlay--textarea').type(
'{backspace}{backspace}{backspace}{backspace}{backspace}'
)
cy.get('button[title*="Import JSON"]').click()
cy.getByTestID('import-overlay--textarea--error').should('have.length', 1)
cy.getByTestID('import-overlay--textarea').contains('this is invalid')
})
it('can create and delete a label and filter a variable by label name & sort by variable name', () => {
@ -209,7 +254,11 @@ describe('Variables', () => {
cy.getByTestID('overlay--children').should('not.exist')
cy.getByTestID('add-resource-button').click()
cy.getByTestID('add-resource-dropdown--button').click()
cy.getByTestID('add-resource-dropdown--new').should('have.length', 1)
cy.getByTestID('add-resource-dropdown--new').click()
cy.getByTestID('variable-type-dropdown--button').click()
cy.getByTestID('variable-type-dropdown-constant').click()

View File

@ -7,5 +7,6 @@
"endpoints": "/endpoints",
"rules": "/rules",
"buckets": "/load-data/buckets",
"telegrafs": "/load-data/telegrafs"
"telegrafs": "/load-data/telegrafs",
"flows": "/flows"
}

View File

@ -6,6 +6,7 @@ import {push} from 'connected-react-router'
// APIs
import * as dashAPI from 'src/dashboards/apis'
import * as api from 'src/client'
import * as tempAPI from 'src/templates/api'
import {createCellWithView} from 'src/cells/actions/thunks'
// Schemas
@ -28,6 +29,7 @@ import {
updateTimeRangeFromQueryParams,
} from 'src/dashboards/actions/ranges'
import {getVariables, hydrateVariables} from 'src/variables/actions/thunks'
import {setExportTemplate} from 'src/templates/actions/creators'
import {checkDashboardLimits} from 'src/cloud/actions/limits'
import {setCells, Action as CellAction} from 'src/cells/actions/creators'
import {
@ -40,6 +42,9 @@ import {setLabelOnResource} from 'src/labels/actions/creators'
import * as creators from 'src/dashboards/actions/creators'
// Utils
import {filterUnusedVars} from 'src/shared/utils/filterUnusedVars'
import {dashboardToTemplate} from 'src/shared/utils/resourceToTemplate'
import {exportVariables} from 'src/variables/utils/exportVariables'
import {getSaveableView} from 'src/timeMachine/selectors'
import {incrementCloneName} from 'src/utils/naming'
import {isLimitError} from 'src/cloud/utils/limits'
@ -57,14 +62,18 @@ import {
GetState,
View,
Cell,
DashboardTemplate,
Label,
RemoteDataState,
DashboardEntities,
ViewEntities,
ResourceType,
VariableEntities,
Variable,
LabelEntities,
} from 'src/types'
import {CellsWithViewProperties} from 'src/client'
import {arrayOfVariables} from 'src/schemas/variables'
type Action = creators.Action
@ -282,6 +291,37 @@ export const getDashboards = () => async (
}
}
export const createDashboardFromTemplate = (
template: DashboardTemplate
) => async (dispatch, getState: GetState) => {
try {
const org = getOrg(getState())
await tempAPI.createDashboardFromTemplate(template, org.id)
const resp = await api.getDashboards({query: {orgID: org.id}})
if (resp.status !== 200) {
throw new Error(resp.data.message)
}
const dashboards = normalize<Dashboard, DashboardEntities, string[]>(
resp.data.dashboards,
arrayOfDashboards
)
dispatch(creators.setDashboards(RemoteDataState.Done, dashboards))
dispatch(notify(copy.importDashboardSucceeded()))
dispatch(checkDashboardLimits())
} catch (error) {
if (isLimitError(error)) {
dispatch(notify(copy.resourceLimitReached('dashboards')))
} else {
dispatch(notify(copy.importDashboardFailed(error)))
}
}
}
export const deleteDashboard = (dashboardID: string, name: string) => async (
dispatch
): Promise<void> => {
@ -455,6 +495,70 @@ export const removeDashboardLabel = (
}
}
export const convertToTemplate = (dashboardID: string) => async (
dispatch,
getState: GetState
): Promise<void> => {
try {
dispatch(setExportTemplate(RemoteDataState.Loading))
const state = getState()
const org = getOrg(state)
const dashResp = await api.getDashboard({dashboardID})
if (dashResp.status !== 200) {
throw new Error(dashResp.data.message)
}
const {entities, result} = normalize<Dashboard, DashboardEntities, string>(
dashResp.data,
dashboardSchema
)
const dashboard = entities.dashboards[result]
const cells = dashboard.cells.map(cellID => entities.cells[cellID])
const pendingViews = dashboard.cells.map(cellID =>
dashAPI.getView(dashboardID, cellID)
)
const views = await Promise.all(pendingViews)
const resp = await api.getVariables({query: {orgID: org.id}})
if (resp.status !== 200) {
throw new Error(resp.data.message)
}
let vars = []
// dumb bug
// https://github.com/paularmstrong/normalizr/issues/290
if (resp.data.variables.length) {
const normVars = normalize<Variable, VariableEntities, string>(
resp.data.variables,
arrayOfVariables
)
vars = Object.values(normVars.entities.variables)
}
const variables = filterUnusedVars(vars, views)
const exportedVariables = exportVariables(variables, vars)
const dashboardTemplate = dashboardToTemplate(
state,
dashboard,
cells,
views,
exportedVariables
)
dispatch(setExportTemplate(RemoteDataState.Done, dashboardTemplate))
} catch (error) {
console.error(error)
dispatch(setExportTemplate(RemoteDataState.Error))
dispatch(notify(copy.createTemplateFailed(error)))
}
}
export const saveVEOView = (dashboardID: string) => async (
dispatch,
getState: GetState

View File

@ -0,0 +1,78 @@
import React, {PureComponent} from 'react'
import {connect, ConnectedProps} from 'react-redux'
import {withRouter, RouteComponentProps} from 'react-router-dom'
// Components
import ExportOverlay from 'src/shared/components/ExportOverlay'
// Actions
import {convertToTemplate as convertToTemplateAction} from 'src/dashboards/actions/thunks'
import {clearExportTemplate as clearExportTemplateAction} from 'src/templates/actions/thunks'
// Types
import {AppState} from 'src/types'
import {
dashboardCopySuccess,
dashboardCopyFailed,
} from 'src/shared/copy/notifications'
type ReduxProps = ConnectedProps<typeof connector>
type Props = ReduxProps &
RouteComponentProps<{orgID: string; dashboardID: string}>
class DashboardExportOverlay extends PureComponent<Props> {
public componentDidMount() {
const {
match: {
params: {dashboardID},
},
convertToTemplate,
} = this.props
convertToTemplate(dashboardID)
}
public render() {
const {status, dashboardTemplate} = this.props
const notes = (_text, success) => {
if (success) {
return dashboardCopySuccess()
}
return dashboardCopyFailed()
}
return (
<ExportOverlay
resourceName="Dashboard"
resource={dashboardTemplate}
onDismissOverlay={this.onDismiss}
onCopyText={notes}
status={status}
/>
)
}
private onDismiss = () => {
const {history, clearExportTemplate} = this.props
history.goBack()
clearExportTemplate()
}
}
const mstp = (state: AppState) => ({
dashboardTemplate: state.resources.templates.exportTemplate.item,
status: state.resources.templates.exportTemplate.status,
})
const mdtp = {
convertToTemplate: convertToTemplateAction,
clearExportTemplate: clearExportTemplateAction,
}
const connector = connect(mstp, mdtp)
export default connector(withRouter(DashboardExportOverlay))

View File

@ -0,0 +1,90 @@
// Libraries
import React, {PureComponent} from 'react'
import {withRouter, RouteComponentProps} from 'react-router-dom'
import {isEmpty} from 'lodash'
import {connect, ConnectedProps} from 'react-redux'
// Components
import ImportOverlay from 'src/shared/components/ImportOverlay'
// Copy
import {invalidJSON} from 'src/shared/copy/notifications'
// Actions
import {
getDashboards,
createDashboardFromTemplate as createDashboardFromTemplateAction,
} from 'src/dashboards/actions/thunks'
import {notify as notifyAction} from 'src/shared/actions/notifications'
// Types
import {ComponentStatus} from '@influxdata/clockface'
// Utils
import jsonlint from 'jsonlint-mod'
interface State {
status: ComponentStatus
}
type ReduxProps = ConnectedProps<typeof connector>
type Props = RouteComponentProps<{orgID: string}> & ReduxProps
class DashboardImportOverlay extends PureComponent<Props> {
public state: State = {
status: ComponentStatus.Default,
}
public render() {
return (
<ImportOverlay
isVisible={true}
onDismissOverlay={this.onDismiss}
resourceName="Dashboard"
onSubmit={this.handleImportDashboard}
status={this.state.status}
updateStatus={this.updateOverlayStatus}
/>
)
}
private updateOverlayStatus = (status: ComponentStatus) =>
this.setState(() => ({status}))
private handleImportDashboard = (uploadContent: string) => {
const {createDashboardFromTemplate, notify, populateDashboards} = this.props
let template
this.updateOverlayStatus(ComponentStatus.Default)
try {
template = jsonlint.parse(uploadContent)
} catch (error) {
this.updateOverlayStatus(ComponentStatus.Error)
notify(invalidJSON(error.message))
return
}
if (isEmpty(template)) {
this.onDismiss()
}
createDashboardFromTemplate(template)
populateDashboards()
this.onDismiss()
}
private onDismiss = (): void => {
const {history} = this.props
history.goBack()
}
}
const mdtp = {
notify: notifyAction,
populateDashboards: getDashboards,
createDashboardFromTemplate: createDashboardFromTemplateAction,
}
const connector = connect(null, mdtp)
export default connector(withRouter(DashboardImportOverlay))

View File

@ -0,0 +1,91 @@
.import-template-overlay,
.import-template-overlay--empty {
height: 500px;
display: flex;
}
.import-template-overlay {
align-items: stretch;
}
.import-template-overlay--templates {
flex: 2 0 0;
border-radius: $ix-radius;
}
.import-template-overlay--details {
flex: 5 0 0;
margin-left: $ix-marg-b;
}
.import-template-overlay--panel {
min-height: 500px;
}
.import-template-overlay--name {
margin-top: 0;
margin-bottom: $ix-marg-b;
}
.import-template-overlay--description {
margin-top: 0;
margin-bottom: $ix-marg-d;
}
.import-template-overlay--heading {
margin-top: 0;
border-bottom: $ix-border solid $g5-pepper;
padding-bottom: $ix-marg-b;
}
.import-templates-overlay--included {
margin-bottom: 0;
}
.import-template-overlay--name.missing,
.import-template-overlay--included.missing,
.import-template-overlay--description.missing {
font-style: italic;
color: $g9-mountain;
}
.import-template-overlay--empty {
background-color: $g3-castle;
border-radius: $ix-radius;
align-content: center;
align-items: center;
justify-content: center;
}
.import-template-overlay--template {
user-select: none;
border-radius: $ix-radius;
padding: $ix-marg-b;
background-color: $g1-raven;
margin-bottom: $ix-border;
border: $ix-border solid $g1-raven;
color: $g11-sidewalk;
display: flex;
flex-wrap: none;
align-items: center;
transition: color 0.25s ease, background-color 0.25s ease, border-color 0.25s ease;
&:hover {
border-color: $g5-pepper;
background-color: $g5-pepper;
color: $g18-cloud;
cursor: pointer;
}
&.active {
border-color: $c-rainforest;
color: $g18-cloud;
background-color: $g5-pepper;
}
}
.import-template-overlay--list-label {
font-size: 13px;
font-weight: 500;
margin-left: $ix-marg-b;
}

View File

@ -98,6 +98,13 @@ class DashboardCard extends PureComponent<Props> {
private get contextMenu(): JSX.Element {
return (
<Context>
<Context.Menu icon={IconFont.CogThick}>
<Context.Item
label="Export"
action={this.handleExport}
testID="context-menu-item-export"
/>
</Context.Menu>
<Context.Menu
icon={IconFont.Duplicate}
color={ComponentColor.Secondary}
@ -164,6 +171,18 @@ class DashboardCard extends PureComponent<Props> {
onRemoveDashboardLabel(id, label)
}
private handleExport = () => {
const {
history,
match: {
params: {orgID},
},
id,
} = this.props
history.push(`/orgs/${orgID}/dashboards-list/${id}/export`)
}
}
const mdtp = {

View File

@ -2,6 +2,7 @@
import React, {PureComponent} from 'react'
import {RouteComponentProps} from 'react-router-dom'
import {connect, ConnectedProps} from 'react-redux'
import {Switch, Route} from 'react-router-dom'
// Decorators
import {ErrorHandling} from 'src/shared/decorators/errors'
@ -10,10 +11,13 @@ import {ErrorHandling} from 'src/shared/decorators/errors'
import DashboardsIndexContents from 'src/dashboards/components/dashboard_index/DashboardsIndexContents'
import {Page} from '@influxdata/clockface'
import SearchWidget from 'src/shared/components/search_widget/SearchWidget'
import AddResourceButton from 'src/shared/components/AddResourceButton'
import AddResourceDropdown from 'src/shared/components/AddResourceDropdown'
import GetAssetLimits from 'src/cloud/components/GetAssetLimits'
import RateLimitAlert from 'src/cloud/components/RateLimitAlert'
import ResourceSortDropdown from 'src/shared/components/resource_sort_dropdown/ResourceSortDropdown'
import DashboardImportOverlay from 'src/dashboards/components/DashboardImportOverlay'
import CreateFromTemplateOverlay from 'src/templates/components/createFromTemplateOverlay/CreateFromTemplateOverlay'
import DashboardExportOverlay from 'src/dashboards/components/DashboardExportOverlay'
// Utils
import {pageTitleSuffixer} from 'src/shared/utils/pageTitles'
@ -76,9 +80,12 @@ class DashboardIndex extends PureComponent<Props, State> {
/>
</Page.ControlBarLeft>
<Page.ControlBarRight>
<AddResourceButton
<AddResourceDropdown
onSelectNew={createDashboard}
onSelectImport={this.summonImportOverlay}
onSelectTemplate={this.summonImportFromTemplateOverlay}
resourceName="Dashboard"
canImportFromTemplate={true}
limitStatus={limitStatus}
/>
</Page.ControlBarRight>
@ -99,6 +106,20 @@ class DashboardIndex extends PureComponent<Props, State> {
</GetAssetLimits>
</Page.Contents>
</Page>
<Switch>
<Route
path="/orgs/:orgID/dashboards-list/:dashboardID/export"
component={DashboardExportOverlay}
/>
<Route
path="/orgs/:orgID/dashboards-list/import/template"
component={CreateFromTemplateOverlay}
/>
<Route
path="/orgs/:orgID/dashboards-list/import"
component={DashboardImportOverlay}
/>
</Switch>
</>
)
}
@ -114,6 +135,26 @@ class DashboardIndex extends PureComponent<Props, State> {
private handleFilterDashboards = (searchTerm: string): void => {
this.setState({searchTerm})
}
private summonImportOverlay = (): void => {
const {
history,
match: {
params: {orgID},
},
} = this.props
history.push(`/orgs/${orgID}/dashboards-list/import`)
}
private summonImportFromTemplateOverlay = (): void => {
const {
history,
match: {
params: {orgID},
},
} = this.props
history.push(`/orgs/${orgID}/dashboards-list/import/template`)
}
}
const mstp = (state: AppState) => {

View File

@ -3,7 +3,7 @@ import React, {FC} from 'react'
// Components
import {EmptyState, ComponentSize} from '@influxdata/clockface'
import AddResourceButton from 'src/shared/components/AddResourceButton'
import AddResourceDropdown from 'src/shared/components/AddResourceDropdown'
// Actions
import {createDashboard} from 'src/dashboards/actions/thunks'
@ -11,11 +11,15 @@ import {createDashboard} from 'src/dashboards/actions/thunks'
interface ComponentProps {
searchTerm?: string
onCreateDashboard: typeof createDashboard
summonImportOverlay: () => void
summonImportFromTemplateOverlay: () => void
}
const DashboardsTableEmpty: FC<ComponentProps> = ({
searchTerm,
onCreateDashboard,
summonImportOverlay,
summonImportFromTemplateOverlay,
}) => {
if (searchTerm) {
return (
@ -30,9 +34,12 @@ const DashboardsTableEmpty: FC<ComponentProps> = ({
<EmptyState.Text>
Looks like you don't have any <b>Dashboards</b>, why not create one?
</EmptyState.Text>
<AddResourceButton
<AddResourceDropdown
onSelectNew={onCreateDashboard}
onSelectImport={summonImportOverlay}
onSelectTemplate={summonImportFromTemplateOverlay}
resourceName="Dashboard"
canImportFromTemplate={true}
/>
</EmptyState>
)

View File

@ -1,6 +1,7 @@
// Libraries
import React, {PureComponent} from 'react'
import {connect, ConnectedProps} from 'react-redux'
import {withRouter, RouteComponentProps} from 'react-router-dom'
// Components
import DashboardCards from 'src/dashboards/components/dashboard_index/DashboardCards'
@ -29,7 +30,7 @@ interface OwnProps {
}
type ReduxProps = ConnectedProps<typeof connector>
type Props = OwnProps & ReduxProps
type Props = OwnProps & ReduxProps & RouteComponentProps<{orgID: string}>
class DashboardsTable extends PureComponent<Props> {
public componentDidMount() {
@ -54,6 +55,8 @@ class DashboardsTable extends PureComponent<Props> {
<DashboardsTableEmpty
searchTerm={searchTerm}
onCreateDashboard={onCreateDashboard}
summonImportFromTemplateOverlay={this.summonImportFromTemplateOverlay}
summonImportOverlay={this.summonImportOverlay}
/>
)
}
@ -68,6 +71,26 @@ class DashboardsTable extends PureComponent<Props> {
/>
)
}
private summonImportOverlay = (): void => {
const {
history,
match: {
params: {orgID},
},
} = this.props
history.push(`/orgs/${orgID}/dashboards-list/import`)
}
private summonImportFromTemplateOverlay = (): void => {
const {
history,
match: {
params: {orgID},
},
} = this.props
history.push(`/orgs/${orgID}/dashboards-list/import/template`)
}
}
const mstp = (state: AppState) => {
@ -86,4 +109,4 @@ const mdtp = {
const connector = connect(mstp, mdtp)
export default connector(DashboardsTable)
export default connector(withRouter(DashboardsTable))

View File

@ -0,0 +1,30 @@
import React, {FC} from 'react'
import {useParams, useHistory} from 'react-router-dom'
// Components
import {ResourceCard} from '@influxdata/clockface'
interface Props {
id: string
name: string
}
const FlowCard: FC<Props> = ({id, name}) => {
const {orgID} = useParams()
const history = useHistory()
const handleClick = () => {
history.push(`/orgs/${orgID}/notebooks/${id}`)
}
return (
<ResourceCard key={`flow-card--${id}`}>
<ResourceCard.Name
name={name || 'Name this flow'}
onClick={handleClick}
/>
</ResourceCard>
)
}
export default FlowCard

View File

@ -0,0 +1,32 @@
import React, {useContext} from 'react'
import {ResourceList, Grid, Columns} from '@influxdata/clockface'
import {NotebookListContext} from 'src/notebooks/context/notebook.list'
import FlowsIndexEmpty from 'src/notebooks/components/FlowsIndexEmpty'
import FlowCard from 'src/notebooks/components/FlowCard'
const FlowCards = () => {
const {notebooks} = useContext(NotebookListContext)
return (
<Grid>
<Grid.Row>
<Grid.Column
widthXS={Columns.Twelve}
widthSM={Columns.Eight}
widthMD={Columns.Ten}
>
<ResourceList>
<ResourceList.Body emptyState={<FlowsIndexEmpty />}>
{Object.entries(notebooks).map(([id, {name}]) => {
return <FlowCard key={id} id={id} name={name} />
})}
</ResourceList.Body>
</ResourceList>
</Grid.Column>
</Grid.Row>
</Grid>
)
}
export default FlowCards

View File

@ -0,0 +1,31 @@
// Libraries
import React, {useContext} from 'react'
import {useHistory, useParams} from 'react-router-dom'
// Components
import {Button, IconFont, ComponentColor} from '@influxdata/clockface'
import {NotebookListContext} from 'src/notebooks/context/notebook.list'
const FlowCreateButton = () => {
const history = useHistory()
const {orgID} = useParams()
const {add} = useContext(NotebookListContext)
const handleCreate = async () => {
const id = await add()
history.push(`/orgs/${orgID}/notebooks/${id}`)
}
return (
<Button
icon={IconFont.Plus}
color={ComponentColor.Primary}
text="Create Flow"
titleText="Click to create a Flow"
onClick={handleCreate}
testID="create-flow--button"
/>
)
}
export default FlowCreateButton

View File

@ -0,0 +1,27 @@
import React from 'react'
// Components
import {Page} from '@influxdata/clockface'
import FlowHeader from 'src/notebooks/components/header'
import PipeList from 'src/notebooks/components/PipeList'
import MiniMap from 'src/notebooks/components/minimap/MiniMap'
const FlowPage = () => {
return (
<Page titleTag="Flows">
<FlowHeader />
<Page.Contents
fullWidth={true}
scrollable={false}
className="notebook-page"
>
<div className="notebook">
<MiniMap />
<PipeList />
</div>
</Page.Contents>
</Page>
)
}
export default FlowPage

View File

@ -0,0 +1,32 @@
// Libraries
import React from 'react'
// Components
import {Page, PageHeader} from '@influxdata/clockface'
import FlowCreateButton from 'src/notebooks/components/FlowCreateButton'
import NotebookListProvider from 'src/notebooks/context/notebook.list'
import FlowCards from 'src/notebooks/components/FlowCards'
// Utils
import {pageTitleSuffixer} from 'src/shared/utils/pageTitles'
const FlowsIndex = () => {
return (
<NotebookListProvider>
<Page titleTag={pageTitleSuffixer(['Flows'])} testID="flows-index">
<PageHeader fullWidth={false}>
<Page.Title title="Flows" />
</PageHeader>
<Page.ControlBar fullWidth={false}>
<Page.ControlBarLeft></Page.ControlBarLeft>
<Page.ControlBarRight>
<FlowCreateButton />
</Page.ControlBarRight>
</Page.ControlBar>
<FlowCards />
</Page>
</NotebookListProvider>
)
}
export default FlowsIndex

View File

@ -0,0 +1,16 @@
import React from 'react'
import {ComponentSize, EmptyState} from '@influxdata/clockface'
import FlowCreateButton from './FlowCreateButton'
const FlowsIndexEmpty = () => {
return (
<EmptyState size={ComponentSize.Large}>
<EmptyState.Text>
Looks like there aren't any <b>Flows</b>, why not create one?
</EmptyState.Text>
<FlowCreateButton />
</EmptyState>
)
}
export default FlowsIndexEmpty

View File

@ -1,18 +1,26 @@
// Libraries
import React, {FC} from 'react'
import React, {FC, useContext, useEffect} from 'react'
import {useParams} from 'react-router-dom'
// Components
import {Page} from '@influxdata/clockface'
import NotebookHeader from 'src/notebooks/components/header'
import PipeList from 'src/notebooks/components/PipeList'
import MiniMap from 'src/notebooks/components/minimap/MiniMap'
// Contexts
import {ResultsProvider} from 'src/notebooks/context/results'
import {RefProvider} from 'src/notebooks/context/refs'
import CurrentNotebook from 'src/notebooks/context/notebook.current'
import CurrentNotebookProvider, {
NotebookContext,
} from 'src/notebooks/context/notebook.current'
import {ScrollProvider} from 'src/notebooks/context/scroll'
import FlowPage from 'src/notebooks/components/FlowPage'
const NotebookFromRoute = () => {
const {id} = useParams()
const {change} = useContext(NotebookContext)
useEffect(() => {
change(id)
}, [id, change])
return null
}
// NOTE: uncommon, but using this to scope the project
// within the page and not bleed it's dependancies outside
// of the feature flag
@ -20,27 +28,16 @@ import 'src/notebooks/style.scss'
const NotebookPage: FC = () => {
return (
<CurrentNotebook>
<CurrentNotebookProvider>
<NotebookFromRoute />
<ResultsProvider>
<RefProvider>
<ScrollProvider>
<Page titleTag="Flows">
<NotebookHeader />
<Page.Contents
fullWidth={true}
scrollable={false}
className="notebook-page"
>
<div className="notebook">
<MiniMap />
<PipeList />
</div>
</Page.Contents>
</Page>
<FlowPage />
</ScrollProvider>
</RefProvider>
</ResultsProvider>
</CurrentNotebook>
</CurrentNotebookProvider>
)
}

View File

@ -2,7 +2,7 @@
import React, {FC, useContext, useCallback} from 'react'
// Contexts
import {NotebookContext} from 'src/notebooks/context/notebook'
import {NotebookContext} from 'src/notebooks/context/notebook.current'
import {TimeProvider, TimeContext, TimeBlock} from 'src/notebooks/context/time'
import AppSettingProvider from 'src/notebooks/context/app'
@ -13,6 +13,7 @@ import TimeRangeDropdown from 'src/notebooks/components/header/TimeRangeDropdown
import AutoRefreshDropdown from 'src/notebooks/components/header/AutoRefreshDropdown'
import Submit from 'src/notebooks/components/header/Submit'
import PresentationMode from 'src/notebooks/components/header/PresentationMode'
import RenamablePageTitle from 'src/pageLayout/components/RenamablePageTitle'
const FULL_WIDTH = true
@ -22,12 +23,12 @@ export interface TimeContextProps {
}
const NotebookHeader: FC = () => {
const {id} = useContext(NotebookContext)
const {id, update, notebook} = useContext(NotebookContext)
const {timeContext, addTimeContext, updateTimeContext} = useContext(
TimeContext
)
const update = useCallback(
const updateTime = useCallback(
(data: TimeBlock) => {
updateTimeContext(id, data)
},
@ -39,10 +40,19 @@ const NotebookHeader: FC = () => {
return null
}
const handleRename = (name: string) => {
update({...notebook, name})
}
return (
<>
<Page.Header fullWidth={FULL_WIDTH}>
<Page.Title title="Flows" />
<RenamablePageTitle
onRename={handleRename}
name={notebook.name}
placeholder="Name this Flow"
maxLength={50}
/>
</Page.Header>
<Page.ControlBar fullWidth={FULL_WIDTH}>
<Page.ControlBarLeft>
@ -51,8 +61,8 @@ const NotebookHeader: FC = () => {
<Page.ControlBarRight>
<PresentationMode />
<TimeZoneDropdown />
<TimeRangeDropdown context={timeContext[id]} update={update} />
<AutoRefreshDropdown context={timeContext[id]} update={update} />
<TimeRangeDropdown context={timeContext[id]} update={updateTime} />
<AutoRefreshDropdown context={timeContext[id]} update={updateTime} />
</Page.ControlBarRight>
</Page.ControlBar>
</>

View File

@ -12,6 +12,7 @@ const useNotebookCurrentState = createPersistedState('current-notebook')
export interface NotebookContextType {
id: string | null
name: string
notebook: Notebook | null
change: (id: string) => void
add: (data: Partial<PipeData>, index?: number) => string
@ -21,6 +22,7 @@ export interface NotebookContextType {
export const DEFAULT_CONTEXT: NotebookContextType = {
id: null,
name: 'Name this Flow',
notebook: null,
add: () => '',
change: () => {},
@ -114,6 +116,7 @@ export const NotebookProvider: FC = ({children}) => {
<NotebookContext.Provider
value={{
id: currentID,
name,
notebook: notebooks[currentID],
add: addPipe,
update: updateCurrent,

View File

@ -20,6 +20,7 @@ export interface NotebookListContextType extends NotebookList {
}
export const EMPTY_NOTEBOOK: NotebookState = {
name: 'Name this Flow',
data: {
byID: {},
allIDs: [],
@ -57,6 +58,7 @@ export const NotebookListProvider: FC = ({children}) => {
}
} else {
_notebook = {
name: notebook.name,
data: notebook.data.serialize(),
meta: notebook.meta.serialize(),
readOnly: notebook.readOnly,
@ -79,6 +81,7 @@ export const NotebookListProvider: FC = ({children}) => {
setNotebooks({
...notebooks,
[id]: {
name: notebook.name,
data: notebook.data.serialize(),
meta: notebook.meta.serialize(),
readOnly: notebook.readOnly,
@ -111,6 +114,7 @@ export const NotebookListProvider: FC = ({children}) => {
}
acc[curr] = {
name: notebooks[curr].name,
data: _asResource(notebooks[curr].data, data => {
stateUpdater('data', data)
}),

View File

@ -14,6 +14,7 @@ export interface PipeMeta {
// TODO: this is screaming for normalization. figure out frontend uuids for cells
export interface NotebookContextType {
id: string
name: string
pipes: PipeData[]
meta: PipeMeta[] // data only used for the view layer for Notebooks
results: FluxResult[]
@ -21,16 +22,19 @@ export interface NotebookContextType {
updatePipe: (idx: number, pipe: Partial<PipeData>) => void
updateMeta: (idx: number, pipe: Partial<PipeMeta>) => void
updateResult: (idx: number, result: Partial<FluxResult>) => void
updateName: (name: string) => void
movePipe: (currentIdx: number, newIdx: number) => void
removePipe: (idx: number) => void
}
export const DEFAULT_CONTEXT: NotebookContextType = {
id: 'new',
name: 'Name this Flow',
pipes: [],
meta: [],
results: [],
addPipe: () => {},
updateName: () => {},
updatePipe: () => {},
updateMeta: () => {},
updateResult: () => {},
@ -78,11 +82,14 @@ export const NotebookProvider: FC = ({children}) => {
const [pipes, setPipes] = useState(DEFAULT_CONTEXT.pipes)
const [meta, setMeta] = useState(DEFAULT_CONTEXT.meta)
const [results, setResults] = useState(DEFAULT_CONTEXT.results)
const [name, setName] = useState(DEFAULT_CONTEXT.name)
const _setPipes = useCallback(setPipes, [id, setPipes])
const _setMeta = useCallback(setMeta, [id, setMeta])
const _setResults = useCallback(setResults, [id, setResults])
const updateName = (newName: string) => setName(newName)
const addPipe = useCallback(
(pipe: PipeData, insertAtIndex?: number) => {
let add = data => {
@ -221,9 +228,11 @@ export const NotebookProvider: FC = ({children}) => {
<NotebookContext.Provider
value={{
id,
name,
pipes,
meta,
results,
updateName,
updatePipe,
updateMeta,
updateResult,

View File

@ -56,12 +56,14 @@ export interface ResourceManipulator<T> {
}
export interface NotebookState {
name: string
data: Resource<PipeData>
meta: Resource<PipeMeta>
readOnly?: boolean
}
export interface Notebook {
name: string
data: ResourceManipulator<PipeData>
meta: ResourceManipulator<PipeMeta>
results: FluxResult

View File

@ -108,17 +108,17 @@ export const generateNavItems = (orgID: string): NavItem[] => {
activeKeywords: ['data-explorer'],
},
{
id: 'notebooks',
testID: 'nav-item-notebooks',
id: 'flows',
testID: 'nav-item-flows',
icon: IconFont.Erlenmeyer,
label: 'Flows',
featureFlag: 'notebooks',
shortLabel: 'Flows',
link: {
type: 'link',
location: `${orgPrefix}/notebooks`,
location: `${orgPrefix}/flows`,
},
activeKeywords: ['notebooks'],
activeKeywords: ['flows'],
},
{
id: 'dashboards',

View File

@ -15,6 +15,7 @@ import {getPlugins} from 'src/dataLoaders/actions/telegrafEditor'
import {getScrapers} from 'src/scrapers/actions/thunks'
import {getTasks} from 'src/tasks/actions/thunks'
import {getTelegrafs} from 'src/telegrafs/actions/thunks'
import {getTemplates} from 'src/templates/actions/thunks'
import {getVariables} from 'src/variables/actions/thunks'
//Utils
@ -99,6 +100,10 @@ class GetResources extends PureComponent<Props> {
return this.props.getAuthorizations()
}
case ResourceType.Templates: {
return this.props.getTemplates()
}
case ResourceType.Members: {
return this.props.getMembers()
}
@ -153,6 +158,7 @@ const mdtp = {
getAuthorizations: getAuthorizations,
getDashboards: getDashboards,
getTasks: getTasks,
getTemplates: getTemplates,
getMembers: getMembers,
getChecks: getChecks,
getNotificationRules: getNotificationRules,

View File

@ -6,9 +6,6 @@ import {withRouter, RouteComponentProps} from 'react-router-dom'
// Components
import TabbedPageTabs from 'src/shared/tabbedPage/TabbedPageTabs'
// Utils
import {isFlagEnabled} from 'src/shared/utils/featureFlag'
// Types
import {TabbedPageTab} from 'src/shared/tabbedPage/TabbedPageTabs'
@ -46,13 +43,9 @@ class SettingsNavigation extends PureComponent<Props> {
},
]
const displayedTabs = isFlagEnabled('communityTemplates')
? tabs
: tabs.filter(t => t.id !== 'templates')
return (
<TabbedPageTabs
tabs={displayedTabs}
tabs={tabs}
activeTab={activeTab}
onTabClick={handleTabClick}
/>

View File

@ -18,6 +18,7 @@ export enum ActionTypes {
SetNotebookMiniMapState = 'SET_NOTEBOOK_MINI_MAP_STATE',
SetAutoRefresh = 'SET_AUTOREFRESH',
SetTimeZone = 'SET_APP_TIME_ZONE',
TemplateControlBarVisibilityToggled = 'TemplateControlBarVisibilityToggledAction',
Noop = 'NOOP',
}

View File

@ -1,70 +0,0 @@
// Libraries
import React, {FC} from 'react'
import {connect, ConnectedProps} from 'react-redux'
import _ from 'lodash'
// Components
import {
IconFont,
ComponentColor,
ComponentSize,
Button,
ComponentStatus,
} from '@influxdata/clockface'
// Actions
import {showOverlay, dismissOverlay} from 'src/overlays/actions/overlays'
// Types
import {LimitStatus} from 'src/cloud/actions/limits'
// Constants
import {CLOUD} from 'src/shared/constants'
interface Props {
onSelectNew: () => void
resourceName: string
status?: ComponentStatus
limitStatus?: LimitStatus
}
type ReduxProps = ConnectedProps<typeof connector>
const AddResourceButton: FC<Props & ReduxProps> = ({
resourceName,
onSelectNew,
onShowOverlay,
onDismissOverlay,
limitStatus = LimitStatus.OK,
status = ComponentStatus.Default,
}) => {
const showLimitOverlay = () =>
onShowOverlay('asset-limit', {asset: `${resourceName}s`}, onDismissOverlay)
const onClick =
CLOUD && limitStatus === LimitStatus.EXCEEDED
? showLimitOverlay
: onSelectNew
return (
<Button
style={{width: '190px'}}
testID="add-resource-button"
onClick={onClick}
color={ComponentColor.Primary}
size={ComponentSize.Small}
text={`Create ${resourceName}`}
icon={IconFont.Plus}
status={status}
/>
)
}
const mdtp = {
onShowOverlay: showOverlay,
onDismissOverlay: dismissOverlay,
}
const connector = connect(null, mdtp)
export default connector(AddResourceButton)

View File

@ -0,0 +1,174 @@
// Libraries
import React, {PureComponent} from 'react'
import {connect, ConnectedProps} from 'react-redux'
import _ from 'lodash'
// Components
import {
IconFont,
ComponentColor,
ComponentSize,
Dropdown,
ComponentStatus,
} from '@influxdata/clockface'
// Actions
import {showOverlay, dismissOverlay} from 'src/overlays/actions/overlays'
// Types
import {LimitStatus} from 'src/cloud/actions/limits'
// Constants
import {CLOUD} from 'src/shared/constants'
interface OwnProps {
onSelectNew: () => void
onSelectImport: () => void
onSelectTemplate?: () => void
resourceName: string
limitStatus?: LimitStatus
}
interface DefaultProps {
canImportFromTemplate: boolean
status: ComponentStatus
titleText: string
}
type ReduxProps = ConnectedProps<typeof connector>
type Props = OwnProps & DefaultProps & ReduxProps
class AddResourceDropdown extends PureComponent<Props> {
public static defaultProps: DefaultProps = {
canImportFromTemplate: false,
status: ComponentStatus.Default,
titleText: null,
}
public render() {
const {titleText, status} = this.props
return (
<Dropdown
style={{width: '190px'}}
testID="add-resource-dropdown"
button={(active, onClick) => (
<Dropdown.Button
testID="add-resource-dropdown--button"
active={active}
onClick={onClick}
color={ComponentColor.Primary}
size={ComponentSize.Small}
icon={IconFont.Plus}
status={status}
>
{titleText || `Create ${this.props.resourceName}`}
</Dropdown.Button>
)}
menu={onCollapse => (
<Dropdown.Menu
onCollapse={onCollapse}
testID="add-resource-dropdown--menu"
>
{this.optionItems}
</Dropdown.Menu>
)}
>
{this.optionItems}
</Dropdown>
)
}
private get optionItems(): JSX.Element[] {
const importOption = this.importOption
const newOption = this.newOption
const templateOption = this.templateOption
const items = [
<Dropdown.Item
id={newOption}
key={newOption}
onClick={this.handleSelect}
value={newOption}
testID="add-resource-dropdown--new"
>
{newOption}
</Dropdown.Item>,
<Dropdown.Item
id={importOption}
key={importOption}
onClick={this.handleSelect}
value={importOption}
testID="add-resource-dropdown--import"
>
{importOption}
</Dropdown.Item>,
]
if (!!this.props.canImportFromTemplate) {
items.push(
<Dropdown.Item
id={templateOption}
key={templateOption}
onClick={this.handleSelect}
value={templateOption}
testID="add-resource-dropdown--template"
>
{templateOption}
</Dropdown.Item>
)
}
return items
}
private get newOption(): string {
return `New ${this.props.resourceName}`
}
private get importOption(): string {
return `Import ${this.props.resourceName}`
}
private get templateOption(): string {
return `From a Template`
}
private handleLimit = (): void => {
const {resourceName, onShowOverlay, onDismissOverlay} = this.props
onShowOverlay('asset-limit', {asset: `${resourceName}s`}, onDismissOverlay)
}
private handleSelect = (selection: string): void => {
const {
onSelectNew,
onSelectImport,
onSelectTemplate,
limitStatus = LimitStatus.OK,
} = this.props
if (CLOUD && limitStatus === LimitStatus.EXCEEDED) {
this.handleLimit()
return
}
if (selection === this.newOption) {
onSelectNew()
}
if (selection === this.importOption) {
onSelectImport()
}
if (selection == this.templateOption) {
onSelectTemplate()
}
}
}
const mdtp = {
onShowOverlay: showOverlay,
onDismissOverlay: dismissOverlay,
}
const connector = connect(null, mdtp)
export default connector(AddResourceDropdown)

View File

@ -0,0 +1,3 @@
.export-overlay--text-area {
height: 60vh;
}

View File

@ -0,0 +1,165 @@
import React, {PureComponent} from 'react'
import {connect, ConnectedProps} from 'react-redux'
import {get} from 'lodash'
// Components
import {
Form,
Button,
SpinnerContainer,
TechnoSpinner,
Overlay,
} from '@influxdata/clockface'
import {Controlled as ReactCodeMirror} from 'react-codemirror2'
import CopyButton from 'src/shared/components/CopyButton'
// Actions
import {createTemplateFromResource} from 'src/templates/actions/thunks'
// Utils
import {downloadTextFile} from 'src/shared/utils/download'
// Types
import {DocumentCreate} from '@influxdata/influx'
import {ComponentColor, ComponentSize} from '@influxdata/clockface'
import {RemoteDataState, Notification} from 'src/types'
interface OwnProps {
onDismissOverlay: () => void
resource: DocumentCreate
resourceName: string
onCopyText?: (text: string, status: boolean) => Notification
status: RemoteDataState
isVisible: boolean
}
type ReduxProps = ConnectedProps<typeof connector>
type Props = OwnProps & ReduxProps
class ExportOverlay extends PureComponent<Props> {
public static defaultProps = {
isVisible: true,
}
public render() {
const {isVisible, resourceName, onDismissOverlay, status} = this.props
return (
<Overlay visible={isVisible}>
<Overlay.Container maxWidth={800}>
<Form onSubmit={this.handleExport}>
<Overlay.Header
title={`Export ${resourceName}`}
onDismiss={onDismissOverlay}
/>
<Overlay.Body>
<SpinnerContainer
loading={status}
spinnerComponent={<TechnoSpinner />}
>
{this.overlayBody}
</SpinnerContainer>
</Overlay.Body>
<Overlay.Footer>
{this.downloadButton}
{this.toTemplateButton}
{this.copyButton}
</Overlay.Footer>
</Form>
</Overlay.Container>
</Overlay>
)
}
private doNothing = () => {}
private get overlayBody(): JSX.Element {
const options = {
tabIndex: 1,
mode: 'json',
readonly: true,
lineNumbers: true,
autoRefresh: true,
theme: 'time-machine',
completeSingle: false,
}
return (
<div
className="export-overlay--text-area"
data-testid="export-overlay--text-area"
>
<ReactCodeMirror
autoFocus={false}
autoCursor={true}
value={this.resourceText}
options={options}
onBeforeChange={this.doNothing}
onTouchStart={this.doNothing}
/>
</div>
)
}
private get resourceText(): string {
return JSON.stringify(this.props.resource, null, 1)
}
private get copyButton(): JSX.Element {
return (
<CopyButton
textToCopy={this.resourceText}
contentName={this.props.resourceName}
onCopyText={this.props.onCopyText}
size={ComponentSize.Small}
color={ComponentColor.Secondary}
/>
)
}
private get downloadButton(): JSX.Element {
return (
<Button
text="Download JSON"
onClick={this.handleExport}
color={ComponentColor.Primary}
/>
)
}
private get toTemplateButton(): JSX.Element {
return (
<Button
text="Save as template"
onClick={this.handleConvertToTemplate}
color={ComponentColor.Primary}
/>
)
}
private handleExport = (): void => {
const {resource, resourceName, onDismissOverlay} = this.props
const name = get(resource, 'content.data.attributes.name', resourceName)
downloadTextFile(JSON.stringify(resource, null, 1), name, '.json')
onDismissOverlay()
}
private handleConvertToTemplate = () => {
const {
resource,
onDismissOverlay,
resourceName,
onCreateTemplateFromResource,
} = this.props
onCreateTemplateFromResource(resource, resourceName)
onDismissOverlay()
}
}
const mdtp = {
onCreateTemplateFromResource: createTemplateFromResource,
}
const connector = connect(null, mdtp)
export default connector(ExportOverlay)

View File

@ -0,0 +1,12 @@
@import "src/style/modules";
.import--options {
display: flex;
margin-top: -8px;
margin-bottom: $ix-marg-b;
justify-content: center;
}
.import--dropdown {
margin-top: $ix-marg-b;
}

View File

@ -0,0 +1,192 @@
// Libraries
import React, {PureComponent, ChangeEvent} from 'react'
import {withRouter, RouteComponentProps} from 'react-router-dom'
// Components
import {
Form,
SelectGroup,
Button,
TextArea,
Overlay,
} from '@influxdata/clockface'
import DragAndDrop from 'src/shared/components/DragAndDrop'
// Types
import {
ButtonType,
ComponentColor,
ComponentStatus,
} from '@influxdata/clockface'
enum ImportOption {
Upload = 'upload',
Paste = 'paste',
}
interface OwnProps {
onDismissOverlay: () => void
resourceName: string
onSubmit: (importString: string, orgID: string) => void
isVisible?: boolean
status?: ComponentStatus
updateStatus?: (status: ComponentStatus) => void
}
interface State {
selectedImportOption: ImportOption
importContent: string
}
type Props = OwnProps & RouteComponentProps<{orgID: string}>
class ImportOverlay extends PureComponent<Props, State> {
public static defaultProps: {isVisible: boolean} = {
isVisible: true,
}
public state: State = {
selectedImportOption: ImportOption.Upload,
importContent: '',
}
public render() {
const {isVisible, resourceName} = this.props
const {selectedImportOption} = this.state
return (
<Overlay visible={isVisible} testID="task-import--overlay">
<Overlay.Container maxWidth={800}>
<Form onSubmit={this.submit}>
<Overlay.Header
title={`Import ${resourceName}`}
onDismiss={this.onDismiss}
/>
<Overlay.Body>
<div className="import--options">
<SelectGroup>
<SelectGroup.Option
name="import-mode"
id={ImportOption.Upload}
active={selectedImportOption === ImportOption.Upload}
value={ImportOption.Upload}
onClick={this.handleSetImportOption}
titleText="Upload"
>
Upload File
</SelectGroup.Option>
<SelectGroup.Option
name="import-mode"
id={ImportOption.Paste}
active={selectedImportOption === ImportOption.Paste}
value={ImportOption.Paste}
onClick={this.handleSetImportOption}
titleText="Paste"
>
Paste JSON
</SelectGroup.Option>
</SelectGroup>
</div>
{this.importBody}
</Overlay.Body>
<Overlay.Footer>{this.submitButton}</Overlay.Footer>
</Form>
</Overlay.Container>
</Overlay>
)
}
private get importBody(): JSX.Element {
const {selectedImportOption, importContent} = this.state
const {status = ComponentStatus.Default} = this.props
if (selectedImportOption === ImportOption.Upload) {
return (
<DragAndDrop
submitText="Upload"
handleSubmit={this.handleSetImportContent}
submitOnDrop={true}
submitOnUpload={true}
onCancel={this.clearImportContent}
/>
)
}
if (selectedImportOption === ImportOption.Paste) {
return (
<TextArea
status={status}
value={importContent}
onChange={this.handleChangeTextArea}
testID="import-overlay--textarea"
/>
)
}
}
private handleChangeTextArea = (
e: ChangeEvent<HTMLTextAreaElement>
): void => {
const {updateStatus = () => {}} = this.props
const importContent = e.target.value
this.handleSetImportContent(importContent)
updateStatus(ComponentStatus.Default)
}
private get submitButton(): JSX.Element {
const {resourceName} = this.props
const {selectedImportOption, importContent} = this.state
const isEnabled =
selectedImportOption === ImportOption.Paste ||
(selectedImportOption === ImportOption.Upload && importContent)
const status = isEnabled
? ComponentStatus.Default
: ComponentStatus.Disabled
return (
<Button
text={`Import JSON as ${resourceName}`}
testID={`submit-button ${resourceName}`}
color={ComponentColor.Primary}
status={status}
type={ButtonType.Submit}
/>
)
}
private submit = () => {
const {importContent} = this.state
const {
onSubmit,
match: {
params: {orgID},
},
} = this.props
onSubmit(importContent, orgID)
this.clearImportContent()
}
private clearImportContent = () => {
this.setState((state, props) => {
const {status = ComponentStatus.Default} = props
return status === ComponentStatus.Error ? {...state} : {importContent: ''}
})
}
private onDismiss = () => {
const {onDismissOverlay} = this.props
this.clearImportContent()
onDismissOverlay()
}
private handleSetImportOption = (selectedImportOption: ImportOption) => {
this.clearImportContent()
this.setState({selectedImportOption})
}
private handleSetImportContent = (importContent: string) => {
this.setState({importContent})
}
}
export default withRouter(ImportOverlay)

View File

@ -0,0 +1,108 @@
// Libraries
import React, {PureComponent} from 'react'
// Components
import {
Button,
SpinnerContainer,
TechnoSpinner,
Overlay,
ComponentSize,
} from '@influxdata/clockface'
import {Controlled as ReactCodeMirror} from 'react-codemirror2'
import CopyButton from 'src/shared/components/CopyButton'
// Types
import {ComponentColor} from '@influxdata/clockface'
import {RemoteDataState, DashboardTemplate} from 'src/types'
import {DocumentCreate} from '@influxdata/influx'
interface Props {
onDismissOverlay: () => void
resource: DashboardTemplate | DocumentCreate
overlayHeading: string
status: RemoteDataState
isVisible: boolean
}
export default class ViewOverlay extends PureComponent<Props> {
public static defaultProps = {
isVisible: true,
}
public render() {
const {isVisible, overlayHeading, onDismissOverlay, status} = this.props
return (
<Overlay visible={isVisible}>
<Overlay.Container maxWidth={800}>
<Overlay.Header title={overlayHeading} onDismiss={onDismissOverlay} />
<Overlay.Body>
<SpinnerContainer
loading={status}
spinnerComponent={<TechnoSpinner />}
>
{this.overlayBody}
</SpinnerContainer>
</Overlay.Body>
<Overlay.Footer>
{this.closeButton}
{this.copyButton}
</Overlay.Footer>
</Overlay.Container>
</Overlay>
)
}
private doNothing = () => {}
private get overlayBody(): JSX.Element {
const options = {
tabIndex: 1,
mode: 'json',
readonly: true,
lineNumbers: true,
autoRefresh: true,
theme: 'time-machine',
completeSingle: false,
}
return (
<div className="export-overlay--text-area">
<ReactCodeMirror
autoFocus={false}
autoCursor={true}
value={this.resourceText}
options={options}
onBeforeChange={this.doNothing}
onTouchStart={this.doNothing}
/>
</div>
)
}
private get resourceText(): string {
return JSON.stringify(this.props.resource, null, 1)
}
private get copyButton(): JSX.Element {
return (
<CopyButton
textToCopy={this.resourceText}
contentName={this.props.overlayHeading}
size={ComponentSize.Small}
color={ComponentColor.Secondary}
/>
)
}
private get closeButton(): JSX.Element {
return (
<Button
text="Close"
onClick={this.props.onDismissOverlay}
color={ComponentColor.Primary}
/>
)
}
}

View File

@ -6,6 +6,7 @@ import {
Task,
Variable,
Label,
Template,
Bucket,
Telegraf,
Scraper,
@ -16,6 +17,7 @@ export type DashboardSortKey = keyof Dashboard | 'meta.updatedAt'
export type TaskSortKey = keyof Task
export type VariableSortKey = keyof Variable | 'arguments.type'
export type LabelSortKey = keyof Label | 'properties.description'
export type TemplateSortKey = keyof Template | 'meta.name' | 'meta.description'
export type BucketSortKey = keyof Bucket | 'retentionRules[0].everySeconds'
export type TelegrafSortKey = keyof Telegraf
export type ScraperSortKey = keyof Scraper
@ -26,6 +28,7 @@ export type SortKey =
| TaskSortKey
| VariableSortKey
| LabelSortKey
| TemplateSortKey
| BucketSortKey
| TelegrafSortKey
| ScraperSortKey
@ -174,6 +177,33 @@ export const generateSortItems = (
sortDirection: Sort.Descending,
},
]
case ResourceType.Templates:
return [
{
label: 'Name (A → Z)',
sortKey: 'meta.name',
sortType: SortTypes.String,
sortDirection: Sort.Ascending,
},
{
label: 'Name (Z → A)',
sortKey: 'meta.name',
sortType: SortTypes.String,
sortDirection: Sort.Descending,
},
{
label: 'Description (A → Z)',
sortKey: 'meta.description',
sortType: SortTypes.String,
sortDirection: Sort.Ascending,
},
{
label: 'Description (Z → A)',
sortKey: 'meta.description',
sortType: SortTypes.String,
sortDirection: Sort.Descending,
},
]
case ResourceType.Buckets:
return [
{

View File

@ -19,7 +19,7 @@ import TelegrafsPage from 'src/telegrafs/containers/TelegrafsPage'
import ScrapersIndex from 'src/scrapers/containers/ScrapersIndex'
import WriteDataPage from 'src/writeData/containers/WriteDataPage'
import VariablesIndex from 'src/variables/containers/VariablesIndex'
import {CommunityTemplatesIndex} from 'src/templates/containers/CommunityTemplatesIndex'
import TemplatesIndex from 'src/templates/containers/TemplatesIndex'
import LabelsIndex from 'src/labels/containers/LabelsIndex'
import OrgProfilePage from 'src/organizations/containers/OrgProfilePage'
import AlertingIndex from 'src/alerting/components/AlertingIndex'
@ -29,6 +29,7 @@ import MembersIndex from 'src/members/containers/MembersIndex'
import RouteToDashboardList from 'src/dashboards/components/RouteToDashboardList'
import ClientLibrariesPage from 'src/writeData/containers/ClientLibrariesPage'
import TelegrafPluginsPage from 'src/writeData/containers/TelegrafPluginsPage'
import FlowsIndex from 'src/notebooks/components/FlowsIndex'
// Types
import {AppState, Organization, ResourceType} from 'src/types'
@ -134,9 +135,13 @@ const SetOrg: FC<Props> = ({
component={RouteToDashboardList}
/>
{/* Flows */}
{/* Flows */}
{isFlagEnabled('notebooks') && (
<Route path={`${orgPath}/notebooks`} component={NotebookPage} />
<Route path={`${orgPath}/notebooks/:id`} component={NotebookPage} />
)}
{isFlagEnabled('notebooks') && (
<Route path={`${orgPath}/flows`} component={FlowsIndex} />
)}
{/* Write Data */}
@ -181,14 +186,10 @@ const SetOrg: FC<Props> = ({
path={`${orgPath}/settings/variables`}
component={VariablesIndex}
/>
{isFlagEnabled('communityTemplates') && (
<Route
path={`${orgPath}/settings/templates`}
component={CommunityTemplatesIndex}
/>
)}
<Route
path={`${orgPath}/settings/templates`}
component={TemplatesIndex}
/>
<Route
exact
path={`${orgPath}/settings/labels`}

View File

@ -310,7 +310,17 @@ export const copyToClipboardFailed = (
message: `${title}'${text}' was not copied to clipboard.`,
})
//Templates
// Templates
export const addTemplateLabelFailed = (): Notification => ({
...defaultErrorNotification,
message: 'Failed to add label to template',
})
export const removeTemplateLabelFailed = (): Notification => ({
...defaultErrorNotification,
message: 'Failed to remove label from template',
})
export const TelegrafDashboardCreated = (configs: string[]): Notification => ({
...defaultSuccessNotification,
message: `Successfully created dashboards for telegraf plugin${
@ -323,6 +333,41 @@ export const TelegrafDashboardFailed = (): Notification => ({
message: `Could not create dashboards for one or more plugins`,
})
export const importTaskSucceeded = (): Notification => ({
...defaultSuccessNotification,
message: `Successfully imported task.`,
})
export const importTaskFailed = (error: string): Notification => ({
...defaultErrorNotification,
message: `Failed to import task: ${error}`,
})
export const importDashboardSucceeded = (): Notification => ({
...defaultSuccessNotification,
message: `Successfully imported dashboard.`,
})
export const importDashboardFailed = (error: string): Notification => ({
...defaultErrorNotification,
message: `Failed to import dashboard: ${error}`,
})
export const importTemplateSucceeded = (): Notification => ({
...defaultSuccessNotification,
message: `Successfully imported template.`,
})
export const importTemplateFailed = (error: string): Notification => ({
...defaultErrorNotification,
message: `Failed to import template: ${error}`,
})
export const createTemplateFailed = (error: string): Notification => ({
...defaultErrorNotification,
message: `Failed to resource as template: ${error}`,
})
export const createResourceFromTemplateFailed = (
error: string
): Notification => ({
@ -330,6 +375,51 @@ export const createResourceFromTemplateFailed = (
message: `Failed to create from template: ${error}`,
})
export const updateTemplateSucceeded = (): Notification => ({
...defaultSuccessNotification,
message: `Successfully updated template.`,
})
export const updateTemplateFailed = (error: string): Notification => ({
...defaultErrorNotification,
message: `Failed to update template: ${error}`,
})
export const deleteTemplateFailed = (error: string): Notification => ({
...defaultErrorNotification,
message: `Failed to delete template: ${error}`,
})
export const deleteTemplateSuccess = (): Notification => ({
...defaultSuccessNotification,
message: 'Template was deleted successfully',
})
export const cloneTemplateFailed = (error: string): Notification => ({
...defaultErrorNotification,
message: `Failed to clone template: ${error}`,
})
export const cloneTemplateSuccess = (): Notification => ({
...defaultSuccessNotification,
message: 'Template cloned successfully',
})
export const resourceSavedAsTemplate = (
resourceName: string
): Notification => ({
...defaultSuccessNotification,
message: `Successfully saved ${resourceName.toLowerCase()} as template.`,
})
export const saveResourceAsTemplateFailed = (
resourceName: string,
error: string
): Notification => ({
...defaultErrorNotification,
message: `Failed to save ${resourceName.toLowerCase()} as template: ${error}`,
})
// Labels
export const getLabelsFailed = (): Notification => ({
...defaultErrorNotification,

View File

@ -0,0 +1,438 @@
import {
labelToRelationship,
labelToIncluded,
taskToTemplate,
variableToTemplate,
dashboardToTemplate,
} from 'src/shared/utils/resourceToTemplate'
import {TemplateType} from '@influxdata/influx'
import {createVariable} from 'src/variables/mocks'
import {
myDashboard,
myView,
myVariable,
myfavelabel,
myfavetask,
myCell,
} from 'src/shared/utils/mocks/resourceToTemplate'
// Libraries
import {RemoteDataState, AppState} from 'src/types'
describe('resourceToTemplate', () => {
const appState = {
resources: {
labels: {
byID: {
[myfavelabel.id]: myfavelabel,
allIDs: [myfavelabel.id],
status: RemoteDataState.Done,
},
},
},
}
describe('labelToRelationship', () => {
it('converts a label to a relationship struct', () => {
const actual = labelToRelationship(myfavelabel)
const expected = {type: TemplateType.Label, id: myfavelabel.id}
expect(actual).toEqual(expected)
})
})
describe('labelToIncluded', () => {
it('converts a label to a data structure in included', () => {
const actual = labelToIncluded(myfavelabel)
const expected = {
type: TemplateType.Label,
id: myfavelabel.id,
attributes: {
name: myfavelabel.name,
properties: {
color: myfavelabel.properties.color,
description: myfavelabel.properties.description,
},
},
}
expect(actual).toEqual(expected)
})
})
describe('variableToTemplate', () => {
it('converts a variable with dependencies to a template', () => {
const a = {
...createVariable('a', 'x.b + 1'),
labels: [myfavelabel.id],
}
const b = createVariable('b', '9000')
const dependencies: any = [a, b]
const actual = variableToTemplate(
(appState as unknown) as AppState,
myVariable,
dependencies
)
const expected = {
meta: {
version: '1',
name: 'beep-Template',
type: 'variable',
description: 'template created from variable: beep',
},
content: {
data: {
type: 'variable',
id: '039ae3b3b74b0000',
attributes: {
name: 'beep',
arguments: {
type: 'query',
values: {
query: 'f(x: v.a)',
language: 'flux',
},
},
selected: null,
},
relationships: {
variable: {
data: [
{
id: 'a',
type: 'variable',
},
{
id: 'b',
type: 'variable',
},
],
},
label: {
data: [],
},
},
},
included: [
{
type: 'variable',
id: 'a',
attributes: {
name: 'a',
arguments: {
type: 'query',
values: {
query: 'x.b + 1',
language: 'flux',
},
},
selected: [],
},
relationships: {
label: {
data: [
{
type: 'label',
id: 'myfavelabel1',
},
],
},
},
},
{
type: 'variable',
id: 'b',
attributes: {
name: 'b',
arguments: {
type: 'query',
values: {
query: '9000',
language: 'flux',
},
},
selected: [],
},
relationships: {
label: {
data: [],
},
},
},
{
id: 'myfavelabel1',
type: 'label',
attributes: {
name: '1label',
properties: {color: 'fffff', description: 'omg'},
},
},
],
},
labels: [],
}
expect(actual).toEqual(expected)
})
})
describe('taskToTemplate', () => {
it('converts a task to a template', () => {
const label = {
id: '037b0c86a92a2000',
name: 'yum',
properties: {
color: '#FF8564',
description: '',
},
}
const state = {
resources: {
labels: {
byID: {
[label.id]: label,
allIDs: [label.id],
status: RemoteDataState.Done,
},
},
},
}
const actual = taskToTemplate((state as unknown) as AppState, {
...myfavetask,
labels: [label.id],
})
const expected = {
content: {
data: {
type: 'task',
attributes: {
every: '24h0m0s',
flux:
'option task = {name: "lala", every: 24h0m0s, offset: 1m0s}\n\nfrom(bucket: "defnuck")\n\t|> range(start: -task.every)',
name: 'lala',
offset: '1m0s',
status: 'active',
},
relationships: {
label: {
data: [
{
id: '037b0c86a92a2000',
type: 'label',
},
],
},
},
},
included: [
{
attributes: {
name: 'yum',
properties: {
color: '#FF8564',
description: '',
},
},
id: '037b0c86a92a2000',
type: TemplateType.Label,
},
],
},
labels: [],
meta: {
description: 'template created from task: lala',
name: 'lala-Template',
type: 'task',
version: '1',
},
}
expect(actual).toEqual(expected)
})
})
describe('dashboardToTemplate', () => {
it('can convert a dashboard to template', () => {
const myLabeledVar = {
...createVariable('var_1', 'labeled var!'),
labels: [myfavelabel.id],
}
const dashboardWithDupeLabel = {
...myDashboard,
labels: [myfavelabel.id],
}
const actual = dashboardToTemplate(
(appState as unknown) as AppState,
dashboardWithDupeLabel,
[myCell],
[myView],
[myLabeledVar]
)
const expected = {
meta: {
version: '1',
name: 'MyDashboard-Template',
type: 'dashboard',
description: 'template created from dashboard: MyDashboard',
},
content: {
data: {
type: 'dashboard',
attributes: {
name: 'MyDashboard',
description: '',
},
relationships: {
label: {
data: [
{
id: 'myfavelabel1',
type: 'label',
},
],
},
cell: {
data: [
{
type: 'cell',
id: 'cell_view_1',
},
],
},
variable: {
data: [
{
type: 'variable',
id: 'var_1',
},
],
},
},
},
included: [
{
id: 'myfavelabel1',
type: 'label',
attributes: {
name: '1label',
properties: {color: 'fffff', description: 'omg'},
},
},
{
id: 'cell_view_1',
type: 'cell',
attributes: {
x: 0,
y: 0,
w: 4,
h: 4,
},
relationships: {
view: {
data: {
type: 'view',
id: 'cell_view_1',
},
},
},
},
{
type: 'view',
id: 'cell_view_1',
attributes: {
name: 'My Cell',
properties: {
shape: 'chronograf-v2',
queries: [
{
text: 'v.bucket',
editMode: 'advanced',
name: 'View Query',
builderConfig: {
buckets: [],
tags: [
{
key: '_measurement',
values: [],
aggregateFunctionType: 'filter',
},
],
functions: [{name: 'mean'}],
aggregateWindow: {period: 'auto', fillValues: false},
},
},
],
axes: {
x: {
bounds: ['', ''],
label: '',
prefix: '',
suffix: '',
base: '10',
scale: 'linear',
},
y: {
bounds: ['', ''],
label: '',
prefix: '',
suffix: '',
base: '10',
scale: 'linear',
},
},
type: 'xy',
legend: {},
geom: 'line',
colors: [],
note: '',
showNoteWhenEmpty: false,
xColumn: null,
yColumn: null,
position: 'overlaid',
},
},
},
{
type: 'variable',
id: 'var_1',
attributes: {
name: 'var_1',
arguments: {
type: 'query',
values: {
query: 'labeled var!',
language: 'flux',
},
},
selected: [],
},
relationships: {
label: {
data: [
{
type: 'label',
id: 'myfavelabel1',
},
],
},
},
},
],
},
labels: [],
}
expect(actual).toEqual(expected)
})
})
})

View File

@ -0,0 +1,350 @@
import {get, pick, flatMap, uniqBy} from 'lodash'
import {defaultBuilderConfig} from 'src/views/helpers'
import {getLabels} from 'src/resources/selectors'
import {
AppState,
Task,
Label,
Dashboard,
DashboardQuery,
Cell,
View,
Variable,
LabelRelationship,
LabelIncluded,
} from 'src/types'
import {TemplateType, DocumentCreate, ITemplate} from '@influxdata/influx'
const CURRENT_TEMPLATE_VERSION = '1'
const blankTemplate = () => ({
meta: {version: CURRENT_TEMPLATE_VERSION},
content: {data: {}, included: []},
labels: [],
})
const blankTaskTemplate = () => {
const baseTemplate = blankTemplate()
return {
...baseTemplate,
meta: {...baseTemplate.meta, type: TemplateType.Task},
content: {
...baseTemplate.content,
data: {...baseTemplate.content.data, type: TemplateType.Task},
},
}
}
const blankVariableTemplate = () => {
const baseTemplate = blankTemplate()
return {
...baseTemplate,
meta: {...baseTemplate.meta, type: TemplateType.Variable},
content: {
...baseTemplate.content,
data: {...baseTemplate.content.data, type: TemplateType.Variable},
},
}
}
const blankDashboardTemplate = () => {
const baseTemplate = blankTemplate()
return {
...baseTemplate,
meta: {...baseTemplate.meta, type: TemplateType.Dashboard},
content: {
...baseTemplate.content,
data: {...baseTemplate.content.data, type: TemplateType.Dashboard},
},
}
}
export const labelToRelationship = (l: Label): LabelRelationship => {
return {type: TemplateType.Label, id: l.id}
}
export const labelToIncluded = (l: Label): LabelIncluded => {
return {
type: TemplateType.Label,
id: l.id,
attributes: {
name: l.name,
properties: l.properties,
},
}
}
export const taskToTemplate = (
state: AppState,
task: Task,
baseTemplate = blankTaskTemplate()
): DocumentCreate => {
const taskName = get(task, 'name', '')
const templateName = `${taskName}-Template`
const taskAttributes = pick(task, [
'status',
'name',
'flux',
'every',
'cron',
'offset',
])
const taskLabels = getLabels(state, task.labels)
const includedLabels = taskLabels.map(label => labelToIncluded(label))
const relationshipsLabels = taskLabels.map(label =>
labelToRelationship(label)
)
const template = {
...baseTemplate,
meta: {
...baseTemplate.meta,
name: templateName,
description: `template created from task: ${taskName}`,
},
content: {
...baseTemplate.content,
data: {
...baseTemplate.content.data,
type: TemplateType.Task,
attributes: taskAttributes,
relationships: {
[TemplateType.Label]: {data: relationshipsLabels},
},
},
included: [...baseTemplate.content.included, ...includedLabels],
},
}
return template
}
const viewToIncluded = (view: View) => {
let properties = view.properties
if ('queries' in properties) {
const sanitizedQueries = properties.queries.map((q: DashboardQuery) => {
return {
...q,
editMode: 'advanced' as 'advanced',
builderConfig: defaultBuilderConfig(),
}
})
properties = {
...properties,
queries: sanitizedQueries,
}
}
return {
type: TemplateType.View,
id: view.id,
attributes: {name: view.name, properties},
}
}
const viewToRelationship = (view: View) => ({
type: TemplateType.View,
id: view.id,
})
const cellToIncluded = (cell: Cell, views: View[]) => {
const cellView = views.find(v => v.id === cell.id)
const viewRelationship = viewToRelationship(cellView)
const cellAttributes = pick(cell, ['x', 'y', 'w', 'h'])
return {
id: cell.id,
type: TemplateType.Cell,
attributes: cellAttributes,
relationships: {
[TemplateType.View]: {
data: viewRelationship,
},
},
}
}
const cellToRelationship = (cell: Cell) => ({
type: TemplateType.Cell,
id: cell.id,
})
export const variableToTemplate = (
state: AppState,
v: Variable,
dependencies: Variable[],
baseTemplate = blankVariableTemplate()
) => {
const labelsByID = state.resources.labels.byID
const variableName = get(v, 'name', '')
const templateName = `${variableName}-Template`
const variableData = variableToIncluded(v, labelsByID)
const variableRelationships = dependencies.map(d => variableToRelationship(d))
const includedDependencies = dependencies.map(d =>
variableToIncluded(d, labelsByID)
)
const vLabels = getLabels(state, v.labels)
const includedLabels = vLabels.map(label => labelToIncluded(label))
const labelRelationships = vLabels.map(label => labelToRelationship(label))
const includedDependentLabels = flatMap(dependencies, d => {
const dLabels = getLabels(state, d.labels)
return dLabels.map(label => labelToIncluded(label))
})
return {
...baseTemplate,
meta: {
...baseTemplate.meta,
name: templateName,
description: `template created from variable: ${variableName}`,
},
content: {
...baseTemplate.content,
data: {
...baseTemplate.content.data,
...variableData,
relationships: {
[TemplateType.Variable]: {
data: [...variableRelationships],
},
[TemplateType.Label]: {
data: [...labelRelationships],
},
},
},
included: [
...includedDependencies,
...includedLabels,
...includedDependentLabels,
],
},
}
}
type LabelsByID = AppState['resources']['labels']['byID']
const variableToIncluded = (v: Variable, labelsByID: LabelsByID) => {
const variableAttributes = pick(v, ['name', 'arguments', 'selected'])
const labelRelationships = v.labels
.map(labelID => {
const label = labelsByID[labelID]
if (!label) {
return null
}
return labelToRelationship(label)
})
.filter(label => !!label)
return {
id: v.id,
type: TemplateType.Variable,
attributes: variableAttributes,
relationships: {
[TemplateType.Label]: {
data: [...labelRelationships],
},
},
}
}
const variableToRelationship = (v: Variable) => ({
type: TemplateType.Variable,
id: v.id,
})
export const dashboardToTemplate = (
state: AppState,
dashboard: Dashboard,
cells: Cell[],
views: View[],
variables: Variable[],
baseTemplate = blankDashboardTemplate()
): DocumentCreate => {
const labelsByID = state.resources.labels.byID
const dashboardName = get(dashboard, 'name', '')
const templateName = `${dashboardName}-Template`
const dashboardAttributes = pick(dashboard, ['name', 'description'])
const dashboardLabels = getLabels(state, dashboard.labels)
const dashboardIncludedLabels = dashboardLabels.map(label =>
labelToIncluded(label)
)
const relationshipsLabels = dashboardLabels.map(label =>
labelToRelationship(label)
)
const includedCells = cells.map(c => cellToIncluded(c, views))
const relationshipsCells = cells.map(c => cellToRelationship(c))
const includedVariables = variables.map(v =>
variableToIncluded(v, labelsByID)
)
const variableIncludedLabels = flatMap(variables, v =>
getLabels(state, v.labels).map(label => labelToIncluded(label))
)
const relationshipsVariables = variables.map(v => variableToRelationship(v))
const includedViews = views.map(v => viewToIncluded(v))
const includedLabels = uniqBy(
[...dashboardIncludedLabels, ...variableIncludedLabels],
'id'
)
const template = {
...baseTemplate,
meta: {
...baseTemplate.meta,
name: templateName,
description: `template created from dashboard: ${dashboardName}`,
},
content: {
...baseTemplate.content,
data: {
...baseTemplate.content.data,
type: TemplateType.Dashboard,
attributes: dashboardAttributes,
relationships: {
[TemplateType.Label]: {data: relationshipsLabels},
[TemplateType.Cell]: {data: relationshipsCells},
[TemplateType.Variable]: {data: relationshipsVariables},
},
},
included: [
...baseTemplate.content.included,
...includedLabels,
...includedCells,
...includedViews,
...includedVariables,
],
},
}
return template
}
export const templateToExport = (template: ITemplate): DocumentCreate => {
const pickedTemplate = pick(template, ['meta', 'content'])
const labelsArray = template.labels.map(l => l.name)
const templateWithLabels = {...pickedTemplate, labels: labelsArray}
return templateWithLabels
}
export const addOrgIDToTemplate = (
template: DocumentCreate,
orgID: string
): DocumentCreate => {
return {...template, orgID}
}

View File

@ -23,6 +23,7 @@
@import 'src/onboarding/OnboardingWizard.scss';
@import 'src/shared/components/protoboard_icon/ProtoboardIcon.scss';
@import 'src/shared/components/columns_options/ColumnsOptions.scss';
@import 'src/shared/components/ImportOverlay.scss';
@import 'src/shared/components/VersionInfo.scss';
@import 'src/shared/components/WaitingText.scss';
@import 'src/shared/components/cells/react-grid-layout.scss';
@ -37,6 +38,7 @@
@import 'src/shared/components/inlineLabels/InlineLabelsEditor.scss';
@import 'src/shared/components/TagInput.scss';
@import 'src/shared/components/ColorSchemeDropdownItem.scss';
@import 'src/shared/components/ExportOverlay.scss';
@import 'src/shared/components/EditableName.scss';
@import 'src/shared/components/SingleStat.scss';
@import 'src/shared/components/DragAndDrop.scss';
@ -96,6 +98,7 @@
@import 'src/shared/components/Checkbox.scss';
@import 'src/shared/components/dapperScrollbars/DapperScrollbars.scss';
@import 'src/shared/components/search_widget/SearchWidget.scss';
@import 'src/templates/components/createFromTemplateOverlay/CreateFromTemplateOverlay.scss';
@import 'src/onboarding/components/SigninForm.scss';
@import 'src/onboarding/containers/LoginPage.scss';
@import 'src/shared/components/ThresholdsSettings.scss';

View File

@ -5,11 +5,13 @@ import {normalize} from 'normalizr'
// APIs
import * as api from 'src/client'
import {createTaskFromTemplate as createTaskFromTemplateAJAX} from 'src/templates/api'
// Schemas
import {taskSchema, arrayOfTasks} from 'src/schemas/tasks'
// Actions
import {setExportTemplate} from 'src/templates/actions/creators'
import {notify, Action as NotifyAction} from 'src/shared/actions/notifications'
import {
addTask,
@ -32,6 +34,7 @@ import * as copy from 'src/shared/copy/notifications'
// Types
import {
Label,
TaskTemplate,
Task,
GetState,
TaskSchedule,
@ -43,6 +46,7 @@ import {
// Utils
import {getErrorMessage} from 'src/utils/api'
import {insertPreambleInScript} from 'src/shared/utils/insertPreambleInScript'
import {taskToTemplate} from 'src/shared/utils/resourceToTemplate'
import {isLimitError} from 'src/cloud/utils/limits'
import {checkTaskLimits} from 'src/cloud/actions/limits'
import {getOrg} from 'src/organizations/selectors'
@ -431,6 +435,52 @@ export const getLogs = (taskID: string, runID: string) => async (
}
}
export const convertToTemplate = (taskID: string) => async (
dispatch,
getState: GetState
): Promise<void> => {
try {
dispatch(setExportTemplate(RemoteDataState.Loading))
const resp = await api.getTask({taskID})
if (resp.status !== 200) {
throw new Error(resp.data.message)
}
const {entities, result} = normalize<Task, TaskEntities, string>(
resp.data,
taskSchema
)
const taskTemplate = taskToTemplate(getState(), entities.tasks[result])
dispatch(setExportTemplate(RemoteDataState.Done, taskTemplate))
} catch (error) {
dispatch(setExportTemplate(RemoteDataState.Error))
dispatch(notify(copy.createTemplateFailed(error)))
}
}
export const createTaskFromTemplate = (template: TaskTemplate) => async (
dispatch: Dispatch<Action>,
getState: GetState
): Promise<void> => {
try {
const org = getOrg(getState())
await createTaskFromTemplateAJAX(template, org.id)
dispatch(getTasks())
dispatch(notify(copy.importTaskSucceeded()))
dispatch(checkTaskLimits())
} catch (error) {
if (isLimitError(error)) {
dispatch(notify(copy.resourceLimitReached('tasks')))
} else {
dispatch(notify(copy.importTaskFailed(error)))
}
}
}
export const runDuration = (finishedAt: Date, startedAt: Date): string => {
let timeTag = 'seconds'

View File

@ -3,7 +3,7 @@ import React, {PureComponent} from 'react'
// Components
import {EmptyState} from '@influxdata/clockface'
import AddResourceButton from 'src/shared/components/AddResourceButton'
import AddResourceDropdown from 'src/shared/components/AddResourceDropdown'
// Types
import {ComponentSize} from '@influxdata/clockface'
@ -12,11 +12,19 @@ interface Props {
searchTerm: string
onCreate: () => void
totalCount: number
onImportTask: () => void
onImportFromTemplate: () => void
}
export default class EmptyTasksLists extends PureComponent<Props> {
public render() {
const {searchTerm, onCreate, totalCount} = this.props
const {
searchTerm,
onCreate,
totalCount,
onImportTask,
onImportFromTemplate,
} = this.props
if (totalCount && searchTerm === '') {
return (
@ -32,7 +40,13 @@ export default class EmptyTasksLists extends PureComponent<Props> {
<EmptyState.Text>
Looks like you don't have any <b>Tasks</b>, why not create one?"
</EmptyState.Text>
<AddResourceButton onSelectNew={onCreate} resourceName="Task" />
<AddResourceDropdown
canImportFromTemplate
onSelectNew={onCreate}
onSelectImport={onImportTask}
onSelectTemplate={onImportFromTemplate}
resourceName="Task"
/>
</EmptyState>
)
}

View File

@ -106,6 +106,7 @@ export class TaskCard extends PureComponent<
return (
<Context>
<Context.Menu icon={IconFont.CogThick}>
<Context.Item label="Export" action={this.handleExport} />
<Context.Item label="View Task Runs" action={this.handleViewRuns} />
<Context.Item label="Run Task" action={onRunTask} value={task.id} />
</Context.Menu>
@ -167,6 +168,15 @@ export class TaskCard extends PureComponent<
onUpdate(name, id)
}
private handleExport = () => {
const {
history,
task,
location: {pathname},
} = this.props
history.push(`${pathname}/${task.id}/export`)
}
private get labels(): JSX.Element {
const {task, onFilterChange} = this.props

View File

@ -0,0 +1,63 @@
import React, {PureComponent} from 'react'
import {withRouter, RouteComponentProps} from 'react-router-dom'
import {connect, ConnectedProps} from 'react-redux'
// Components
import ExportOverlay from 'src/shared/components/ExportOverlay'
// Actions
import {convertToTemplate as convertToTemplateAction} from 'src/tasks/actions/thunks'
import {clearExportTemplate as clearExportTemplateAction} from 'src/templates/actions/thunks'
// Types
import {AppState} from 'src/types'
type ReduxProps = ConnectedProps<typeof connector>
type Props = ReduxProps & RouteComponentProps<{orgID: string; id: string}>
class TaskExportOverlay extends PureComponent<Props> {
public componentDidMount() {
const {
match: {
params: {id},
},
convertToTemplate,
} = this.props
convertToTemplate(id)
}
public render() {
const {taskTemplate, status} = this.props
return (
<ExportOverlay
resourceName="Task"
resource={taskTemplate}
onDismissOverlay={this.onDismiss}
status={status}
/>
)
}
private onDismiss = () => {
const {history, clearExportTemplate} = this.props
history.goBack()
clearExportTemplate()
}
}
const mstp = (state: AppState) => ({
taskTemplate: state.resources.templates.exportTemplate.item,
status: state.resources.templates.exportTemplate.status,
})
const mdtp = {
convertToTemplate: convertToTemplateAction,
clearExportTemplate: clearExportTemplateAction,
}
const connector = connect(mstp, mdtp)
export default connector(withRouter(TaskExportOverlay))

View File

@ -0,0 +1,162 @@
// Libraries
import React, {PureComponent} from 'react'
import {withRouter, RouteComponentProps} from 'react-router-dom'
import {connect, ConnectedProps} from 'react-redux'
import {sortBy} from 'lodash'
// Components
import {
Button,
ComponentColor,
ComponentStatus,
Overlay,
} from '@influxdata/clockface'
import TemplateBrowser from 'src/templates/components/createFromTemplateOverlay/TemplateBrowser'
import TemplateBrowserEmpty from 'src/tasks/components/TemplateBrowserEmpty'
import GetResources from 'src/resources/components/GetResources'
// Actions
import {createTaskFromTemplate as createTaskFromTemplateAction} from 'src/tasks/actions/thunks'
import {getTemplateByID} from 'src/templates/actions/thunks'
// Types
import {
TemplateSummary,
Template,
TemplateType,
AppState,
TaskTemplate,
ResourceType,
} from 'src/types'
// Selectors
import {getAll} from 'src/resources/selectors/getAll'
interface State {
selectedTemplateSummary: TemplateSummary
selectedTemplate: Template
}
type ReduxProps = ConnectedProps<typeof connector>
type Props = ReduxProps
class TaskImportFromTemplateOverlay extends PureComponent<
Props & RouteComponentProps<{orgID: string}>,
State
> {
constructor(props) {
super(props)
this.state = {
selectedTemplateSummary: null,
selectedTemplate: null,
}
}
render() {
return (
<Overlay visible={true} testID="task-import-template--overlay">
<GetResources resources={[ResourceType.Templates]}>
<Overlay.Container maxWidth={900}>
<Overlay.Header
title="Create Task from a Template"
onDismiss={this.onDismiss}
/>
<Overlay.Body>{this.overlayBody}</Overlay.Body>
<Overlay.Footer>
<Button
text="Cancel"
onClick={this.onDismiss}
key="cancel-button"
/>
<Button
text="Create Task"
onClick={this.onSubmit}
key="submit-button"
testID="create-task-button"
color={ComponentColor.Success}
status={this.submitStatus}
/>
</Overlay.Footer>
</Overlay.Container>
</GetResources>
</Overlay>
)
}
private get overlayBody(): JSX.Element {
const {selectedTemplateSummary, selectedTemplate} = this.state
const {templates} = this.props
if (!templates.length) {
return <TemplateBrowserEmpty />
}
return (
<TemplateBrowser
templates={templates}
selectedTemplate={selectedTemplate}
selectedTemplateSummary={selectedTemplateSummary}
onSelectTemplate={this.handleSelectTemplate}
/>
)
}
private get submitStatus(): ComponentStatus {
const {selectedTemplate} = this.state
return selectedTemplate ? ComponentStatus.Default : ComponentStatus.Disabled
}
private handleSelectTemplate = async (
selectedTemplateSummary: TemplateSummary
): Promise<void> => {
const selectedTemplate = await getTemplateByID(selectedTemplateSummary.id)
this.setState({
selectedTemplateSummary,
selectedTemplate,
})
}
private onDismiss = () => {
const {history, match} = this.props
history.push(`/orgs/${match.params.orgID}/tasks`)
}
private onSubmit = () => {
const {createTaskFromTemplate} = this.props
const taskTemplate = this.state.selectedTemplate as TaskTemplate
createTaskFromTemplate(taskTemplate)
this.onDismiss()
}
}
const mstp = (state: AppState) => {
const {
resources: {
templates: {status},
},
} = state
const items = getAll(state, ResourceType.Templates)
const filteredTemplates = items.filter(
t => !t.meta.type || t.meta.type === TemplateType.Task
)
const templates = sortBy(filteredTemplates, item =>
item.meta.name.toLocaleLowerCase()
)
return {
templates,
templateStatus: status,
}
}
const mdtp = {
createTaskFromTemplate: createTaskFromTemplateAction,
}
const connector = connect(mstp, mdtp)
export default connector(withRouter(TaskImportFromTemplateOverlay))

View File

@ -0,0 +1,79 @@
import React, {PureComponent} from 'react'
import {withRouter, RouteComponentProps} from 'react-router-dom'
import {connect, ConnectedProps} from 'react-redux'
// Components
import ImportOverlay from 'src/shared/components/ImportOverlay'
// Copy
import {invalidJSON} from 'src/shared/copy/notifications'
// Actions
import {createTaskFromTemplate as createTaskFromTemplateAction} from 'src/tasks/actions/thunks'
import {notify as notifyAction} from 'src/shared/actions/notifications'
// Types
import {ComponentStatus} from '@influxdata/clockface'
// Utils
import jsonlint from 'jsonlint-mod'
interface State {
status: ComponentStatus
}
type ReduxProps = ConnectedProps<typeof connector>
type Props = ReduxProps & RouteComponentProps<{orgID: string}>
class TaskImportOverlay extends PureComponent<Props> {
public state: State = {
status: ComponentStatus.Default,
}
public render() {
return (
<ImportOverlay
onDismissOverlay={this.onDismiss}
resourceName="Task"
onSubmit={this.handleImportTask}
status={this.state.status}
updateStatus={this.updateOverlayStatus}
/>
)
}
private onDismiss = () => {
const {history} = this.props
history.goBack()
}
private updateOverlayStatus = (status: ComponentStatus) =>
this.setState(() => ({status}))
private handleImportTask = (importString: string) => {
const {createTaskFromTemplate, notify} = this.props
let template
this.updateOverlayStatus(ComponentStatus.Default)
try {
template = jsonlint.parse(importString)
} catch (error) {
this.updateOverlayStatus(ComponentStatus.Error)
notify(invalidJSON(error.message))
return
}
createTaskFromTemplate(template)
this.onDismiss()
}
}
const mdtp = {
createTaskFromTemplate: createTaskFromTemplateAction,
notify: notifyAction,
}
const connector = connect(null, mdtp)
export default connector(withRouter(TaskImportOverlay))

View File

@ -11,7 +11,7 @@ import {
FlexBox,
FlexDirection,
} from '@influxdata/clockface'
import AddResourceButton from 'src/shared/components/AddResourceButton'
import AddResourceDropdown from 'src/shared/components/AddResourceDropdown'
import SearchWidget from 'src/shared/components/search_widget/SearchWidget'
import ResourceSortDropdown from 'src/shared/components/resource_sort_dropdown/ResourceSortDropdown'
import RateLimitAlert from 'src/cloud/components/RateLimitAlert'
@ -27,7 +27,9 @@ interface Props {
onCreateTask: () => void
setShowInactive: () => void
showInactive: boolean
onImportTask: () => void
limitStatus: LimitStatus
onImportFromTemplate: () => void
searchTerm: string
setSearchTerm: typeof setSearchTermAction
sortKey: TaskSortKey
@ -46,6 +48,8 @@ export default class TasksHeader extends PureComponent<Props> {
onCreateTask,
setShowInactive,
showInactive,
onImportTask,
onImportFromTemplate,
setSearchTerm,
searchTerm,
sortKey,
@ -88,8 +92,11 @@ export default class TasksHeader extends PureComponent<Props> {
onChange={setShowInactive}
/>
</FlexBox>
<AddResourceButton
<AddResourceDropdown
canImportFromTemplate
onSelectNew={onCreateTask}
onSelectImport={onImportTask}
onSelectTemplate={onImportFromTemplate}
resourceName="Task"
limitStatus={limitStatus}
/>

View File

@ -29,10 +29,12 @@ interface Props {
onRunTask: any
onUpdate: (name: string, taskID: string) => void
filterComponent?: JSX.Element
onImportTask: () => void
sortKey: TaskSortKey
sortDirection: Sort
sortType: SortTypes
checkTaskLimits: any
onImportFromTemplate: () => void
}
interface State {
@ -58,7 +60,13 @@ export default class TasksList extends PureComponent<Props, State> {
}
public render() {
const {searchTerm, onCreate, totalCount} = this.props
const {
searchTerm,
onCreate,
totalCount,
onImportTask,
onImportFromTemplate,
} = this.props
return (
<>
@ -69,6 +77,8 @@ export default class TasksList extends PureComponent<Props, State> {
searchTerm={searchTerm}
onCreate={onCreate}
totalCount={totalCount}
onImportTask={onImportTask}
onImportFromTemplate={onImportFromTemplate}
/>
}
>

View File

@ -0,0 +1,60 @@
// Libraries
import React, {PureComponent} from 'react'
import {withRouter, RouteComponentProps} from 'react-router-dom'
import {connect} from 'react-redux'
// Components
import {
EmptyState,
ComponentSize,
Button,
IconFont,
} from '@influxdata/clockface'
// Types
import {AppState, Organization} from 'src/types'
// Selectors
import {getOrg} from 'src/organizations/selectors'
interface StateProps {
org: Organization
}
type Props = StateProps & RouteComponentProps<{orgID: string}>
class TemplateBrowserEmpty extends PureComponent<Props> {
public render() {
return (
<div className="import-template-overlay--empty">
<EmptyState size={ComponentSize.Large}>
<EmptyState.Text>
Looks like you don't have any <b>Templates</b> yet, why not import
one?
</EmptyState.Text>
<Button
size={ComponentSize.Medium}
text="Import One Here"
icon={IconFont.CogThick}
onClick={this.handleButtonClick}
/>
</EmptyState>
</div>
)
}
private handleButtonClick = (): void => {
const {history, org} = this.props
history.push(`/orgs/${org.id}/settings/templates/import`)
}
}
const mstp = (state: AppState) => ({
org: getOrg(state),
})
export default connect<StateProps, {}>(
mstp,
null
)(withRouter(TemplateBrowserEmpty))

View File

@ -1,6 +1,7 @@
// Libraries
import React, {PureComponent} from 'react'
import {connect, ConnectedProps} from 'react-redux'
import {Switch, Route} from 'react-router-dom'
// Components
import TasksHeader from 'src/tasks/components/TasksHeader'
@ -11,6 +12,9 @@ import FilterList from 'src/shared/components/FilterList'
import GetResources from 'src/resources/components/GetResources'
import GetAssetLimits from 'src/cloud/components/GetAssetLimits'
import AssetLimitAlert from 'src/cloud/components/AssetLimitAlert'
import TaskExportOverlay from 'src/tasks/components/TaskExportOverlay'
import TaskImportOverlay from 'src/tasks/components/TaskImportOverlay'
import TaskImportFromTemplateOverlay from 'src/tasks/components/TaskImportFromTemplateOverlay'
// Utils
import {pageTitleSuffixer} from 'src/shared/utils/pageTitles'
@ -96,6 +100,8 @@ class TasksPage extends PureComponent<Props, State> {
onCreateTask={this.handleCreateTask}
setShowInactive={setShowInactive}
showInactive={showInactive}
onImportTask={this.summonImportOverlay}
onImportFromTemplate={this.summonImportFromTemplateOverlay}
limitStatus={limitStatus}
searchTerm={searchTerm}
setSearchTerm={setSearchTerm}
@ -125,6 +131,10 @@ class TasksPage extends PureComponent<Props, State> {
onRunTask={onRunTask}
onFilterChange={setSearchTerm}
onUpdate={updateTaskName}
onImportTask={this.summonImportOverlay}
onImportFromTemplate={
this.summonImportFromTemplateOverlay
}
sortKey={sortKey}
sortDirection={sortDirection}
sortType={sortType}
@ -141,6 +151,20 @@ class TasksPage extends PureComponent<Props, State> {
</GetResources>
</Page.Contents>
</Page>
<Switch>
<Route
path="/orgs/:orgID/tasks/:id/export"
component={TaskExportOverlay}
/>
<Route
path="/orgs/:orgID/tasks/import-template"
component={TaskImportFromTemplateOverlay}
/>
<Route
path="/orgs/:orgID/tasks/import"
component={TaskImportOverlay}
/>
</Switch>
</>
)
}
@ -176,6 +200,28 @@ class TasksPage extends PureComponent<Props, State> {
history.push(`/orgs/${orgID}/tasks/new`)
}
private summonImportFromTemplateOverlay = () => {
const {
history,
match: {
params: {orgID},
},
} = this.props
history.push(`/orgs/${orgID}/tasks/import-template`)
}
private summonImportOverlay = (): void => {
const {
history,
match: {
params: {orgID},
},
} = this.props
history.push(`/orgs/${orgID}/tasks/import`)
}
private get filteredTasks(): Task[] {
const {tasks, showInactive} = this.props
const matchingTasks = tasks.filter(t => {

View File

@ -1,9 +1,23 @@
// Types
import {CommunityTemplate} from 'src/types'
import {
CommunityTemplate,
RemoteDataState,
TemplateSummaryEntities,
} from 'src/types'
import {DocumentCreate} from '@influxdata/influx'
import {NormalizedSchema} from 'normalizr'
import {InstalledStack} from 'src/types'
export const ADD_TEMPLATE_SUMMARY = 'ADD_TEMPLATE_SUMMARY'
export const GET_TEMPLATE_SUMMARIES_FOR_ORG = 'GET_TEMPLATE_SUMMARIES_FOR_ORG'
export const POPULATE_TEMPLATE_SUMMARIES = 'POPULATE_TEMPLATE_SUMMARIES'
export const REMOVE_TEMPLATE_SUMMARY = 'REMOVE_TEMPLATE_SUMMARY'
export const SET_STAGED_TEMPLATE = 'SET_STAGED_TEMPLATE'
export const SET_STAGED_TEMPLATE_URL = 'SET_STAGED_TEMPLATE_URL'
export const SET_EXPORT_TEMPLATE = 'SET_EXPORT_TEMPLATE'
export const SET_TEMPLATE_SUMMARY = 'SET_TEMPLATE_SUMMARY'
export const SET_TEMPLATES_STATUS = 'SET_TEMPLATES_STATUS'
export const TOGGLE_TEMPLATE_RESOURCE_INSTALL =
'TOGGLE_TEMPLATE_RESOURCE_INSTALL'
@ -11,13 +25,73 @@ export const SET_STACKS = 'SET_STACKS'
export const DELETE_STACKS = 'DELETE_STACKS'
export type Action =
| ReturnType<typeof addTemplateSummary>
| ReturnType<typeof populateTemplateSummaries>
| ReturnType<typeof removeTemplateSummary>
| ReturnType<typeof setExportTemplate>
| ReturnType<typeof setTemplatesStatus>
| ReturnType<typeof setTemplateSummary>
| ReturnType<typeof setStagedCommunityTemplate>
| ReturnType<typeof setStagedTemplateUrl>
| ReturnType<typeof toggleTemplateResourceInstall>
| ReturnType<typeof setStacks>
| ReturnType<typeof removeStack>
type TemplateSummarySchema<R extends string | string[]> = NormalizedSchema<
TemplateSummaryEntities,
R
>
// Action Creators
export const addTemplateSummary = (schema: TemplateSummarySchema<string>) =>
({
type: ADD_TEMPLATE_SUMMARY,
schema,
} as const)
export const populateTemplateSummaries = (
schema: TemplateSummarySchema<string[]>
) =>
({
type: POPULATE_TEMPLATE_SUMMARIES,
status: RemoteDataState.Done,
schema,
} as const)
export const setExportTemplate = (
status: RemoteDataState,
item?: DocumentCreate
) =>
({
type: SET_EXPORT_TEMPLATE,
status,
item,
} as const)
export const setTemplatesStatus = (status: RemoteDataState) =>
({
type: SET_TEMPLATES_STATUS,
status,
} as const)
export const removeTemplateSummary = (id: string) =>
({
type: REMOVE_TEMPLATE_SUMMARY,
id,
} as const)
export const setTemplateSummary = (
id: string,
status: RemoteDataState,
schema?: TemplateSummarySchema<string>
) =>
({
type: SET_TEMPLATE_SUMMARY,
id,
status,
schema,
} as const)
export const setStagedCommunityTemplate = (template: CommunityTemplate) =>
({
type: SET_STAGED_TEMPLATE,

View File

@ -1,16 +1,283 @@
// Libraries
import {normalize} from 'normalizr'
// APIs
import {client} from 'src/utils/api'
import {fetchStacks} from 'src/templates/api'
import {createDashboardFromTemplate} from 'src/dashboards/actions/thunks'
import {createVariableFromTemplate} from 'src/variables/actions/thunks'
import {createTaskFromTemplate} from 'src/tasks/actions/thunks'
// Schemas
import {templateSchema, arrayOfTemplates} from 'src/schemas/templates'
// Actions
import {notify, Action as NotifyAction} from 'src/shared/actions/notifications'
import {
addTemplateSummary,
setStacks,
populateTemplateSummaries,
removeTemplateSummary,
setExportTemplate,
setTemplatesStatus,
setTemplateSummary,
Action as TemplateAction,
} from 'src/templates/actions/creators'
// Constants
import * as copy from 'src/shared/copy/notifications'
import {staticTemplates} from 'src/templates/constants/defaultTemplates'
// Types
import {Dispatch} from 'react'
import {DocumentCreate, TemplateType} from '@influxdata/influx'
import {
RemoteDataState,
GetState,
DashboardTemplate,
VariableTemplate,
TemplateSummary,
TemplateSummaryEntities,
Label,
Template,
TaskTemplate,
ResourceType,
} from 'src/types'
type Action = TemplateAction
// Utils
import {templateToExport} from 'src/shared/utils/resourceToTemplate'
import {getOrg} from 'src/organizations/selectors'
import {getLabels, getStatus} from 'src/resources/selectors'
type Action = TemplateAction | NotifyAction
export const getTemplateByID = async (id: string): Promise<Template> => {
const template: Template = (await client.templates.get(id)) as any
return template
}
export const getTemplates = () => async (
dispatch: Dispatch<Action>,
getState: GetState
): Promise<void> => {
const state = getState()
if (getStatus(state, ResourceType.Templates) === RemoteDataState.NotStarted) {
dispatch(setTemplatesStatus(RemoteDataState.Loading))
}
const org = getOrg(state)
const items = await client.templates.getAll(org.id)
const templateSummaries = normalize<
TemplateSummary,
TemplateSummaryEntities,
string[]
>(items, arrayOfTemplates)
dispatch(populateTemplateSummaries(templateSummaries))
}
export const createTemplate = (template: DocumentCreate) => async (
dispatch: Dispatch<Action>,
getState: GetState
) => {
try {
const org = getOrg(getState())
const item = await client.templates.create({...template, orgID: org.id})
const templateSummary = normalize<
TemplateSummary,
TemplateSummaryEntities,
string
>(item, templateSchema)
dispatch(addTemplateSummary(templateSummary))
dispatch(notify(copy.importTemplateSucceeded()))
} catch (error) {
console.error(error)
dispatch(notify(copy.importTemplateFailed(error)))
}
}
export const createTemplateFromResource = (
resource: DocumentCreate,
resourceName: string
) => async (dispatch: Dispatch<Action>, getState: GetState) => {
try {
const org = getOrg(getState())
await client.templates.create({...resource, orgID: org.id})
dispatch(notify(copy.resourceSavedAsTemplate(resourceName)))
} catch (error) {
console.error(error)
dispatch(notify(copy.saveResourceAsTemplateFailed(resourceName, error)))
}
}
export const updateTemplate = (id: string, props: TemplateSummary) => async (
dispatch: Dispatch<Action>,
getState: GetState
): Promise<void> => {
setTemplateSummary(id, RemoteDataState.Loading)
const state = getState()
const labels = getLabels(state, props.labels)
try {
const item = await client.templates.update(id, {...props, labels})
const templateSummary = normalize<
TemplateSummary,
TemplateSummaryEntities,
string
>(item, templateSchema)
dispatch(setTemplateSummary(id, RemoteDataState.Done, templateSummary))
dispatch(notify(copy.updateTemplateSucceeded()))
} catch (error) {
console.error(error)
dispatch(notify(copy.updateTemplateFailed(error)))
}
}
export const convertToTemplate = (id: string) => async (
dispatch: Dispatch<Action>
): Promise<void> => {
try {
dispatch(setExportTemplate(RemoteDataState.Loading))
const templateDocument = await client.templates.get(id)
const template = templateToExport(templateDocument)
dispatch(setExportTemplate(RemoteDataState.Done, template))
} catch (error) {
dispatch(setExportTemplate(RemoteDataState.Error))
dispatch(notify(copy.createTemplateFailed(error)))
}
}
export const clearExportTemplate = () => (dispatch: Dispatch<Action>) => {
dispatch(setExportTemplate(RemoteDataState.NotStarted, null))
}
export const deleteTemplate = (templateID: string) => async (
dispatch: Dispatch<Action>
): Promise<void> => {
try {
await client.templates.delete(templateID)
dispatch(removeTemplateSummary(templateID))
dispatch(notify(copy.deleteTemplateSuccess()))
} catch (e) {
console.error(e)
dispatch(notify(copy.deleteTemplateFailed(e)))
}
}
export const cloneTemplate = (templateID: string) => async (
dispatch: Dispatch<Action>,
getState: GetState
): Promise<void> => {
try {
const org = getOrg(getState())
const createdTemplate = await client.templates.clone(templateID, org.id)
const templateSummary = normalize<
TemplateSummary,
TemplateSummaryEntities,
string
>(createdTemplate, templateSchema)
dispatch(addTemplateSummary(templateSummary))
dispatch(notify(copy.cloneTemplateSuccess()))
} catch (error) {
console.error(error)
dispatch(notify(copy.cloneTemplateFailed(error)))
}
}
const createFromTemplate = (template: Template) => dispatch => {
const {
content: {
data: {type},
},
} = template
try {
switch (type) {
case TemplateType.Dashboard:
return dispatch(
createDashboardFromTemplate(template as DashboardTemplate)
)
case TemplateType.Task:
return dispatch(createTaskFromTemplate(template as TaskTemplate))
case TemplateType.Variable:
return dispatch(
createVariableFromTemplate(template as VariableTemplate)
)
default:
throw new Error(`Cannot create template: ${type}`)
}
} catch (e) {
console.error(e)
dispatch(notify(copy.createResourceFromTemplateFailed(e)))
}
}
export const createResourceFromStaticTemplate = (name: string) => dispatch => {
const template = staticTemplates[name]
dispatch(createFromTemplate(template))
}
export const createResourceFromTemplate = (templateID: string) => async (
dispatch
): Promise<void> => {
const template: Template = (await client.templates.get(templateID)) as any
dispatch(createFromTemplate(template))
}
export const addTemplateLabelsAsync = (
templateID: string,
labels: Label[]
) => async (dispatch: Dispatch<Action>): Promise<void> => {
try {
await client.templates.addLabels(
templateID,
labels.map(l => l.id)
)
const item = await client.templates.get(templateID)
const templateSummary = normalize<
TemplateSummary,
TemplateSummaryEntities,
string
>(item, templateSchema)
dispatch(
setTemplateSummary(templateID, RemoteDataState.Done, templateSummary)
)
} catch (error) {
console.error(error)
dispatch(notify(copy.addTemplateLabelFailed()))
}
}
export const removeTemplateLabelsAsync = (
templateID: string,
labels: Label[]
) => async (dispatch: Dispatch<Action>): Promise<void> => {
try {
await client.templates.removeLabels(
templateID,
labels.map(l => l.id)
)
const item = await client.templates.get(templateID)
const templateSummary = normalize<
TemplateSummary,
TemplateSummaryEntities,
string
>(item, templateSchema)
dispatch(
setTemplateSummary(templateID, RemoteDataState.Done, templateSummary)
)
} catch (error) {
console.error(error)
dispatch(notify(copy.removeTemplateLabelFailed()))
}
}
export const fetchAndSetStacks = (orgID: string) => async (
dispatch: Dispatch<Action>

View File

@ -4,6 +4,7 @@ import {normalize} from 'normalizr'
// Schemas
import {arrayOfVariables, variableSchema} from 'src/schemas/variables'
import {taskSchema} from 'src/schemas/tasks'
// Utils
import {
@ -20,8 +21,12 @@ import {addLabelDefaults} from 'src/labels/utils'
// API
import {
getDashboard as apiGetDashboard,
getTask as apiGetTask,
postTask as apiPostTask,
postTasksLabel as apiPostTasksLabel,
getLabels as apiGetLabels,
postLabel as apiPostLabel,
getVariable as apiGetVariable,
getVariables as apiGetVariables,
postVariable as apiPostVariable,
postVariablesLabel as apiPostVariablesLabel,
@ -41,6 +46,7 @@ import {addDashboardDefaults} from 'src/schemas/dashboards'
// Types
import {
TaskEntities,
DashboardTemplate,
Dashboard,
TemplateType,
@ -49,7 +55,9 @@ import {
CellIncluded,
LabelIncluded,
ViewIncluded,
TaskTemplate,
TemplateBase,
Task,
VariableTemplate,
Variable,
VariableEntities,
@ -350,6 +358,112 @@ const createVariablesFromTemplate = async (
await Promise.all(addLabelsToVars)
}
export const createTaskFromTemplate = async (
template: TaskTemplate,
orgID: string
): Promise<Task> => {
const {content} = template
try {
if (
content.data.type !== TemplateType.Task ||
template.meta.version !== '1'
) {
throw new Error('Cannot create task from this template')
}
const flux = content.data.attributes.flux
const postResp = await apiPostTask({data: {orgID, flux}})
if (postResp.status !== 201) {
throw new Error(postResp.data.message)
}
const {entities, result} = normalize<Task, TaskEntities, string>(
postResp.data,
taskSchema
)
const postedTask = entities.tasks[result]
// associate imported label.id with created label
const labelMap = await createLabelsFromTemplate(template, orgID)
await addTaskLabelsFromTemplate(template, labelMap, postedTask)
const resp = await apiGetTask({taskID: postedTask.id})
if (resp.status !== 200) {
throw new Error(resp.data.message)
}
return postedTask
} catch (e) {
console.error(e)
}
}
const addTaskLabelsFromTemplate = async (
template: TaskTemplate,
labelMap: LabelMap,
task: Task
) => {
try {
const relationships = getLabelRelationships(template.content.data)
const labelIDs = relationships.map(l => labelMap[l.id] || '')
const pending = labelIDs.map(labelID =>
apiPostTasksLabel({taskID: task.id, data: {labelID}})
)
const resolved = await Promise.all(pending)
if (resolved.length > 0 && resolved.some(r => r.status !== 201)) {
throw new Error('An error occurred adding task labels from the templates')
}
} catch (error) {
console.error(error)
}
}
export const createVariableFromTemplate = async (
template: VariableTemplate,
orgID: string
) => {
const {content} = template
try {
if (
content.data.type !== TemplateType.Variable ||
template.meta.version !== '1'
) {
throw new Error('Cannot create variable from this template')
}
const resp = await apiPostVariable({
data: {
...content.data.attributes,
orgID,
},
})
if (resp.status !== 201) {
throw new Error(resp.data.message)
}
// associate imported label.id with created label
const labelsMap = await createLabelsFromTemplate(template, orgID)
await createVariablesFromTemplate(template, labelsMap, orgID)
const variable = await apiGetVariable({variableID: resp.data.id})
if (variable.status !== 200) {
throw new Error(variable.data.message)
}
return variable.data
} catch (error) {
console.error(error)
}
}
const applyTemplates = async params => {
const resp = await postTemplatesApply(params)
if (resp.status >= 300) {
@ -440,3 +554,15 @@ export const updateStackName = async (stackID, name) => {
return resp
}
export const fetchReadMe = async (directory: string) => {
const resp = await fetch(
`https://raw.githubusercontent.com/influxdata/community-templates/master/${directory}/README.md`
)
if (resp.status >= 300) {
throw new Error(`Network response was not ok:' ${resp.statusText}`)
}
return resp.text()
}

View File

@ -7,7 +7,7 @@ import {CommunityTemplateOverlay} from 'src/templates/components/CommunityTempla
// Actions
import {setStagedCommunityTemplate} from 'src/templates/actions/creators'
import {fetchAndSetStacks} from 'src/templates/actions/thunks'
import {createTemplate, fetchAndSetStacks} from 'src/templates/actions/thunks'
import {notify} from 'src/shared/actions/notifications'
import {getTotalResourceCount} from 'src/templates/selectors'
@ -63,13 +63,18 @@ class UnconnectedTemplateImportOverlay extends PureComponent<Props> {
}
public render() {
const templateDetails = getTemplateNameFromUrl(this.props.stagedTemplateUrl)
const templateName = templateDetails.name
const templateDirectory = templateDetails.directory
return (
<CommunityTemplateOverlay
onDismissOverlay={this.onDismiss}
onInstall={this.handleInstallTemplate}
resourceCount={this.props.resourceCount}
status={this.state.status}
templateName={getTemplateNameFromUrl(this.props.stagedTemplateUrl).name}
templateName={templateName}
templateDirectory={templateDirectory}
updateStatus={this.updateOverlayStatus}
/>
)
@ -151,6 +156,7 @@ const mstp = (state: AppState, props: RouterProps) => {
}
const mdtp = {
createTemplate,
notify,
setStagedCommunityTemplate,
fetchAndSetStacks,

View File

@ -18,6 +18,7 @@ interface OwnProps {
resourceCount: number
status?: ComponentStatus
templateName: string
templateDirectory: string
updateStatus?: (status: ComponentStatus) => void
}
@ -44,7 +45,13 @@ class CommunityTemplateOverlayUnconnected extends PureComponent<Props, State> {
}
public render() {
const {isVisible, onInstall, resourceCount, templateName} = this.props
const {
isVisible,
onInstall,
resourceCount,
templateName,
templateDirectory,
} = this.props
return (
<Overlay visible={isVisible}>
@ -71,13 +78,14 @@ class CommunityTemplateOverlayUnconnected extends PureComponent<Props, State> {
active={this.state.activeTab === Tab.Readme}
id="readme"
text="Readme"
testID="community-templates-readme-tab"
onClick={this.setTabToReadme}
/>
</Tabs.Tabs>
{this.state.activeTab === Tab.IncludedResources ? (
<CommunityTemplateOverlayContents />
) : (
<CommunityTemplateReadme />
<CommunityTemplateReadme directory={templateDirectory} />
)}
</Tabs.Container>
</Overlay.Body>

View File

@ -1,6 +1,43 @@
// Libraries
import React, {FC} from 'react'
import React, {PureComponent} from 'react'
import {MarkdownRenderer} from 'src/shared/components/views/MarkdownRenderer'
import {fetchReadMe} from 'src/templates/api'
import {reportError} from 'src/shared/utils/errors'
export const CommunityTemplateReadme: FC = () => {
return <h3>Readme to go here</h3>
interface Props {
directory: string
}
const cloudImageRenderer = (): any =>
"We don't support images in markdown for security purposes"
export class CommunityTemplateReadme extends PureComponent<Props> {
state = {readMeData: ''}
componentDidMount = async () => {
try {
const response = await fetchReadMe(this.props.directory)
this.setState({readMeData: response})
} catch (error) {
reportError(error, {
name: 'The community template fetch github readme failed',
})
this.setState({
readMeData: "## We can't find the readme associated with this template",
})
}
}
render = () => {
return (
<MarkdownRenderer
text={this.state.readMeData}
className="markdown-format"
cloudRenderers={{
image: cloudImageRenderer,
imageReference: cloudImageRenderer,
}}
/>
)
}
}

View File

@ -0,0 +1,47 @@
// Libraries
import React, {FunctionComponent} from 'react'
// Components
import {
EmptyState,
IconFont,
ComponentColor,
Button,
} from '@influxdata/clockface'
// Types
import {ComponentSize} from '@influxdata/clockface'
interface Props {
searchTerm: string
onImport: () => void
}
const EmptyTemplatesList: FunctionComponent<Props> = ({
searchTerm,
onImport,
}) => {
if (searchTerm === '') {
return (
<EmptyState size={ComponentSize.Large}>
<EmptyState.Text>
Looks like you don't have any <b>Templates</b>, why not create one?
</EmptyState.Text>
<Button
text="Import Template"
icon={IconFont.Plus}
color={ComponentColor.Primary}
onClick={onImport}
/>
</EmptyState>
)
}
return (
<EmptyState size={ComponentSize.Large}>
<EmptyState.Text>No Templates match your search term</EmptyState.Text>
</EmptyState>
)
}
export default EmptyTemplatesList

View File

@ -0,0 +1,114 @@
// Libraries
import React, {PureComponent, MouseEvent} from 'react'
import {get, capitalize} from 'lodash'
import {connect, ConnectedProps} from 'react-redux'
import {withRouter, RouteComponentProps} from 'react-router-dom'
import {
Button,
ComponentSize,
FlexBox,
FlexDirection,
JustifyContent,
} from '@influxdata/clockface'
// Components
import {ResourceCard} from '@influxdata/clockface'
// Actions
import {createResourceFromStaticTemplate} from 'src/templates/actions/thunks'
// Selectors
import {getOrg} from 'src/organizations/selectors'
// Types
import {ComponentColor} from '@influxdata/clockface'
import {AppState, TemplateSummary} from 'src/types'
// Constants
interface OwnProps {
template: TemplateSummary
name: string
onFilterChange: (searchTerm: string) => void
}
type ReduxProps = ConnectedProps<typeof connector>
type Props = ReduxProps & OwnProps
class StaticTemplateCard extends PureComponent<
Props & RouteComponentProps<{orgID: string}>
> {
public render() {
const {template} = this.props
return (
<ResourceCard testID="template-card" contextMenu={this.contextMenu}>
<ResourceCard.Name
onClick={this.handleNameClick}
name={template.meta.name}
testID="template-card--name"
/>
{this.description}
<ResourceCard.Meta>
{capitalize(get(template, 'content.data.type', ''))}
</ResourceCard.Meta>
</ResourceCard>
)
}
private get contextMenu(): JSX.Element {
return (
<FlexBox
margin={ComponentSize.Medium}
direction={FlexDirection.Row}
justifyContent={JustifyContent.FlexEnd}
>
<Button
text="Create"
color={ComponentColor.Primary}
size={ComponentSize.ExtraSmall}
onClick={this.handleCreate}
/>
</FlexBox>
)
}
private get description(): JSX.Element {
const {template} = this.props
const description = get(template, 'content.data.attributes.description')
return (
<ResourceCard.Description description={description || 'No description'} />
)
}
private handleCreate = () => {
const {onCreateFromTemplate, name} = this.props
onCreateFromTemplate(name)
}
private handleNameClick = (e: MouseEvent<HTMLAnchorElement>) => {
e.preventDefault()
this.handleViewTemplate()
}
private handleViewTemplate = () => {
const {history, org, name} = this.props
history.push(`/orgs/${org.id}/settings/templates/${name}/static/view`)
}
}
const mstp = (state: AppState) => {
return {
org: getOrg(state),
}
}
const mdtp = {
onCreateFromTemplate: createResourceFromStaticTemplate,
}
const connector = connect(mstp, mdtp)
export default connector(withRouter(StaticTemplateCard))

View File

@ -0,0 +1,55 @@
import React, {PureComponent} from 'react'
import {withRouter, RouteComponentProps} from 'react-router-dom'
import _ from 'lodash'
// Components
import ViewOverlay from 'src/shared/components/ViewOverlay'
import {ErrorHandling} from 'src/shared/decorators/errors'
// Types
import {RemoteDataState} from '@influxdata/clockface'
import {staticTemplates} from 'src/templates/constants/defaultTemplates'
import {DashboardTemplate} from 'src/types'
interface OwnProps {
match: {id: string}
}
type Props = OwnProps & RouteComponentProps<{orgID: string; id: string}>
@ErrorHandling
class TemplateExportOverlay extends PureComponent<Props> {
public render() {
return (
<ViewOverlay
resource={this.template}
overlayHeading={this.overlayTitle}
onDismissOverlay={this.onDismiss}
status={RemoteDataState.Done}
/>
)
}
private get template(): DashboardTemplate {
const {
match: {
params: {id},
},
} = this.props
return staticTemplates[id]
}
private get overlayTitle() {
return this.template.meta.name
}
private onDismiss = () => {
const {history} = this.props
history.goBack()
}
}
export default withRouter(TemplateExportOverlay)

View File

@ -0,0 +1,81 @@
// Libraries
import React, {PureComponent} from 'react'
import memoizeOne from 'memoize-one'
// Components
import {ResourceList} from '@influxdata/clockface'
import EmptyTemplatesList from 'src/templates/components/EmptyTemplatesList'
import StaticTemplateCard from 'src/templates/components/StaticTemplateCard'
// Types
import {Template, TemplateSummary, RemoteDataState} from 'src/types'
import {SortTypes} from 'src/shared/utils/sort'
import {Sort} from 'src/clockface'
// Selectors
import {getSortedResources} from 'src/shared/utils/sort'
export type TemplateOrSummary = Template | TemplateSummary
export interface StaticTemplate {
name: string
template: TemplateOrSummary
}
interface Props {
templates: StaticTemplate[]
searchTerm: string
onFilterChange: (searchTerm: string) => void
onImport: () => void
sortKey: string
sortDirection: Sort
sortType: SortTypes
}
export default class StaticTemplatesList extends PureComponent<Props> {
private memGetSortedResources = memoizeOne<typeof getSortedResources>(
getSortedResources
)
public render() {
const {searchTerm, onImport} = this.props
return (
<ResourceList>
<ResourceList.Body
emptyState={
<EmptyTemplatesList searchTerm={searchTerm} onImport={onImport} />
}
>
{this.rows}
</ResourceList.Body>
</ResourceList>
)
}
private get rows(): JSX.Element[] {
const {
templates,
sortKey,
sortDirection,
sortType,
onFilterChange,
} = this.props
const sortedTemplates = this.memGetSortedResources(
templates,
`template.${sortKey}`,
sortDirection,
sortType
)
return sortedTemplates.map(t => (
<StaticTemplateCard
key={`template-id--static-${t.name}`}
name={t.name}
template={{...t.template, status: RemoteDataState.Done}}
onFilterChange={onFilterChange}
/>
))
}
}

View File

@ -0,0 +1,206 @@
// Libraries
import React, {PureComponent, MouseEvent} from 'react'
import {connect, ConnectedProps} from 'react-redux'
import {get, capitalize} from 'lodash'
import {withRouter, RouteComponentProps} from 'react-router-dom'
import {
Button,
ComponentSize,
FlexBox,
FlexDirection,
JustifyContent,
} from '@influxdata/clockface'
// Components
import {Context} from 'src/clockface'
import {ResourceCard, IconFont} from '@influxdata/clockface'
import InlineLabels from 'src/shared/components/inlineLabels/InlineLabels'
// Actions
import {
deleteTemplate,
cloneTemplate,
updateTemplate,
createResourceFromTemplate,
removeTemplateLabelsAsync,
addTemplateLabelsAsync,
} from 'src/templates/actions/thunks'
// Selectors
import {getOrg} from 'src/organizations/selectors'
// Types
import {ComponentColor} from '@influxdata/clockface'
import {AppState, Label, TemplateSummary} from 'src/types'
// Constants
import {DEFAULT_TEMPLATE_NAME} from 'src/templates/constants'
interface OwnProps {
template: TemplateSummary
onFilterChange: (searchTerm: string) => void
}
type ReduxProps = ConnectedProps<typeof connector>
type Props = ReduxProps & OwnProps
class TemplateCard extends PureComponent<
Props & RouteComponentProps<{orgID: string}>
> {
public render() {
const {template, onFilterChange} = this.props
return (
<ResourceCard testID="template-card" contextMenu={this.contextMenu}>
<ResourceCard.EditableName
onClick={this.handleNameClick}
onUpdate={this.handleUpdateTemplateName}
name={template.meta.name}
noNameString={DEFAULT_TEMPLATE_NAME}
testID="template-card--name"
buttonTestID="template-card--name-button"
inputTestID="template-card--input"
/>
{this.description}
<ResourceCard.Meta>
{capitalize(get(template, 'content.data.type', ''))}
</ResourceCard.Meta>
<InlineLabels
selectedLabelIDs={template.labels}
onFilterChange={onFilterChange}
onAddLabel={this.handleAddLabel}
onRemoveLabel={this.handleRemoveLabel}
/>
</ResourceCard>
)
}
private handleUpdateTemplateName = (name: string) => {
const {template} = this.props
this.props.onUpdate(template.id, {
...template,
meta: {...template.meta, name},
})
}
private handleUpdateTemplateDescription = (description: string) => {
const {template} = this.props
this.props.onUpdate(template.id, {
...template,
meta: {...template.meta, description},
})
}
private get description(): JSX.Element {
const {template} = this.props
const description = get(template, 'meta.description', '')
const name = get(template, 'meta.name', '')
return (
<ResourceCard.EditableDescription
onUpdate={this.handleUpdateTemplateDescription}
description={description}
placeholder={`Describe ${name} Template`}
/>
)
}
private get contextMenu(): JSX.Element {
const {
template: {id},
onDelete,
} = this.props
return (
<FlexBox
margin={ComponentSize.Medium}
direction={FlexDirection.Row}
justifyContent={JustifyContent.FlexEnd}
>
<Button
text="Create"
color={ComponentColor.Primary}
size={ComponentSize.ExtraSmall}
onClick={this.handleCreate}
/>
<Context>
<Context.Menu
icon={IconFont.Duplicate}
color={ComponentColor.Secondary}
>
<Context.Item label="Clone" action={this.handleClone} value={id} />
</Context.Menu>
<Context.Menu
icon={IconFont.Trash}
color={ComponentColor.Danger}
testID="context-delete-menu"
>
<Context.Item
label="Delete"
action={onDelete}
value={id}
testID="context-delete-task"
/>
</Context.Menu>
</Context>
</FlexBox>
)
}
private handleCreate = () => {
const {onCreateFromTemplate, template} = this.props
onCreateFromTemplate(template.id)
}
private handleClone = () => {
const {
template: {id},
onClone,
} = this.props
onClone(id)
}
private handleNameClick = (e: MouseEvent): void => {
e.preventDefault()
this.handleViewTemplate()
}
private handleViewTemplate = () => {
const {history, template, org} = this.props
history.push(`/orgs/${org.id}/settings/templates/${template.id}/view`)
}
private handleAddLabel = (label: Label): void => {
const {template, onAddTemplateLabels} = this.props
onAddTemplateLabels(template.id, [label])
}
private handleRemoveLabel = (label: Label): void => {
const {template, onRemoveTemplateLabels} = this.props
onRemoveTemplateLabels(template.id, [label])
}
}
const mstp = (state: AppState) => {
return {
org: getOrg(state),
}
}
const mdtp = {
onDelete: deleteTemplate,
onClone: cloneTemplate,
onUpdate: updateTemplate,
onCreateFromTemplate: createResourceFromTemplate,
onAddTemplateLabels: addTemplateLabelsAsync,
onRemoveTemplateLabels: removeTemplateLabelsAsync,
}
const connector = connect(mstp, mdtp)
export default connector(withRouter(TemplateCard))

View File

@ -0,0 +1,70 @@
import React, {PureComponent} from 'react'
import {connect, ConnectedProps} from 'react-redux'
import {withRouter, RouteComponentProps} from 'react-router-dom'
// Components
import ExportOverlay from 'src/shared/components/ExportOverlay'
// Actions
import {
convertToTemplate as convertToTemplateAction,
clearExportTemplate as clearExportTemplateAction,
} from 'src/templates/actions/thunks'
// Types
import {AppState} from 'src/types'
interface OwnProps {
match: {id: string}
}
type ReduxProps = ConnectedProps<typeof connector>
type Props = OwnProps &
ReduxProps &
RouteComponentProps<{orgID: string; id: string}>
class TemplateExportOverlay extends PureComponent<Props> {
public componentDidMount() {
const {
match: {
params: {id},
},
convertToTemplate,
} = this.props
convertToTemplate(id)
}
public render() {
const {exportTemplate, status} = this.props
return (
<ExportOverlay
resourceName="Template"
resource={exportTemplate}
onDismissOverlay={this.onDismiss}
status={status}
/>
)
}
private onDismiss = () => {
const {history, clearExportTemplate} = this.props
history.goBack()
clearExportTemplate()
}
}
const mstp = (state: AppState) => ({
exportTemplate: state.resources.templates.exportTemplate.item,
status: state.resources.templates.exportTemplate.status,
})
const mdtp = {
convertToTemplate: convertToTemplateAction,
clearExportTemplate: clearExportTemplateAction,
}
const connector = connect(mstp, mdtp)
export default connector(withRouter(TemplateExportOverlay))

View File

@ -0,0 +1,91 @@
import React, {PureComponent} from 'react'
import {withRouter, RouteComponentProps} from 'react-router-dom'
import {connect, ConnectedProps} from 'react-redux'
// Components
import ImportOverlay from 'src/shared/components/ImportOverlay'
// Copy
import {invalidJSON} from 'src/shared/copy/notifications'
// Actions
import {createTemplate as createTemplateAction} from 'src/templates/actions/thunks'
import {notify as notifyAction} from 'src/shared/actions/notifications'
// Types
import {AppState, ResourceType, Organization} from 'src/types'
import {ComponentStatus} from '@influxdata/clockface'
// Utils
import jsonlint from 'jsonlint-mod'
import {getByID} from 'src/resources/selectors'
interface State {
status: ComponentStatus
}
type ReduxProps = ConnectedProps<typeof connector>
type RouterProps = RouteComponentProps<{orgID: string}>
type Props = ReduxProps & RouterProps
class TemplateImportOverlay extends PureComponent<Props> {
public state: State = {
status: ComponentStatus.Default,
}
public render() {
return (
<ImportOverlay
onDismissOverlay={this.onDismiss}
resourceName="Template"
onSubmit={this.handleImportTemplate}
status={this.state.status}
updateStatus={this.updateOverlayStatus}
/>
)
}
private onDismiss = () => {
const {history} = this.props
history.goBack()
}
private updateOverlayStatus = (status: ComponentStatus) =>
this.setState(() => ({status}))
private handleImportTemplate = (importString: string) => {
const {createTemplate, notify} = this.props
let template
this.updateOverlayStatus(ComponentStatus.Default)
try {
template = jsonlint.parse(importString)
} catch (error) {
this.updateOverlayStatus(ComponentStatus.Error)
notify(invalidJSON(error.message))
return
}
createTemplate(template)
this.onDismiss()
}
}
const mstp = (state: AppState, props: RouterProps) => {
const org = getByID<Organization>(
state,
ResourceType.Orgs,
props.match.params.orgID
)
return {org}
}
const mdtp = {
notify: notifyAction,
createTemplate: createTemplateAction,
}
const connector = connect(mstp, mdtp)
export default connector(withRouter(TemplateImportOverlay))

View File

@ -0,0 +1,81 @@
import React, {PureComponent} from 'react'
import {connect, ConnectedProps} from 'react-redux'
import {withRouter, RouteComponentProps} from 'react-router-dom'
import _ from 'lodash'
// Components
import ViewOverlay from 'src/shared/components/ViewOverlay'
import {ErrorHandling} from 'src/shared/decorators/errors'
// Actions
import {
convertToTemplate as convertToTemplateAction,
clearExportTemplate as clearExportTemplateAction,
} from 'src/templates/actions/thunks'
// Types
import {AppState} from 'src/types'
interface OwnProps {
match: {id: string}
}
type ReduxProps = ConnectedProps<typeof connector>
type Props = OwnProps &
ReduxProps &
RouteComponentProps<{orgID: string; id: string}>
@ErrorHandling
class TemplateExportOverlay extends PureComponent<Props> {
public componentDidMount() {
const {
match: {
params: {id},
},
convertToTemplate,
} = this.props
convertToTemplate(id)
}
public render() {
const {exportTemplate, status} = this.props
return (
<ViewOverlay
resource={exportTemplate}
overlayHeading={this.overlayTitle}
onDismissOverlay={this.onDismiss}
status={status}
/>
)
}
private get overlayTitle() {
const {exportTemplate} = this.props
if (exportTemplate) {
return exportTemplate.meta.name
}
return ''
}
private onDismiss = () => {
const {history, clearExportTemplate} = this.props
history.goBack()
clearExportTemplate()
}
}
const mstp = (state: AppState) => ({
exportTemplate: state.resources.templates.exportTemplate.item,
status: state.resources.templates.exportTemplate.status,
})
const mdtp = {
convertToTemplate: convertToTemplateAction,
clearExportTemplate: clearExportTemplateAction,
}
const connector = connect(mstp, mdtp)
export default connector(withRouter(TemplateExportOverlay))

View File

@ -0,0 +1,76 @@
// Libraries
import React, {PureComponent} from 'react'
import _ from 'lodash'
import memoizeOne from 'memoize-one'
// Components
import {ResourceList} from '@influxdata/clockface'
import EmptyTemplatesList from 'src/templates/components/EmptyTemplatesList'
import TemplateCard from 'src/templates/components/TemplateCard'
// Types
import {TemplateSummary} from 'src/types'
import {SortTypes} from 'src/shared/utils/sort'
import {Sort} from 'src/clockface'
// Selectors
import {getSortedResources} from 'src/shared/utils/sort'
interface Props {
templates: TemplateSummary[]
searchTerm: string
onFilterChange: (searchTerm: string) => void
onImport: () => void
sortKey: string
sortDirection: Sort
sortType: SortTypes
}
export default class TemplatesList extends PureComponent<Props> {
private memGetSortedResources = memoizeOne<typeof getSortedResources>(
getSortedResources
)
public render() {
const {searchTerm, onImport} = this.props
return (
<>
<ResourceList>
<ResourceList.Body
emptyState={
<EmptyTemplatesList searchTerm={searchTerm} onImport={onImport} />
}
>
{this.rows}
</ResourceList.Body>
</ResourceList>
</>
)
}
private get rows(): JSX.Element[] {
const {
templates,
sortKey,
sortDirection,
sortType,
onFilterChange,
} = this.props
const sortedTemplates = this.memGetSortedResources(
templates,
sortKey,
sortDirection,
sortType
)
return sortedTemplates.map(t => (
<TemplateCard
key={`template-id--${t.id}`}
template={t}
onFilterChange={onFilterChange}
/>
))
}
}

View File

@ -0,0 +1,219 @@
// Libraries
import React, {PureComponent} from 'react'
import _ from 'lodash'
import {connect, ConnectedProps} from 'react-redux'
// Components
import FilterList from 'src/shared/components/FilterList'
import TemplatesList from 'src/templates/components/TemplatesList'
import StaticTemplatesList, {
StaticTemplate,
TemplateOrSummary,
} from 'src/templates/components/StaticTemplatesList'
import {ErrorHandling} from 'src/shared/decorators/errors'
import SearchWidget from 'src/shared/components/search_widget/SearchWidget'
import GetResources from 'src/resources/components/GetResources'
import TabbedPageHeader from 'src/shared/components/tabbed_page/TabbedPageHeader'
import ResourceSortDropdown from 'src/shared/components/resource_sort_dropdown/ResourceSortDropdown'
// Types
import {AppState, ResourceType, TemplateSummary} from 'src/types'
import {SortTypes} from 'src/shared/utils/sort'
import {
Sort,
Button,
ComponentColor,
IconFont,
SelectGroup,
} from '@influxdata/clockface'
import {TemplateSortKey} from 'src/shared/components/resource_sort_dropdown/generateSortItems'
import {staticTemplates as statics} from 'src/templates/constants/defaultTemplates'
// Selectors
import {getAll} from 'src/resources/selectors/getAll'
// Constants
const staticTemplates: StaticTemplate[] = _.map(statics, (template, name) => ({
name,
template: template as TemplateOrSummary,
}))
interface OwnProps {
onImport: () => void
}
type ReduxProps = ConnectedProps<typeof connector>
type Props = OwnProps & ReduxProps
interface State {
searchTerm: string
sortKey: TemplateSortKey
sortDirection: Sort
sortType: SortTypes
activeTab: string
}
const FilterStaticTemplates = FilterList<StaticTemplate>()
const FilterTemplateSummaries = FilterList<TemplateSummary>()
@ErrorHandling
class TemplatesPage extends PureComponent<Props, State> {
constructor(props) {
super(props)
this.state = {
searchTerm: '',
sortKey: 'meta.name',
sortDirection: Sort.Ascending,
sortType: SortTypes.String,
activeTab: 'static-templates',
}
}
public render() {
const {onImport} = this.props
const {activeTab, sortType, sortKey, sortDirection} = this.state
const leftHeaderItems = (
<>
{this.filterComponent}
<SelectGroup>
<SelectGroup.Option
name="template-type"
id="static-templates"
active={activeTab === 'static-templates'}
value="static-templates"
onClick={this.handleClickTab}
titleText="Static Templates"
>
Static Templates
</SelectGroup.Option>
<SelectGroup.Option
name="template-type"
id="user-templates"
active={activeTab === 'user-templates'}
value="user-templates"
onClick={this.handleClickTab}
titleText="User Templates"
>
User Templates
</SelectGroup.Option>
</SelectGroup>
<ResourceSortDropdown
resourceType={ResourceType.Templates}
sortType={sortType}
sortKey={sortKey}
sortDirection={sortDirection}
onSelect={this.handleSort}
/>
</>
)
return (
<>
<TabbedPageHeader
childrenLeft={leftHeaderItems}
childrenRight={
<Button
text="Import Template"
icon={IconFont.Plus}
color={ComponentColor.Primary}
onClick={onImport}
/>
}
/>
{this.templatesList}
</>
)
}
private handleClickTab = val => {
this.setState({activeTab: val})
}
private handleSort = (
sortKey: TemplateSortKey,
sortDirection: Sort,
sortType: SortTypes
): void => {
this.setState({sortKey, sortDirection, sortType})
}
private get templatesList(): JSX.Element {
const {templates, onImport} = this.props
const {searchTerm, sortKey, sortDirection, sortType, activeTab} = this.state
if (activeTab === 'static-templates') {
return (
<FilterStaticTemplates
searchTerm={searchTerm}
searchKeys={['template.meta.name', 'labels[].name']}
list={staticTemplates}
>
{ts => {
return (
<StaticTemplatesList
searchTerm={searchTerm}
templates={ts}
onFilterChange={this.setSearchTerm}
onImport={onImport}
sortKey={sortKey}
sortDirection={sortDirection}
sortType={sortType}
/>
)
}}
</FilterStaticTemplates>
)
}
if (activeTab === 'user-templates') {
return (
<GetResources resources={[ResourceType.Labels]}>
<FilterTemplateSummaries
searchTerm={searchTerm}
searchKeys={['meta.name', 'labels[].name']}
list={templates}
>
{ts => {
return (
<TemplatesList
searchTerm={searchTerm}
templates={ts}
onFilterChange={this.setSearchTerm}
onImport={onImport}
sortKey={sortKey}
sortDirection={sortDirection}
sortType={sortType}
/>
)
}}
</FilterTemplateSummaries>
</GetResources>
)
}
}
private get filterComponent(): JSX.Element {
const {searchTerm} = this.state
return (
<SearchWidget
placeholderText="Filter templates..."
onSearch={this.setSearchTerm}
searchTerm={searchTerm}
/>
)
}
private setSearchTerm = (searchTerm: string) => {
this.setState({searchTerm})
}
}
const mstp = (state: AppState) => ({
templates: getAll(state, ResourceType.Templates),
})
const connector = connect(mstp)
export default connector(TemplatesPage)

View File

@ -0,0 +1,91 @@
.import-template-overlay,
.import-template-overlay--empty {
height: 500px;
display: flex;
}
.import-template-overlay {
align-items: stretch;
}
.import-template-overlay--templates {
flex: 2 0 0;
border-radius: $ix-radius;
}
.import-template-overlay--details {
flex: 5 0 0;
margin-left: $ix-marg-b;
}
.import-template-overlay--panel {
min-height: 500px;
}
.import-template-overlay--name {
margin-top: 0;
margin-bottom: $ix-marg-b;
}
.import-template-overlay--description {
margin-top: 0;
margin-bottom: $ix-marg-d;
}
.import-template-overlay--heading {
margin-top: 0;
border-bottom: $ix-border solid $g5-pepper;
padding-bottom: $ix-marg-b;
}
.import-templates-overlay--included {
margin-bottom: 0;
}
.import-template-overlay--name.missing,
.import-template-overlay--included.missing,
.import-template-overlay--description.missing {
font-style: italic;
color: $g9-mountain;
}
.import-template-overlay--empty {
background-color: $g3-castle;
border-radius: $ix-radius;
align-content: center;
align-items: center;
justify-content: center;
}
.import-template-overlay--template {
user-select: none;
border-radius: $ix-radius;
padding: $ix-marg-b;
background-color: $g1-raven;
margin-bottom: $ix-border;
border: $ix-border solid $g1-raven;
color: $g11-sidewalk;
display: flex;
flex-wrap: none;
align-items: center;
transition: color 0.25s ease, background-color 0.25s ease, border-color 0.25s ease;
&:hover {
border-color: $g5-pepper;
background-color: $g5-pepper;
color: $g18-cloud;
cursor: pointer;
}
&.active {
border-color: $c-rainforest;
color: $g18-cloud;
background-color: $g5-pepper;
}
}
.import-template-overlay--list-label {
font-size: 13px;
font-weight: 500;
margin-left: $ix-marg-b;
}

View File

@ -0,0 +1,210 @@
// Libraries
import React, {PureComponent} from 'react'
import {withRouter, RouteComponentProps} from 'react-router-dom'
import {connect, ConnectedProps} from 'react-redux'
import {sortBy} from 'lodash'
// Components
import {
Button,
ComponentColor,
ComponentStatus,
Overlay,
} from '@influxdata/clockface'
import TemplateBrowser from 'src/templates/components/createFromTemplateOverlay/TemplateBrowser'
import TemplateBrowserEmpty from 'src/templates/components/createFromTemplateOverlay/TemplateBrowserEmpty'
import GetResources from 'src/resources/components/GetResources'
// Actions
import {createDashboardFromTemplate as createDashboardFromTemplateAction} from 'src/dashboards/actions/thunks'
import {getTemplateByID} from 'src/templates/actions/thunks'
// Constants
import {influxdbTemplateList} from 'src/templates/constants/defaultTemplates'
// Types
import {
TemplateSummary,
Template,
TemplateType,
DashboardTemplateIncluded,
AppState,
DashboardTemplate,
ResourceType,
} from 'src/types'
// Selectors
import {getAll} from 'src/resources/selectors/getAll'
interface State {
selectedTemplateSummary: TemplateSummary
selectedTemplate: Template
variables: string[]
cells: string[]
}
type ReduxProps = ConnectedProps<typeof connector>
type Props = ReduxProps
class DashboardImportFromTemplateOverlay extends PureComponent<
Props & RouteComponentProps<{orgID: string}>,
State
> {
constructor(props) {
super(props)
this.state = {
selectedTemplateSummary: null,
selectedTemplate: null,
variables: [],
cells: [],
}
}
render() {
return (
<GetResources resources={[ResourceType.Templates]}>
<Overlay visible={true}>
<Overlay.Container maxWidth={900}>
<Overlay.Header
title="Create Dashboard from a Template"
onDismiss={this.onDismiss}
/>
<Overlay.Body>{this.overlayBody}</Overlay.Body>
<Overlay.Footer>
<Button
text="Cancel"
onClick={this.onDismiss}
key="cancel-button"
/>
<Button
text="Create Dashboard"
onClick={this.onSubmit}
key="submit-button"
testID="create-dashboard-button"
color={ComponentColor.Success}
status={this.submitStatus}
/>
</Overlay.Footer>
</Overlay.Container>
</Overlay>
</GetResources>
)
}
private get overlayBody(): JSX.Element {
const {
selectedTemplateSummary,
cells,
variables,
selectedTemplate,
} = this.state
const {templates} = this.props
if (!templates.length) {
return <TemplateBrowserEmpty />
}
return (
<TemplateBrowser
templates={templates}
cells={cells}
variables={variables}
selectedTemplate={selectedTemplate}
selectedTemplateSummary={selectedTemplateSummary}
onSelectTemplate={this.handleSelectTemplate}
/>
)
}
private get submitStatus(): ComponentStatus {
const {selectedTemplate} = this.state
return selectedTemplate ? ComponentStatus.Default : ComponentStatus.Disabled
}
private getVariablesForTemplate(template: Template): string[] {
const variables = []
const included = template.content.included as DashboardTemplateIncluded[]
included.forEach(data => {
if (data.type === TemplateType.Variable) {
variables.push(data.attributes.name)
}
})
return variables
}
private getCellsForTemplate(template: Template): string[] {
const cells = []
const included = template.content.included as DashboardTemplateIncluded[]
included.forEach(data => {
if (data.type === TemplateType.View) {
cells.push(data.attributes.name)
}
})
return cells
}
private handleSelectTemplate = async (
selectedTemplateSummary: TemplateSummary
): Promise<void> => {
const {id} = selectedTemplateSummary
let selectedTemplate
if (!id.includes('influxdb-template')) {
selectedTemplate = await getTemplateByID(id)
} else {
selectedTemplate = selectedTemplateSummary
}
this.setState({
selectedTemplateSummary,
selectedTemplate,
variables: this.getVariablesForTemplate(selectedTemplate),
cells: this.getCellsForTemplate(selectedTemplate),
})
}
private onDismiss = () => {
const {history} = this.props
history.goBack()
}
private onSubmit = () => {
const {createDashboardFromTemplate} = this.props
const dashboardTemplate = this.state.selectedTemplate as DashboardTemplate
createDashboardFromTemplate(dashboardTemplate)
this.onDismiss()
}
}
const mstp = (state: AppState) => {
const {
resources: {
templates: {status},
},
} = state
const items = getAll(state, ResourceType.Templates)
const filteredTemplates = items.filter(
t => !t.meta.type || t.meta.type === TemplateType.Dashboard
)
const templates = sortBy(filteredTemplates, item =>
item.meta.name.toLocaleLowerCase()
)
return {
templates: [...templates, ...(influxdbTemplateList as any)],
templateStatus: status,
}
}
const mdtp = {
createDashboardFromTemplate: createDashboardFromTemplateAction,
}
const connector = connect(mstp, mdtp)
export default connector(withRouter(DashboardImportFromTemplateOverlay))

View File

@ -0,0 +1,48 @@
// Libraries
import React, {PureComponent} from 'react'
import _ from 'lodash'
// Components
import {TemplateSummary, Template} from 'src/types'
import TemplateBrowserDetails from 'src/templates/components/createFromTemplateOverlay/TemplateBrowserDetails'
import TemplateBrowserList from 'src/templates/components/createFromTemplateOverlay/TemplateBrowserList'
interface Props {
templates: TemplateSummary[]
selectedTemplateSummary: TemplateSummary
selectedTemplate: Template
variables?: string[]
cells?: string[]
onSelectTemplate: (selectedTemplateSummary: TemplateSummary) => void
}
class TemplateBrowser extends PureComponent<Props> {
public render() {
const {
selectedTemplateSummary,
cells,
variables,
selectedTemplate,
templates,
onSelectTemplate,
} = this.props
return (
<div className="import-template-overlay">
<TemplateBrowserList
templates={templates}
onSelectTemplate={onSelectTemplate}
selectedTemplateSummary={selectedTemplateSummary}
/>
<TemplateBrowserDetails
cells={cells}
variables={variables}
selectedTemplateSummary={selectedTemplateSummary}
selectedTemplate={selectedTemplate}
/>
</div>
)
}
}
export default TemplateBrowser

View File

@ -0,0 +1,146 @@
// Libraries
import React, {PureComponent} from 'react'
import _ from 'lodash'
// Components
import {
Panel,
EmptyState,
ComponentSize,
Grid,
Columns,
DapperScrollbars,
} from '@influxdata/clockface'
import {TemplateSummary, Template} from 'src/types'
interface Props {
selectedTemplateSummary: TemplateSummary
selectedTemplate: Template
variables: string[]
cells: string[]
}
class TemplateBrowserDetails extends PureComponent<Props> {
public render() {
return (
<DapperScrollbars
className="import-template-overlay--details"
autoSize={false}
>
<Panel
testID="template-panel"
className="import-template-overlay--panel"
>
<Panel.Body size={ComponentSize.Medium}>
{this.panelContents}
</Panel.Body>
</Panel>
</DapperScrollbars>
)
}
private get panelContents(): JSX.Element {
const {selectedTemplateSummary} = this.props
if (!selectedTemplateSummary) {
return (
<EmptyState size={ComponentSize.Medium}>
<EmptyState.Text>Select a Template from the left</EmptyState.Text>
</EmptyState>
)
}
return (
<Grid>
<Grid.Row>
<Grid.Column widthSM={Columns.Twelve}>
{this.templateName}
{this.templateDescription}
</Grid.Column>
{this.props.variables && (
<Grid.Column widthSM={Columns.Six}>
<h5 className="import-template-overlay--heading">Variables</h5>
{this.variablesList}
</Grid.Column>
)}
{this.props.cells && (
<Grid.Column widthSM={Columns.Six}>
<h5 className="import-template-overlay--heading">Cells</h5>
{this.cellsList}
</Grid.Column>
)}
</Grid.Row>
</Grid>
)
}
private get variablesList(): JSX.Element | JSX.Element[] {
const {variables} = this.props
if (!variables.length) {
return (
<p className="import-template-overlay--included missing">
No included variables
</p>
)
}
return variables.map((variable, i) => (
<p
className="import-templates-overlay--included"
key={`${i} ${variable}`}
>
{variable}
</p>
))
}
private get cellsList(): JSX.Element | JSX.Element[] {
const {cells} = this.props
if (!cells.length) {
return (
<p className="import-template-overlay--included missing">
No included cells
</p>
)
}
return cells.map((cell, i) => (
<p className="import-templates-overlay--included" key={`${i} ${cell}`}>
{cell}
</p>
))
}
private get templateDescription(): JSX.Element {
const {selectedTemplateSummary} = this.props
const description = _.get(selectedTemplateSummary, 'meta.description')
if (description) {
return (
<p className="import-template-overlay--description">{description}</p>
)
}
return (
<p className="import-template-overlay--description missing">
No description
</p>
)
}
private get templateName(): JSX.Element {
const {selectedTemplateSummary} = this.props
const name = _.get(selectedTemplateSummary, 'meta.name')
const templateName = name || 'Untitled'
const className = name
? 'import-template-overlay--name'
: 'import-template-overlay--name missing'
return <h3 className={className}>{templateName}</h3>
}
}
export default TemplateBrowserDetails

View File

@ -0,0 +1,60 @@
// Libraries
import React, {PureComponent} from 'react'
import {withRouter, RouteComponentProps} from 'react-router-dom'
import {connect} from 'react-redux'
// Components
import {
EmptyState,
ComponentSize,
Button,
IconFont,
} from '@influxdata/clockface'
// Types
import {AppState, Organization} from 'src/types'
// Selectors
import {getOrg} from 'src/organizations/selectors'
interface StateProps {
org: Organization
}
type Props = StateProps & RouteComponentProps<{orgID: string}>
class TemplateBrowserEmpty extends PureComponent<Props> {
public render() {
return (
<div className="import-template-overlay--empty">
<EmptyState size={ComponentSize.Large}>
<EmptyState.Text>
Looks like you don't have any <b>Templates</b> yet, why not import
one?
</EmptyState.Text>
<Button
size={ComponentSize.Medium}
text="Go to Templates Settings"
icon={IconFont.CogThick}
onClick={this.handleButtonClick}
/>
</EmptyState>
</div>
)
}
private handleButtonClick = (): void => {
const {history, org} = this.props
history.push(`/orgs/${org.id}/settings/templates`)
}
}
const mstp = (state: AppState) => ({
org: getOrg(state),
})
export default connect<StateProps, {}>(
mstp,
null
)(withRouter(TemplateBrowserEmpty))

View File

@ -0,0 +1,43 @@
// Libraries
import React, {PureComponent} from 'react'
import {get, orderBy} from 'lodash'
// Components
import {DapperScrollbars} from '@influxdata/clockface'
import {TemplateSummary} from 'src/types'
import TemplateBrowserListItem from 'src/templates/components/createFromTemplateOverlay/TemplateBrowserListItem'
interface Props {
templates: TemplateSummary[]
selectedTemplateSummary: TemplateSummary
onSelectTemplate: (selectedTemplateSummary: TemplateSummary) => void
}
class TemplateBrowser extends PureComponent<Props> {
public render() {
const {selectedTemplateSummary, templates, onSelectTemplate} = this.props
return (
<DapperScrollbars
className="import-template-overlay--templates"
autoSize={false}
noScrollX={true}
>
{orderBy(templates, [({meta: {name}}) => name.toLocaleLowerCase()]).map(
t => (
<TemplateBrowserListItem
key={t.id}
template={t}
label={t.meta.name}
onClick={onSelectTemplate}
testID={`template--${t.meta.name}`}
selected={get(selectedTemplateSummary, 'id', '') === t.id}
/>
)
)}
</DapperScrollbars>
)
}
}
export default TemplateBrowser

View File

@ -0,0 +1,49 @@
// Libraries
import React, {PureComponent} from 'react'
import classnames from 'classnames'
import {TemplateSummary} from 'src/types'
// Components
import {Icon, IconFont} from '@influxdata/clockface'
interface Props {
onClick: (template: TemplateSummary) => void
template: TemplateSummary
label: string
selected: boolean
testID: string
}
class TemplateBrowser extends PureComponent<Props> {
public render() {
const {testID, label} = this.props
return (
<div
className={this.className}
data-testid={testID}
onClick={this.handleClick}
>
<Icon
glyph={IconFont.Cube}
className="import-template-overlay--list-icon"
/>
<span className="import-template-overlay--list-label">{label}</span>
</div>
)
}
private get className(): string {
const {selected} = this.props
return classnames('import-template-overlay--template', {active: selected})
}
private handleClick = (): void => {
const {onClick, template} = this.props
onClick(template)
}
}
export default TemplateBrowser

View File

@ -13,6 +13,7 @@ import {notify} from 'src/shared/actions/notifications'
import {ErrorHandling} from 'src/shared/decorators/errors'
import {CommunityTemplateImportOverlay} from 'src/templates/components/CommunityTemplateImportOverlay'
import {CommunityTemplatesInstalledList} from 'src/templates/components/CommunityTemplatesInstalledList'
import {
Bullet,
Button,
@ -28,13 +29,16 @@ import {
} from '@influxdata/clockface'
import SettingsTabbedPage from 'src/settings/components/SettingsTabbedPage'
import SettingsHeader from 'src/settings/components/SettingsHeader'
import {communityTemplatesImportPath} from 'src/templates/containers/TemplatesIndex'
import GetResources from 'src/resources/components/GetResources'
import {getOrg} from 'src/organizations/selectors'
import {setStagedTemplateUrl} from 'src/templates/actions/creators'
// Utils
import {pageTitleSuffixer} from 'src/shared/utils/pageTitles'
import {getOrg} from 'src/organizations/selectors'
import {
getGithubUrlFromTemplateDetails,
getTemplateNameFromUrl,
@ -43,15 +47,14 @@ import {reportError} from 'src/shared/utils/errors'
import {communityTemplateUnsupportedFormatError} from 'src/shared/copy/notifications'
import {event} from 'src/cloud/utils/reporting'
// Types
import {AppState, ResourceType} from 'src/types'
import {event} from 'src/cloud/utils/reporting'
const communityTemplatesUrl =
'https://github.com/influxdata/community-templates#templates'
const templatesPath = '/orgs/:orgID/settings/templates'
const communityTemplatesImportPath = `${templatesPath}/import/:directory/:templateName/:templateExtension`
type Params = {
params: {directory: string; templateName: string; templateExtension: string}

View File

@ -0,0 +1,88 @@
import React, {Component} from 'react'
import {RouteComponentProps} from 'react-router-dom'
import {connect, ConnectedProps} from 'react-redux'
import {Switch, Route} from 'react-router-dom'
// Components
import {ErrorHandling} from 'src/shared/decorators/errors'
import {Page} from '@influxdata/clockface'
import SettingsTabbedPage from 'src/settings/components/SettingsTabbedPage'
import SettingsHeader from 'src/settings/components/SettingsHeader'
import TemplatesPage from 'src/templates/components/TemplatesPage'
import GetResources from 'src/resources/components/GetResources'
import TemplateImportOverlay from 'src/templates/components/TemplateImportOverlay'
import TemplateExportOverlay from 'src/templates/components/TemplateExportOverlay'
import TemplateViewOverlay from 'src/templates/components/TemplateViewOverlay'
import StaticTemplateViewOverlay from 'src/templates/components/StaticTemplateViewOverlay'
import {CommunityTemplatesIndex} from 'src/templates/containers/CommunityTemplatesIndex'
// Utils
import {pageTitleSuffixer} from 'src/shared/utils/pageTitles'
import {getOrg} from 'src/organizations/selectors'
// Types
import {AppState, ResourceType} from 'src/types'
type ReduxProps = ConnectedProps<typeof connector>
type Props = RouteComponentProps & ReduxProps
const templatesPath = '/orgs/:orgID/settings/templates'
export const communityTemplatesImportPath = `${templatesPath}/import/:directory/:templateName/:templateExtension`
@ErrorHandling
class TemplatesIndex extends Component<Props> {
public render() {
const {org, flags} = this.props
if (flags.communityTemplates) {
return <CommunityTemplatesIndex />
}
return (
<>
<Page titleTag={pageTitleSuffixer(['Templates', 'Settings'])}>
<SettingsHeader />
<SettingsTabbedPage activeTab="templates" orgID={org.id}>
<GetResources resources={[ResourceType.Templates]}>
<TemplatesPage onImport={this.handleImport} />
</GetResources>
</SettingsTabbedPage>
</Page>
<Switch>
<Route
path={`${templatesPath}/import`}
component={TemplateImportOverlay}
/>
<Route
path={`${templatesPath}/:id/export`}
component={TemplateExportOverlay}
/>
<Route
path={`${templatesPath}/:id/view`}
component={TemplateViewOverlay}
/>
<Route
path={`${templatesPath}/:id/static/view`}
component={StaticTemplateViewOverlay}
/>
</Switch>
</>
)
}
private handleImport = () => {
const {history, org} = this.props
history.push(`/orgs/${org.id}/settings/templates/import`)
}
}
const mstp = (state: AppState) => {
return {
org: getOrg(state),
flags: state.flags.original,
}
}
const connector = connect(mstp)
export default connector(TemplatesIndex)

View File

@ -0,0 +1,131 @@
// Libraries
import {normalize} from 'normalizr'
// Schema
import {templateSchema, arrayOfTemplates} from 'src/schemas/templates'
// Reducer
import {templatesReducer as reducer} from 'src/templates/reducers'
// Actions
import {
addTemplateSummary,
populateTemplateSummaries,
removeTemplateSummary,
setTemplateSummary,
} from 'src/templates/actions/creators'
// Types
import {
CommunityTemplate,
RemoteDataState,
TemplateSummaryEntities,
TemplateSummary,
} from 'src/types'
const status = RemoteDataState.Done
const templateSummary = {
links: {
self: '/api/v2/documents/templates/051ff6b3a8d23000',
},
id: '1',
meta: {
name: 'foo',
type: 'dashboard',
description: 'A template dashboard for something',
version: '1',
},
labels: [],
status,
}
const exportTemplate = {status, item: null}
const stagedCommunityTemplate: CommunityTemplate = {}
const initialState = () => ({
stagedCommunityTemplate,
stagedTemplateUrl: '',
status,
byID: {
['1']: templateSummary,
['2']: {...templateSummary, id: '2'},
},
allIDs: [templateSummary.id, '2'],
exportTemplate,
stacks: [],
})
describe('templates reducer', () => {
it('can set the templatess', () => {
const schema = normalize<
TemplateSummary,
TemplateSummaryEntities,
string[]
>([templateSummary], arrayOfTemplates)
const byID = schema.entities.templates
const allIDs = schema.result
const actual = reducer(undefined, populateTemplateSummaries(schema))
expect(actual.byID).toEqual(byID)
expect(actual.allIDs).toEqual(allIDs)
})
it('can add a template', () => {
const id = '3'
const anotherTemplateSummary = {...templateSummary, id}
const schema = normalize<TemplateSummary, TemplateSummaryEntities, string>(
anotherTemplateSummary,
templateSchema
)
const state = initialState()
const actual = reducer(state, addTemplateSummary(schema))
expect(actual.allIDs.length).toEqual(Number(id))
})
it('can remove a template', () => {
const allIDs = [templateSummary.id]
const byID = {[templateSummary.id]: templateSummary}
const state = initialState()
const expected = {
status,
byID,
allIDs,
exportTemplate,
stagedCommunityTemplate,
stagedTemplateUrl: '',
stacks: [],
}
const actual = reducer(state, removeTemplateSummary(state.allIDs[1]))
expect(actual).toEqual(expected)
})
it('can set a template', () => {
const name = 'updated name'
const loadedTemplateSummary = {
...templateSummary,
meta: {...templateSummary.meta, name: 'updated name'},
}
const schema = normalize<TemplateSummary, TemplateSummaryEntities, string>(
loadedTemplateSummary,
templateSchema
)
const state = initialState()
const actual = reducer(
state,
setTemplateSummary(templateSummary.id, RemoteDataState.Done, schema)
)
expect(actual.byID[templateSummary.id].meta.name).toEqual(name)
})
})

View File

@ -1,12 +1,30 @@
import {produce} from 'immer'
import {
Action,
ADD_TEMPLATE_SUMMARY,
POPULATE_TEMPLATE_SUMMARIES,
REMOVE_TEMPLATE_SUMMARY,
SET_EXPORT_TEMPLATE,
SET_STACKS,
SET_STAGED_TEMPLATE,
SET_STAGED_TEMPLATE_URL,
SET_TEMPLATES_STATUS,
SET_TEMPLATE_SUMMARY,
TOGGLE_TEMPLATE_RESOURCE_INSTALL,
} from 'src/templates/actions/creators'
import {CommunityTemplate, RemoteDataState, TemplatesState} from 'src/types'
import {
CommunityTemplate,
ResourceType,
RemoteDataState,
TemplateSummary,
TemplatesState,
} from 'src/types'
import {
addResource,
removeResource,
setResource,
setResourceAtID,
} from 'src/resources/reducers/helpers'
const defaultCommunityTemplate = (): CommunityTemplate => {
return {
@ -37,6 +55,28 @@ export const templatesReducer = (
): TemplatesState =>
produce(state, draftState => {
switch (action.type) {
case POPULATE_TEMPLATE_SUMMARIES: {
setResource<TemplateSummary>(draftState, action, ResourceType.Templates)
return
}
case SET_TEMPLATES_STATUS: {
const {status} = action
draftState.status = status
return
}
case SET_TEMPLATE_SUMMARY: {
setResourceAtID<TemplateSummary>(
draftState,
action,
ResourceType.Templates
)
return
}
case SET_STAGED_TEMPLATE_URL: {
const {templateUrl} = action
@ -137,6 +177,30 @@ export const templatesReducer = (
return
}
case SET_EXPORT_TEMPLATE: {
const {status, item} = action
draftState.exportTemplate.status = status
if (item) {
draftState.exportTemplate.item = item
} else {
draftState.exportTemplate.item = null
}
return
}
case REMOVE_TEMPLATE_SUMMARY: {
removeResource<TemplateSummary>(draftState, action)
return
}
case ADD_TEMPLATE_SUMMARY: {
addResource<TemplateSummary>(draftState, action, ResourceType.Templates)
return
}
case TOGGLE_TEMPLATE_RESOURCE_INSTALL: {
const {resourceType, shouldInstall, templateMetaName} = action

View File

@ -49,43 +49,49 @@ describe('Templates utils', () => {
})
describe('the Community Template url utilities', () => {
it('returns the template name and extension from an arbitrary url', () => {
const {name, extension} = getTemplateNameFromUrl(
it('returns the template name, directory, and extension from an arbitrary url', () => {
const {name, extension, directory} = getTemplateNameFromUrl(
'https://github.com/influxdata/influxdb/blob/master/pkger/testdata/dashboard_params.yml'
)
expect(name).toBe('dashboard_params')
expect(extension).toBe('yml')
expect(directory).toBe('testdata')
})
it('returns the template name and extension from the official community templates github repo', () => {
const {name, extension} = getTemplateNameFromUrl(
'https://github.com/influxdata/community-templates/blob/master/csgo/csgo.yml'
const {name, extension, directory} = getTemplateNameFromUrl(
'https://github.com/influxdata/community-templates/blob/master/hooray/csgo.yml'
)
expect(name).toBe('csgo')
expect(extension).toBe('yml')
expect(directory).toBe('hooray')
})
it('returns the template name and extension from the official community templates github repo when the extension is not yml', () => {
const {name, extension} = getTemplateNameFromUrl(
const {name, extension, directory} = getTemplateNameFromUrl(
'https://github.com/influxdata/community-templates/blob/master/csgo/csgo.json'
)
expect(name).toBe('csgo')
expect(extension).toBe('json')
expect(directory).toBe('csgo')
})
it('returns the template name and extension from arbitrary urls', () => {
const {name, extension} = getTemplateNameFromUrl(
const {name, extension, directory} = getTemplateNameFromUrl(
'https://www.example.com/csgo/csgo.json'
)
expect(name).toBe('csgo')
expect(extension).toBe('json')
expect(directory).toBe('csgo')
})
it('handles non secure arbitrary urls', () => {
const {name, extension} = getTemplateNameFromUrl(
'http://www.example.com/blog/cats/catstuff/memes/csgo/downsampling.yml'
const {name, extension, directory} = getTemplateNameFromUrl(
'http://www.example.com/blog/cats/catstuff/memes/-------/downsampling.yml'
)
expect(name).toBe('downsampling')
expect(extension).toBe('yml')
expect(directory).toBe('-------')
})
})

View File

@ -71,6 +71,9 @@ export const getLabelRelationships = (resource: {
return [].concat(resource.relationships[TemplateType.Label].data)
}
export const getIncludedLabels = (included: {type: TemplateType}[]) =>
included.filter((i): i is LabelIncluded => i.type === TemplateType.Label)
export interface TemplateDetails {
directory: string
templateExtension: string
@ -100,10 +103,12 @@ const getTemplateDetailsFromFileSource = (_source: string): TemplateDetails => {
export const getTemplateNameFromUrl = (
url: string
): {name: string; extension: string} => {
const fullName = url.split('/').pop()
): {name: string; extension: string; directory: string} => {
const urlSplit = url.split('/')
const fullName = urlSplit.pop()
const directory = urlSplit.pop()
const [name, extension] = fullName.split('.')
return {name, extension}
return {name, extension, directory}
}
export const getTemplateDetails = (source: string): TemplateDetails => {

View File

@ -4,6 +4,7 @@ import {get} from 'lodash'
// Actions
import {notify} from 'src/shared/actions/notifications'
import {setExportTemplate} from 'src/templates/actions/creators'
import {
setVariables,
setVariable,
@ -19,6 +20,7 @@ import {variableSchema, arrayOfVariables} from 'src/schemas/variables'
// APIs
import * as api from 'src/client'
import {hydrateVars} from 'src/variables/utils/hydrateVars'
import {createVariableFromTemplate as createVariableFromTemplateAJAX} from 'src/templates/api'
// Utils
import {
@ -27,6 +29,8 @@ import {
getAllVariables as getAllVariablesFromState,
normalizeValues,
} from 'src/variables/selectors'
import {variableToTemplate} from 'src/shared/utils/resourceToTemplate'
import {findDependentVariables} from 'src/variables/utils/exportVariables'
import {getOrg} from 'src/organizations/selectors'
import {getLabels, getStatus} from 'src/resources/selectors'
import {currentContext} from 'src/shared/selectors/currentContext'
@ -41,6 +45,7 @@ import {
AppState,
GetState,
RemoteDataState,
VariableTemplate,
Label,
GenVariable,
Variable,
@ -254,6 +259,27 @@ export const createVariable = (
}
}
export const createVariableFromTemplate = (
template: VariableTemplate
) => async (dispatch: Dispatch<Action>, getState: GetState) => {
try {
const state = getState()
const org = getOrg(state)
const resp = await createVariableFromTemplateAJAX(template, org.id)
const createdVar = normalize<Variable, VariableEntities, string>(
resp,
variableSchema
)
dispatch(setVariable(resp.id, RemoteDataState.Done, createdVar))
dispatch(notify(copy.createVariableSuccess(resp.name)))
} catch (error) {
console.error(error)
dispatch(notify(copy.createVariableFailed(error.message)))
}
}
export const updateVariable = (id: string, props: Variable) => async (
dispatch: Dispatch<Action>,
getState: GetState
@ -316,6 +342,49 @@ export const moveVariable = (originalIndex: number, newIndex: number) => async (
await dispatch(moveVariableInState(originalIndex, newIndex, contextID))
}
export const convertToTemplate = (variableID: string) => async (
dispatch,
getState: GetState
): Promise<void> => {
try {
dispatch(setExportTemplate(RemoteDataState.Loading))
const state = getState()
const org = getOrg(state)
const resp = await api.getVariable({variableID})
if (resp.status !== 200) {
throw new Error(resp.data.message)
}
const allVariables = await api.getVariables({query: {orgID: org.id}})
if (allVariables.status !== 200) {
throw new Error(allVariables.data.message)
}
const normVariable = normalize<Variable, VariableEntities, string>(
resp.data,
variableSchema
)
const normVariables = normalize<Variable, VariableEntities, string>(
allVariables.data.variables,
arrayOfVariables
)
const variable = normVariable.entities.variables[normVariable.result]
const variables = Object.values(normVariables.entities.variables)
const dependencies = findDependentVariables(variable, variables)
const variableTemplate = variableToTemplate(state, variable, dependencies)
dispatch(setExportTemplate(RemoteDataState.Done, variableTemplate))
} catch (error) {
dispatch(setExportTemplate(RemoteDataState.Error))
dispatch(notify(copy.createTemplateFailed(error)))
}
}
export const addVariableLabelAsync = (
variableID: string,
label: Label

View File

@ -0,0 +1,63 @@
import React, {PureComponent} from 'react'
import {withRouter, RouteComponentProps} from 'react-router-dom'
import {connect, ConnectedProps} from 'react-redux'
// Components
import ExportOverlay from 'src/shared/components/ExportOverlay'
// Actions
import {convertToTemplate as convertToTemplateAction} from 'src/variables/actions/thunks'
import {clearExportTemplate as clearExportTemplateAction} from 'src/templates/actions/thunks'
// Types
import {AppState} from 'src/types'
type ReduxProps = ConnectedProps<typeof connector>
type Props = RouteComponentProps<{orgID: string; id: string}> & ReduxProps
class VariableExportOverlay extends PureComponent<Props> {
public componentDidMount() {
const {
match: {
params: {id},
},
convertToTemplate,
} = this.props
convertToTemplate(id)
}
public render() {
const {variableTemplate, status} = this.props
return (
<ExportOverlay
resourceName="Variable"
resource={variableTemplate}
onDismissOverlay={this.onDismiss}
status={status}
/>
)
}
private onDismiss = () => {
const {history, clearExportTemplate} = this.props
history.goBack()
clearExportTemplate()
}
}
const mstp = (state: AppState) => ({
variableTemplate: state.resources.templates.exportTemplate.item,
status: state.resources.templates.exportTemplate.status,
})
const mdtp = {
convertToTemplate: convertToTemplateAction,
clearExportTemplate: clearExportTemplateAction,
}
const connector = connect(mstp, mdtp)
export default connector(withRouter(VariableExportOverlay))

View File

@ -0,0 +1,85 @@
import React, {PureComponent} from 'react'
import {withRouter, RouteComponentProps} from 'react-router-dom'
import {connect, ConnectedProps} from 'react-redux'
// Components
import ImportOverlay from 'src/shared/components/ImportOverlay'
// Copy
import {invalidJSON} from 'src/shared/copy/notifications'
// Actions
import {
createVariableFromTemplate as createVariableFromTemplateAction,
getVariables as getVariablesAction,
} from 'src/variables/actions/thunks'
import {notify as notifyAction} from 'src/shared/actions/notifications'
// Types
import {ComponentStatus} from '@influxdata/clockface'
// Utils
import jsonlint from 'jsonlint-mod'
interface State {
status: ComponentStatus
}
type ReduxProps = ConnectedProps<typeof connector>
type Props = RouteComponentProps<{orgID: string}> & ReduxProps
class VariableImportOverlay extends PureComponent<Props> {
public state: State = {
status: ComponentStatus.Default,
}
public render() {
return (
<ImportOverlay
onDismissOverlay={this.onDismiss}
resourceName="Variable"
onSubmit={this.handleImportVariable}
status={this.state.status}
updateStatus={this.updateOverlayStatus}
/>
)
}
private onDismiss = () => {
const {history} = this.props
history.goBack()
}
private updateOverlayStatus = (status: ComponentStatus) =>
this.setState(() => ({status}))
private handleImportVariable = (uploadContent: string) => {
const {createVariableFromTemplate, getVariables, notify} = this.props
let template
this.updateOverlayStatus(ComponentStatus.Default)
try {
template = jsonlint.parse(uploadContent)
} catch (error) {
this.updateOverlayStatus(ComponentStatus.Error)
notify(invalidJSON(error.message))
return
}
createVariableFromTemplate(template)
getVariables()
this.onDismiss()
}
}
const mdtp = {
createVariableFromTemplate: createVariableFromTemplateAction,
getVariables: getVariablesAction,
notify: notifyAction,
}
const connector = connect(null, mdtp)
export default connector(withRouter(VariableImportOverlay))

View File

@ -13,13 +13,13 @@ import SearchWidget from 'src/shared/components/search_widget/SearchWidget'
import TabbedPageHeader from 'src/shared/components/tabbed_page/TabbedPageHeader'
import VariableList from 'src/variables/components/VariableList'
import Filter from 'src/shared/components/FilterList'
import AddResourceButton from 'src/shared/components/AddResourceButton'
import AddResourceDropdown from 'src/shared/components/AddResourceDropdown'
import ResourceSortDropdown from 'src/shared/components/resource_sort_dropdown/ResourceSortDropdown'
import GetResources from 'src/resources/components/GetResources'
import {Sort} from '@influxdata/clockface'
// Types
import {AppState, ResourceType, Variable} from 'src/types'
import {AppState, OverlayState, ResourceType, Variable} from 'src/types'
import {ComponentSize} from '@influxdata/clockface'
import {SortTypes} from 'src/shared/utils/sort'
import {VariableSortKey} from 'src/shared/components/resource_sort_dropdown/generateSortItems'
@ -29,6 +29,7 @@ type Props = RouteComponentProps<{orgID: string}> & ReduxProps
interface State {
searchTerm: string
importOverlayState: OverlayState
sortKey: VariableSortKey
sortDirection: Sort
sortType: SortTypes
@ -39,6 +40,7 @@ const FilterList = Filter<Variable>()
class VariablesTab extends PureComponent<Props, State> {
public state: State = {
searchTerm: '',
importOverlayState: OverlayState.Closed,
sortKey: 'name',
sortDirection: Sort.Ascending,
sortType: SortTypes.String,
@ -66,8 +68,9 @@ class VariablesTab extends PureComponent<Props, State> {
)
const rightHeaderItems = (
<AddResourceButton
<AddResourceDropdown
resourceName="Variable"
onSelectImport={this.handleOpenImportOverlay}
onSelectNew={this.handleOpenCreateOverlay}
/>
)
@ -118,8 +121,9 @@ class VariablesTab extends PureComponent<Props, State> {
<EmptyState.Text>
Looks like there aren't any <b>Variables</b>, why not create one?
</EmptyState.Text>
<AddResourceButton
<AddResourceDropdown
resourceName="Variable"
onSelectImport={this.handleOpenImportOverlay}
onSelectNew={this.handleOpenCreateOverlay}
/>
</EmptyState>
@ -141,6 +145,12 @@ class VariablesTab extends PureComponent<Props, State> {
this.setState({searchTerm})
}
private handleOpenImportOverlay = (): void => {
const {history, match} = this.props
history.push(`/orgs/${match.params.orgID}/settings/variables/import`)
}
private handleOpenCreateOverlay = (): void => {
const {history, match} = this.props

View File

@ -10,6 +10,8 @@ import SettingsHeader from 'src/settings/components/SettingsHeader'
import {Page} from '@influxdata/clockface'
import VariablesTab from 'src/variables/components/VariablesTab'
import GetResources from 'src/resources/components/GetResources'
import VariableImportOverlay from 'src/variables/components/VariableImportOverlay'
import VariableExportOverlay from 'src/variables/components/VariableExportOverlay'
import CreateVariableOverlay from 'src/variables/components/CreateVariableOverlay'
import RenameVariableOverlay from 'src/variables/components/RenameVariableOverlay'
import UpdateVariableOverlay from 'src/variables/components/UpdateVariableOverlay'
@ -44,6 +46,14 @@ class VariablesIndex extends Component<StateProps> {
</SettingsTabbedPage>
</Page>
<Switch>
<Route
path={`${varsPath}/import`}
component={VariableImportOverlay}
/>
<Route
path={`${varsPath}/:id/export`}
component={VariableExportOverlay}
/>
<Route path={`${varsPath}/new`} component={CreateVariableOverlay} />
<Route
path={`${varsPath}/:id/rename`}

View File

@ -0,0 +1,65 @@
import {exportVariables} from 'src/variables/utils/exportVariables'
// Mocks
import {createVariable} from 'src/variables/mocks'
describe('exportVariables', () => {
it('should find dependent variables', () => {
const a = createVariable('a', 'f(x: v.b)')
const b = createVariable('b', 'cool')
const c = createVariable('c', 'nooo!')
const vars = [a, b, c]
const actual = exportVariables([a], vars)
expect(actual).toEqual([a, b])
})
it('should find dependent variables with cycles', () => {
const a = createVariable('a', 'f(x: v.b, y: v.c)')
const b = createVariable('b', 'f(x: v.f, y: v.e)')
const c = createVariable('c', 'f(x: v.g)')
const d = createVariable('d', 'nooooo!')
const e = createVariable('e', 'pick')
const f = createVariable('f', 'f(x: v.a, y: v.b)')
const g = createVariable('g', 'yay')
const h = createVariable('h', 'nooooo!')
const vars = [a, b, c, d, e, f, g, h]
const actual = exportVariables([a], vars)
const expected = new Set([a, b, c, e, f, g])
expect(new Set(actual)).toEqual(expected)
})
const examples = [
createVariable('alone', 'v.target'),
createVariable('space', '\tv.target\n'),
createVariable('func', 'f(x: v.target)'),
createVariable('brackets', '[v.target, other]'),
createVariable('braces', '(v.target)'),
createVariable('add', '1+v.target-2'),
createVariable('mult', '1*v.target/2'),
createVariable('mod', '1+v.target%2'),
createVariable('bool', '1>v.target<2'),
createVariable('assignment', 'x=v.target\n'),
createVariable('curly', '{beep:v.target}\n'),
createVariable('arrow', '(r)=>v.target==r.field\n'),
createVariable('comment', '\nv.target//wat?'),
createVariable('not equal', 'v.target!=r.field'),
createVariable('like', 'other=~v.target'),
]
examples.forEach(example => {
it(`should filter vars with shared prefix: ${example.name}`, () => {
const target = createVariable('target', 'match me!')
const partial = createVariable('tar', 'broke!')
const vars = [example, target, partial]
const actual = exportVariables([example], vars)
expect(actual).toEqual([example, target])
})
})
})

View File

@ -0,0 +1,46 @@
// Utils
import {
collectDescendants,
createVariableGraph,
VariableNode,
} from 'src/variables/utils/hydrateVars'
// Types
import {Variable} from 'src/types'
const getDescendantsFromGraph = (
variable: Variable,
varGraph: VariableNode[]
): Variable[] => {
const node = varGraph.find(n => n.variable.id === variable.id)
return collectDescendants(node).map(n => n.variable)
}
export const findDependentVariables = (
variable: Variable,
allVariables: Variable[]
) => {
const varGraph = createVariableGraph(allVariables)
return getDescendantsFromGraph(variable, varGraph)
}
export const exportVariables = (
variables: Variable[],
allVariables: Variable[]
): Variable[] => {
const varSet = new Set<Variable>()
const varGraph = createVariableGraph(allVariables)
for (const v of variables) {
if (varSet.has(v)) {
continue
}
varSet.add(v)
for (const d of getDescendantsFromGraph(v, varGraph)) {
varSet.add(d)
}
}
return [...varSet]
}