chore(master): Merge branch 'master' into sgc/tsm1
commit
09b0258ab4
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
161
pkger/parser.go
161
pkger/parser.go
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
|
@ -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")
|
|
@ -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")
|
|
@ -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', () => {
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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}')
|
||||
})
|
||||
})
|
|
@ -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')
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -7,5 +7,6 @@
|
|||
"endpoints": "/endpoints",
|
||||
"rules": "/rules",
|
||||
"buckets": "/load-data/buckets",
|
||||
"telegrafs": "/load-data/telegrafs"
|
||||
"telegrafs": "/load-data/telegrafs",
|
||||
"flows": "/flows"
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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))
|
|
@ -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))
|
|
@ -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;
|
||||
}
|
|
@ -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 = {
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
}),
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
|
@ -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)
|
|
@ -0,0 +1,3 @@
|
|||
.export-overlay--text-area {
|
||||
height: 60vh;
|
||||
}
|
|
@ -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)
|
|
@ -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;
|
||||
}
|
|
@ -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)
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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 [
|
||||
{
|
||||
|
|
|
@ -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`}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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}
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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'
|
||||
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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))
|
|
@ -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))
|
|
@ -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))
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
|
|
@ -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))
|
|
@ -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 => {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
|
@ -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))
|
|
@ -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)
|
|
@ -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}
|
||||
/>
|
||||
))
|
||||
}
|
||||
}
|
|
@ -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))
|
|
@ -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))
|
|
@ -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))
|
|
@ -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))
|
|
@ -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}
|
||||
/>
|
||||
))
|
||||
}
|
||||
}
|
|
@ -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)
|
|
@ -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;
|
||||
}
|
|
@ -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))
|
|
@ -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
|
|
@ -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
|
|
@ -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))
|
|
@ -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
|
|
@ -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
|
|
@ -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}
|
||||
|
|
|
@ -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)
|
|
@ -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)
|
||||
})
|
||||
})
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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('-------')
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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))
|
|
@ -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))
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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`}
|
||||
|
|
|
@ -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])
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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]
|
||||
}
|
Loading…
Reference in New Issue