diff --git a/.circleci/config.yml b/.circleci/config.yml index 2c6ce28db7..c6cf41acea 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -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: diff --git a/CHANGELOG.md b/CHANGELOG.md index b87d432c03..05069d551f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/cmd/influx/query.go b/cmd/influx/query.go index c2b8b1e56f..6e3b6f4bdd 100644 --- a/cmd/influx/query.go +++ b/cmd/influx/query.go @@ -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() } diff --git a/pkger/parser.go b/pkger/parser.go index 5f4629d8fe..299e9d4813 100644 --- a/pkger/parser.go +++ b/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 } diff --git a/pkger/parser_models.go b/pkger/parser_models.go index f644abc8aa..d6680538a7 100644 --- a/pkger/parser_models.go +++ b/pkger/parser_models.go @@ -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 diff --git a/pkger/parser_test.go b/pkger/parser_test.go index 29a3fc3285..0936cef80a 100644 --- a/pkger/parser_test.go +++ b/pkger/parser_test.go @@ -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) diff --git a/pkger/service.go b/pkger/service.go index b471caabf0..0a21af3c3a 100644 --- a/pkger/service.go +++ b/pkger/service.go @@ -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 diff --git a/pkger/testdata/task_v2.yml b/pkger/testdata/task_v2.yml new file mode 100644 index 0000000000..8967f17b3c --- /dev/null +++ b/pkger/testdata/task_v2.yml @@ -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") diff --git a/pkger/testdata/task_v2_params.yml b/pkger/testdata/task_v2_params.yml new file mode 100644 index 0000000000..5236bef386 --- /dev/null +++ b/pkger/testdata/task_v2_params.yml @@ -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") diff --git a/pkger/testdata/task_v2_taskSpec.yml b/pkger/testdata/task_v2_taskSpec.yml new file mode 100644 index 0000000000..c5564ac054 --- /dev/null +++ b/pkger/testdata/task_v2_taskSpec.yml @@ -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") diff --git a/ui/cypress/e2e/communityTemplates.test.ts b/ui/cypress/e2e/communityTemplates.test.ts index 0befeda2d6..f02148e855 100644 --- a/ui/cypress/e2e/communityTemplates.test.ts +++ b/ui/cypress/e2e/communityTemplates.test.ts @@ -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', () => { diff --git a/ui/cypress/e2e/dashboardsIndex.test.ts b/ui/cypress/e2e/dashboardsIndex.test.ts index 9b1a1ac2d7..a895d7cbcc 100644 --- a/ui/cypress/e2e/dashboardsIndex.test.ts +++ b/ui/cypress/e2e/dashboardsIndex.test.ts @@ -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) => { diff --git a/ui/cypress/e2e/flows.test.ts b/ui/cypress/e2e/flows.test.ts new file mode 100644 index 0000000000..6386fe18ba --- /dev/null +++ b/ui/cypress/e2e/flows.test.ts @@ -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}') + }) +}) diff --git a/ui/cypress/e2e/tasks.test.ts b/ui/cypress/e2e/tasks.test.ts index 16545f0217..443b58e6cc 100644 --- a/ui/cypress/e2e/tasks.test.ts +++ b/ui/cypress/e2e/tasks.test.ts @@ -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').then(bucket => { cy.getByTestID('flux-editor').within(() => { cy.get('textarea.inputarea') diff --git a/ui/cypress/e2e/variables.test.ts b/ui/cypress/e2e/variables.test.ts index fa06a4b21e..55b18ff46d 100644 --- a/ui/cypress/e2e/variables.test.ts +++ b/ui/cypress/e2e/variables.test.ts @@ -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() diff --git a/ui/cypress/fixtures/routes.json b/ui/cypress/fixtures/routes.json index f58ecaec35..4a97191304 100644 --- a/ui/cypress/fixtures/routes.json +++ b/ui/cypress/fixtures/routes.json @@ -7,5 +7,6 @@ "endpoints": "/endpoints", "rules": "/rules", "buckets": "/load-data/buckets", - "telegrafs": "/load-data/telegrafs" + "telegrafs": "/load-data/telegrafs", + "flows": "/flows" } diff --git a/ui/src/dashboards/actions/thunks.ts b/ui/src/dashboards/actions/thunks.ts index 7651a9b253..01c950dd47 100644 --- a/ui/src/dashboards/actions/thunks.ts +++ b/ui/src/dashboards/actions/thunks.ts @@ -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( + 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 => { @@ -455,6 +495,70 @@ export const removeDashboardLabel = ( } } +export const convertToTemplate = (dashboardID: string) => async ( + dispatch, + getState: GetState +): Promise => { + 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( + 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( + 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 diff --git a/ui/src/dashboards/components/DashboardExportOverlay.tsx b/ui/src/dashboards/components/DashboardExportOverlay.tsx new file mode 100644 index 0000000000..724efbf49c --- /dev/null +++ b/ui/src/dashboards/components/DashboardExportOverlay.tsx @@ -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 +type Props = ReduxProps & + RouteComponentProps<{orgID: string; dashboardID: string}> + +class DashboardExportOverlay extends PureComponent { + 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 ( + + ) + } + + 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)) diff --git a/ui/src/dashboards/components/DashboardImportOverlay.tsx b/ui/src/dashboards/components/DashboardImportOverlay.tsx new file mode 100644 index 0000000000..589d323e2d --- /dev/null +++ b/ui/src/dashboards/components/DashboardImportOverlay.tsx @@ -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 +type Props = RouteComponentProps<{orgID: string}> & ReduxProps + +class DashboardImportOverlay extends PureComponent { + public state: State = { + status: ComponentStatus.Default, + } + + public render() { + return ( + + ) + } + + 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)) diff --git a/ui/src/dashboards/components/createFromTemplateOverlay/CreateFromTemplateOverlay.scss b/ui/src/dashboards/components/createFromTemplateOverlay/CreateFromTemplateOverlay.scss new file mode 100644 index 0000000000..e1ad1803c4 --- /dev/null +++ b/ui/src/dashboards/components/createFromTemplateOverlay/CreateFromTemplateOverlay.scss @@ -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; +} \ No newline at end of file diff --git a/ui/src/dashboards/components/dashboard_index/DashboardCard.tsx b/ui/src/dashboards/components/dashboard_index/DashboardCard.tsx index eb8cbd102a..041c6e9f50 100644 --- a/ui/src/dashboards/components/dashboard_index/DashboardCard.tsx +++ b/ui/src/dashboards/components/dashboard_index/DashboardCard.tsx @@ -98,6 +98,13 @@ class DashboardCard extends PureComponent { private get contextMenu(): JSX.Element { return ( + + + { onRemoveDashboardLabel(id, label) } + + private handleExport = () => { + const { + history, + match: { + params: {orgID}, + }, + id, + } = this.props + + history.push(`/orgs/${orgID}/dashboards-list/${id}/export`) + } } const mdtp = { diff --git a/ui/src/dashboards/components/dashboard_index/DashboardsIndex.tsx b/ui/src/dashboards/components/dashboard_index/DashboardsIndex.tsx index 9c06df231d..21c6e96a4a 100644 --- a/ui/src/dashboards/components/dashboard_index/DashboardsIndex.tsx +++ b/ui/src/dashboards/components/dashboard_index/DashboardsIndex.tsx @@ -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 { /> - @@ -99,6 +106,20 @@ class DashboardIndex extends PureComponent { + + + + + ) } @@ -114,6 +135,26 @@ class DashboardIndex extends PureComponent { 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) => { diff --git a/ui/src/dashboards/components/dashboard_index/DashboardsTableEmpty.tsx b/ui/src/dashboards/components/dashboard_index/DashboardsTableEmpty.tsx index 68e7429705..6e05dbe9c4 100644 --- a/ui/src/dashboards/components/dashboard_index/DashboardsTableEmpty.tsx +++ b/ui/src/dashboards/components/dashboard_index/DashboardsTableEmpty.tsx @@ -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 = ({ searchTerm, onCreateDashboard, + summonImportOverlay, + summonImportFromTemplateOverlay, }) => { if (searchTerm) { return ( @@ -30,9 +34,12 @@ const DashboardsTableEmpty: FC = ({ Looks like you don't have any Dashboards, why not create one? - ) diff --git a/ui/src/dashboards/components/dashboard_index/Table.tsx b/ui/src/dashboards/components/dashboard_index/Table.tsx index 22f803099d..3712c32f6d 100644 --- a/ui/src/dashboards/components/dashboard_index/Table.tsx +++ b/ui/src/dashboards/components/dashboard_index/Table.tsx @@ -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 -type Props = OwnProps & ReduxProps +type Props = OwnProps & ReduxProps & RouteComponentProps<{orgID: string}> class DashboardsTable extends PureComponent { public componentDidMount() { @@ -54,6 +55,8 @@ class DashboardsTable extends PureComponent { ) } @@ -68,6 +71,26 @@ class DashboardsTable extends PureComponent { /> ) } + + 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)) diff --git a/ui/src/notebooks/components/FlowCard.tsx b/ui/src/notebooks/components/FlowCard.tsx new file mode 100644 index 0000000000..996e62c7b3 --- /dev/null +++ b/ui/src/notebooks/components/FlowCard.tsx @@ -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 = ({id, name}) => { + const {orgID} = useParams() + const history = useHistory() + + const handleClick = () => { + history.push(`/orgs/${orgID}/notebooks/${id}`) + } + + return ( + + + + ) +} + +export default FlowCard diff --git a/ui/src/notebooks/components/FlowCards.tsx b/ui/src/notebooks/components/FlowCards.tsx new file mode 100644 index 0000000000..6008a797e1 --- /dev/null +++ b/ui/src/notebooks/components/FlowCards.tsx @@ -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 ( + + + + + }> + {Object.entries(notebooks).map(([id, {name}]) => { + return + })} + + + + + + ) +} + +export default FlowCards diff --git a/ui/src/notebooks/components/FlowCreateButton.tsx b/ui/src/notebooks/components/FlowCreateButton.tsx new file mode 100644 index 0000000000..b1deac397c --- /dev/null +++ b/ui/src/notebooks/components/FlowCreateButton.tsx @@ -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 ( +