You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@superset.apache.org by GitBox <gi...@apache.org> on 2018/08/29 15:50:48 UTC
[GitHub] timifasubaa closed pull request #5773: [WIP] Merge sqlparse fix
timifasubaa closed pull request #5773: [WIP] Merge sqlparse fix
URL: https://github.com/apache/incubator-superset/pull/5773
This is a PR merged from a forked repository.
As GitHub hides the original diff on merge, it is displayed below for
the sake of provenance:
As this is a foreign pull request (from a fork), the diff is supplied
below (as it won't show otherwise due to GitHub magic):
diff --git a/.pylintrc b/.pylintrc
index 0f9710688d..016b04e367 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -277,12 +277,12 @@ ignore-mixin-members=yes
# (useful for modules/projects where namespaces are manipulated during runtime
# and thus existing member attributes cannot be deduced by static analysis. It
# supports qualified module names, as well as Unix pattern matching.
-ignored-modules=numpy,pandas,alembic.op,sqlalchemy,alembic.context,flask_appbuilder.security.sqla.PermissionView.role,flask_appbuilder.Model.metadata,flask_appbuilder.Base.metadata
+ignored-modules=numpy,pandas,alembic.op,sqlalchemy,alembic.context,flask_appbuilder.security.sqla.PermissionView.role,flask_appbuilder.Model.metadata,flask_appbuilder.Base.metadata,distutils
# List of class names for which member attributes should not be checked (useful
# for classes with dynamically set attributes). This supports the use of
# qualified names.
-ignored-classes=optparse.Values,thread._local,_thread._local,sqlalchemy.orm.scoping.scoped_session
+ignored-classes=contextlib.closing,optparse.Values,thread._local,_thread._local,sqlalchemy.orm.scoping.scoped_session
# List of members which are set dynamically and missed by pylint inference
# system, and so shouldn't trigger E1101 when accessed. Python regular
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 519644a4af..7463bc1a57 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -505,3 +505,21 @@ https://github.com/apache/incubator-superset/pull/3013
with a PGP key and providing MD5, Apache voting, as well as
publishing to Apache's SVN repository. View the ASF docs for more
information.
+
+
+## Merging DB migrations
+
+When 2 db migrations collide, you'll get an error message like this one:
+
+```
+ alembic.util.exc.CommandError: Multiple head revisions are present for
+ given argument 'head'; please specify a specific target
+ revision, '<branchname>@head' to narrow to a specific head,
+ or 'heads' for all heads`
+```
+
+To fix it, first run `superset db heads`, this should list 2 or more
+migration hashes. Then run
+`superset db merge {PASTE_SHA1_HERE} {PASTE_SHA2_HERE}`. This will create
+a new merge migration. You can then `superset db upgrade` to this new
+checkpoint.
diff --git a/UPDATING.md b/UPDATING.md
index 78607ffee0..753ec05d0e 100644
--- a/UPDATING.md
+++ b/UPDATING.md
@@ -3,6 +3,20 @@
This file documents any backwards-incompatible changes in Superset and
assists people when migrating to a new version.
+## Superset 0.27.0
+* Superset 0.27 start to use nested layout for dashboard builder, which is not
+backward-compatible with earlier dashboard grid data. We provide migration script
+to automatically convert dashboard grid to nested layout data. To be safe, please
+take a database backup prior to this upgrade. It's the only way people could go
+back to a previous state.
+
+
+## Superset 0.26.0
+* Superset 0.26.0 deprecates the `superset worker` CLI, which is a simple
+wrapper around the `celery worker` command, forcing you into crafting
+your own native `celery worker` command. Your command should look something
+like `celery worker --app=superset.sql_lab:celery_app --pool=gevent -Ofair`
+
## Superset 0.25.0
Superset 0.25.0 contains a backwards incompatible changes.
If you run a production system you should schedule downtime for this
diff --git a/babel-node b/babel-node
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000000..dea4bad056
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,743 @@
+{
+ "requires": true,
+ "lockfileVersion": 1,
+ "dependencies": {
+ "ansi-regex": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
+ "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8="
+ },
+ "ansi-styles": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
+ "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4="
+ },
+ "babel-code-frame": {
+ "version": "6.26.0",
+ "resolved": "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-6.26.0.tgz",
+ "integrity": "sha1-Y/1D99weO7fONZR9uP42mj9Yx0s=",
+ "requires": {
+ "chalk": "^1.1.3",
+ "esutils": "^2.0.2",
+ "js-tokens": "^3.0.2"
+ }
+ },
+ "babel-helper-builder-binary-assignment-operator-visitor": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-helper-builder-binary-assignment-operator-visitor/-/babel-helper-builder-binary-assignment-operator-visitor-6.24.1.tgz",
+ "integrity": "sha1-zORReto1b0IgvK6KAsKzRvmlZmQ=",
+ "dev": true,
+ "requires": {
+ "babel-helper-explode-assignable-expression": "^6.24.1",
+ "babel-runtime": "^6.22.0",
+ "babel-types": "^6.24.1"
+ }
+ },
+ "babel-helper-call-delegate": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-helper-call-delegate/-/babel-helper-call-delegate-6.24.1.tgz",
+ "integrity": "sha1-7Oaqzdx25Bw0YfiL/Fdb0Nqi340=",
+ "requires": {
+ "babel-helper-hoist-variables": "^6.24.1",
+ "babel-runtime": "^6.22.0",
+ "babel-traverse": "^6.24.1",
+ "babel-types": "^6.24.1"
+ }
+ },
+ "babel-helper-define-map": {
+ "version": "6.26.0",
+ "resolved": "https://registry.npmjs.org/babel-helper-define-map/-/babel-helper-define-map-6.26.0.tgz",
+ "integrity": "sha1-pfVtq0GiX5fstJjH66ypgZ+Vvl8=",
+ "requires": {
+ "babel-helper-function-name": "^6.24.1",
+ "babel-runtime": "^6.26.0",
+ "babel-types": "^6.26.0",
+ "lodash": "^4.17.4"
+ }
+ },
+ "babel-helper-explode-assignable-expression": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-helper-explode-assignable-expression/-/babel-helper-explode-assignable-expression-6.24.1.tgz",
+ "integrity": "sha1-8luCz33BBDPFX3BZLVdGQArCLKo=",
+ "dev": true,
+ "requires": {
+ "babel-runtime": "^6.22.0",
+ "babel-traverse": "^6.24.1",
+ "babel-types": "^6.24.1"
+ }
+ },
+ "babel-helper-function-name": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-helper-function-name/-/babel-helper-function-name-6.24.1.tgz",
+ "integrity": "sha1-00dbjAPtmCQqJbSDUasYOZ01gKk=",
+ "requires": {
+ "babel-helper-get-function-arity": "^6.24.1",
+ "babel-runtime": "^6.22.0",
+ "babel-template": "^6.24.1",
+ "babel-traverse": "^6.24.1",
+ "babel-types": "^6.24.1"
+ }
+ },
+ "babel-helper-get-function-arity": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-helper-get-function-arity/-/babel-helper-get-function-arity-6.24.1.tgz",
+ "integrity": "sha1-j3eCqpNAfEHTqlCQj4mwMbG2hT0=",
+ "requires": {
+ "babel-runtime": "^6.22.0",
+ "babel-types": "^6.24.1"
+ }
+ },
+ "babel-helper-hoist-variables": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-helper-hoist-variables/-/babel-helper-hoist-variables-6.24.1.tgz",
+ "integrity": "sha1-HssnaJydJVE+rbyZFKc/VAi+enY=",
+ "requires": {
+ "babel-runtime": "^6.22.0",
+ "babel-types": "^6.24.1"
+ }
+ },
+ "babel-helper-optimise-call-expression": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-helper-optimise-call-expression/-/babel-helper-optimise-call-expression-6.24.1.tgz",
+ "integrity": "sha1-96E0J7qfc/j0+pk8VKl4gtEkQlc=",
+ "requires": {
+ "babel-runtime": "^6.22.0",
+ "babel-types": "^6.24.1"
+ }
+ },
+ "babel-helper-regex": {
+ "version": "6.26.0",
+ "resolved": "https://registry.npmjs.org/babel-helper-regex/-/babel-helper-regex-6.26.0.tgz",
+ "integrity": "sha1-MlxZ+QL4LyS3T6zu0DY5VPZJXnI=",
+ "requires": {
+ "babel-runtime": "^6.26.0",
+ "babel-types": "^6.26.0",
+ "lodash": "^4.17.4"
+ }
+ },
+ "babel-helper-remap-async-to-generator": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-helper-remap-async-to-generator/-/babel-helper-remap-async-to-generator-6.24.1.tgz",
+ "integrity": "sha1-XsWBgnrXI/7N04HxySg5BnbkVRs=",
+ "dev": true,
+ "requires": {
+ "babel-helper-function-name": "^6.24.1",
+ "babel-runtime": "^6.22.0",
+ "babel-template": "^6.24.1",
+ "babel-traverse": "^6.24.1",
+ "babel-types": "^6.24.1"
+ }
+ },
+ "babel-helper-replace-supers": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-helper-replace-supers/-/babel-helper-replace-supers-6.24.1.tgz",
+ "integrity": "sha1-v22/5Dk40XNpohPKiov3S2qQqxo=",
+ "requires": {
+ "babel-helper-optimise-call-expression": "^6.24.1",
+ "babel-messages": "^6.23.0",
+ "babel-runtime": "^6.22.0",
+ "babel-template": "^6.24.1",
+ "babel-traverse": "^6.24.1",
+ "babel-types": "^6.24.1"
+ }
+ },
+ "babel-messages": {
+ "version": "6.23.0",
+ "resolved": "https://registry.npmjs.org/babel-messages/-/babel-messages-6.23.0.tgz",
+ "integrity": "sha1-8830cDhYA1sqKVHG7F7fbGLyYw4=",
+ "requires": {
+ "babel-runtime": "^6.22.0"
+ }
+ },
+ "babel-plugin-check-es2015-constants": {
+ "version": "6.22.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-check-es2015-constants/-/babel-plugin-check-es2015-constants-6.22.0.tgz",
+ "integrity": "sha1-NRV7EBQm/S/9PaP3XH0ekYNbv4o=",
+ "requires": {
+ "babel-runtime": "^6.22.0"
+ }
+ },
+ "babel-plugin-dynamic-import-node": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-1.2.0.tgz",
+ "integrity": "sha512-yeDwKaLgGdTpXL7RgGt5r6T4LmnTza/hUn5Ul8uZSGGMtEjYo13Nxai7SQaGCTEzUtg9Zq9qJn0EjEr7SeSlTQ==",
+ "requires": {
+ "babel-plugin-syntax-dynamic-import": "^6.18.0"
+ }
+ },
+ "babel-plugin-syntax-async-functions": {
+ "version": "6.13.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-syntax-async-functions/-/babel-plugin-syntax-async-functions-6.13.0.tgz",
+ "integrity": "sha1-ytnK0RkbWtY0vzCuCHI5HgZHvpU=",
+ "dev": true
+ },
+ "babel-plugin-syntax-dynamic-import": {
+ "version": "6.18.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-syntax-dynamic-import/-/babel-plugin-syntax-dynamic-import-6.18.0.tgz",
+ "integrity": "sha1-jWomIpyDdFqZgqRBBRVyyqF5sdo="
+ },
+ "babel-plugin-syntax-exponentiation-operator": {
+ "version": "6.13.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-syntax-exponentiation-operator/-/babel-plugin-syntax-exponentiation-operator-6.13.0.tgz",
+ "integrity": "sha1-nufoM3KQ2pUoggGmpX9BcDF4MN4=",
+ "dev": true
+ },
+ "babel-plugin-syntax-trailing-function-commas": {
+ "version": "6.22.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-syntax-trailing-function-commas/-/babel-plugin-syntax-trailing-function-commas-6.22.0.tgz",
+ "integrity": "sha1-ugNgk3+NBuQBgKQ/4NVhb/9TLPM=",
+ "dev": true
+ },
+ "babel-plugin-transform-async-to-generator": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-async-to-generator/-/babel-plugin-transform-async-to-generator-6.24.1.tgz",
+ "integrity": "sha1-ZTbjeK/2yx1VF6wOQOs+n8jQh2E=",
+ "dev": true,
+ "requires": {
+ "babel-helper-remap-async-to-generator": "^6.24.1",
+ "babel-plugin-syntax-async-functions": "^6.8.0",
+ "babel-runtime": "^6.22.0"
+ }
+ },
+ "babel-plugin-transform-es2015-arrow-functions": {
+ "version": "6.22.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-arrow-functions/-/babel-plugin-transform-es2015-arrow-functions-6.22.0.tgz",
+ "integrity": "sha1-RSaSy3EdX3ncf4XkQM5BufJE0iE=",
+ "requires": {
+ "babel-runtime": "^6.22.0"
+ }
+ },
+ "babel-plugin-transform-es2015-block-scoped-functions": {
+ "version": "6.22.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-block-scoped-functions/-/babel-plugin-transform-es2015-block-scoped-functions-6.22.0.tgz",
+ "integrity": "sha1-u8UbSflk1wy42OC5ToICRs46YUE=",
+ "requires": {
+ "babel-runtime": "^6.22.0"
+ }
+ },
+ "babel-plugin-transform-es2015-block-scoping": {
+ "version": "6.26.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-block-scoping/-/babel-plugin-transform-es2015-block-scoping-6.26.0.tgz",
+ "integrity": "sha1-1w9SmcEwjQXBL0Y4E7CgnnOxiV8=",
+ "requires": {
+ "babel-runtime": "^6.26.0",
+ "babel-template": "^6.26.0",
+ "babel-traverse": "^6.26.0",
+ "babel-types": "^6.26.0",
+ "lodash": "^4.17.4"
+ }
+ },
+ "babel-plugin-transform-es2015-classes": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-classes/-/babel-plugin-transform-es2015-classes-6.24.1.tgz",
+ "integrity": "sha1-WkxYpQyclGHlZLSyo7+ryXolhNs=",
+ "requires": {
+ "babel-helper-define-map": "^6.24.1",
+ "babel-helper-function-name": "^6.24.1",
+ "babel-helper-optimise-call-expression": "^6.24.1",
+ "babel-helper-replace-supers": "^6.24.1",
+ "babel-messages": "^6.23.0",
+ "babel-runtime": "^6.22.0",
+ "babel-template": "^6.24.1",
+ "babel-traverse": "^6.24.1",
+ "babel-types": "^6.24.1"
+ }
+ },
+ "babel-plugin-transform-es2015-computed-properties": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-computed-properties/-/babel-plugin-transform-es2015-computed-properties-6.24.1.tgz",
+ "integrity": "sha1-b+Ko0WiV1WNPTNmZttNICjCBWbM=",
+ "requires": {
+ "babel-runtime": "^6.22.0",
+ "babel-template": "^6.24.1"
+ }
+ },
+ "babel-plugin-transform-es2015-destructuring": {
+ "version": "6.23.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-destructuring/-/babel-plugin-transform-es2015-destructuring-6.23.0.tgz",
+ "integrity": "sha1-mXux8auWf2gtKwh2/jWNYOdlxW0=",
+ "requires": {
+ "babel-runtime": "^6.22.0"
+ }
+ },
+ "babel-plugin-transform-es2015-duplicate-keys": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-duplicate-keys/-/babel-plugin-transform-es2015-duplicate-keys-6.24.1.tgz",
+ "integrity": "sha1-c+s9MQypaePvnskcU3QabxV2Qj4=",
+ "requires": {
+ "babel-runtime": "^6.22.0",
+ "babel-types": "^6.24.1"
+ }
+ },
+ "babel-plugin-transform-es2015-for-of": {
+ "version": "6.23.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-for-of/-/babel-plugin-transform-es2015-for-of-6.23.0.tgz",
+ "integrity": "sha1-9HyVsrYT3x0+zC/bdXNiPHUkhpE=",
+ "requires": {
+ "babel-runtime": "^6.22.0"
+ }
+ },
+ "babel-plugin-transform-es2015-function-name": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-function-name/-/babel-plugin-transform-es2015-function-name-6.24.1.tgz",
+ "integrity": "sha1-g0yJhTvDaxrw86TF26qU/Y6sqos=",
+ "requires": {
+ "babel-helper-function-name": "^6.24.1",
+ "babel-runtime": "^6.22.0",
+ "babel-types": "^6.24.1"
+ }
+ },
+ "babel-plugin-transform-es2015-literals": {
+ "version": "6.22.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-literals/-/babel-plugin-transform-es2015-literals-6.22.0.tgz",
+ "integrity": "sha1-T1SgLWzWbPkVKAAZox0xklN3yi4=",
+ "requires": {
+ "babel-runtime": "^6.22.0"
+ }
+ },
+ "babel-plugin-transform-es2015-modules-amd": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-modules-amd/-/babel-plugin-transform-es2015-modules-amd-6.24.1.tgz",
+ "integrity": "sha1-Oz5UAXI5hC1tGcMBHEvS8AoA0VQ=",
+ "requires": {
+ "babel-plugin-transform-es2015-modules-commonjs": "^6.24.1",
+ "babel-runtime": "^6.22.0",
+ "babel-template": "^6.24.1"
+ }
+ },
+ "babel-plugin-transform-es2015-modules-commonjs": {
+ "version": "6.26.2",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-modules-commonjs/-/babel-plugin-transform-es2015-modules-commonjs-6.26.2.tgz",
+ "integrity": "sha512-CV9ROOHEdrjcwhIaJNBGMBCodN+1cfkwtM1SbUHmvyy35KGT7fohbpOxkE2uLz1o6odKK2Ck/tz47z+VqQfi9Q==",
+ "requires": {
+ "babel-plugin-transform-strict-mode": "^6.24.1",
+ "babel-runtime": "^6.26.0",
+ "babel-template": "^6.26.0",
+ "babel-types": "^6.26.0"
+ }
+ },
+ "babel-plugin-transform-es2015-modules-systemjs": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-modules-systemjs/-/babel-plugin-transform-es2015-modules-systemjs-6.24.1.tgz",
+ "integrity": "sha1-/4mhQrkRmpBhlfXxBuzzBdlAfSM=",
+ "requires": {
+ "babel-helper-hoist-variables": "^6.24.1",
+ "babel-runtime": "^6.22.0",
+ "babel-template": "^6.24.1"
+ }
+ },
+ "babel-plugin-transform-es2015-modules-umd": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-modules-umd/-/babel-plugin-transform-es2015-modules-umd-6.24.1.tgz",
+ "integrity": "sha1-rJl+YoXNGO1hdq22B9YCNErThGg=",
+ "requires": {
+ "babel-plugin-transform-es2015-modules-amd": "^6.24.1",
+ "babel-runtime": "^6.22.0",
+ "babel-template": "^6.24.1"
+ }
+ },
+ "babel-plugin-transform-es2015-object-super": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-object-super/-/babel-plugin-transform-es2015-object-super-6.24.1.tgz",
+ "integrity": "sha1-JM72muIcuDp/hgPa0CH1cusnj40=",
+ "requires": {
+ "babel-helper-replace-supers": "^6.24.1",
+ "babel-runtime": "^6.22.0"
+ }
+ },
+ "babel-plugin-transform-es2015-parameters": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-parameters/-/babel-plugin-transform-es2015-parameters-6.24.1.tgz",
+ "integrity": "sha1-V6w1GrScrxSpfNE7CfZv3wpiXys=",
+ "requires": {
+ "babel-helper-call-delegate": "^6.24.1",
+ "babel-helper-get-function-arity": "^6.24.1",
+ "babel-runtime": "^6.22.0",
+ "babel-template": "^6.24.1",
+ "babel-traverse": "^6.24.1",
+ "babel-types": "^6.24.1"
+ }
+ },
+ "babel-plugin-transform-es2015-shorthand-properties": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-shorthand-properties/-/babel-plugin-transform-es2015-shorthand-properties-6.24.1.tgz",
+ "integrity": "sha1-JPh11nIch2YbvZmkYi5R8U3jiqA=",
+ "requires": {
+ "babel-runtime": "^6.22.0",
+ "babel-types": "^6.24.1"
+ }
+ },
+ "babel-plugin-transform-es2015-spread": {
+ "version": "6.22.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-spread/-/babel-plugin-transform-es2015-spread-6.22.0.tgz",
+ "integrity": "sha1-1taKmfia7cRTbIGlQujdnxdG+NE=",
+ "requires": {
+ "babel-runtime": "^6.22.0"
+ }
+ },
+ "babel-plugin-transform-es2015-sticky-regex": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-sticky-regex/-/babel-plugin-transform-es2015-sticky-regex-6.24.1.tgz",
+ "integrity": "sha1-AMHNsaynERLN8M9hJsLta0V8zbw=",
+ "requires": {
+ "babel-helper-regex": "^6.24.1",
+ "babel-runtime": "^6.22.0",
+ "babel-types": "^6.24.1"
+ }
+ },
+ "babel-plugin-transform-es2015-template-literals": {
+ "version": "6.22.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-template-literals/-/babel-plugin-transform-es2015-template-literals-6.22.0.tgz",
+ "integrity": "sha1-qEs0UPfp+PH2g51taH2oS7EjbY0=",
+ "requires": {
+ "babel-runtime": "^6.22.0"
+ }
+ },
+ "babel-plugin-transform-es2015-typeof-symbol": {
+ "version": "6.23.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-typeof-symbol/-/babel-plugin-transform-es2015-typeof-symbol-6.23.0.tgz",
+ "integrity": "sha1-3sCfHN3/lLUqxz1QXITfWdzOs3I=",
+ "requires": {
+ "babel-runtime": "^6.22.0"
+ }
+ },
+ "babel-plugin-transform-es2015-unicode-regex": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-unicode-regex/-/babel-plugin-transform-es2015-unicode-regex-6.24.1.tgz",
+ "integrity": "sha1-04sS9C6nMj9yk4fxinxa4frrNek=",
+ "requires": {
+ "babel-helper-regex": "^6.24.1",
+ "babel-runtime": "^6.22.0",
+ "regexpu-core": "^2.0.0"
+ }
+ },
+ "babel-plugin-transform-exponentiation-operator": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-exponentiation-operator/-/babel-plugin-transform-exponentiation-operator-6.24.1.tgz",
+ "integrity": "sha1-KrDJx/MJj6SJB3cruBP+QejeOg4=",
+ "dev": true,
+ "requires": {
+ "babel-helper-builder-binary-assignment-operator-visitor": "^6.24.1",
+ "babel-plugin-syntax-exponentiation-operator": "^6.8.0",
+ "babel-runtime": "^6.22.0"
+ }
+ },
+ "babel-plugin-transform-regenerator": {
+ "version": "6.26.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-regenerator/-/babel-plugin-transform-regenerator-6.26.0.tgz",
+ "integrity": "sha1-4HA2lvveJ/Cj78rPi03KL3s6jy8=",
+ "requires": {
+ "regenerator-transform": "^0.10.0"
+ }
+ },
+ "babel-plugin-transform-strict-mode": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-strict-mode/-/babel-plugin-transform-strict-mode-6.24.1.tgz",
+ "integrity": "sha1-1fr3qleKZbvlkc9e2uBKDGcCB1g=",
+ "requires": {
+ "babel-runtime": "^6.22.0",
+ "babel-types": "^6.24.1"
+ }
+ },
+ "babel-preset-env": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/babel-preset-env/-/babel-preset-env-1.7.0.tgz",
+ "integrity": "sha512-9OR2afuKDneX2/q2EurSftUYM0xGu4O2D9adAhVfADDhrYDaxXV0rBbevVYoY9n6nyX1PmQW/0jtpJvUNr9CHg==",
+ "dev": true,
+ "requires": {
+ "babel-plugin-check-es2015-constants": "^6.22.0",
+ "babel-plugin-syntax-trailing-function-commas": "^6.22.0",
+ "babel-plugin-transform-async-to-generator": "^6.22.0",
+ "babel-plugin-transform-es2015-arrow-functions": "^6.22.0",
+ "babel-plugin-transform-es2015-block-scoped-functions": "^6.22.0",
+ "babel-plugin-transform-es2015-block-scoping": "^6.23.0",
+ "babel-plugin-transform-es2015-classes": "^6.23.0",
+ "babel-plugin-transform-es2015-computed-properties": "^6.22.0",
+ "babel-plugin-transform-es2015-destructuring": "^6.23.0",
+ "babel-plugin-transform-es2015-duplicate-keys": "^6.22.0",
+ "babel-plugin-transform-es2015-for-of": "^6.23.0",
+ "babel-plugin-transform-es2015-function-name": "^6.22.0",
+ "babel-plugin-transform-es2015-literals": "^6.22.0",
+ "babel-plugin-transform-es2015-modules-amd": "^6.22.0",
+ "babel-plugin-transform-es2015-modules-commonjs": "^6.23.0",
+ "babel-plugin-transform-es2015-modules-systemjs": "^6.23.0",
+ "babel-plugin-transform-es2015-modules-umd": "^6.23.0",
+ "babel-plugin-transform-es2015-object-super": "^6.22.0",
+ "babel-plugin-transform-es2015-parameters": "^6.23.0",
+ "babel-plugin-transform-es2015-shorthand-properties": "^6.22.0",
+ "babel-plugin-transform-es2015-spread": "^6.22.0",
+ "babel-plugin-transform-es2015-sticky-regex": "^6.22.0",
+ "babel-plugin-transform-es2015-template-literals": "^6.22.0",
+ "babel-plugin-transform-es2015-typeof-symbol": "^6.23.0",
+ "babel-plugin-transform-es2015-unicode-regex": "^6.22.0",
+ "babel-plugin-transform-exponentiation-operator": "^6.22.0",
+ "babel-plugin-transform-regenerator": "^6.22.0",
+ "browserslist": "^3.2.6",
+ "invariant": "^2.2.2",
+ "semver": "^5.3.0"
+ }
+ },
+ "babel-preset-es2015": {
+ "version": "6.24.1",
+ "resolved": "https://registry.npmjs.org/babel-preset-es2015/-/babel-preset-es2015-6.24.1.tgz",
+ "integrity": "sha1-1EBQ1rwsn+6nAqrzjXJ6AhBTiTk=",
+ "requires": {
+ "babel-plugin-check-es2015-constants": "^6.22.0",
+ "babel-plugin-transform-es2015-arrow-functions": "^6.22.0",
+ "babel-plugin-transform-es2015-block-scoped-functions": "^6.22.0",
+ "babel-plugin-transform-es2015-block-scoping": "^6.24.1",
+ "babel-plugin-transform-es2015-classes": "^6.24.1",
+ "babel-plugin-transform-es2015-computed-properties": "^6.24.1",
+ "babel-plugin-transform-es2015-destructuring": "^6.22.0",
+ "babel-plugin-transform-es2015-duplicate-keys": "^6.24.1",
+ "babel-plugin-transform-es2015-for-of": "^6.22.0",
+ "babel-plugin-transform-es2015-function-name": "^6.24.1",
+ "babel-plugin-transform-es2015-literals": "^6.22.0",
+ "babel-plugin-transform-es2015-modules-amd": "^6.24.1",
+ "babel-plugin-transform-es2015-modules-commonjs": "^6.24.1",
+ "babel-plugin-transform-es2015-modules-systemjs": "^6.24.1",
+ "babel-plugin-transform-es2015-modules-umd": "^6.24.1",
+ "babel-plugin-transform-es2015-object-super": "^6.24.1",
+ "babel-plugin-transform-es2015-parameters": "^6.24.1",
+ "babel-plugin-transform-es2015-shorthand-properties": "^6.24.1",
+ "babel-plugin-transform-es2015-spread": "^6.22.0",
+ "babel-plugin-transform-es2015-sticky-regex": "^6.24.1",
+ "babel-plugin-transform-es2015-template-literals": "^6.22.0",
+ "babel-plugin-transform-es2015-typeof-symbol": "^6.22.0",
+ "babel-plugin-transform-es2015-unicode-regex": "^6.24.1",
+ "babel-plugin-transform-regenerator": "^6.24.1"
+ }
+ },
+ "babel-runtime": {
+ "version": "6.26.0",
+ "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz",
+ "integrity": "sha1-llxwWGaOgrVde/4E/yM3vItWR/4=",
+ "requires": {
+ "core-js": "^2.4.0",
+ "regenerator-runtime": "^0.11.0"
+ }
+ },
+ "babel-template": {
+ "version": "6.26.0",
+ "resolved": "https://registry.npmjs.org/babel-template/-/babel-template-6.26.0.tgz",
+ "integrity": "sha1-3gPi0WOWsGn0bdn/+FIfsaDjXgI=",
+ "requires": {
+ "babel-runtime": "^6.26.0",
+ "babel-traverse": "^6.26.0",
+ "babel-types": "^6.26.0",
+ "babylon": "^6.18.0",
+ "lodash": "^4.17.4"
+ }
+ },
+ "babel-traverse": {
+ "version": "6.26.0",
+ "resolved": "https://registry.npmjs.org/babel-traverse/-/babel-traverse-6.26.0.tgz",
+ "integrity": "sha1-RqnL1+3MYsjlwGTi0tjQ9ANXZu4=",
+ "requires": {
+ "babel-code-frame": "^6.26.0",
+ "babel-messages": "^6.23.0",
+ "babel-runtime": "^6.26.0",
+ "babel-types": "^6.26.0",
+ "babylon": "^6.18.0",
+ "debug": "^2.6.8",
+ "globals": "^9.18.0",
+ "invariant": "^2.2.2",
+ "lodash": "^4.17.4"
+ }
+ },
+ "babel-types": {
+ "version": "6.26.0",
+ "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.26.0.tgz",
+ "integrity": "sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=",
+ "requires": {
+ "babel-runtime": "^6.26.0",
+ "esutils": "^2.0.2",
+ "lodash": "^4.17.4",
+ "to-fast-properties": "^1.0.3"
+ }
+ },
+ "babylon": {
+ "version": "6.18.0",
+ "resolved": "https://registry.npmjs.org/babylon/-/babylon-6.18.0.tgz",
+ "integrity": "sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ=="
+ },
+ "browserslist": {
+ "version": "3.2.8",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-3.2.8.tgz",
+ "integrity": "sha512-WHVocJYavUwVgVViC0ORikPHQquXwVh939TaelZ4WDqpWgTX/FsGhl/+P4qBUAGcRvtOgDgC+xftNWWp2RUTAQ==",
+ "dev": true,
+ "requires": {
+ "caniuse-lite": "^1.0.30000844",
+ "electron-to-chromium": "^1.3.47"
+ }
+ },
+ "caniuse-lite": {
+ "version": "1.0.30000856",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30000856.tgz",
+ "integrity": "sha512-x3mYcApHMQemyaHuH/RyqtKCGIYTgEA63fdi+VBvDz8xUSmRiVWTLeyKcoGQCGG6UPR9/+4qG4OKrTa6aSQRKg==",
+ "dev": true
+ },
+ "chalk": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
+ "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
+ "requires": {
+ "ansi-styles": "^2.2.1",
+ "escape-string-regexp": "^1.0.2",
+ "has-ansi": "^2.0.0",
+ "strip-ansi": "^3.0.0",
+ "supports-color": "^2.0.0"
+ }
+ },
+ "core-js": {
+ "version": "2.5.7",
+ "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.5.7.tgz",
+ "integrity": "sha512-RszJCAxg/PP6uzXVXL6BsxSXx/B05oJAQ2vkJRjyjrEcNVycaqOmNb5OTxZPE3xa5gwZduqza6L9JOCenh/Ecw=="
+ },
+ "debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "requires": {
+ "ms": "2.0.0"
+ }
+ },
+ "electron-to-chromium": {
+ "version": "1.3.48",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.48.tgz",
+ "integrity": "sha1-07DYWTgUBE4JLs4hCPw6ya6kuQA=",
+ "dev": true
+ },
+ "escape-string-regexp": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
+ "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ="
+ },
+ "esutils": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.2.tgz",
+ "integrity": "sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs="
+ },
+ "globals": {
+ "version": "9.18.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-9.18.0.tgz",
+ "integrity": "sha512-S0nG3CLEQiY/ILxqtztTWH/3iRRdyBLw6KMDxnKMchrtbj2OFmehVh0WUCfW3DUrIgx/qFrJPICrq4Z4sTR9UQ=="
+ },
+ "has-ansi": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz",
+ "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=",
+ "requires": {
+ "ansi-regex": "^2.0.0"
+ }
+ },
+ "invariant": {
+ "version": "2.2.4",
+ "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
+ "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==",
+ "requires": {
+ "loose-envify": "^1.0.0"
+ }
+ },
+ "js-tokens": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz",
+ "integrity": "sha1-mGbfOVECEw449/mWvOtlRDIJwls="
+ },
+ "jsesc": {
+ "version": "0.5.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz",
+ "integrity": "sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0="
+ },
+ "lodash": {
+ "version": "4.17.10",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.10.tgz",
+ "integrity": "sha512-UejweD1pDoXu+AD825lWwp4ZGtSwgnpZxb3JDViD7StjQz+Nb/6l093lx4OQ0foGWNRoc19mWy7BzL+UAK2iVg=="
+ },
+ "loose-envify": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.3.1.tgz",
+ "integrity": "sha1-0aitM/qc4OcT1l/dCsi3SNR4yEg=",
+ "requires": {
+ "js-tokens": "^3.0.0"
+ }
+ },
+ "ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+ },
+ "private": {
+ "version": "0.1.8",
+ "resolved": "https://registry.npmjs.org/private/-/private-0.1.8.tgz",
+ "integrity": "sha512-VvivMrbvd2nKkiG38qjULzlc+4Vx4wm/whI9pQD35YrARNnhxeiRktSOhSukRLFNlzg6Br/cJPet5J/u19r/mg=="
+ },
+ "regenerate": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.0.tgz",
+ "integrity": "sha512-1G6jJVDWrt0rK99kBjvEtziZNCICAuvIPkSiUFIQxVP06RCVpq3dmDo2oi6ABpYaDYaTRr67BEhL8r1wgEZZKg=="
+ },
+ "regenerator-runtime": {
+ "version": "0.11.1",
+ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz",
+ "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg=="
+ },
+ "regenerator-transform": {
+ "version": "0.10.1",
+ "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.10.1.tgz",
+ "integrity": "sha512-PJepbvDbuK1xgIgnau7Y90cwaAmO/LCLMI2mPvaXq2heGMR3aWW5/BQvYrhJ8jgmQjXewXvBjzfqKcVOmhjZ6Q==",
+ "requires": {
+ "babel-runtime": "^6.18.0",
+ "babel-types": "^6.19.0",
+ "private": "^0.1.6"
+ }
+ },
+ "regexpu-core": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-2.0.0.tgz",
+ "integrity": "sha1-SdA4g3uNz4v6W5pCE5k45uoq4kA=",
+ "requires": {
+ "regenerate": "^1.2.1",
+ "regjsgen": "^0.2.0",
+ "regjsparser": "^0.1.4"
+ }
+ },
+ "regjsgen": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.2.0.tgz",
+ "integrity": "sha1-bAFq3qxVT3WCP+N6wFuS1aTtsfc="
+ },
+ "regjsparser": {
+ "version": "0.1.5",
+ "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.1.5.tgz",
+ "integrity": "sha1-fuj4Tcb6eS0/0K4ijSS9lJ6tIFw=",
+ "requires": {
+ "jsesc": "~0.5.0"
+ }
+ },
+ "semver": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-5.5.0.tgz",
+ "integrity": "sha512-4SJ3dm0WAwWy/NVeioZh5AntkdJoWKxHxcmyP622fOkgHa4z3R0TdBJICINyaSDE6uNwVc8gZr+ZinwZAH4xIA==",
+ "dev": true
+ },
+ "strip-ansi": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+ "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
+ "requires": {
+ "ansi-regex": "^2.0.0"
+ }
+ },
+ "supports-color": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
+ "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc="
+ },
+ "to-fast-properties": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-1.0.3.tgz",
+ "integrity": "sha1-uDVx+k2MJbguIxsG46MFXeTKGkc="
+ }
+ }
+}
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 7f9616395e..fdb627d962 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -1,8 +1,16 @@
+console_log==0.2.10
+flake8-coding==1.3.0
+flake8-commas==2.0.0
+flake8-future-import==0.4.5
+flake8-import-order==0.18
+flake8-quotes==1.0.0
+flake8==3.5.0
flask-cors==3.0.3
ipdb==0.11
mysqlclient==1.3.12
-psycopg2==2.7.4
+psycopg2-binary==2.7.5
+pycodestyle==2.3.1
+pylint==1.9.2
redis==2.10.6
statsd==3.2.2
-tox==2.9.1
-console_log==0.2.10
+tox==3.1.2
diff --git a/requirements.txt b/requirements.txt
index 1f42d8c8c0..fbc69c51d6 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -17,6 +17,7 @@ geopy==1.11.0
gunicorn==19.8.0
humanize==0.5.1
idna==2.6
+isodate==0.6.0
markdown==2.6.11
pandas==0.22.0
parsedatetime==2.0.0
diff --git a/setup.py b/setup.py
index 204271e5b5..e4fb35aa23 100644
--- a/setup.py
+++ b/setup.py
@@ -55,7 +55,7 @@ def get_git_sha():
'celery',
'colorama',
'cryptography',
- 'flask<=1.0.0',
+ 'flask<1.0.0',
'flask-appbuilder',
'flask-caching',
'flask-compress',
@@ -69,6 +69,7 @@ def get_git_sha():
'gunicorn', # deprecated
'humanize',
'idna',
+ 'isodate',
'markdown',
'pandas',
'parsedatetime',
diff --git a/superset/__init__.py b/superset/__init__.py
index 2c3957b7d6..5ec3c94a33 100644
--- a/superset/__init__.py
+++ b/superset/__init__.py
@@ -47,23 +47,37 @@ def parse_manifest_json():
global manifest
try:
with open(MANIFEST_FILE, 'r') as f:
- manifest = json.load(f)
+ # the manifest inclues non-entry files
+ # we only need entries in templates
+ full_manifest = json.load(f)
+ manifest = full_manifest.get('entrypoints', {})
except Exception:
pass
-def get_manifest_file(filename):
+def get_js_manifest_files(filename):
if app.debug:
parse_manifest_json()
- return '/static/assets/dist/' + manifest.get(filename, '')
+ entry_files = manifest.get(filename, {})
+ return entry_files.get('js', [])
+
+
+def get_css_manifest_files(filename):
+ if app.debug:
+ parse_manifest_json()
+ entry_files = manifest.get(filename, {})
+ return entry_files.get('css', [])
parse_manifest_json()
@app.context_processor
-def get_js_manifest():
- return dict(js_manifest=get_manifest_file)
+def get_manifest():
+ return dict(
+ js_manifest=get_js_manifest_files,
+ css_manifest=get_css_manifest_files,
+ )
#################################################################
diff --git a/superset/assets/.babelrc b/superset/assets/.babelrc
index a965253b72..bb026bc1b3 100644
--- a/superset/assets/.babelrc
+++ b/superset/assets/.babelrc
@@ -1,3 +1,4 @@
{
- "presets" : ["airbnb"],
+ "presets" : ["airbnb", "react", "env"],
+ "plugins": ["syntax-dynamic-import"],
}
diff --git a/superset/assets/.eslintignore b/superset/assets/.eslintignore
index 7479173e66..61262fc2e1 100644
--- a/superset/assets/.eslintignore
+++ b/superset/assets/.eslintignore
@@ -8,3 +8,4 @@ node_modules*/*
stylesheets/*
vendor/*
docs/*
+src/dashboard/deprecated/*
diff --git a/superset/assets/.eslintrc b/superset/assets/.eslintrc
index 921e927b82..e49a4e0f1b 100644
--- a/superset/assets/.eslintrc
+++ b/superset/assets/.eslintrc
@@ -1,6 +1,7 @@
{
"extends": "airbnb",
- "parserOptions":{
+ "parser": "babel-eslint",
+ "parserOptions": {
"ecmaFeatures": {
"experimentalObjectRestSpread": true
}
diff --git a/superset/assets/.istanbul.yml b/superset/assets/.istanbul.yml
index 356247bc65..bb5c6521e9 100644
--- a/superset/assets/.istanbul.yml
+++ b/superset/assets/.istanbul.yml
@@ -1,9 +1,10 @@
verbose: true
instrumentation:
- root: './javascripts'
+ root: './src'
extensions: ['.js', '.jsx']
excludes: [
- 'dist/**'
+ 'dist/**',
+ 'visualizations/index.js',
]
embed-source: false
variable: __coverage__
diff --git a/superset/assets/backendSync.json b/superset/assets/backendSync.json
index 9145cc9817..9b825529e9 100644
--- a/superset/assets/backendSync.json
+++ b/superset/assets/backendSync.json
@@ -1683,18 +1683,6 @@
"renderTrigger": true,
"default": ""
},
- "where": {
- "type": "TextControl",
- "label": "Custom WHERE clause",
- "default": "",
- "description": "The text in this box gets included in your query's WHERE clause, as an AND to other criteria. You can include complex expression, parenthesis and anything else supported by the backend it is directed towards."
- },
- "having": {
- "type": "TextControl",
- "label": "Custom HAVING clause",
- "default": "",
- "description": "The text in this box gets included in your query's HAVING clause, as an AND to other criteria. You can include complex expression, parenthesis and anything else supported by the backend it is directed towards."
- },
"compare_lag": {
"type": "TextControl",
"label": "Comparison Period Lag",
@@ -2628,12 +2616,6 @@
"default": "",
"description": "Labels for the marker lines"
},
- "filters": {
- "type": "FilterControl",
- "label": "",
- "default": [],
- "description": ""
- },
"annotation_layers": {
"type": "AnnotationLayerControl",
"label": "",
@@ -2641,12 +2623,6 @@
"description": "Annotation Layers",
"renderTrigger": true
},
- "having_filters": {
- "type": "FilterControl",
- "label": "",
- "default": [],
- "description": ""
- },
"slice_id": {
"type": "HiddenControl",
"label": "Slice ID",
diff --git a/superset/assets/images/loading.gif b/superset/assets/images/loading.gif
index 01ae3939c4..d82fc5d924 100644
Binary files a/superset/assets/images/loading.gif and b/superset/assets/images/loading.gif differ
diff --git a/superset/assets/images/viz_thumbnails/line_multi.png b/superset/assets/images/viz_thumbnails/line_multi.png
new file mode 100644
index 0000000000..f776bb847b
Binary files /dev/null and b/superset/assets/images/viz_thumbnails/line_multi.png differ
diff --git a/superset/assets/images/viz_thumbnails_large/line_multi.png b/superset/assets/images/viz_thumbnails_large/line_multi.png
new file mode 100644
index 0000000000..473be99b21
Binary files /dev/null and b/superset/assets/images/viz_thumbnails_large/line_multi.png differ
diff --git a/superset/assets/package.json b/superset/assets/package.json
index c4911b3101..5c8863da2a 100644
--- a/superset/assets/package.json
+++ b/superset/assets/package.json
@@ -8,13 +8,12 @@
"test": "spec"
},
"scripts": {
- "test": "mocha --require ignore-styles --compilers js:babel-core/register --require spec/helpers/browser.js --recursive spec/**/*_spec.*",
- "cover": "babel-node node_modules/.bin/babel-istanbul cover _mocha -- --require ignore-styles spec/helpers/browser.js --recursive spec/**/*_spec.*",
- "dev": "NODE_ENV=dev webpack --watch --colors --progress --debug --output-pathinfo --devtool eval-cheap-source-map",
- "dev-slow": "NODE_ENV=dev webpack --watch --colors --progress --debug --output-pathinfo --devtool inline-source-map",
- "dev-fast": "echo 'dev-fast in now replaced by dev'",
- "prod": "NODE_ENV=production node --max_old_space_size=4096 ./node_modules/webpack/bin/webpack.js -p --colors --progress",
- "build": "NODE_ENV=production webpack --colors --progress",
+ "test": "mocha --require ignore-styles --compilers js:babel-core/register --require spec/helpers/browser.js 'spec/**/*_spec.*'",
+ "test:one": "mocha --require ignore-styles --compilers js:babel-core/register --require spec/helpers/browser.js",
+ "cover": "babel-node node_modules/.bin/babel-istanbul cover _mocha -- --compilers babel-core/register --require spec/helpers/browser.js --require ignore-styles 'spec/**/*_spec.*'",
+ "dev": "webpack --mode=development --colors --progress --debug --watch",
+ "prod": "node --max_old_space_size=4096 webpack --mode=production --colors --progress",
+ "build": "webpack --mode=production --colors --progress",
"lint": "eslint --ignore-path=.eslintignore --ext .js,.jsx .",
"lint-fix": "eslint --fix --ignore-path=.eslintignore --ext .js,.jsx .",
"sync-backend": "babel-node --presets env src/syncBackend.js"
@@ -39,18 +38,23 @@
"bugs": {
"url": "https://github.com/apache/incubator-superset/issues"
},
+ "engines": {
+ "node": ">= 6.11.5 <7.0.0 || >= 8.9.0"
+ },
"homepage": "http://superset.apache.org/",
"dependencies": {
- "@data-ui/event-flow": "^0.0.8",
- "@data-ui/sparkline": "^0.0.49",
+ "@data-ui/event-flow": "^0.0.54",
+ "@data-ui/sparkline": "^0.0.54",
+ "@data-ui/xy-chart": "^0.0.61",
+ "@vx/responsive": "0.0.153",
"babel-register": "^6.24.1",
"bootstrap": "^3.3.6",
"bootstrap-slider": "^10.0.0",
- "brace": "^0.10.0",
- "brfs": "^1.4.3",
+ "brace": "^0.11.1",
"classnames": "^2.2.5",
"d3": "^3.5.17",
"d3-cloud": "^1.2.1",
+ "d3-color": "^1.2.0",
"d3-hierarchy": "^1.1.5",
"d3-sankey": "^0.4.2",
"d3-svg-legend": "^1.x",
@@ -58,8 +62,8 @@
"datamaps": "^0.5.8",
"datatables.net-bs": "^1.10.15",
"deck.gl": "^5.1.4",
- "deep-equal": "^1.0.1",
"distributions": "^1.0.0",
+ "dnd-core": "^2.6.0",
"dompurify": "^1.0.3",
"fastdom": "^1.0.6",
"geojson-extent": "^0.3.2",
@@ -74,34 +78,33 @@
"moment": "^2.20.1",
"mousetrap": "^1.6.1",
"mustache": "^2.2.1",
- "npm": "^5.7.1",
"nvd3": "1.8.6",
- "object.entries": "^1.0.4",
- "object.keys": "^0.1.0",
- "object.values": "^1.0.4",
"parse-iso-duration": "^1.0.0",
- "po2json": "^0.4.5",
"prop-types": "^15.6.0",
+ "re-resizable": "^4.3.1",
"react": "^15.6.2",
- "react-ace": "^5.0.1",
+ "react-ace": "^5.10.0",
"react-addons-css-transition-group": "^15.6.0",
"react-addons-shallow-compare": "^15.4.2",
- "react-alert": "^2.3.0",
"react-bootstrap": "^0.31.5",
"react-bootstrap-slider": "2.0.1",
"react-bootstrap-table": "^4.0.2",
"react-color": "^2.13.8",
"react-datetime": "2.9.0",
+ "react-dnd": "^2.5.4",
+ "react-dnd-html5-backend": "^2.5.4",
"react-dom": "^15.6.2",
"react-gravatar": "^2.6.1",
- "react-grid-layout": "0.16.5",
"react-map-gl": "^3.0.4",
+ "react-markdown": "^3.3.0",
"react-redux": "^5.0.2",
"react-resizable": "^1.3.3",
+ "react-search-input": "^0.11.3",
"react-select": "1.2.1",
"react-select-fast-filter-options": "^0.2.1",
"react-sortable-hoc": "^0.6.7",
"react-split-pane": "^0.1.66",
+ "react-sticky": "^6.0.2",
"react-syntax-highlighter": "^5.7.0",
"react-virtualized": "9.3.0",
"react-virtualized-select": "2.4.0",
@@ -109,6 +112,7 @@
"redux": "^3.5.2",
"redux-localstorage": "^0.4.1",
"redux-thunk": "^2.1.0",
+ "redux-undo": "^1.0.0-beta9-9-7",
"shortid": "^2.2.6",
"sprintf-js": "^1.1.1",
"srcdoc-polyfill": "^1.0.0",
@@ -120,23 +124,27 @@
"devDependencies": {
"babel-cli": "^6.14.0",
"babel-core": "^6.10.4",
+ "babel-eslint": "^8.2.2",
"babel-istanbul": "^0.12.2",
- "babel-loader": "^7.0.0",
+ "babel-loader": "^7.1.4",
"babel-plugin-css-modules-transform": "^1.1.0",
+ "babel-plugin-dynamic-import-node": "^1.2.0",
+ "babel-plugin-syntax-dynamic-import": "^6.18.0",
"babel-polyfill": "^6.23.0",
"babel-preset-airbnb": "^2.1.1",
"chai": "^4.0.2",
- "clean-webpack-plugin": "^0.1.16",
+ "clean-webpack-plugin": "^0.1.19",
"css-loader": "^0.28.0",
"enzyme": "^2.0.0",
"eslint": "^4.19.0",
"eslint-config-airbnb": "^15.0.1",
+ "eslint-config-prettier": "^2.9.0",
"eslint-plugin-import": "^2.2.0",
"eslint-plugin-jsx-a11y": "^5.1.1",
+ "eslint-plugin-prettier": "^2.6.0",
"eslint-plugin-react": "^7.0.1",
- "exports-loader": "^0.6.3",
- "extract-text-webpack-plugin": "3.0.0",
- "file-loader": "^0.11.1",
+ "exports-loader": "^0.7.0",
+ "file-loader": "^1.1.11",
"github-changes": "^1.0.4",
"ignore-styles": "^5.0.1",
"imports-loader": "^0.7.1",
@@ -144,9 +152,12 @@
"jsdom": "9.12.0",
"json-loader": "^0.5.4",
"less": "^2.6.1",
- "less-loader": "^4.0.3",
+ "less-loader": "^4.1.0",
+ "mini-css-extract-plugin": "^0.4.0",
"mocha": "^3.2.0",
"npm-check-updates": "^2.14.0",
+ "po2json": "^0.4.5",
+ "prettier": "^1.12.1",
"react-addons-test-utils": "^15.6.2",
"react-test-renderer": "^15.6.2",
"redux-mock-store": "^1.2.3",
@@ -154,9 +165,10 @@
"style-loader": "^0.18.2",
"transform-loader": "^0.2.3",
"uglifyjs-webpack-plugin": "^1.1.0",
- "url-loader": "^0.6.2",
- "webpack": "^3.8.1",
- "webpack-manifest-plugin": "1.3.2",
- "webworkify-webpack": "2.1.0"
+ "url-loader": "^1.0.1",
+ "webpack": "^4.6.0",
+ "webpack-assets-manifest": "^3.0.1",
+ "webpack-cli": "^2.0.10",
+ "webpack-sources": "^1.1.0"
}
}
diff --git a/superset/assets/spec/helpers/browser.js b/superset/assets/spec/helpers/browser.js
index d465d86473..80ce31539f 100644
--- a/superset/assets/spec/helpers/browser.js
+++ b/superset/assets/spec/helpers/browser.js
@@ -3,13 +3,20 @@ import 'babel-polyfill';
import chai from 'chai';
import jsdom from 'jsdom';
-require('babel-register')();
+require('babel-register')({
+ // NOTE: If `dynamic-import-node` is in .babelrc alongside
+ // `syntax-dynamic-import` it breaks webpack's bundle splitting capability.
+ // So only load during runtime on the node-side (in tests)
+ plugins: ['dynamic-import-node'],
+});
const exposedProperties = ['window', 'navigator', 'document'];
global.jsdom = jsdom.jsdom;
global.document = global.jsdom('<!doctype html><html><body></body></html>');
global.window = document.defaultView;
+global.HTMLElement = window.HTMLElement;
+
Object.keys(document.defaultView).forEach((property) => {
if (typeof global[property] === 'undefined') {
exposedProperties.push(property);
@@ -38,5 +45,5 @@ global.sinon.useFakeXMLHttpRequest();
global.window.XMLHttpRequest = global.XMLHttpRequest;
global.window.location = { href: 'about:blank' };
-global.window.performance = { now: () => (new Date().getTime()) };
+global.window.performance = { now: () => new Date().getTime() };
global.$ = require('jquery')(global.window);
diff --git a/superset/assets/spec/javascripts/chart/Chart_spec.jsx b/superset/assets/spec/javascripts/chart/Chart_spec.jsx
index b766d9f8f4..30f24feb65 100644
--- a/superset/assets/spec/javascripts/chart/Chart_spec.jsx
+++ b/superset/assets/spec/javascripts/chart/Chart_spec.jsx
@@ -20,7 +20,7 @@ describe('Chart', () => {
};
const mockedProps = {
...chart,
- chartKey: 'slice_223',
+ id: 223,
containerId: 'slice-container-223',
datasource: {},
formData: {},
@@ -38,10 +38,10 @@ describe('Chart', () => {
<Chart {...mockedProps} />,
);
});
- describe('renderViz', () => {
+ describe('renderVis', () => {
let stub;
beforeEach(() => {
- stub = sinon.stub(wrapper.instance(), 'renderViz');
+ stub = sinon.stub(wrapper.instance(), 'renderVis');
});
afterEach(() => {
stub.restore();
diff --git a/superset/assets/spec/javascripts/components/AlteredSliceTag_spec.jsx b/superset/assets/spec/javascripts/components/AlteredSliceTag_spec.jsx
index 79947201e6..867006993e 100644
--- a/superset/assets/spec/javascripts/components/AlteredSliceTag_spec.jsx
+++ b/superset/assets/spec/javascripts/components/AlteredSliceTag_spec.jsx
@@ -11,7 +11,15 @@ import TooltipWrapper from '../../../src/components/TooltipWrapper';
const defaultProps = {
origFormData: {
- filters: [{ col: 'a', op: '==', val: 'hello' }],
+ adhoc_filters: [
+ {
+ clause: 'WHERE',
+ comparator: 'hello',
+ expressionType: 'SIMPLE',
+ operator: '==',
+ subject: 'a',
+ },
+ ],
y_axis_bounds: [10, 20],
column_collection: [{ 1: 'a', b: ['6', 'g'] }],
bool: false,
@@ -21,7 +29,15 @@ const defaultProps = {
ever: { a: 'b', c: 'd' },
},
currentFormData: {
- filters: [{ col: 'b', op: 'in', val: ['hello', 'my', 'name'] }],
+ adhoc_filters: [
+ {
+ clause: 'WHERE',
+ comparator: ['hello', 'my', 'name'],
+ expressionType: 'SIMPLE',
+ operator: 'in',
+ subject: 'b',
+ },
+ ],
y_axis_bounds: [15, 16],
column_collection: [{ 1: 'a', b: [9, '15'], t: 'gggg' }],
bool: true,
@@ -33,9 +49,25 @@ const defaultProps = {
};
const expectedDiffs = {
- filters: {
- before: [{ col: 'a', op: '==', val: 'hello' }],
- after: [{ col: 'b', op: 'in', val: ['hello', 'my', 'name'] }],
+ adhoc_filters: {
+ before: [
+ {
+ clause: 'WHERE',
+ comparator: 'hello',
+ expressionType: 'SIMPLE',
+ operator: '==',
+ subject: 'a',
+ },
+ ],
+ after: [
+ {
+ clause: 'WHERE',
+ comparator: ['hello', 'my', 'name'],
+ expressionType: 'SIMPLE',
+ operator: 'in',
+ subject: 'b',
+ },
+ ],
},
y_axis_bounds: {
before: [10, 20],
@@ -211,25 +243,49 @@ describe('AlteredSliceTag', () => {
});
it('returns "[]" for empty filters', () => {
- expect(wrapper.instance().formatValue([], 'filters')).to.equal('[]');
+ expect(wrapper.instance().formatValue([], 'adhoc_filters')).to.equal('[]');
});
it('correctly formats filters with array values', () => {
const filters = [
- { col: 'a', op: 'in', val: ['1', 'g', '7', 'ho'] },
- { col: 'b', op: 'not in', val: ['hu', 'ho', 'ha'] },
+ {
+ clause: 'WHERE',
+ comparator: ['1', 'g', '7', 'ho'],
+ expressionType: 'SIMPLE',
+ operator: 'in',
+ subject: 'a',
+ },
+ {
+ clause: 'WHERE',
+ comparator: ['hu', 'ho', 'ha'],
+ expressionType: 'SIMPLE',
+ operator: 'not in',
+ subject: 'b',
+ },
];
const expected = 'a in [1, g, 7, ho], b not in [hu, ho, ha]';
- expect(wrapper.instance().formatValue(filters, 'filters')).to.equal(expected);
+ expect(wrapper.instance().formatValue(filters, 'adhoc_filters')).to.equal(expected);
});
it('correctly formats filters with string values', () => {
const filters = [
- { col: 'a', op: '==', val: 'gucci' },
- { col: 'b', op: 'LIKE', val: 'moshi moshi' },
+ {
+ clause: 'WHERE',
+ comparator: 'gucci',
+ expressionType: 'SIMPLE',
+ operator: '==',
+ subject: 'a',
+ },
+ {
+ clause: 'WHERE',
+ comparator: 'moshi moshi',
+ expressionType: 'SIMPLE',
+ operator: 'LIKE',
+ subject: 'b',
+ },
];
const expected = 'a == gucci, b LIKE moshi moshi';
- expect(wrapper.instance().formatValue(filters, 'filters')).to.equal(expected);
+ expect(wrapper.instance().formatValue(filters, 'adhoc_filters')).to.equal(expected);
});
});
});
diff --git a/superset/assets/spec/javascripts/dashboard/.eslintrc b/superset/assets/spec/javascripts/dashboard/.eslintrc
new file mode 100644
index 0000000000..a3f86e3a17
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/.eslintrc
@@ -0,0 +1,33 @@
+{
+ "extends": "prettier",
+ "plugins": ["prettier"],
+ "rules": {
+ "prefer-template": 2,
+ "new-cap": 2,
+ "no-restricted-syntax": 2,
+ "guard-for-in": 2,
+ "prefer-arrow-callback": 2,
+ "func-names": 2,
+ "react/jsx-no-bind": 2,
+ "no-confusing-arrow": 2,
+ "jsx-a11y/no-static-element-interactions": 2,
+ "jsx-a11y/anchor-has-content": 2,
+ "react/require-default-props": 2,
+ "no-plusplus": 2,
+ "no-mixed-operators": 0,
+ "no-continue": 2,
+ "no-bitwise": 2,
+ "no-undef": 2,
+ "no-multi-assign": 2,
+ "no-restricted-properties": 2,
+ "no-prototype-builtins": 2,
+ "jsx-a11y/href-no-hash": 2,
+ "class-methods-use-this": 2,
+ "import/no-named-as-default": 2,
+ "import/prefer-default-export": 2,
+ "react/no-unescaped-entities": 2,
+ "react/no-string-refs": 2,
+ "react/jsx-indent": 0,
+ "prettier/prettier": "error"
+ }
+}
diff --git a/superset/assets/spec/javascripts/dashboard/.prettierrc b/superset/assets/spec/javascripts/dashboard/.prettierrc
new file mode 100644
index 0000000000..a20502b7f0
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/.prettierrc
@@ -0,0 +1,4 @@
+{
+ "singleQuote": true,
+ "trailingComma": "all"
+}
diff --git a/superset/assets/spec/javascripts/dashboard/Dashboard_spec.jsx b/superset/assets/spec/javascripts/dashboard/Dashboard_spec.jsx
deleted file mode 100644
index c6e94d87d9..0000000000
--- a/superset/assets/spec/javascripts/dashboard/Dashboard_spec.jsx
+++ /dev/null
@@ -1,182 +0,0 @@
-import React from 'react';
-import { shallow } from 'enzyme';
-import { describe, it } from 'mocha';
-import { expect } from 'chai';
-import sinon from 'sinon';
-
-import * as dashboardActions from '../../../src/dashboard/actions';
-import * as chartActions from '../../../src/chart/chartAction';
-import Dashboard from '../../../src/dashboard/components/Dashboard';
-import { defaultFilters, dashboard, charts } from './fixtures';
-
-describe('Dashboard', () => {
- const mockedProps = {
- actions: { ...chartActions, ...dashboardActions },
- initMessages: [],
- dashboard: dashboard.dashboard,
- slices: charts,
- filters: dashboard.filters,
- datasources: dashboard.datasources,
- refresh: false,
- timeout: 60,
- isStarred: false,
- userId: dashboard.userId,
- };
-
- it('should render', () => {
- const wrapper = shallow(<Dashboard {...mockedProps} />);
- expect(wrapper.find('#dashboard-container')).to.have.length(1);
- expect(wrapper.instance().getAllSlices()).to.have.length(3);
- });
-
- it('should handle metadata default_filters', () => {
- const wrapper = shallow(<Dashboard {...mockedProps} />);
- expect(wrapper.instance().props.filters).deep.equal(defaultFilters);
- });
-
- describe('getFormDataExtra', () => {
- let wrapper;
- let selectedSlice;
- beforeEach(() => {
- wrapper = shallow(<Dashboard {...mockedProps} />);
- selectedSlice = wrapper.instance().props.dashboard.slices[1];
- });
-
- it('should carry default_filters', () => {
- const extraFilters = wrapper.instance().getFormDataExtra(selectedSlice).extra_filters;
- expect(extraFilters[0]).to.deep.equal({ col: 'region', op: 'in', val: [] });
- expect(extraFilters[1]).to.deep.equal({ col: 'country_name', op: 'in', val: ['United States'] });
- });
-
- it('should carry updated filter', () => {
- wrapper.setProps({
- filters: {
- 256: { region: [] },
- 257: { country_name: ['France'] },
- },
- });
- const extraFilters = wrapper.instance().getFormDataExtra(selectedSlice).extra_filters;
- expect(extraFilters[1]).to.deep.equal({ col: 'country_name', op: 'in', val: ['France'] });
- });
- });
-
- describe('refreshExcept', () => {
- let wrapper;
- let spy;
- beforeEach(() => {
- wrapper = shallow(<Dashboard {...mockedProps} />);
- spy = sinon.spy(wrapper.instance(), 'fetchSlices');
- });
- afterEach(() => {
- spy.restore();
- });
-
- it('should not refresh filter slice', () => {
- const filterKey = Object.keys(defaultFilters)[1];
- wrapper.instance().refreshExcept(filterKey);
- expect(spy.callCount).to.equal(1);
- expect(spy.getCall(0).args[0].length).to.equal(1);
- });
-
- it('should refresh all slices', () => {
- wrapper.instance().refreshExcept();
- expect(spy.callCount).to.equal(1);
- expect(spy.getCall(0).args[0].length).to.equal(3);
- });
- });
-
- describe('componentDidUpdate', () => {
- let wrapper;
- let refreshExceptSpy;
- let fetchSlicesStub;
- let prevProp;
- beforeEach(() => {
- wrapper = shallow(<Dashboard {...mockedProps} />);
- prevProp = wrapper.instance().props;
- refreshExceptSpy = sinon.spy(wrapper.instance(), 'refreshExcept');
- fetchSlicesStub = sinon.stub(wrapper.instance(), 'fetchSlices');
- });
- afterEach(() => {
- fetchSlicesStub.restore();
- refreshExceptSpy.restore();
- });
-
- describe('should check if filter has change', () => {
- beforeEach(() => {
- refreshExceptSpy.reset();
- });
- it('no change', () => {
- wrapper.setProps({
- refresh: true,
- filters: {
- 256: { region: [] },
- 257: { country_name: ['United States'] },
- },
- });
- wrapper.instance().componentDidUpdate(prevProp);
- expect(refreshExceptSpy.callCount).to.equal(0);
- });
-
- it('remove filter', () => {
- wrapper.setProps({
- refresh: true,
- filters: {
- 256: { region: [] },
- },
- });
- wrapper.instance().componentDidUpdate(prevProp);
- expect(refreshExceptSpy.callCount).to.equal(1);
- });
-
- it('change filter', () => {
- wrapper.setProps({
- refresh: true,
- filters: {
- 256: { region: [] },
- 257: { country_name: ['Canada'] },
- },
- });
- wrapper.instance().componentDidUpdate(prevProp);
- expect(refreshExceptSpy.callCount).to.equal(1);
- });
-
- it('add filter', () => {
- wrapper.setProps({
- refresh: true,
- filters: {
- 256: { region: [] },
- 257: { country_name: ['Canada'] },
- 258: { another_filter: ['new'] },
- },
- });
- wrapper.instance().componentDidUpdate(prevProp);
- expect(refreshExceptSpy.callCount).to.equal(1);
- });
- });
-
- it('should refresh if refresh flag is true', () => {
- wrapper.setProps({
- refresh: true,
- filters: {
- 256: { region: ['Asian'] },
- },
- });
- wrapper.instance().componentDidUpdate(prevProp);
- const fetchArgs = fetchSlicesStub.lastCall.args[0];
- expect(fetchArgs).to.have.length(2);
- });
-
- it('should not refresh filter_immune_slices', () => {
- wrapper.setProps({
- refresh: true,
- filters: {
- 256: { region: [] },
- 257: { country_name: ['Canada'] },
- },
- });
- wrapper.instance().componentDidUpdate(prevProp);
- const fetchArgs = fetchSlicesStub.lastCall.args[0];
- expect(fetchArgs).to.have.length(1);
- });
- });
-});
diff --git a/superset/assets/spec/javascripts/dashboard/actions/dashboardLayout_spec.js b/superset/assets/spec/javascripts/dashboard/actions/dashboardLayout_spec.js
new file mode 100644
index 0000000000..e58bb11a48
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/actions/dashboardLayout_spec.js
@@ -0,0 +1,454 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+import sinon from 'sinon';
+
+import { ActionCreators as UndoActionCreators } from 'redux-undo';
+
+import {
+ UPDATE_COMPONENTS,
+ updateComponents,
+ DELETE_COMPONENT,
+ deleteComponent,
+ CREATE_COMPONENT,
+ CREATE_TOP_LEVEL_TABS,
+ createTopLevelTabs,
+ DELETE_TOP_LEVEL_TABS,
+ deleteTopLevelTabs,
+ resizeComponent,
+ MOVE_COMPONENT,
+ handleComponentDrop,
+ updateDashboardTitle,
+ undoLayoutAction,
+ redoLayoutAction,
+} from '../../../../src/dashboard/actions/dashboardLayout';
+
+import { setUnsavedChanges } from '../../../../src/dashboard/actions/dashboardState';
+import { addInfoToast } from '../../../../src/messageToasts/actions';
+
+import {
+ DASHBOARD_GRID_TYPE,
+ ROW_TYPE,
+ CHART_TYPE,
+ TABS_TYPE,
+ TAB_TYPE,
+} from '../../../../src/dashboard/util/componentTypes';
+
+import {
+ DASHBOARD_HEADER_ID,
+ DASHBOARD_GRID_ID,
+ DASHBOARD_ROOT_ID,
+ NEW_COMPONENTS_SOURCE_ID,
+ NEW_ROW_ID,
+} from '../../../../src/dashboard/util/constants';
+
+describe('dashboardLayout actions', () => {
+ const mockState = {
+ dashboardState: {
+ hasUnsavedChanges: true, // don't dispatch setUnsavedChanges() after every action
+ },
+ dashboardInfo: {},
+ dashboardLayout: {
+ past: [],
+ present: {},
+ future: {},
+ },
+ };
+
+ function setup(stateOverrides) {
+ const state = { ...mockState, ...stateOverrides };
+ const getState = sinon.spy(() => state);
+ const dispatch = sinon.spy();
+
+ return { getState, dispatch, state };
+ }
+
+ describe('updateComponents', () => {
+ it('should dispatch an updateLayout action', () => {
+ const { getState, dispatch } = setup();
+ const nextComponents = { 1: {} };
+ const thunk = updateComponents(nextComponents);
+ thunk(dispatch, getState);
+ expect(dispatch.callCount).to.equal(1);
+ expect(dispatch.getCall(0).args[0]).to.deep.equal({
+ type: UPDATE_COMPONENTS,
+ payload: { nextComponents },
+ });
+ });
+
+ it('should dispatch a setUnsavedChanges action if hasUnsavedChanges=false', () => {
+ const { getState, dispatch } = setup({
+ dashboardState: { hasUnsavedChanges: false },
+ });
+ const nextComponents = { 1: {} };
+ const thunk = updateComponents(nextComponents);
+ thunk(dispatch, getState);
+ expect(dispatch.callCount).to.equal(2);
+ expect(dispatch.getCall(1).args[0]).to.deep.equal(
+ setUnsavedChanges(true),
+ );
+ });
+ });
+
+ describe('deleteComponents', () => {
+ it('should dispatch an deleteComponent action', () => {
+ const { getState, dispatch } = setup();
+ const thunk = deleteComponent('id', 'parentId');
+ thunk(dispatch, getState);
+ expect(dispatch.callCount).to.equal(1);
+ expect(dispatch.getCall(0).args[0]).to.deep.equal({
+ type: DELETE_COMPONENT,
+ payload: { id: 'id', parentId: 'parentId' },
+ });
+ });
+
+ it('should dispatch a setUnsavedChanges action if hasUnsavedChanges=false', () => {
+ const { getState, dispatch } = setup({
+ dashboardState: { hasUnsavedChanges: false },
+ });
+ const thunk = deleteComponent('id', 'parentId');
+ thunk(dispatch, getState);
+ expect(dispatch.callCount).to.equal(2);
+ expect(dispatch.getCall(1).args[0]).to.deep.equal(
+ setUnsavedChanges(true),
+ );
+ });
+ });
+
+ describe('updateDashboardTitle', () => {
+ it('should dispatch an updateComponent action for the header component', () => {
+ const { getState, dispatch } = setup();
+ const thunk1 = updateDashboardTitle('new text');
+ thunk1(dispatch, getState);
+
+ const thunk2 = dispatch.getCall(0).args[0];
+ thunk2(dispatch, getState);
+
+ expect(dispatch.getCall(1).args[0]).to.deep.equal({
+ type: UPDATE_COMPONENTS,
+ payload: {
+ nextComponents: {
+ [DASHBOARD_HEADER_ID]: {
+ meta: { text: 'new text' },
+ },
+ },
+ },
+ });
+
+ expect(dispatch.callCount).to.equal(2);
+ });
+ });
+
+ describe('createTopLevelTabs', () => {
+ it('should dispatch a createTopLevelTabs action', () => {
+ const { getState, dispatch } = setup();
+ const dropResult = {};
+ const thunk = createTopLevelTabs(dropResult);
+ thunk(dispatch, getState);
+ expect(dispatch.callCount).to.equal(1);
+ expect(dispatch.getCall(0).args[0]).to.deep.equal({
+ type: CREATE_TOP_LEVEL_TABS,
+ payload: { dropResult },
+ });
+ });
+
+ it('should dispatch a setUnsavedChanges action if hasUnsavedChanges=false', () => {
+ const { getState, dispatch } = setup({
+ dashboardState: { hasUnsavedChanges: false },
+ });
+ const dropResult = {};
+ const thunk = createTopLevelTabs(dropResult);
+ thunk(dispatch, getState);
+ expect(dispatch.callCount).to.equal(2);
+ expect(dispatch.getCall(1).args[0]).to.deep.equal(
+ setUnsavedChanges(true),
+ );
+ });
+ });
+
+ describe('deleteTopLevelTabs', () => {
+ it('should dispatch a deleteTopLevelTabs action', () => {
+ const { getState, dispatch } = setup();
+ const dropResult = {};
+ const thunk = deleteTopLevelTabs(dropResult);
+ thunk(dispatch, getState);
+ expect(dispatch.callCount).to.equal(1);
+ expect(dispatch.getCall(0).args[0]).to.deep.equal({
+ type: DELETE_TOP_LEVEL_TABS,
+ payload: {},
+ });
+ });
+
+ it('should dispatch a setUnsavedChanges action if hasUnsavedChanges=false', () => {
+ const { getState, dispatch } = setup({
+ dashboardState: { hasUnsavedChanges: false },
+ });
+ const dropResult = {};
+ const thunk = deleteTopLevelTabs(dropResult);
+ thunk(dispatch, getState);
+ expect(dispatch.callCount).to.equal(2);
+ expect(dispatch.getCall(1).args[0]).to.deep.equal(
+ setUnsavedChanges(true),
+ );
+ });
+ });
+
+ describe('resizeComponent', () => {
+ const dashboardLayout = {
+ ...mockState.dashboardLayout,
+ present: {
+ 1: {
+ id: 1,
+ children: [],
+ meta: {
+ width: 1,
+ height: 1,
+ },
+ },
+ },
+ };
+
+ it('should update the size of the component', () => {
+ const { getState, dispatch } = setup({
+ dashboardLayout,
+ });
+
+ const thunk1 = resizeComponent({ id: 1, width: 10, height: 3 });
+ thunk1(dispatch, getState);
+
+ const thunk2 = dispatch.getCall(0).args[0];
+ thunk2(dispatch, getState);
+
+ expect(dispatch.callCount).to.equal(2);
+ expect(dispatch.getCall(1).args[0]).to.deep.equal({
+ type: UPDATE_COMPONENTS,
+ payload: {
+ nextComponents: {
+ 1: {
+ id: 1,
+ children: [],
+ meta: {
+ width: 10,
+ height: 3,
+ },
+ },
+ },
+ },
+ });
+
+ expect(dispatch.callCount).to.equal(2);
+ });
+
+ it('should dispatch a setUnsavedChanges action if hasUnsavedChanges=false', () => {
+ const { getState, dispatch } = setup({
+ dashboardState: { hasUnsavedChanges: false },
+ dashboardLayout,
+ });
+ const thunk1 = resizeComponent({ id: 1, width: 10, height: 3 });
+ thunk1(dispatch, getState);
+
+ const thunk2 = dispatch.getCall(0).args[0];
+ thunk2(dispatch, getState);
+
+ expect(dispatch.callCount).to.equal(3);
+ });
+ });
+
+ describe('handleComponentDrop', () => {
+ it('should create a component if it is new', () => {
+ const { getState, dispatch } = setup();
+ const dropResult = {
+ source: { id: NEW_COMPONENTS_SOURCE_ID },
+ destination: { id: DASHBOARD_GRID_ID, type: DASHBOARD_GRID_TYPE },
+ dragging: { id: NEW_ROW_ID, type: ROW_TYPE },
+ };
+
+ const handleComponentDropThunk = handleComponentDrop(dropResult);
+ handleComponentDropThunk(dispatch, getState);
+
+ const createComponentThunk = dispatch.getCall(0).args[0];
+ createComponentThunk(dispatch, getState);
+
+ expect(dispatch.getCall(1).args[0]).to.deep.equal({
+ type: CREATE_COMPONENT,
+ payload: {
+ dropResult,
+ },
+ });
+
+ expect(dispatch.callCount).to.equal(2);
+ });
+
+ it('should move a component if the component is not new', () => {
+ const { getState, dispatch } = setup({
+ dashboardLayout: {
+ // if 'dragging' is not only child will dispatch deleteComponent thunk
+ present: { id: { type: ROW_TYPE, children: ['_'] } },
+ },
+ });
+ const dropResult = {
+ source: { id: 'id', index: 0, type: ROW_TYPE },
+ destination: { id: DASHBOARD_GRID_ID, type: DASHBOARD_GRID_TYPE },
+ dragging: { id: 'dragging', type: ROW_TYPE },
+ };
+
+ const handleComponentDropThunk = handleComponentDrop(dropResult);
+ handleComponentDropThunk(dispatch, getState);
+
+ const moveComponentThunk = dispatch.getCall(0).args[0];
+ moveComponentThunk(dispatch, getState);
+
+ expect(dispatch.getCall(1).args[0]).to.deep.equal({
+ type: MOVE_COMPONENT,
+ payload: {
+ dropResult,
+ },
+ });
+
+ expect(dispatch.callCount).to.equal(2);
+ });
+
+ it('should dispatch a toast if the drop overflows the destination', () => {
+ const { getState, dispatch } = setup({
+ dashboardLayout: {
+ present: {
+ source: { type: ROW_TYPE },
+ destination: { type: ROW_TYPE, children: ['rowChild'] },
+ dragging: { type: CHART_TYPE, meta: { width: 1 } },
+ rowChild: { type: CHART_TYPE, meta: { width: 12 } },
+ },
+ },
+ });
+ const dropResult = {
+ source: { id: 'source', type: ROW_TYPE },
+ destination: { id: 'destination', type: ROW_TYPE },
+ dragging: { id: 'dragging', type: CHART_TYPE },
+ };
+
+ const thunk = handleComponentDrop(dropResult);
+ thunk(dispatch, getState);
+ expect(dispatch.getCall(0).args[0].type).to.deep.equal(
+ addInfoToast('').type,
+ );
+
+ expect(dispatch.callCount).to.equal(1);
+ });
+
+ it('should delete a parent Row or Tabs if the moved child was the only child', () => {
+ const { getState, dispatch } = setup({
+ dashboardLayout: {
+ present: {
+ parentId: { id: 'parentId', children: ['tabsId'] },
+ tabsId: { id: 'tabsId', type: TABS_TYPE, children: [] },
+ [DASHBOARD_GRID_ID]: {
+ id: DASHBOARD_GRID_ID,
+ type: DASHBOARD_GRID_TYPE,
+ },
+ tabId: { id: 'tabId', type: TAB_TYPE },
+ },
+ },
+ });
+
+ const dropResult = {
+ source: { id: 'tabsId', type: TABS_TYPE },
+ destination: { id: DASHBOARD_GRID_ID, type: DASHBOARD_GRID_TYPE },
+ dragging: { id: 'tabId', type: TAB_TYPE },
+ };
+
+ const moveThunk = handleComponentDrop(dropResult);
+ moveThunk(dispatch, getState);
+
+ // first call is move action which is not a thunk
+ const deleteThunk = dispatch.getCall(1).args[0];
+ deleteThunk(dispatch, getState);
+
+ expect(dispatch.getCall(2).args[0]).to.deep.equal({
+ type: DELETE_COMPONENT,
+ payload: {
+ id: 'tabsId',
+ parentId: 'parentId',
+ },
+ });
+
+ // move thunk, delete thunk, delete result actions
+ expect(dispatch.callCount).to.equal(3);
+ });
+
+ it('should create top-level tabs if dropped on root', () => {
+ const { getState, dispatch } = setup();
+ const dropResult = {
+ source: { id: NEW_COMPONENTS_SOURCE_ID },
+ destination: { id: DASHBOARD_ROOT_ID },
+ dragging: { id: NEW_ROW_ID, type: ROW_TYPE },
+ };
+
+ const thunk1 = handleComponentDrop(dropResult);
+ thunk1(dispatch, getState);
+
+ const thunk2 = dispatch.getCall(0).args[0];
+ thunk2(dispatch, getState);
+
+ expect(dispatch.getCall(1).args[0]).to.deep.equal({
+ type: CREATE_TOP_LEVEL_TABS,
+ payload: {
+ dropResult,
+ },
+ });
+
+ expect(dispatch.callCount).to.equal(2);
+ });
+ });
+
+ describe('undoLayoutAction', () => {
+ it('should dispatch a redux-undo .undo() action ', () => {
+ const { getState, dispatch } = setup({
+ dashboardLayout: { past: ['non-empty'] },
+ });
+ const thunk = undoLayoutAction();
+ thunk(dispatch, getState);
+
+ expect(dispatch.callCount).to.equal(1);
+ expect(dispatch.getCall(0).args[0]).to.deep.equal(
+ UndoActionCreators.undo(),
+ );
+ });
+
+ it('should dispatch a setUnsavedChanges(false) action history length is zero', () => {
+ const { getState, dispatch } = setup({
+ dashboardLayout: { past: [] },
+ });
+ const thunk = undoLayoutAction();
+ thunk(dispatch, getState);
+
+ expect(dispatch.callCount).to.equal(2);
+ expect(dispatch.getCall(1).args[0]).to.deep.equal(
+ setUnsavedChanges(false),
+ );
+ });
+ });
+
+ describe('redoLayoutAction', () => {
+ it('should dispatch a redux-undo .redo() action ', () => {
+ const { getState, dispatch } = setup();
+ const thunk = redoLayoutAction();
+ thunk(dispatch, getState);
+
+ expect(dispatch.callCount).to.equal(1);
+ expect(dispatch.getCall(0).args[0]).to.deep.equal(
+ UndoActionCreators.redo(),
+ );
+ });
+
+ it('should dispatch a setUnsavedChanges(true) action if hasUnsavedChanges=false', () => {
+ const { getState, dispatch } = setup({
+ dashboardState: { hasUnsavedChanges: false },
+ });
+ const thunk = redoLayoutAction();
+ thunk(dispatch, getState);
+
+ expect(dispatch.callCount).to.equal(2);
+ expect(dispatch.getCall(1).args[0]).to.deep.equal(
+ setUnsavedChanges(true),
+ );
+ });
+ });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/CodeModal_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/CodeModal_spec.jsx
similarity index 72%
rename from superset/assets/spec/javascripts/dashboard/CodeModal_spec.jsx
rename to superset/assets/spec/javascripts/dashboard/components/CodeModal_spec.jsx
index a93c5573ed..d316dc3d38 100644
--- a/superset/assets/spec/javascripts/dashboard/CodeModal_spec.jsx
+++ b/superset/assets/spec/javascripts/dashboard/components/CodeModal_spec.jsx
@@ -3,16 +3,14 @@ import { mount } from 'enzyme';
import { describe, it } from 'mocha';
import { expect } from 'chai';
-import CodeModal from '../../../src/dashboard/components/CodeModal';
+import CodeModal from '../../../../src/dashboard/components/CodeModal';
describe('CodeModal', () => {
const mockedProps = {
triggerNode: <i className="fa fa-edit" />,
};
it('is valid', () => {
- expect(
- React.isValidElement(<CodeModal {...mockedProps} />),
- ).to.equal(true);
+ expect(React.isValidElement(<CodeModal {...mockedProps} />)).to.equal(true);
});
it('renders the trigger node', () => {
const wrapper = mount(<CodeModal {...mockedProps} />);
diff --git a/superset/assets/spec/javascripts/dashboard/CssEditor_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/CssEditor_spec.jsx
similarity index 72%
rename from superset/assets/spec/javascripts/dashboard/CssEditor_spec.jsx
rename to superset/assets/spec/javascripts/dashboard/components/CssEditor_spec.jsx
index c325dc1b78..8c991fa489 100644
--- a/superset/assets/spec/javascripts/dashboard/CssEditor_spec.jsx
+++ b/superset/assets/spec/javascripts/dashboard/components/CssEditor_spec.jsx
@@ -3,16 +3,14 @@ import { mount } from 'enzyme';
import { describe, it } from 'mocha';
import { expect } from 'chai';
-import CssEditor from '../../../src/dashboard/components/CssEditor';
+import CssEditor from '../../../../src/dashboard/components/CssEditor';
describe('CssEditor', () => {
const mockedProps = {
triggerNode: <i className="fa fa-edit" />,
};
it('is valid', () => {
- expect(
- React.isValidElement(<CssEditor {...mockedProps} />),
- ).to.equal(true);
+ expect(React.isValidElement(<CssEditor {...mockedProps} />)).to.equal(true);
});
it('renders the trigger node', () => {
const wrapper = mount(<CssEditor {...mockedProps} />);
diff --git a/superset/assets/spec/javascripts/dashboard/components/DashboardBuilder_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/DashboardBuilder_spec.jsx
new file mode 100644
index 0000000000..4c3185fecd
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/DashboardBuilder_spec.jsx
@@ -0,0 +1,138 @@
+import { Provider } from 'react-redux';
+import React from 'react';
+import { shallow, mount } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import ParentSize from '@vx/responsive/build/components/ParentSize';
+import { Sticky, StickyContainer } from 'react-sticky';
+import { TabContainer, TabContent, TabPane } from 'react-bootstrap';
+
+import BuilderComponentPane from '../../../../src/dashboard/components/BuilderComponentPane';
+import DashboardBuilder from '../../../../src/dashboard/components/DashboardBuilder';
+import DashboardComponent from '../../../../src/dashboard/containers/DashboardComponent';
+import DashboardHeader from '../../../../src/dashboard/containers/DashboardHeader';
+import DashboardGrid from '../../../../src/dashboard/containers/DashboardGrid';
+import WithDragDropContext from '../helpers/WithDragDropContext';
+import {
+ dashboardLayout as undoableDashboardLayout,
+ dashboardLayoutWithTabs as undoableDashboardLayoutWithTabs,
+} from '../fixtures/mockDashboardLayout';
+
+import { mockStore, mockStoreWithTabs } from '../fixtures/mockStore';
+
+const dashboardLayout = undoableDashboardLayout.present;
+const layoutWithTabs = undoableDashboardLayoutWithTabs.present;
+
+describe('DashboardBuilder', () => {
+ const props = {
+ dashboardLayout,
+ deleteTopLevelTabs() {},
+ editMode: false,
+ showBuilderPane: false,
+ handleComponentDrop() {},
+ toggleBuilderPane() {},
+ };
+
+ function setup(overrideProps, useProvider = false, store = mockStore) {
+ const builder = <DashboardBuilder {...props} {...overrideProps} />;
+ return useProvider
+ ? mount(
+ <Provider store={store}>
+ <WithDragDropContext>{builder}</WithDragDropContext>
+ </Provider>,
+ )
+ : shallow(builder);
+ }
+
+ it('should render a StickyContainer with class "dashboard"', () => {
+ const wrapper = setup();
+ const stickyContainer = wrapper.find(StickyContainer);
+ expect(stickyContainer).to.have.length(1);
+ expect(stickyContainer.prop('className')).to.equal('dashboard');
+ });
+
+ it('should add the "dashboard--editing" class if editMode=true', () => {
+ const wrapper = setup({ editMode: true });
+ const stickyContainer = wrapper.find(StickyContainer);
+ expect(stickyContainer.prop('className')).to.equal(
+ 'dashboard dashboard--editing',
+ );
+ });
+
+ it('should render a DragDroppable DashboardHeader', () => {
+ const wrapper = setup(null, true);
+ expect(wrapper.find(DashboardHeader)).to.have.length(1);
+ });
+
+ it('should render a Sticky top-level Tabs if the dashboard has tabs', () => {
+ const wrapper = setup(
+ { dashboardLayout: layoutWithTabs },
+ true,
+ mockStoreWithTabs,
+ );
+ const sticky = wrapper.find(Sticky);
+ const dashboardComponent = sticky.find(DashboardComponent);
+
+ const tabChildren = layoutWithTabs.TABS_ID.children;
+ expect(sticky).to.have.length(1);
+ expect(dashboardComponent).to.have.length(1 + tabChildren.length); // tab + tabs
+ expect(dashboardComponent.at(0).prop('id')).to.equal('TABS_ID');
+ tabChildren.forEach((tabId, i) => {
+ expect(dashboardComponent.at(i + 1).prop('id')).to.equal(tabId);
+ });
+ });
+
+ it('should render a TabContainer and TabContent', () => {
+ const wrapper = setup({ dashboardLayout: layoutWithTabs });
+ const parentSize = wrapper.find(ParentSize).dive();
+ expect(parentSize.find(TabContainer)).to.have.length(1);
+ expect(parentSize.find(TabContent)).to.have.length(1);
+ });
+
+ it('should set animation=true, mountOnEnter=true, and unmounOnExit=false on TabContainer for perf', () => {
+ const wrapper = setup({ dashboardLayout: layoutWithTabs });
+ const tabProps = wrapper
+ .find(ParentSize)
+ .dive()
+ .find(TabContainer)
+ .props();
+ expect(tabProps.animation).to.equal(true);
+ expect(tabProps.mountOnEnter).to.equal(true);
+ expect(tabProps.unmountOnExit).to.equal(false);
+ });
+
+ it('should render a TabPane and DashboardGrid for each Tab', () => {
+ const wrapper = setup({ dashboardLayout: layoutWithTabs });
+ const parentSize = wrapper.find(ParentSize).dive();
+
+ const expectedCount = layoutWithTabs.TABS_ID.children.length;
+ expect(parentSize.find(TabPane)).to.have.length(expectedCount);
+ expect(parentSize.find(DashboardGrid)).to.have.length(expectedCount);
+ });
+
+ it('should render a BuilderComponentPane if editMode=showBuilderPane=true', () => {
+ const wrapper = setup();
+ expect(wrapper.find(BuilderComponentPane)).to.have.length(0);
+
+ wrapper.setProps({ ...props, editMode: true, showBuilderPane: true });
+ expect(wrapper.find(BuilderComponentPane)).to.have.length(1);
+ });
+
+ it('should change tabs if a top-level Tab is clicked', () => {
+ const wrapper = setup(
+ { dashboardLayout: layoutWithTabs },
+ true,
+ mockStoreWithTabs,
+ );
+
+ expect(wrapper.find(TabContainer).prop('activeKey')).to.equal(0);
+
+ wrapper
+ .find('.dashboard-component-tabs .nav-tabs a')
+ .at(1)
+ .simulate('click');
+
+ expect(wrapper.find(TabContainer).prop('activeKey')).to.equal(1);
+ });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/DashboardGrid_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/DashboardGrid_spec.jsx
new file mode 100644
index 0000000000..d11c37f331
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/DashboardGrid_spec.jsx
@@ -0,0 +1,84 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+import sinon from 'sinon';
+
+import DashboardComponent from '../../../../src/dashboard/containers/DashboardComponent';
+import DashboardGrid from '../../../../src/dashboard/components/DashboardGrid';
+import DragDroppable from '../../../../src/dashboard/components/dnd/DragDroppable';
+import newComponentFactory from '../../../../src/dashboard/util/newComponentFactory';
+
+import { DASHBOARD_GRID_TYPE } from '../../../../src/dashboard/util/componentTypes';
+import { GRID_COLUMN_COUNT } from '../../../../src/dashboard/util/constants';
+
+describe('DashboardGrid', () => {
+ const props = {
+ depth: 1,
+ editMode: false,
+ gridComponent: {
+ ...newComponentFactory(DASHBOARD_GRID_TYPE),
+ children: ['a'],
+ },
+ handleComponentDrop() {},
+ resizeComponent() {},
+ width: 500,
+ };
+
+ function setup(overrideProps) {
+ const wrapper = shallow(<DashboardGrid {...props} {...overrideProps} />);
+ return wrapper;
+ }
+
+ it('should render a div with class "dashboard-grid"', () => {
+ const wrapper = setup();
+ expect(wrapper.find('.dashboard-grid')).to.have.length(1);
+ });
+
+ it('should render one DashboardComponent for each gridComponent child', () => {
+ const wrapper = setup({
+ gridComponent: { ...props.gridComponent, children: ['a', 'b'] },
+ });
+ expect(wrapper.find(DashboardComponent)).to.have.length(2);
+ });
+
+ it('should render two empty DragDroppables in editMode to increase the drop target zone', () => {
+ const viewMode = setup({ editMode: false });
+ const editMode = setup({ editMode: true });
+ expect(viewMode.find(DragDroppable)).to.have.length(0);
+ expect(editMode.find(DragDroppable)).to.have.length(2);
+ });
+
+ it('should render grid column guides when resizing', () => {
+ const wrapper = setup({ editMode: true });
+ expect(wrapper.find('.grid-column-guide')).to.have.length(0);
+
+ wrapper.setState({ isResizing: true });
+
+ expect(wrapper.find('.grid-column-guide')).to.have.length(
+ GRID_COLUMN_COUNT,
+ );
+ });
+
+ it('should render a grid row guide when resizing', () => {
+ const wrapper = setup();
+ expect(wrapper.find('.grid-row-guide')).to.have.length(0);
+ wrapper.setState({ isResizing: true, rowGuideTop: 10 });
+ expect(wrapper.find('.grid-row-guide')).to.have.length(1);
+ });
+
+ it('should call resizeComponent when a child DashboardComponent calls resizeStop', () => {
+ const resizeComponent = sinon.spy();
+ const args = { id: 'id', widthMultiple: 1, heightMultiple: 3 };
+ const wrapper = setup({ resizeComponent });
+ const dashboardComponent = wrapper.find(DashboardComponent).first();
+ dashboardComponent.prop('onResizeStop')(args);
+
+ expect(resizeComponent.callCount).to.equal(1);
+ expect(resizeComponent.getCall(0).args[0]).to.deep.equal({
+ id: 'id',
+ width: 1,
+ height: 3,
+ });
+ });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/Dashboard_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/Dashboard_spec.jsx
new file mode 100644
index 0000000000..e855009592
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/Dashboard_spec.jsx
@@ -0,0 +1,250 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+import sinon from 'sinon';
+
+import Dashboard from '../../../../src/dashboard/components/Dashboard';
+import DashboardBuilder from '../../../../src/dashboard/containers/DashboardBuilder';
+
+// mock data
+import chartQueries, { sliceId as chartId } from '../fixtures/mockChartQueries';
+import datasources from '../fixtures/mockDatasource';
+import dashboardInfo from '../fixtures/mockDashboardInfo';
+import { dashboardLayout } from '../fixtures/mockDashboardLayout';
+import dashboardState from '../fixtures/mockDashboardState';
+import { sliceEntitiesForChart as sliceEntities } from '../fixtures/mockSliceEntities';
+
+import { CHART_TYPE } from '../../../../src/dashboard/util/componentTypes';
+import newComponentFactory from '../../../../src/dashboard/util/newComponentFactory';
+
+describe('Dashboard', () => {
+ const props = {
+ actions: {
+ addSliceToDashboard() {},
+ removeSliceFromDashboard() {},
+ runQuery() {},
+ },
+ initMessages: [],
+ dashboardState,
+ dashboardInfo,
+ charts: chartQueries,
+ slices: sliceEntities.slices,
+ datasources,
+ layout: dashboardLayout.present,
+ timeout: 60,
+ userId: dashboardInfo.userId,
+ impressionId: 'id',
+ loadStats: {},
+ };
+
+ function setup(overrideProps) {
+ const wrapper = shallow(<Dashboard {...props} {...overrideProps} />);
+ return wrapper;
+ }
+
+ it('should render a DashboardBuilder', () => {
+ const wrapper = setup();
+ expect(wrapper.find(DashboardBuilder)).to.have.length(1);
+ });
+
+ describe('refreshExcept', () => {
+ const overrideCharts = {
+ ...chartQueries,
+ 1001: {
+ ...chartQueries[chartId],
+ id: 1001,
+ },
+ };
+
+ const overrideSlices = {
+ ...props.slices,
+ 1001: {
+ ...props.slices[chartId],
+ slice_id: 1001,
+ },
+ };
+
+ it('should call runQuery for all non-exempt slices', () => {
+ const wrapper = setup({ charts: overrideCharts, slices: overrideSlices });
+ const spy = sinon.spy(props.actions, 'runQuery');
+ wrapper.instance().refreshExcept('1001');
+ spy.restore();
+ expect(spy.callCount).to.equal(Object.keys(overrideCharts).length - 1);
+ });
+
+ it('should not call runQuery for filter_immune_slices', () => {
+ const wrapper = setup({
+ charts: overrideCharts,
+ dashboardInfo: {
+ ...dashboardInfo,
+ metadata: {
+ ...dashboardInfo.metadata,
+ filter_immune_slices: Object.keys(overrideCharts).map(id =>
+ Number(id),
+ ),
+ },
+ },
+ });
+ const spy = sinon.spy(props.actions, 'runQuery');
+ wrapper.instance().refreshExcept();
+ spy.restore();
+ expect(spy.callCount).to.equal(0);
+ });
+ });
+
+ describe('componentWillReceiveProps', () => {
+ const layoutWithExtraChart = {
+ ...props.layout,
+ 1001: newComponentFactory(CHART_TYPE, { chartId: 1001 }),
+ };
+
+ it('should call addSliceToDashboard if a new slice is added to the layout', () => {
+ const wrapper = setup();
+ const spy = sinon.spy(props.actions, 'addSliceToDashboard');
+ wrapper.instance().componentWillReceiveProps({
+ ...props,
+ layout: layoutWithExtraChart,
+ });
+ spy.restore();
+ expect(spy.callCount).to.equal(1);
+ });
+
+ it('should call removeSliceFromDashboard if a slice is removed from the layout', () => {
+ const wrapper = setup({ layout: layoutWithExtraChart });
+ const spy = sinon.spy(props.actions, 'removeSliceFromDashboard');
+ const nextLayout = { ...layoutWithExtraChart };
+ delete nextLayout[1001];
+
+ wrapper.instance().componentWillReceiveProps({
+ ...props,
+ layout: nextLayout,
+ });
+ spy.restore();
+ expect(spy.callCount).to.equal(1);
+ });
+ });
+
+ describe('componentDidUpdate', () => {
+ const overrideDashboardState = {
+ ...dashboardState,
+ filters: {
+ 1: { region: [] },
+ 2: { country_name: ['USA'] },
+ },
+ refresh: true,
+ };
+
+ it('should not call refresh when there is no change', () => {
+ const wrapper = setup({ dashboardState: overrideDashboardState });
+ const refreshExceptSpy = sinon.spy(wrapper.instance(), 'refreshExcept');
+ const prevProps = wrapper.instance().props;
+ wrapper.setProps({
+ dashboardState: {
+ ...overrideDashboardState,
+ },
+ });
+ wrapper.instance().componentDidUpdate(prevProps);
+ refreshExceptSpy.restore();
+ expect(refreshExceptSpy.callCount).to.equal(0);
+ });
+
+ it('should call refresh if a filter is added', () => {
+ const wrapper = setup({ dashboardState: overrideDashboardState });
+ const refreshExceptSpy = sinon.spy(wrapper.instance(), 'refreshExcept');
+ const prevProps = wrapper.instance().props;
+ wrapper.setProps({
+ dashboardState: {
+ ...overrideDashboardState,
+ filters: {
+ ...overrideDashboardState.filters,
+ 3: { another_filter: ['please'] },
+ },
+ },
+ });
+ wrapper.instance().componentDidUpdate(prevProps);
+ refreshExceptSpy.restore();
+ expect(refreshExceptSpy.callCount).to.equal(1);
+ });
+
+ it('should call refresh if a filter is removed', () => {
+ const wrapper = setup({ dashboardState: overrideDashboardState });
+ const refreshExceptSpy = sinon.spy(wrapper.instance(), 'refreshExcept');
+ const prevProps = wrapper.instance().props;
+ wrapper.setProps({
+ dashboardState: {
+ ...overrideDashboardState,
+ filters: {},
+ },
+ });
+ wrapper.instance().componentDidUpdate(prevProps);
+ refreshExceptSpy.restore();
+ expect(refreshExceptSpy.callCount).to.equal(1);
+ });
+
+ it('should call refresh if a filter is changed', () => {
+ const wrapper = setup({ dashboardState: overrideDashboardState });
+ const refreshExceptSpy = sinon.spy(wrapper.instance(), 'refreshExcept');
+ const prevProps = wrapper.instance().props;
+ wrapper.setProps({
+ dashboardState: {
+ ...overrideDashboardState,
+ filters: {
+ ...overrideDashboardState.filters,
+ 2: { country_name: ['Canada'] },
+ },
+ },
+ });
+ wrapper.instance().componentDidUpdate(prevProps);
+ refreshExceptSpy.restore();
+ expect(refreshExceptSpy.callCount).to.equal(1);
+ });
+
+ it('should not call refresh if filters change and refresh is false', () => {
+ const wrapper = setup({ dashboardState: overrideDashboardState });
+ const refreshExceptSpy = sinon.spy(wrapper.instance(), 'refreshExcept');
+ const prevProps = wrapper.instance().props;
+ wrapper.setProps({
+ dashboardState: {
+ ...overrideDashboardState,
+ filters: {
+ ...overrideDashboardState.filters,
+ 2: { country_name: ['Canada'] },
+ },
+ refresh: false,
+ },
+ });
+ wrapper.instance().componentDidUpdate(prevProps);
+ refreshExceptSpy.restore();
+ expect(refreshExceptSpy.callCount).to.equal(0);
+ });
+
+ it('should not refresh filter_immune_slices', () => {
+ const wrapper = setup({
+ dashboardState: overrideDashboardState,
+ dashboardInfo: {
+ ...dashboardInfo,
+ metadata: {
+ ...dashboardInfo.metadata,
+ filter_immune_slices: [chartId],
+ },
+ },
+ });
+ const refreshExceptSpy = sinon.spy(wrapper.instance(), 'refreshExcept');
+ const prevProps = wrapper.instance().props;
+ wrapper.setProps({
+ dashboardState: {
+ ...overrideDashboardState,
+ filters: {
+ ...overrideDashboardState.filters,
+ 2: { country_name: ['Canada'] },
+ },
+ refresh: false,
+ },
+ });
+ wrapper.instance().componentDidUpdate(prevProps);
+ refreshExceptSpy.restore();
+ expect(refreshExceptSpy.callCount).to.equal(0);
+ });
+ });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/MissingChart_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/MissingChart_spec.jsx
new file mode 100644
index 0000000000..92a18c10c9
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/MissingChart_spec.jsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import Loading from '../../../../src/components/Loading';
+import MissingChart from '../../../../src/dashboard/components/MissingChart';
+
+describe('MissingChart', () => {
+ function setup(overrideProps) {
+ const wrapper = shallow(<MissingChart height={100} {...overrideProps} />);
+ return wrapper;
+ }
+
+ it('renders a .missing-chart-container', () => {
+ const wrapper = setup();
+ expect(wrapper.find('.missing-chart-container')).to.have.length(1);
+ });
+
+ it('renders a .missing-chart-body', () => {
+ const wrapper = setup();
+ expect(wrapper.find('.missing-chart-body')).to.have.length(1);
+ });
+
+ it('renders a Loading', () => {
+ const wrapper = setup();
+ expect(wrapper.find(Loading)).to.have.length(1);
+ });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/RefreshIntervalModal_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/RefreshIntervalModal_spec.jsx
similarity index 85%
rename from superset/assets/spec/javascripts/dashboard/RefreshIntervalModal_spec.jsx
rename to superset/assets/spec/javascripts/dashboard/components/RefreshIntervalModal_spec.jsx
index 3a2f7000df..564857c088 100644
--- a/superset/assets/spec/javascripts/dashboard/RefreshIntervalModal_spec.jsx
+++ b/superset/assets/spec/javascripts/dashboard/components/RefreshIntervalModal_spec.jsx
@@ -3,7 +3,7 @@ import { mount } from 'enzyme';
import { describe, it } from 'mocha';
import { expect } from 'chai';
-import RefreshIntervalModal from '../../../src/dashboard/components/RefreshIntervalModal';
+import RefreshIntervalModal from '../../../../src/dashboard/components/RefreshIntervalModal';
describe('RefreshIntervalModal', () => {
const mockedProps = {
diff --git a/superset/assets/spec/javascripts/dashboard/components/SliceAdder_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/SliceAdder_spec.jsx
new file mode 100644
index 0000000000..da0f7df754
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/SliceAdder_spec.jsx
@@ -0,0 +1,154 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { describe, it, beforeEach, afterEach } from 'mocha';
+import sinon from 'sinon';
+import { expect } from 'chai';
+
+import { List } from 'react-virtualized';
+
+import SliceAdder from '../../../../src/dashboard/components/SliceAdder';
+import { sliceEntitiesForDashboard as mockSliceEntities } from '../fixtures/mockSliceEntities';
+
+describe('SliceAdder', () => {
+ const mockEvent = {
+ key: 'Enter',
+ target: {
+ value: 'mock event target',
+ },
+ preventDefault: () => {},
+ };
+ const props = {
+ ...mockSliceEntities,
+ fetchAllSlices: () => {},
+ selectedSliceIds: [127, 128],
+ userId: '1',
+ height: 100,
+ };
+ const errorProps = {
+ ...props,
+ errorMessage: 'this is error',
+ };
+
+ describe('SliceAdder.sortByComparator', () => {
+ it('should sort by timestamp descending', () => {
+ const sortedTimestamps = Object.values(props.slices)
+ .sort(SliceAdder.sortByComparator('changed_on'))
+ .map(slice => slice.changed_on);
+ expect(
+ sortedTimestamps.every((currentTimestamp, index) => {
+ if (index === 0) {
+ return true;
+ }
+ return currentTimestamp < sortedTimestamps[index - 1];
+ }),
+ ).to.equal(true);
+ });
+
+ it('should sort by slice_name', () => {
+ const sortedNames = Object.values(props.slices)
+ .sort(SliceAdder.sortByComparator('slice_name'))
+ .map(slice => slice.slice_name);
+ const expectedNames = Object.values(props.slices)
+ .map(slice => slice.slice_name)
+ .sort();
+ expect(sortedNames).to.deep.equal(expectedNames);
+ });
+ });
+
+ it('render List', () => {
+ const wrapper = shallow(<SliceAdder {...props} />);
+ wrapper.setState({ filteredSlices: Object.values(props.slices) });
+ expect(wrapper.find(List)).to.have.length(1);
+ });
+
+ it('render error', () => {
+ const wrapper = shallow(<SliceAdder {...errorProps} />);
+ wrapper.setState({ filteredSlices: Object.values(props.slices) });
+ expect(wrapper.text()).to.have.string(errorProps.errorMessage);
+ });
+
+ it('componentDidMount', () => {
+ sinon.spy(SliceAdder.prototype, 'componentDidMount');
+ sinon.spy(props, 'fetchAllSlices');
+
+ shallow(<SliceAdder {...props} />, {
+ lifecycleExperimental: true,
+ });
+ expect(SliceAdder.prototype.componentDidMount.calledOnce).to.equal(true);
+ expect(props.fetchAllSlices.calledOnce).to.equal(true);
+
+ SliceAdder.prototype.componentDidMount.restore();
+ props.fetchAllSlices.restore();
+ });
+
+ describe('componentWillReceiveProps', () => {
+ let wrapper;
+ beforeEach(() => {
+ wrapper = shallow(<SliceAdder {...props} />);
+ wrapper.setState({ filteredSlices: Object.values(props.slices) });
+ sinon.spy(wrapper.instance(), 'setState');
+ });
+ afterEach(() => {
+ wrapper.instance().setState.restore();
+ });
+
+ it('fetch slices should update state', () => {
+ wrapper.instance().componentWillReceiveProps({
+ ...props,
+ lastUpdated: new Date().getTime(),
+ });
+ expect(wrapper.instance().setState.calledOnce).to.equal(true);
+
+ const stateKeys = Object.keys(
+ wrapper.instance().setState.lastCall.args[0],
+ );
+ expect(stateKeys).to.include('filteredSlices');
+ });
+
+ it('select slices should update state', () => {
+ wrapper.instance().componentWillReceiveProps({
+ ...props,
+ selectedSliceIds: [127],
+ });
+ expect(wrapper.instance().setState.calledOnce).to.equal(true);
+
+ const stateKeys = Object.keys(
+ wrapper.instance().setState.lastCall.args[0],
+ );
+ expect(stateKeys).to.include('selectedSliceIdsSet');
+ });
+ });
+
+ describe('should rerun filter and sort', () => {
+ let wrapper;
+ let spy;
+ beforeEach(() => {
+ wrapper = shallow(<SliceAdder {...props} />);
+ wrapper.setState({ filteredSlices: Object.values(props.slices) });
+ spy = sinon.spy(wrapper.instance(), 'getFilteredSortedSlices');
+ });
+ afterEach(() => {
+ spy.restore();
+ });
+
+ it('searchUpdated', () => {
+ const newSearchTerm = 'new search term';
+ wrapper.instance().searchUpdated(newSearchTerm);
+ expect(spy.calledOnce).to.equal(true);
+ expect(spy.lastCall.args[0]).to.equal(newSearchTerm);
+ });
+
+ it('handleSelect', () => {
+ const newSortBy = 1;
+ wrapper.instance().handleSelect(newSortBy);
+ expect(spy.calledOnce).to.equal(true);
+ expect(spy.lastCall.args[1]).to.equal(newSortBy);
+ });
+
+ it('handleKeyPress', () => {
+ wrapper.instance().handleKeyPress(mockEvent);
+ expect(spy.calledOnce).to.equal(true);
+ expect(spy.lastCall.args[0]).to.equal(mockEvent.target.value);
+ });
+ });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/dnd/DragDroppable_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/dnd/DragDroppable_spec.jsx
new file mode 100644
index 0000000000..c7e2c2a8d3
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/dnd/DragDroppable_spec.jsx
@@ -0,0 +1,90 @@
+import React from 'react';
+import { shallow, mount } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+import sinon from 'sinon';
+
+import newComponentFactory from '../../../../../src/dashboard/util/newComponentFactory';
+import {
+ CHART_TYPE,
+ ROW_TYPE,
+} from '../../../../../src/dashboard/util/componentTypes';
+import { UnwrappedDragDroppable as DragDroppable } from '../../../../../src/dashboard/components/dnd/DragDroppable';
+
+describe('DragDroppable', () => {
+ const props = {
+ component: newComponentFactory(CHART_TYPE),
+ parentComponent: newComponentFactory(ROW_TYPE),
+ editMode: false,
+ depth: 1,
+ index: 0,
+ isDragging: false,
+ isDraggingOver: false,
+ isDraggingOverShallow: false,
+ droppableRef() {},
+ dragSourceRef() {},
+ dragPreviewRef() {},
+ };
+
+ function setup(overrideProps, shouldMount = false) {
+ const method = shouldMount ? mount : shallow;
+ const wrapper = method(<DragDroppable {...props} {...overrideProps} />);
+ return wrapper;
+ }
+
+ it('should render a div with class dragdroppable', () => {
+ const wrapper = setup();
+ expect(wrapper.find('.dragdroppable')).to.have.length(1);
+ });
+
+ it('should add class dragdroppable--dragging when dragging', () => {
+ const wrapper = setup({ isDragging: true });
+ expect(wrapper.find('.dragdroppable')).to.have.length(1);
+ });
+
+ it('should call its child function', () => {
+ const childrenSpy = sinon.spy();
+ setup({ children: childrenSpy });
+ expect(childrenSpy.callCount).to.equal(1);
+ });
+
+ it('should call its child function with "dragSourceRef" if editMode=true', () => {
+ const children = sinon.spy();
+ const dragSourceRef = () => {};
+ setup({ children, editMode: false, dragSourceRef });
+ setup({ children, editMode: true, dragSourceRef });
+
+ expect(children.getCall(0).args[0].dragSourceRef).to.equal(undefined);
+ expect(children.getCall(1).args[0].dragSourceRef).to.equal(dragSourceRef);
+ });
+
+ it('should call its child function with "dropIndicatorProps" dependent on editMode, isDraggingOver, state.dropIndicator is set', () => {
+ const children = sinon.spy();
+ const wrapper = setup({ children, editMode: false, isDraggingOver: false });
+ wrapper.setState({ dropIndicator: 'nonsense' });
+ wrapper.setProps({ ...props, editMode: true, isDraggingOver: true });
+
+ expect(children.callCount).to.equal(3); // initial + setState + setProps
+ expect(children.getCall(0).args[0].dropIndicatorProps).to.equal(undefined);
+ expect(children.getCall(2).args[0].dropIndicatorProps).to.deep.equal({
+ className: 'drop-indicator',
+ });
+ });
+
+ it('should call props.dragPreviewRef and props.droppableRef on mount', () => {
+ const dragPreviewRef = sinon.spy();
+ const droppableRef = sinon.spy();
+
+ setup({ dragPreviewRef, droppableRef }, true);
+ expect(dragPreviewRef.callCount).to.equal(1);
+ expect(droppableRef.callCount).to.equal(1);
+ });
+
+ it('should set this.mounted dependent on life cycle', () => {
+ const wrapper = setup({}, true);
+ const instance = wrapper.instance();
+ expect(instance.mounted).to.equal(true);
+ wrapper.unmount();
+ expect(instance.mounted).to.equal(false);
+ });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/ChartHolder_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/ChartHolder_spec.jsx
new file mode 100644
index 0000000000..821b6371f7
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/ChartHolder_spec.jsx
@@ -0,0 +1,112 @@
+import { Provider } from 'react-redux';
+import React from 'react';
+import { mount } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+import sinon from 'sinon';
+
+import Chart from '../../../../../src/dashboard/containers/Chart';
+import ChartHolder from '../../../../../src/dashboard/components/gridComponents/ChartHolder';
+import DeleteComponentButton from '../../../../../src/dashboard/components/DeleteComponentButton';
+import DragDroppable from '../../../../../src/dashboard/components/dnd/DragDroppable';
+import HoverMenu from '../../../../../src/dashboard/components/menu/HoverMenu';
+import ResizableContainer from '../../../../../src/dashboard/components/resizable/ResizableContainer';
+
+import { mockStore } from '../../fixtures/mockStore';
+import { sliceId } from '../../fixtures/mockSliceEntities';
+import { dashboardLayout as mockLayout } from '../../fixtures/mockDashboardLayout';
+import WithDragDropContext from '../../helpers/WithDragDropContext';
+
+describe('ChartHolder', () => {
+ const props = {
+ id: String(sliceId),
+ parentId: 'ROW_ID',
+ component: mockLayout.present.CHART_ID,
+ depth: 2,
+ parentComponent: mockLayout.present.ROW_ID,
+ index: 0,
+ editMode: false,
+ availableColumnCount: 12,
+ columnWidth: 50,
+ onResizeStart() {},
+ onResize() {},
+ onResizeStop() {},
+ handleComponentDrop() {},
+ updateComponents() {},
+ deleteComponent() {},
+ };
+
+ function setup(overrideProps) {
+ // We have to wrap provide DragDropContext for the underlying DragDroppable
+ // otherwise we cannot assert on DragDroppable children
+ const wrapper = mount(
+ <Provider store={mockStore}>
+ <WithDragDropContext>
+ <ChartHolder {...props} {...overrideProps} />
+ </WithDragDropContext>
+ </Provider>,
+ );
+ return wrapper;
+ }
+
+ it('should render a DragDroppable', () => {
+ const wrapper = setup();
+ expect(wrapper.find(DragDroppable)).to.have.length(1);
+ });
+
+ it('should render a ResizableContainer', () => {
+ const wrapper = setup();
+ expect(wrapper.find(ResizableContainer)).to.have.length(1);
+ });
+
+ it('should only have an adjustableWidth if its parent is a Row', () => {
+ let wrapper = setup();
+ expect(wrapper.find(ResizableContainer).prop('adjustableWidth')).to.equal(
+ true,
+ );
+
+ wrapper = setup({ ...props, parentComponent: mockLayout.present.CHART_ID });
+ expect(wrapper.find(ResizableContainer).prop('adjustableWidth')).to.equal(
+ false,
+ );
+ });
+
+ it('should pass correct props to ResizableContainer', () => {
+ const wrapper = setup();
+ const resizableProps = wrapper.find(ResizableContainer).props();
+ expect(resizableProps.widthStep).to.equal(props.columnWidth);
+ expect(resizableProps.widthMultiple).to.equal(props.component.meta.width);
+ expect(resizableProps.heightMultiple).to.equal(props.component.meta.height);
+ expect(resizableProps.maxWidthMultiple).to.equal(
+ props.component.meta.width + props.availableColumnCount,
+ );
+ });
+
+ it('should render a div with class "dashboard-component-chart-holder"', () => {
+ const wrapper = setup();
+ expect(wrapper.find('.dashboard-component-chart-holder')).to.have.length(1);
+ });
+
+ it('should render a Chart', () => {
+ const wrapper = setup();
+ expect(wrapper.find(Chart)).to.have.length(1);
+ });
+
+ it('should render a HoverMenu with DeleteComponentButton in editMode', () => {
+ let wrapper = setup();
+ expect(wrapper.find(HoverMenu)).to.have.length(0);
+ expect(wrapper.find(DeleteComponentButton)).to.have.length(0);
+
+ // we cannot set props on the Divider because of the WithDragDropContext wrapper
+ wrapper = setup({ editMode: true });
+ expect(wrapper.find(HoverMenu)).to.have.length(1);
+ expect(wrapper.find(DeleteComponentButton)).to.have.length(1);
+ });
+
+ it('should call deleteComponent when deleted', () => {
+ const deleteComponent = sinon.spy();
+ const wrapper = setup({ editMode: true, deleteComponent });
+ wrapper.find(DeleteComponentButton).simulate('click');
+ expect(deleteComponent.callCount).to.equal(1);
+ });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/Chart_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Chart_spec.jsx
new file mode 100644
index 0000000000..dcd711947a
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Chart_spec.jsx
@@ -0,0 +1,89 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+import sinon from 'sinon';
+
+import Chart from '../../../../../src/dashboard/components/gridComponents/Chart';
+import SliceHeader from '../../../../../src/dashboard/components/SliceHeader';
+import ChartContainer from '../../../../../src/chart/ChartContainer';
+
+import mockDatasource from '../../fixtures/mockDatasource';
+import {
+ sliceEntitiesForChart as sliceEntities,
+ sliceId,
+} from '../../fixtures/mockSliceEntities';
+import chartQueries, {
+ sliceId as queryId,
+} from '../../fixtures/mockChartQueries';
+
+describe('Chart', () => {
+ const props = {
+ id: sliceId,
+ width: 100,
+ height: 100,
+ updateSliceName() {},
+
+ // from redux
+ chart: chartQueries[queryId],
+ formData: chartQueries[queryId].formData,
+ datasource: mockDatasource[sliceEntities.slices[sliceId].datasource],
+ slice: {
+ ...sliceEntities.slices[sliceId],
+ description_markeddown: 'markdown',
+ },
+ sliceName: sliceEntities.slices[sliceId].slice_name,
+ timeout: 60,
+ filters: {},
+ refreshChart() {},
+ toggleExpandSlice() {},
+ addFilter() {},
+ editMode: false,
+ isExpanded: false,
+ supersetCanExplore: false,
+ sliceCanEdit: false,
+ };
+
+ function setup(overrideProps) {
+ const wrapper = shallow(<Chart {...props} {...overrideProps} />);
+ return wrapper;
+ }
+
+ it('should render a SliceHeader', () => {
+ const wrapper = setup();
+ expect(wrapper.find(SliceHeader)).to.have.length(1);
+ });
+
+ it('should render a ChartContainer', () => {
+ const wrapper = setup();
+ expect(wrapper.find(ChartContainer)).to.have.length(1);
+ });
+
+ it('should render a description if it has one and isExpanded=true', () => {
+ const wrapper = setup();
+ expect(wrapper.find('.slice_description')).to.have.length(0);
+
+ wrapper.setProps({ ...props, isExpanded: true });
+ expect(wrapper.find('.slice_description')).to.have.length(1);
+ });
+
+ it('should call refreshChart when SliceHeader calls forceRefresh', () => {
+ const refreshChart = sinon.spy();
+ const wrapper = setup({ refreshChart });
+ wrapper.instance().forceRefresh();
+ expect(refreshChart.callCount).to.equal(1);
+ });
+
+ it('should call addFilter when ChartContainer calls addFilter', () => {
+ const addFilter = sinon.spy();
+ const wrapper = setup({ addFilter });
+ wrapper.instance().addFilter();
+ expect(addFilter.callCount).to.equal(1);
+ });
+
+ it('should return props.filters when its getFilters method is called', () => {
+ const filters = { column: ['value'] };
+ const wrapper = setup({ filters });
+ expect(wrapper.instance().getFilters()).to.equal(filters);
+ });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/Column_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Column_spec.jsx
new file mode 100644
index 0000000000..e97414b65e
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Column_spec.jsx
@@ -0,0 +1,144 @@
+import { Provider } from 'react-redux';
+import React from 'react';
+import { mount } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+import sinon from 'sinon';
+
+import BackgroundStyleDropdown from '../../../../../src/dashboard/components/menu/BackgroundStyleDropdown';
+import Column from '../../../../../src/dashboard/components/gridComponents/Column';
+import DashboardComponent from '../../../../../src/dashboard/containers/DashboardComponent';
+import DeleteComponentButton from '../../../../../src/dashboard/components/DeleteComponentButton';
+import DragDroppable from '../../../../../src/dashboard/components/dnd/DragDroppable';
+import HoverMenu from '../../../../../src/dashboard/components/menu/HoverMenu';
+import IconButton from '../../../../../src/dashboard/components/IconButton';
+import ResizableContainer from '../../../../../src/dashboard/components/resizable/ResizableContainer';
+import WithPopoverMenu from '../../../../../src/dashboard/components/menu/WithPopoverMenu';
+
+import { mockStore } from '../../fixtures/mockStore';
+import { dashboardLayout as mockLayout } from '../../fixtures/mockDashboardLayout';
+import WithDragDropContext from '../../helpers/WithDragDropContext';
+
+describe('Column', () => {
+ const columnWithoutChildren = {
+ ...mockLayout.present.COLUMN_ID,
+ children: [],
+ };
+ const props = {
+ id: 'COLUMN_ID',
+ parentId: 'ROW_ID',
+ component: mockLayout.present.COLUMN_ID,
+ parentComponent: mockLayout.present.ROW_ID,
+ index: 0,
+ depth: 2,
+ editMode: false,
+ availableColumnCount: 12,
+ minColumnWidth: 2,
+ columnWidth: 50,
+ occupiedColumnCount: 6,
+ onResizeStart() {},
+ onResize() {},
+ onResizeStop() {},
+ handleComponentDrop() {},
+ deleteComponent() {},
+ updateComponents() {},
+ };
+
+ function setup(overrideProps) {
+ // We have to wrap provide DragDropContext for the underlying DragDroppable
+ // otherwise we cannot assert on DragDroppable children
+ const wrapper = mount(
+ <Provider store={mockStore}>
+ <WithDragDropContext>
+ <Column {...props} {...overrideProps} />
+ </WithDragDropContext>
+ </Provider>,
+ );
+ return wrapper;
+ }
+
+ it('should render a DragDroppable', () => {
+ // don't count child DragDroppables
+ const wrapper = setup({ component: columnWithoutChildren });
+ expect(wrapper.find(DragDroppable)).to.have.length(1);
+ });
+
+ it('should render a WithPopoverMenu', () => {
+ // don't count child DragDroppables
+ const wrapper = setup({ component: columnWithoutChildren });
+ expect(wrapper.find(WithPopoverMenu)).to.have.length(1);
+ });
+
+ it('should render a ResizableContainer', () => {
+ // don't count child DragDroppables
+ const wrapper = setup({ component: columnWithoutChildren });
+ expect(wrapper.find(ResizableContainer)).to.have.length(1);
+ });
+
+ it('should render a HoverMenu in editMode', () => {
+ let wrapper = setup({ component: columnWithoutChildren });
+ expect(wrapper.find(HoverMenu)).to.have.length(0);
+
+ // we cannot set props on the Row because of the WithDragDropContext wrapper
+ wrapper = setup({ component: columnWithoutChildren, editMode: true });
+ expect(wrapper.find(HoverMenu)).to.have.length(1);
+ });
+
+ it('should render a DeleteComponentButton in editMode', () => {
+ let wrapper = setup({ component: columnWithoutChildren });
+ expect(wrapper.find(DeleteComponentButton)).to.have.length(0);
+
+ // we cannot set props on the Row because of the WithDragDropContext wrapper
+ wrapper = setup({ component: columnWithoutChildren, editMode: true });
+ expect(wrapper.find(DeleteComponentButton)).to.have.length(1);
+ });
+
+ it('should render a BackgroundStyleDropdown when focused', () => {
+ let wrapper = setup({ component: columnWithoutChildren });
+ expect(wrapper.find(BackgroundStyleDropdown)).to.have.length(0);
+
+ // we cannot set props on the Row because of the WithDragDropContext wrapper
+ wrapper = setup({ component: columnWithoutChildren, editMode: true });
+ wrapper
+ .find(IconButton)
+ .at(1) // first one is delete button
+ .simulate('click');
+
+ expect(wrapper.find(BackgroundStyleDropdown)).to.have.length(1);
+ });
+
+ it('should call deleteComponent when deleted', () => {
+ const deleteComponent = sinon.spy();
+ const wrapper = setup({ editMode: true, deleteComponent });
+ wrapper.find(DeleteComponentButton).simulate('click');
+ expect(deleteComponent.callCount).to.equal(1);
+ });
+
+ it('should pass its own width as availableColumnCount to children', () => {
+ const wrapper = setup();
+ const dashboardComponent = wrapper.find(DashboardComponent).first();
+ expect(dashboardComponent.props().availableColumnCount).to.equal(
+ props.component.meta.width,
+ );
+ });
+
+ it('should pass appropriate dimensions to ResizableContainer', () => {
+ const wrapper = setup({ component: columnWithoutChildren });
+ const columnWidth = columnWithoutChildren.meta.width;
+ const resizableProps = wrapper.find(ResizableContainer).props();
+ expect(resizableProps.adjustableWidth).to.equal(true);
+ expect(resizableProps.adjustableHeight).to.equal(false);
+ expect(resizableProps.widthStep).to.equal(props.columnWidth);
+ expect(resizableProps.widthMultiple).to.equal(columnWidth);
+ expect(resizableProps.minWidthMultiple).to.equal(props.minColumnWidth);
+ expect(resizableProps.maxWidthMultiple).to.equal(
+ props.availableColumnCount + columnWidth,
+ );
+ });
+
+ it('should increment the depth of its children', () => {
+ const wrapper = setup();
+ const dashboardComponent = wrapper.find(DashboardComponent);
+ expect(dashboardComponent.props().depth).to.equal(props.depth + 1);
+ });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/Divider_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Divider_spec.jsx
new file mode 100644
index 0000000000..c8317f8459
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Divider_spec.jsx
@@ -0,0 +1,70 @@
+import React from 'react';
+import { mount } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+import sinon from 'sinon';
+
+import DeleteComponentButton from '../../../../../src/dashboard/components/DeleteComponentButton';
+import HoverMenu from '../../../../../src/dashboard/components/menu/HoverMenu';
+import DragDroppable from '../../../../../src/dashboard/components/dnd/DragDroppable';
+import Divider from '../../../../../src/dashboard/components/gridComponents/Divider';
+import newComponentFactory from '../../../../../src/dashboard/util/newComponentFactory';
+import {
+ DIVIDER_TYPE,
+ DASHBOARD_GRID_TYPE,
+} from '../../../../../src/dashboard/util/componentTypes';
+
+import WithDragDropContext from '../../helpers/WithDragDropContext';
+
+describe('Divider', () => {
+ const props = {
+ id: 'id',
+ parentId: 'parentId',
+ component: newComponentFactory(DIVIDER_TYPE),
+ depth: 1,
+ parentComponent: newComponentFactory(DASHBOARD_GRID_TYPE),
+ index: 0,
+ editMode: false,
+ handleComponentDrop() {},
+ deleteComponent() {},
+ };
+
+ function setup(overrideProps) {
+ // We have to wrap provide DragDropContext for the underlying DragDroppable
+ // otherwise we cannot assert on DragDroppable children
+ const wrapper = mount(
+ <WithDragDropContext>
+ <Divider {...props} {...overrideProps} />
+ </WithDragDropContext>,
+ );
+ return wrapper;
+ }
+
+ it('should render a DragDroppable', () => {
+ const wrapper = setup();
+ expect(wrapper.find(DragDroppable)).to.have.length(1);
+ });
+
+ it('should render a div with class "dashboard-component-divider"', () => {
+ const wrapper = setup();
+ expect(wrapper.find('.dashboard-component-divider')).to.have.length(1);
+ });
+
+ it('should render a HoverMenu with DeleteComponentButton in editMode', () => {
+ let wrapper = setup();
+ expect(wrapper.find(HoverMenu)).to.have.length(0);
+ expect(wrapper.find(DeleteComponentButton)).to.have.length(0);
+
+ // we cannot set props on the Divider because of the WithDragDropContext wrapper
+ wrapper = setup({ editMode: true });
+ expect(wrapper.find(HoverMenu)).to.have.length(1);
+ expect(wrapper.find(DeleteComponentButton)).to.have.length(1);
+ });
+
+ it('should call deleteComponent when deleted', () => {
+ const deleteComponent = sinon.spy();
+ const wrapper = setup({ editMode: true, deleteComponent });
+ wrapper.find(DeleteComponentButton).simulate('click');
+ expect(deleteComponent.callCount).to.equal(1);
+ });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/Header_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Header_spec.jsx
new file mode 100644
index 0000000000..1d547756a8
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Header_spec.jsx
@@ -0,0 +1,100 @@
+import React from 'react';
+import { mount } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+import sinon from 'sinon';
+
+import DeleteComponentButton from '../../../../../src/dashboard/components/DeleteComponentButton';
+import EditableTitle from '../../../../../src/components/EditableTitle';
+import HoverMenu from '../../../../../src/dashboard/components/menu/HoverMenu';
+import WithPopoverMenu from '../../../../../src/dashboard/components/menu/WithPopoverMenu';
+import DragDroppable from '../../../../../src/dashboard/components/dnd/DragDroppable';
+import Header from '../../../../../src/dashboard/components/gridComponents/Header';
+import newComponentFactory from '../../../../../src/dashboard/util/newComponentFactory';
+import {
+ HEADER_TYPE,
+ DASHBOARD_GRID_TYPE,
+} from '../../../../../src/dashboard/util/componentTypes';
+
+import WithDragDropContext from '../../helpers/WithDragDropContext';
+
+describe('Header', () => {
+ const props = {
+ id: 'id',
+ parentId: 'parentId',
+ component: newComponentFactory(HEADER_TYPE),
+ depth: 1,
+ parentComponent: newComponentFactory(DASHBOARD_GRID_TYPE),
+ index: 0,
+ editMode: false,
+ handleComponentDrop() {},
+ deleteComponent() {},
+ updateComponents() {},
+ };
+
+ function setup(overrideProps) {
+ // We have to wrap provide DragDropContext for the underlying DragDroppable
+ // otherwise we cannot assert on DragDroppable children
+ const wrapper = mount(
+ <WithDragDropContext>
+ <Header {...props} {...overrideProps} />
+ </WithDragDropContext>,
+ );
+ return wrapper;
+ }
+
+ it('should render a DragDroppable', () => {
+ const wrapper = setup();
+ expect(wrapper.find(DragDroppable)).to.have.length(1);
+ });
+
+ it('should render a WithPopoverMenu', () => {
+ const wrapper = setup();
+ expect(wrapper.find(WithPopoverMenu)).to.have.length(1);
+ });
+
+ it('should render a HoverMenu in editMode', () => {
+ let wrapper = setup();
+ expect(wrapper.find(HoverMenu)).to.have.length(0);
+
+ // we cannot set props on the Header because of the WithDragDropContext wrapper
+ wrapper = setup({ editMode: true });
+ expect(wrapper.find(HoverMenu)).to.have.length(1);
+ });
+
+ it('should render an EditableTitle with meta.text', () => {
+ const wrapper = setup();
+ expect(wrapper.find(EditableTitle)).to.have.length(1);
+ expect(wrapper.find('input').prop('value')).to.equal(
+ props.component.meta.text,
+ );
+ });
+
+ it('should call updateComponents when EditableTitle changes', () => {
+ const updateComponents = sinon.spy();
+ const wrapper = setup({ editMode: true, updateComponents });
+ wrapper.find(EditableTitle).prop('onSaveTitle')('New title');
+
+ const headerId = props.component.id;
+ expect(updateComponents.callCount).to.equal(1);
+ expect(updateComponents.getCall(0).args[0][headerId].meta.text).to.equal(
+ 'New title',
+ );
+ });
+
+ it('should render a DeleteComponentButton when focused in editMode', () => {
+ const wrapper = setup({ editMode: true });
+ wrapper.find(WithPopoverMenu).simulate('click'); // focus
+
+ expect(wrapper.find(DeleteComponentButton)).to.have.length(1);
+ });
+
+ it('should call deleteComponent when deleted', () => {
+ const deleteComponent = sinon.spy();
+ const wrapper = setup({ editMode: true, deleteComponent });
+ wrapper.find(WithPopoverMenu).simulate('click'); // focus
+ wrapper.find(DeleteComponentButton).simulate('click');
+
+ expect(deleteComponent.callCount).to.equal(1);
+ });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/Markdown_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Markdown_spec.jsx
new file mode 100644
index 0000000000..ca71045de7
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Markdown_spec.jsx
@@ -0,0 +1,156 @@
+import { Provider } from 'react-redux';
+import React from 'react';
+import { mount } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+import sinon from 'sinon';
+import AceEditor from 'react-ace';
+import ReactMarkdown from 'react-markdown';
+
+import Markdown from '../../../../../src/dashboard/components/gridComponents/Markdown';
+import MarkdownModeDropdown from '../../../../../src/dashboard/components/menu/MarkdownModeDropdown';
+import DeleteComponentButton from '../../../../../src/dashboard/components/DeleteComponentButton';
+import DragDroppable from '../../../../../src/dashboard/components/dnd/DragDroppable';
+import WithPopoverMenu from '../../../../../src/dashboard/components/menu/WithPopoverMenu';
+import ResizableContainer from '../../../../../src/dashboard/components/resizable/ResizableContainer';
+
+import { mockStore } from '../../fixtures/mockStore';
+import { dashboardLayout as mockLayout } from '../../fixtures/mockDashboardLayout';
+import WithDragDropContext from '../../helpers/WithDragDropContext';
+
+describe('Markdown', () => {
+ const props = {
+ id: 'id',
+ parentId: 'parentId',
+ component: mockLayout.present.MARKDOWN_ID,
+ depth: 2,
+ parentComponent: mockLayout.present.ROW_ID,
+ index: 0,
+ editMode: false,
+ availableColumnCount: 12,
+ columnWidth: 50,
+ onResizeStart() {},
+ onResize() {},
+ onResizeStop() {},
+ handleComponentDrop() {},
+ updateComponents() {},
+ deleteComponent() {},
+ };
+
+ function setup(overrideProps) {
+ // We have to wrap provide DragDropContext for the underlying DragDroppable
+ // otherwise we cannot assert on DragDroppable children
+ const wrapper = mount(
+ <Provider store={mockStore}>
+ <WithDragDropContext>
+ <Markdown {...props} {...overrideProps} />
+ </WithDragDropContext>
+ </Provider>,
+ );
+ return wrapper;
+ }
+
+ it('should render a DragDroppable', () => {
+ const wrapper = setup();
+ expect(wrapper.find(DragDroppable)).to.have.length(1);
+ });
+
+ it('should render a WithPopoverMenu', () => {
+ const wrapper = setup();
+ expect(wrapper.find(WithPopoverMenu)).to.have.length(1);
+ });
+
+ it('should render a ResizableContainer', () => {
+ const wrapper = setup();
+ expect(wrapper.find(ResizableContainer)).to.have.length(1);
+ });
+
+ it('should only have an adjustableWidth if its parent is a Row', () => {
+ let wrapper = setup();
+ expect(wrapper.find(ResizableContainer).prop('adjustableWidth')).to.equal(
+ true,
+ );
+
+ wrapper = setup({ ...props, parentComponent: mockLayout.present.CHART_ID });
+ expect(wrapper.find(ResizableContainer).prop('adjustableWidth')).to.equal(
+ false,
+ );
+ });
+
+ it('should pass correct props to ResizableContainer', () => {
+ const wrapper = setup();
+ const resizableProps = wrapper.find(ResizableContainer).props();
+ expect(resizableProps.widthStep).to.equal(props.columnWidth);
+ expect(resizableProps.widthMultiple).to.equal(props.component.meta.width);
+ expect(resizableProps.heightMultiple).to.equal(props.component.meta.height);
+ expect(resizableProps.maxWidthMultiple).to.equal(
+ props.component.meta.width + props.availableColumnCount,
+ );
+ });
+
+ it('should render an Markdown when NOT focused', () => {
+ const wrapper = setup();
+ expect(wrapper.find(AceEditor)).to.have.length(0);
+ expect(wrapper.find(ReactMarkdown)).to.have.length(1);
+ });
+
+ it('should render an AceEditor when focused and editMode=true and editorMode=edit', () => {
+ const wrapper = setup({ editMode: true });
+ expect(wrapper.find(AceEditor)).to.have.length(0);
+ expect(wrapper.find(ReactMarkdown)).to.have.length(1);
+ wrapper.find(WithPopoverMenu).simulate('click'); // focus + edit
+ expect(wrapper.find(AceEditor)).to.have.length(1);
+ expect(wrapper.find(ReactMarkdown)).to.have.length(0);
+ });
+
+ it('should render a ReactMarkdown when focused and editMode=true and editorMode=preview', () => {
+ const wrapper = setup({ editMode: true });
+ wrapper.find(WithPopoverMenu).simulate('click'); // focus + edit
+ expect(wrapper.find(AceEditor)).to.have.length(1);
+ expect(wrapper.find(ReactMarkdown)).to.have.length(0);
+
+ // we can't call setState on Markdown bc it's not the root component, so call
+ // the mode dropdown onchange instead
+ const dropdown = wrapper.find(MarkdownModeDropdown);
+ dropdown.prop('onChange')('preview');
+
+ expect(wrapper.find(AceEditor)).to.have.length(0);
+ expect(wrapper.find(ReactMarkdown)).to.have.length(1);
+ });
+
+ it('should call updateComponents when editMode changes from edit => preview, and there are markdownSource changes', () => {
+ const updateComponents = sinon.spy();
+ const wrapper = setup({ editMode: true, updateComponents });
+ wrapper.find(WithPopoverMenu).simulate('click'); // focus + edit
+
+ // we can't call setState on Markdown bc it's not the root component, so call
+ // the mode dropdown onchange instead
+ const dropdown = wrapper.find(MarkdownModeDropdown);
+ dropdown.prop('onChange')('preview');
+ expect(updateComponents.callCount).to.equal(0);
+
+ dropdown.prop('onChange')('edit');
+ // because we can't call setState on Markdown, change it through the editor
+ // then go back to preview mode to invoke updateComponents
+ const editor = wrapper.find(AceEditor);
+ editor.prop('onChange')('new markdown!');
+ dropdown.prop('onChange')('preview');
+ expect(updateComponents.callCount).to.equal(1);
+ });
+
+ it('should render a DeleteComponentButton when focused in editMode', () => {
+ const wrapper = setup({ editMode: true });
+ wrapper.find(WithPopoverMenu).simulate('click'); // focus
+
+ expect(wrapper.find(DeleteComponentButton)).to.have.length(1);
+ });
+
+ it('should call deleteComponent when deleted', () => {
+ const deleteComponent = sinon.spy();
+ const wrapper = setup({ editMode: true, deleteComponent });
+ wrapper.find(WithPopoverMenu).simulate('click'); // focus
+ wrapper.find(DeleteComponentButton).simulate('click');
+
+ expect(deleteComponent.callCount).to.equal(1);
+ });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/Row_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Row_spec.jsx
new file mode 100644
index 0000000000..a718ff406a
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Row_spec.jsx
@@ -0,0 +1,120 @@
+import { Provider } from 'react-redux';
+import React from 'react';
+import { mount } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+import sinon from 'sinon';
+
+import BackgroundStyleDropdown from '../../../../../src/dashboard/components/menu/BackgroundStyleDropdown';
+import DashboardComponent from '../../../../../src/dashboard/containers/DashboardComponent';
+import DeleteComponentButton from '../../../../../src/dashboard/components/DeleteComponentButton';
+import DragDroppable from '../../../../../src/dashboard/components/dnd/DragDroppable';
+import HoverMenu from '../../../../../src/dashboard/components/menu/HoverMenu';
+import IconButton from '../../../../../src/dashboard/components/IconButton';
+import Row from '../../../../../src/dashboard/components/gridComponents/Row';
+import WithPopoverMenu from '../../../../../src/dashboard/components/menu/WithPopoverMenu';
+
+import { mockStore } from '../../fixtures/mockStore';
+import { DASHBOARD_GRID_ID } from '../../../../../src/dashboard/util/constants';
+import { dashboardLayout as mockLayout } from '../../fixtures/mockDashboardLayout';
+import WithDragDropContext from '../../helpers/WithDragDropContext';
+
+describe('Row', () => {
+ const rowWithoutChildren = { ...mockLayout.present.ROW_ID, children: [] };
+ const props = {
+ id: 'ROW_ID',
+ parentId: DASHBOARD_GRID_ID,
+ component: mockLayout.present.ROW_ID,
+ parentComponent: mockLayout.present[DASHBOARD_GRID_ID],
+ index: 0,
+ depth: 2,
+ editMode: false,
+ availableColumnCount: 12,
+ columnWidth: 50,
+ occupiedColumnCount: 6,
+ onResizeStart() {},
+ onResize() {},
+ onResizeStop() {},
+ handleComponentDrop() {},
+ deleteComponent() {},
+ updateComponents() {},
+ };
+
+ function setup(overrideProps) {
+ // We have to wrap provide DragDropContext for the underlying DragDroppable
+ // otherwise we cannot assert on DragDroppable children
+ const wrapper = mount(
+ <Provider store={mockStore}>
+ <WithDragDropContext>
+ <Row {...props} {...overrideProps} />
+ </WithDragDropContext>
+ </Provider>,
+ );
+ return wrapper;
+ }
+
+ it('should render a DragDroppable', () => {
+ // don't count child DragDroppables
+ const wrapper = setup({ component: rowWithoutChildren });
+ expect(wrapper.find(DragDroppable)).to.have.length(1);
+ });
+
+ it('should render a WithPopoverMenu', () => {
+ // don't count child DragDroppables
+ const wrapper = setup({ component: rowWithoutChildren });
+ expect(wrapper.find(WithPopoverMenu)).to.have.length(1);
+ });
+
+ it('should render a HoverMenu in editMode', () => {
+ let wrapper = setup({ component: rowWithoutChildren });
+ expect(wrapper.find(HoverMenu)).to.have.length(0);
+
+ // we cannot set props on the Row because of the WithDragDropContext wrapper
+ wrapper = setup({ component: rowWithoutChildren, editMode: true });
+ expect(wrapper.find(HoverMenu)).to.have.length(1);
+ });
+
+ it('should render a DeleteComponentButton in editMode', () => {
+ let wrapper = setup({ component: rowWithoutChildren });
+ expect(wrapper.find(DeleteComponentButton)).to.have.length(0);
+
+ // we cannot set props on the Row because of the WithDragDropContext wrapper
+ wrapper = setup({ component: rowWithoutChildren, editMode: true });
+ expect(wrapper.find(DeleteComponentButton)).to.have.length(1);
+ });
+
+ it('should render a BackgroundStyleDropdown when focused', () => {
+ let wrapper = setup({ component: rowWithoutChildren });
+ expect(wrapper.find(BackgroundStyleDropdown)).to.have.length(0);
+
+ // we cannot set props on the Row because of the WithDragDropContext wrapper
+ wrapper = setup({ component: rowWithoutChildren, editMode: true });
+ wrapper
+ .find(IconButton)
+ .at(1) // first one is delete button
+ .simulate('click');
+
+ expect(wrapper.find(BackgroundStyleDropdown)).to.have.length(1);
+ });
+
+ it('should call deleteComponent when deleted', () => {
+ const deleteComponent = sinon.spy();
+ const wrapper = setup({ editMode: true, deleteComponent });
+ wrapper.find(DeleteComponentButton).simulate('click');
+ expect(deleteComponent.callCount).to.equal(1);
+ });
+
+ it('should pass appropriate availableColumnCount to children', () => {
+ const wrapper = setup();
+ const dashboardComponent = wrapper.find(DashboardComponent).first();
+ expect(dashboardComponent.props().availableColumnCount).to.equal(
+ props.availableColumnCount - props.occupiedColumnCount,
+ );
+ });
+
+ it('should increment the depth of its children', () => {
+ const wrapper = setup();
+ const dashboardComponent = wrapper.find(DashboardComponent).first();
+ expect(dashboardComponent.props().depth).to.equal(props.depth + 1);
+ });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/Tab_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Tab_spec.jsx
new file mode 100644
index 0000000000..a984565b4c
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Tab_spec.jsx
@@ -0,0 +1,126 @@
+import { Provider } from 'react-redux';
+import React from 'react';
+import { mount } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+import sinon from 'sinon';
+
+import DashboardComponent from '../../../../../src/dashboard/containers/DashboardComponent';
+import DeleteComponentButton from '../../../../../src/dashboard/components/DeleteComponentButton';
+import DragDroppable from '../../../../../src/dashboard/components/dnd/DragDroppable';
+import EditableTitle from '../../../../../src/components/EditableTitle';
+import WithPopoverMenu from '../../../../../src/dashboard/components/menu/WithPopoverMenu';
+import Tab, {
+ RENDER_TAB,
+ RENDER_TAB_CONTENT,
+} from '../../../../../src/dashboard/components/gridComponents/Tab';
+import WithDragDropContext from '../../helpers/WithDragDropContext';
+import { dashboardLayoutWithTabs } from '../../fixtures/mockDashboardLayout';
+import { mockStoreWithTabs } from '../../fixtures/mockStore';
+
+describe('Tabs', () => {
+ const props = {
+ id: 'TAB_ID',
+ parentId: 'TABS_ID',
+ component: dashboardLayoutWithTabs.present.TAB_ID,
+ parentComponent: dashboardLayoutWithTabs.present.TABS_ID,
+ index: 0,
+ depth: 1,
+ editMode: false,
+ renderType: RENDER_TAB,
+ onDropOnTab() {},
+ onDeleteTab() {},
+ availableColumnCount: 12,
+ columnWidth: 50,
+ onResizeStart() {},
+ onResize() {},
+ onResizeStop() {},
+ createComponent() {},
+ handleComponentDrop() {},
+ onChangeTab() {},
+ deleteComponent() {},
+ updateComponents() {},
+ };
+
+ function setup(overrideProps) {
+ // We have to wrap provide DragDropContext for the underlying DragDroppable
+ // otherwise we cannot assert on DragDroppable children
+ const wrapper = mount(
+ <Provider store={mockStoreWithTabs}>
+ <WithDragDropContext>
+ <Tab {...props} {...overrideProps} />
+ </WithDragDropContext>
+ </Provider>,
+ );
+ return wrapper;
+ }
+
+ describe('renderType=RENDER_TAB', () => {
+ it('should render a DragDroppable', () => {
+ const wrapper = setup();
+ expect(wrapper.find(DragDroppable)).to.have.length(1);
+ });
+
+ it('should render an EditableTitle with meta.text', () => {
+ const wrapper = setup();
+ const title = wrapper.find(EditableTitle);
+ expect(title).to.have.length(1);
+ expect(title.find('input').prop('value')).to.equal(
+ props.component.meta.text,
+ );
+ });
+
+ it('should call updateComponents when EditableTitle changes', () => {
+ const updateComponents = sinon.spy();
+ const wrapper = setup({ editMode: true, updateComponents });
+ wrapper.find(EditableTitle).prop('onSaveTitle')('New title');
+
+ expect(updateComponents.callCount).to.equal(1);
+ expect(updateComponents.getCall(0).args[0].TAB_ID.meta.text).to.equal(
+ 'New title',
+ );
+ });
+
+ it('should render a WithPopoverMenu', () => {
+ const wrapper = setup();
+ expect(wrapper.find(WithPopoverMenu)).to.have.length(1);
+ });
+
+ it('should render a DeleteComponentButton when focused if its not the only tab', () => {
+ let wrapper = setup();
+ wrapper.find(WithPopoverMenu).simulate('click'); // focus
+ expect(wrapper.find(DeleteComponentButton)).to.have.length(0);
+
+ wrapper = setup({ editMode: true });
+ wrapper.find(WithPopoverMenu).simulate('click');
+ expect(wrapper.find(DeleteComponentButton)).to.have.length(1);
+
+ wrapper = setup({
+ editMode: true,
+ parentComponent: {
+ ...props.parentComponent,
+ children: props.parentComponent.children.slice(0, 1),
+ },
+ });
+ wrapper.find(WithPopoverMenu).simulate('click');
+ expect(wrapper.find(DeleteComponentButton)).to.have.length(0);
+ });
+
+ it('should call deleteComponent when deleted', () => {
+ const deleteComponent = sinon.spy();
+ const wrapper = setup({ editMode: true, deleteComponent });
+ wrapper.find(WithPopoverMenu).simulate('click'); // focus
+ wrapper.find(DeleteComponentButton).simulate('click');
+
+ expect(deleteComponent.callCount).to.equal(1);
+ });
+ });
+
+ describe('renderType=RENDER_TAB_CONTENT', () => {
+ it('should render a DashboardComponent', () => {
+ const wrapper = setup({ renderType: RENDER_TAB_CONTENT });
+ // We expect 2 because this Tab has a Row child and the row has a Chart
+ expect(wrapper.find(DashboardComponent)).to.have.length(2);
+ });
+ });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/Tabs_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Tabs_spec.jsx
new file mode 100644
index 0000000000..d521fe5045
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/Tabs_spec.jsx
@@ -0,0 +1,140 @@
+import { Provider } from 'react-redux';
+import React from 'react';
+import { mount } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+import sinon from 'sinon';
+import { Tabs as BootstrapTabs, Tab as BootstrapTab } from 'react-bootstrap';
+
+import DashboardComponent from '../../../../../src/dashboard/containers/DashboardComponent';
+import DeleteComponentButton from '../../../../../src/dashboard/components/DeleteComponentButton';
+import HoverMenu from '../../../../../src/dashboard/components/menu/HoverMenu';
+import DragDroppable from '../../../../../src/dashboard/components/dnd/DragDroppable';
+import Tabs from '../../../../../src/dashboard/components/gridComponents/Tabs';
+import WithDragDropContext from '../../helpers/WithDragDropContext';
+import { dashboardLayoutWithTabs } from '../../fixtures/mockDashboardLayout';
+import { mockStoreWithTabs } from '../../fixtures/mockStore';
+import { DASHBOARD_ROOT_ID } from '../../../../../src/dashboard/util/constants';
+
+describe('Tabs', () => {
+ const props = {
+ id: 'TABS_ID',
+ parentId: DASHBOARD_ROOT_ID,
+ component: dashboardLayoutWithTabs.present.TABS_ID,
+ parentComponent: dashboardLayoutWithTabs.present[DASHBOARD_ROOT_ID],
+ index: 0,
+ depth: 1,
+ renderTabContent: true,
+ editMode: false,
+ availableColumnCount: 12,
+ columnWidth: 50,
+ onResizeStart() {},
+ onResize() {},
+ onResizeStop() {},
+ createComponent() {},
+ handleComponentDrop() {},
+ onChangeTab() {},
+ deleteComponent() {},
+ updateComponents() {},
+ };
+
+ function setup(overrideProps) {
+ // We have to wrap provide DragDropContext for the underlying DragDroppable
+ // otherwise we cannot assert on DragDroppable children
+ const wrapper = mount(
+ <Provider store={mockStoreWithTabs}>
+ <WithDragDropContext>
+ <Tabs {...props} {...overrideProps} />
+ </WithDragDropContext>
+ </Provider>,
+ );
+ return wrapper;
+ }
+
+ it('should render a DragDroppable', () => {
+ // test just Tabs with no children DragDroppables
+ const wrapper = setup({ component: { ...props.component, children: [] } });
+ expect(wrapper.find(DragDroppable)).to.have.length(1);
+ });
+
+ it('should render BootstrapTabs', () => {
+ const wrapper = setup();
+ expect(wrapper.find(BootstrapTabs)).to.have.length(1);
+ });
+
+ it('should set animation=true, mountOnEnter=true, and unmounOnExit=false on BootstrapTabs for perf', () => {
+ const wrapper = setup();
+ const tabProps = wrapper.find(BootstrapTabs).props();
+ expect(tabProps.animation).to.equal(true);
+ expect(tabProps.mountOnEnter).to.equal(true);
+ expect(tabProps.unmountOnExit).to.equal(false);
+ });
+
+ it('should render a BootstrapTab for each child', () => {
+ const wrapper = setup();
+ expect(wrapper.find(BootstrapTab)).to.have.length(
+ props.component.children.length,
+ );
+ });
+
+ it('should render an extra (+) BootstrapTab in editMode', () => {
+ const wrapper = setup({ editMode: true });
+ expect(wrapper.find(BootstrapTab)).to.have.length(
+ props.component.children.length + 1,
+ );
+ });
+
+ it('should render a DashboardComponent for each child', () => {
+ // note: this does not test Tab content
+ const wrapper = setup({ renderTabContent: false });
+ expect(wrapper.find(DashboardComponent)).to.have.length(
+ props.component.children.length,
+ );
+ });
+
+ it('should call createComponent if the (+) tab is clicked', () => {
+ const createComponent = sinon.spy();
+ const wrapper = setup({ editMode: true, createComponent });
+ wrapper
+ .find('.dashboard-component-tabs .nav-tabs a')
+ .last()
+ .simulate('click');
+
+ expect(createComponent.callCount).to.equal(1);
+ });
+
+ it('should call onChangeTab when a tab is clicked', () => {
+ const onChangeTab = sinon.spy();
+ const wrapper = setup({ editMode: true, onChangeTab });
+ wrapper
+ .find('.dashboard-component-tabs .nav-tabs a')
+ .at(1) // will not call if it is already selected
+ .simulate('click');
+
+ expect(onChangeTab.callCount).to.equal(1);
+ });
+
+ it('should render a HoverMenu in editMode', () => {
+ let wrapper = setup();
+ expect(wrapper.find(HoverMenu)).to.have.length(0);
+
+ wrapper = setup({ editMode: true });
+ expect(wrapper.find(HoverMenu)).to.have.length(1);
+ });
+
+ it('should render a DeleteComponentButton in editMode', () => {
+ let wrapper = setup();
+ expect(wrapper.find(DeleteComponentButton)).to.have.length(0);
+
+ wrapper = setup({ editMode: true });
+ expect(wrapper.find(DeleteComponentButton)).to.have.length(1);
+ });
+
+ it('should call deleteComponent when deleted', () => {
+ const deleteComponent = sinon.spy();
+ const wrapper = setup({ editMode: true, deleteComponent });
+ wrapper.find(DeleteComponentButton).simulate('click');
+
+ expect(deleteComponent.callCount).to.equal(1);
+ });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/DraggableNewComponent_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/DraggableNewComponent_spec.jsx
new file mode 100644
index 0000000000..4334b37ca4
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/DraggableNewComponent_spec.jsx
@@ -0,0 +1,68 @@
+import React from 'react';
+import { mount } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import DragDroppable from '../../../../../../src/dashboard/components/dnd/DragDroppable';
+import DraggableNewComponent from '../../../../../../src/dashboard/components/gridComponents/new/DraggableNewComponent';
+import WithDragDropContext from '../../../helpers/WithDragDropContext';
+
+import { NEW_COMPONENTS_SOURCE_ID } from '../../../../../../src/dashboard/util/constants';
+import {
+ NEW_COMPONENT_SOURCE_TYPE,
+ CHART_TYPE,
+} from '../../../../../../src/dashboard/util/componentTypes';
+
+describe('DraggableNewComponent', () => {
+ const props = {
+ id: 'id',
+ type: CHART_TYPE,
+ label: 'label!',
+ className: 'a_class',
+ };
+
+ function setup(overrideProps) {
+ // We have to wrap provide DragDropContext for the underlying DragDroppable
+ // otherwise we cannot assert on DragDroppable children
+ const wrapper = mount(
+ <WithDragDropContext>
+ <DraggableNewComponent {...props} {...overrideProps} />
+ </WithDragDropContext>,
+ );
+ return wrapper;
+ }
+
+ it('should render a DragDroppable', () => {
+ const wrapper = setup();
+ expect(wrapper.find(DragDroppable)).to.have.length(1);
+ });
+
+ it('should pass component={ type, id } to DragDroppable', () => {
+ const wrapper = setup();
+ const dragdroppable = wrapper.find(DragDroppable);
+ expect(dragdroppable.prop('component')).to.deep.equal({
+ id: props.id,
+ type: props.type,
+ });
+ });
+
+ it('should pass appropriate parent source and id to DragDroppable', () => {
+ const wrapper = setup();
+ const dragdroppable = wrapper.find(DragDroppable);
+ expect(dragdroppable.prop('parentComponent')).to.deep.equal({
+ id: NEW_COMPONENTS_SOURCE_ID,
+ type: NEW_COMPONENT_SOURCE_TYPE,
+ });
+ });
+
+ it('should render the passed label', () => {
+ const wrapper = setup();
+ expect(wrapper.find('.new-component').text()).to.equal(props.label);
+ });
+
+ it('should add the passed className', () => {
+ const wrapper = setup();
+ const className = `.new-component-placeholder.${props.className}`;
+ expect(wrapper.find(className)).to.have.length(1);
+ });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewColumn_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewColumn_spec.jsx
new file mode 100644
index 0000000000..cb07cb988a
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewColumn_spec.jsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import DraggableNewComponent from '../../../../../../src/dashboard/components/gridComponents/new/DraggableNewComponent';
+import NewColumn from '../../../../../../src/dashboard/components/gridComponents/new/NewColumn';
+
+import { NEW_COLUMN_ID } from '../../../../../../src/dashboard/util/constants';
+import { COLUMN_TYPE } from '../../../../../../src/dashboard/util/componentTypes';
+
+describe('NewColumn', () => {
+ function setup() {
+ return shallow(<NewColumn />);
+ }
+
+ it('should render a DraggableNewComponent', () => {
+ const wrapper = setup();
+ expect(wrapper.find(DraggableNewComponent)).to.have.length(1);
+ });
+
+ it('should set appropriate type and id', () => {
+ const wrapper = setup();
+ expect(wrapper.find(DraggableNewComponent).props()).to.include({
+ type: COLUMN_TYPE,
+ id: NEW_COLUMN_ID,
+ });
+ });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewDivider_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewDivider_spec.jsx
new file mode 100644
index 0000000000..71703b3900
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewDivider_spec.jsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import DraggableNewComponent from '../../../../../../src/dashboard/components/gridComponents/new/DraggableNewComponent';
+import NewDivider from '../../../../../../src/dashboard/components/gridComponents/new/NewDivider';
+
+import { NEW_DIVIDER_ID } from '../../../../../../src/dashboard/util/constants';
+import { DIVIDER_TYPE } from '../../../../../../src/dashboard/util/componentTypes';
+
+describe('NewDivider', () => {
+ function setup() {
+ return shallow(<NewDivider />);
+ }
+
+ it('should render a DraggableNewComponent', () => {
+ const wrapper = setup();
+ expect(wrapper.find(DraggableNewComponent)).to.have.length(1);
+ });
+
+ it('should set appropriate type and id', () => {
+ const wrapper = setup();
+ expect(wrapper.find(DraggableNewComponent).props()).to.include({
+ type: DIVIDER_TYPE,
+ id: NEW_DIVIDER_ID,
+ });
+ });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewHeader_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewHeader_spec.jsx
new file mode 100644
index 0000000000..a499fe8f6e
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewHeader_spec.jsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import DraggableNewComponent from '../../../../../../src/dashboard/components/gridComponents/new/DraggableNewComponent';
+import NewHeader from '../../../../../../src/dashboard/components/gridComponents/new/NewHeader';
+
+import { NEW_HEADER_ID } from '../../../../../../src/dashboard/util/constants';
+import { HEADER_TYPE } from '../../../../../../src/dashboard/util/componentTypes';
+
+describe('NewHeader', () => {
+ function setup() {
+ return shallow(<NewHeader />);
+ }
+
+ it('should render a DraggableNewComponent', () => {
+ const wrapper = setup();
+ expect(wrapper.find(DraggableNewComponent)).to.have.length(1);
+ });
+
+ it('should set appropriate type and id', () => {
+ const wrapper = setup();
+ expect(wrapper.find(DraggableNewComponent).props()).to.include({
+ type: HEADER_TYPE,
+ id: NEW_HEADER_ID,
+ });
+ });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewRow_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewRow_spec.jsx
new file mode 100644
index 0000000000..e91893d489
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewRow_spec.jsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import DraggableNewComponent from '../../../../../../src/dashboard/components/gridComponents/new/DraggableNewComponent';
+import NewRow from '../../../../../../src/dashboard/components/gridComponents/new/NewRow';
+
+import { NEW_ROW_ID } from '../../../../../../src/dashboard/util/constants';
+import { ROW_TYPE } from '../../../../../../src/dashboard/util/componentTypes';
+
+describe('NewRow', () => {
+ function setup() {
+ return shallow(<NewRow />);
+ }
+
+ it('should render a DraggableNewComponent', () => {
+ const wrapper = setup();
+ expect(wrapper.find(DraggableNewComponent)).to.have.length(1);
+ });
+
+ it('should set appropriate type and id', () => {
+ const wrapper = setup();
+ expect(wrapper.find(DraggableNewComponent).props()).to.include({
+ type: ROW_TYPE,
+ id: NEW_ROW_ID,
+ });
+ });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewTabs_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewTabs_spec.jsx
new file mode 100644
index 0000000000..4e71c8ca42
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/gridComponents/new/NewTabs_spec.jsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import DraggableNewComponent from '../../../../../../src/dashboard/components/gridComponents/new/DraggableNewComponent';
+import NewTabs from '../../../../../../src/dashboard/components/gridComponents/new/NewTabs';
+
+import { NEW_TABS_ID } from '../../../../../../src/dashboard/util/constants';
+import { TABS_TYPE } from '../../../../../../src/dashboard/util/componentTypes';
+
+describe('NewTabs', () => {
+ function setup() {
+ return shallow(<NewTabs />);
+ }
+
+ it('should render a DraggableNewComponent', () => {
+ const wrapper = setup();
+ expect(wrapper.find(DraggableNewComponent)).to.have.length(1);
+ });
+
+ it('should set appropriate type and id', () => {
+ const wrapper = setup();
+ expect(wrapper.find(DraggableNewComponent).props()).to.include({
+ type: TABS_TYPE,
+ id: NEW_TABS_ID,
+ });
+ });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/menu/HoverMenu_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/menu/HoverMenu_spec.jsx
new file mode 100644
index 0000000000..1f85085743
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/menu/HoverMenu_spec.jsx
@@ -0,0 +1,13 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import HoverMenu from '../../../../../src/dashboard/components/menu/HoverMenu';
+
+describe('HoverMenu', () => {
+ it('should render a div.hover-menu', () => {
+ const wrapper = shallow(<HoverMenu />);
+ expect(wrapper.find('.hover-menu')).to.have.length(1);
+ });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/menu/WithPopoverMenu_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/menu/WithPopoverMenu_spec.jsx
new file mode 100644
index 0000000000..5add770a8c
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/menu/WithPopoverMenu_spec.jsx
@@ -0,0 +1,71 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import WithPopoverMenu from '../../../../../src/dashboard/components/menu/WithPopoverMenu';
+
+describe('WithPopoverMenu', () => {
+ const props = {
+ children: <div id="child" />,
+ disableClick: false,
+ menuItems: [<div id="menu1" />, <div id="menu2" />],
+ onChangeFocus() {},
+ shouldFocus: () => true, // needed for mock
+ isFocused: false,
+ editMode: false,
+ };
+
+ function setup(overrideProps) {
+ const wrapper = shallow(<WithPopoverMenu {...props} {...overrideProps} />);
+ return wrapper;
+ }
+
+ it('should render a div with class "with-popover-menu"', () => {
+ const wrapper = setup();
+ expect(wrapper.find('.with-popover-menu')).to.have.length(1);
+ });
+
+ it('should render the passed children', () => {
+ const wrapper = setup();
+ expect(wrapper.find('#child')).to.have.length(1);
+ });
+
+ it('should focus on click in editMode', () => {
+ const wrapper = setup();
+ expect(wrapper.state('isFocused')).to.equal(false);
+
+ wrapper.simulate('click');
+ expect(wrapper.state('isFocused')).to.equal(false);
+
+ wrapper.setProps({ ...props, editMode: true });
+ wrapper.simulate('click');
+ expect(wrapper.state('isFocused')).to.equal(true);
+ });
+
+ it('should render menuItems when focused', () => {
+ const wrapper = setup({ editMode: true });
+ expect(wrapper.find('#menu1')).to.have.length(0);
+ expect(wrapper.find('#menu2')).to.have.length(0);
+
+ wrapper.simulate('click');
+ expect(wrapper.find('#menu1')).to.have.length(1);
+ expect(wrapper.find('#menu2')).to.have.length(1);
+ });
+
+ it('should not focus when disableClick=true', () => {
+ const wrapper = setup({ disableClick: true, editMode: true });
+ expect(wrapper.state('isFocused')).to.equal(false);
+
+ wrapper.simulate('click');
+ expect(wrapper.state('isFocused')).to.equal(false);
+ });
+
+ it('should use the passed shouldFocus func to determine if it should focus', () => {
+ const wrapper = setup({ editMode: true, shouldFocus: () => false });
+ expect(wrapper.state('isFocused')).to.equal(false);
+
+ wrapper.simulate('click');
+ expect(wrapper.state('isFocused')).to.equal(false);
+ });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/resizable/ResizableContainer_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/resizable/ResizableContainer_spec.jsx
new file mode 100644
index 0000000000..69fca76f69
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/resizable/ResizableContainer_spec.jsx
@@ -0,0 +1,20 @@
+import React from 'react';
+import Resizable from 're-resizable';
+import { shallow } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import ResizableContainer from '../../../../../src/dashboard/components/resizable/ResizableContainer';
+
+describe('ResizableContainer', () => {
+ const props = { editMode: false, id: 'id' };
+
+ function setup(propOverrides) {
+ return shallow(<ResizableContainer {...props} {...propOverrides} />);
+ }
+
+ it('should render a Resizable', () => {
+ const wrapper = setup();
+ expect(wrapper.find(Resizable)).to.have.length(1);
+ });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/components/resizable/ResizableHandle_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/resizable/ResizableHandle_spec.jsx
new file mode 100644
index 0000000000..0c37855d25
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/components/resizable/ResizableHandle_spec.jsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import ResizableHandle from '../../../../../src/dashboard/components/resizable/ResizableHandle';
+
+describe('ResizableHandle', () => {
+ it('should render a right resize handle', () => {
+ const wrapper = shallow(<ResizableHandle.right />);
+ expect(wrapper.find('.resize-handle.resize-handle--right')).to.have.length(
+ 1,
+ );
+ });
+
+ it('should render a bottom resize handle', () => {
+ const wrapper = shallow(<ResizableHandle.bottom />);
+ expect(wrapper.find('.resize-handle.resize-handle--bottom')).to.have.length(
+ 1,
+ );
+ });
+
+ it('should render a bottomRight resize handle', () => {
+ const wrapper = shallow(<ResizableHandle.bottomRight />);
+ expect(
+ wrapper.find('.resize-handle.resize-handle--bottom-right'),
+ ).to.have.length(1);
+ });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/fixtures.jsx b/superset/assets/spec/javascripts/dashboard/fixtures.jsx
deleted file mode 100644
index 371b02c02c..0000000000
--- a/superset/assets/spec/javascripts/dashboard/fixtures.jsx
+++ /dev/null
@@ -1,161 +0,0 @@
-import { getInitialState } from '../../../src/dashboard/reducers';
-
-export const defaultFilters = {
- 256: { region: [] },
- 257: { country_name: ['United States'] },
-};
-export const regionFilter = {
- datasource: null,
- description: null,
- description_markeddown: '',
- edit_url: '/slicemodelview/edit/256',
- form_data: {
- datasource: '2__table',
- date_filter: false,
- filters: [{
- col: 'country_name',
- op: 'in',
- val: ['United States', 'France', 'Japan'],
- }],
- granularity_sqla: null,
- groupby: ['region', 'country_name'],
- having: '',
- instant_filtering: true,
- metric: 'sum__SP_POP_TOTL',
- show_druid_time_granularity: false,
- show_druid_time_origin: false,
- show_sqla_time_column: false,
- show_sqla_time_granularity: false,
- since: '100 years ago',
- slice_id: 256,
- time_grain_sqla: null,
- until: 'now',
- viz_type: 'filter_box',
- where: '',
- },
- slice_id: 256,
- slice_name: 'Region Filters',
- slice_url: '/superset/explore/table/2/?form_data=%7B%22slice_id%22%3A%20256%7D',
-};
-export const countryFilter = {
- datasource: null,
- description: null,
- description_markeddown: '',
- edit_url: '/slicemodelview/edit/257',
- form_data: {
- datasource: '2__table',
- date_filter: false,
- filters: [],
- granularity_sqla: null,
- groupby: ['country_name'],
- having: '',
- instant_filtering: true,
- metric: 'sum__SP_POP_TOTL',
- show_druid_time_granularity: false,
- show_druid_time_origin: false,
- show_sqla_time_column: false,
- show_sqla_time_granularity: false,
- since: '100 years ago',
- slice_id: 257,
- time_grain_sqla: null,
- until: 'now',
- viz_type: 'filter_box',
- where: '',
- },
- slice_id: 257,
- slice_name: 'Country Filters',
- slice_url: '/superset/explore/table/2/?form_data=%7B%22slice_id%22%3A%20257%7D',
-};
-export const slice = {
- datasource: null,
- description: null,
- description_markeddown: '',
- edit_url: '/slicemodelview/edit/248',
- form_data: {
- annotation_layers: [],
- bottom_margin: 'auto',
- color_scheme: 'bnbColors',
- contribution: false,
- datasource: '2__table',
- filters: [],
- granularity_sqla: null,
- groupby: [],
- having: '',
- left_margin: 'auto',
- limit: 50,
- line_interpolation: 'linear',
- metrics: ['sum__SP_POP_TOTL'],
- num_period_compare: '',
- order_desc: true,
- period_ratio_type: 'growth',
- resample_fillmethod: null,
- resample_how: null,
- resample_rule: null,
- rich_tooltip: true,
- rolling_type: 'None',
- show_brush: false,
- show_legend: true,
- show_markers: false,
- since: '1961-01-01T00:00:00',
- slice_id: 248,
- time_compare: null,
- time_grain_sqla: null,
- timeseries_limit_metric: null,
- until: '2014-12-31T00:00:00',
- viz_type: 'line',
- where: '',
- x_axis_format: 'smart_date',
- x_axis_label: '',
- x_axis_showminmax: true,
- y_axis_bounds: [null, null],
- y_axis_format: '.3s',
- y_axis_label: '',
- y_axis_showminmax: true,
- y_log_scale: false,
- },
- slice_id: 248,
- slice_name: 'Filtered Population',
- slice_url: '/superset/explore/table/2/?form_data=%7B%22slice_id%22%3A%20248%7D',
-};
-
-const datasources = {};
-const mockDashboardData = {
- css: '',
- dash_edit_perm: true,
- dash_save_perm: true,
- dashboard_title: 'Births',
- id: 2,
- metadata: {
- default_filters: JSON.stringify(defaultFilters),
- filter_immune_slices: [256],
- timed_refresh_immune_slices: [],
- filter_immune_slice_fields: {},
- expanded_slices: {},
- },
- position_json: [
- {
- size_x: 4,
- slice_id: '256',
- row: 0,
- size_y: 4,
- col: 5,
- },
- {
- size_x: 4,
- slice_id: '248',
- row: 0,
- size_y: 4,
- col: 1,
- },
- ],
- slug: 'births',
- slices: [regionFilter, slice, countryFilter],
- standalone_mode: false,
-};
-export const { dashboard, charts } = getInitialState({
- common: {},
- dashboard_data: mockDashboardData,
- datasources,
- user_id: '1',
-});
-
diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockChartQueries.js b/superset/assets/spec/javascripts/dashboard/fixtures/mockChartQueries.js
new file mode 100644
index 0000000000..b5004a1e18
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/fixtures/mockChartQueries.js
@@ -0,0 +1,61 @@
+import { datasourceId } from './mockDatasource';
+
+export const sliceId = 18;
+
+export default {
+ [sliceId]: {
+ id: sliceId,
+ chartAlert: null,
+ chartStatus: 'rendered',
+ chartUpdateEndTime: 1525852456388,
+ chartUpdateStartTime: 1525852454838,
+ latestQueryFormData: {},
+ queryRequest: {},
+ queryResponse: {},
+ triggerQuery: false,
+ lastRendered: 0,
+ form_data: {
+ slice_id: sliceId,
+ viz_type: 'pie',
+ row_limit: 50000,
+ metric: 'sum__num',
+ since: '100 years ago',
+ groupby: ['gender'],
+ metrics: ['sum__num'],
+ compare_lag: '10',
+ limit: '25',
+ until: 'now',
+ granularity: 'ds',
+ markup_type: 'markdown',
+ where: '',
+ compare_suffix: 'o10Y',
+ datasource: datasourceId,
+ },
+ formData: {
+ datasource: datasourceId,
+ viz_type: 'pie',
+ slice_id: sliceId,
+ granularity_sqla: null,
+ time_grain_sqla: null,
+ since: '100 years ago',
+ until: 'now',
+ metrics: ['sum__num'],
+ groupby: ['gender'],
+ limit: '25',
+ pie_label_type: 'key',
+ donut: false,
+ show_legend: true,
+ labels_outside: true,
+ color_scheme: 'bnbColors',
+ where: '',
+ having: '',
+ filters: [],
+ row_limit: 50000,
+ metric: 'sum__num',
+ compare_lag: '10',
+ granularity: 'ds',
+ markup_type: 'markdown',
+ compare_suffix: 'o10Y',
+ },
+ },
+};
diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardInfo.js b/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardInfo.js
new file mode 100644
index 0000000000..4dd2670722
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardInfo.js
@@ -0,0 +1,12 @@
+export default {
+ id: 1234,
+ slug: 'dashboardSlug',
+ metadata: {},
+ userId: 'mock_user_id',
+ dash_edit_perm: true,
+ dash_save_perm: true,
+ common: {
+ flash_messages: [],
+ conf: { ENABLE_JAVASCRIPT_CONTROLS: false, SUPERSET_WEBSERVER_TIMEOUT: 60 },
+ },
+};
diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardLayout.js b/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardLayout.js
new file mode 100644
index 0000000000..7327d69475
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardLayout.js
@@ -0,0 +1,146 @@
+import {
+ DASHBOARD_GRID_TYPE,
+ DASHBOARD_HEADER_TYPE,
+ DASHBOARD_ROOT_TYPE,
+ TABS_TYPE,
+ TAB_TYPE,
+ CHART_TYPE,
+ ROW_TYPE,
+ COLUMN_TYPE,
+ MARKDOWN_TYPE,
+} from '../../../../src/dashboard/util/componentTypes';
+
+import {
+ DASHBOARD_ROOT_ID,
+ DASHBOARD_HEADER_ID,
+ DASHBOARD_GRID_ID,
+} from '../../../../src/dashboard/util/constants';
+
+import newComponentFactory from '../../../../src/dashboard/util/newComponentFactory';
+
+import { sliceId as chartId } from './mockChartQueries';
+
+export const sliceId = chartId;
+
+export const dashboardLayout = {
+ past: [],
+ present: {
+ [DASHBOARD_ROOT_ID]: {
+ type: DASHBOARD_ROOT_TYPE,
+ id: DASHBOARD_ROOT_ID,
+ children: [DASHBOARD_GRID_ID],
+ },
+
+ [DASHBOARD_GRID_ID]: {
+ type: DASHBOARD_GRID_TYPE,
+ id: DASHBOARD_GRID_ID,
+ children: ['ROW_ID'],
+ meta: {},
+ },
+
+ [DASHBOARD_HEADER_ID]: {
+ type: DASHBOARD_HEADER_TYPE,
+ id: DASHBOARD_HEADER_ID,
+ meta: {
+ text: 'New dashboard',
+ },
+ },
+
+ ROW_ID: {
+ ...newComponentFactory(ROW_TYPE),
+ id: 'ROW_ID',
+ children: ['COLUMN_ID'],
+ },
+
+ COLUMN_ID: {
+ ...newComponentFactory(COLUMN_TYPE),
+ id: 'COLUMN_ID',
+ children: ['CHART_ID'],
+ },
+
+ CHART_ID: {
+ ...newComponentFactory(CHART_TYPE),
+ id: 'CHART_ID',
+ meta: {
+ chartId,
+ width: 3,
+ height: 10,
+ chartName: 'Mock chart name',
+ },
+ },
+
+ MARKDOWN_ID: {
+ ...newComponentFactory(MARKDOWN_TYPE),
+ id: 'MARKDOWN_ID',
+ },
+ },
+ future: [],
+};
+
+export const dashboardLayoutWithTabs = {
+ past: [],
+ present: {
+ [DASHBOARD_ROOT_ID]: {
+ type: DASHBOARD_ROOT_TYPE,
+ id: DASHBOARD_ROOT_ID,
+ children: ['TABS_ID'],
+ },
+
+ TABS_ID: {
+ id: 'TABS_ID',
+ type: TABS_TYPE,
+ children: ['TAB_ID', 'TAB_ID2'],
+ },
+
+ TAB_ID: {
+ id: 'TAB_ID',
+ type: TAB_TYPE,
+ children: ['ROW_ID'],
+ meta: {
+ text: 'tab1',
+ },
+ },
+
+ TAB_ID2: {
+ id: 'TAB_ID2',
+ type: TAB_TYPE,
+ children: [],
+ meta: {
+ text: 'tab2',
+ },
+ },
+
+ CHART_ID: {
+ ...newComponentFactory(CHART_TYPE),
+ id: 'CHART_ID',
+ meta: {
+ chartId,
+ width: 3,
+ height: 10,
+ chartName: 'Mock chart name',
+ },
+ },
+
+ ROW_ID: {
+ ...newComponentFactory(ROW_TYPE),
+ id: 'ROW_ID',
+ children: ['CHART_ID'],
+ },
+
+ [DASHBOARD_GRID_ID]: {
+ type: DASHBOARD_GRID_TYPE,
+ id: DASHBOARD_GRID_ID,
+ children: [],
+ meta: {},
+ },
+
+ [DASHBOARD_HEADER_ID]: {
+ type: DASHBOARD_HEADER_TYPE,
+ id: DASHBOARD_HEADER_ID,
+ meta: {
+ text: 'New dashboard',
+ },
+ },
+ },
+ future: [],
+};
diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardState.js b/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardState.js
new file mode 100644
index 0000000000..d405ccf327
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/fixtures/mockDashboardState.js
@@ -0,0 +1,14 @@
+import { id as sliceId } from './mockChartQueries';
+
+export default {
+ sliceIds: [sliceId],
+ refresh: false,
+ filters: {},
+ expandedSlices: {},
+ editMode: false,
+ showBuilderPane: false,
+ hasUnsavedChanges: false,
+ maxUndoHistoryExceeded: false,
+ isStarred: true,
+ css: '',
+};
diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockDatasource.js b/superset/assets/spec/javascripts/dashboard/fixtures/mockDatasource.js
new file mode 100644
index 0000000000..1de79158e0
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/fixtures/mockDatasource.js
@@ -0,0 +1,206 @@
+export const id = 7;
+export const datasourceId = `${id}__table`;
+
+export default {
+ [datasourceId]: {
+ verbose_map: {
+ count: 'COUNT(*)',
+ __timestamp: 'Time',
+ sum__sum_girls: 'sum__sum_girls',
+ name: 'name',
+ avg__sum_girls: 'avg__sum_girls',
+ gender: 'gender',
+ sum_girls: 'sum_girls',
+ ds: 'ds',
+ sum__sum_boys: 'sum__sum_boys',
+ state: 'state',
+ num: 'num',
+ sum__num: 'sum__num',
+ sum_boys: 'sum_boys',
+ avg__num: 'avg__num',
+ avg__sum_boys: 'avg__sum_boys',
+ },
+ gb_cols: [['gender', 'gender'], ['name', 'name'], ['state', 'state']],
+ metrics: [
+ {
+ expression: 'SUM(birth_names.num)',
+ warning_text: null,
+ verbose_name: 'sum__num',
+ metric_name: 'sum__num',
+ description: null,
+ },
+ {
+ expression: 'AVG(birth_names.num)',
+ warning_text: null,
+ verbose_name: 'avg__num',
+ metric_name: 'avg__num',
+ description: null,
+ },
+ {
+ expression: 'SUM(birth_names.sum_boys)',
+ warning_text: null,
+ verbose_name: 'sum__sum_boys',
+ metric_name: 'sum__sum_boys',
+ description: null,
+ },
+ {
+ expression: 'AVG(birth_names.sum_boys)',
+ warning_text: null,
+ verbose_name: 'avg__sum_boys',
+ metric_name: 'avg__sum_boys',
+ description: null,
+ },
+ {
+ expression: 'SUM(birth_names.sum_girls)',
+ warning_text: null,
+ verbose_name: 'sum__sum_girls',
+ metric_name: 'sum__sum_girls',
+ description: null,
+ },
+ {
+ expression: 'AVG(birth_names.sum_girls)',
+ warning_text: null,
+ verbose_name: 'avg__sum_girls',
+ metric_name: 'avg__sum_girls',
+ description: null,
+ },
+ {
+ expression: 'COUNT(*)',
+ warning_text: null,
+ verbose_name: 'COUNT(*)',
+ metric_name: 'count',
+ description: null,
+ },
+ ],
+ column_formats: {},
+ columns: [
+ {
+ type: 'DATETIME',
+ description: null,
+ filterable: false,
+ verbose_name: null,
+ is_dttm: true,
+ expression: '',
+ groupby: false,
+ column_name: 'ds',
+ },
+ {
+ type: 'VARCHAR(16)',
+ description: null,
+ filterable: true,
+ verbose_name: null,
+ is_dttm: false,
+ expression: '',
+ groupby: true,
+ column_name: 'gender',
+ },
+ {
+ type: 'VARCHAR(255)',
+ description: null,
+ filterable: true,
+ verbose_name: null,
+ is_dttm: false,
+ expression: '',
+ groupby: true,
+ column_name: 'name',
+ },
+ {
+ type: 'BIGINT',
+ description: null,
+ filterable: false,
+ verbose_name: null,
+ is_dttm: false,
+ expression: '',
+ groupby: false,
+ column_name: 'num',
+ },
+ {
+ type: 'VARCHAR(10)',
+ description: null,
+ filterable: true,
+ verbose_name: null,
+ is_dttm: false,
+ expression: '',
+ groupby: true,
+ column_name: 'state',
+ },
+ {
+ type: 'BIGINT',
+ description: null,
+ filterable: false,
+ verbose_name: null,
+ is_dttm: false,
+ expression: '',
+ groupby: false,
+ column_name: 'sum_boys',
+ },
+ {
+ type: 'BIGINT',
+ description: null,
+ filterable: false,
+ verbose_name: null,
+ is_dttm: false,
+ expression: '',
+ groupby: false,
+ column_name: 'sum_girls',
+ },
+ ],
+ id,
+ granularity_sqla: [['ds', 'ds']],
+ name: 'birth_names',
+ database: {
+ allow_multi_schema_metadata_fetch: null,
+ name: 'main',
+ backend: 'sqlite',
+ },
+ time_grain_sqla: [
+ [null, 'Time Column'],
+ ['PT1H', 'hour'],
+ ['P1D', 'day'],
+ ['P1W', 'week'],
+ ['P1M', 'month'],
+ ],
+ filterable_cols: [
+ ['gender', 'gender'],
+ ['name', 'name'],
+ ['state', 'state'],
+ ],
+ all_cols: [
+ ['ds', 'ds'],
+ ['gender', 'gender'],
+ ['name', 'name'],
+ ['num', 'num'],
+ ['state', 'state'],
+ ['sum_boys', 'sum_boys'],
+ ['sum_girls', 'sum_girls'],
+ ],
+ filter_select: true,
+ order_by_choices: [
+ ['["ds", true]', 'ds [asc]'],
+ ['["ds", false]', 'ds [desc]'],
+ ['["gender", true]', 'gender [asc]'],
+ ['["gender", false]', 'gender [desc]'],
+ ['["name", true]', 'name [asc]'],
+ ['["name", false]', 'name [desc]'],
+ ['["num", true]', 'num [asc]'],
+ ['["num", false]', 'num [desc]'],
+ ['["state", true]', 'state [asc]'],
+ ['["state", false]', 'state [desc]'],
+ ['["sum_boys", true]', 'sum_boys [asc]'],
+ ['["sum_boys", false]', 'sum_boys [desc]'],
+ ['["sum_girls", true]', 'sum_girls [asc]'],
+ ['["sum_girls", false]', 'sum_girls [desc]'],
+ ],
+ metrics_combo: [
+ ['count', 'COUNT(*)'],
+ ['avg__num', 'avg__num'],
+ ['avg__sum_boys', 'avg__sum_boys'],
+ ['avg__sum_girls', 'avg__sum_girls'],
+ ['sum__num', 'sum__num'],
+ ['sum__sum_boys', 'sum__sum_boys'],
+ ['sum__sum_girls', 'sum__sum_girls'],
+ ],
+ type: 'table',
+ edit_url: '/tablemodelview/edit/7',
+ },
+};
diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockSliceEntities.js b/superset/assets/spec/javascripts/dashboard/fixtures/mockSliceEntities.js
new file mode 100644
index 0000000000..3644c100a2
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/fixtures/mockSliceEntities.js
@@ -0,0 +1,177 @@
+import { sliceId as id } from './mockChartQueries';
+import { datasourceId } from './mockDatasource';
+
+export const sliceId = id;
+
+export const sliceEntitiesForChart = {
+ slices: {
+ [sliceId]: {
+ slice_id: sliceId,
+ slice_url: '/superset/explore/?form_data=%7B%22slice_id%22%3A%2018%7D',
+ slice_name: 'Genders',
+ form_data: {
+ slice_id: sliceId,
+ viz_type: 'pie',
+ row_limit: 50000,
+ metric: 'sum__num',
+ since: '100 years ago',
+ groupby: ['gender'],
+ metrics: ['sum__num'],
+ compare_lag: '10',
+ limit: '25',
+ until: 'now',
+ granularity: 'ds',
+ markup_type: 'markdown',
+ where: '',
+ compare_suffix: 'o10Y',
+ datasource: datasourceId,
+ },
+ edit_url: `/slicemodelview/edit/${sliceId}`,
+ viz_type: 'pie',
+ datasource: datasourceId,
+ description: null,
+ description_markeddown: '',
+ },
+ },
+ isLoading: false,
+ errorMessage: null,
+ lastUpdated: 0,
+};
+
+export const sliceEntitiesForDashboard = {
+ slices: {
+ 127: {
+ slice_id: 127,
+ slice_url: '/superset/explore/?form_data=%7B%22slice_id%22%3A%20127%7D',
+ slice_name: 'Region Filter',
+ form_data: {},
+ edit_url: '/slicemodelview/edit/127',
+ viz_type: 'filter_box',
+ datasource: '2__table',
+ description: null,
+ description_markeddown: '',
+ modified: '23 hours ago',
+ changed_on: 1529453332615,
+ },
+ 128: {
+ slice_id: 128,
+ slice_url: '/superset/explore/?form_data=%7B%22slice_id%22%3A%20128%7D',
+ slice_name: "World's Population",
+ form_data: {},
+ edit_url: '/slicemodelview/edit/128',
+ viz_type: 'big_number',
+ datasource: '2__table',
+ description: null,
+ description_markeddown: '',
+ modified: '23 hours ago',
+ changed_on: 1529453332628,
+ },
+ 129: {
+ slice_id: 129,
+ slice_url: '/superset/explore/?form_data=%7B%22slice_id%22%3A%20129%7D',
+ slice_name: 'Most Populated Countries',
+ form_data: {},
+ edit_url: '/slicemodelview/edit/129',
+ viz_type: 'table',
+ datasource: '2__table',
+ description: null,
+ description_markeddown: '',
+ modified: '23 hours ago',
+ changed_on: 1529453332637,
+ },
+ 130: {
+ slice_id: 130,
+ slice_url: '/superset/explore/?form_data=%7B%22slice_id%22%3A%20130%7D',
+ slice_name: 'Growth Rate',
+ form_data: {},
+ edit_url: '/slicemodelview/edit/130',
+ viz_type: 'line',
+ datasource: '2__table',
+ description: null,
+ description_markeddown: '',
+ modified: '23 hours ago',
+ changed_on: 1529453332645,
+ },
+ 131: {
+ slice_id: 131,
+ slice_url: '/superset/explore/?form_data=%7B%22slice_id%22%3A%20131%7D',
+ slice_name: '% Rural',
+ form_data: {},
+ edit_url: '/slicemodelview/edit/131',
+ viz_type: 'world_map',
+ datasource: '2__table',
+ description: null,
+ description_markeddown: '',
+ modified: '23 hours ago',
+ changed_on: 1529453332654,
+ },
+ 132: {
+ slice_id: 132,
+ slice_url: '/superset/explore/?form_data=%7B%22slice_id%22%3A%20132%7D',
+ slice_name: 'Life Expectancy VS Rural %',
+ form_data: {},
+ edit_url: '/slicemodelview/edit/132',
+ viz_type: 'bubble',
+ datasource: '2__table',
+ description: null,
+ description_markeddown: '',
+ modified: '23 hours ago',
+ changed_on: 1529453332663,
+ },
+ 133: {
+ slice_id: 133,
+ slice_url: '/superset/explore/?form_data=%7B%22slice_id%22%3A%20133%7D',
+ slice_name: 'Rural Breakdown',
+ form_data: {},
+ edit_url: '/slicemodelview/edit/133',
+ viz_type: 'sunburst',
+ datasource: '2__table',
+ description: null,
+ description_markeddown: '',
+ modified: '23 hours ago',
+ changed_on: 1529453332673,
+ },
+ 134: {
+ slice_id: 134,
+ slice_url: '/superset/explore/?form_data=%7B%22slice_id%22%3A%20134%7D',
+ slice_name: "World's Pop Growth",
+ form_data: {},
+ edit_url: '/slicemodelview/edit/134',
+ viz_type: 'area',
+ datasource: '2__table',
+ description: null,
+ description_markeddown: '',
+ modified: '23 hours ago',
+ changed_on: 1529453332680,
+ },
+ 135: {
+ slice_id: 135,
+ slice_url: '/superset/explore/?form_data=%7B%22slice_id%22%3A%20135%7D',
+ slice_name: 'Box plot',
+ form_data: {},
+ edit_url: '/slicemodelview/edit/135',
+ viz_type: 'box_plot',
+ datasource: '2__table',
+ description: null,
+ description_markeddown: '',
+ modified: '23 hours ago',
+ changed_on: 1529453332688,
+ },
+ 136: {
+ slice_id: 136,
+ slice_url: '/superset/explore/?form_data=%7B%22slice_id%22%3A%20136%7D',
+ slice_name: 'Treemap',
+ form_data: {},
+ edit_url: '/slicemodelview/edit/136',
+ viz_type: 'treemap',
+ datasource: '2__table',
+ description: null,
+ description_markeddown: '',
+ modified: '23 hours ago',
+ changed_on: 1529453332700,
+ },
+ },
+ isLoading: false,
+ errorMessage: null,
+ lastUpdated: 0,
+};
diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockState.js b/superset/assets/spec/javascripts/dashboard/fixtures/mockState.js
new file mode 100644
index 0000000000..514442fc00
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/fixtures/mockState.js
@@ -0,0 +1,18 @@
+import chartQueries from './mockChartQueries';
+import { dashboardLayout } from './mockDashboardLayout';
+import dashboardInfo from './mockDashboardInfo';
+import dashboardState from './mockDashboardState';
+import messageToasts from '../../messageToasts/mockMessageToasts';
+import datasources from './mockDatasource';
+import sliceEntities from './mockSliceEntities';
+
+export default {
+ datasources,
+ sliceEntities,
+ charts: chartQueries,
+ dashboardInfo,
+ dashboardState,
+ dashboardLayout,
+ messageToasts,
+ impressionId: 'mock_impression_id',
+};
diff --git a/superset/assets/spec/javascripts/dashboard/fixtures/mockStore.js b/superset/assets/spec/javascripts/dashboard/fixtures/mockStore.js
new file mode 100644
index 0000000000..97132ac43a
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/fixtures/mockStore.js
@@ -0,0 +1,22 @@
+import { createStore, applyMiddleware, compose } from 'redux';
+import thunk from 'redux-thunk';
+
+import rootReducer from '../../../../src/dashboard/reducers/index';
+
+import mockState from './mockState';
+import { dashboardLayoutWithTabs } from './mockDashboardLayout';
+
+export const mockStore = createStore(
+ rootReducer,
+ mockState,
+ compose(applyMiddleware(thunk)),
+);
+
+export const mockStoreWithTabs = createStore(
+ rootReducer,
+ {
+ ...mockState,
+ dashboardLayout: dashboardLayoutWithTabs,
+ },
+ compose(applyMiddleware(thunk)),
+);
diff --git a/superset/assets/spec/javascripts/dashboard/helpers/WithDragDropContext.jsx b/superset/assets/spec/javascripts/dashboard/helpers/WithDragDropContext.jsx
new file mode 100644
index 0000000000..3e892a6c16
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/helpers/WithDragDropContext.jsx
@@ -0,0 +1,27 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import getDragDropManager from '../../../../src/dashboard/util/getDragDropManager';
+
+// A helper component that provides a DragDropContext for components that require it
+class WithDragDropContext extends React.Component {
+ getChildContext() {
+ return {
+ dragDropManager: this.context.dragDropManager || getDragDropManager(),
+ };
+ }
+
+ render() {
+ return this.props.children;
+ }
+}
+
+WithDragDropContext.propTypes = {
+ children: PropTypes.node.isRequired,
+};
+
+WithDragDropContext.childContextTypes = {
+ dragDropManager: PropTypes.object.isRequired,
+};
+
+export default WithDragDropContext;
diff --git a/superset/assets/spec/javascripts/dashboard/reducers/dashboardLayout_spec.js b/superset/assets/spec/javascripts/dashboard/reducers/dashboardLayout_spec.js
new file mode 100644
index 0000000000..dd933ac572
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/reducers/dashboardLayout_spec.js
@@ -0,0 +1,443 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import layoutReducer from '../../../../src/dashboard/reducers/dashboardLayout';
+
+import {
+ UPDATE_COMPONENTS,
+ DELETE_COMPONENT,
+ CREATE_COMPONENT,
+ MOVE_COMPONENT,
+ CREATE_TOP_LEVEL_TABS,
+ DELETE_TOP_LEVEL_TABS,
+} from '../../../../src/dashboard/actions/dashboardLayout';
+
+import {
+ CHART_TYPE,
+ DASHBOARD_GRID_TYPE,
+ DASHBOARD_ROOT_TYPE,
+ ROW_TYPE,
+ TAB_TYPE,
+ TABS_TYPE,
+} from '../../../../src/dashboard/util/componentTypes';
+
+import {
+ DASHBOARD_ROOT_ID,
+ DASHBOARD_GRID_ID,
+ NEW_COMPONENTS_SOURCE_ID,
+ NEW_TABS_ID,
+ NEW_ROW_ID,
+} from '../../../../src/dashboard/util/constants';
+
+describe('dashboardLayout reducer', () => {
+ it('should return initial state for unrecognized actions', () => {
+ expect(layoutReducer(undefined, {})).to.deep.equal({});
+ });
+
+ it('should delete a component, remove its reference in its parent, and recursively all of its children', () => {
+ expect(
+ layoutReducer(
+ {
+ toDelete: {
+ id: 'toDelete',
+ children: ['child1'],
+ },
+ child1: {
+ id: 'child1',
+ children: ['child2'],
+ },
+ child2: {
+ id: 'child2',
+ children: [],
+ },
+ parentId: {
+ id: 'parentId',
+ type: ROW_TYPE,
+ children: ['toDelete', 'anotherId'],
+ },
+ },
+ {
+ type: DELETE_COMPONENT,
+ payload: { id: 'toDelete', parentId: 'parentId' },
+ },
+ ),
+ ).to.deep.equal({
+ parentId: {
+ id: 'parentId',
+ children: ['anotherId'],
+ type: ROW_TYPE,
+ },
+ });
+ });
+
+ it('should delete a parent if the parent was a row and no longer has children', () => {
+ expect(
+ layoutReducer(
+ {
+ grandparentId: {
+ id: 'grandparentId',
+ children: ['parentId'],
+ },
+ parentId: {
+ id: 'parentId',
+ type: ROW_TYPE,
+ children: ['toDelete'],
+ },
+ toDelete: {
+ id: 'toDelete',
+ children: ['child1'],
+ },
+ child1: {
+ id: 'child1',
+ children: [],
+ },
+ },
+ {
+ type: DELETE_COMPONENT,
+ payload: { id: 'toDelete', parentId: 'parentId' },
+ },
+ ),
+ ).to.deep.equal({
+ grandparentId: {
+ id: 'grandparentId',
+ children: [],
+ },
+ });
+ });
+
+ it('should update components', () => {
+ expect(
+ layoutReducer(
+ {
+ update: {
+ id: 'update',
+ children: [],
+ },
+ update2: {
+ id: 'update2',
+ children: [],
+ },
+ dontUpdate: {
+ id: 'dontUpdate',
+ something: 'something',
+ children: ['abcd'],
+ },
+ },
+ {
+ type: UPDATE_COMPONENTS,
+ payload: {
+ nextComponents: {
+ update: {
+ id: 'update',
+ newField: 'newField',
+ },
+ update2: {
+ id: 'update2',
+ newField: 'newField',
+ },
+ },
+ },
+ },
+ ),
+ ).to.deep.equal({
+ update: {
+ id: 'update',
+ newField: 'newField',
+ },
+ update2: {
+ id: 'update2',
+ newField: 'newField',
+ },
+ dontUpdate: {
+ id: 'dontUpdate',
+ something: 'something',
+ children: ['abcd'],
+ },
+ });
+ });
+
+ it('should move a component', () => {
+ const layout = {
+ source: {
+ id: 'source',
+ type: ROW_TYPE,
+ children: ['dontMove', 'toMove'],
+ },
+ destination: {
+ id: 'destination',
+ type: ROW_TYPE,
+ children: ['anotherChild'],
+ },
+ toMove: {
+ id: 'toMove',
+ type: CHART_TYPE,
+ children: [],
+ },
+ };
+
+ const dropResult = {
+ source: { id: 'source', type: ROW_TYPE, index: 1 },
+ destination: { id: 'destination', type: ROW_TYPE, index: 0 },
+ dragging: { id: 'toMove', type: CHART_TYPE },
+ };
+
+ expect(
+ layoutReducer(layout, {
+ type: MOVE_COMPONENT,
+ payload: { dropResult },
+ }),
+ ).to.deep.equal({
+ source: {
+ id: 'source',
+ type: ROW_TYPE,
+ children: ['dontMove'],
+ },
+ destination: {
+ id: 'destination',
+ type: ROW_TYPE,
+ children: ['toMove', 'anotherChild'],
+ },
+ toMove: {
+ id: 'toMove',
+ type: CHART_TYPE,
+ children: [],
+ },
+ });
+ });
+
+ it('should wrap a moved component in a row if need be', () => {
+ const layout = {
+ source: {
+ id: 'source',
+ type: ROW_TYPE,
+ children: ['dontMove', 'toMove'],
+ },
+ destination: {
+ id: 'destination',
+ type: DASHBOARD_GRID_TYPE,
+ children: [],
+ },
+ toMove: {
+ id: 'toMove',
+ type: CHART_TYPE,
+ children: [],
+ },
+ };
+
+ const dropResult = {
+ source: { id: 'source', type: ROW_TYPE, index: 1 },
+ destination: { id: 'destination', type: DASHBOARD_GRID_TYPE, index: 0 },
+ dragging: { id: 'toMove', type: CHART_TYPE },
+ };
+
+ const result = layoutReducer(layout, {
+ type: MOVE_COMPONENT,
+ payload: { dropResult },
+ });
+
+ const newRow = Object.values(result).find(
+ component =>
+ ['source', 'destination', 'toMove'].indexOf(component.id) === -1,
+ );
+
+ expect(newRow.children[0]).to.equal('toMove');
+ expect(result.destination.children[0]).to.equal(newRow.id);
+ expect(Object.keys(result)).to.have.length(4);
+ });
+
+ it('should add top-level tabs from a new tabs component, moving grid children to new tab', () => {
+ const layout = {
+ [DASHBOARD_ROOT_ID]: {
+ id: DASHBOARD_ROOT_ID,
+ children: [DASHBOARD_GRID_ID],
+ },
+ [DASHBOARD_GRID_ID]: {
+ id: DASHBOARD_GRID_ID,
+ children: ['child'],
+ },
+ child: {
+ id: 'child',
+ children: [],
+ },
+ };
+
+ const dropResult = {
+ source: { id: NEW_COMPONENTS_SOURCE_ID, type: '' },
+ destination: {
+ id: DASHBOARD_ROOT_ID,
+ type: DASHBOARD_ROOT_TYPE,
+ index: 0,
+ },
+ dragging: { id: NEW_TABS_ID, type: TABS_TYPE },
+ };
+
+ const result = layoutReducer(layout, {
+ type: CREATE_TOP_LEVEL_TABS,
+ payload: { dropResult },
+ });
+
+ const tabComponent = Object.values(result).find(
+ component => component.type === TAB_TYPE,
+ );
+
+ const tabsComponent = Object.values(result).find(
+ component => component.type === TABS_TYPE,
+ );
+
+ expect(Object.keys(result)).to.have.length(5); // initial + Tabs + Tab
+ expect(result[DASHBOARD_ROOT_ID].children[0]).to.equal(tabsComponent.id);
+ expect(result[tabsComponent.id].children[0]).to.equal(tabComponent.id);
+ expect(result[tabComponent.id].children[0]).to.equal('child');
+ expect(result[DASHBOARD_GRID_ID].children).to.have.length(0);
+ });
+
+ it('should add top-level tabs from an existing tabs component, moving grid children to new tab', () => {
+ const layout = {
+ [DASHBOARD_ROOT_ID]: {
+ id: DASHBOARD_ROOT_ID,
+ children: [DASHBOARD_GRID_ID],
+ },
+ [DASHBOARD_GRID_ID]: {
+ id: DASHBOARD_GRID_ID,
+ children: ['child', 'tabs', 'child2'],
+ },
+ child: {
+ id: 'child',
+ children: [],
+ },
+ child2: {
+ id: 'child2',
+ children: [],
+ },
+ tabs: {
+ id: 'tabs',
+ type: TABS_TYPE,
+ children: ['tab'],
+ },
+ tab: {
+ id: 'tab',
+ type: TAB_TYPE,
+ children: [],
+ },
+ };
+
+ const dropResult = {
+ source: { id: DASHBOARD_GRID_ID, type: DASHBOARD_GRID_TYPE, index: 1 },
+ destination: {
+ id: DASHBOARD_ROOT_ID,
+ type: DASHBOARD_ROOT_TYPE,
+ index: 0,
+ },
+ dragging: { id: 'tabs', type: TABS_TYPE },
+ };
+
+ const result = layoutReducer(layout, {
+ type: CREATE_TOP_LEVEL_TABS,
+ payload: { dropResult },
+ });
+
+ expect(Object.keys(result)).to.have.length(Object.keys(layout).length);
+ expect(result[DASHBOARD_ROOT_ID].children[0]).to.equal('tabs');
+ expect(result.tabs.children[0]).to.equal('tab');
+ expect(result.tab.children).to.deep.equal(['child', 'child2']);
+ expect(result[DASHBOARD_GRID_ID].children).to.have.length(0);
+ });
+
+ it('should remove top-level tabs, moving children to the grid', () => {
+ const layout = {
+ [DASHBOARD_ROOT_ID]: {
+ id: DASHBOARD_ROOT_ID,
+ children: ['tabs'],
+ },
+ [DASHBOARD_GRID_ID]: {
+ id: DASHBOARD_GRID_ID,
+ children: [],
+ },
+ child: {
+ id: 'child',
+ children: [],
+ },
+ child2: {
+ id: 'child2',
+ children: [],
+ },
+ tabs: {
+ id: 'tabs',
+ type: TABS_TYPE,
+ children: ['tab'],
+ },
+ tab: {
+ id: 'tab',
+ type: TAB_TYPE,
+ children: ['child', 'child2'],
+ },
+ };
+
+ const dropResult = {
+ source: { id: DASHBOARD_GRID_ID, type: DASHBOARD_GRID_TYPE, index: 1 },
+ destination: {
+ id: DASHBOARD_ROOT_ID,
+ type: DASHBOARD_ROOT_TYPE,
+ index: 0,
+ },
+ dragging: { id: 'tabs', type: TABS_TYPE },
+ };
+
+ const result = layoutReducer(layout, {
+ type: DELETE_TOP_LEVEL_TABS,
+ payload: { dropResult },
+ });
+
+ expect(result).to.deep.equal({
+ [DASHBOARD_ROOT_ID]: {
+ id: DASHBOARD_ROOT_ID,
+ children: [DASHBOARD_GRID_ID],
+ },
+ [DASHBOARD_GRID_ID]: {
+ id: DASHBOARD_GRID_ID,
+ children: ['child', 'child2'],
+ },
+ child: {
+ id: 'child',
+ children: [],
+ },
+ child2: {
+ id: 'child2',
+ children: [],
+ },
+ });
+ });
+
+ it('should create a component', () => {
+ const layout = {
+ [DASHBOARD_ROOT_ID]: {
+ id: DASHBOARD_ROOT_ID,
+ children: [DASHBOARD_GRID_ID],
+ },
+ [DASHBOARD_GRID_ID]: {
+ id: DASHBOARD_GRID_ID,
+ children: ['child'],
+ },
+ child: { id: 'child' },
+ };
+
+ const dropResult = {
+ source: { id: NEW_COMPONENTS_SOURCE_ID, type: '' },
+ destination: {
+ id: DASHBOARD_GRID_ID,
+ type: DASHBOARD_GRID_TYPE,
+ index: 1,
+ },
+ dragging: { id: NEW_ROW_ID, type: ROW_TYPE },
+ };
+
+ const result = layoutReducer(layout, {
+ type: CREATE_COMPONENT,
+ payload: { dropResult },
+ });
+
+ const newId = result[DASHBOARD_GRID_ID].children[1];
+ expect(result[DASHBOARD_GRID_ID].children).to.have.length(2);
+ expect(result[newId].type).to.equal(ROW_TYPE);
+ });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/reducers/dashboardState_spec.js b/superset/assets/spec/javascripts/dashboard/reducers/dashboardState_spec.js
new file mode 100644
index 0000000000..7772f71015
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/reducers/dashboardState_spec.js
@@ -0,0 +1,240 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import {
+ ADD_SLICE,
+ CHANGE_FILTER,
+ ON_CHANGE,
+ ON_SAVE,
+ REMOVE_SLICE,
+ SET_EDIT_MODE,
+ SET_MAX_UNDO_HISTORY_EXCEEDED,
+ SET_UNSAVED_CHANGES,
+ TOGGLE_BUILDER_PANE,
+ TOGGLE_EXPAND_SLICE,
+ TOGGLE_FAVE_STAR,
+} from '../../../../src/dashboard/actions/dashboardState';
+
+import dashboardStateReducer from '../../../../src/dashboard/reducers/dashboardState';
+
+describe('dashboardState reducer', () => {
+ it('should return initial state', () => {
+ expect(dashboardStateReducer(undefined, {})).to.deep.equal({});
+ });
+
+ it('should add a slice', () => {
+ expect(
+ dashboardStateReducer(
+ { sliceIds: [1] },
+ { type: ADD_SLICE, slice: { slice_id: 2 } },
+ ),
+ ).to.deep.equal({ sliceIds: [1, 2] });
+ });
+
+ it('should remove a slice', () => {
+ expect(
+ dashboardStateReducer(
+ { sliceIds: [1, 2], filters: {} },
+ { type: REMOVE_SLICE, sliceId: 2 },
+ ),
+ ).to.deep.equal({ sliceIds: [1], refresh: false, filters: {} });
+ });
+
+ it('should reset filters if a removed slice is a filter', () => {
+ expect(
+ dashboardStateReducer(
+ { sliceIds: [1, 2], filters: { 2: {}, 1: {} } },
+ { type: REMOVE_SLICE, sliceId: 2 },
+ ),
+ ).to.deep.equal({ sliceIds: [1], filters: { 1: {} }, refresh: true });
+ });
+
+ it('should toggle fav star', () => {
+ expect(
+ dashboardStateReducer(
+ { isStarred: false },
+ { type: TOGGLE_FAVE_STAR, isStarred: true },
+ ),
+ ).to.deep.equal({ isStarred: true });
+ });
+
+ it('should toggle edit mode', () => {
+ expect(
+ dashboardStateReducer(
+ { editMode: false },
+ { type: SET_EDIT_MODE, editMode: true },
+ ),
+ ).to.deep.equal({ editMode: true, showBuilderPane: true });
+ });
+
+ it('should toggle builder pane', () => {
+ expect(
+ dashboardStateReducer(
+ { showBuilderPane: false },
+ { type: TOGGLE_BUILDER_PANE },
+ ),
+ ).to.deep.equal({ showBuilderPane: true });
+
+ expect(
+ dashboardStateReducer(
+ { showBuilderPane: true },
+ { type: TOGGLE_BUILDER_PANE },
+ ),
+ ).to.deep.equal({ showBuilderPane: false });
+ });
+
+ it('should toggle expanded slices', () => {
+ expect(
+ dashboardStateReducer(
+ { expandedSlices: { 1: true, 2: false } },
+ { type: TOGGLE_EXPAND_SLICE, sliceId: 1 },
+ ),
+ ).to.deep.equal({ expandedSlices: { 2: false } });
+
+ expect(
+ dashboardStateReducer(
+ { expandedSlices: { 1: true, 2: false } },
+ { type: TOGGLE_EXPAND_SLICE, sliceId: 2 },
+ ),
+ ).to.deep.equal({ expandedSlices: { 1: true, 2: true } });
+ });
+
+ it('should set hasUnsavedChanges', () => {
+ expect(dashboardStateReducer({}, { type: ON_CHANGE })).to.deep.equal({
+ hasUnsavedChanges: true,
+ });
+
+ expect(
+ dashboardStateReducer(
+ {},
+ { type: SET_UNSAVED_CHANGES, payload: { hasUnsavedChanges: false } },
+ ),
+ ).to.deep.equal({
+ hasUnsavedChanges: false,
+ });
+ });
+
+ it('should set maxUndoHistoryExceeded', () => {
+ expect(
+ dashboardStateReducer(
+ {},
+ {
+ type: SET_MAX_UNDO_HISTORY_EXCEEDED,
+ payload: { maxUndoHistoryExceeded: true },
+ },
+ ),
+ ).to.deep.equal({
+ maxUndoHistoryExceeded: true,
+ });
+ });
+
+ it('should set unsaved changes, max undo history, and editMode to false on save', () => {
+ expect(
+ dashboardStateReducer({ hasUnsavedChanges: true }, { type: ON_SAVE }),
+ ).to.deep.equal({
+ hasUnsavedChanges: false,
+ maxUndoHistoryExceeded: false,
+ editMode: false,
+ });
+ });
+
+ describe('change filter', () => {
+ it('should add a new filter if it does not exist', () => {
+ expect(
+ dashboardStateReducer(
+ {
+ filters: {},
+ sliceIds: [1],
+ },
+ {
+ type: CHANGE_FILTER,
+ chart: { id: 1, formData: { groupby: 'column' } },
+ col: 'column',
+ vals: ['b', 'a'],
+ refresh: true,
+ merge: true,
+ },
+ ),
+ ).to.deep.equal({
+ filters: { 1: { column: ['b', 'a'] } },
+ refresh: true,
+ sliceIds: [1],
+ });
+ });
+
+ it('should overwrite a filter if merge is false', () => {
+ expect(
+ dashboardStateReducer(
+ {
+ filters: {
+ 1: { column: ['z'] },
+ },
+ sliceIds: [1],
+ },
+ {
+ type: CHANGE_FILTER,
+ chart: { id: 1, formData: { groupby: 'column' } },
+ col: 'column',
+ vals: ['b', 'a'],
+ refresh: true,
+ merge: false,
+ },
+ ),
+ ).to.deep.equal({
+ filters: { 1: { column: ['b', 'a'] } },
+ refresh: true,
+ sliceIds: [1],
+ });
+ });
+
+ it('should merge a filter if merge is true', () => {
+ expect(
+ dashboardStateReducer(
+ {
+ filters: {
+ 1: { column: ['z'] },
+ },
+ sliceIds: [1],
+ },
+ {
+ type: CHANGE_FILTER,
+ chart: { id: 1, formData: { groupby: 'column' } },
+ col: 'column',
+ vals: ['b', 'a'],
+ refresh: true,
+ merge: true,
+ },
+ ),
+ ).to.deep.equal({
+ filters: { 1: { column: ['z', 'b', 'a'] } },
+ refresh: true,
+ sliceIds: [1],
+ });
+ });
+
+ it('should remove the filter if values are empty', () => {
+ expect(
+ dashboardStateReducer(
+ {
+ filters: {
+ 1: { column: ['z'] },
+ },
+ sliceIds: [1],
+ },
+ {
+ type: CHANGE_FILTER,
+ chart: { id: 1, formData: { groupby: 'column' } },
+ col: 'column',
+ vals: [],
+ refresh: true,
+ merge: false,
+ },
+ ),
+ ).to.deep.equal({
+ filters: {},
+ refresh: true,
+ sliceIds: [1],
+ });
+ });
+ });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/reducers/sliceEntities_spec.js b/superset/assets/spec/javascripts/dashboard/reducers/sliceEntities_spec.js
new file mode 100644
index 0000000000..7e3bb76bc1
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/reducers/sliceEntities_spec.js
@@ -0,0 +1,51 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import {
+ FETCH_ALL_SLICES_FAILED,
+ FETCH_ALL_SLICES_STARTED,
+ SET_ALL_SLICES,
+} from '../../../../src/dashboard/actions/sliceEntities';
+
+import sliceEntitiesReducer from '../../../../src/dashboard/reducers/sliceEntities';
+
+describe('sliceEntities reducer', () => {
+ it('should return initial state', () => {
+ expect(sliceEntitiesReducer({}, {})).to.deep.equal({});
+ });
+
+ it('should set loading when fetching slices', () => {
+ expect(
+ sliceEntitiesReducer(
+ { isLoading: false },
+ { type: FETCH_ALL_SLICES_STARTED },
+ ).isLoading,
+ ).to.equal(true);
+ });
+
+ it('should set slices', () => {
+ const result = sliceEntitiesReducer(
+ { slices: { a: {} } },
+ { type: SET_ALL_SLICES, slices: { 1: {}, 2: {} } },
+ );
+
+ expect(result.slices).to.deep.equal({
+ 1: {},
+ 2: {},
+ a: {},
+ });
+ expect(result.isLoading).to.equal(false);
+ });
+
+ it('should set an error on error', () => {
+ const result = sliceEntitiesReducer(
+ {},
+ {
+ type: FETCH_ALL_SLICES_FAILED,
+ error: { responseJSON: { message: 'errorrr' } },
+ },
+ );
+ expect(result.isLoading).to.equal(false);
+ expect(result.errorMessage.indexOf('errorrr')).to.be.above(-1);
+ });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/reducers_spec.js b/superset/assets/spec/javascripts/dashboard/reducers_spec.js
deleted file mode 100644
index 6421fec83c..0000000000
--- a/superset/assets/spec/javascripts/dashboard/reducers_spec.js
+++ /dev/null
@@ -1,35 +0,0 @@
-import { describe, it } from 'mocha';
-import { expect } from 'chai';
-
-import { dashboard as reducers } from '../../../src/dashboard/reducers';
-import * as actions from '../../../src/dashboard/actions';
-import { defaultFilters, dashboard as initState } from './fixtures';
-
-describe('Dashboard reducers', () => {
- it('should remove slice', () => {
- const action = {
- type: actions.REMOVE_SLICE,
- slice: initState.dashboard.slices[1],
- };
- expect(initState.dashboard.slices).to.have.length(3);
-
- const { dashboard, filters, refresh } = reducers(initState, action);
- expect(dashboard.slices).to.have.length(2);
- expect(filters).to.deep.equal(defaultFilters);
- expect(refresh).to.equal(false);
- });
-
- it('should remove filter slice', () => {
- const action = {
- type: actions.REMOVE_SLICE,
- slice: initState.dashboard.slices[0],
- };
- const initFilters = Object.keys(initState.filters);
- expect(initFilters).to.have.length(2);
-
- const { dashboard, filters, refresh } = reducers(initState, action);
- expect(dashboard.slices).to.have.length(2);
- expect(Object.keys(filters)).to.have.length(1);
- expect(refresh).to.equal(true);
- });
-});
diff --git a/superset/assets/spec/javascripts/dashboard/util/componentIsResizable_spec.js b/superset/assets/spec/javascripts/dashboard/util/componentIsResizable_spec.js
new file mode 100644
index 0000000000..b49a91f7bb
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/util/componentIsResizable_spec.js
@@ -0,0 +1,42 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import componentIsResizable from '../../../../src/dashboard/util/componentIsResizable';
+import {
+ CHART_TYPE,
+ COLUMN_TYPE,
+ DASHBOARD_GRID_TYPE,
+ DASHBOARD_ROOT_TYPE,
+ DIVIDER_TYPE,
+ HEADER_TYPE,
+ MARKDOWN_TYPE,
+ ROW_TYPE,
+ TABS_TYPE,
+ TAB_TYPE,
+} from '../../../../src/dashboard/util/componentTypes';
+
+const notResizable = [
+ DASHBOARD_GRID_TYPE,
+ DASHBOARD_ROOT_TYPE,
+ DIVIDER_TYPE,
+ HEADER_TYPE,
+ ROW_TYPE,
+ TABS_TYPE,
+ TAB_TYPE,
+];
+
+const resizable = [COLUMN_TYPE, CHART_TYPE, MARKDOWN_TYPE];
+
+describe('componentIsResizable', () => {
+ resizable.forEach(type => {
+ it(`should return true for ${type}`, () => {
+ expect(componentIsResizable({ type })).to.equal(true);
+ });
+ });
+
+ notResizable.forEach(type => {
+ it(`should return false for ${type}`, () => {
+ expect(componentIsResizable({ type })).to.equal(false);
+ });
+ });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/util/dnd-reorder_spec.js b/superset/assets/spec/javascripts/dashboard/util/dnd-reorder_spec.js
new file mode 100644
index 0000000000..4ff6a52bcc
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/util/dnd-reorder_spec.js
@@ -0,0 +1,62 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import reorderItem from '../../../../src/dashboard/util/dnd-reorder';
+
+describe('dnd-reorderItem', () => {
+ it('should remove the item from its source entity and add it to its destination entity', () => {
+ const result = reorderItem({
+ entitiesMap: {
+ a: {
+ id: 'a',
+ children: ['x', 'y', 'z'],
+ },
+ b: {
+ id: 'b',
+ children: ['banana'],
+ },
+ },
+ source: { id: 'a', index: 2 },
+ destination: { id: 'b', index: 1 },
+ });
+
+ expect(result.a.children).to.deep.equal(['x', 'y']);
+ expect(result.b.children).to.deep.equal(['banana', 'z']);
+ });
+
+ it('should correctly move elements within the same list', () => {
+ const result = reorderItem({
+ entitiesMap: {
+ a: {
+ id: 'a',
+ children: ['x', 'y', 'z'],
+ },
+ },
+ source: { id: 'a', index: 2 },
+ destination: { id: 'a', index: 0 },
+ });
+
+ expect(result.a.children).to.deep.equal(['z', 'x', 'y']);
+ });
+
+ it('should copy items that do not move into the result', () => {
+ const extraEntity = {};
+ const result = reorderItem({
+ entitiesMap: {
+ a: {
+ id: 'a',
+ children: ['x', 'y', 'z'],
+ },
+ b: {
+ id: 'b',
+ children: ['banana'],
+ },
+ iAmExtra: extraEntity,
+ },
+ source: { id: 'a', index: 2 },
+ destination: { id: 'b', index: 1 },
+ });
+
+ expect(result.iAmExtra === extraEntity).to.equal(true);
+ });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/util/dropOverflowsParent_spec.js b/superset/assets/spec/javascripts/dashboard/util/dropOverflowsParent_spec.js
new file mode 100644
index 0000000000..8e6f88964c
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/util/dropOverflowsParent_spec.js
@@ -0,0 +1,227 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import dropOverflowsParent from '../../../../src/dashboard/util/dropOverflowsParent';
+import { NEW_COMPONENTS_SOURCE_ID } from '../../../../src/dashboard/util/constants';
+import {
+ CHART_TYPE,
+ COLUMN_TYPE,
+ ROW_TYPE,
+ HEADER_TYPE,
+ TAB_TYPE,
+} from '../../../../src/dashboard/util/componentTypes';
+
+describe('dropOverflowsParent', () => {
+ it('returns true if a parent does NOT have adequate width for child', () => {
+ const dropResult = {
+ source: { id: '_' },
+ destination: { id: 'a' },
+ dragging: { id: 'z' },
+ };
+
+ const layout = {
+ a: {
+ id: 'a',
+ type: ROW_TYPE,
+ children: ['b', 'b', 'b', 'b'], // width = 4x bs = 12
+ },
+ b: {
+ id: 'b',
+ type: CHART_TYPE,
+ meta: {
+ width: 3,
+ },
+ },
+ z: {
+ id: 'z',
+ type: CHART_TYPE,
+ meta: {
+ width: 2,
+ },
+ },
+ };
+
+ expect(dropOverflowsParent(dropResult, layout)).to.equal(true);
+ });
+
+ it('returns false if a parent DOES have adequate width for child', () => {
+ const dropResult = {
+ source: { id: '_' },
+ destination: { id: 'a' },
+ dragging: { id: 'z' },
+ };
+
+ const layout = {
+ a: {
+ id: 'a',
+ type: ROW_TYPE,
+ children: ['b', 'b'],
+ },
+ b: {
+ id: 'b',
+ type: CHART_TYPE,
+ meta: {
+ width: 3,
+ },
+ },
+ z: {
+ id: 'z',
+ type: CHART_TYPE,
+ meta: {
+ width: 2,
+ },
+ },
+ };
+
+ expect(dropOverflowsParent(dropResult, layout)).to.equal(false);
+ });
+
+ it('returns false if a child CAN shrink to available parent space', () => {
+ const dropResult = {
+ source: { id: '_' },
+ destination: { id: 'a' },
+ dragging: { id: 'z' },
+ };
+
+ const layout = {
+ a: {
+ id: 'a',
+ type: ROW_TYPE,
+ children: ['b', 'b'], // 2x b = 10
+ },
+ b: {
+ id: 'b',
+ type: CHART_TYPE,
+ meta: {
+ width: 5,
+ },
+ },
+ z: {
+ id: 'z',
+ type: CHART_TYPE,
+ meta: {
+ width: 10,
+ },
+ },
+ };
+
+ expect(dropOverflowsParent(dropResult, layout)).to.equal(false);
+ });
+
+ it('returns true if a child CANNOT shrink to available parent space', () => {
+ const dropResult = {
+ source: { id: '_' },
+ destination: { id: 'a' },
+ dragging: { id: 'b' },
+ };
+
+ const layout = {
+ a: {
+ id: 'a',
+ type: COLUMN_TYPE,
+ meta: {
+ width: 6,
+ },
+ },
+ // rows with children cannot shrink
+ b: {
+ id: 'b',
+ type: ROW_TYPE,
+ children: ['bChild', 'bChild', 'bChild'],
+ },
+ bChild: {
+ id: 'bChild',
+ type: CHART_TYPE,
+ meta: {
+ width: 3,
+ },
+ },
+ };
+
+ expect(dropOverflowsParent(dropResult, layout)).to.equal(true);
+ });
+
+ it('returns true if a column has children that CANNOT shrink to available parent space', () => {
+ const dropResult = {
+ source: { id: '_' },
+ destination: { id: 'destination' },
+ dragging: { id: 'dragging' },
+ };
+
+ const layout = {
+ destination: {
+ id: 'destination',
+ type: ROW_TYPE,
+ children: ['b', 'b'], // 2x b = 10, 2 available
+ },
+ b: {
+ id: 'b',
+ type: CHART_TYPE,
+ meta: {
+ width: 5,
+ },
+ },
+ dragging: {
+ id: 'dragging',
+ type: COLUMN_TYPE,
+ meta: {
+ width: 10,
+ },
+ children: ['rowWithChildren'], // 2x b = width 10
+ },
+ rowWithChildren: {
+ id: 'rowWithChildren',
+ type: ROW_TYPE,
+ children: ['b', 'b'],
+ },
+ };
+
+ expect(dropOverflowsParent(dropResult, layout)).to.equal(true);
+ // remove children
+ expect(
+ dropOverflowsParent(dropResult, {
+ ...layout,
+ dragging: { ...layout.dragging, children: [] },
+ }),
+ ).to.equal(false);
+ });
+
+ it('should work with new components that are not in the layout', () => {
+ const dropResult = {
+ source: { id: NEW_COMPONENTS_SOURCE_ID },
+ destination: { id: 'a' },
+ dragging: { type: CHART_TYPE },
+ };
+
+ const layout = {
+ a: {
+ id: 'a',
+ type: ROW_TYPE,
+ children: [],
+ },
+ };
+
+ expect(dropOverflowsParent(dropResult, layout)).to.equal(false);
+ });
+
+ it('source/destination without widths should not overflow parent', () => {
+ const dropResult = {
+ source: { id: '_' },
+ destination: { id: 'tab' },
+ dragging: { id: 'header' },
+ };
+
+ const layout = {
+ tab: {
+ id: 'tab',
+ type: TAB_TYPE,
+ },
+ header: {
+ id: 'header',
+ type: HEADER_TYPE,
+ },
+ };
+
+ expect(dropOverflowsParent(dropResult, layout)).to.equal(false);
+ });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/util/findFirstParentContainer_spec.js b/superset/assets/spec/javascripts/dashboard/util/findFirstParentContainer_spec.js
new file mode 100644
index 0000000000..ecaca67664
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/util/findFirstParentContainer_spec.js
@@ -0,0 +1,113 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import findFirstParentContainerId from '../../../../src/dashboard/util/findFirstParentContainer';
+import {
+ DASHBOARD_GRID_ID,
+ DASHBOARD_ROOT_ID,
+} from '../../../../src/dashboard/util/constants';
+
+describe('findFirstParentContainer', () => {
+ const mockGridLayout = {
+ DASHBOARD_VERSION_KEY: 'v2',
+ ROOT_ID: {
+ type: 'ROOT',
+ id: 'ROOT_ID',
+ children: ['GRID_ID'],
+ },
+ GRID_ID: {
+ type: 'GRID',
+ id: 'GRID_ID',
+ children: ['ROW-Bk45URrlQ'],
+ },
+ 'ROW-Bk45URrlQ': {
+ type: 'ROW',
+ id: 'ROW-Bk45URrlQ',
+ children: ['CHART-ryxVc8RHlX'],
+ },
+ 'CHART-ryxVc8RHlX': {
+ type: 'CHART',
+ id: 'CHART-ryxVc8RHlX',
+ children: [],
+ },
+ HEADER_ID: {
+ id: 'HEADER_ID',
+ type: 'HEADER',
+ },
+ };
+ const mockTabsLayout = {
+ 'CHART-S1gilYABe7': {
+ children: [],
+ id: 'CHART-S1gilYABe7',
+ type: 'CHART',
+ },
+ 'CHART-SJli5K0HlQ': {
+ children: [],
+ id: 'CHART-SJli5K0HlQ',
+ type: 'CHART',
+ },
+ GRID_ID: {
+ children: [],
+ id: 'GRID_ID',
+ type: 'GRID',
+ },
+ HEADER_ID: {
+ id: 'HEADER_ID',
+ type: 'HEADER',
+ },
+ ROOT_ID: {
+ children: ['TABS-SkgJ5t0Bem'],
+ id: 'ROOT_ID',
+ type: 'ROOT',
+ },
+ 'ROW-S1B8-JLgX': {
+ children: ['CHART-SJli5K0HlQ'],
+ id: 'ROW-S1B8-JLgX',
+ type: 'ROW',
+ },
+ 'ROW-S1bUb1Ilm': {
+ children: ['CHART-S1gilYABe7'],
+ id: 'ROW-S1bUb1Ilm',
+ type: 'ROW',
+ },
+ 'TABS-ByeLSWyLe7': {
+ children: ['TAB-BJbLSZ1UeQ'],
+ id: 'TABS-ByeLSWyLe7',
+ type: 'TABS',
+ },
+ 'TABS-SkgJ5t0Bem': {
+ children: ['TAB-HkWJcFCHxQ', 'TAB-ByDBbkLlQ'],
+ id: 'TABS-SkgJ5t0Bem',
+ meta: {},
+ type: 'TABS',
+ },
+ 'TAB-BJbLSZ1UeQ': {
+ children: ['ROW-S1bUb1Ilm'],
+ id: 'TAB-BJbLSZ1UeQ',
+ type: 'TAB',
+ },
+ 'TAB-ByDBbkLlQ': {
+ children: ['ROW-S1B8-JLgX'],
+ id: 'TAB-ByDBbkLlQ',
+ type: 'TAB',
+ },
+ 'TAB-HkWJcFCHxQ': {
+ children: ['TABS-ByeLSWyLe7'],
+ id: 'TAB-HkWJcFCHxQ',
+ type: 'TAB',
+ },
+ DASHBOARD_VERSION_KEY: 'v2',
+ };
+
+ it('should return grid root', () => {
+ expect(findFirstParentContainerId(mockGridLayout)).to.equal(
+ DASHBOARD_GRID_ID,
+ );
+ });
+
+ it('should return first tab', () => {
+ const tabsId = mockTabsLayout[DASHBOARD_ROOT_ID].children[0];
+ const firstTabId = mockTabsLayout[tabsId].children[0];
+ expect(findFirstParentContainerId(mockTabsLayout)).to.equal(firstTabId);
+ });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/util/findParentId_spec.js b/superset/assets/spec/javascripts/dashboard/util/findParentId_spec.js
new file mode 100644
index 0000000000..71c8aece17
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/util/findParentId_spec.js
@@ -0,0 +1,29 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import findParentId from '../../../../src/dashboard/util/findParentId';
+
+describe('findParentId', () => {
+ const layout = {
+ a: {
+ id: 'a',
+ children: ['b', 'r', 'k'],
+ },
+ b: {
+ id: 'b',
+ children: ['x', 'y', 'z'],
+ },
+ z: {
+ id: 'z',
+ children: [],
+ },
+ };
+ it('should return the correct parentId', () => {
+ expect(findParentId({ childId: 'b', layout })).to.equal('a');
+ expect(findParentId({ childId: 'z', layout })).to.equal('b');
+ });
+
+ it('should return null if the parent cannot be found', () => {
+ expect(findParentId({ childId: 'a', layout })).to.equal(null);
+ });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/util/getChartIdsFromLayout_spec.js b/superset/assets/spec/javascripts/dashboard/util/getChartIdsFromLayout_spec.js
new file mode 100644
index 0000000000..71bbccd59a
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/util/getChartIdsFromLayout_spec.js
@@ -0,0 +1,41 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import getChartIdsFromLayout from '../../../../src/dashboard/util/getChartIdsFromLayout';
+import {
+ ROW_TYPE,
+ CHART_TYPE,
+} from '../../../../src/dashboard/util/componentTypes';
+
+describe('getChartIdsFromLayout', () => {
+ const mockLayout = {
+ a: {
+ id: 'a',
+ type: CHART_TYPE,
+ meta: { chartId: 'A' },
+ },
+ b: {
+ id: 'b',
+ type: CHART_TYPE,
+ meta: { chartId: 'B' },
+ },
+ c: {
+ id: 'c',
+ type: ROW_TYPE,
+ meta: { chartId: 'C' },
+ },
+ };
+
+ it('should return an array of chartIds', () => {
+ const result = getChartIdsFromLayout(mockLayout);
+ expect(Array.isArray(result)).to.equal(true);
+ expect(result.includes('A')).to.equal(true);
+ expect(result.includes('B')).to.equal(true);
+ });
+
+ it('should return ids only from CHART_TYPE components', () => {
+ const result = getChartIdsFromLayout(mockLayout);
+ expect(result.length).to.equal(2);
+ expect(result.includes('C')).to.equal(false);
+ });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/util/getDetailedComponentWidth_spec.js b/superset/assets/spec/javascripts/dashboard/util/getDetailedComponentWidth_spec.js
new file mode 100644
index 0000000000..99e2282f7a
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/util/getDetailedComponentWidth_spec.js
@@ -0,0 +1,223 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import getDetailedComponentWidth from '../../../../src/dashboard/util/getDetailedComponentWidth';
+import * as types from '../../../../src/dashboard/util/componentTypes';
+import {
+ GRID_COLUMN_COUNT,
+ GRID_MIN_COLUMN_COUNT,
+} from '../../../../src/dashboard/util/constants';
+
+describe('getDetailedComponentWidth', () => {
+ it('should return an object with width, minimumWidth, and occupiedWidth', () => {
+ expect(
+ getDetailedComponentWidth({ id: '_', components: {} }),
+ ).to.have.all.keys(['minimumWidth', 'occupiedWidth', 'width']);
+ });
+
+ describe('width', () => {
+ it('should be undefined if the component is not resizable and has no defined width', () => {
+ const empty = {
+ width: undefined,
+ occupiedWidth: undefined,
+ minimumWidth: undefined,
+ };
+
+ expect(
+ getDetailedComponentWidth({
+ component: { id: '', type: types.HEADER_TYPE },
+ }),
+ ).to.deep.equal(empty);
+
+ expect(
+ getDetailedComponentWidth({
+ component: { id: '', type: types.DIVIDER_TYPE },
+ }),
+ ).to.deep.equal(empty);
+
+ expect(
+ getDetailedComponentWidth({
+ component: { id: '', type: types.TAB_TYPE },
+ }),
+ ).to.deep.equal(empty);
+ });
+
+ it('should match component meta width for resizeable components', () => {
+ expect(
+ getDetailedComponentWidth({
+ component: { id: '', type: types.CHART_TYPE, meta: { width: 1 } },
+ }),
+ ).to.deep.equal({ width: 1, occupiedWidth: 1, minimumWidth: 1 });
+
+ expect(
+ getDetailedComponentWidth({
+ component: { id: '', type: types.MARKDOWN_TYPE, meta: { width: 2 } },
+ }),
+ ).to.deep.equal({ width: 2, occupiedWidth: 2, minimumWidth: 1 });
+
+ expect(
+ getDetailedComponentWidth({
+ component: { id: '', type: types.COLUMN_TYPE, meta: { width: 3 } },
+ }),
+ // note: occupiedWidth is zero for colunns/see test below
+ ).to.deep.equal({ width: 3, occupiedWidth: 0, minimumWidth: 1 });
+ });
+
+ it('should be GRID_COLUMN_COUNT for row components WITHOUT parents', () => {
+ expect(
+ getDetailedComponentWidth({
+ id: 'row',
+ components: { row: { id: 'row', type: types.ROW_TYPE } },
+ }),
+ ).to.deep.equal({
+ width: GRID_COLUMN_COUNT,
+ occupiedWidth: 0,
+ minimumWidth: GRID_MIN_COLUMN_COUNT,
+ });
+ });
+
+ it('should match parent width for row components WITH parents', () => {
+ expect(
+ getDetailedComponentWidth({
+ id: 'row',
+ components: {
+ row: { id: 'row', type: types.ROW_TYPE },
+ parent: {
+ id: 'parent',
+ type: types.COLUMN_TYPE,
+ children: ['row'],
+ meta: { width: 7 },
+ },
+ },
+ }),
+ ).to.deep.equal({
+ width: 7,
+ occupiedWidth: 0,
+ minimumWidth: GRID_MIN_COLUMN_COUNT,
+ });
+ });
+
+ it('should use either id or component (to support new components)', () => {
+ expect(
+ getDetailedComponentWidth({
+ id: 'id',
+ components: {
+ id: { id: 'id', type: types.CHART_TYPE, meta: { width: 6 } },
+ },
+ }).width,
+ ).to.equal(6);
+
+ expect(
+ getDetailedComponentWidth({
+ component: { id: 'id', type: types.CHART_TYPE, meta: { width: 6 } },
+ }).width,
+ ).to.equal(6);
+ });
+ });
+
+ describe('occupiedWidth', () => {
+ it('should reflect the sum of child widths for row components', () => {
+ expect(
+ getDetailedComponentWidth({
+ id: 'row',
+ components: {
+ row: {
+ id: 'row',
+ type: types.ROW_TYPE,
+ children: ['child', 'child'],
+ },
+ child: { id: 'child', meta: { width: 3.5 } },
+ },
+ }),
+ ).to.deep.equal({ width: 12, occupiedWidth: 7, minimumWidth: 7 });
+ });
+
+ it('should always be zero for column components', () => {
+ expect(
+ getDetailedComponentWidth({
+ component: { id: '', type: types.COLUMN_TYPE, meta: { width: 2 } },
+ }),
+ ).to.deep.equal({ width: 2, occupiedWidth: 0, minimumWidth: 1 });
+ });
+ });
+
+ describe('minimumWidth', () => {
+ it('should equal GRID_MIN_COLUMN_COUNT for resizable components', () => {
+ expect(
+ getDetailedComponentWidth({
+ component: { id: '', type: types.CHART_TYPE, meta: { width: 1 } },
+ }),
+ ).to.deep.equal({
+ width: 1,
+ minimumWidth: GRID_MIN_COLUMN_COUNT,
+ occupiedWidth: 1,
+ });
+
+ expect(
+ getDetailedComponentWidth({
+ component: { id: '', type: types.MARKDOWN_TYPE, meta: { width: 2 } },
+ }),
+ ).to.deep.equal({
+ width: 2,
+ minimumWidth: GRID_MIN_COLUMN_COUNT,
+ occupiedWidth: 2,
+ });
+
+ expect(
+ getDetailedComponentWidth({
+ component: { id: '', type: types.COLUMN_TYPE, meta: { width: 3 } },
+ }),
+ ).to.deep.equal({
+ width: 3,
+ minimumWidth: GRID_MIN_COLUMN_COUNT,
+ occupiedWidth: 0,
+ });
+ });
+
+ it('should equal the width of row children for column components with row children', () => {
+ expect(
+ getDetailedComponentWidth({
+ id: 'column',
+ components: {
+ column: {
+ id: 'column',
+ type: types.COLUMN_TYPE,
+ children: ['rowChild', 'ignoredChartChild'],
+ meta: { width: 12 },
+ },
+ rowChild: {
+ id: 'rowChild',
+ type: types.ROW_TYPE,
+ children: ['rowChildChild', 'rowChildChild'],
+ },
+ rowChildChild: {
+ id: 'rowChildChild',
+ meta: { width: 3.5 },
+ },
+ ignoredChartChild: {
+ id: 'ignoredChartChild',
+ meta: { width: 100 },
+ },
+ },
+ }),
+ // occupiedWidth is zero for colunns/see test below
+ ).to.deep.equal({ width: 12, occupiedWidth: 0, minimumWidth: 7 });
+ });
+
+ it('should equal occupiedWidth for row components', () => {
+ expect(
+ getDetailedComponentWidth({
+ id: 'row',
+ components: {
+ row: {
+ id: 'row',
+ type: types.ROW_TYPE,
+ children: ['child', 'child'],
+ },
+ child: { id: 'child', meta: { width: 3.5 } },
+ },
+ }),
+ ).to.deep.equal({ width: 12, occupiedWidth: 7, minimumWidth: 7 });
+ });
+ });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/util/getDropPosition_spec.js b/superset/assets/spec/javascripts/dashboard/util/getDropPosition_spec.js
new file mode 100644
index 0000000000..287b7a6f33
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/util/getDropPosition_spec.js
@@ -0,0 +1,422 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import getDropPosition, {
+ DROP_TOP,
+ DROP_RIGHT,
+ DROP_BOTTOM,
+ DROP_LEFT,
+} from '../../../../src/dashboard/util/getDropPosition';
+
+import {
+ CHART_TYPE,
+ DASHBOARD_GRID_TYPE,
+ DASHBOARD_ROOT_TYPE,
+ HEADER_TYPE,
+ ROW_TYPE,
+ TAB_TYPE,
+} from '../../../../src/dashboard/util/componentTypes';
+
+describe('getDropPosition', () => {
+ // helper to easily configure test
+ function getMocks({
+ parentType,
+ componentType,
+ draggingType,
+ depth = 1,
+ hasChildren = false,
+ orientation = 'row',
+ clientOffset = { x: 0, y: 0 },
+ boundingClientRect = {
+ top: 0,
+ right: 0,
+ bottom: 0,
+ left: 0,
+ },
+ isDraggingOverShallow = true,
+ }) {
+ const monitorMock = {
+ getItem: () => ({
+ id: 'id',
+ type: draggingType,
+ }),
+ getClientOffset: () => clientOffset,
+ };
+
+ const ComponentMock = {
+ props: {
+ depth,
+ parentComponent: {
+ type: parentType,
+ },
+ component: {
+ type: componentType,
+ children: hasChildren ? [''] : [],
+ },
+ orientation,
+ isDraggingOverShallow,
+ },
+ ref: {
+ getBoundingClientRect: () => boundingClientRect,
+ },
+ };
+
+ return [monitorMock, ComponentMock];
+ }
+
+ describe('invalid child + invalid sibling', () => {
+ it('should return null', () => {
+ const result = getDropPosition(
+ // TAB is an invalid child + sibling of GRID > ROW
+ ...getMocks({
+ parentType: DASHBOARD_GRID_TYPE,
+ componentType: ROW_TYPE,
+ draggingType: TAB_TYPE,
+ }),
+ );
+ expect(result).to.equal(null);
+ });
+ });
+
+ describe('valid child + invalid sibling', () => {
+ it('should return DROP_LEFT if component has NO children, and orientation is "row"', () => {
+ // HEADER is a valid child + invalid sibling of ROOT > GRID
+ const result = getDropPosition(
+ ...getMocks({
+ parentType: DASHBOARD_ROOT_TYPE,
+ componentType: DASHBOARD_GRID_TYPE,
+ draggingType: HEADER_TYPE,
+ }),
+ );
+ expect(result).to.equal(DROP_LEFT);
+ });
+
+ it('should return DROP_RIGHT if component HAS children, and orientation is "row"', () => {
+ const result = getDropPosition(
+ ...getMocks({
+ parentType: DASHBOARD_ROOT_TYPE,
+ componentType: DASHBOARD_GRID_TYPE,
+ draggingType: HEADER_TYPE,
+ hasChildren: true,
+ }),
+ );
+ expect(result).to.equal(DROP_RIGHT);
+ });
+
+ it('should return DROP_TOP if component has NO children, and orientation is "column"', () => {
+ const result = getDropPosition(
+ ...getMocks({
+ parentType: DASHBOARD_ROOT_TYPE,
+ componentType: DASHBOARD_GRID_TYPE,
+ draggingType: HEADER_TYPE,
+ orientation: 'column',
+ }),
+ );
+ expect(result).to.equal(DROP_TOP);
+ });
+
+ it('should return DROP_BOTTOM if component HAS children, and orientation is "column"', () => {
+ const result = getDropPosition(
+ ...getMocks({
+ parentType: DASHBOARD_ROOT_TYPE,
+ componentType: DASHBOARD_GRID_TYPE,
+ draggingType: HEADER_TYPE,
+ orientation: 'column',
+ hasChildren: true,
+ }),
+ );
+ expect(result).to.equal(DROP_BOTTOM);
+ });
+ });
+
+ describe('invalid child + valid sibling', () => {
+ it('should return DROP_TOP if orientation="row" and clientOffset is closer to component top than bottom', () => {
+ const result = getDropPosition(
+ // HEADER is an invalid child but valid sibling of GRID > ROW
+ ...getMocks({
+ parentType: DASHBOARD_GRID_TYPE,
+ componentType: ROW_TYPE,
+ draggingType: HEADER_TYPE,
+ clientOffset: { y: 10 },
+ boundingClientRect: {
+ top: 0,
+ bottom: 100,
+ },
+ }),
+ );
+ expect(result).to.equal(DROP_TOP);
+ });
+
+ it('should return DROP_BOTTOM if orientation="row" and clientOffset is closer to component bottom than top', () => {
+ const result = getDropPosition(
+ ...getMocks({
+ parentType: DASHBOARD_GRID_TYPE,
+ componentType: ROW_TYPE,
+ draggingType: HEADER_TYPE,
+ clientOffset: { y: 55 },
+ boundingClientRect: {
+ top: 0,
+ bottom: 100,
+ },
+ }),
+ );
+ expect(result).to.equal(DROP_BOTTOM);
+ });
+
+ it('should return DROP_LEFT if orientation="column" and clientOffset is closer to component left than right', () => {
+ const result = getDropPosition(
+ ...getMocks({
+ parentType: DASHBOARD_GRID_TYPE,
+ componentType: ROW_TYPE,
+ draggingType: HEADER_TYPE,
+ orientation: 'column',
+ clientOffset: { x: 45 },
+ boundingClientRect: {
+ left: 0,
+ right: 100,
+ },
+ }),
+ );
+ expect(result).to.equal(DROP_LEFT);
+ });
+
+ it('should return DROP_RIGHT if orientation="column" and clientOffset is closer to component right than left', () => {
+ const result = getDropPosition(
+ ...getMocks({
+ parentType: DASHBOARD_GRID_TYPE,
+ componentType: ROW_TYPE,
+ draggingType: HEADER_TYPE,
+ orientation: 'column',
+ clientOffset: { x: 55 },
+ boundingClientRect: {
+ left: 0,
+ right: 100,
+ },
+ }),
+ );
+ expect(result).to.equal(DROP_RIGHT);
+ });
+ });
+
+ describe('child + valid sibling (row orientation)', () => {
+ it('should return DROP_LEFT if component has NO children, and clientOffset is NOT near top/bottom sibling boundary', () => {
+ const result = getDropPosition(
+ // CHART is a valid child + sibling of GRID > ROW
+ ...getMocks({
+ parentType: DASHBOARD_GRID_TYPE,
+ componentType: ROW_TYPE,
+ draggingType: CHART_TYPE,
+ clientOffset: { x: 10, y: 50 },
+ boundingClientRect: {
+ left: 0,
+ right: 100,
+ top: 0,
+ bottom: 100,
+ },
+ }),
+ );
+ expect(result).to.equal(DROP_LEFT);
+ });
+
+ it('should return DROP_RIGHT if component HAS children, and clientOffset is NOT near top/bottom sibling boundary', () => {
+ const result = getDropPosition(
+ ...getMocks({
+ parentType: DASHBOARD_GRID_TYPE,
+ componentType: ROW_TYPE,
+ draggingType: CHART_TYPE,
+ hasChildren: true,
+ clientOffset: { x: 10, y: 50 },
+ boundingClientRect: {
+ left: 0,
+ right: 100,
+ top: 0,
+ bottom: 100,
+ },
+ }),
+ );
+ expect(result).to.equal(DROP_RIGHT);
+ });
+
+ it('should return DROP_TOP regardless of component children if clientOffset IS near top sibling boundary', () => {
+ const noChildren = getDropPosition(
+ ...getMocks({
+ parentType: DASHBOARD_GRID_TYPE,
+ componentType: ROW_TYPE,
+ draggingType: CHART_TYPE,
+ clientOffset: { x: 10, y: 2 },
+ boundingClientRect: {
+ left: 0,
+ right: 100,
+ top: 0,
+ bottom: 100,
+ },
+ }),
+ );
+ const withChildren = getDropPosition(
+ ...getMocks({
+ parentType: DASHBOARD_GRID_TYPE,
+ componentType: ROW_TYPE,
+ draggingType: CHART_TYPE,
+ hasChildren: true,
+ clientOffset: { x: 10, y: 2 },
+ boundingClientRect: {
+ left: 0,
+ right: 100,
+ top: 0,
+ bottom: 100,
+ },
+ }),
+ );
+ expect(noChildren).to.equal(DROP_TOP);
+ expect(withChildren).to.equal(DROP_TOP);
+ });
+
+ it('should return DROP_BOTTOM regardless of component children if clientOffset IS near bottom sibling boundary', () => {
+ const noChildren = getDropPosition(
+ ...getMocks({
+ parentType: DASHBOARD_GRID_TYPE,
+ componentType: ROW_TYPE,
+ draggingType: CHART_TYPE,
+ clientOffset: { x: 10, y: 95 },
+ boundingClientRect: {
+ left: 0,
+ right: 100,
+ top: 0,
+ bottom: 100,
+ },
+ }),
+ );
+ const withChildren = getDropPosition(
+ ...getMocks({
+ parentType: DASHBOARD_GRID_TYPE,
+ componentType: ROW_TYPE,
+ draggingType: CHART_TYPE,
+ hasChildren: true,
+ clientOffset: { x: 10, y: 95 },
+ boundingClientRect: {
+ left: 0,
+ right: 100,
+ top: 0,
+ bottom: 100,
+ },
+ }),
+ );
+ expect(noChildren).to.equal(DROP_BOTTOM);
+ expect(withChildren).to.equal(DROP_BOTTOM);
+ });
+ });
+
+ describe('child + valid sibling (column orientation)', () => {
+ it('should return DROP_TOP if component has NO children, and clientOffset is NOT near left/right sibling boundary', () => {
+ const result = getDropPosition(
+ // CHART is a valid child + sibling of GRID > ROW
+ ...getMocks({
+ parentType: DASHBOARD_GRID_TYPE,
+ componentType: ROW_TYPE,
+ draggingType: CHART_TYPE,
+ orientation: 'column',
+ clientOffset: { x: 50, y: 0 },
+ boundingClientRect: {
+ left: 0,
+ right: 100,
+ top: 0,
+ bottom: 100,
+ },
+ }),
+ );
+ expect(result).to.equal(DROP_TOP);
+ });
+
+ it('should return DROP_BOTTOM if component HAS children, and clientOffset is NOT near left/right sibling boundary', () => {
+ const result = getDropPosition(
+ ...getMocks({
+ parentType: DASHBOARD_GRID_TYPE,
+ componentType: ROW_TYPE,
+ draggingType: CHART_TYPE,
+ orientation: 'column',
+ hasChildren: true,
+ clientOffset: { x: 50, y: 0 },
+ boundingClientRect: {
+ left: 0,
+ right: 100,
+ top: 0,
+ bottom: 100,
+ },
+ }),
+ );
+ expect(result).to.equal(DROP_BOTTOM);
+ });
+
+ it('should return DROP_LEFT regardless of component children if clientOffset IS near left sibling boundary', () => {
+ const noChildren = getDropPosition(
+ ...getMocks({
+ parentType: DASHBOARD_GRID_TYPE,
+ componentType: ROW_TYPE,
+ draggingType: CHART_TYPE,
+ orientation: 'column',
+ clientOffset: { x: 10, y: 2 },
+ boundingClientRect: {
+ left: 0,
+ right: 100,
+ top: 0,
+ bottom: 100,
+ },
+ }),
+ );
+ const withChildren = getDropPosition(
+ ...getMocks({
+ parentType: DASHBOARD_GRID_TYPE,
+ componentType: ROW_TYPE,
+ draggingType: CHART_TYPE,
+ orientation: 'column',
+ hasChildren: true,
+ clientOffset: { x: 10, y: 2 },
+ boundingClientRect: {
+ left: 0,
+ right: 100,
+ top: 0,
+ bottom: 100,
+ },
+ }),
+ );
+ expect(noChildren).to.equal(DROP_LEFT);
+ expect(withChildren).to.equal(DROP_LEFT);
+ });
+
+ it('should return DROP_RIGHT regardless of component children if clientOffset IS near right sibling boundary', () => {
+ const noChildren = getDropPosition(
+ ...getMocks({
+ parentType: DASHBOARD_GRID_TYPE,
+ componentType: ROW_TYPE,
+ draggingType: CHART_TYPE,
+ orientation: 'column',
+ clientOffset: { x: 90, y: 95 },
+ boundingClientRect: {
+ left: 0,
+ right: 100,
+ top: 0,
+ bottom: 100,
+ },
+ }),
+ );
+ const withChildren = getDropPosition(
+ ...getMocks({
+ parentType: DASHBOARD_GRID_TYPE,
+ componentType: ROW_TYPE,
+ draggingType: CHART_TYPE,
+ orientation: 'column',
+ hasChildren: true,
+ clientOffset: { x: 90, y: 95 },
+ boundingClientRect: {
+ left: 0,
+ right: 100,
+ top: 0,
+ bottom: 100,
+ },
+ }),
+ );
+ expect(noChildren).to.equal(DROP_RIGHT);
+ expect(withChildren).to.equal(DROP_RIGHT);
+ });
+ });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/util/getFormDataWithExtraFilters_spec.js b/superset/assets/spec/javascripts/dashboard/util/getFormDataWithExtraFilters_spec.js
new file mode 100644
index 0000000000..388630be52
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/util/getFormDataWithExtraFilters_spec.js
@@ -0,0 +1,70 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import getFormDataWithExtraFilters from '../../../../src/dashboard/util/charts/getFormDataWithExtraFilters';
+
+describe('getFormDataWithExtraFilters', () => {
+ const chartId = 'chartId';
+ const mockArgs = {
+ chart: {
+ id: chartId,
+ formData: {
+ filters: [
+ {
+ col: 'country_name',
+ op: 'in',
+ val: ['United States'],
+ },
+ ],
+ },
+ },
+ dashboardMetadata: {
+ filter_immune_slices: [],
+ filter_immune_slice_fields: {},
+ },
+ filters: {
+ filterId: {
+ region: ['Spain'],
+ color: ['pink', 'purple'],
+ },
+ },
+ sliceId: chartId,
+ };
+
+ it('should include filters from the passed filters', () => {
+ const result = getFormDataWithExtraFilters(mockArgs);
+ expect(result.extra_filters).to.have.length(2);
+ expect(result.extra_filters[0]).to.deep.equal({
+ col: 'region',
+ op: 'in',
+ val: ['Spain'],
+ });
+ expect(result.extra_filters[1]).to.deep.equal({
+ col: 'color',
+ op: 'in',
+ val: ['pink', 'purple'],
+ });
+ });
+
+ it('should not add additional filters if the slice is immune to them', () => {
+ const result = getFormDataWithExtraFilters({
+ ...mockArgs,
+ dashboardMetadata: {
+ filter_immune_slices: [chartId],
+ },
+ });
+ expect(result.extra_filters).to.have.length(0);
+ });
+
+ it('should not add additional filters for fields to which the slice is immune', () => {
+ const result = getFormDataWithExtraFilters({
+ ...mockArgs,
+ dashboardMetadata: {
+ filter_immune_slice_fields: {
+ [chartId]: ['region'],
+ },
+ },
+ });
+ expect(result.extra_filters).to.have.length(1);
+ });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/util/isValidChild_spec.js b/superset/assets/spec/javascripts/dashboard/util/isValidChild_spec.js
new file mode 100644
index 0000000000..3563059d7b
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/util/isValidChild_spec.js
@@ -0,0 +1,147 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import isValidChild from '../../../../src/dashboard/util/isValidChild';
+
+import {
+ CHART_TYPE as CHART,
+ COLUMN_TYPE as COLUMN,
+ DASHBOARD_GRID_TYPE as GRID,
+ DASHBOARD_ROOT_TYPE as ROOT,
+ DIVIDER_TYPE as DIVIDER,
+ HEADER_TYPE as HEADER,
+ MARKDOWN_TYPE as MARKDOWN,
+ ROW_TYPE as ROW,
+ TABS_TYPE as TABS,
+ TAB_TYPE as TAB,
+} from '../../../../src/dashboard/util/componentTypes';
+
+const getIndentation = depth =>
+ Array(depth * 3)
+ .fill('')
+ .join('-');
+
+describe('isValidChild', () => {
+ describe('valid calls', () => {
+ // these are representations of nested structures for easy testing
+ // [ROOT (depth 0) > GRID (depth 1) > HEADER (depth 2)]
+ // every unique parent > child relationship is tested, but because this
+ // test representation WILL result in duplicates, we hash each test
+ // to keep track of which we've run
+ const didTest = {};
+ const validExamples = [
+ [ROOT, GRID, CHART], // chart is valid because it is wrapped in a row
+ [ROOT, GRID, MARKDOWN], // markdown is valid because it is wrapped in a row
+ [ROOT, GRID, COLUMN], // column is valid because it is wrapped in a row
+ [ROOT, GRID, HEADER],
+ [ROOT, GRID, ROW, MARKDOWN],
+ [ROOT, GRID, ROW, CHART],
+
+ [ROOT, GRID, ROW, COLUMN, HEADER],
+ [ROOT, GRID, ROW, COLUMN, DIVIDER],
+ [ROOT, GRID, ROW, COLUMN, CHART],
+ [ROOT, GRID, ROW, COLUMN, MARKDOWN],
+
+ [ROOT, GRID, ROW, COLUMN, ROW, CHART],
+ [ROOT, GRID, ROW, COLUMN, ROW, MARKDOWN],
+
+ [ROOT, GRID, ROW, COLUMN, ROW, COLUMN, CHART],
+ [ROOT, GRID, ROW, COLUMN, ROW, COLUMN, MARKDOWN],
+ [ROOT, GRID, TABS, TAB, ROW, COLUMN, ROW, COLUMN, MARKDOWN],
+
+ // tab equivalents
+ [ROOT, TABS, TAB, CHART],
+ [ROOT, TABS, TAB, MARKDOWN],
+ [ROOT, TABS, TAB, COLUMN],
+ [ROOT, TABS, TAB, HEADER],
+ [ROOT, TABS, TAB, ROW, MARKDOWN],
+ [ROOT, TABS, TAB, ROW, CHART],
+
+ [ROOT, TABS, TAB, ROW, COLUMN, HEADER],
+ [ROOT, TABS, TAB, ROW, COLUMN, DIVIDER],
+ [ROOT, TABS, TAB, ROW, COLUMN, CHART],
+ [ROOT, TABS, TAB, ROW, COLUMN, MARKDOWN],
+
+ [ROOT, TABS, TAB, ROW, COLUMN, ROW, CHART],
+ [ROOT, TABS, TAB, ROW, COLUMN, ROW, MARKDOWN],
+
+ [ROOT, TABS, TAB, ROW, COLUMN, ROW, COLUMN, CHART],
+ [ROOT, TABS, TAB, ROW, COLUMN, ROW, COLUMN, MARKDOWN],
+ [ROOT, TABS, TAB, TABS, TAB, ROW, COLUMN, ROW, COLUMN, MARKDOWN],
+ ];
+
+ validExamples.forEach((example, exampleIdx) => {
+ let childDepth = 0;
+ example.forEach((childType, i) => {
+ const parentDepth = childDepth - 1;
+ const parentType = example[i - 1];
+ const testKey = `${parentType}-${childType}-${parentDepth}`;
+
+ if (i > 0 && !didTest[testKey]) {
+ didTest[testKey] = true;
+
+ it(`(${exampleIdx})${getIndentation(
+ childDepth,
+ )}${parentType} (depth ${parentDepth}) > ${childType} ✅`, () => {
+ expect(
+ isValidChild({
+ parentDepth,
+ parentType,
+ childType,
+ }),
+ ).to.equal(true);
+ });
+ }
+ // see isValidChild.js for why tabs do not increment the depth of their children
+ childDepth += childType !== TABS && childType !== TAB ? 1 : 0;
+ });
+ });
+ });
+
+ describe('invalid calls', () => {
+ // In order to assert that a parent > child hierarchy at a given depth is invalid
+ // we also define some valid hierarchies in doing so. we indicate which
+ // parent > [child] relationships should be asserted as invalid using a nested array
+ const invalidExamples = [
+ [ROOT, [DIVIDER]],
+ [ROOT, [CHART]],
+ [ROOT, [MARKDOWN]],
+ [ROOT, GRID, [TAB]],
+ [ROOT, GRID, TABS, [ROW]],
+ // [ROOT, GRID, TABS, TAB, [TABS]], // @TODO this needs to be fixed
+ [ROOT, GRID, ROW, [TABS]],
+ [ROOT, GRID, ROW, [TAB]],
+ [ROOT, GRID, ROW, [DIVIDER]],
+ [ROOT, GRID, ROW, COLUMN, [TABS]],
+ [ROOT, GRID, ROW, COLUMN, [TAB]],
+ [ROOT, GRID, ROW, COLUMN, ROW, [DIVIDER]],
+ [ROOT, GRID, ROW, COLUMN, ROW, COLUMN, [ROW]], // too nested
+ ];
+
+ invalidExamples.forEach((example, exampleIdx) => {
+ let childDepth = 0;
+ example.forEach((childType, i) => {
+ const shouldTestChild = Array.isArray(childType);
+
+ if (i > 0 && shouldTestChild) {
+ const parentDepth = childDepth - 1;
+ const parentType = example[i - 1];
+
+ it(`(${exampleIdx})${getIndentation(
+ childDepth,
+ )}${parentType} (depth ${parentDepth}) > ${childType} ❌`, () => {
+ expect(
+ isValidChild({
+ parentDepth,
+ parentType,
+ childType,
+ }),
+ ).to.equal(false);
+ });
+ }
+ // see isValidChild.js for why tabs do not increment the depth of their children
+ childDepth += childType !== TABS && childType !== TAB ? 1 : 0;
+ });
+ });
+ });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/util/newComponentFactory_spec.js b/superset/assets/spec/javascripts/dashboard/util/newComponentFactory_spec.js
new file mode 100644
index 0000000000..f52eba978f
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/util/newComponentFactory_spec.js
@@ -0,0 +1,51 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import newComponentFactory from '../../../../src/dashboard/util/newComponentFactory';
+
+import {
+ CHART_TYPE,
+ COLUMN_TYPE,
+ DASHBOARD_GRID_TYPE,
+ DASHBOARD_ROOT_TYPE,
+ DIVIDER_TYPE,
+ HEADER_TYPE,
+ MARKDOWN_TYPE,
+ NEW_COMPONENT_SOURCE_TYPE,
+ ROW_TYPE,
+ TABS_TYPE,
+ TAB_TYPE,
+} from '../../../../src/dashboard/util/componentTypes';
+
+const types = [
+ CHART_TYPE,
+ COLUMN_TYPE,
+ DASHBOARD_GRID_TYPE,
+ DASHBOARD_ROOT_TYPE,
+ DIVIDER_TYPE,
+ HEADER_TYPE,
+ MARKDOWN_TYPE,
+ NEW_COMPONENT_SOURCE_TYPE,
+ ROW_TYPE,
+ TABS_TYPE,
+ TAB_TYPE,
+];
+
+describe('newEntityFactory', () => {
+ types.forEach(type => {
+ it(`returns a new ${type}`, () => {
+ const result = newComponentFactory(type);
+
+ expect(result.type).to.equal(type);
+ expect(typeof result.id).to.equal('string');
+ expect(typeof result.meta).to.equal('object');
+ expect(Array.isArray(result.children)).to.equal(true);
+ });
+ });
+
+ it('adds passed meta data to the entity', () => {
+ const banana = 'banana';
+ const result = newComponentFactory(CHART_TYPE, { banana });
+ expect(result.meta.banana).to.equal(banana);
+ });
+});
diff --git a/superset/assets/spec/javascripts/dashboard/util/newEntitiesFromDrop_spec.js b/superset/assets/spec/javascripts/dashboard/util/newEntitiesFromDrop_spec.js
new file mode 100644
index 0000000000..8d00c186e2
--- /dev/null
+++ b/superset/assets/spec/javascripts/dashboard/util/newEntitiesFromDrop_spec.js
@@ -0,0 +1,85 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import newEntitiesFromDrop from '../../../../src/dashboard/util/newEntitiesFromDrop';
+import {
+ CHART_TYPE,
+ DASHBOARD_GRID_TYPE,
+ ROW_TYPE,
+ TABS_TYPE,
+ TAB_TYPE,
+} from '../../../../src/dashboard/util/componentTypes';
+
+describe('newEntitiesFromDrop', () => {
+ it('should return a new Entity of appropriate type, and add it to the drop target children', () => {
+ const result = newEntitiesFromDrop({
+ dropResult: {
+ destination: { id: 'a', index: 0 },
+ dragging: { type: CHART_TYPE },
+ source: { id: 'b', index: 0 },
+ },
+ layout: {
+ a: {
+ id: 'a',
+ type: ROW_TYPE,
+ children: [],
+ },
+ },
+ });
+
+ const newId = result.a.children[0];
+ expect(result.a.children.length).to.equal(1);
+ expect(Object.keys(result).length).to.equal(2);
+ expect(result[newId].type).to.equal(CHART_TYPE);
+ });
+
+ it('should create Tab AND Tabs components if the drag entity is Tabs', () => {
+ const result = newEntitiesFromDrop({
+ dropResult: {
+ destination: { id: 'a', index: 0 },
+ dragging: { type: TABS_TYPE },
+ source: { id: 'b', index: 0 },
+ },
+ layout: {
+ a: {
+ id: 'a',
+ type: DASHBOARD_GRID_TYPE,
+ children: [],
+ },
+ },
+ });
+
+ const newTabsId = result.a.children[0];
+ const newTabId = result[newTabsId].children[0];
+
+ expect(result.a.children.length).to.equal(1);
+ expect(Object.keys(result).length).to.equal(3);
+ expect(result[newTabsId].type).to.equal(TABS_TYPE);
+ expect(result[newTabId].type).to.equal(TAB_TYPE);
+ });
+
+ it('should create a Row if the drag entity should be wrapped in a row', () => {
+ const result = newEntitiesFromDrop({
+ dropResult: {
+ destination: { id: 'a', index: 0 },
+ dragging: { type: CHART_TYPE },
+ source: { id: 'b', index: 0 },
+ },
+ layout: {
+ a: {
+ id: 'a',
+ type: DASHBOARD_GRID_TYPE,
+ children: [],
+ },
+ },
+ });
+
+ const newRowId = result.a.children[0];
+ const newChartId = result[newRowId].children[0];
+
+ expect(result.a.children.length).to.equal(1);
+ expect(Object.keys(result).length).to.equal(3);
+ expect(result[newRowId].type).to.equal(ROW_TYPE);
+ expect(result[newChartId].type).to.equal(CHART_TYPE);
+ });
+});
diff --git a/superset/assets/spec/javascripts/explore/AdhocFilter_spec.js b/superset/assets/spec/javascripts/explore/AdhocFilter_spec.js
index 0cf9e58e1f..36d3bdd095 100644
--- a/superset/assets/spec/javascripts/explore/AdhocFilter_spec.js
+++ b/superset/assets/spec/javascripts/explore/AdhocFilter_spec.js
@@ -112,6 +112,26 @@ describe('AdhocFilter', () => {
});
// eslint-disable-next-line no-unused-expressions
expect(adhocFilter3.isValid()).to.be.false;
+
+ const adhocFilter4 = new AdhocFilter({
+ expressionType: EXPRESSION_TYPES.SIMPLE,
+ subject: 'value',
+ operator: 'in',
+ comparator: [],
+ clause: CLAUSES.WHERE,
+ });
+ // eslint-disable-next-line no-unused-expressions
+ expect(adhocFilter4.isValid()).to.be.false;
+
+ const adhocFilter5 = new AdhocFilter({
+ expressionType: EXPRESSION_TYPES.SIMPLE,
+ subject: 'value',
+ operator: 'in',
+ comparator: ['val1'],
+ clause: CLAUSES.WHERE,
+ });
+ // eslint-disable-next-line no-unused-expressions
+ expect(adhocFilter5.isValid()).to.be.true;
});
it('can translate from simple expressions to sql expressions', () => {
diff --git a/superset/assets/spec/javascripts/explore/components/AdhocFilterControl_spec.jsx b/superset/assets/spec/javascripts/explore/components/AdhocFilterControl_spec.jsx
index 4be8a2eba3..2123ed7c2e 100644
--- a/superset/assets/spec/javascripts/explore/components/AdhocFilterControl_spec.jsx
+++ b/superset/assets/spec/javascripts/explore/components/AdhocFilterControl_spec.jsx
@@ -33,18 +33,9 @@ const columns = [
{ type: 'DOUBLE', column_name: 'value' },
];
-const legacyFilter = { col: 'value', op: '>', val: '5' };
-const legacyHavingFilter = { col: 'SUM(value)', op: '>', val: '10' };
-const whereFilterText = 'target in (\'alpha\')';
-const havingFilterText = 'SUM(value) < 20';
-
const formData = {
- filters: [legacyFilter],
- having: havingFilterText,
- having_filters: [legacyHavingFilter],
metric: undefined,
metrics: [sumValueAdhocMetric, savedMetric.saved_metric_name],
- where: whereFilterText,
};
function setup(overrides) {
@@ -68,49 +59,6 @@ describe('AdhocFilterControl', () => {
expect(wrapper.find(OnPasteSelect)).to.have.lengthOf(1);
});
- it('will translate legacy filters into adhoc filters if no adhoc filters are present', () => {
- const { wrapper } = setup({ value: undefined });
- expect(wrapper.state('values')).to.have.lengthOf(4);
- expect(wrapper.state('values')[0].equals((
- new AdhocFilter({
- expressionType: EXPRESSION_TYPES.SIMPLE,
- subject: 'value',
- operator: '>',
- comparator: '5',
- clause: CLAUSES.WHERE,
- })
- ))).to.be.true;
- expect(wrapper.state('values')[1].equals((
- new AdhocFilter({
- expressionType: EXPRESSION_TYPES.SIMPLE,
- subject: 'SUM(value)',
- operator: '>',
- comparator: '10',
- clause: CLAUSES.HAVING,
- })
- ))).to.be.true;
- expect(wrapper.state('values')[2].equals((
- new AdhocFilter({
- expressionType: EXPRESSION_TYPES.SQL,
- sqlExpression: 'target in (\'alpha\')',
- clause: CLAUSES.WHERE,
- })
- ))).to.be.true;
- expect(wrapper.state('values')[3].equals((
- new AdhocFilter({
- expressionType: EXPRESSION_TYPES.SQL,
- sqlExpression: 'SUM(value) < 20',
- clause: CLAUSES.HAVING,
- })
- ))).to.be.true;
- });
-
- it('will ignore legacy filters if adhoc filters are present', () => {
- const { wrapper } = setup();
- expect(wrapper.state('values')).to.have.lengthOf(1);
- expect(wrapper.state('values')[0]).to.equal(simpleAdhocFilter);
- });
-
it('handles saved metrics being selected to filter on', () => {
const { wrapper, onChange } = setup({ value: [] });
const select = wrapper.find(OnPasteSelect);
diff --git a/superset/assets/spec/javascripts/explore/components/AdhocFilterEditPopoverSimpleTabContent_spec.jsx b/superset/assets/spec/javascripts/explore/components/AdhocFilterEditPopoverSimpleTabContent_spec.jsx
index 005b287626..2014bbc7c9 100644
--- a/superset/assets/spec/javascripts/explore/components/AdhocFilterEditPopoverSimpleTabContent_spec.jsx
+++ b/superset/assets/spec/javascripts/explore/components/AdhocFilterEditPopoverSimpleTabContent_spec.jsx
@@ -43,15 +43,17 @@ const options = [
function setup(overrides) {
const onChange = sinon.spy();
+ const onHeightChange = sinon.spy();
const props = {
adhocFilter: simpleAdhocFilter,
onChange,
+ onHeightChange,
options,
datasource: {},
...overrides,
};
const wrapper = shallow(<AdhocFilterEditPopoverSimpleTabContent {...props} />);
- return { wrapper, onChange };
+ return { wrapper, onChange, onHeightChange };
}
describe('AdhocFilterEditPopoverSimpleTabContent', () => {
@@ -111,12 +113,23 @@ describe('AdhocFilterEditPopoverSimpleTabContent', () => {
it('will filter operators for table datasources', () => {
const { wrapper } = setup({ datasource: { type: 'table' } });
expect(wrapper.instance().isOperatorRelevant('regex')).to.be.false;
- expect(wrapper.instance().isOperatorRelevant('like')).to.be.true;
+ expect(wrapper.instance().isOperatorRelevant('LIKE')).to.be.true;
});
it('will filter operators for druid datasources', () => {
const { wrapper } = setup({ datasource: { type: 'druid' } });
expect(wrapper.instance().isOperatorRelevant('regex')).to.be.true;
- expect(wrapper.instance().isOperatorRelevant('like')).to.be.false;
+ expect(wrapper.instance().isOperatorRelevant('LIKE')).to.be.false;
+ });
+
+ it('expands when its multi comparator input field expands', () => {
+ const { wrapper, onHeightChange } = setup();
+
+ wrapper.instance().multiComparatorComponent =
+ { _selectRef: { select: { control: { clientHeight: 57 } } } };
+ wrapper.instance().handleMultiComparatorInputHeightChange();
+
+ expect(onHeightChange.calledOnce).to.be.true;
+ expect(onHeightChange.lastCall.args[0]).to.equal(27);
});
});
diff --git a/superset/assets/spec/javascripts/explore/components/ControlPanelsContainer_spec.jsx b/superset/assets/spec/javascripts/explore/components/ControlPanelsContainer_spec.jsx
index fb1ea610fc..64a657e1f7 100644
--- a/superset/assets/spec/javascripts/explore/components/ControlPanelsContainer_spec.jsx
+++ b/superset/assets/spec/javascripts/explore/components/ControlPanelsContainer_spec.jsx
@@ -26,6 +26,6 @@ describe('ControlPanelsContainer', () => {
});
it('renders ControlPanelSections', () => {
- expect(wrapper.find(ControlPanelSection)).to.have.lengthOf(7);
+ expect(wrapper.find(ControlPanelSection)).to.have.lengthOf(6);
});
});
diff --git a/superset/assets/spec/javascripts/explore/components/DatasourceControl_spec.jsx b/superset/assets/spec/javascripts/explore/components/DatasourceControl_spec.jsx
index c8d1390850..68dc783c4c 100644
--- a/superset/assets/spec/javascripts/explore/components/DatasourceControl_spec.jsx
+++ b/superset/assets/spec/javascripts/explore/components/DatasourceControl_spec.jsx
@@ -1,7 +1,8 @@
import React from 'react';
import sinon from 'sinon';
+import configureStore from 'redux-mock-store';
import { expect } from 'chai';
-import { describe, it, beforeEach } from 'mocha';
+import { describe, it } from 'mocha';
import { shallow } from 'enzyme';
import { Modal } from 'react-bootstrap';
import DatasourceControl from '../../../../src/explore/components/controls/DatasourceControl';
@@ -26,13 +27,14 @@ const defaultProps = {
};
describe('DatasourceControl', () => {
- let wrapper;
-
- beforeEach(() => {
- wrapper = shallow(<DatasourceControl {...defaultProps} />);
- });
+ function setup() {
+ const mockStore = configureStore([]);
+ const store = mockStore({});
+ return shallow(<DatasourceControl {...defaultProps} />, { context: { store } }).dive();
+ }
it('renders a Modal', () => {
+ const wrapper = setup();
expect(wrapper.find(Modal)).to.have.lengthOf(1);
});
});
diff --git a/superset/assets/spec/javascripts/explore/components/DisplayQueryButton_spec.jsx b/superset/assets/spec/javascripts/explore/components/DisplayQueryButton_spec.jsx
index 196e07751c..d80737e175 100644
--- a/superset/assets/spec/javascripts/explore/components/DisplayQueryButton_spec.jsx
+++ b/superset/assets/spec/javascripts/explore/components/DisplayQueryButton_spec.jsx
@@ -16,6 +16,9 @@ describe('DisplayQueryButton', () => {
},
chartStatus: 'success',
queryEndpoint: 'localhost',
+ latestQueryFormData: {
+ datasource: '1__table',
+ },
};
it('is valid', () => {
diff --git a/superset/assets/spec/javascripts/explore/components/FilterControl_spec.jsx b/superset/assets/spec/javascripts/explore/components/FilterControl_spec.jsx
deleted file mode 100644
index 2b83fff457..0000000000
--- a/superset/assets/spec/javascripts/explore/components/FilterControl_spec.jsx
+++ /dev/null
@@ -1,248 +0,0 @@
-/* eslint-disable no-unused-expressions */
-import React from 'react';
-import { Button } from 'react-bootstrap';
-import sinon from 'sinon';
-import { expect } from 'chai';
-import { describe, it, beforeEach } from 'mocha';
-import { shallow } from 'enzyme';
-import FilterControl from '../../../../src/explore/components/controls/FilterControl';
-import Filter from '../../../../src/explore/components/controls/Filter';
-
-const $ = window.$ = require('jquery');
-
-const defaultProps = {
- name: 'not_having_filters',
- onChange: sinon.spy(),
- value: [
- {
- col: 'col1',
- op: 'in',
- val: ['a', 'b', 'd'],
- },
- {
- col: 'col2',
- op: '==',
- val: 'Z',
- },
- ],
- datasource: {
- id: 1,
- type: 'qtable',
- filter_select: true,
- filterable_cols: [['col1', 'col2']],
- metrics_combo: [
- ['m1', 'v1'],
- ['m2', 'v2'],
- ],
- },
-};
-
-describe('FilterControl', () => {
- let wrapper;
-
- beforeEach(() => {
- wrapper = shallow(<FilterControl {...defaultProps} />);
- wrapper.setState({
- filters: [
- {
- valuesLoading: false,
- valueChoices: ['a', 'b', 'c', 'd', 'e', 'f'],
- },
- {
- valuesLoading: false,
- valueChoices: ['X', 'Y', 'Z'],
- },
- // Need a duplicate since onChange calls are not changing props
- {
- valuesLoading: false,
- valueChoices: ['X', 'Y', 'Z'],
- },
- ],
- });
- });
-
- it('renders Filters', () => {
- expect(
- React.isValidElement(<FilterControl {...defaultProps} />),
- ).to.equal(true);
- });
-
- it('renders one button and two filters', () => {
- expect(wrapper.find(Filter)).to.have.lengthOf(2);
- expect(wrapper.find(Button)).to.have.lengthOf(1);
- });
-
- it('adds filter when clicking Add Filter', () => {
- const addButton = wrapper.find('#add-button');
- expect(addButton).to.have.lengthOf(1);
- addButton.simulate('click');
- expect(defaultProps.onChange).to.have.property('callCount', 1);
- expect(defaultProps.onChange.getCall(0).args[0]).to.deep.equal([
- {
- col: 'col1',
- op: 'in',
- val: ['a', 'b', 'd'],
- },
- {
- col: 'col2',
- op: '==',
- val: 'Z',
- },
- {
- col: 'col1',
- op: 'in',
- val: [],
- },
- ]);
- });
-
- it('removes a the second filter when its delete button is clicked', () => {
- expect(wrapper.find(Filter)).to.have.lengthOf(2);
- wrapper.instance().removeFilter(1);
- expect(defaultProps.onChange).to.have.property('callCount', 2);
- expect(defaultProps.onChange.getCall(1).args[0]).to.deep.equal([
- {
- col: 'col1',
- op: 'in',
- val: ['a', 'b', 'd'],
- },
- ]);
- });
-
- before(() => {
- sinon.stub($, 'ajax');
- });
-
- after(() => {
- $.ajax.restore();
- });
-
- it('makes a GET request to retrieve value choices', () => {
- wrapper.instance().fetchFilterValues(0, 'col1');
- expect($.ajax.getCall(0).args[0].type).to.deep.equal('GET');
- expect($.ajax.getCall(0).args[0].url).to.deep.equal('/superset/filter/qtable/1/col1/');
- });
-
- it('changes filter values when one is removed', () => {
- wrapper.instance().changeFilter(0, 'val', ['a', 'b']);
- expect(defaultProps.onChange).to.have.property('callCount', 3);
- expect(defaultProps.onChange.getCall(2).args[0]).to.deep.equal([
- {
- col: 'col1',
- op: 'in',
- val: ['a', 'b'],
- },
- {
- col: 'col2',
- op: '==',
- val: 'Z',
- },
- ]);
- });
-
- it('changes filter values when one is added', () => {
- wrapper.instance().changeFilter(0, 'val', ['a', 'b', 'd', 'e']);
- expect(defaultProps.onChange).to.have.property('callCount', 4);
- expect(defaultProps.onChange.getCall(3).args[0]).to.deep.equal([
- {
- col: 'col1',
- op: 'in',
- val: ['a', 'b', 'd', 'e'],
- },
- {
- col: 'col2',
- op: '==',
- val: 'Z',
- },
- ]);
- });
-
- it('changes op and transforms values', () => {
- wrapper.instance().changeFilter(0, ['val', 'op'], ['a', '==']);
- wrapper.instance().changeFilter(1, ['val', 'op'], [['Z'], 'in']);
- expect(defaultProps.onChange).to.have.property('callCount', 6);
- expect(defaultProps.onChange.getCall(4).args[0]).to.deep.equal([
- {
- col: 'col1',
- op: '==',
- val: 'a',
- },
- {
- col: 'col2',
- op: '==',
- val: 'Z',
- },
- ]);
- expect(defaultProps.onChange.getCall(5).args[0]).to.deep.equal([
- {
- col: 'col1',
- op: 'in',
- val: ['a', 'b', 'd'],
- },
- {
- col: 'col2',
- op: 'in',
- val: ['Z'],
- },
- ]);
- });
-
- it('changes column and clears invalid values', () => {
- wrapper.instance().changeFilter(0, 'col', 'col2');
- expect(defaultProps.onChange).to.have.property('callCount', 7);
- expect(defaultProps.onChange.getCall(6).args[0]).to.deep.equal([
- {
- col: 'col2',
- op: 'in',
- val: [],
- },
- {
- col: 'col2',
- op: '==',
- val: 'Z',
- },
- ]);
- wrapper.instance().changeFilter(1, 'col', 'col1');
- expect(defaultProps.onChange).to.have.property('callCount', 8);
- expect(defaultProps.onChange.getCall(7).args[0]).to.deep.equal([
- {
- col: 'col1',
- op: 'in',
- val: ['a', 'b', 'd'],
- },
- {
- col: 'col1',
- op: '==',
- val: '',
- },
- ]);
- });
-
- it('tracks an active filter select ajax request', () => {
- const spyReq = sinon.spy();
- $.ajax.reset();
- $.ajax.onFirstCall().returns(spyReq);
- wrapper.instance().fetchFilterValues(0, 'col1');
- expect(wrapper.state().activeRequest).to.equal(spyReq);
- // Sets active to null after success
- $.ajax.getCall(0).args[0].success(['opt1', 'opt2', null, '']);
- expect(wrapper.state().filters[0].valuesLoading).to.equal(false);
- expect(wrapper.state().filters[0].valueChoices).to.deep.equal(['opt1', 'opt2', null, '']);
- expect(wrapper.state().activeRequest).to.equal(null);
- });
-
-
- it('cancels active request if another is submitted', () => {
- const spyReq = sinon.spy();
- spyReq.abort = sinon.spy();
- $.ajax.reset();
- $.ajax.onFirstCall().returns(spyReq);
- wrapper.instance().fetchFilterValues(0, 'col1');
- expect(wrapper.state().activeRequest).to.equal(spyReq);
- const spyReq1 = sinon.spy();
- $.ajax.onSecondCall().returns(spyReq1);
- wrapper.instance().fetchFilterValues(1, 'col2');
- expect(spyReq.abort.called).to.equal(true);
- expect(wrapper.state().activeRequest).to.equal(spyReq1);
- });
-});
diff --git a/superset/assets/spec/javascripts/explore/components/Filter_spec.jsx b/superset/assets/spec/javascripts/explore/components/Filter_spec.jsx
deleted file mode 100644
index 27425de9a3..0000000000
--- a/superset/assets/spec/javascripts/explore/components/Filter_spec.jsx
+++ /dev/null
@@ -1,115 +0,0 @@
-/* eslint-disable no-unused-expressions */
-import React from 'react';
-import Select from 'react-select';
-import { Button } from 'react-bootstrap';
-import sinon from 'sinon';
-import { expect } from 'chai';
-import { describe, it, beforeEach } from 'mocha';
-import { shallow } from 'enzyme';
-import Filter from '../../../../src/explore/components/controls/Filter';
-import SelectControl from '../../../../src/explore/components/controls/SelectControl';
-
-const defaultProps = {
- changeFilter: sinon.spy(),
- removeFilter: () => {},
- filter: {
- col: null,
- op: 'in',
- value: ['val'],
- },
- datasource: {
- id: 1,
- type: 'qtable',
- filter_select: false,
- filterable_cols: ['col1', 'col2'],
- metrics_combo: [
- ['m1', 'v1'],
- ['m2', 'v2'],
- ],
- },
-};
-
-describe('Filter', () => {
- let wrapper;
-
- beforeEach(() => {
- wrapper = shallow(<Filter {...defaultProps} />);
- });
-
- it('renders Filters', () => {
- expect(
- React.isValidElement(<Filter {...defaultProps} />),
- ).to.equal(true);
- });
-
- it('renders two selects, one button and one input', () => {
- expect(wrapper.find(Select)).to.have.lengthOf(2);
- expect(wrapper.find(Button)).to.have.lengthOf(1);
- expect(wrapper.find(SelectControl)).to.have.lengthOf(1);
- expect(wrapper.find('#select-op').prop('options')).to.have.lengthOf(10);
- });
-
- it('renders five op choices for table datasource', () => {
- const props = Object.assign({}, defaultProps);
- props.datasource = {
- id: 1,
- type: 'druid',
- filter_select: false,
- filterable_cols: ['country_name'],
- };
- const druidWrapper = shallow(<Filter {...props} />);
- expect(druidWrapper.find('#select-op').prop('options')).to.have.lengthOf(11);
- });
-
- it('renders six op choices for having filter', () => {
- const props = Object.assign({}, defaultProps);
- props.having = true;
- const havingWrapper = shallow(<Filter {...props} />);
- expect(havingWrapper.find('#select-op').prop('options')).to.have.lengthOf(6);
- });
-
- it('calls changeFilter when select is changed', () => {
- const selectCol = wrapper.find('#select-col');
- selectCol.simulate('change', { value: 'col' });
- const selectOp = wrapper.find('#select-op');
- selectOp.simulate('change', { value: 'in' });
- const selectVal = wrapper.find(SelectControl);
- selectVal.simulate('change', { value: 'x' });
- expect(defaultProps.changeFilter).to.have.property('callCount', 3);
- });
-
- it('renders input for regex filters', () => {
- const props = Object.assign({}, defaultProps);
- props.filter = {
- col: null,
- op: 'regex',
- value: 'val',
- };
- const regexWrapper = shallow(<Filter {...props} />);
- expect(regexWrapper.find('input')).to.have.lengthOf(1);
- });
-
- it('renders `input` for text filters', () => {
- const props = Object.assign({}, defaultProps);
- ['>=', '>', '<=', '<'].forEach((op) => {
- props.filter = {
- col: 'col1',
- op,
- value: 'val',
- };
- wrapper = shallow(<Filter {...props} />);
- expect(wrapper.find('input')).to.have.lengthOf(1);
- });
- });
-
- it('replaces null filter values with empty string in `input`', () => {
- const props = Object.assign({}, defaultProps);
- props.filter = {
- col: 'col1',
- op: '>=',
- value: null,
- };
- wrapper = shallow(<Filter {...props} />);
- expect(wrapper.find('input').props().value).to.equal('');
- });
-});
diff --git a/superset/assets/spec/javascripts/explore/components/MetricDefinitionOption_spec.jsx b/superset/assets/spec/javascripts/explore/components/MetricDefinitionOption_spec.jsx
index 18129666cd..418e9c5812 100644
--- a/superset/assets/spec/javascripts/explore/components/MetricDefinitionOption_spec.jsx
+++ b/superset/assets/spec/javascripts/explore/components/MetricDefinitionOption_spec.jsx
@@ -1,5 +1,5 @@
-/* eslint-disable no-unused-expressions */
import React from 'react';
+import configureStore from 'redux-mock-store';
import { expect } from 'chai';
import { describe, it } from 'mocha';
import { shallow } from 'enzyme';
@@ -10,18 +10,25 @@ import ColumnOption from '../../../../src/components/ColumnOption';
import AggregateOption from '../../../../src/explore/components/AggregateOption';
describe('MetricDefinitionOption', () => {
+ const mockStore = configureStore([]);
+ const store = mockStore({});
+
+ function setup(props) {
+ return shallow(<MetricDefinitionOption {...props} />, { context: { store } }).dive();
+ }
+
it('renders a MetricOption given a saved metric', () => {
- const wrapper = shallow(<MetricDefinitionOption option={{ metric_name: 'a_saved_metric' }} />);
+ const wrapper = setup({ option: { metric_name: 'a_saved_metric' } });
expect(wrapper.find(MetricOption)).to.have.lengthOf(1);
});
it('renders a ColumnOption given a column', () => {
- const wrapper = shallow(<MetricDefinitionOption option={{ column_name: 'a_column' }} />);
+ const wrapper = setup({ option: { column_name: 'a_column' } });
expect(wrapper.find(ColumnOption)).to.have.lengthOf(1);
});
it('renders an AggregateOption given an aggregate metric', () => {
- const wrapper = shallow(<MetricDefinitionOption option={{ aggregate_name: 'an_aggregate' }} />);
+ const wrapper = setup({ option: { aggregate_name: 'an_aggregate' } });
expect(wrapper.find(AggregateOption)).to.have.lengthOf(1);
});
});
diff --git a/superset/assets/spec/javascripts/explore/components/MetricsControl_spec.jsx b/superset/assets/spec/javascripts/explore/components/MetricsControl_spec.jsx
index 5d2e1e3342..9aa553ca88 100644
--- a/superset/assets/spec/javascripts/explore/components/MetricsControl_spec.jsx
+++ b/superset/assets/spec/javascripts/explore/components/MetricsControl_spec.jsx
@@ -211,8 +211,8 @@ describe('MetricsControl', () => {
expect(!!wrapper.instance().selectFilterOption(
{
- metric_name: 'a_metric',
- optionName: 'a_metric',
+ metric_name: 'avg__metric',
+ optionName: 'avg__metric',
expression: 'AVG(metric)',
},
'a',
@@ -238,14 +238,27 @@ describe('MetricsControl', () => {
expect(!!wrapper.instance().selectFilterOption(
{
- metric_name: 'a_metric',
- optionName: 'a_metric',
+ metric_name: 'avg__metric',
+ optionName: 'avg__metric',
expression: 'AVG(metric)',
},
'a',
)).to.be.false;
});
+ it('includes custom made simple saved metrics', () => {
+ const { wrapper } = setup();
+
+ expect(!!wrapper.instance().selectFilterOption(
+ {
+ metric_name: 'my_fancy_sum_metric',
+ optionName: 'my_fancy_sum_metric',
+ expression: 'SUM(value)',
+ },
+ 'sum',
+ )).to.be.true;
+ });
+
it('excludes auto generated metrics', () => {
const { wrapper } = setup();
diff --git a/superset/assets/spec/javascripts/explore/components/URLShortLinkButton_spec.jsx b/superset/assets/spec/javascripts/explore/components/URLShortLinkButton_spec.jsx
index 986daaa434..0fc54c3432 100644
--- a/superset/assets/spec/javascripts/explore/components/URLShortLinkButton_spec.jsx
+++ b/superset/assets/spec/javascripts/explore/components/URLShortLinkButton_spec.jsx
@@ -1,4 +1,5 @@
import React from 'react';
+import configureStore from 'redux-mock-store';
import { expect } from 'chai';
import { describe, it } from 'mocha';
import { shallow } from 'enzyme';
@@ -13,11 +14,14 @@ describe('URLShortLinkButton', () => {
},
};
- it('renders', () => {
- expect(React.isValidElement(<URLShortLinkButton {...defaultProps} />)).to.equal(true);
- });
+ function setup() {
+ const mockStore = configureStore([]);
+ const store = mockStore({});
+ return shallow(<URLShortLinkButton {...defaultProps} />, { context: { store } }).dive();
+ }
+
it('renders OverlayTrigger', () => {
- const wrapper = shallow(<URLShortLinkButton {...defaultProps} />);
+ const wrapper = setup();
expect(wrapper.find(OverlayTrigger)).have.length(1);
});
});
diff --git a/superset/assets/spec/javascripts/logger_spec.js b/superset/assets/spec/javascripts/logger_spec.js
new file mode 100644
index 0000000000..64580b4bdb
--- /dev/null
+++ b/superset/assets/spec/javascripts/logger_spec.js
@@ -0,0 +1,143 @@
+import $ from 'jquery';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+import sinon from 'sinon';
+
+import { Logger, ActionLog } from '../../src/logger';
+
+describe('ActionLog', () => {
+ it('should be a constructor', () => {
+ const newLogger = new ActionLog({});
+ expect(newLogger instanceof ActionLog).to.equal(true);
+ });
+
+ it('should set the eventNames, impressionId, source, sourceId, and sendNow init parameters', () => {
+ const eventNames = [];
+ const impressionId = 'impressionId';
+ const source = 'source';
+ const sourceId = 'sourceId';
+ const sendNow = true;
+
+ const log = new ActionLog({ eventNames, impressionId, source, sourceId, sendNow });
+ expect(log.eventNames).to.equal(eventNames);
+ expect(log.impressionId).to.equal(impressionId);
+ expect(log.source).to.equal(source);
+ expect(log.sourceId).to.equal(sourceId);
+ expect(log.sendNow).to.equal(sendNow);
+ });
+
+ it('should set attributes with the setAttribute method', () => {
+ const log = new ActionLog({});
+ expect(log.test).to.equal(undefined);
+ log.setAttribute('test', 'testValue');
+ expect(log.test).to.equal('testValue');
+ });
+
+ it('should track added events', () => {
+ const log = new ActionLog({});
+ const eventName = 'myEventName';
+ const eventBody = { test: 'event' };
+ expect(log.events[eventName]).to.equal(undefined);
+
+ log.addEvent(eventName, eventBody);
+ expect(log.events[eventName]).to.have.length(1);
+ expect(log.events[eventName][0]).to.deep.include(eventBody);
+ });
+});
+
+describe('Logger', () => {
+ it('should add events when .append(eventName, eventBody) is called', () => {
+ const eventName = 'testEvent';
+ const eventBody = { test: 'event' };
+ const log = new ActionLog({ eventNames: [eventName] });
+ Logger.start(log);
+ Logger.append(eventName, eventBody);
+ expect(log.events[eventName]).to.have.length(1);
+ expect(log.events[eventName][0]).to.deep.include(eventBody);
+ Logger.end(log);
+ });
+
+ describe('.send()', () => {
+ beforeEach(() => {
+ sinon.spy($, 'ajax');
+ });
+ afterEach(() => {
+ $.ajax.restore();
+ });
+
+ const eventNames = ['test'];
+
+ function setup(overrides = {}) {
+ const log = new ActionLog({ eventNames, ...overrides });
+ return log;
+ }
+
+ it('should POST an event to /superset/log/ when called', () => {
+ const log = setup();
+ Logger.start(log);
+ Logger.append(eventNames[0], { test: 'event' });
+ expect(log.events[eventNames[0]]).to.have.length(1);
+ Logger.end(log);
+ expect($.ajax.calledOnce).to.equal(true);
+ const args = $.ajax.getCall(0).args[0];
+ expect(args.url).to.equal('/superset/log/');
+ expect(args.method).to.equal('POST');
+ });
+
+ it("should flush the log's events", () => {
+ const log = setup();
+ Logger.start(log);
+ Logger.append(eventNames[0], { test: 'event' });
+ const event = log.events[eventNames[0]][0];
+ expect(event).to.deep.include({ test: 'event' });
+ Logger.end(log);
+ expect(log.events).to.deep.equal({});
+ });
+
+ it('should include ts, start_offset, event_name, impression_id, source, and source_id in every event', () => {
+ const config = {
+ eventNames: ['event1', 'event2'],
+ impressionId: 'impress_me',
+ source: 'superset',
+ sourceId: 'lolz',
+ };
+ const log = setup(config);
+
+ Logger.start(log);
+ Logger.append('event1', { key: 'value' });
+ Logger.append('event2', { foo: 'bar' });
+ Logger.end(log);
+
+ const args = $.ajax.getCall(0).args[0];
+ const events = JSON.parse(args.data.events);
+
+ expect(events).to.have.length(2);
+ expect(events[0]).to.deep.include({
+ key: 'value',
+ event_name: 'event1',
+ impression_id: config.impressionId,
+ source: config.source,
+ source_id: config.sourceId,
+ });
+ expect(events[1]).to.deep.include({
+ foo: 'bar',
+ event_name: 'event2',
+ impression_id: config.impressionId,
+ source: config.source,
+ source_id: config.sourceId,
+ });
+ expect(typeof events[0].ts).to.equal('number');
+ expect(typeof events[1].ts).to.equal('number');
+ expect(typeof events[0].start_offset).to.equal('number');
+ expect(typeof events[1].start_offset).to.equal('number');
+ });
+
+ it('should send() a log immediately if .append() is called with sendNow=true', () => {
+ const log = setup();
+ Logger.start(log);
+ Logger.append(eventNames[0], { test: 'event' }, true);
+ expect($.ajax.calledOnce).to.equal(true);
+ Logger.end(log);
+ });
+ });
+});
diff --git a/superset/assets/spec/javascripts/messageToasts/.eslintrc b/superset/assets/spec/javascripts/messageToasts/.eslintrc
new file mode 100644
index 0000000000..a3f86e3a17
--- /dev/null
+++ b/superset/assets/spec/javascripts/messageToasts/.eslintrc
@@ -0,0 +1,33 @@
+{
+ "extends": "prettier",
+ "plugins": ["prettier"],
+ "rules": {
+ "prefer-template": 2,
+ "new-cap": 2,
+ "no-restricted-syntax": 2,
+ "guard-for-in": 2,
+ "prefer-arrow-callback": 2,
+ "func-names": 2,
+ "react/jsx-no-bind": 2,
+ "no-confusing-arrow": 2,
+ "jsx-a11y/no-static-element-interactions": 2,
+ "jsx-a11y/anchor-has-content": 2,
+ "react/require-default-props": 2,
+ "no-plusplus": 2,
+ "no-mixed-operators": 0,
+ "no-continue": 2,
+ "no-bitwise": 2,
+ "no-undef": 2,
+ "no-multi-assign": 2,
+ "no-restricted-properties": 2,
+ "no-prototype-builtins": 2,
+ "jsx-a11y/href-no-hash": 2,
+ "class-methods-use-this": 2,
+ "import/no-named-as-default": 2,
+ "import/prefer-default-export": 2,
+ "react/no-unescaped-entities": 2,
+ "react/no-string-refs": 2,
+ "react/jsx-indent": 0,
+ "prettier/prettier": "error"
+ }
+}
diff --git a/superset/assets/spec/javascripts/messageToasts/.prettierrc b/superset/assets/spec/javascripts/messageToasts/.prettierrc
new file mode 100644
index 0000000000..a20502b7f0
--- /dev/null
+++ b/superset/assets/spec/javascripts/messageToasts/.prettierrc
@@ -0,0 +1,4 @@
+{
+ "singleQuote": true,
+ "trailingComma": "all"
+}
diff --git a/superset/assets/spec/javascripts/messageToasts/components/ToastPresenter_spec.jsx b/superset/assets/spec/javascripts/messageToasts/components/ToastPresenter_spec.jsx
new file mode 100644
index 0000000000..aa04adcb3c
--- /dev/null
+++ b/superset/assets/spec/javascripts/messageToasts/components/ToastPresenter_spec.jsx
@@ -0,0 +1,41 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import mockMessageToasts from '../mockMessageToasts';
+import Toast from '../../../../src/messageToasts/components/Toast';
+import ToastPresenter from '../../../../src/messageToasts/components/ToastPresenter';
+
+describe('ToastPresenter', () => {
+ const props = {
+ toasts: mockMessageToasts,
+ removeToast() {},
+ };
+
+ function setup(overrideProps) {
+ const wrapper = shallow(<ToastPresenter {...props} {...overrideProps} />);
+ return wrapper;
+ }
+
+ it('should render a div with class toast-presenter', () => {
+ const wrapper = setup();
+ expect(wrapper.find('.toast-presenter')).to.have.length(1);
+ });
+
+ it('should render a Toast for each toast object', () => {
+ const wrapper = setup();
+ expect(wrapper.find(Toast)).to.have.length(props.toasts.length);
+ });
+
+ it('should pass removeToast to the Toast component', () => {
+ const removeToast = () => {};
+ const wrapper = setup({ removeToast });
+ expect(
+ wrapper
+ .find(Toast)
+ .first()
+ .prop('onCloseToast'),
+ ).to.equal(removeToast);
+ });
+});
diff --git a/superset/assets/spec/javascripts/messageToasts/components/Toast_spec.jsx b/superset/assets/spec/javascripts/messageToasts/components/Toast_spec.jsx
new file mode 100644
index 0000000000..ce3396cd62
--- /dev/null
+++ b/superset/assets/spec/javascripts/messageToasts/components/Toast_spec.jsx
@@ -0,0 +1,43 @@
+import { Alert } from 'react-bootstrap';
+import React from 'react';
+import { shallow } from 'enzyme';
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import mockMessageToasts from '../mockMessageToasts';
+import Toast from '../../../../src/messageToasts/components/Toast';
+
+describe('Toast', () => {
+ const props = {
+ toast: mockMessageToasts[0],
+ onCloseToast() {},
+ };
+
+ function setup(overrideProps) {
+ const wrapper = shallow(<Toast {...props} {...overrideProps} />);
+ return wrapper;
+ }
+
+ it('should render an Alert', () => {
+ const wrapper = setup();
+ expect(wrapper.find(Alert)).to.have.length(1);
+ });
+
+ it('should render toastText within the alert', () => {
+ const wrapper = setup();
+ const alert = wrapper.find(Alert).dive();
+
+ expect(alert.childAt(1).text()).to.equal(props.toast.text);
+ });
+
+ it('should call onCloseToast upon alert dismissal', done => {
+ const onCloseToast = id => {
+ expect(id).to.equal(props.toast.id);
+ done();
+ };
+ const wrapper = setup({ onCloseToast });
+ const handleClosePress = wrapper.instance().handleClosePress;
+ expect(wrapper.find(Alert).prop('onDismiss')).to.equal(handleClosePress);
+ handleClosePress(); // there is a timeout for onCloseToast to be called
+ });
+});
diff --git a/superset/assets/spec/javascripts/messageToasts/mockMessageToasts.js b/superset/assets/spec/javascripts/messageToasts/mockMessageToasts.js
new file mode 100644
index 0000000000..087374c91b
--- /dev/null
+++ b/superset/assets/spec/javascripts/messageToasts/mockMessageToasts.js
@@ -0,0 +1,6 @@
+import { INFO_TOAST, DANGER_TOAST } from '../../../src/messageToasts/constants';
+
+export default [
+ { id: 'info_id', toastType: INFO_TOAST, text: 'info toast' },
+ { id: 'danger_id', toastType: DANGER_TOAST, text: 'danger toast' },
+];
diff --git a/superset/assets/spec/javascripts/messageToasts/reducers/messageToasts_spec.js b/superset/assets/spec/javascripts/messageToasts/reducers/messageToasts_spec.js
new file mode 100644
index 0000000000..8d7127087e
--- /dev/null
+++ b/superset/assets/spec/javascripts/messageToasts/reducers/messageToasts_spec.js
@@ -0,0 +1,29 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import { ADD_TOAST, REMOVE_TOAST } from '../../../../src/messageToasts/actions';
+import messageToastsReducer from '../../../../src/messageToasts/reducers';
+
+describe('messageToasts reducer', () => {
+ it('should return initial state', () => {
+ expect(messageToastsReducer(undefined, {})).to.deep.equal([]);
+ });
+
+ it('should add a toast', () => {
+ expect(
+ messageToastsReducer([], {
+ type: ADD_TOAST,
+ payload: { text: 'test', id: 'id', type: 'test_type' },
+ }),
+ ).to.deep.equal([{ text: 'test', id: 'id', type: 'test_type' }]);
+ });
+
+ it('should add a toast', () => {
+ expect(
+ messageToastsReducer([{ id: 'id' }, { id: 'id2' }], {
+ type: REMOVE_TOAST,
+ payload: { id: 'id' },
+ }),
+ ).to.deep.equal([{ id: 'id2' }]);
+ });
+});
diff --git a/superset/assets/spec/javascripts/messageToasts/utils/getToastsFromPyFlashMessages_spec.js b/superset/assets/spec/javascripts/messageToasts/utils/getToastsFromPyFlashMessages_spec.js
new file mode 100644
index 0000000000..a3c7ce9068
--- /dev/null
+++ b/superset/assets/spec/javascripts/messageToasts/utils/getToastsFromPyFlashMessages_spec.js
@@ -0,0 +1,35 @@
+import { describe, it } from 'mocha';
+import { expect } from 'chai';
+
+import {
+ DANGER_TOAST,
+ INFO_TOAST,
+ SUCCESS_TOAST,
+} from '../../../../src/messageToasts/constants';
+
+import getToastsFromPyFlashMessages from '../../../../src/messageToasts/utils/getToastsFromPyFlashMessages';
+
+describe('getToastsFromPyFlashMessages', () => {
+ it('should return an info toast', () => {
+ const toast = getToastsFromPyFlashMessages([['info', 'info test']])[0];
+ expect(toast).to.deep.include({ toastType: INFO_TOAST, text: 'info test' });
+ });
+
+ it('should return a success toast', () => {
+ const toast = getToastsFromPyFlashMessages([
+ ['success', 'success test'],
+ ])[0];
+ expect(toast).to.deep.include({
+ toastType: SUCCESS_TOAST,
+ text: 'success test',
+ });
+ });
+
+ it('should return a danger toast', () => {
+ const toast = getToastsFromPyFlashMessages([['danger', 'danger test']])[0];
+ expect(toast).to.deep.include({
+ toastType: DANGER_TOAST,
+ text: 'danger test',
+ });
+ });
+});
diff --git a/superset/assets/spec/javascripts/sqllab/AlertsWrapper_spec.jsx b/superset/assets/spec/javascripts/sqllab/AlertsWrapper_spec.jsx
deleted file mode 100644
index 9adec4fbbb..0000000000
--- a/superset/assets/spec/javascripts/sqllab/AlertsWrapper_spec.jsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import React from 'react';
-import { describe, it } from 'mocha';
-import { expect } from 'chai';
-import { shallow } from 'enzyme';
-import AlertContainer from 'react-alert';
-import AlertsWrapper from '../../../src/components/AlertsWrapper';
-
-describe('AlertsWrapper', () => {
- let wrapper;
-
- beforeEach(() => {
- wrapper = shallow(<AlertsWrapper />);
- });
-
- it('is valid', () => {
- expect(React.isValidElement(<AlertsWrapper />)).to.equal(true);
- });
-
- it('renders AlertContainer', () => {
- expect(wrapper.find(AlertContainer)).to.have.length(1);
- });
-
- it('expects AlertContainer to have correct props', () => {
- const alertContainerProps = wrapper.find(AlertContainer).props();
- expect(alertContainerProps.offset).to.be.equal(14);
- expect(alertContainerProps.position).to.be.equal('top right');
- expect(alertContainerProps.theme).to.be.equal('dark');
- expect(alertContainerProps.time).to.be.equal(5000);
- expect(alertContainerProps.transition).to.be.equal('fade');
- });
-});
diff --git a/superset/assets/spec/javascripts/sqllab/App_spec.jsx b/superset/assets/spec/javascripts/sqllab/App_spec.jsx
index 8d9facb0c2..ce76e309f4 100644
--- a/superset/assets/spec/javascripts/sqllab/App_spec.jsx
+++ b/superset/assets/spec/javascripts/sqllab/App_spec.jsx
@@ -14,23 +14,24 @@ import { sqlLabReducer } from '../../../src/SqlLab/reducers';
describe('App', () => {
const middlewares = [thunk];
const mockStore = configureStore(middlewares);
- const store = mockStore(sqlLabReducer(undefined, {}));
+ const store = mockStore({ sqlLab: sqlLabReducer(undefined, {}), messageToasts: [] });
let wrapper;
beforeEach(() => {
- wrapper = shallow(<App />, {
- context: { store },
- }).dive();
+ wrapper = shallow(<App />, { context: { store } }).dive();
});
+
it('is valid', () => {
expect(React.isValidElement(<App />)).to.equal(true);
});
+
it('should handler resize', () => {
sinon.spy(wrapper.instance(), 'getHeight');
wrapper.instance().handleResize();
expect(wrapper.instance().getHeight.callCount).to.equal(1);
wrapper.instance().getHeight.restore();
});
+
it('should render', () => {
expect(wrapper.find('.SqlLab')).to.have.length(1);
expect(wrapper.find(TabbedSqlEditors)).to.have.length(1);
diff --git a/superset/assets/spec/javascripts/sqllab/CopyQueryTabUrl_spec.jsx b/superset/assets/spec/javascripts/sqllab/CopyQueryTabUrl_spec.jsx
index dcbb64e1a1..662cb352f3 100644
--- a/superset/assets/spec/javascripts/sqllab/CopyQueryTabUrl_spec.jsx
+++ b/superset/assets/spec/javascripts/sqllab/CopyQueryTabUrl_spec.jsx
@@ -7,7 +7,7 @@ import CopyQueryTabUrl from '../../../src/SqlLab/components/CopyQueryTabUrl';
describe('CopyQueryTabUrl', () => {
const mockedProps = {
- queryEditor: initialState.queryEditors[0],
+ queryEditor: initialState.sqlLab.queryEditors[0],
};
it('is valid with props', () => {
expect(
diff --git a/superset/assets/spec/javascripts/sqllab/SqlEditorLeftBar_spec.jsx b/superset/assets/spec/javascripts/sqllab/SqlEditorLeftBar_spec.jsx
index df7035a96d..16f1f8bf36 100644
--- a/superset/assets/spec/javascripts/sqllab/SqlEditorLeftBar_spec.jsx
+++ b/superset/assets/spec/javascripts/sqllab/SqlEditorLeftBar_spec.jsx
@@ -20,6 +20,7 @@ describe('SqlEditorLeftBar', () => {
queryEditorSetDb: sinon.stub(),
setDatabases: sinon.stub(),
addTable: sinon.stub(),
+ addDangerToast: sinon.stub(),
},
tables: [table],
queryEditor: defaultQueryEditor,
diff --git a/superset/assets/spec/javascripts/sqllab/SqlEditor_spec.jsx b/superset/assets/spec/javascripts/sqllab/SqlEditor_spec.jsx
index d1b58d32ed..b0689650da 100644
--- a/superset/assets/spec/javascripts/sqllab/SqlEditor_spec.jsx
+++ b/superset/assets/spec/javascripts/sqllab/SqlEditor_spec.jsx
@@ -11,7 +11,7 @@ describe('SqlEditor', () => {
const mockedProps = {
actions: {},
database: {},
- queryEditor: initialState.queryEditors[0],
+ queryEditor: initialState.sqlLab.queryEditors[0],
latestQuery: queries[0],
tables: [table],
queries,
diff --git a/superset/assets/spec/javascripts/sqllab/TabbedSqlEditors_spec.jsx b/superset/assets/spec/javascripts/sqllab/TabbedSqlEditors_spec.jsx
index c898662f57..0846af8166 100644
--- a/superset/assets/spec/javascripts/sqllab/TabbedSqlEditors_spec.jsx
+++ b/superset/assets/spec/javascripts/sqllab/TabbedSqlEditors_spec.jsx
@@ -22,10 +22,12 @@ describe('TabbedSqlEditors', () => {
'dfsadfs',
'newEditorId',
];
+
const tables = [Object.assign({}, table[0], {
dataPreviewQueryId: 'B1-VQU1zW',
queryEditorId: 'newEditorId',
})];
+
const queryEditors = [{
autorun: false,
dbId: 1,
@@ -47,8 +49,8 @@ describe('TabbedSqlEditors', () => {
databases: {},
tables: [],
queries: {},
- queryEditors: initialState.queryEditors,
- tabHistory: initialState.tabHistory,
+ queryEditors: initialState.sqlLab.queryEditors,
+ tabHistory: initialState.sqlLab.tabHistory,
editorHeight: '',
getHeight: () => ('100px'),
database: {},
@@ -163,7 +165,7 @@ describe('TabbedSqlEditors', () => {
wrapper.setState({ hideLeftBar: true });
const firstTab = wrapper.find(Tab).first();
- expect(firstTab.props().eventKey).to.contain(initialState.queryEditors[0].id);
+ expect(firstTab.props().eventKey).to.contain(initialState.sqlLab.queryEditors[0].id);
expect(firstTab.find(SqlEditor)).to.have.length(1);
const lastTab = wrapper.find(Tab).last();
diff --git a/superset/assets/spec/javascripts/sqllab/VisualizeModal_spec.jsx b/superset/assets/spec/javascripts/sqllab/VisualizeModal_spec.jsx
index b80a962606..5eb4802a7d 100644
--- a/superset/assets/spec/javascripts/sqllab/VisualizeModal_spec.jsx
+++ b/superset/assets/spec/javascripts/sqllab/VisualizeModal_spec.jsx
@@ -17,17 +17,16 @@ import { VISUALIZE_VALIDATION_ERRORS } from '../../../src/SqlLab/constants';
import VisualizeModal from '../../../src/SqlLab/components/VisualizeModal';
import * as exploreUtils from '../../../src/explore/exploreUtils';
-global.notify = {
- info: () => {},
- error: () => {},
-};
-
describe('VisualizeModal', () => {
const middlewares = [thunk];
const mockStore = configureStore(middlewares);
- const initialState = sqlLabReducer({}, {});
- initialState.common = {
- conf: { SUPERSET_WEBSERVER_TIMEOUT: 45 },
+ const initialState = {
+ sqlLab: {
+ ...sqlLabReducer(undefined, {}),
+ common: {
+ conf: { SUPERSET_WEBSERVER_TIMEOUT: 45 },
+ },
+ },
};
const store = mockStore(initialState);
const mockedProps = {
@@ -277,7 +276,7 @@ describe('VisualizeModal', () => {
});
it('should build visualize advise for long query', () => {
- const longQuery = Object.assign({}, queries[0], { endDttm: 1476910666798 });
+ const longQuery = { ...queries[0], endDttm: 1476910666798 };
const props = {
show: true,
query: longQuery,
@@ -334,29 +333,46 @@ describe('VisualizeModal', () => {
expect(spyCall.args[0].data.data).to.equal(JSON.stringify(mockOptions));
});
it('should open new window', () => {
+ const infoToastSpy = sinon.spy();
+
datasourceSpy.callsFake(() => {
const d = $.Deferred();
d.resolve('done');
return d.promise();
});
- wrapper.setProps({ actions: { createDatasource: datasourceSpy } });
+
+ wrapper.setProps({
+ actions: {
+ createDatasource: datasourceSpy,
+ addInfoToast: infoToastSpy,
+ },
+ });
wrapper.instance().visualize();
expect(exploreUtils.exportChart.callCount).to.equal(1);
expect(exploreUtils.exportChart.getCall(0).args[0].datasource).to.equal('107__table');
+ expect(infoToastSpy.callCount).to.equal(1);
});
- it('should notify error', () => {
+ it('should add error toast', () => {
+ const dangerToastSpy = sinon.spy();
+
datasourceSpy.callsFake(() => {
const d = $.Deferred();
d.reject('error message');
return d.promise();
});
- wrapper.setProps({ actions: { createDatasource: datasourceSpy } });
- sinon.spy(notify, 'error');
+
+
+ wrapper.setProps({
+ actions: {
+ createDatasource: datasourceSpy,
+ addDangerToast: dangerToastSpy,
+ },
+ });
wrapper.instance().visualize();
expect(exploreUtils.exportChart.callCount).to.equal(0);
- expect(notify.error.callCount).to.equal(1);
+ expect(dangerToastSpy.callCount).to.equal(1);
});
});
});
diff --git a/superset/assets/spec/javascripts/sqllab/actions_spec.js b/superset/assets/spec/javascripts/sqllab/actions_spec.js
index 34c32a23a0..5909ca8241 100644
--- a/superset/assets/spec/javascripts/sqllab/actions_spec.js
+++ b/superset/assets/spec/javascripts/sqllab/actions_spec.js
@@ -20,13 +20,15 @@ describe('async actions', () => {
describe('saveQuery', () => {
it('makes the ajax request', () => {
- actions.saveQuery(query);
+ const thunk = actions.saveQuery(query);
+ thunk((/* mockDispatch */) => {});
expect(ajaxStub.calledOnce).to.be.true;
});
it('calls correct url', () => {
const url = '/savedqueryviewapi/api/create';
- actions.saveQuery(query);
+ const thunk = actions.saveQuery(query);
+ thunk((/* mockDispatch */) => {});
expect(ajaxStub.getCall(0).args[0].url).to.equal(url);
});
});
diff --git a/superset/assets/spec/javascripts/sqllab/fixtures.js b/superset/assets/spec/javascripts/sqllab/fixtures.js
index c05a745884..510b5d5bda 100644
--- a/superset/assets/spec/javascripts/sqllab/fixtures.js
+++ b/superset/assets/spec/javascripts/sqllab/fixtures.js
@@ -319,15 +319,18 @@ export const runningQuery = {
export const cachedQuery = Object.assign({}, queries[0], { cached: true });
export const initialState = {
- alerts: [],
- queries: {},
- databases: {},
- queryEditors: [defaultQueryEditor],
- tabHistory: [defaultQueryEditor.id],
- tables: [],
- workspaceQueries: [],
- queriesLastUpdate: 0,
- activeSouthPaneTab: 'Results',
+ sqlLab: {
+ alerts: [],
+ queries: {},
+ databases: {},
+ queryEditors: [defaultQueryEditor],
+ tabHistory: [defaultQueryEditor.id],
+ tables: [],
+ workspaceQueries: [],
+ queriesLastUpdate: 0,
+ activeSouthPaneTab: 'Results',
+ },
+ messageToasts: [],
};
export const query = {
diff --git a/superset/assets/spec/javascripts/sqllab/reducers_spec.js b/superset/assets/spec/javascripts/sqllab/reducers_spec.js
index a23ceb5d57..2931d13b46 100644
--- a/superset/assets/spec/javascripts/sqllab/reducers_spec.js
+++ b/superset/assets/spec/javascripts/sqllab/reducers_spec.js
@@ -3,12 +3,17 @@ import { expect } from 'chai';
import * as r from '../../../src/SqlLab/reducers';
import * as actions from '../../../src/SqlLab/actions';
-import { alert, table, initialState } from './fixtures';
+import { table, initialState as mockState } from './fixtures';
+
+const initialState = mockState.sqlLab;
describe('sqlLabReducer', () => {
describe('CLONE_QUERY_TO_NEW_TAB', () => {
const testQuery = { sql: 'SELECT * FROM...', dbId: 1, id: 'flasj233' };
- let newState = Object.assign({}, initialState, { queries: { [testQuery.id]: testQuery } });
+ let newState = {
+ ...initialState,
+ queries: { [testQuery.id]: testQuery },
+ };
beforeEach(() => {
newState = r.sqlLabReducer(newState, actions.cloneQueryToNewTab(testQuery));
});
@@ -29,24 +34,12 @@ describe('sqlLabReducer', () => {
expect(newState.tabHistory[1]).to.eq(newState.queryEditors[1].id);
});
});
- describe('Alerts', () => {
- const state = Object.assign({}, initialState);
- let newState;
- it('should add one alert', () => {
- newState = r.sqlLabReducer(state, actions.addAlert(alert));
- expect(newState.alerts).to.have.lengthOf(1);
- });
- it('should remove one alert', () => {
- newState = r.sqlLabReducer(newState, actions.removeAlert(newState.alerts[0]));
- expect(newState.alerts).to.have.lengthOf(0);
- });
- });
describe('Query editors actions', () => {
let newState;
let defaultQueryEditor;
let qe;
beforeEach(() => {
- newState = Object.assign({}, initialState);
+ newState = { ...initialState };
defaultQueryEditor = newState.queryEditors[0];
qe = Object.assign({}, defaultQueryEditor);
newState = r.sqlLabReducer(newState, actions.addQueryEditor(qe));
@@ -134,8 +127,8 @@ describe('sqlLabReducer', () => {
let query;
let newQuery;
beforeEach(() => {
- newState = Object.assign({}, initialState);
- newQuery = Object.assign({}, query);
+ newState = { ...initialState };
+ newQuery = { ...query };
});
it('should start a query', () => {
newState = r.sqlLabReducer(newState, actions.startQuery(newQuery));
diff --git a/superset/assets/src/SqlLab/TODO.md b/superset/assets/src/SqlLab/TODO.md
deleted file mode 100644
index db8f247abf..0000000000
--- a/superset/assets/src/SqlLab/TODO.md
+++ /dev/null
@@ -1,19 +0,0 @@
-
-# TODO
-* Figure out how to organize the left panel, integrate Search
-* collapse sql beyond 10 lines
-* Security per-database (dropdown)
-* Get a to work
-
-## Cosmetic
-* Result set font is too big
-* lmiit/timer/buttons wrap
-* table label is transparent
-* SqlEditor buttons
-* use react-bootstrap-prompt for query title input
-* Make tabs look great
-
-# PROJECT
-* Write Runbook
-* Confirm backups
-* merge chef branch
diff --git a/superset/assets/src/SqlLab/actions.js b/superset/assets/src/SqlLab/actions.js
index 644947023b..c36865f399 100644
--- a/superset/assets/src/SqlLab/actions.js
+++ b/superset/assets/src/SqlLab/actions.js
@@ -1,11 +1,16 @@
-/* global notify */
+/* global window */
+/* eslint no-undef: 2 */
+import $ from 'jquery';
import shortid from 'shortid';
import { now } from '../modules/dates';
import { t } from '../locales';
+import {
+ addSuccessToast as addSuccessToastAction,
+ addDangerToast as addDangerToastAction,
+ addInfoToast as addInfoToastAction,
+} from '../messageToasts/actions';
import { COMMON_ERR_MESSAGES } from '../common';
-const $ = require('jquery');
-
export const RESET_STATE = 'RESET_STATE';
export const ADD_QUERY_EDITOR = 'ADD_QUERY_EDITOR';
export const CLONE_QUERY_TO_NEW_TAB = 'CLONE_QUERY_TO_NEW_TAB';
@@ -28,8 +33,6 @@ export const QUERY_EDITOR_PERSIST_HEIGHT = 'QUERY_EDITOR_PERSIST_HEIGHT';
export const SET_DATABASES = 'SET_DATABASES';
export const SET_ACTIVE_QUERY_EDITOR = 'SET_ACTIVE_QUERY_EDITOR';
export const SET_ACTIVE_SOUTHPANE_TAB = 'SET_ACTIVE_SOUTHPANE_TAB';
-export const ADD_ALERT = 'ADD_ALERT';
-export const REMOVE_ALERT = 'REMOVE_ALERT';
export const REFRESH_QUERIES = 'REFRESH_QUERIES';
export const RUN_QUERY = 'RUN_QUERY';
export const START_QUERY = 'START_QUERY';
@@ -46,21 +49,31 @@ export const CREATE_DATASOURCE_STARTED = 'CREATE_DATASOURCE_STARTED';
export const CREATE_DATASOURCE_SUCCESS = 'CREATE_DATASOURCE_SUCCESS';
export const CREATE_DATASOURCE_FAILED = 'CREATE_DATASOURCE_FAILED';
+export const addInfoToast = addInfoToastAction;
+export const addSuccessToast = addSuccessToastAction;
+export const addDangerToast = addDangerToastAction;
+
export function resetState() {
return { type: RESET_STATE };
}
export function saveQuery(query) {
- const url = '/savedqueryviewapi/api/create';
- $.ajax({
- type: 'POST',
- url,
- data: query,
- success: () => notify.success(t('Your query was saved')),
- error: () => notify.error(t('Your query could not be saved')),
- dataType: 'json',
- });
- return { type: SAVE_QUERY };
+ return (dispatch) => {
+ const url = '/savedqueryviewapi/api/create';
+ $.ajax({
+ type: 'POST',
+ url,
+ data: query,
+ success: () => {
+ dispatch(addSuccessToast(t('Your query was saved')));
+ },
+ error: () => {
+ dispatch(addDangerToast(t('Your query could not be saved')));
+ },
+ dataType: 'json',
+ });
+ return { type: SAVE_QUERY };
+ };
}
export function startQuery(query) {
@@ -78,8 +91,8 @@ export function querySuccess(query, results) {
return { type: QUERY_SUCCESS, query, results };
}
-export function queryFailed(query, msg) {
- return { type: QUERY_FAILED, query, msg };
+export function queryFailed(query, msg, link) {
+ return { type: QUERY_FAILED, query, msg, link };
}
export function stopQuery(query) {
@@ -98,6 +111,14 @@ export function requestQueryResults(query) {
return { type: REQUEST_QUERY_RESULTS, query };
}
+function getErrorLink(err) {
+ let link = '';
+ if (err.responseJSON && err.responseJSON.link) {
+ link = err.responseJSON.link;
+ }
+ return link;
+}
+
export function fetchQueryResults(query) {
return function (dispatch) {
dispatch(requestQueryResults(query));
@@ -114,7 +135,7 @@ export function fetchQueryResults(query) {
if (err.responseJSON && err.responseJSON.error) {
msg = err.responseJSON.error;
}
- dispatch(queryFailed(query, msg));
+ dispatch(queryFailed(query, msg, getErrorLink(err)));
},
});
};
@@ -136,7 +157,7 @@ export function runQuery(query) {
select_as_cta: query.ctas,
templateParams: query.templateParams,
};
- const sqlJsonUrl = '/superset/sql_json/' + location.search;
+ const sqlJsonUrl = '/superset/sql_json/' + window.location.search;
$.ajax({
type: 'POST',
dataType: 'json',
@@ -166,7 +187,7 @@ export function runQuery(query) {
if (msg.indexOf('CSRF token') > 0) {
msg = COMMON_ERR_MESSAGES.SESSION_TIMED_OUT;
}
- dispatch(queryFailed(query, msg));
+ dispatch(queryFailed(query, msg, getErrorLink(err)));
},
});
};
@@ -183,10 +204,10 @@ export function postStopQuery(query) {
url: stopQueryUrl,
data: stopQueryRequestData,
success() {
- notify.success(t('Query was stopped.'));
+ dispatch(addSuccessToast(t('Query was stopped.')));
},
error() {
- notify.error(t('Failed at stopping query.'));
+ dispatch(addDangerToast(t('Failed at stopping query.')));
},
});
};
@@ -208,16 +229,6 @@ export function cloneQueryToNewTab(query) {
return { type: CLONE_QUERY_TO_NEW_TAB, query };
}
-export function addAlert(alert) {
- const o = Object.assign({}, alert);
- o.id = shortid.generate();
- return { type: ADD_ALERT, alert: o };
-}
-
-export function removeAlert(alert) {
- return { type: REMOVE_ALERT, alert };
-}
-
export function setActiveQueryEditor(queryEditor) {
return { type: SET_ACTIVE_QUERY_EDITOR, queryEditor };
}
@@ -306,7 +317,7 @@ export function addTable(query, tableName, schemaName) {
isMetadataLoading: false,
});
dispatch(mergeTable(newTable));
- notify.error(t('Error occurred while fetching table metadata'));
+ dispatch(addDangerToast(t('Error occurred while fetching table metadata')));
});
url = `/superset/extra_table_metadata/${query.dbId}/${tableName}/${schemaName}/`;
@@ -319,7 +330,7 @@ export function addTable(query, tableName, schemaName) {
isExtraMetadataLoading: false,
});
dispatch(mergeTable(newTable));
- notify.error(t('Error occurred while fetching table metadata'));
+ dispatch(addDangerToast(t('Error occurred while fetching table metadata')));
});
};
}
@@ -381,7 +392,9 @@ export function popStoredQuery(urlId) {
};
dispatch(addQueryEditor(queryEditorProps));
},
- error: () => notify.error(t('The query couldn\'t be loaded')),
+ error: () => {
+ dispatch(addDangerToast(t('The query couldn\'t be loaded')));
+ },
});
};
}
@@ -401,7 +414,9 @@ export function popSavedQuery(saveQueryId) {
};
dispatch(addQueryEditor(queryEditorProps));
},
- error: () => notify.error(t('The query couldn\'t be loaded')),
+ error: () => {
+ dispatch(addDangerToast(t('The query couldn\'t be loaded')));
+ },
});
};
}
diff --git a/superset/assets/src/SqlLab/components/App.jsx b/superset/assets/src/SqlLab/components/App.jsx
index 3698a2a258..8a0c084373 100644
--- a/superset/assets/src/SqlLab/components/App.jsx
+++ b/superset/assets/src/SqlLab/components/App.jsx
@@ -2,15 +2,14 @@ import React from 'react';
import PropTypes from 'prop-types';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
+import $ from 'jquery';
import TabbedSqlEditors from './TabbedSqlEditors';
import QueryAutoRefresh from './QueryAutoRefresh';
import QuerySearch from './QuerySearch';
-import AlertsWrapper from '../../components/AlertsWrapper';
+import ToastPresenter from '../../messageToasts/containers/ToastPresenter';
import * as Actions from '../actions';
-const $ = window.$ = require('jquery');
-
class App extends React.PureComponent {
constructor(props) {
super(props);
@@ -39,8 +38,10 @@ class App extends React.PureComponent {
const alertEl = $('#sqllab-alerts');
const headerEl = $('header .navbar');
const headerHeight = headerEl.outerHeight() + parseInt(headerEl.css('marginBottom'), 10);
- const searchHeaderHeight = searchHeaderEl.length > 0 ?
- searchHeaderEl.outerHeight() + parseInt(searchHeaderEl.css('marginBottom'), 10) : 0;
+ const searchHeaderHeight =
+ searchHeaderEl.length > 0
+ ? searchHeaderEl.outerHeight() + parseInt(searchHeaderEl.css('marginBottom'), 10)
+ : 0;
const tabsHeight = tabsEl.length > 0 ? tabsEl.outerHeight() : searchHeaderHeight;
const warningHeight = warningEl.length > 0 ? warningEl.outerHeight() : 0;
const alertHeight = alertEl.length > 0 ? alertEl.outerHeight() : 0;
@@ -71,27 +72,17 @@ class App extends React.PureComponent {
}
return (
<div className="App SqlLab">
- <AlertsWrapper initMessages={this.props.initMessages} />
- <div className="container-fluid">
- {content}
- </div>
+ <div className="container-fluid">{content}</div>
+ <ToastPresenter />
</div>
);
}
}
App.propTypes = {
- alerts: PropTypes.array,
actions: PropTypes.object,
- initMessages: PropTypes.array,
};
-function mapStateToProps(state) {
- return {
- alerts: state.alerts,
- initMessages: state.flash_messages,
- };
-}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators(Actions, dispatch),
@@ -99,4 +90,7 @@ function mapDispatchToProps(dispatch) {
}
export { App };
-export default connect(mapStateToProps, mapDispatchToProps)(App);
+export default connect(
+ null,
+ mapDispatchToProps,
+)(App);
diff --git a/superset/assets/src/SqlLab/components/CopyQueryTabUrl.jsx b/superset/assets/src/SqlLab/components/CopyQueryTabUrl.jsx
index fed7c28bbe..e48fe1be8f 100644
--- a/superset/assets/src/SqlLab/components/CopyQueryTabUrl.jsx
+++ b/superset/assets/src/SqlLab/components/CopyQueryTabUrl.jsx
@@ -27,10 +27,13 @@ export default class CopyQueryTabUrl extends React.PureComponent {
inMenu
copyNode={(
<div>
- <i className="fa fa-clipboard" /> <span>{t('share query')}</span>
+ <div className="icon-container">
+ <i className="fa fa-clipboard" />
+ </div>
+ <span>{t('Share query')}</span>
</div>
)}
- tooltipText={t('copy URL to clipboard')}
+ tooltipText={t('Copy URL to clipboard')}
shouldShowText={false}
getText={this.getUrl.bind(this)}
/>
diff --git a/superset/assets/src/SqlLab/components/QueryAutoRefresh.jsx b/superset/assets/src/SqlLab/components/QueryAutoRefresh.jsx
index 55e06cc146..4bd034e712 100644
--- a/superset/assets/src/SqlLab/components/QueryAutoRefresh.jsx
+++ b/superset/assets/src/SqlLab/components/QueryAutoRefresh.jsx
@@ -57,10 +57,10 @@ QueryAutoRefresh.propTypes = {
queriesLastUpdate: PropTypes.number.isRequired,
};
-function mapStateToProps(state) {
+function mapStateToProps({ sqlLab }) {
return {
- queries: state.queries,
- queriesLastUpdate: state.queriesLastUpdate,
+ queries: sqlLab.queries,
+ queriesLastUpdate: sqlLab.queriesLastUpdate,
};
}
diff --git a/superset/assets/src/SqlLab/components/QuerySearch.jsx b/superset/assets/src/SqlLab/components/QuerySearch.jsx
index 9d36d85218..d13d99376b 100644
--- a/superset/assets/src/SqlLab/components/QuerySearch.jsx
+++ b/superset/assets/src/SqlLab/components/QuerySearch.jsx
@@ -1,15 +1,21 @@
+/* eslint no-undef: 2 */
import React from 'react';
import PropTypes from 'prop-types';
import { Button } from 'react-bootstrap';
import Select from 'react-select';
+import Loading from '../../components/Loading';
import QueryTable from './QueryTable';
-import { now, epochTimeXHoursAgo,
- epochTimeXDaysAgo, epochTimeXYearsAgo } from '../../modules/dates';
+import {
+ now,
+ epochTimeXHoursAgo,
+ epochTimeXDaysAgo,
+ epochTimeXYearsAgo,
+} from '../../modules/dates';
import { STATUS_OPTIONS, TIME_OPTIONS } from '../constants';
import AsyncSelect from '../../components/AsyncSelect';
import { t } from '../../locales';
-const $ = window.$ = require('jquery');
+const $ = require('jquery');
const propTypes = {
actions: PropTypes.object.isRequired,
@@ -47,13 +53,17 @@ class QuerySearch extends React.PureComponent {
this.refreshQueries();
}
onUserClicked(userId) {
- this.setState({ userId }, () => { this.refreshQueries(); });
+ this.setState({ userId }, () => {
+ this.refreshQueries();
+ });
}
onDbClicked(dbId) {
- this.setState({ databaseId: dbId }, () => { this.refreshQueries(); });
+ this.setState({ databaseId: dbId }, () => {
+ this.refreshQueries();
+ });
}
onChange(db) {
- const val = (db) ? db.value : null;
+ const val = db ? db.value : null;
this.setState({ databaseId: val });
}
getTimeFromSelection(selection) {
@@ -77,25 +87,25 @@ class QuerySearch extends React.PureComponent {
}
}
changeFrom(user) {
- const val = (user) ? user.value : null;
+ const val = user ? user.value : null;
this.setState({ from: val });
}
changeTo(status) {
- const val = (status) ? status.value : null;
+ const val = status ? status.value : null;
this.setState({ to: val });
}
changeUser(user) {
- const val = (user) ? user.value : null;
+ const val = user ? user.value : null;
this.setState({ userId: val });
}
insertParams(baseUrl, params) {
- const validParams = params.filter(
- function (p) { return p !== ''; },
- );
+ const validParams = params.filter(function (p) {
+ return p !== '';
+ });
return baseUrl + '?' + validParams.join('&');
}
changeStatus(status) {
- const val = (status) ? status.value : null;
+ const val = status ? status.value : null;
this.setState({ status: val });
}
changeSearch(event) {
@@ -118,10 +128,7 @@ class QuerySearch extends React.PureComponent {
const options = data.result.map(db => ({ value: db.id, label: db.database_name }));
this.props.actions.setDatabases(data.result);
if (data.result.length === 0) {
- this.props.actions.addAlert({
- bsStyle: 'danger',
- msg: t('It seems you don\'t have access to any database'),
- });
+ this.props.actions.addDangerToast(t("It seems you don't have access to any database"));
}
return options;
}
@@ -175,8 +182,10 @@ class QuerySearch extends React.PureComponent {
<Select
name="select-from"
placeholder={t('[From]-')}
- options={TIME_OPTIONS
- .slice(1, TIME_OPTIONS.length).map(xt => ({ value: xt, label: xt }))}
+ options={TIME_OPTIONS.slice(1, TIME_OPTIONS.length).map(xt => ({
+ value: xt,
+ label: xt,
+ }))}
value={this.state.from}
autosize={false}
onChange={this.changeFrom}
@@ -206,29 +215,21 @@ class QuerySearch extends React.PureComponent {
</Button>
</div>
</div>
- {this.state.queriesLoading ?
- (<img className="loading" alt="Loading..." src="/static/assets/images/loading.gif" />)
- :
- (
- <div className="scrollbar-container">
- <div
- className="scrollbar-content"
- style={{ height: this.props.height }}
- >
- <QueryTable
- columns={[
- 'state', 'db', 'user', 'time',
- 'progress', 'rows', 'sql', 'querylink',
- ]}
- onUserClicked={this.onUserClicked}
- onDbClicked={this.onDbClicked}
- queries={this.state.queriesArray}
- actions={this.props.actions}
- />
- </div>
+ <div className="scrollbar-container">
+ {this.state.queriesLoading ? (
+ <Loading />
+ ) : (
+ <div className="scrollbar-content" style={{ height: this.props.height }}>
+ <QueryTable
+ columns={['state', 'db', 'user', 'time', 'progress', 'rows', 'sql', 'querylink']}
+ onUserClicked={this.onUserClicked}
+ onDbClicked={this.onDbClicked}
+ queries={this.state.queriesArray}
+ actions={this.props.actions}
+ />
</div>
- )
- }
+ )}
+ </div>
</div>
);
}
diff --git a/superset/assets/src/SqlLab/components/ResultSet.jsx b/superset/assets/src/SqlLab/components/ResultSet.jsx
index f36a1640c7..1acda09b89 100644
--- a/superset/assets/src/SqlLab/components/ResultSet.jsx
+++ b/superset/assets/src/SqlLab/components/ResultSet.jsx
@@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import { Alert, Button, ButtonGroup, ProgressBar } from 'react-bootstrap';
import shortid from 'shortid';
+import Loading from '../../components/Loading';
import VisualizeModal from './VisualizeModal';
import HighlightedSql from './HighlightedSql';
import FilterableTable from '../../components/FilterableTable/FilterableTable';
@@ -30,6 +31,8 @@ const defaultProps = {
const SEARCH_HEIGHT = 46;
+const LOADING_STYLES = { position: 'relative', height: 50 };
+
export default class ResultSet extends React.PureComponent {
constructor(props) {
super(props);
@@ -155,7 +158,11 @@ export default class ResultSet extends React.PureComponent {
if (query.state === 'stopped') {
return <Alert bsStyle="warning">Query was stopped</Alert>;
} else if (query.state === 'failed') {
- return <Alert bsStyle="danger">{query.errorMessage}</Alert>;
+ return (
+ <Alert bsStyle="danger">
+ {query.errorMessage}
+ {query.link && <a href={query.link}> {t('(Request Access)')} </a>}
+ </Alert>);
} else if (query.state === 'success' && query.ctas) {
return (
<div>
@@ -233,9 +240,9 @@ export default class ResultSet extends React.PureComponent {
);
}
return (
- <div>
- <img className="loading" alt={t('Loading...')} src="/static/assets/images/loading.gif" />
+ <div style={LOADING_STYLES}>
<QueryStateLabel query={query} />
+ {!progressBar && <Loading />}
{progressBar}
<div>
{trackingUrl}
diff --git a/superset/assets/src/SqlLab/components/SouthPane.jsx b/superset/assets/src/SqlLab/components/SouthPane.jsx
index 318b0af005..3b68a03781 100644
--- a/superset/assets/src/SqlLab/components/SouthPane.jsx
+++ b/superset/assets/src/SqlLab/components/SouthPane.jsx
@@ -54,7 +54,7 @@ class SouthPane extends React.PureComponent {
const dataPreviewTabs = props.dataPreviewQueries.map(query => (
<Tab
- title={t('Preview for %s', query.tableName)}
+ title={t('Preview: `%s`', query.tableName)}
eventKey={query.id}
key={query.id}
>
@@ -87,7 +87,7 @@ class SouthPane extends React.PureComponent {
title={t('Query History')}
eventKey="History"
>
- <div style={{ height: `${innerTabHeight}px`, overflow: 'scroll' }}>
+ <div style={{ height: `${innerTabHeight}px`, overflow: 'auto' }}>
<QueryHistory queries={props.editorQueries} actions={props.actions} />
</div>
</Tab>
@@ -98,9 +98,9 @@ class SouthPane extends React.PureComponent {
}
}
-function mapStateToProps(state) {
+function mapStateToProps({ sqlLab }) {
return {
- activeSouthPaneTab: state.activeSouthPaneTab,
+ activeSouthPaneTab: sqlLab.activeSouthPaneTab,
};
}
diff --git a/superset/assets/src/SqlLab/components/SqlEditor.jsx b/superset/assets/src/SqlLab/components/SqlEditor.jsx
index 37626a83bc..96c26e98df 100644
--- a/superset/assets/src/SqlLab/components/SqlEditor.jsx
+++ b/superset/assets/src/SqlLab/components/SqlEditor.jsx
@@ -1,3 +1,5 @@
+/* global window */
+/* eslint no-undef: 2 */
import React from 'react';
import PropTypes from 'prop-types';
import throttle from 'lodash.throttle';
@@ -126,7 +128,7 @@ class SqlEditor extends React.PureComponent {
this.props.actions.queryEditorSetSql(this.props.queryEditor, sql);
}
runQuery() {
- this.startQuery(!this.props.database.allow_run_sync);
+ this.startQuery(!(this.props.database || {}).allow_run_sync);
}
startQuery(runAsync = false, ctas = false) {
const qe = this.props.queryEditor;
@@ -234,7 +236,7 @@ class SqlEditor extends React.PureComponent {
{ctasControls}
<span className="m-l-5">
<Hotkeys
- header="Hotkeys"
+ header="Keyboard shortcuts"
hotkeys={hotkeys}
/>
</span>
diff --git a/superset/assets/src/SqlLab/components/SqlEditorLeftBar.jsx b/superset/assets/src/SqlLab/components/SqlEditorLeftBar.jsx
index 08c0e9cdc6..d20d494bcf 100644
--- a/superset/assets/src/SqlLab/components/SqlEditorLeftBar.jsx
+++ b/superset/assets/src/SqlLab/components/SqlEditorLeftBar.jsx
@@ -1,7 +1,8 @@
-/* global notify */
+/* global window */
+/* eslint no-undef: 2 */
import React from 'react';
import PropTypes from 'prop-types';
-import { Button } from 'react-bootstrap';
+import { ControlLabel, Button } from 'react-bootstrap';
import Select from 'react-virtualized-select';
import createFilterOptions from 'react-select-fast-filter-options';
@@ -9,7 +10,7 @@ import TableElement from './TableElement';
import AsyncSelect from '../../components/AsyncSelect';
import { t } from '../../locales';
-const $ = window.$ = require('jquery');
+const $ = require('jquery');
const propTypes = {
queryEditor: PropTypes.object.isRequired,
@@ -62,10 +63,7 @@ class SqlEditorLeftBar extends React.PureComponent {
const options = data.result.map(db => ({ value: db.id, label: db.database_name }));
this.props.actions.setDatabases(data.result);
if (data.result.length === 0) {
- this.props.actions.addAlert({
- bsStyle: 'danger',
- msg: t('It seems you don\'t have access to any database'),
- });
+ this.props.actions.addDangerToast(t('It seems you don\'t have access to any database'));
}
return options;
}
@@ -88,7 +86,7 @@ class SqlEditorLeftBar extends React.PureComponent {
})
.fail(() => {
this.setState({ tableLoading: false, tableOptions: [], tableLength: 0 });
- notify.error(t('Error while fetching table list'));
+ this.props.actions.addDangerToast(t('Error while fetching table list'));
});
} else {
this.setState({ tableLoading: false, tableOptions: [], filterOptions: null });
@@ -129,7 +127,7 @@ class SqlEditorLeftBar extends React.PureComponent {
})
.fail(() => {
this.setState({ schemaLoading: false, schemaOptions: [] });
- notify.error(t('Error while fetching schema list'));
+ this.props.actions.addDangerToast(t('Error while fetching schema list'));
});
}
}
@@ -159,7 +157,9 @@ class SqlEditorLeftBar extends React.PureComponent {
'_od_DatabaseAsync=asc'
}
onChange={this.onDatabaseChange.bind(this)}
- onAsyncError={() => notify.error(t('Error while fetching database list'))}
+ onAsyncError={() => {
+ this.props.actions.addDangerToast(t('Error while fetching database list'));
+ }}
value={this.props.queryEditor.dbId}
databaseId={this.props.queryEditor.dbId}
actions={this.props.actions}
@@ -189,13 +189,25 @@ class SqlEditorLeftBar extends React.PureComponent {
onChange={this.changeSchema.bind(this)}
/>
</div>
+ <hr />
<div className="m-t-5">
+ <ControlLabel>
+ {t('See table schema')}
+
+ <small>
+ ({this.state.tableOptions.length}
+ {t('in')}
+ <i>
+ {this.props.queryEditor.schema}
+ </i>)
+ </small>
+ </ControlLabel>
{this.props.queryEditor.schema &&
<Select
name="select-table"
ref="selectTable"
isLoading={this.state.tableLoading}
- placeholder={t('Add a table (%s)', this.state.tableOptions.length)}
+ placeholder={t('Select table or type table name')}
autosize={false}
onChange={this.changeTable.bind(this)}
filterOptions={this.state.filterOptions}
diff --git a/superset/assets/src/SqlLab/components/TabbedSqlEditors.jsx b/superset/assets/src/SqlLab/components/TabbedSqlEditors.jsx
index b9acc160d5..b513ab7a7f 100644
--- a/superset/assets/src/SqlLab/components/TabbedSqlEditors.jsx
+++ b/superset/assets/src/SqlLab/components/TabbedSqlEditors.jsx
@@ -123,12 +123,15 @@ class TabbedSqlEditors extends React.PureComponent {
const activeQueryEditor = this.activeQueryEditor();
const qe = {
title: t('Untitled Query %s', queryCount),
- dbId: (activeQueryEditor && activeQueryEditor.dbId) ?
- activeQueryEditor.dbId :
- this.props.defaultDbId,
- schema: (activeQueryEditor) ? activeQueryEditor.schema : null,
+ dbId:
+ activeQueryEditor && activeQueryEditor.dbId
+ ? activeQueryEditor.dbId
+ : this.props.defaultDbId,
+ schema: activeQueryEditor ? activeQueryEditor.schema : null,
autorun: false,
- sql: 'SELECT ...',
+ sql: `${t(
+ '-- Note: Unless you save your query, these tabs will NOT persist if you clear your cookies or change browsers.',
+ )}\n\nSELECT ...`,
};
this.props.actions.addQueryEditor(qe);
}
@@ -147,7 +150,7 @@ class TabbedSqlEditors extends React.PureComponent {
}
render() {
const editors = this.props.queryEditors.map((qe, i) => {
- const isSelected = (qe.id === this.activeQueryEditor().id);
+ const isSelected = qe.id === this.activeQueryEditor().id;
let latestQuery;
if (qe.latestQueryId) {
@@ -157,45 +160,48 @@ class TabbedSqlEditors extends React.PureComponent {
if (qe.dbId) {
database = this.props.databases[qe.dbId];
}
- const state = (latestQuery) ? latestQuery.state : '';
+ const state = latestQuery ? latestQuery.state : '';
const tabTitle = (
<div>
- <TabStatusIcon onClose={this.removeQueryEditor.bind(this, qe)} tabState={state} /> {qe.title} {' '}
+ <TabStatusIcon onClose={this.removeQueryEditor.bind(this, qe)} tabState={state} />{' '}
+ {qe.title}{' '}
<DropdownButton
bsSize="small"
id={'ddbtn-tab-' + i}
+ className="ddbtn-tab"
title=""
>
<MenuItem eventKey="1" onClick={this.removeQueryEditor.bind(this, qe)}>
- <i className="fa fa-close" /> {t('close tab')}
+ <div className="icon-container">
+ <i className="fa fa-close" />
+ </div>
+ {t('Close tab')}
</MenuItem>
<MenuItem eventKey="2" onClick={this.renameTab.bind(this, qe)}>
- <i className="fa fa-i-cursor" /> {t('rename tab')}
+ <div className="icon-container">
+ <i className="fa fa-i-cursor" />
+ </div>
+ {t('Rename tab')}
</MenuItem>
- {qe &&
- <CopyQueryTabUrl queryEditor={qe} />
- }
+ {qe && <CopyQueryTabUrl queryEditor={qe} />}
<MenuItem eventKey="4" onClick={this.toggleLeftBar.bind(this)}>
- <i className="fa fa-cogs" />
-
- {this.state.hideLeftBar ? t('expand tool bar') : t('hide tool bar')}
+ <div className="icon-container">
+ <i className="fa fa-cogs" />
+ </div>
+ {this.state.hideLeftBar ? t('Expand tool bar') : t('Hide tool bar')}
</MenuItem>
</DropdownButton>
</div>
);
return (
- <Tab
- key={qe.id}
- title={tabTitle}
- eventKey={qe.id}
- >
+ <Tab key={qe.id} title={tabTitle} eventKey={qe.id}>
<div className="panel panel-default">
<div className="panel-body">
- {isSelected &&
+ {isSelected && (
<SqlEditor
getHeight={this.props.getHeight}
- tables={this.props.tables.filter(xt => (xt.queryEditorId === qe.id))}
+ tables={this.props.tables.filter(xt => xt.queryEditorId === qe.id)}
queryEditor={qe}
editorQueries={this.state.queriesArray}
dataPreviewQueries={this.state.dataPreviewQueries}
@@ -204,10 +210,11 @@ class TabbedSqlEditors extends React.PureComponent {
actions={this.props.actions}
hideLeftBar={this.state.hideLeftBar}
/>
- }
+ )}
</div>
</div>
- </Tab>);
+ </Tab>
+ );
});
return (
<Tabs
@@ -221,7 +228,8 @@ class TabbedSqlEditors extends React.PureComponent {
title={
<div>
<i className="fa fa-plus-circle" />
- </div>}
+ </div>
+ }
eventKey="add_tab"
/>
</Tabs>
@@ -231,14 +239,14 @@ class TabbedSqlEditors extends React.PureComponent {
TabbedSqlEditors.propTypes = propTypes;
TabbedSqlEditors.defaultProps = defaultProps;
-function mapStateToProps(state) {
+function mapStateToProps({ sqlLab }) {
return {
- databases: state.databases,
- queryEditors: state.queryEditors,
- queries: state.queries,
- tabHistory: state.tabHistory,
- tables: state.tables,
- defaultDbId: state.defaultDbId,
+ databases: sqlLab.databases,
+ queryEditors: sqlLab.queryEditors,
+ queries: sqlLab.queries,
+ tabHistory: sqlLab.tabHistory,
+ tables: sqlLab.tables,
+ defaultDbId: sqlLab.defaultDbId,
};
}
function mapDispatchToProps(dispatch) {
@@ -248,4 +256,8 @@ function mapDispatchToProps(dispatch) {
}
export { TabbedSqlEditors };
-export default connect(mapStateToProps, mapDispatchToProps)(TabbedSqlEditors);
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps,
+)(TabbedSqlEditors);
diff --git a/superset/assets/src/SqlLab/components/TableElement.jsx b/superset/assets/src/SqlLab/components/TableElement.jsx
index 624a0ed1c7..b1e53a9c74 100644
--- a/superset/assets/src/SqlLab/components/TableElement.jsx
+++ b/superset/assets/src/SqlLab/components/TableElement.jsx
@@ -165,10 +165,10 @@ class TableElement extends React.PureComponent {
className="table-name"
onClick={(e) => { this.toggleTable(e); }}
>
- <strong>{table.name}</strong>
- <small className="m-l-5">
+ <small className="m-r-5">
<i className={`fa fa-${table.expanded ? 'minus' : 'plus'}-square-o`} />
</small>
+ <strong>`{table.name}`</strong>
</a>
</div>
<div className="pull-right">
@@ -197,11 +197,10 @@ class TableElement extends React.PureComponent {
>
<div>
{this.renderWell()}
- <div className="table-columns">
+ <div className="table-columns m-t-5">
{cols && cols.map(col => (
<ColumnElement column={col} key={col.name} />
))}
- <hr />
</div>
</div>
</Collapse>
@@ -217,7 +216,7 @@ class TableElement extends React.PureComponent {
transitionAppear
onExited={this.removeFromStore.bind(this)}
>
- <div className="TableElement">
+ <div className="TableElement m-b-10">
{this.renderHeader()}
<div>
{this.renderBody()}
diff --git a/superset/assets/src/SqlLab/components/TemplateParamsEditor.jsx b/superset/assets/src/SqlLab/components/TemplateParamsEditor.jsx
index d22b9abfae..8a3387ad54 100644
--- a/superset/assets/src/SqlLab/components/TemplateParamsEditor.jsx
+++ b/superset/assets/src/SqlLab/components/TemplateParamsEditor.jsx
@@ -65,7 +65,7 @@ export default class TemplateParamsEditor extends React.Component {
(example: <code>{'{"my_table": "foo"}'}</code>),
and they become available
in your SQL (example: <code>SELECT * FROM {'{{ my_table }}'} </code>)
- by using
+ by using
<a
href="http://superset.apache.org/sqllab.html#templating-with-jinja"
target="_blank"
diff --git a/superset/assets/src/SqlLab/components/VisualizeModal.jsx b/superset/assets/src/SqlLab/components/VisualizeModal.jsx
index 14ba0c0e0b..c02656e088 100644
--- a/superset/assets/src/SqlLab/components/VisualizeModal.jsx
+++ b/superset/assets/src/SqlLab/components/VisualizeModal.jsx
@@ -1,4 +1,4 @@
-/* global notify */
+/* eslint no-undef: 2 */
import moment from 'moment';
import React from 'react';
import PropTypes from 'prop-types';
@@ -168,13 +168,13 @@ class VisualizeModal extends React.PureComponent {
if (mainGroupBy) {
formData.groupby = [mainGroupBy.name];
}
- notify.info(t('Creating a data source and popping a new tab'));
+ this.props.actions.addInfoToast(t('Creating a data source and creating a new tab'));
// open new window for data visualization
exportChart(formData);
})
.fail(() => {
- notify.error(this.props.errorMessage);
+ this.props.actions.addDangerToast(this.props.errorMessage);
});
}
changeDatasourceName(event) {
@@ -295,11 +295,11 @@ class VisualizeModal extends React.PureComponent {
VisualizeModal.propTypes = propTypes;
VisualizeModal.defaultProps = defaultProps;
-function mapStateToProps(state) {
+function mapStateToProps({ sqlLab }) {
return {
- datasource: state.datasource,
- errorMessage: state.errorMessage,
- timeout: state.common ? state.common.conf.SUPERSET_WEBSERVER_TIMEOUT : null,
+ datasource: sqlLab.datasource,
+ errorMessage: sqlLab.errorMessage,
+ timeout: sqlLab.common ? sqlLab.common.conf.SUPERSET_WEBSERVER_TIMEOUT : null,
};
}
diff --git a/superset/assets/src/SqlLab/getInitialState.js b/superset/assets/src/SqlLab/getInitialState.js
new file mode 100644
index 0000000000..9452ac570c
--- /dev/null
+++ b/superset/assets/src/SqlLab/getInitialState.js
@@ -0,0 +1,33 @@
+/* eslint no-undef: 2 */
+import shortid from 'shortid';
+import { t } from '../locales';
+import getToastsFromPyFlashMessages from '../messageToasts/utils/getToastsFromPyFlashMessages';
+
+export default function getInitialState({ defaultDbId, ...restBootstrapData }) {
+ const defaultQueryEditor = {
+ id: shortid.generate(),
+ title: t('Untitled Query'),
+ sql: 'SELECT *\nFROM\nWHERE',
+ selectedText: null,
+ latestQueryId: null,
+ autorun: false,
+ dbId: defaultDbId,
+ };
+
+ return {
+ sqlLab: {
+ alerts: [],
+ queries: {},
+ databases: {},
+ queryEditors: [defaultQueryEditor],
+ tabHistory: [defaultQueryEditor.id],
+ tables: [],
+ queriesLastUpdate: 0,
+ activeSouthPaneTab: 'Results',
+ ...restBootstrapData,
+ },
+ messageToasts: getToastsFromPyFlashMessages(
+ (restBootstrapData.common || {}).flash_messages || [],
+ ),
+ };
+}
diff --git a/superset/assets/src/SqlLab/index.jsx b/superset/assets/src/SqlLab/index.jsx
index 4e2eae898c..24983de9bc 100644
--- a/superset/assets/src/SqlLab/index.jsx
+++ b/superset/assets/src/SqlLab/index.jsx
@@ -4,7 +4,8 @@ import { createStore, compose, applyMiddleware } from 'redux';
import { Provider } from 'react-redux';
import thunkMiddleware from 'redux-thunk';
-import { getInitialState, sqlLabReducer } from './reducers';
+import getInitialState from './getInitialState';
+import rootReducer from './reducers';
import { initEnhancer } from '../reduxUtils';
import { initJQueryAjax } from '../modules/utils';
import App from './components/App';
@@ -19,13 +20,21 @@ initJQueryAjax();
const appContainer = document.getElementById('app');
const bootstrapData = JSON.parse(appContainer.getAttribute('data-bootstrap'));
-const state = Object.assign({}, getInitialState(bootstrapData.defaultDbId), bootstrapData);
+const state = getInitialState(bootstrapData);
const store = createStore(
- sqlLabReducer, state, compose(applyMiddleware(thunkMiddleware), initEnhancer()));
+ rootReducer,
+ state,
+ compose(
+ applyMiddleware(thunkMiddleware),
+ initEnhancer(),
+ ),
+);
// jquery hack to highlight the navbar menu
-$('a:contains("SQL Lab")').parent().addClass('active');
+$('a:contains("SQL Lab")')
+ .parent()
+ .addClass('active');
render(
<Provider store={store}>
diff --git a/superset/assets/src/SqlLab/main.less b/superset/assets/src/SqlLab/main.less
index df3ba9b95e..9ed25be8d3 100644
--- a/superset/assets/src/SqlLab/main.less
+++ b/superset/assets/src/SqlLab/main.less
@@ -45,7 +45,7 @@ body {
left: 0px;
right: 0px;
bottom: 0px;
- overflow: scroll;
+ overflow: auto;
margin-right: 0px;
margin-bottom: 0px;
}
@@ -248,6 +248,13 @@ div.tablePopover:hover {
min-width: 100%;
width: inherit;
}
+.Select-clear {
+ margin-top: -2px;
+}
+.Select-arrow {
+ margin-top: 5px;
+}
+
.ace_content {
background-color: #f4f4f4;
}
@@ -266,6 +273,10 @@ div.tablePopover:hover {
padding: 5px 10px;
}
+.TableElement .ws-el-controls {
+ margin-right: -.3em;
+}
+
.QueryTable .label {
display: inline-block;
}
@@ -302,13 +313,25 @@ a.Link {
margin-top: 10px;
position: absolute;
width: 100%;
- overflow: scroll;
+ overflow: auto;
}
.nav-tabs > li.active > a,
.nav-tabs > li.active > a:hover,
.nav-tabs > li.active > a:focus {
padding-bottom: 8px;
}
+.nav-tabs .dropdown-toggle.btn .caret {
+ margin-top: -12px;
+}
+.nav-tabs .ddbtn-tab {
+ margin-left: 5px;
+ padding-right: 0;
+}
+.icon-container {
+ display: inline-block;
+ width: 30px;
+ text-align: center;
+}
.search-date-filter-container {
display: flex;
diff --git a/superset/assets/src/SqlLab/reducers.js b/superset/assets/src/SqlLab/reducers.js
index 9c67f3822c..ec880278f9 100644
--- a/superset/assets/src/SqlLab/reducers.js
+++ b/superset/assets/src/SqlLab/reducers.js
@@ -1,34 +1,20 @@
+import { combineReducers } from 'redux';
import shortid from 'shortid';
+import messageToasts from '../messageToasts/reducers';
+
import * as actions from './actions';
import { now } from '../modules/dates';
-import { addToObject, alterInObject, alterInArr, removeFromArr, getFromArr, addToArr }
- from '../reduxUtils';
+import {
+ addToObject,
+ alterInObject,
+ alterInArr,
+ removeFromArr,
+ getFromArr,
+ addToArr,
+} from '../reduxUtils';
import { t } from '../locales';
-export function getInitialState(defaultDbId) {
- const defaultQueryEditor = {
- id: shortid.generate(),
- title: t('Untitled Query'),
- sql: 'SELECT *\nFROM\nWHERE',
- selectedText: null,
- latestQueryId: null,
- autorun: false,
- dbId: defaultDbId,
- };
-
- return {
- alerts: [],
- queries: {},
- databases: {},
- queryEditors: [defaultQueryEditor],
- tabHistory: [defaultQueryEditor.id],
- tables: [],
- queriesLastUpdate: 0,
- activeSouthPaneTab: 'Results',
- };
-}
-
-export const sqlLabReducer = function (state, action) {
+export const sqlLabReducer = function (state = {}, action) {
const actionHandlers = {
[actions.ADD_QUERY_EDITOR]() {
const tabHistory = state.tabHistory.slice();
@@ -95,7 +81,7 @@ export const sqlLabReducer = function (state, action) {
at.id = shortid.generate();
// for new table, associate Id of query for data preview
at.dataPreviewQueryId = null;
- let newState = addToArr(state, 'tables', at);
+ let newState = addToArr(state, 'tables', at, true);
if (action.query) {
newState = alterInArr(newState, 'tables', at, { dataPreviewQueryId: action.query.id });
}
@@ -181,7 +167,12 @@ export const sqlLabReducer = function (state, action) {
if (action.query.state === 'stopped') {
return state;
}
- const alts = { state: 'failed', errorMessage: action.msg, endDttm: now() };
+ const alts = {
+ state: 'failed',
+ errorMessage: action.msg,
+ endDttm: now(),
+ link: action.link,
+ };
return alterInObject(state, 'queries', action.query, alts);
},
[actions.SET_ACTIVE_QUERY_EDITOR]() {
@@ -220,9 +211,6 @@ export const sqlLabReducer = function (state, action) {
[actions.QUERY_EDITOR_PERSIST_HEIGHT]() {
return alterInArr(state, 'queryEditors', action.queryEditor, { height: action.currentHeight });
},
- [actions.ADD_ALERT]() {
- return addToArr(state, 'alerts', action.alert);
- },
[actions.SET_DATABASES]() {
const databases = {};
action.databases.forEach((db) => {
@@ -230,9 +218,6 @@ export const sqlLabReducer = function (state, action) {
});
return Object.assign({}, state, { databases });
},
- [actions.REMOVE_ALERT]() {
- return removeFromArr(state, 'alerts', action.alert);
- },
[actions.REFRESH_QUERIES]() {
let newQueries = Object.assign({}, state.queries);
// Fetch the updates to the queries present in the store.
@@ -279,3 +264,8 @@ export const sqlLabReducer = function (state, action) {
}
return state;
};
+
+export default combineReducers({
+ sqlLab: sqlLabReducer,
+ messageToasts,
+});
diff --git a/superset/assets/src/chart/Chart.jsx b/superset/assets/src/chart/Chart.jsx
index f223174efa..ed49eea684 100644
--- a/superset/assets/src/chart/Chart.jsx
+++ b/superset/assets/src/chart/Chart.jsx
@@ -1,23 +1,22 @@
-/* eslint camelcase: 0 */
+/* eslint no-undef: 2 */
import React from 'react';
import PropTypes from 'prop-types';
-import Mustache from 'mustache';
import { Tooltip } from 'react-bootstrap';
import { d3format } from '../modules/utils';
import ChartBody from './ChartBody';
import Loading from '../components/Loading';
-import { Logger, LOG_ACTIONS_RENDER_EVENT } from '../logger';
+import { Logger, LOG_ACTIONS_RENDER_CHART } from '../logger';
import StackTraceMessage from '../components/StackTraceMessage';
import RefreshChartOverlay from '../components/RefreshChartOverlay';
-import visMap from '../visualizations';
+import visPromiseLookup from '../visualizations';
import sandboxedEval from '../modules/sandbox';
import './chart.css';
const propTypes = {
annotationData: PropTypes.object,
actions: PropTypes.object,
- chartKey: PropTypes.string.isRequired,
+ chartId: PropTypes.number.isRequired,
containerId: PropTypes.string.isRequired,
datasource: PropTypes.object.isRequired,
formData: PropTypes.object.isRequired,
@@ -42,8 +41,6 @@ const propTypes = {
// dashboard callbacks
addFilter: PropTypes.func,
getFilters: PropTypes.func,
- clearFilter: PropTypes.func,
- removeFilter: PropTypes.func,
onQuery: PropTypes.func,
onDismissRefreshOverlay: PropTypes.func,
};
@@ -51,14 +48,16 @@ const propTypes = {
const defaultProps = {
addFilter: () => ({}),
getFilters: () => ({}),
- clearFilter: () => ({}),
- removeFilter: () => ({}),
};
class Chart extends React.PureComponent {
constructor(props) {
super(props);
- this.state = {};
+ // visualizations are lazy-loaded with promises that resolve to a renderVis function
+ this.state = {
+ renderVis: null,
+ };
+
// these properties are used by visualizations
this.annotationData = props.annotationData;
this.containerId = props.containerId;
@@ -67,20 +66,26 @@ class Chart extends React.PureComponent {
this.datasource = props.datasource;
this.addFilter = this.addFilter.bind(this);
this.getFilters = this.getFilters.bind(this);
- this.clearFilter = this.clearFilter.bind(this);
- this.removeFilter = this.removeFilter.bind(this);
this.headerHeight = this.headerHeight.bind(this);
this.height = this.height.bind(this);
this.width = this.width.bind(this);
+ this.visPromise = null;
}
componentDidMount() {
if (this.props.triggerQuery) {
- this.props.actions.runQuery(this.props.formData, false,
+ this.props.actions.runQuery(
+ this.props.formData,
+ false,
this.props.timeout,
- this.props.chartKey,
+ this.props.chartId,
);
+ } else {
+ // when drag/dropping in a dashboard, a chart may be unmounted/remounted but still have data
+ this.renderVis();
}
+
+ this.loadAsyncVis(this.props.vizType);
}
componentWillReceiveProps(nextProps) {
@@ -89,23 +94,31 @@ class Chart extends React.PureComponent {
this.selector = `#${this.containerId}`;
this.formData = nextProps.formData;
this.datasource = nextProps.datasource;
+ if (nextProps.vizType !== this.props.vizType) {
+ this.setState(() => ({ renderVis: null }));
+ this.loadAsyncVis(nextProps.vizType);
+ }
}
componentDidUpdate(prevProps) {
if (
- this.props.queryResponse &&
- ['success', 'rendered'].indexOf(this.props.chartStatus) > -1 &&
- !this.props.queryResponse.error && (
- prevProps.annotationData !== this.props.annotationData ||
+ this.props.queryResponse &&
+ ['success', 'rendered'].indexOf(this.props.chartStatus) > -1 &&
+ !this.props.queryResponse.error &&
+ (prevProps.annotationData !== this.props.annotationData ||
prevProps.queryResponse !== this.props.queryResponse ||
prevProps.height !== this.props.height ||
prevProps.width !== this.props.width ||
prevProps.lastRendered !== this.props.lastRendered)
) {
- this.renderViz();
+ this.renderVis();
}
}
+ componentWillUnmount() {
+ this.visPromise = null;
+ }
+
getFilters() {
return this.props.getFilters();
}
@@ -114,16 +127,24 @@ class Chart extends React.PureComponent {
this.setState({ tooltip });
}
- addFilter(col, vals, merge = true, refresh = true) {
- this.props.addFilter(col, vals, merge, refresh);
- }
+ loadAsyncVis(visType) {
+ this.visPromise = visPromiseLookup[visType];
- clearFilter() {
- this.props.clearFilter();
+ this.visPromise()
+ .then((renderVis) => {
+ // ensure Component is still mounted
+ if (this.visPromise) {
+ this.setState({ renderVis }, this.renderVis);
+ }
+ })
+ .catch((error) => {
+ console.warn(error); // eslint-disable-line
+ this.props.actions.chartRenderingFailed(error, this.props.chartId);
+ });
}
- removeFilter(col, vals, refresh = true) {
- this.props.removeFilter(col, vals, refresh);
+ addFilter(col, vals, merge = true, refresh = true) {
+ this.props.addFilter(col, vals, merge, refresh);
}
clearError() {
@@ -131,7 +152,9 @@ class Chart extends React.PureComponent {
}
width() {
- return this.props.width || this.container.el.offsetWidth;
+ return (
+ this.props.width || (this.container && this.container.el && this.container.el.offsetWidth)
+ );
}
headerHeight() {
@@ -139,7 +162,9 @@ class Chart extends React.PureComponent {
}
height() {
- return this.props.height || this.container.el.offsetHeight;
+ return (
+ this.props.height || (this.container && this.container.el && this.container.el.offsetHeight)
+ );
}
d3format(col, number) {
@@ -150,24 +175,15 @@ class Chart extends React.PureComponent {
}
error(e) {
- this.props.actions.chartRenderingFailed(e, this.props.chartKey);
+ this.props.actions.chartRenderingFailed(e, this.props.chartId);
}
verboseMetricName(metric) {
return this.props.datasource.verbose_map[metric] || metric;
}
- render_template(s) {
- const context = {
- width: this.width(),
- height: this.height(),
- };
- return Mustache.render(s, context);
- }
-
renderTooltip() {
if (this.state.tooltip) {
- /* eslint-disable react/no-danger */
return (
<Tooltip
className="chart-tooltip"
@@ -177,77 +193,94 @@ class Chart extends React.PureComponent {
positionLeft={this.state.tooltip.x + 30}
arrowOffsetTop={10}
>
- <div dangerouslySetInnerHTML={{ __html: this.state.tooltip.content }} />
+ <div // eslint-disable-next-line react/no-danger
+ dangerouslySetInnerHTML={{ __html: this.state.tooltip.content }}
+ />
</Tooltip>
);
- /* eslint-enable react/no-danger */
}
return null;
}
- renderViz() {
- const viz = visMap[this.props.vizType];
- const fd = this.props.formData;
- const qr = this.props.queryResponse;
- const renderStart = Logger.getTimestamp();
- try {
- // Executing user-defined data mutator function
- if (fd.js_data) {
- qr.data = sandboxedEval(fd.js_data)(qr.data);
+ renderVis() {
+ const { chartStatus } = this.props;
+ const hasVisPromise = !!this.state.renderVis;
+ // check that we have the render function and data
+ if (hasVisPromise && ['success', 'rendered'].indexOf(chartStatus) > -1) {
+ const { vizType, formData, queryResponse, setControlValue, chartId } = this.props;
+ const renderStart = Logger.getTimestamp();
+
+ try {
+ // Executing user-defined data mutator function
+ if (formData.js_data) {
+ queryResponse.data = sandboxedEval(formData.js_data)(queryResponse.data);
+ }
+ // [re]rendering the visualization
+ this.state.renderVis(this, queryResponse, setControlValue);
+
+ if (chartStatus !== 'rendered') {
+ this.props.actions.chartRenderingSucceeded(chartId);
+ }
+
+ Logger.append(LOG_ACTIONS_RENDER_CHART, {
+ slice_id: chartId,
+ viz_type: vizType,
+ start_offset: renderStart,
+ duration: Logger.getTimestamp() - renderStart,
+ });
+ } catch (e) {
+ console.warn(e); // eslint-disable-line
+ this.props.actions.chartRenderingFailed(e, chartId);
}
- // [re]rendering the visualization
- viz(this, qr, this.props.setControlValue);
- Logger.append(LOG_ACTIONS_RENDER_EVENT, {
- label: this.props.chartKey,
- vis_type: this.props.vizType,
- start_offset: renderStart,
- duration: Logger.getTimestamp() - renderStart,
- });
- this.props.actions.chartRenderingSucceeded(this.props.chartKey);
- } catch (e) {
- this.props.actions.chartRenderingFailed(e, this.props.chartKey);
}
}
render() {
- const isLoading = this.props.chartStatus === 'loading';
+ const isLoading = this.props.chartStatus === 'loading' || !this.state.renderVis;
+
+ // this allows <Loading /> to be positioned in the middle of the chart
+ const containerStyles = isLoading ? { height: this.height(), width: this.width() } : null;
return (
- <div className={`token col-md-12 ${isLoading ? 'is-loading' : ''}`}>
+ <div className={`chart-container ${isLoading ? 'is-loading' : ''}`} style={containerStyles}>
{this.renderTooltip()}
- {isLoading &&
- <Loading size={25} />
- }
- {this.props.chartAlert &&
- <StackTraceMessage
- message={this.props.chartAlert}
- queryResponse={this.props.queryResponse}
- />
- }
+
+ {isLoading && <Loading size={50} />}
+
+ {this.props.chartAlert && (
+ <StackTraceMessage
+ message={this.props.chartAlert}
+ queryResponse={this.props.queryResponse}
+ />
+ )}
{!isLoading &&
!this.props.chartAlert &&
this.props.refreshOverlayVisible &&
!this.props.errorMessage &&
- this.container &&
- <RefreshChartOverlay
- height={this.height()}
- width={this.width()}
- onQuery={this.props.onQuery}
- onDismiss={this.props.onDismissRefreshOverlay}
- />
- }
- {!isLoading && !this.props.chartAlert &&
- <ChartBody
- containerId={this.containerId}
- vizType={this.props.vizType}
- height={this.height}
- width={this.width}
- faded={this.props.refreshOverlayVisible && !this.props.errorMessage}
- ref={(inner) => {
- this.container = inner;
- }}
- />
- }
+ this.container && (
+ <RefreshChartOverlay
+ height={this.height()}
+ width={this.width()}
+ onQuery={this.props.onQuery}
+ onDismiss={this.props.onDismissRefreshOverlay}
+ />
+ )}
+
+ {!isLoading &&
+ !this.props.chartAlert && (
+ <ChartBody
+ containerId={this.containerId}
+ vizType={this.props.vizType}
+ height={this.height}
+ width={this.width}
+ faded={
+ this.props.refreshOverlayVisible && !this.props.errorMessage
+ }
+ ref={(inner) => {
+ this.container = inner;
+ }}
+ />
+ )}
</div>
);
}
diff --git a/superset/assets/src/chart/ChartContainer.jsx b/superset/assets/src/chart/ChartContainer.jsx
index b731412fc5..b66fe5d017 100644
--- a/superset/assets/src/chart/ChartContainer.jsx
+++ b/superset/assets/src/chart/ChartContainer.jsx
@@ -1,29 +1,13 @@
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
-import * as Actions from './chartAction';
+import * as actions from './chartAction';
import Chart from './Chart';
-function mapStateToProps({ charts }, ownProps) {
- const chart = charts[ownProps.chartKey];
- return {
- annotationData: chart.annotationData,
- chartAlert: chart.chartAlert,
- chartStatus: chart.chartStatus,
- chartUpdateEndTime: chart.chartUpdateEndTime,
- chartUpdateStartTime: chart.chartUpdateStartTime,
- latestQueryFormData: chart.latestQueryFormData,
- lastRendered: chart.lastRendered,
- queryResponse: chart.queryResponse,
- queryRequest: chart.queryRequest,
- triggerQuery: chart.triggerQuery,
- };
-}
-
function mapDispatchToProps(dispatch) {
return {
- actions: bindActionCreators(Actions, dispatch),
+ actions: bindActionCreators(actions, dispatch),
};
}
-export default connect(mapStateToProps, mapDispatchToProps)(Chart);
+export default connect(null, mapDispatchToProps)(Chart);
diff --git a/superset/assets/src/chart/chartAction.js b/superset/assets/src/chart/chartAction.js
index f1112f5a8d..9a5a40da82 100644
--- a/superset/assets/src/chart/chartAction.js
+++ b/superset/assets/src/chart/chartAction.js
@@ -1,10 +1,10 @@
import { getExploreUrlAndPayload, getAnnotationJsonUrl } from '../explore/exploreUtils';
import { requiresQuery, ANNOTATION_SOURCE_TYPES } from '../modules/AnnotationTypes';
-import { Logger, LOG_ACTIONS_LOAD_EVENT } from '../logger';
+import { Logger, LOG_ACTIONS_LOAD_CHART } from '../logger';
import { COMMON_ERR_MESSAGES } from '../common';
import { t } from '../locales';
-const $ = window.$ = require('jquery');
+const $ = (window.$ = require('jquery'));
export const CHART_UPDATE_STARTED = 'CHART_UPDATE_STARTED';
export function chartUpdateStarted(queryRequest, latestQueryFormData, key) {
@@ -70,11 +70,13 @@ export function runAnnotationQuery(annotation, timeout = 60, formData = null, ke
return Promise.resolve();
}
- const sliceFormData = Object.keys(annotation.overrides)
- .reduce((d, k) => ({
+ const sliceFormData = Object.keys(annotation.overrides).reduce(
+ (d, k) => ({
...d,
[k]: annotation.overrides[k] || fd[k],
- }), {});
+ }),
+ {},
+ );
const isNative = annotation.sourceType === ANNOTATION_SOURCE_TYPES.NATIVE;
const url = getAnnotationJsonUrl(annotation.value, sliceFormData, isNative);
const queryRequest = $.ajax({
@@ -113,6 +115,11 @@ export function updateQueryFormData(value, key) {
return { type: UPDATE_QUERY_FORM_DATA, value, key };
}
+export const ADD_CHART = 'ADD_CHART';
+export function addChart(chart, key) {
+ return { type: ADD_CHART, chart, key };
+}
+
export const RUN_QUERY = 'RUN_QUERY';
export function runQuery(formData, force = false, timeout = 60, key) {
return (dispatch) => {
@@ -134,19 +141,22 @@ export function runQuery(formData, force = false, timeout = 60, key) {
const queryPromise = Promise.resolve(dispatch(chartUpdateStarted(queryRequest, payload, key)))
.then(() => queryRequest)
.then((queryResponse) => {
- Logger.append(LOG_ACTIONS_LOAD_EVENT, {
- label: key,
+ Logger.append(LOG_ACTIONS_LOAD_CHART, {
+ slice_id: key,
is_cached: queryResponse.is_cached,
+ force_refresh: force,
row_count: queryResponse.rowcount,
datasource: formData.datasource,
start_offset: logStart,
duration: Logger.getTimestamp() - logStart,
+ has_extra_filters: formData.extra_filters && formData.extra_filters.length > 0,
+ viz_type: formData.viz_type,
});
return dispatch(chartUpdateSucceeded(queryResponse, key));
})
.catch((err) => {
- Logger.append(LOG_ACTIONS_LOAD_EVENT, {
- label: key,
+ Logger.append(LOG_ACTIONS_LOAD_CHART, {
+ slice_id: key,
has_err: true,
datasource: formData.datasource,
start_offset: logStart,
@@ -186,3 +196,12 @@ export function runQuery(formData, force = false, timeout = 60, key) {
]);
};
}
+
+export function refreshChart(chart, force, timeout) {
+ return (dispatch) => {
+ if (!chart.latestQueryFormData || Object.keys(chart.latestQueryFormData).length === 0) {
+ return;
+ }
+ dispatch(runQuery(chart.latestQueryFormData, force, timeout, chart.id));
+ };
+}
diff --git a/superset/assets/src/chart/chartReducer.js b/superset/assets/src/chart/chartReducer.js
index f68a5b80ee..ea8de8b54d 100644
--- a/superset/assets/src/chart/chartReducer.js
+++ b/superset/assets/src/chart/chartReducer.js
@@ -1,25 +1,10 @@
/* eslint camelcase: 0 */
-import PropTypes from 'prop-types';
-
import { now } from '../modules/dates';
import * as actions from './chartAction';
import { t } from '../locales';
-export const chartPropType = {
- chartKey: PropTypes.string.isRequired,
- chartAlert: PropTypes.string,
- chartStatus: PropTypes.string,
- chartUpdateEndTime: PropTypes.number,
- chartUpdateStartTime: PropTypes.number,
- latestQueryFormData: PropTypes.object,
- queryRequest: PropTypes.object,
- queryResponse: PropTypes.object,
- triggerQuery: PropTypes.bool,
- lastRendered: PropTypes.number,
-};
-
export const chart = {
- chartKey: '',
+ id: 0,
chartAlert: null,
chartStatus: 'loading',
chartUpdateEndTime: null,
@@ -33,6 +18,12 @@ export const chart = {
export default function chartReducer(charts = {}, action) {
const actionHandlers = {
+ [actions.ADD_CHART]() {
+ return {
+ ...chart,
+ ...action.chart,
+ };
+ },
[actions.CHART_UPDATE_SUCCEEDED](state) {
return { ...state,
chartStatus: 'success',
@@ -70,12 +61,12 @@ export default function chartReducer(charts = {}, action) {
return { ...state,
chartStatus: 'failed',
chartAlert: (
- `${t('Query timeout')} - ` +
- t(`visualization queries are set to timeout at ${action.timeout} seconds. `) +
- t('Perhaps your data has grown, your database is under unusual load, ' +
- 'or you are simply querying a data source that is too large ' +
- 'to be processed within the timeout range. ' +
- 'If that is the case, we recommend that you summarize your data further.')),
+ `${t('Query timeout')} - ` +
+ t(`visualization queries are set to timeout at ${action.timeout} seconds. `) +
+ t('Perhaps your data has grown, your database is under unusual load, ' +
+ 'or you are simply querying a data source that is too large ' +
+ 'to be processed within the timeout range. ' +
+ 'If that is the case, we recommend that you summarize your data further.')),
};
},
[actions.CHART_UPDATE_FAILED](state) {
@@ -151,7 +142,10 @@ export default function chartReducer(charts = {}, action) {
}
if (action.type in actionHandlers) {
- return { ...charts, [action.key]: actionHandlers[action.type](charts[action.key], action) };
+ return {
+ ...charts,
+ [action.key]: actionHandlers[action.type](charts[action.key], action),
+ };
}
return charts;
diff --git a/superset/assets/src/components/ActionMenuItem.jsx b/superset/assets/src/components/ActionMenuItem.jsx
new file mode 100644
index 0000000000..e6c44478b6
--- /dev/null
+++ b/superset/assets/src/components/ActionMenuItem.jsx
@@ -0,0 +1,65 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { MenuItem } from 'react-bootstrap';
+
+import InfoTooltipWithTrigger from './InfoTooltipWithTrigger';
+
+export function MenuItemContent({ faIcon, text, tooltip, children }) {
+ return (
+ <span>
+ {faIcon && <i className={`fa fa-${faIcon}`}> </i>}
+ {text} {''}
+ <InfoTooltipWithTrigger
+ tooltip={tooltip}
+ label={faIcon ? `dash-${faIcon}` : ''}
+ placement="top"
+ />
+ {children}
+ </span>
+ );
+}
+
+MenuItemContent.propTypes = {
+ faIcon: PropTypes.string,
+ text: PropTypes.string,
+ tooltip: PropTypes.string,
+ children: PropTypes.node,
+};
+
+MenuItemContent.defaultProps = {
+ faIcon: '',
+ text: '',
+ tooltip: null,
+ children: null,
+};
+
+export function ActionMenuItem({
+ onClick,
+ href,
+ target,
+ text,
+ tooltip,
+ children,
+ faIcon,
+}) {
+ return (
+ <MenuItem onClick={onClick} href={href} target={target}>
+ <MenuItemContent faIcon={faIcon} text={text} tooltip={tooltip}>
+ {children}
+ </MenuItemContent>
+ </MenuItem>
+ );
+}
+
+ActionMenuItem.propTypes = {
+ onClick: PropTypes.func,
+ href: PropTypes.string,
+ target: PropTypes.string,
+ ...MenuItemContent.propTypes,
+};
+
+ActionMenuItem.defaultProps = {
+ onClick() {},
+ href: null,
+ target: null,
+};
diff --git a/superset/assets/src/components/AlertsWrapper.jsx b/superset/assets/src/components/AlertsWrapper.jsx
deleted file mode 100644
index 672c56d59d..0000000000
--- a/superset/assets/src/components/AlertsWrapper.jsx
+++ /dev/null
@@ -1,38 +0,0 @@
-/* global notify */
-import React from 'react';
-import AlertContainer from 'react-alert';
-import PropTypes from 'prop-types';
-
-const propTypes = {
- initMessages: PropTypes.array,
-};
-const defaultProps = {
- initMessages: [],
-};
-
-export default class AlertsWrapper extends React.PureComponent {
- componentDidMount() {
- this.props.initMessages.forEach((msg) => {
- if (['info', 'error', 'success'].indexOf(msg[0]) >= 0) {
- notify[msg[0]](msg[1]);
- } else {
- notify.show(msg[1]);
- }
- });
- }
- render() {
- return (
- <AlertContainer
- ref={(ref) => {
- global.notify = ref;
- }}
- offset={14}
- position="top right"
- theme="dark"
- time={5000}
- transition="fade"
- />);
- }
-}
-AlertsWrapper.propTypes = propTypes;
-AlertsWrapper.defaultProps = defaultProps;
diff --git a/superset/assets/src/components/AlteredSliceTag.jsx b/superset/assets/src/components/AlteredSliceTag.jsx
index 209e4a94bd..a317a041a6 100644
--- a/superset/assets/src/components/AlteredSliceTag.jsx
+++ b/superset/assets/src/components/AlteredSliceTag.jsx
@@ -42,6 +42,10 @@ export default class AlteredSliceTag extends React.Component {
if (!ofd[fdKey] && !cfd[fdKey]) {
continue;
}
+ // Ignore obsolete legacy filters
+ if (['filters', 'having', 'having_filters', 'where'].includes(fdKey)) {
+ continue;
+ }
if (!isEqual(ofd[fdKey], cfd[fdKey])) {
diffs[fdKey] = { before: ofd[fdKey], after: cfd[fdKey] };
}
@@ -56,13 +60,15 @@ export default class AlteredSliceTag extends React.Component {
return 'N/A';
} else if (value === null) {
return 'null';
- } else if (controls[key] && controls[key].type === 'FilterControl') {
+ } else if (controls[key] && controls[key].type === 'AdhocFilterControl') {
if (!value.length) {
return '[]';
}
return value.map((v) => {
- const filterVal = v.val && v.val.constructor === Array ? `[${v.val.join(', ')}]` : v.val;
- return `${v.col} ${v.op} ${filterVal}`;
+ const filterVal = v.comparator && v.comparator.constructor === Array ?
+ `[${v.comparator.join(', ')}]` :
+ v.comparator;
+ return `${v.subject} ${v.operator} ${filterVal}`;
}).join(', ');
} else if (controls[key] && controls[key].type === 'BoundsControl') {
return `Min: ${value[0]}, Max: ${value[1]}`;
diff --git a/superset/assets/src/components/EditableTitle.jsx b/superset/assets/src/components/EditableTitle.jsx
index b773340846..45fea1dcb0 100644
--- a/superset/assets/src/components/EditableTitle.jsx
+++ b/superset/assets/src/components/EditableTitle.jsx
@@ -1,5 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
+import cx from 'classnames';
import TooltipWrapper from './TooltipWrapper';
import { t } from '../locales';
@@ -27,8 +28,10 @@ class EditableTitle extends React.PureComponent {
this.handleClick = this.handleClick.bind(this);
this.handleBlur = this.handleBlur.bind(this);
this.handleChange = this.handleChange.bind(this);
+ this.handleKeyUp = this.handleKeyUp.bind(this);
this.handleKeyPress = this.handleKeyPress.bind(this);
}
+
componentWillReceiveProps(nextProps) {
if (nextProps.title !== this.state.title) {
this.setState({
@@ -37,8 +40,9 @@ class EditableTitle extends React.PureComponent {
});
}
}
+
handleClick() {
- if (!this.props.canEdit) {
+ if (!this.props.canEdit || this.state.isEditing) {
return;
}
@@ -46,6 +50,7 @@ class EditableTitle extends React.PureComponent {
isEditing: true,
});
}
+
handleBlur() {
if (!this.props.canEdit) {
return;
@@ -67,9 +72,31 @@ class EditableTitle extends React.PureComponent {
this.setState({
lastTitle: this.state.title,
});
+ }
+
+ if (this.props.title !== this.state.title) {
this.props.onSaveTitle(this.state.title);
}
}
+
+ handleKeyUp(ev) {
+ // this entire method exists to support using EditableTitle as the title of a
+ // react-bootstrap Tab, as a workaround for this line in react-bootstrap https://goo.gl/ZVLmv4
+ //
+ // tl;dr when a Tab EditableTitle is being edited, typically the Tab it's within has been
+ // clicked and is focused/active. for accessibility, when focused the Tab <a /> intercepts
+ // the ' ' key (among others, including all arrows) and onChange() doesn't fire. somehow
+ // keydown is still called so we can detect this and manually add a ' ' to the current title
+ if (ev.key === ' ') {
+ let title = ev.target.value;
+ const titleLength = (title || '').length;
+ if (title && title[titleLength - 1] !== ' ') {
+ title = `${title} `;
+ this.setState(() => ({ title }));
+ }
+ }
+ }
+
handleChange(ev) {
if (!this.props.canEdit) {
return;
@@ -79,6 +106,7 @@ class EditableTitle extends React.PureComponent {
title: ev.target.value,
});
}
+
handleKeyPress(ev) {
if (ev.key === 'Enter') {
ev.preventDefault();
@@ -86,12 +114,14 @@ class EditableTitle extends React.PureComponent {
this.handleBlur();
}
}
+
render() {
- let input = (
+ let content = (
<input
required
type={this.state.isEditing ? 'text' : 'button'}
value={this.state.title}
+ onKeyUp={this.handleKeyUp}
onChange={this.handleChange}
onBlur={this.handleBlur}
onClick={this.handleClick}
@@ -99,18 +129,26 @@ class EditableTitle extends React.PureComponent {
/>
);
if (this.props.showTooltip) {
- input = (
+ content = (
<TooltipWrapper
label="title"
tooltip={this.props.canEdit ? t('click to edit title') :
this.props.noPermitTooltip || t('You don\'t have the rights to alter this title.')}
>
- {input}
+ {content}
</TooltipWrapper>
);
}
return (
- <span className="editable-title">{input}</span>
+ <span
+ className={cx(
+ 'editable-title',
+ this.props.canEdit && 'editable-title--editable',
+ this.state.isEditing && 'editable-title--editing',
+ )}
+ >
+ {content}
+ </span>
);
}
}
diff --git a/superset/assets/src/components/Loading.jsx b/superset/assets/src/components/Loading.jsx
index 416e770295..0cfeaf1096 100644
--- a/superset/assets/src/components/Loading.jsx
+++ b/superset/assets/src/components/Loading.jsx
@@ -5,24 +5,28 @@ const propTypes = {
size: PropTypes.number,
};
const defaultProps = {
- size: 25,
+ size: 50,
};
-export default function Loading(props) {
+export default function Loading({ size }) {
return (
<img
className="loading"
alt="Loading..."
src="/static/assets/images/loading.gif"
style={{
- width: props.size,
- height: props.size,
+ width: Math.min(size, 50),
+ // height is auto
padding: 0,
margin: 0,
position: 'absolute',
+ left: '50%',
+ top: '50%',
+ transform: 'translate(-50%, -50%)',
}}
/>
);
}
+
Loading.propTypes = propTypes;
Loading.defaultProps = defaultProps;
diff --git a/superset/assets/src/components/MetricOption.jsx b/superset/assets/src/components/MetricOption.jsx
index 81838f0d0a..77596ea0aa 100644
--- a/superset/assets/src/components/MetricOption.jsx
+++ b/superset/assets/src/components/MetricOption.jsx
@@ -17,7 +17,7 @@ const defaultProps = {
};
export default function MetricOption({ metric, openInNewWindow, showFormula, showType, url }) {
- const verbose = metric.verbose_name || metric.metric_name;
+ const verbose = metric.verbose_name || metric.metric_name || metric.label;
const link = url ? <a href={url} target={openInNewWindow ? '_blank' : null}>{verbose}</a> : verbose;
return (
<div>
diff --git a/superset/assets/src/components/StackTraceMessage.jsx b/superset/assets/src/components/StackTraceMessage.jsx
index ee190b7c5c..71b0972c25 100644
--- a/superset/assets/src/components/StackTraceMessage.jsx
+++ b/superset/assets/src/components/StackTraceMessage.jsx
@@ -25,6 +25,10 @@ class StackTraceMessage extends React.PureComponent {
return this.props.queryResponse && this.props.queryResponse.stacktrace;
}
+ hasLink() {
+ return this.props.queryResponse && this.props.queryResponse.link;
+ }
+
render() {
return (
<div className={`stack-trace-container${this.hasTrace() ? ' has-trace' : ''}`}>
@@ -33,6 +37,9 @@ class StackTraceMessage extends React.PureComponent {
onClick={() => this.setState({ showStackTrace: !this.state.showStackTrace })}
>
{this.props.message}
+ {this.hasLink() &&
+ <a href={this.props.queryResponse.link}> (Request Access) </a>
+ }
</Alert>
{this.hasTrace() &&
<Collapse in={this.state.showStackTrace}>
diff --git a/superset/assets/src/dashboard/.eslintrc b/superset/assets/src/dashboard/.eslintrc
new file mode 100644
index 0000000000..a3f86e3a17
--- /dev/null
+++ b/superset/assets/src/dashboard/.eslintrc
@@ -0,0 +1,33 @@
+{
+ "extends": "prettier",
+ "plugins": ["prettier"],
+ "rules": {
+ "prefer-template": 2,
+ "new-cap": 2,
+ "no-restricted-syntax": 2,
+ "guard-for-in": 2,
+ "prefer-arrow-callback": 2,
+ "func-names": 2,
+ "react/jsx-no-bind": 2,
+ "no-confusing-arrow": 2,
+ "jsx-a11y/no-static-element-interactions": 2,
+ "jsx-a11y/anchor-has-content": 2,
+ "react/require-default-props": 2,
+ "no-plusplus": 2,
+ "no-mixed-operators": 0,
+ "no-continue": 2,
+ "no-bitwise": 2,
+ "no-undef": 2,
+ "no-multi-assign": 2,
+ "no-restricted-properties": 2,
+ "no-prototype-builtins": 2,
+ "jsx-a11y/href-no-hash": 2,
+ "class-methods-use-this": 2,
+ "import/no-named-as-default": 2,
+ "import/prefer-default-export": 2,
+ "react/no-unescaped-entities": 2,
+ "react/no-string-refs": 2,
+ "react/jsx-indent": 0,
+ "prettier/prettier": "error"
+ }
+}
diff --git a/superset/assets/src/dashboard/.prettierrc b/superset/assets/src/dashboard/.prettierrc
new file mode 100644
index 0000000000..a20502b7f0
--- /dev/null
+++ b/superset/assets/src/dashboard/.prettierrc
@@ -0,0 +1,4 @@
+{
+ "singleQuote": true,
+ "trailingComma": "all"
+}
diff --git a/superset/assets/src/dashboard/actions.js b/superset/assets/src/dashboard/actions.js
deleted file mode 100644
index c7f1a6aa26..0000000000
--- a/superset/assets/src/dashboard/actions.js
+++ /dev/null
@@ -1,127 +0,0 @@
-/* global notify */
-import $ from 'jquery';
-import { getExploreUrlAndPayload } from '../explore/exploreUtils';
-
-export const ADD_FILTER = 'ADD_FILTER';
-export function addFilter(sliceId, col, vals, merge = true, refresh = true) {
- return { type: ADD_FILTER, sliceId, col, vals, merge, refresh };
-}
-
-export const CLEAR_FILTER = 'CLEAR_FILTER';
-export function clearFilter(sliceId) {
- return { type: CLEAR_FILTER, sliceId };
-}
-
-export const REMOVE_FILTER = 'REMOVE_FILTER';
-export function removeFilter(sliceId, col, vals, refresh = true) {
- return { type: REMOVE_FILTER, sliceId, col, vals, refresh };
-}
-
-export const UPDATE_DASHBOARD_LAYOUT = 'UPDATE_DASHBOARD_LAYOUT';
-export function updateDashboardLayout(layout) {
- return { type: UPDATE_DASHBOARD_LAYOUT, layout };
-}
-
-export const UPDATE_DASHBOARD_TITLE = 'UPDATE_DASHBOARD_TITLE';
-export function updateDashboardTitle(title) {
- return { type: UPDATE_DASHBOARD_TITLE, title };
-}
-
-export function addSlicesToDashboard(dashboardId, sliceIds) {
- return () => (
- $.ajax({
- type: 'POST',
- url: `/superset/add_slices/${dashboardId}/`,
- data: {
- data: JSON.stringify({ slice_ids: sliceIds }),
- },
- })
- .done(() => {
- // Refresh page to allow for slices to re-render
- window.location.reload();
- })
- );
-}
-
-export const REMOVE_SLICE = 'REMOVE_SLICE';
-export function removeSlice(slice) {
- return { type: REMOVE_SLICE, slice };
-}
-
-export const UPDATE_SLICE_NAME = 'UPDATE_SLICE_NAME';
-export function updateSliceName(slice, sliceName) {
- return { type: UPDATE_SLICE_NAME, slice, sliceName };
-}
-export function saveSlice(slice, sliceName) {
- const oldName = slice.slice_name;
- return (dispatch) => {
- const sliceParams = {};
- sliceParams.slice_id = slice.slice_id;
- sliceParams.action = 'overwrite';
- sliceParams.slice_name = sliceName;
-
- const { url, payload } = getExploreUrlAndPayload({
- formData: slice.form_data,
- endpointType: 'base',
- force: false,
- curUrl: null,
- requestParams: sliceParams,
- });
- return $.ajax({
- url,
- type: 'POST',
- data: {
- form_data: JSON.stringify(payload),
- },
- success: () => {
- dispatch(updateSliceName(slice, sliceName));
- notify.success('This slice name was saved successfully.');
- },
- error: () => {
- // if server-side reject the overwrite action,
- // revert to old state
- dispatch(updateSliceName(slice, oldName));
- notify.error("You don't have the rights to alter this slice");
- },
- });
- };
-}
-
-const FAVESTAR_BASE_URL = '/superset/favstar/Dashboard';
-export const TOGGLE_FAVE_STAR = 'TOGGLE_FAVE_STAR';
-export function toggleFaveStar(isStarred) {
- return { type: TOGGLE_FAVE_STAR, isStarred };
-}
-
-export const FETCH_FAVE_STAR = 'FETCH_FAVE_STAR';
-export function fetchFaveStar(id) {
- return function (dispatch) {
- const url = `${FAVESTAR_BASE_URL}/${id}/count`;
- return $.get(url)
- .done((data) => {
- if (data.count > 0) {
- dispatch(toggleFaveStar(true));
- }
- });
- };
-}
-
-export const SAVE_FAVE_STAR = 'SAVE_FAVE_STAR';
-export function saveFaveStar(id, isStarred) {
- return function (dispatch) {
- const urlSuffix = isStarred ? 'unselect' : 'select';
- const url = `${FAVESTAR_BASE_URL}/${id}/${urlSuffix}/`;
- $.get(url);
- dispatch(toggleFaveStar(!isStarred));
- };
-}
-
-export const TOGGLE_EXPAND_SLICE = 'TOGGLE_EXPAND_SLICE';
-export function toggleExpandSlice(slice, isExpanded) {
- return { type: TOGGLE_EXPAND_SLICE, slice, isExpanded };
-}
-
-export const SET_EDIT_MODE = 'SET_EDIT_MODE';
-export function setEditMode(editMode) {
- return { type: SET_EDIT_MODE, editMode };
-}
diff --git a/superset/assets/src/dashboard/actions/dashboardLayout.js b/superset/assets/src/dashboard/actions/dashboardLayout.js
new file mode 100644
index 0000000000..149ead7fc9
--- /dev/null
+++ b/superset/assets/src/dashboard/actions/dashboardLayout.js
@@ -0,0 +1,203 @@
+import { ActionCreators as UndoActionCreators } from 'redux-undo';
+
+import { addInfoToast } from '../../messageToasts/actions';
+import { setUnsavedChanges } from './dashboardState';
+import { TABS_TYPE, ROW_TYPE } from '../util/componentTypes';
+import {
+ DASHBOARD_ROOT_ID,
+ NEW_COMPONENTS_SOURCE_ID,
+ DASHBOARD_HEADER_ID,
+} from '../util/constants';
+import dropOverflowsParent from '../util/dropOverflowsParent';
+import findParentId from '../util/findParentId';
+
+// this is a helper that takes an action as input and dispatches
+// an additional setUnsavedChanges(true) action after the dispatch in the case
+// that dashboardState.hasUnsavedChanges is false.
+function setUnsavedChangesAfterAction(action) {
+ return (...args) => (dispatch, getState) => {
+ const result = action(...args);
+ if (typeof result === 'function') {
+ dispatch(result(dispatch, getState));
+ } else {
+ dispatch(result);
+ }
+
+ if (!getState().dashboardState.hasUnsavedChanges) {
+ dispatch(setUnsavedChanges(true));
+ }
+ };
+}
+
+// Component CRUD -------------------------------------------------------------
+export const UPDATE_COMPONENTS = 'UPDATE_COMPONENTS';
+
+export const updateComponents = setUnsavedChangesAfterAction(
+ nextComponents => ({
+ type: UPDATE_COMPONENTS,
+ payload: {
+ nextComponents,
+ },
+ }),
+);
+
+export function updateDashboardTitle(text) {
+ return (dispatch, getState) => {
+ const { dashboardLayout } = getState();
+ dispatch(
+ updateComponents({
+ [DASHBOARD_HEADER_ID]: {
+ ...dashboardLayout.present[DASHBOARD_HEADER_ID],
+ meta: {
+ text,
+ },
+ },
+ }),
+ );
+ };
+}
+
+export const DELETE_COMPONENT = 'DELETE_COMPONENT';
+export const deleteComponent = setUnsavedChangesAfterAction((id, parentId) => ({
+ type: DELETE_COMPONENT,
+ payload: {
+ id,
+ parentId,
+ },
+}));
+
+export const CREATE_COMPONENT = 'CREATE_COMPONENT';
+export const createComponent = setUnsavedChangesAfterAction(dropResult => ({
+ type: CREATE_COMPONENT,
+ payload: {
+ dropResult,
+ },
+}));
+
+// Tabs -----------------------------------------------------------------------
+export const CREATE_TOP_LEVEL_TABS = 'CREATE_TOP_LEVEL_TABS';
+export const createTopLevelTabs = setUnsavedChangesAfterAction(dropResult => ({
+ type: CREATE_TOP_LEVEL_TABS,
+ payload: {
+ dropResult,
+ },
+}));
+
+export const DELETE_TOP_LEVEL_TABS = 'DELETE_TOP_LEVEL_TABS';
+export const deleteTopLevelTabs = setUnsavedChangesAfterAction(() => ({
+ type: DELETE_TOP_LEVEL_TABS,
+ payload: {},
+}));
+
+// Resize ---------------------------------------------------------------------
+export const RESIZE_COMPONENT = 'RESIZE_COMPONENT';
+export function resizeComponent({ id, width, height }) {
+ return (dispatch, getState) => {
+ const { dashboardLayout: undoableLayout } = getState();
+ const { present: dashboard } = undoableLayout;
+ const component = dashboard[id];
+ const widthChanged = width && component.meta.width !== width;
+ const heightChanged = height && component.meta.height !== height;
+ if (component && (widthChanged || heightChanged)) {
+ // update the size of this component
+ const updatedComponents = {
+ [id]: {
+ ...component,
+ meta: {
+ ...component.meta,
+ width: width || component.meta.width,
+ height: height || component.meta.height,
+ },
+ },
+ };
+
+ dispatch(updateComponents(updatedComponents));
+ }
+ };
+}
+
+// Drag and drop --------------------------------------------------------------
+export const MOVE_COMPONENT = 'MOVE_COMPONENT';
+const moveComponent = setUnsavedChangesAfterAction(dropResult => ({
+ type: MOVE_COMPONENT,
+ payload: {
+ dropResult,
+ },
+}));
+
+export const HANDLE_COMPONENT_DROP = 'HANDLE_COMPONENT_DROP';
+export function handleComponentDrop(dropResult) {
+ return (dispatch, getState) => {
+ const overflowsParent = dropOverflowsParent(
+ dropResult,
+ getState().dashboardLayout.present,
+ );
+
+ if (overflowsParent) {
+ return dispatch(
+ addInfoToast(
+ `There is not enough space for this component. Try decreasing its width, or increasing the destination width.`,
+ ),
+ );
+ }
+
+ const { source, destination } = dropResult;
+ const droppedOnRoot = destination && destination.id === DASHBOARD_ROOT_ID;
+ const isNewComponent = source.id === NEW_COMPONENTS_SOURCE_ID;
+
+ if (droppedOnRoot) {
+ dispatch(createTopLevelTabs(dropResult));
+ } else if (destination && isNewComponent) {
+ dispatch(createComponent(dropResult));
+ } else if (
+ destination &&
+ source &&
+ !// ensure it has moved
+ (destination.id === source.id && destination.index === source.index)
+ ) {
+ dispatch(moveComponent(dropResult));
+ }
+
+ const { dashboardLayout: undoableLayout } = getState();
+
+ // if we moved a child from a Tab or Row parent and it was the only child, delete the parent.
+ if (!isNewComponent) {
+ const { present: layout } = undoableLayout;
+ const sourceComponent = layout[source.id];
+ if (
+ (sourceComponent.type === TABS_TYPE ||
+ sourceComponent.type === ROW_TYPE) &&
+ sourceComponent.children &&
+ sourceComponent.children.length === 0
+ ) {
+ const parentId = findParentId({
+ childId: source.id,
+ layout,
+ });
+ dispatch(deleteComponent(source.id, parentId));
+ }
+ }
+
+ return null;
+ };
+}
+
+// Undo redo ------------------------------------------------------------------
+export function undoLayoutAction() {
+ return (dispatch, getState) => {
+ dispatch(UndoActionCreators.undo());
+
+ const { dashboardLayout, dashboardState } = getState();
+
+ if (
+ dashboardLayout.past.length === 0 &&
+ !dashboardState.maxUndoHistoryExceeded
+ ) {
+ dispatch(setUnsavedChanges(false));
+ }
+ };
+}
+
+export const redoLayoutAction = setUnsavedChangesAfterAction(
+ UndoActionCreators.redo,
+);
diff --git a/superset/assets/src/dashboard/actions/dashboardState.js b/superset/assets/src/dashboard/actions/dashboardState.js
new file mode 100644
index 0000000000..17f6d46da0
--- /dev/null
+++ b/superset/assets/src/dashboard/actions/dashboardState.js
@@ -0,0 +1,261 @@
+/* eslint camelcase: 0 */
+import $ from 'jquery';
+import { ActionCreators as UndoActionCreators } from 'redux-undo';
+
+import { addChart, removeChart, refreshChart } from '../../chart/chartAction';
+import { chart as initChart } from '../../chart/chartReducer';
+import { fetchDatasourceMetadata } from '../../dashboard/actions/datasources';
+import { applyDefaultFormData } from '../../explore/store';
+import { getAjaxErrorMsg } from '../../modules/utils';
+import {
+ Logger,
+ LOG_ACTIONS_CHANGE_DASHBOARD_FILTER,
+ LOG_ACTIONS_REFRESH_DASHBOARD,
+} from '../../logger';
+import { SAVE_TYPE_OVERWRITE } from '../util/constants';
+import { t } from '../../locales';
+
+import {
+ addSuccessToast,
+ addWarningToast,
+ addDangerToast,
+} from '../../messageToasts/actions';
+
+export const SET_UNSAVED_CHANGES = 'SET_UNSAVED_CHANGES';
+export function setUnsavedChanges(hasUnsavedChanges) {
+ return { type: SET_UNSAVED_CHANGES, payload: { hasUnsavedChanges } };
+}
+
+export const CHANGE_FILTER = 'CHANGE_FILTER';
+export function changeFilter(chart, col, vals, merge = true, refresh = true) {
+ Logger.append(LOG_ACTIONS_CHANGE_DASHBOARD_FILTER, {
+ id: chart.id,
+ column: col,
+ value_count: Array.isArray(vals) ? vals.length : (vals && 1) || 0,
+ merge,
+ refresh,
+ });
+ return { type: CHANGE_FILTER, chart, col, vals, merge, refresh };
+}
+
+export const ADD_SLICE = 'ADD_SLICE';
+export function addSlice(slice) {
+ return { type: ADD_SLICE, slice };
+}
+
+export const REMOVE_SLICE = 'REMOVE_SLICE';
+export function removeSlice(sliceId) {
+ return { type: REMOVE_SLICE, sliceId };
+}
+
+const FAVESTAR_BASE_URL = '/superset/favstar/Dashboard';
+export const TOGGLE_FAVE_STAR = 'TOGGLE_FAVE_STAR';
+export function toggleFaveStar(isStarred) {
+ return { type: TOGGLE_FAVE_STAR, isStarred };
+}
+
+export const FETCH_FAVE_STAR = 'FETCH_FAVE_STAR';
+export function fetchFaveStar(id) {
+ return function fetchFaveStarThunk(dispatch) {
+ const url = `${FAVESTAR_BASE_URL}/${id}/count`;
+ return $.get(url).done(data => {
+ if (data.count > 0) {
+ dispatch(toggleFaveStar(true));
+ }
+ });
+ };
+}
+
+export const SAVE_FAVE_STAR = 'SAVE_FAVE_STAR';
+export function saveFaveStar(id, isStarred) {
+ return function saveFaveStarThunk(dispatch) {
+ const urlSuffix = isStarred ? 'unselect' : 'select';
+ const url = `${FAVESTAR_BASE_URL}/${id}/${urlSuffix}/`;
+ $.get(url);
+ dispatch(toggleFaveStar(!isStarred));
+ };
+}
+
+export const TOGGLE_EXPAND_SLICE = 'TOGGLE_EXPAND_SLICE';
+export function toggleExpandSlice(sliceId) {
+ return { type: TOGGLE_EXPAND_SLICE, sliceId };
+}
+
+export const UPDATE_CSS = 'UPDATE_CSS';
+export function updateCss(css) {
+ return { type: UPDATE_CSS, css };
+}
+
+export const SET_EDIT_MODE = 'SET_EDIT_MODE';
+export function setEditMode(editMode) {
+ return { type: SET_EDIT_MODE, editMode };
+}
+
+export const ON_CHANGE = 'ON_CHANGE';
+export function onChange() {
+ return { type: ON_CHANGE };
+}
+
+export const ON_SAVE = 'ON_SAVE';
+export function onSave() {
+ return { type: ON_SAVE };
+}
+
+export function saveDashboardRequestSuccess() {
+ return dispatch => {
+ dispatch(onSave());
+ // clear layout undo history
+ dispatch(UndoActionCreators.clearHistory());
+ };
+}
+
+export function saveDashboardRequest(data, id, saveType) {
+ const path = saveType === SAVE_TYPE_OVERWRITE ? 'save_dash' : 'copy_dash';
+ const url = `/superset/${path}/${id}/`;
+ return dispatch =>
+ $.ajax({
+ type: 'POST',
+ url,
+ data: {
+ data: JSON.stringify(data),
+ },
+ success: () => {
+ dispatch(saveDashboardRequestSuccess());
+ dispatch(addSuccessToast(t('This dashboard was saved successfully.')));
+ },
+ error: error => {
+ const errorMsg = getAjaxErrorMsg(error);
+ dispatch(
+ addDangerToast(
+ `${t('Sorry, there was an error saving this dashboard: ')}
+ ${errorMsg}`,
+ ),
+ );
+ },
+ });
+}
+
+export function fetchCharts(chartList = [], force = false, interval = 0) {
+ return (dispatch, getState) => {
+ Logger.append(LOG_ACTIONS_REFRESH_DASHBOARD, {
+ force,
+ interval,
+ chartCount: chartList.length,
+ });
+ const timeout = getState().dashboardInfo.common.conf
+ .SUPERSET_WEBSERVER_TIMEOUT;
+ if (!interval) {
+ chartList.forEach(chart => dispatch(refreshChart(chart, force, timeout)));
+ return;
+ }
+
+ const { metadata: meta } = getState().dashboardInfo;
+ const refreshTime = Math.max(interval, meta.stagger_time || 5000); // default 5 seconds
+ if (typeof meta.stagger_refresh !== 'boolean') {
+ meta.stagger_refresh =
+ meta.stagger_refresh === undefined
+ ? true
+ : meta.stagger_refresh === 'true';
+ }
+ const delay = meta.stagger_refresh
+ ? refreshTime / (chartList.length - 1)
+ : 0;
+ chartList.forEach((chart, i) => {
+ setTimeout(
+ () => dispatch(refreshChart(chart, force, timeout)),
+ delay * i,
+ );
+ });
+ };
+}
+
+let refreshTimer = null;
+export function startPeriodicRender(interval) {
+ const stopPeriodicRender = () => {
+ if (refreshTimer) {
+ clearTimeout(refreshTimer);
+ refreshTimer = null;
+ }
+ };
+
+ return (dispatch, getState) => {
+ stopPeriodicRender();
+
+ const { metadata } = getState().dashboardInfo;
+ const immune = metadata.timed_refresh_immune_slices || [];
+ const refreshAll = () => {
+ const affected = Object.values(getState().charts).filter(
+ chart => immune.indexOf(chart.id) === -1,
+ );
+ return dispatch(fetchCharts(affected, true, interval * 0.2));
+ };
+ const fetchAndRender = () => {
+ refreshAll();
+ if (interval > 0) {
+ refreshTimer = setTimeout(fetchAndRender, interval);
+ }
+ };
+
+ fetchAndRender();
+ };
+}
+
+export const TOGGLE_BUILDER_PANE = 'TOGGLE_BUILDER_PANE';
+export function toggleBuilderPane() {
+ return { type: TOGGLE_BUILDER_PANE };
+}
+
+export function addSliceToDashboard(id) {
+ return (dispatch, getState) => {
+ const { sliceEntities } = getState();
+ const selectedSlice = sliceEntities.slices[id];
+ if (!selectedSlice) {
+ return dispatch(
+ addWarningToast(
+ 'Sorry, there is no chart definition associated with the chart trying to be added.',
+ ),
+ );
+ }
+ const form_data = selectedSlice.form_data;
+ const newChart = {
+ ...initChart,
+ id,
+ form_data,
+ formData: applyDefaultFormData(form_data),
+ };
+
+ return Promise.all([
+ dispatch(addChart(newChart, id)),
+ dispatch(fetchDatasourceMetadata(form_data.datasource)),
+ ]).then(() => dispatch(addSlice(selectedSlice)));
+ };
+}
+
+export function removeSliceFromDashboard(id) {
+ return dispatch => {
+ dispatch(removeSlice(id));
+ dispatch(removeChart(id));
+ };
+}
+
+// Undo history ---------------------------------------------------------------
+export const SET_MAX_UNDO_HISTORY_EXCEEDED = 'SET_MAX_UNDO_HISTORY_EXCEEDED';
+export function setMaxUndoHistoryExceeded(maxUndoHistoryExceeded = true) {
+ return {
+ type: SET_MAX_UNDO_HISTORY_EXCEEDED,
+ payload: { maxUndoHistoryExceeded },
+ };
+}
+
+export function maxUndoHistoryToast() {
+ return (dispatch, getState) => {
+ const { dashboardLayout } = getState();
+ const historyLength = dashboardLayout.past.length;
+
+ return dispatch(
+ addWarningToast(
+ `You have used all ${historyLength} undo slots and will not be able to fully undo subsequent actions. You may save your current state to reset the history.`,
+ ),
+ );
+ };
+}
diff --git a/superset/assets/src/dashboard/actions/datasources.js b/superset/assets/src/dashboard/actions/datasources.js
new file mode 100644
index 0000000000..d97296e9e8
--- /dev/null
+++ b/superset/assets/src/dashboard/actions/datasources.js
@@ -0,0 +1,36 @@
+import $ from 'jquery';
+
+export const SET_DATASOURCE = 'SET_DATASOURCE';
+export function setDatasource(datasource, key) {
+ return { type: SET_DATASOURCE, datasource, key };
+}
+
+export const FETCH_DATASOURCE_STARTED = 'FETCH_DATASOURCE_STARTED';
+export function fetchDatasourceStarted(key) {
+ return { type: FETCH_DATASOURCE_STARTED, key };
+}
+
+export const FETCH_DATASOURCE_FAILED = 'FETCH_DATASOURCE_FAILED';
+export function fetchDatasourceFailed(error, key) {
+ return { type: FETCH_DATASOURCE_FAILED, error, key };
+}
+
+export function fetchDatasourceMetadata(key) {
+ return (dispatch, getState) => {
+ const { datasources } = getState();
+ const datasource = datasources[key];
+
+ if (datasource) {
+ return dispatch(setDatasource(datasource, key));
+ }
+
+ const url = `/superset/fetch_datasource_metadata?datasourceKey=${key}`;
+ return $.ajax({
+ type: 'GET',
+ url,
+ success: data => dispatch(setDatasource(data, key)),
+ error: error =>
+ dispatch(fetchDatasourceFailed(error.responseJSON.error, key)),
+ });
+ };
+}
diff --git a/superset/assets/src/dashboard/actions/sliceEntities.js b/superset/assets/src/dashboard/actions/sliceEntities.js
new file mode 100644
index 0000000000..516a5144d1
--- /dev/null
+++ b/superset/assets/src/dashboard/actions/sliceEntities.js
@@ -0,0 +1,73 @@
+/* eslint camelcase: 0 */
+import $ from 'jquery';
+
+import { getDatasourceParameter } from '../../modules/utils';
+
+export const SET_ALL_SLICES = 'SET_ALL_SLICES';
+export function setAllSlices(slices) {
+ return { type: SET_ALL_SLICES, slices };
+}
+
+export const FETCH_ALL_SLICES_STARTED = 'FETCH_ALL_SLICES_STARTED';
+export function fetchAllSlicesStarted() {
+ return { type: FETCH_ALL_SLICES_STARTED };
+}
+
+export const FETCH_ALL_SLICES_FAILED = 'FETCH_ALL_SLICES_FAILED';
+export function fetchAllSlicesFailed(error) {
+ return { type: FETCH_ALL_SLICES_FAILED, error };
+}
+
+export function fetchAllSlices(userId) {
+ return (dispatch, getState) => {
+ const { sliceEntities } = getState();
+ if (sliceEntities.lastUpdated === 0) {
+ dispatch(fetchAllSlicesStarted());
+
+ const uri = `/sliceaddview/api/read?_flt_0_created_by=${userId}`;
+ return $.ajax({
+ url: uri,
+ type: 'GET',
+ success: response => {
+ const slices = {};
+ response.result.forEach(slice => {
+ let form_data = JSON.parse(slice.params);
+ let datasource = form_data.datasource;
+ if (!datasource) {
+ datasource = getDatasourceParameter(
+ slice.datasource_id,
+ slice.datasource_type,
+ );
+ form_data = {
+ ...form_data,
+ datasource,
+ };
+ }
+ if (['markup', 'separator'].indexOf(slice.viz_type) === -1) {
+ slices[slice.id] = {
+ slice_id: slice.id,
+ slice_url: slice.slice_url,
+ slice_name: slice.slice_name,
+ edit_url: slice.edit_url,
+ form_data,
+ datasource_name: slice.datasource_name_text,
+ datasource_link: slice.datasource_link,
+ changed_on: new Date(slice.changed_on).getTime(),
+ description: slice.description,
+ description_markdown: slice.description_markeddown,
+ viz_type: slice.viz_type,
+ modified: slice.modified
+ ? slice.modified.replace(/<[^>]*>/g, '')
+ : '',
+ };
+ }
+ });
+ return dispatch(setAllSlices(slices));
+ },
+ error: error => dispatch(fetchAllSlicesFailed(error)),
+ });
+ }
+
+ return dispatch(setAllSlices(sliceEntities.slices));
+ };
+}
diff --git a/superset/assets/src/dashboard/components/AddSliceCard.jsx b/superset/assets/src/dashboard/components/AddSliceCard.jsx
new file mode 100644
index 0000000000..c8266ad162
--- /dev/null
+++ b/superset/assets/src/dashboard/components/AddSliceCard.jsx
@@ -0,0 +1,61 @@
+import cx from 'classnames';
+import React from 'react';
+import PropTypes from 'prop-types';
+import { t } from '../../locales';
+
+const propTypes = {
+ datasourceLink: PropTypes.string,
+ innerRef: PropTypes.func,
+ isSelected: PropTypes.bool,
+ lastModified: PropTypes.string.isRequired,
+ sliceName: PropTypes.string.isRequired,
+ style: PropTypes.object,
+ visType: PropTypes.string.isRequired,
+};
+
+const defaultProps = {
+ datasourceLink: '—',
+ innerRef: null,
+ isSelected: false,
+ style: null,
+};
+
+function AddSliceCard({
+ datasourceLink,
+ innerRef,
+ isSelected,
+ lastModified,
+ sliceName,
+ style,
+ visType,
+}) {
+ return (
+ <div ref={innerRef} className="chart-card-container" style={style}>
+ <div className={cx('chart-card', isSelected && 'is-selected')}>
+ <div className="card-title">{sliceName}</div>
+ <div className="card-body">
+ <div className="item">
+ <span>Modified </span>
+ <span>{lastModified}</span>
+ </div>
+ <div className="item">
+ <span>Visualization </span>
+ <span>{visType}</span>
+ </div>
+ <div className="item">
+ <span>Data source </span>
+ <span // eslint-disable-next-line react/no-danger
+ dangerouslySetInnerHTML={{ __html: datasourceLink }}
+ />
+ </div>
+ </div>
+ </div>
+ {isSelected && <div className="is-added-label">{t('Added')}</div>}
+ </div>
+ );
+}
+
+AddSliceCard.propTypes = propTypes;
+AddSliceCard.defaultProps = defaultProps;
+
+export default AddSliceCard;
diff --git a/superset/assets/src/dashboard/components/BuilderComponentPane.jsx b/superset/assets/src/dashboard/components/BuilderComponentPane.jsx
new file mode 100644
index 0000000000..a21ec11de7
--- /dev/null
+++ b/superset/assets/src/dashboard/components/BuilderComponentPane.jsx
@@ -0,0 +1,127 @@
+/* eslint-env browser */
+import PropTypes from 'prop-types';
+import React from 'react';
+import cx from 'classnames';
+import { StickyContainer, Sticky } from 'react-sticky';
+import ParentSize from '@vx/responsive/build/components/ParentSize';
+
+import NewColumn from './gridComponents/new/NewColumn';
+import NewDivider from './gridComponents/new/NewDivider';
+import NewHeader from './gridComponents/new/NewHeader';
+import NewRow from './gridComponents/new/NewRow';
+import NewTabs from './gridComponents/new/NewTabs';
+import NewMarkdown from './gridComponents/new/NewMarkdown';
+import SliceAdder from '../containers/SliceAdder';
+import { t } from '../../locales';
+
+const SUPERSET_HEADER_HEIGHT = 59;
+
+const propTypes = {
+ topOffset: PropTypes.number,
+ toggleBuilderPane: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+ topOffset: 0,
+};
+
+class BuilderComponentPane extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = {
+ slideDirection: 'slide-out',
+ };
+
+ this.openSlicesPane = this.slide.bind(this, 'slide-in');
+ this.closeSlicesPane = this.slide.bind(this, 'slide-out');
+ }
+
+ slide(direction) {
+ this.setState({
+ slideDirection: direction,
+ });
+ }
+
+ render() {
+ const { topOffset } = this.props;
+ return (
+ <div
+ className="dashboard-builder-sidepane"
+ style={{
+ height: `calc(100vh - ${topOffset + SUPERSET_HEADER_HEIGHT}px)`,
+ }}
+ >
+ <ParentSize>
+ {({ height }) => (
+ <StickyContainer>
+ <Sticky topOffset={-topOffset} bottomOffset={Infinity}>
+ {({ style, isSticky }) => (
+ <div
+ className="viewport"
+ style={isSticky ? { ...style, top: topOffset } : null}
+ >
+ <div
+ className={cx(
+ 'slider-container',
+ this.state.slideDirection,
+ )}
+ >
+ <div className="component-layer slide-content">
+ <div className="dashboard-builder-sidepane-header">
+ <span>{t('Insert')}</span>
+ <i
+ className="fa fa-times trigger"
+ onClick={this.props.toggleBuilderPane}
+ role="none"
+ />
+ </div>
+ <div
+ className="new-component static"
+ role="none"
+ onClick={this.openSlicesPane}
+ >
+ <div className="new-component-placeholder fa fa-area-chart" />
+ <div className="new-component-label">
+ {t('Your charts & filters')}
+ </div>
+
+ <i className="fa fa-arrow-right trigger" />
+ </div>
+ <NewTabs />
+ <NewRow />
+ <NewColumn />
+ <NewHeader />
+ <NewMarkdown />
+ <NewDivider />
+ </div>
+ <div className="slices-layer slide-content">
+ <div
+ className="dashboard-builder-sidepane-header"
+ onClick={this.closeSlicesPane}
+ role="none"
+ >
+ <i className="fa fa-arrow-left trigger" />
+ <span>{t('All components')}</span>
+ </div>
+ <SliceAdder
+ height={
+ height + (isSticky ? SUPERSET_HEADER_HEIGHT : 0)
+ }
+ />
+ </div>
+ </div>
+ </div>
+ )}
+ </Sticky>
+ </StickyContainer>
+ )}
+ </ParentSize>
+ </div>
+ );
+ }
+}
+
+BuilderComponentPane.propTypes = propTypes;
+BuilderComponentPane.defaultProps = defaultProps;
+
+export default BuilderComponentPane;
diff --git a/superset/assets/src/dashboard/components/CodeModal.jsx b/superset/assets/src/dashboard/components/CodeModal.jsx
index 0e84ad1bab..cc0c9f2a41 100644
--- a/superset/assets/src/dashboard/components/CodeModal.jsx
+++ b/superset/assets/src/dashboard/components/CodeModal.jsx
@@ -12,13 +12,16 @@ const propTypes = {
const defaultProps = {
codeCallback: () => {},
+ code: '',
};
export default class CodeModal extends React.PureComponent {
constructor(props) {
super(props);
this.state = { code: props.code };
+ this.beforeOpen = this.beforeOpen.bind(this);
}
+
beforeOpen() {
let code = this.props.code;
if (!code && this.props.codeCallback) {
@@ -26,18 +29,17 @@ export default class CodeModal extends React.PureComponent {
}
this.setState({ code });
}
+
render() {
return (
<ModalTrigger
triggerNode={this.props.triggerNode}
isButton
- beforeOpen={this.beforeOpen.bind(this)}
+ beforeOpen={this.beforeOpen}
modalTitle={t('Active Dashboard Filters')}
modalBody={
<div className="CodeModal">
- <pre>
- {this.state.code}
- </pre>
+ <pre>{this.state.code}</pre>
</div>
}
/>
diff --git a/superset/assets/src/dashboard/components/Controls.jsx b/superset/assets/src/dashboard/components/Controls.jsx
deleted file mode 100644
index feacf961ba..0000000000
--- a/superset/assets/src/dashboard/components/Controls.jsx
+++ /dev/null
@@ -1,214 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { DropdownButton, MenuItem } from 'react-bootstrap';
-
-import CssEditor from './CssEditor';
-import RefreshIntervalModal from './RefreshIntervalModal';
-import SaveModal from './SaveModal';
-import SliceAdder from './SliceAdder';
-import { t } from '../../locales';
-import InfoTooltipWithTrigger from '../../components/InfoTooltipWithTrigger';
-
-const $ = window.$ = require('jquery');
-
-const propTypes = {
- dashboard: PropTypes.object.isRequired,
- filters: PropTypes.object.isRequired,
- slices: PropTypes.array,
- userId: PropTypes.string.isRequired,
- addSlicesToDashboard: PropTypes.func,
- onSave: PropTypes.func,
- onChange: PropTypes.func,
- renderSlices: PropTypes.func,
- serialize: PropTypes.func,
- startPeriodicRender: PropTypes.func,
- editMode: PropTypes.bool,
-};
-
-function MenuItemContent({ faIcon, text, tooltip, children }) {
- return (
- <span>
- <i className={`fa fa-${faIcon}`} /> {text} {''}
- <InfoTooltipWithTrigger
- tooltip={tooltip}
- label={`dash-${faIcon}`}
- placement="top"
- />
- {children}
- </span>
- );
-}
-MenuItemContent.propTypes = {
- faIcon: PropTypes.string.isRequired,
- text: PropTypes.string,
- tooltip: PropTypes.string,
- children: PropTypes.node,
-};
-
-function ActionMenuItem(props) {
- return (
- <MenuItem onClick={props.onClick}>
- <MenuItemContent {...props} />
- </MenuItem>
- );
-}
-ActionMenuItem.propTypes = {
- onClick: PropTypes.func,
-};
-
-class Controls extends React.PureComponent {
- constructor(props) {
- super(props);
- this.state = {
- css: props.dashboard.css || '',
- cssTemplates: [],
- };
- this.refresh = this.refresh.bind(this);
- this.toggleModal = this.toggleModal.bind(this);
- this.updateDom = this.updateDom.bind(this);
- }
- componentWillMount() {
- this.updateDom(this.state.css);
-
- $.get('/csstemplateasyncmodelview/api/read', (data) => {
- const cssTemplates = data.result.map(row => ({
- value: row.template_name,
- css: row.css,
- label: row.template_name,
- }));
- this.setState({ cssTemplates });
- });
- }
- refresh() {
- // Force refresh all slices
- this.props.renderSlices(true);
- }
- toggleModal(modal) {
- let currentModal;
- if (modal !== this.state.currentModal) {
- currentModal = modal;
- }
- this.setState({ currentModal });
- }
- changeCss(css) {
- this.setState({ css }, () => {
- this.updateDom(css);
- });
- this.props.onChange();
- }
- updateDom(css) {
- const className = 'CssEditor-css';
- const head = document.head || document.getElementsByTagName('head')[0];
- let style = document.querySelector('.' + className);
-
- if (!style) {
- style = document.createElement('style');
- style.className = className;
- style.type = 'text/css';
- head.appendChild(style);
- }
- if (style.styleSheet) {
- style.styleSheet.cssText = css;
- } else {
- style.innerHTML = css;
- }
- }
- render() {
- const { dashboard, userId, filters,
- addSlicesToDashboard, startPeriodicRender,
- serialize, onSave, editMode } = this.props;
- const emailBody = t('Checkout this dashboard: %s', window.location.href);
- const emailLink = 'mailto:?Subject=Superset%20Dashboard%20'
- + `${dashboard.dashboard_title}&Body=${emailBody}`;
- let saveText = t('Save as');
- if (editMode) {
- saveText = t('Save');
- }
- return (
- <span>
- <DropdownButton title="Actions" bsSize="small" id="bg-nested-dropdown" pullRight>
- <ActionMenuItem
- text={t('Force Refresh')}
- tooltip={t('Force refresh the whole dashboard')}
- faIcon="refresh"
- onClick={this.refresh}
- />
- <RefreshIntervalModal
- onChange={refreshInterval => startPeriodicRender(refreshInterval * 1000)}
- triggerNode={
- <MenuItemContent
- text={t('Set autorefresh')}
- tooltip={t('Set the auto-refresh interval for this session')}
- faIcon="clock-o"
- />
- }
- />
- {dashboard.dash_save_perm &&
- <SaveModal
- dashboard={dashboard}
- filters={filters}
- serialize={serialize}
- onSave={onSave}
- css={this.state.css}
- triggerNode={
- <MenuItemContent
- text={saveText}
- tooltip={t('Save the dashboard')}
- faIcon="save"
- />
- }
- />
- }
- {editMode &&
- <ActionMenuItem
- text={t('Edit properties')}
- tooltip={t("Edit the dashboards's properties")}
- faIcon="edit"
- onClick={() => { window.location = `/dashboardmodelview/edit/${dashboard.id}`; }}
- />
- }
- {editMode &&
- <ActionMenuItem
- text={t('Email')}
- tooltip={t('Email a link to this dashboard')}
- onClick={() => { window.location = emailLink; }}
- faIcon="envelope"
- />
- }
- {editMode &&
- <SliceAdder
- dashboard={dashboard}
- addSlicesToDashboard={addSlicesToDashboard}
- userId={userId}
- triggerNode={
- <MenuItemContent
- text={t('Add Slices')}
- tooltip={t('Add some slices to this dashboard')}
- faIcon="plus"
- />
- }
- />
- }
- {editMode &&
- <CssEditor
- dashboard={dashboard}
- triggerNode={
- <MenuItemContent
- text={t('Edit CSS')}
- tooltip={t('Change the style of the dashboard using CSS code')}
- faIcon="css3"
- />
- }
- initialCss={this.state.css}
- templates={this.state.cssTemplates}
- onChange={this.changeCss.bind(this)}
- />
- }
- </DropdownButton>
- </span>
- );
- }
-}
-Controls.propTypes = propTypes;
-
-export default Controls;
diff --git a/superset/assets/src/dashboard/components/CssEditor.jsx b/superset/assets/src/dashboard/components/CssEditor.jsx
index 5abf5f81b5..45ef86d63a 100644
--- a/superset/assets/src/dashboard/components/CssEditor.jsx
+++ b/superset/assets/src/dashboard/components/CssEditor.jsx
@@ -29,15 +29,20 @@ class CssEditor extends React.PureComponent {
css: props.initialCss,
cssTemplateOptions: [],
};
+ this.changeCss = this.changeCss.bind(this);
+ this.changeCssTemplate = this.changeCssTemplate.bind(this);
}
+
changeCss(css) {
this.setState({ css }, () => {
this.props.onChange(css);
});
}
+
changeCssTemplate(opt) {
this.changeCss(opt.css);
}
+
renderTemplateSelector() {
if (this.props.templates) {
return (
@@ -46,13 +51,14 @@ class CssEditor extends React.PureComponent {
<Select
options={this.props.templates}
placeholder={t('Load a CSS template')}
- onChange={this.changeCssTemplate.bind(this)}
+ onChange={this.changeCssTemplate}
/>
</div>
);
}
return null;
}
+
render() {
return (
<ModalTrigger
@@ -70,7 +76,7 @@ class CssEditor extends React.PureComponent {
theme="github"
minLines={8}
maxLines={30}
- onChange={this.changeCss.bind(this)}
+ onChange={this.changeCss}
height="200px"
width="100%"
editorProps={{ $blockScrolling: true }}
@@ -85,6 +91,7 @@ class CssEditor extends React.PureComponent {
);
}
}
+
CssEditor.propTypes = propTypes;
CssEditor.defaultProps = defaultProps;
diff --git a/superset/assets/src/dashboard/components/Dashboard.jsx b/superset/assets/src/dashboard/components/Dashboard.jsx
index 2a6a227997..2bb9b9ca20 100644
--- a/superset/assets/src/dashboard/components/Dashboard.jsx
+++ b/superset/assets/src/dashboard/components/Dashboard.jsx
@@ -1,351 +1,222 @@
+/* global window */
import React from 'react';
import PropTypes from 'prop-types';
-import AlertsWrapper from '../../components/AlertsWrapper';
-import GridLayout from './GridLayout';
-import Header from './Header';
-import { exportChart } from '../../explore/exploreUtils';
+import getChartIdsFromLayout from '../util/getChartIdsFromLayout';
+import DashboardBuilder from '../containers/DashboardBuilder';
+import {
+ chartPropShape,
+ slicePropShape,
+ dashboardInfoPropShape,
+ dashboardStatePropShape,
+ loadStatsPropShape,
+} from '../util/propShapes';
import { areObjectsEqual } from '../../reduxUtils';
-import { Logger, ActionLog, LOG_ACTIONS_PAGE_LOAD,
- LOG_ACTIONS_LOAD_EVENT, LOG_ACTIONS_RENDER_EVENT } from '../../logger';
+import getFormDataWithExtraFilters from '../util/charts/getFormDataWithExtraFilters';
+import {
+ Logger,
+ ActionLog,
+ DASHBOARD_EVENT_NAMES,
+ LOG_ACTIONS_MOUNT_DASHBOARD,
+ LOG_ACTIONS_LOAD_DASHBOARD_PANE,
+ LOG_ACTIONS_FIRST_DASHBOARD_LOAD,
+} from '../../logger';
+
import { t } from '../../locales';
-import '../../../stylesheets/dashboard.css';
+import '../stylesheets/index.less';
const propTypes = {
- actions: PropTypes.object,
+ actions: PropTypes.shape({
+ addSliceToDashboard: PropTypes.func.isRequired,
+ removeSliceFromDashboard: PropTypes.func.isRequired,
+ runQuery: PropTypes.func.isRequired,
+ }).isRequired,
+ dashboardInfo: dashboardInfoPropShape.isRequired,
+ dashboardState: dashboardStatePropShape.isRequired,
+ charts: PropTypes.objectOf(chartPropShape).isRequired,
+ slices: PropTypes.objectOf(slicePropShape).isRequired,
+ datasources: PropTypes.object.isRequired,
+ loadStats: loadStatsPropShape.isRequired,
+ layout: PropTypes.object.isRequired,
+ impressionId: PropTypes.string.isRequired,
initMessages: PropTypes.array,
- dashboard: PropTypes.object.isRequired,
- slices: PropTypes.object,
- datasources: PropTypes.object,
- filters: PropTypes.object,
- refresh: PropTypes.bool,
timeout: PropTypes.number,
userId: PropTypes.string,
- isStarred: PropTypes.bool,
- editMode: PropTypes.bool,
- impressionId: PropTypes.string,
};
const defaultProps = {
initMessages: [],
- dashboard: {},
- slices: {},
- datasources: {},
- filters: {},
- refresh: false,
timeout: 60,
userId: '',
- isStarred: false,
- editMode: false,
};
class Dashboard extends React.PureComponent {
+ // eslint-disable-next-line react/sort-comp
+ static onBeforeUnload(hasChanged) {
+ if (hasChanged) {
+ window.addEventListener('beforeunload', Dashboard.unload);
+ } else {
+ window.removeEventListener('beforeunload', Dashboard.unload);
+ }
+ }
+
+ static unload() {
+ const message = t('You have unsaved changes.');
+ window.event.returnValue = message; // Gecko + IE
+ return message; // Gecko + Webkit, Safari, Chrome etc.
+ }
+
constructor(props) {
super(props);
- this.refreshTimer = null;
- this.firstLoad = true;
- this.loadingLog = new ActionLog({
+ this.isFirstLoad = true;
+ this.actionLog = new ActionLog({
impressionId: props.impressionId,
- actionType: LOG_ACTIONS_PAGE_LOAD,
source: 'dashboard',
- sourceId: props.dashboard.id,
- eventNames: [LOG_ACTIONS_LOAD_EVENT, LOG_ACTIONS_RENDER_EVENT],
+ sourceId: props.dashboardInfo.id,
+ eventNames: DASHBOARD_EVENT_NAMES,
});
- Logger.start(this.loadingLog);
-
- // alert for unsaved changes
- this.state = { unsavedChanges: false };
-
- this.rerenderCharts = this.rerenderCharts.bind(this);
- this.updateDashboardTitle = this.updateDashboardTitle.bind(this);
- this.onSave = this.onSave.bind(this);
- this.onChange = this.onChange.bind(this);
- this.serialize = this.serialize.bind(this);
- this.fetchAllSlices = this.fetchSlices.bind(this, this.getAllSlices());
- this.startPeriodicRender = this.startPeriodicRender.bind(this);
- this.addSlicesToDashboard = this.addSlicesToDashboard.bind(this);
- this.fetchSlice = this.fetchSlice.bind(this);
- this.getFormDataExtra = this.getFormDataExtra.bind(this);
- this.exploreChart = this.exploreChart.bind(this);
- this.exportCSV = this.exportCSV.bind(this);
- this.props.actions.fetchFaveStar = this.props.actions.fetchFaveStar.bind(this);
- this.props.actions.saveFaveStar = this.props.actions.saveFaveStar.bind(this);
- this.props.actions.saveSlice = this.props.actions.saveSlice.bind(this);
- this.props.actions.removeSlice = this.props.actions.removeSlice.bind(this);
- this.props.actions.removeChart = this.props.actions.removeChart.bind(this);
- this.props.actions.updateDashboardLayout = this.props.actions.updateDashboardLayout.bind(this);
- this.props.actions.toggleExpandSlice = this.props.actions.toggleExpandSlice.bind(this);
- this.props.actions.addFilter = this.props.actions.addFilter.bind(this);
- this.props.actions.clearFilter = this.props.actions.clearFilter.bind(this);
- this.props.actions.removeFilter = this.props.actions.removeFilter.bind(this);
+ Logger.start(this.actionLog);
+ this.initTs = new Date().getTime();
}
componentDidMount() {
- window.addEventListener('resize', this.rerenderCharts);
+ Logger.append(LOG_ACTIONS_MOUNT_DASHBOARD);
}
componentWillReceiveProps(nextProps) {
- if (this.firstLoad &&
- Object.values(nextProps.slices)
- .every(slice => (['rendered', 'failed', 'stopped'].indexOf(slice.chartStatus) > -1))
- ) {
- Logger.end(this.loadingLog);
- this.firstLoad = false;
+ if (!nextProps.dashboardState.editMode) {
+ // log pane loads
+ const loadedPaneIds = [];
+ let minQueryStartTime = Infinity;
+ const allVisiblePanesDidLoad = Object.entries(nextProps.loadStats).every(
+ ([paneId, stats]) => {
+ const {
+ didLoad,
+ minQueryStartTime: paneMinQueryStart,
+ ...restStats
+ } = stats;
+ if (
+ didLoad &&
+ this.props.loadStats[paneId] &&
+ !this.props.loadStats[paneId].didLoad
+ ) {
+ Logger.append(LOG_ACTIONS_LOAD_DASHBOARD_PANE, {
+ ...restStats,
+ duration: new Date().getTime() - paneMinQueryStart,
+ version: 'v2',
+ });
+
+ if (!this.isFirstLoad) {
+ Logger.send(this.actionLog);
+ }
+ }
+ if (this.isFirstLoad && didLoad && stats.slice_ids.length > 0) {
+ loadedPaneIds.push(paneId);
+ minQueryStartTime = Math.min(minQueryStartTime, paneMinQueryStart);
+ }
+
+ // return true if it is loaded, or it's index is not 0
+ return didLoad || stats.index !== 0;
+ },
+ );
+
+ if (allVisiblePanesDidLoad && this.isFirstLoad) {
+ Logger.append(LOG_ACTIONS_FIRST_DASHBOARD_LOAD, {
+ pane_ids: loadedPaneIds,
+ duration: new Date().getTime() - minQueryStartTime,
+ version: 'v2',
+ });
+ Logger.send(this.actionLog);
+ this.isFirstLoad = false;
+ }
+ }
+
+ const currentChartIds = getChartIdsFromLayout(this.props.layout);
+ const nextChartIds = getChartIdsFromLayout(nextProps.layout);
+
+ if (currentChartIds.length < nextChartIds.length) {
+ const newChartIds = nextChartIds.filter(
+ key => currentChartIds.indexOf(key) === -1,
+ );
+ newChartIds.forEach(newChartId =>
+ this.props.actions.addSliceToDashboard(newChartId),
+ );
+ } else if (currentChartIds.length > nextChartIds.length) {
+ // remove chart
+ const removedChartIds = currentChartIds.filter(
+ key => nextChartIds.indexOf(key) === -1,
+ );
+ removedChartIds.forEach(removedChartId =>
+ this.props.actions.removeSliceFromDashboard(removedChartId),
+ );
}
}
componentDidUpdate(prevProps) {
- if (this.props.refresh) {
- let changedFilterKey;
- const prevFiltersKeySet = new Set(Object.keys(prevProps.filters));
- Object.keys(this.props.filters).some((key) => {
- prevFiltersKeySet.delete(key);
- if (prevProps.filters[key] === undefined ||
- !areObjectsEqual(prevProps.filters[key], this.props.filters[key])) {
+ const { refresh, filters, hasUnsavedChanges } = this.props.dashboardState;
+ if (refresh) {
+ // refresh charts if a filter was removed, added, or changed
+ let changedFilterKey = null;
+ const currFilterKeys = Object.keys(filters);
+ const prevFilterKeys = Object.keys(prevProps.dashboardState.filters);
+
+ currFilterKeys.forEach(key => {
+ const prevFilter = prevProps.dashboardState.filters[key];
+ if (
+ // filter was added or changed
+ typeof prevFilter === 'undefined' ||
+ !areObjectsEqual(prevFilter, filters[key])
+ ) {
changedFilterKey = key;
- return true;
}
- return false;
});
- // has changed filter or removed a filter?
- if (!!changedFilterKey || prevFiltersKeySet.size) {
+
+ if (
+ !!changedFilterKey ||
+ currFilterKeys.length !== prevFilterKeys.length
+ ) {
this.refreshExcept(changedFilterKey);
}
}
- }
- componentWillUnmount() {
- window.removeEventListener('resize', this.rerenderCharts);
- }
-
- onBeforeUnload(hasChanged) {
- if (hasChanged) {
- window.addEventListener('beforeunload', this.unload);
+ if (hasUnsavedChanges) {
+ Dashboard.onBeforeUnload(true);
} else {
- window.removeEventListener('beforeunload', this.unload);
+ Dashboard.onBeforeUnload(false);
}
}
- onChange() {
- this.onBeforeUnload(true);
- this.setState({ unsavedChanges: true });
- }
-
- onSave() {
- this.onBeforeUnload(false);
- this.setState({ unsavedChanges: false });
- }
-
// return charts in array
- getAllSlices() {
- return Object.values(this.props.slices);
- }
-
- getFormDataExtra(slice) {
- const formDataExtra = Object.assign({}, slice.formData);
- const extraFilters = this.effectiveExtraFilters(slice.slice_id);
- formDataExtra.extra_filters = formDataExtra.filters.concat(extraFilters);
- return formDataExtra;
- }
-
- getFilters(sliceId) {
- return this.props.filters[sliceId];
- }
-
- unload() {
- const message = t('You have unsaved changes.');
- window.event.returnValue = message; // Gecko + IE
- return message; // Gecko + Webkit, Safari, Chrome etc.
- }
-
- effectiveExtraFilters(sliceId) {
- const metadata = this.props.dashboard.metadata;
- const filters = this.props.filters;
- const f = [];
- const immuneSlices = metadata.filter_immune_slices || [];
- if (sliceId && immuneSlices.includes(sliceId)) {
- // The slice is immune to dashboard filters
- return f;
- }
-
- // Building a list of fields the slice is immune to filters on
- let immuneToFields = [];
- if (
- sliceId &&
- metadata.filter_immune_slice_fields &&
- metadata.filter_immune_slice_fields[sliceId]) {
- immuneToFields = metadata.filter_immune_slice_fields[sliceId];
- }
- for (const filteringSliceId in filters) {
- if (filteringSliceId === sliceId.toString()) {
- // Filters applied by the slice don't apply to itself
- continue;
- }
- for (const field in filters[filteringSliceId]) {
- if (!immuneToFields.includes(field)) {
- f.push({
- col: field,
- op: 'in',
- val: filters[filteringSliceId][field],
- });
- }
- }
- }
- return f;
+ getAllCharts() {
+ return Object.values(this.props.charts);
}
refreshExcept(filterKey) {
- const immune = this.props.dashboard.metadata.filter_immune_slices || [];
- let slices = this.getAllSlices();
- if (filterKey) {
- slices = slices.filter(slice => (
- String(slice.slice_id) !== filterKey &&
- immune.indexOf(slice.slice_id) === -1
- ));
- }
- this.fetchSlices(slices);
- }
-
- stopPeriodicRender() {
- if (this.refreshTimer) {
- clearTimeout(this.refreshTimer);
- this.refreshTimer = null;
- }
- }
-
- startPeriodicRender(interval) {
- this.stopPeriodicRender();
- const immune = this.props.dashboard.metadata.timed_refresh_immune_slices || [];
- const refreshAll = () => {
- const affectedSlices = this.getAllSlices()
- .filter(slice => immune.indexOf(slice.slice_id) === -1);
- this.fetchSlices(affectedSlices, true, interval * 0.2);
- };
- const fetchAndRender = () => {
- refreshAll();
- if (interval > 0) {
- this.refreshTimer = setTimeout(fetchAndRender, interval);
+ const immune = this.props.dashboardInfo.metadata.filter_immune_slices || [];
+
+ this.getAllCharts().forEach(chart => {
+ // filterKey is a string, immune array contains numbers
+ if (String(chart.id) !== filterKey && immune.indexOf(chart.id) === -1) {
+ const updatedFormData = getFormDataWithExtraFilters({
+ chart,
+ dashboardMetadata: this.props.dashboardInfo.metadata,
+ filters: this.props.dashboardState.filters,
+ sliceId: chart.id,
+ });
+
+ this.props.actions.runQuery(
+ updatedFormData,
+ false,
+ this.props.timeout,
+ chart.id,
+ );
}
- };
-
- fetchAndRender();
- }
-
- updateDashboardTitle(title) {
- this.props.actions.updateDashboardTitle(title);
- this.onChange();
- }
-
- serialize() {
- return this.props.dashboard.layout.map(reactPos => ({
- slice_id: reactPos.i,
- col: reactPos.x + 1,
- row: reactPos.y,
- size_x: reactPos.w,
- size_y: reactPos.h,
- }));
- }
-
- addSlicesToDashboard(sliceIds) {
- return this.props.actions.addSlicesToDashboard(this.props.dashboard.id, sliceIds);
- }
-
- fetchSlice(slice, force = false) {
- return this.props.actions.runQuery(
- this.getFormDataExtra(slice), force, this.props.timeout, slice.chartKey,
- );
- }
-
- // fetch and render an list of slices
- fetchSlices(slc, force = false, interval = 0) {
- const slices = slc || this.getAllSlices();
- if (!interval) {
- slices.forEach((slice) => { this.fetchSlice(slice, force); });
- return;
- }
-
- const meta = this.props.dashboard.metadata;
- const refreshTime = Math.max(interval, meta.stagger_time || 5000); // default 5 seconds
- if (typeof meta.stagger_refresh !== 'boolean') {
- meta.stagger_refresh = meta.stagger_refresh === undefined ?
- true : meta.stagger_refresh === 'true';
- }
- const delay = meta.stagger_refresh ? refreshTime / (slices.length - 1) : 0;
- slices.forEach((slice, i) => {
- setTimeout(() => { this.fetchSlice(slice, force); }, delay * i);
- });
- }
-
- exploreChart(slice) {
- const formData = this.getFormDataExtra(slice);
- exportChart(formData);
- }
-
- exportCSV(slice) {
- const formData = this.getFormDataExtra(slice);
- exportChart(formData, 'csv');
- }
-
- // re-render chart without fetch
- rerenderCharts() {
- this.getAllSlices().forEach((slice) => {
- setTimeout(() => {
- this.props.actions.renderTriggered(new Date().getTime(), slice.chartKey);
- }, 50);
});
}
render() {
- return (
- <div id="dashboard-container">
- <div id="dashboard-header">
- <AlertsWrapper initMessages={this.props.initMessages} />
- <Header
- dashboard={this.props.dashboard}
- unsavedChanges={this.state.unsavedChanges}
- filters={this.props.filters}
- userId={this.props.userId}
- isStarred={this.props.isStarred}
- updateDashboardTitle={this.updateDashboardTitle}
- onSave={this.onSave}
- onChange={this.onChange}
- serialize={this.serialize}
- fetchFaveStar={this.props.actions.fetchFaveStar}
- saveFaveStar={this.props.actions.saveFaveStar}
- renderSlices={this.fetchAllSlices}
- startPeriodicRender={this.startPeriodicRender}
- addSlicesToDashboard={this.addSlicesToDashboard}
- editMode={this.props.editMode}
- setEditMode={this.props.actions.setEditMode}
- />
- </div>
- <div id="grid-container" className="slice-grid gridster">
- <GridLayout
- dashboard={this.props.dashboard}
- datasources={this.props.datasources}
- filters={this.props.filters}
- charts={this.props.slices}
- timeout={this.props.timeout}
- onChange={this.onChange}
- getFormDataExtra={this.getFormDataExtra}
- exploreChart={this.exploreChart}
- exportCSV={this.exportCSV}
- fetchSlice={this.fetchSlice}
- saveSlice={this.props.actions.saveSlice}
- removeSlice={this.props.actions.removeSlice}
- removeChart={this.props.actions.removeChart}
- updateDashboardLayout={this.props.actions.updateDashboardLayout}
- toggleExpandSlice={this.props.actions.toggleExpandSlice}
- addFilter={this.props.actions.addFilter}
- getFilters={this.getFilters}
- clearFilter={this.props.actions.clearFilter}
- removeFilter={this.props.actions.removeFilter}
- editMode={this.props.editMode}
- />
- </div>
- </div>
- );
+ return <DashboardBuilder />;
}
}
diff --git a/superset/assets/src/dashboard/components/DashboardBuilder.jsx b/superset/assets/src/dashboard/components/DashboardBuilder.jsx
new file mode 100644
index 0000000000..ecb528dc89
--- /dev/null
+++ b/superset/assets/src/dashboard/components/DashboardBuilder.jsx
@@ -0,0 +1,208 @@
+/* eslint-env browser */
+import cx from 'classnames';
+// ParentSize uses resize observer so the dashboard will update size
+// when its container size changes, due to e.g., builder side panel opening
+import ParentSize from '@vx/responsive/build/components/ParentSize';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Sticky, StickyContainer } from 'react-sticky';
+import { TabContainer, TabContent, TabPane } from 'react-bootstrap';
+
+import BuilderComponentPane from './BuilderComponentPane';
+import DashboardHeader from '../containers/DashboardHeader';
+import DashboardGrid from '../containers/DashboardGrid';
+import IconButton from './IconButton';
+import DragDroppable from './dnd/DragDroppable';
+import DashboardComponent from '../containers/DashboardComponent';
+import ToastPresenter from '../../messageToasts/containers/ToastPresenter';
+import WithPopoverMenu from './menu/WithPopoverMenu';
+
+import getDragDropManager from '../util/getDragDropManager';
+
+import {
+ DASHBOARD_GRID_ID,
+ DASHBOARD_ROOT_ID,
+ DASHBOARD_ROOT_DEPTH,
+} from '../util/constants';
+
+const TABS_HEIGHT = 47;
+const HEADER_HEIGHT = 67;
+
+const propTypes = {
+ // redux
+ dashboardLayout: PropTypes.object.isRequired,
+ deleteTopLevelTabs: PropTypes.func.isRequired,
+ editMode: PropTypes.bool.isRequired,
+ showBuilderPane: PropTypes.bool,
+ handleComponentDrop: PropTypes.func.isRequired,
+ toggleBuilderPane: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+ showBuilderPane: false,
+};
+
+class DashboardBuilder extends React.Component {
+ static shouldFocusTabs(event, container) {
+ // don't focus the tabs when we click on a tab
+ return (
+ event.target.tagName === 'UL' ||
+ (/icon-button/.test(event.target.className) &&
+ container.contains(event.target))
+ );
+ }
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ tabIndex: 0, // top-level tabs
+ };
+ this.handleChangeTab = this.handleChangeTab.bind(this);
+ this.handleDeleteTopLevelTabs = this.handleDeleteTopLevelTabs.bind(this);
+ }
+
+ getChildContext() {
+ return {
+ dragDropManager: this.context.dragDropManager || getDragDropManager(),
+ };
+ }
+
+ handleDeleteTopLevelTabs() {
+ this.props.deleteTopLevelTabs();
+ this.setState({ tabIndex: 0 });
+ }
+
+ handleChangeTab({ tabIndex }) {
+ this.setState(() => ({ tabIndex }));
+ setTimeout(() => {
+ if (window)
+ window.scrollTo({
+ top: 0,
+ behavior: 'smooth',
+ });
+ }, 100);
+ }
+
+ render() {
+ const { handleComponentDrop, dashboardLayout, editMode } = this.props;
+ const { tabIndex } = this.state;
+ const dashboardRoot = dashboardLayout[DASHBOARD_ROOT_ID];
+ const rootChildId = dashboardRoot.children[0];
+ const topLevelTabs =
+ rootChildId !== DASHBOARD_GRID_ID && dashboardLayout[rootChildId];
+
+ const childIds = topLevelTabs ? topLevelTabs.children : [DASHBOARD_GRID_ID];
+
+ return (
+ <StickyContainer
+ className={cx('dashboard', editMode && 'dashboard--editing')}
+ >
+ <Sticky>
+ {({ style }) => (
+ <DragDroppable
+ component={dashboardRoot}
+ parentComponent={null}
+ depth={DASHBOARD_ROOT_DEPTH}
+ index={0}
+ orientation="column"
+ onDrop={handleComponentDrop}
+ editMode={editMode}
+ // you cannot drop on/displace tabs if they already exist
+ disableDragdrop={!!topLevelTabs}
+ style={{ zIndex: 100, ...style }}
+ >
+ {({ dropIndicatorProps }) => (
+ <div>
+ <DashboardHeader />
+ {dropIndicatorProps && <div {...dropIndicatorProps} />}
+ {topLevelTabs && (
+ <WithPopoverMenu
+ shouldFocus={DashboardBuilder.shouldFocusTabs}
+ menuItems={[
+ <IconButton
+ className="fa fa-level-down"
+ label="Collapse tab content"
+ onClick={this.handleDeleteTopLevelTabs}
+ />,
+ ]}
+ editMode={editMode}
+ >
+ <DashboardComponent
+ id={topLevelTabs.id}
+ parentId={DASHBOARD_ROOT_ID}
+ depth={DASHBOARD_ROOT_DEPTH + 1}
+ index={0}
+ renderTabContent={false}
+ renderHoverMenu={false}
+ onChangeTab={this.handleChangeTab}
+ />
+ </WithPopoverMenu>
+ )}
+ </div>
+ )}
+ </DragDroppable>
+ )}
+ </Sticky>
+
+ <div className="dashboard-content">
+ <div className="grid-container">
+ <ParentSize>
+ {({ width }) => (
+ /*
+ We use a TabContainer irrespective of whether top-level tabs exist to maintain
+ a consistent React component tree. This avoids expensive mounts/unmounts of
+ the entire dashboard upon adding/removing top-level tabs, which would otherwise
+ happen because of React's diffing algorithm
+ */
+ <TabContainer
+ id={DASHBOARD_GRID_ID}
+ activeKey={Math.min(tabIndex, childIds.length - 1)}
+ onSelect={this.handleChangeTab}
+ animation
+ mountOnEnter
+ unmountOnExit={false}
+ >
+ <TabContent>
+ {childIds.map((id, index) => (
+ // Matching the key of the first TabPane irrespective of topLevelTabs
+ // lets us keep the same React component tree when !!topLevelTabs changes.
+ // This avoids expensive mounts/unmounts of the entire dashboard.
+ <TabPane
+ key={index === 0 ? DASHBOARD_GRID_ID : id}
+ eventKey={index}
+ >
+ <DashboardGrid
+ gridComponent={dashboardLayout[id]}
+ // see isValidChild for why tabs do not increment the depth of their children
+ depth={DASHBOARD_ROOT_DEPTH + 1} // (topLevelTabs ? 0 : 1)}
+ width={width}
+ />
+ </TabPane>
+ ))}
+ </TabContent>
+ </TabContainer>
+ )}
+ </ParentSize>
+ </div>
+
+ {this.props.editMode &&
+ this.props.showBuilderPane && (
+ <BuilderComponentPane
+ topOffset={HEADER_HEIGHT + (topLevelTabs ? TABS_HEIGHT : 0)}
+ toggleBuilderPane={this.props.toggleBuilderPane}
+ />
+ )}
+ </div>
+ <ToastPresenter />
+ </StickyContainer>
+ );
+ }
+}
+
+DashboardBuilder.propTypes = propTypes;
+DashboardBuilder.defaultProps = defaultProps;
+DashboardBuilder.childContextTypes = {
+ dragDropManager: PropTypes.object.isRequired,
+};
+
+export default DashboardBuilder;
diff --git a/superset/assets/src/dashboard/components/DashboardContainer.jsx b/superset/assets/src/dashboard/components/DashboardContainer.jsx
deleted file mode 100644
index a18a5d2990..0000000000
--- a/superset/assets/src/dashboard/components/DashboardContainer.jsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import { bindActionCreators } from 'redux';
-import { connect } from 'react-redux';
-
-import * as dashboardActions from '../actions';
-import * as chartActions from '../../chart/chartAction';
-import Dashboard from './Dashboard';
-
-function mapStateToProps({ charts, dashboard, impressionId }) {
- return {
- initMessages: dashboard.common.flash_messages,
- timeout: dashboard.common.conf.SUPERSET_WEBSERVER_TIMEOUT,
- dashboard: dashboard.dashboard,
- slices: charts,
- datasources: dashboard.datasources,
- filters: dashboard.filters,
- refresh: !!dashboard.refresh,
- userId: dashboard.userId,
- isStarred: !!dashboard.isStarred,
- editMode: dashboard.editMode,
- impressionId,
- };
-}
-
-function mapDispatchToProps(dispatch) {
- const actions = { ...chartActions, ...dashboardActions };
- return {
- actions: bindActionCreators(actions, dispatch),
- };
-}
-
-export default connect(mapStateToProps, mapDispatchToProps)(Dashboard);
diff --git a/superset/assets/src/dashboard/components/DashboardGrid.jsx b/superset/assets/src/dashboard/components/DashboardGrid.jsx
new file mode 100644
index 0000000000..d85015e6b7
--- /dev/null
+++ b/superset/assets/src/dashboard/components/DashboardGrid.jsx
@@ -0,0 +1,198 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { componentShape } from '../util/propShapes';
+import DashboardComponent from '../containers/DashboardComponent';
+import DragDroppable from './dnd/DragDroppable';
+
+import { GRID_GUTTER_SIZE, GRID_COLUMN_COUNT } from '../util/constants';
+
+const propTypes = {
+ depth: PropTypes.number.isRequired,
+ editMode: PropTypes.bool.isRequired,
+ gridComponent: componentShape.isRequired,
+ handleComponentDrop: PropTypes.func.isRequired,
+ resizeComponent: PropTypes.func.isRequired,
+ width: PropTypes.number.isRequired,
+};
+
+const defaultProps = {};
+
+class DashboardGrid extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = {
+ isResizing: false,
+ rowGuideTop: null,
+ };
+
+ this.handleResizeStart = this.handleResizeStart.bind(this);
+ this.handleResize = this.handleResize.bind(this);
+ this.handleResizeStop = this.handleResizeStop.bind(this);
+ this.handleTopDropTargetDrop = this.handleTopDropTargetDrop.bind(this);
+ this.getRowGuidePosition = this.getRowGuidePosition.bind(this);
+ this.setGridRef = this.setGridRef.bind(this);
+ }
+
+ getRowGuidePosition(resizeRef) {
+ if (resizeRef && this.grid) {
+ return (
+ resizeRef.getBoundingClientRect().bottom -
+ this.grid.getBoundingClientRect().top -
+ 2
+ );
+ }
+ return null;
+ }
+
+ setGridRef(ref) {
+ this.grid = ref;
+ }
+
+ handleResizeStart({ ref, direction }) {
+ let rowGuideTop = null;
+ if (direction === 'bottom' || direction === 'bottomRight') {
+ rowGuideTop = this.getRowGuidePosition(ref);
+ }
+
+ this.setState(() => ({
+ isResizing: true,
+ rowGuideTop,
+ }));
+ }
+
+ handleResize({ ref, direction }) {
+ if (direction === 'bottom' || direction === 'bottomRight') {
+ this.setState(() => ({ rowGuideTop: this.getRowGuidePosition(ref) }));
+ }
+ }
+
+ handleResizeStop({ id, widthMultiple: width, heightMultiple: height }) {
+ this.props.resizeComponent({ id, width, height });
+
+ this.setState(() => ({
+ isResizing: false,
+ rowGuideTop: null,
+ }));
+ }
+
+ handleTopDropTargetDrop(dropResult) {
+ if (dropResult) {
+ this.props.handleComponentDrop({
+ ...dropResult,
+ destination: {
+ ...dropResult.destination,
+ // force appending as the first child if top drop target
+ index: 0,
+ },
+ });
+ }
+ }
+
+ render() {
+ const {
+ gridComponent,
+ handleComponentDrop,
+ depth,
+ editMode,
+ width,
+ } = this.props;
+
+ const columnPlusGutterWidth =
+ (width + GRID_GUTTER_SIZE) / GRID_COLUMN_COUNT;
+
+ const columnWidth = columnPlusGutterWidth - GRID_GUTTER_SIZE;
+ const { isResizing, rowGuideTop } = this.state;
+
+ return width < 100 ? null : (
+ <div className="dashboard-grid" ref={this.setGridRef}>
+ <div className="grid-content">
+ {/* make the area above components droppable */}
+ {editMode && (
+ <DragDroppable
+ component={gridComponent}
+ depth={depth}
+ parentComponent={null}
+ index={0}
+ orientation="column"
+ onDrop={this.handleTopDropTargetDrop}
+ className="empty-droptarget"
+ editMode
+ >
+ {({ dropIndicatorProps }) =>
+ dropIndicatorProps && (
+ <div className="drop-indicator drop-indicator--bottom" />
+ )
+ }
+ </DragDroppable>
+ )}
+
+ {gridComponent.children.map((id, index) => (
+ <DashboardComponent
+ key={id}
+ id={id}
+ parentId={gridComponent.id}
+ depth={depth + 1}
+ index={index}
+ availableColumnCount={GRID_COLUMN_COUNT}
+ columnWidth={columnWidth}
+ onResizeStart={this.handleResizeStart}
+ onResize={this.handleResize}
+ onResizeStop={this.handleResizeStop}
+ />
+ ))}
+
+ {/* make the area below components droppable */}
+ {editMode && (
+ <DragDroppable
+ component={gridComponent}
+ depth={depth}
+ parentComponent={null}
+ index={gridComponent.children.length}
+ orientation="column"
+ onDrop={handleComponentDrop}
+ className="empty-droptarget"
+ editMode
+ >
+ {({ dropIndicatorProps }) =>
+ dropIndicatorProps && (
+ <div className="drop-indicator drop-indicator--top" />
+ )
+ }
+ </DragDroppable>
+ )}
+
+ {isResizing &&
+ Array(GRID_COLUMN_COUNT)
+ .fill(null)
+ .map((_, i) => (
+ <div
+ key={`grid-column-${i}`}
+ className="grid-column-guide"
+ style={{
+ left: i * GRID_GUTTER_SIZE + i * columnWidth,
+ width: columnWidth,
+ }}
+ />
+ ))}
+
+ {isResizing &&
+ rowGuideTop && (
+ <div
+ className="grid-row-guide"
+ style={{
+ top: rowGuideTop,
+ width,
+ }}
+ />
+ )}
+ </div>
+ </div>
+ );
+ }
+}
+
+DashboardGrid.propTypes = propTypes;
+DashboardGrid.defaultProps = defaultProps;
+
+export default DashboardGrid;
diff --git a/superset/assets/src/dashboard/components/DeleteComponentButton.jsx b/superset/assets/src/dashboard/components/DeleteComponentButton.jsx
new file mode 100644
index 0000000000..8470947085
--- /dev/null
+++ b/superset/assets/src/dashboard/components/DeleteComponentButton.jsx
@@ -0,0 +1,20 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import IconButton from './IconButton';
+
+const propTypes = {
+ onDelete: PropTypes.func.isRequired,
+};
+
+const defaultProps = {};
+
+export default class DeleteComponentButton extends React.PureComponent {
+ render() {
+ const { onDelete } = this.props;
+ return <IconButton onClick={onDelete} className="fa fa-trash" />;
+ }
+}
+
+DeleteComponentButton.propTypes = propTypes;
+DeleteComponentButton.defaultProps = defaultProps;
diff --git a/superset/assets/src/dashboard/components/GridCell.jsx b/superset/assets/src/dashboard/components/GridCell.jsx
deleted file mode 100644
index 91fe83943b..0000000000
--- a/superset/assets/src/dashboard/components/GridCell.jsx
+++ /dev/null
@@ -1,157 +0,0 @@
-/* eslint-disable react/no-danger */
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import SliceHeader from './SliceHeader';
-import ChartContainer from '../../chart/ChartContainer';
-
-import '../../../stylesheets/dashboard.css';
-
-const propTypes = {
- timeout: PropTypes.number,
- datasource: PropTypes.object,
- isLoading: PropTypes.bool,
- isCached: PropTypes.bool,
- cachedDttm: PropTypes.string,
- isExpanded: PropTypes.bool,
- widgetHeight: PropTypes.number,
- widgetWidth: PropTypes.number,
- slice: PropTypes.object,
- chartKey: PropTypes.string,
- formData: PropTypes.object,
- filters: PropTypes.object,
- forceRefresh: PropTypes.func,
- removeSlice: PropTypes.func,
- updateSliceName: PropTypes.func,
- toggleExpandSlice: PropTypes.func,
- exploreChart: PropTypes.func,
- exportCSV: PropTypes.func,
- addFilter: PropTypes.func,
- getFilters: PropTypes.func,
- clearFilter: PropTypes.func,
- removeFilter: PropTypes.func,
- editMode: PropTypes.bool,
- annotationQuery: PropTypes.object,
-};
-
-const defaultProps = {
- forceRefresh: () => ({}),
- removeSlice: () => ({}),
- updateSliceName: () => ({}),
- toggleExpandSlice: () => ({}),
- exploreChart: () => ({}),
- exportCSV: () => ({}),
- addFilter: () => ({}),
- getFilters: () => ({}),
- clearFilter: () => ({}),
- removeFilter: () => ({}),
- editMode: false,
-};
-
-class GridCell extends React.PureComponent {
- constructor(props) {
- super(props);
-
- const sliceId = this.props.slice.slice_id;
- this.addFilter = this.props.addFilter.bind(this, sliceId);
- this.getFilters = this.props.getFilters.bind(this, sliceId);
- this.clearFilter = this.props.clearFilter.bind(this, sliceId);
- this.removeFilter = this.props.removeFilter.bind(this, sliceId);
- }
-
- getDescriptionId(slice) {
- return 'description_' + slice.slice_id;
- }
-
- getHeaderId(slice) {
- return 'header_' + slice.slice_id;
- }
-
- width() {
- return this.props.widgetWidth - 10;
- }
-
- height(slice) {
- const widgetHeight = this.props.widgetHeight;
- const headerHeight = this.headerHeight(slice);
- const descriptionId = this.getDescriptionId(slice);
- let descriptionHeight = 0;
- if (this.props.isExpanded && this.refs[descriptionId]) {
- descriptionHeight = this.refs[descriptionId].offsetHeight + 10;
- }
-
- return widgetHeight - headerHeight - descriptionHeight;
- }
-
- headerHeight(slice) {
- const headerId = this.getHeaderId(slice);
- return this.refs[headerId] ? this.refs[headerId].offsetHeight : 30;
- }
-
- render() {
- const {
- isExpanded, isLoading, isCached, cachedDttm,
- removeSlice, updateSliceName, toggleExpandSlice, forceRefresh,
- chartKey, slice, datasource, formData, timeout, annotationQuery,
- exploreChart, exportCSV,
- } = this.props;
- return (
- <div
- className={isLoading ? 'slice-cell-highlight' : 'slice-cell'}
- id={`${slice.slice_id}-cell`}
- >
- <div ref={this.getHeaderId(slice)}>
- <SliceHeader
- slice={slice}
- isExpanded={isExpanded}
- isCached={isCached}
- cachedDttm={cachedDttm}
- removeSlice={removeSlice}
- updateSliceName={updateSliceName}
- toggleExpandSlice={toggleExpandSlice}
- forceRefresh={forceRefresh}
- editMode={this.props.editMode}
- annotationQuery={annotationQuery}
- exploreChart={exploreChart}
- exportCSV={exportCSV}
- />
- </div>
- {
- /* This usage of dangerouslySetInnerHTML is safe since it is being used to render
- markdown that is sanitized with bleach. See:
- https://github.com/apache/incubator-superset/pull/4390
- and
- https://github.com/apache/incubator-superset/commit/b6fcc22d5a2cb7a5e92599ed5795a0169385a825 */}
- <div
- className="slice_description bs-callout bs-callout-default"
- style={isExpanded ? {} : { display: 'none' }}
- ref={this.getDescriptionId(slice)}
- dangerouslySetInnerHTML={{ __html: slice.description_markeddown }}
- />
- <div className="row chart-container">
- <input type="hidden" value="false" />
- <ChartContainer
- containerId={`slice-container-${slice.slice_id}`}
- chartKey={chartKey}
- datasource={datasource}
- formData={formData}
- headerHeight={this.headerHeight(slice)}
- height={this.height(slice)}
- width={this.width()}
- timeout={timeout}
- vizType={slice.formData.viz_type}
- addFilter={this.addFilter}
- getFilters={this.getFilters}
- clearFilter={this.clearFilter}
- removeFilter={this.removeFilter}
- />
- </div>
- </div>
- );
- }
-}
-
-GridCell.propTypes = propTypes;
-GridCell.defaultProps = defaultProps;
-
-export default GridCell;
diff --git a/superset/assets/src/dashboard/components/GridLayout.jsx b/superset/assets/src/dashboard/components/GridLayout.jsx
deleted file mode 100644
index ef0ec24796..0000000000
--- a/superset/assets/src/dashboard/components/GridLayout.jsx
+++ /dev/null
@@ -1,198 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import { Responsive, WidthProvider } from 'react-grid-layout';
-
-import GridCell from './GridCell';
-
-require('react-grid-layout/css/styles.css');
-require('react-resizable/css/styles.css');
-
-const ResponsiveReactGridLayout = WidthProvider(Responsive);
-
-const propTypes = {
- dashboard: PropTypes.object.isRequired,
- datasources: PropTypes.object,
- charts: PropTypes.object.isRequired,
- filters: PropTypes.object,
- timeout: PropTypes.number,
- onChange: PropTypes.func,
- getFormDataExtra: PropTypes.func,
- exploreChart: PropTypes.func,
- exportCSV: PropTypes.func,
- fetchSlice: PropTypes.func,
- saveSlice: PropTypes.func,
- removeSlice: PropTypes.func,
- removeChart: PropTypes.func,
- updateDashboardLayout: PropTypes.func,
- toggleExpandSlice: PropTypes.func,
- addFilter: PropTypes.func,
- getFilters: PropTypes.func,
- clearFilter: PropTypes.func,
- removeFilter: PropTypes.func,
- editMode: PropTypes.bool.isRequired,
-};
-
-const defaultProps = {
- onChange: () => ({}),
- getFormDataExtra: () => ({}),
- exploreChart: () => ({}),
- exportCSV: () => ({}),
- fetchSlice: () => ({}),
- saveSlice: () => ({}),
- removeSlice: () => ({}),
- removeChart: () => ({}),
- updateDashboardLayout: () => ({}),
- toggleExpandSlice: () => ({}),
- addFilter: () => ({}),
- getFilters: () => ({}),
- clearFilter: () => ({}),
- removeFilter: () => ({}),
-};
-
-class GridLayout extends React.Component {
- constructor(props) {
- super(props);
-
- this.onResizeStop = this.onResizeStop.bind(this);
- this.onDragStop = this.onDragStop.bind(this);
- this.forceRefresh = this.forceRefresh.bind(this);
- this.removeSlice = this.removeSlice.bind(this);
- this.updateSliceName = this.props.dashboard.dash_edit_perm ?
- this.updateSliceName.bind(this) : null;
- }
-
- onResizeStop(layout) {
- this.props.updateDashboardLayout(layout);
- this.props.onChange();
- }
-
- onDragStop(layout) {
- this.props.updateDashboardLayout(layout);
- this.props.onChange();
- }
-
- getWidgetId(slice) {
- return 'widget_' + slice.slice_id;
- }
-
- getWidgetHeight(slice) {
- const widgetId = this.getWidgetId(slice);
- if (!widgetId || !this.refs[widgetId]) {
- return 400;
- }
- return this.refs[widgetId].offsetHeight;
- }
-
- getWidgetWidth(slice) {
- const widgetId = this.getWidgetId(slice);
- if (!widgetId || !this.refs[widgetId]) {
- return 400;
- }
- return this.refs[widgetId].offsetWidth;
- }
-
- findSliceIndexById(sliceId) {
- return this.props.dashboard.slices
- .map(slice => (slice.slice_id)).indexOf(sliceId);
- }
-
- forceRefresh(sliceId) {
- return this.props.fetchSlice(this.props.charts['slice_' + sliceId], true);
- }
-
- removeSlice(slice) {
- if (!slice) {
- return;
- }
-
- // remove slice dashboard and charts
- this.props.removeSlice(slice);
- this.props.removeChart(this.props.charts['slice_' + slice.slice_id].chartKey);
- this.props.onChange();
- }
-
- updateSliceName(sliceId, sliceName) {
- const index = this.findSliceIndexById(sliceId);
- if (index === -1) {
- return;
- }
-
- const currentSlice = this.props.dashboard.slices[index];
- if (currentSlice.slice_name === sliceName) {
- return;
- }
-
- this.props.saveSlice(currentSlice, sliceName);
- }
-
- isExpanded(slice) {
- return this.props.dashboard.metadata.expanded_slices &&
- this.props.dashboard.metadata.expanded_slices[slice.slice_id];
- }
-
- render() {
- const cells = this.props.dashboard.slices.map((slice) => {
- const chartKey = `slice_${slice.slice_id}`;
- const currentChart = this.props.charts[chartKey];
- const queryResponse = currentChart.queryResponse || {};
- return (
- <div
- id={'slice_' + slice.slice_id}
- key={slice.slice_id}
- data-slice-id={slice.slice_id}
- className={`widget ${slice.form_data.viz_type}`}
- ref={this.getWidgetId(slice)}
- >
- <GridCell
- slice={slice}
- chartKey={chartKey}
- datasource={this.props.datasources[slice.form_data.datasource]}
- filters={this.props.filters}
- formData={this.props.getFormDataExtra(slice)}
- timeout={this.props.timeout}
- widgetHeight={this.getWidgetHeight(slice)}
- widgetWidth={this.getWidgetWidth(slice)}
- exploreChart={this.props.exploreChart}
- exportCSV={this.props.exportCSV}
- isExpanded={!!this.isExpanded(slice)}
- isLoading={currentChart.chartStatus === 'loading'}
- isCached={queryResponse.is_cached}
- cachedDttm={queryResponse.cached_dttm}
- toggleExpandSlice={this.props.toggleExpandSlice}
- forceRefresh={this.forceRefresh}
- removeSlice={this.removeSlice}
- updateSliceName={this.updateSliceName}
- addFilter={this.props.addFilter}
- getFilters={this.props.getFilters}
- clearFilter={this.props.clearFilter}
- removeFilter={this.props.removeFilter}
- editMode={this.props.editMode}
- annotationQuery={currentChart.annotationQuery}
- annotationError={currentChart.annotationError}
- />
- </div>);
- });
-
- return (
- <ResponsiveReactGridLayout
- className="layout"
- layouts={{ lg: this.props.dashboard.layout }}
- onResizeStop={this.onResizeStop}
- onDragStop={this.onDragStop}
- cols={{ lg: 48, md: 48, sm: 40, xs: 32, xxs: 24 }}
- rowHeight={10}
- autoSize
- margin={[20, 20]}
- useCSSTransforms
- draggableHandle=".drag"
- >
- {cells}
- </ResponsiveReactGridLayout>
- );
- }
-}
-
-GridLayout.propTypes = propTypes;
-GridLayout.defaultProps = defaultProps;
-
-export default GridLayout;
diff --git a/superset/assets/src/dashboard/components/Header.jsx b/superset/assets/src/dashboard/components/Header.jsx
index 52d3024ff9..aa29bea131 100644
--- a/superset/assets/src/dashboard/components/Header.jsx
+++ b/superset/assets/src/dashboard/components/Header.jsx
@@ -1,116 +1,315 @@
+/* eslint-env browser */
import React from 'react';
import PropTypes from 'prop-types';
-import Controls from './Controls';
+import HeaderActionsDropdown from './HeaderActionsDropdown';
import EditableTitle from '../../components/EditableTitle';
import Button from '../../components/Button';
import FaveStar from '../../components/FaveStar';
-import InfoTooltipWithTrigger from '../../components/InfoTooltipWithTrigger';
+import UndoRedoKeylisteners from './UndoRedoKeylisteners';
+
+import { chartPropShape } from '../util/propShapes';
import { t } from '../../locales';
+import {
+ UNDO_LIMIT,
+ SAVE_TYPE_OVERWRITE,
+ DASHBOARD_POSITION_DATA_LIMIT,
+} from '../util/constants';
const propTypes = {
- dashboard: PropTypes.object.isRequired,
+ addSuccessToast: PropTypes.func.isRequired,
+ addDangerToast: PropTypes.func.isRequired,
+ addWarningToast: PropTypes.func.isRequired,
+ dashboardInfo: PropTypes.object.isRequired,
+ dashboardTitle: PropTypes.string.isRequired,
+ charts: PropTypes.objectOf(chartPropShape).isRequired,
+ layout: PropTypes.object.isRequired,
filters: PropTypes.object.isRequired,
- userId: PropTypes.string.isRequired,
- isStarred: PropTypes.bool,
- addSlicesToDashboard: PropTypes.func,
- onSave: PropTypes.func,
- onChange: PropTypes.func,
- fetchFaveStar: PropTypes.func,
- renderSlices: PropTypes.func,
- saveFaveStar: PropTypes.func,
- serialize: PropTypes.func,
- startPeriodicRender: PropTypes.func,
- updateDashboardTitle: PropTypes.func,
+ expandedSlices: PropTypes.object.isRequired,
+ css: PropTypes.string.isRequired,
+ isStarred: PropTypes.bool.isRequired,
+ onSave: PropTypes.func.isRequired,
+ onChange: PropTypes.func.isRequired,
+ fetchFaveStar: PropTypes.func.isRequired,
+ fetchCharts: PropTypes.func.isRequired,
+ saveFaveStar: PropTypes.func.isRequired,
+ startPeriodicRender: PropTypes.func.isRequired,
+ updateDashboardTitle: PropTypes.func.isRequired,
editMode: PropTypes.bool.isRequired,
setEditMode: PropTypes.func.isRequired,
- unsavedChanges: PropTypes.bool.isRequired,
+ showBuilderPane: PropTypes.bool.isRequired,
+ toggleBuilderPane: PropTypes.func.isRequired,
+ updateCss: PropTypes.func.isRequired,
+ hasUnsavedChanges: PropTypes.bool.isRequired,
+ maxUndoHistoryExceeded: PropTypes.bool.isRequired,
+
+ // redux
+ onUndo: PropTypes.func.isRequired,
+ onRedo: PropTypes.func.isRequired,
+ undoLength: PropTypes.number.isRequired,
+ redoLength: PropTypes.number.isRequired,
+ setMaxUndoHistoryExceeded: PropTypes.func.isRequired,
+ maxUndoHistoryToast: PropTypes.func.isRequired,
};
class Header extends React.PureComponent {
+ static discardChanges() {
+ window.location.reload();
+ }
+
constructor(props) {
super(props);
- this.handleSaveTitle = this.handleSaveTitle.bind(this);
+ this.state = {
+ didNotifyMaxUndoHistoryToast: false,
+ emphasizeUndo: false,
+ hightlightRedo: false,
+ };
+
+ this.handleChangeText = this.handleChangeText.bind(this);
+ this.handleCtrlZ = this.handleCtrlZ.bind(this);
+ this.handleCtrlY = this.handleCtrlY.bind(this);
this.toggleEditMode = this.toggleEditMode.bind(this);
+ this.forceRefresh = this.forceRefresh.bind(this);
+ this.overwriteDashboard = this.overwriteDashboard.bind(this);
+ }
+
+ componentWillReceiveProps(nextProps) {
+ if (
+ UNDO_LIMIT - nextProps.undoLength <= 0 &&
+ !this.state.didNotifyMaxUndoHistoryToast
+ ) {
+ this.setState(() => ({ didNotifyMaxUndoHistoryToast: true }));
+ this.props.maxUndoHistoryToast();
+ }
+ if (
+ nextProps.undoLength > UNDO_LIMIT &&
+ !this.props.maxUndoHistoryExceeded
+ ) {
+ this.props.setMaxUndoHistoryExceeded();
+ }
}
- handleSaveTitle(title) {
- this.props.updateDashboardTitle(title);
+
+ componentWillUnmount() {
+ clearTimeout(this.ctrlYTimeout);
+ clearTimeout(this.ctrlZTimeout);
}
- toggleEditMode() {
- this.props.setEditMode(!this.props.editMode);
+
+ forceRefresh() {
+ return this.props.fetchCharts(Object.values(this.props.charts), true);
}
- renderUnsaved() {
- if (!this.props.unsavedChanges) {
- return null;
+
+ handleChangeText(nextText) {
+ const { updateDashboardTitle, onChange } = this.props;
+ if (nextText && this.props.dashboardTitle !== nextText) {
+ updateDashboardTitle(nextText);
+ onChange();
}
- return (
- <InfoTooltipWithTrigger
- label="unsaved"
- tooltip={t('Unsaved changes')}
- icon="exclamation-triangle"
- className="text-danger m-r-5"
- placement="top"
- />
- );
}
- renderEditButton() {
- if (!this.props.dashboard.dash_save_perm) {
- return null;
+
+ handleCtrlY() {
+ this.props.onRedo();
+ this.setState({ emphasizeRedo: true }, () => {
+ if (this.ctrlYTimeout) clearTimeout(this.ctrlYTimeout);
+ this.ctrlYTimeout = setTimeout(() => {
+ this.setState({ emphasizeRedo: false });
+ }, 100);
+ });
+ }
+
+ handleCtrlZ() {
+ this.props.onUndo();
+ this.setState({ emphasizeUndo: true }, () => {
+ if (this.ctrlZTimeout) clearTimeout(this.ctrlZTimeout);
+ this.ctrlZTimeout = setTimeout(() => {
+ this.setState({ emphasizeUndo: false });
+ }, 100);
+ });
+ }
+
+ toggleEditMode() {
+ this.props.setEditMode(!this.props.editMode);
+ }
+
+ overwriteDashboard() {
+ const {
+ dashboardTitle,
+ layout: positions,
+ expandedSlices,
+ css,
+ filters,
+ dashboardInfo,
+ } = this.props;
+
+ const data = {
+ positions,
+ expanded_slices: expandedSlices,
+ css,
+ dashboard_title: dashboardTitle,
+ default_filters: JSON.stringify(filters),
+ };
+
+ // make sure positions data less than DB storage limitation:
+ const positionJSONLength = JSON.stringify(positions).length;
+ const limit =
+ dashboardInfo.common.conf.SUPERSET_DASHBOARD_POSITION_DATA_LIMIT ||
+ DASHBOARD_POSITION_DATA_LIMIT;
+ if (positionJSONLength >= limit) {
+ this.props.addDangerToast(
+ t(
+ 'Your dashboard is too large. Please reduce the size before save it.',
+ ),
+ );
+ } else {
+ if (positionJSONLength >= limit * 0.9) {
+ this.props.addWarningToast('Your dashboard is near the size limit.');
+ }
+
+ this.props.onSave(data, dashboardInfo.id, SAVE_TYPE_OVERWRITE);
}
- const btnText = this.props.editMode ? 'Switch to View Mode' : 'Edit Dashboard';
- return (
- <Button
- bsStyle="default"
- className="m-r-5"
- style={{ width: '150px' }}
- onClick={this.toggleEditMode}
- >
- {btnText}
- </Button>);
}
+
render() {
- const dashboard = this.props.dashboard;
+ const {
+ dashboardTitle,
+ layout,
+ filters,
+ expandedSlices,
+ css,
+ onUndo,
+ onRedo,
+ undoLength,
+ redoLength,
+ onChange,
+ onSave,
+ updateCss,
+ editMode,
+ showBuilderPane,
+ dashboardInfo,
+ hasUnsavedChanges,
+ } = this.props;
+
+ const userCanEdit = dashboardInfo.dash_edit_perm;
+ const userCanSaveAs = dashboardInfo.dash_save_perm;
+ const popButton = hasUnsavedChanges;
+
return (
- <div className="title">
- <div className="pull-left">
- <h1 className="outer-container pull-left">
- <EditableTitle
- title={dashboard.dashboard_title}
- canEdit={dashboard.dash_save_perm && this.props.editMode}
- onSaveTitle={this.handleSaveTitle}
- showTooltip={this.props.editMode}
- />
- <span className="favstar m-r-5">
- <FaveStar
- itemId={dashboard.id}
- fetchFaveStar={this.props.fetchFaveStar}
- saveFaveStar={this.props.saveFaveStar}
- isStarred={this.props.isStarred}
- />
- </span>
- {this.renderUnsaved()}
- </h1>
- </div>
- <div className="pull-right" style={{ marginTop: '35px' }}>
- {this.renderEditButton()}
- <Controls
- dashboard={dashboard}
- filters={this.props.filters}
- userId={this.props.userId}
- addSlicesToDashboard={this.props.addSlicesToDashboard}
- onSave={this.props.onSave}
- onChange={this.props.onChange}
- renderSlices={this.props.renderSlices}
- serialize={this.props.serialize}
- startPeriodicRender={this.props.startPeriodicRender}
- editMode={this.props.editMode}
+ <div className="dashboard-header">
+ <div className="dashboard-component-header header-large">
+ <EditableTitle
+ title={dashboardTitle}
+ canEdit={userCanEdit && editMode}
+ onSaveTitle={this.handleChangeText}
+ showTooltip={false}
/>
+ <span className="favstar">
+ <FaveStar
+ itemId={dashboardInfo.id}
+ fetchFaveStar={this.props.fetchFaveStar}
+ saveFaveStar={this.props.saveFaveStar}
+ isStarred={this.props.isStarred}
+ />
+ </span>
</div>
- <div className="clearfix" />
+
+ {userCanSaveAs && (
+ <div className="button-container">
+ {editMode && (
+ <Button
+ bsSize="small"
+ onClick={onUndo}
+ disabled={undoLength < 1}
+ bsStyle={this.state.emphasizeUndo ? 'primary' : undefined}
+ >
+ <div title="Undo" className="undo-action fa fa-reply" />
+ </Button>
+ )}
+
+ {editMode && (
+ <Button
+ bsSize="small"
+ onClick={onRedo}
+ disabled={redoLength < 1}
+ bsStyle={this.state.emphasizeRedo ? 'primary' : undefined}
+ >
+ <div title="Redo" className="redo-action fa fa-share" />
+ </Button>
+ )}
+
+ {editMode && (
+ <Button bsSize="small" onClick={this.props.toggleBuilderPane}>
+ {showBuilderPane
+ ? t('Hide components')
+ : t('Insert components')}
+ </Button>
+ )}
+
+ {editMode &&
+ hasUnsavedChanges && (
+ <Button
+ bsSize="small"
+ bsStyle={popButton ? 'primary' : undefined}
+ onClick={this.overwriteDashboard}
+ >
+ {t('Save changes')}
+ </Button>
+ )}
+
+ {!editMode &&
+ !hasUnsavedChanges && (
+ <Button
+ bsSize="small"
+ onClick={this.toggleEditMode}
+ bsStyle={popButton ? 'primary' : undefined}
+ disabled={!userCanEdit}
+ >
+ {t('Edit dashboard')}
+ </Button>
+ )}
+
+ {editMode &&
+ !hasUnsavedChanges && (
+ <Button
+ bsSize="small"
+ onClick={this.toggleEditMode}
+ bsStyle={undefined}
+ disabled={!userCanEdit}
+ >
+ {t('Switch to view mode')}
+ </Button>
+ )}
+
+ <HeaderActionsDropdown
+ addSuccessToast={this.props.addSuccessToast}
+ addDangerToast={this.props.addDangerToast}
+ dashboardId={dashboardInfo.id}
+ dashboardTitle={dashboardTitle}
+ layout={layout}
+ filters={filters}
+ expandedSlices={expandedSlices}
+ css={css}
+ onSave={onSave}
+ onChange={onChange}
+ forceRefreshAllCharts={this.forceRefresh}
+ startPeriodicRender={this.props.startPeriodicRender}
+ updateCss={updateCss}
+ editMode={editMode}
+ hasUnsavedChanges={hasUnsavedChanges}
+ userCanEdit={userCanEdit}
+ />
+
+ {editMode && (
+ <UndoRedoKeylisteners
+ onUndo={this.handleCtrlZ}
+ onRedo={this.handleCtrlY}
+ />
+ )}
+ </div>
+ )}
</div>
);
}
}
+
Header.propTypes = propTypes;
export default Header;
diff --git a/superset/assets/src/dashboard/components/HeaderActionsDropdown.jsx b/superset/assets/src/dashboard/components/HeaderActionsDropdown.jsx
new file mode 100644
index 0000000000..dab11c382f
--- /dev/null
+++ b/superset/assets/src/dashboard/components/HeaderActionsDropdown.jsx
@@ -0,0 +1,160 @@
+/* global window */
+import React from 'react';
+import PropTypes from 'prop-types';
+import $ from 'jquery';
+import { DropdownButton, MenuItem } from 'react-bootstrap';
+
+import CssEditor from './CssEditor';
+import RefreshIntervalModal from './RefreshIntervalModal';
+import SaveModal from './SaveModal';
+import injectCustomCss from '../util/injectCustomCss';
+import { SAVE_TYPE_NEWDASHBOARD } from '../util/constants';
+import { t } from '../../locales';
+
+const propTypes = {
+ addSuccessToast: PropTypes.func.isRequired,
+ addDangerToast: PropTypes.func.isRequired,
+ dashboardId: PropTypes.number.isRequired,
+ dashboardTitle: PropTypes.string.isRequired,
+ hasUnsavedChanges: PropTypes.bool.isRequired,
+ css: PropTypes.string.isRequired,
+ onChange: PropTypes.func.isRequired,
+ updateCss: PropTypes.func.isRequired,
+ forceRefreshAllCharts: PropTypes.func.isRequired,
+ startPeriodicRender: PropTypes.func.isRequired,
+ editMode: PropTypes.bool.isRequired,
+ userCanEdit: PropTypes.bool.isRequired,
+ layout: PropTypes.object.isRequired,
+ filters: PropTypes.object.isRequired,
+ expandedSlices: PropTypes.object.isRequired,
+ onSave: PropTypes.func.isRequired,
+};
+
+const defaultProps = {};
+
+class HeaderActionsDropdown extends React.PureComponent {
+ static discardChanges() {
+ window.location.reload();
+ }
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ css: props.css,
+ cssTemplates: [],
+ };
+
+ this.changeCss = this.changeCss.bind(this);
+ }
+
+ componentWillMount() {
+ injectCustomCss(this.state.css);
+
+ $.get('/csstemplateasyncmodelview/api/read', data => {
+ const cssTemplates = data.result.map(row => ({
+ value: row.template_name,
+ css: row.css,
+ label: row.template_name,
+ }));
+ this.setState({ cssTemplates });
+ });
+ }
+
+ changeCss(css) {
+ this.setState({ css }, () => {
+ injectCustomCss(css);
+ });
+ this.props.onChange();
+ this.props.updateCss(css);
+ }
+
+ render() {
+ const {
+ dashboardTitle,
+ dashboardId,
+ startPeriodicRender,
+ forceRefreshAllCharts,
+ editMode,
+ css,
+ hasUnsavedChanges,
+ layout,
+ filters,
+ expandedSlices,
+ onSave,
+ userCanEdit,
+ } = this.props;
+
+ const emailBody = t('Check out this dashboard: %s', window.location.href);
+ const emailLink = `mailto:?Subject=Superset%20Dashboard%20${dashboardTitle}&Body=${emailBody}`;
+
+ return (
+ <DropdownButton
+ title=""
+ id="save-dash-split-button"
+ bsStyle={hasUnsavedChanges ? 'primary' : undefined}
+ bsSize="small"
+ pullRight
+ >
+ <SaveModal
+ addSuccessToast={this.props.addSuccessToast}
+ addDangerToast={this.props.addDangerToast}
+ dashboardId={dashboardId}
+ dashboardTitle={dashboardTitle}
+ saveType={SAVE_TYPE_NEWDASHBOARD}
+ layout={layout}
+ filters={filters}
+ expandedSlices={expandedSlices}
+ css={css}
+ onSave={onSave}
+ isMenuItem
+ triggerNode={<span>{t('Save as')}</span>}
+ canOverwrite={userCanEdit}
+ />
+ {hasUnsavedChanges && (
+ <MenuItem
+ eventKey="discard"
+ onSelect={HeaderActionsDropdown.discardChanges}
+ >
+ {t('Discard changes')}
+ </MenuItem>
+ )}
+
+ <MenuItem divider />
+
+ <MenuItem onClick={forceRefreshAllCharts}>
+ {t('Force refresh dashboard')}
+ </MenuItem>
+ <RefreshIntervalModal
+ onChange={refreshInterval =>
+ startPeriodicRender(refreshInterval * 1000)
+ }
+ triggerNode={<span>{t('Set auto-refresh interval')}</span>}
+ />
+ {editMode && (
+ <MenuItem
+ target="_blank"
+ href={`/dashboardmodelview/edit/${dashboardId}`}
+ >
+ {t('Edit dashboard metadata')}
+ </MenuItem>
+ )}
+ {editMode && (
+ <MenuItem href={emailLink}>{t('Email dashboard link')}</MenuItem>
+ )}
+ {editMode && (
+ <CssEditor
+ triggerNode={<span>{t('Edit CSS')}</span>}
+ initialCss={this.state.css}
+ templates={this.state.cssTemplates}
+ onChange={this.changeCss}
+ />
+ )}
+ </DropdownButton>
+ );
+ }
+}
+
+HeaderActionsDropdown.propTypes = propTypes;
+HeaderActionsDropdown.defaultProps = defaultProps;
+
+export default HeaderActionsDropdown;
diff --git a/superset/assets/src/dashboard/components/IconButton.jsx b/superset/assets/src/dashboard/components/IconButton.jsx
new file mode 100644
index 0000000000..18fd3b1030
--- /dev/null
+++ b/superset/assets/src/dashboard/components/IconButton.jsx
@@ -0,0 +1,44 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+const propTypes = {
+ onClick: PropTypes.func.isRequired,
+ className: PropTypes.string,
+ label: PropTypes.string,
+};
+
+const defaultProps = {
+ className: null,
+ label: null,
+};
+
+export default class IconButton extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.handleClick = this.handleClick.bind(this);
+ }
+
+ handleClick(event) {
+ event.preventDefault();
+ const { onClick } = this.props;
+ onClick(event);
+ }
+
+ render() {
+ const { className, label } = this.props;
+ return (
+ <div
+ className="icon-button"
+ onClick={this.handleClick}
+ tabIndex="0"
+ role="button"
+ >
+ <span className={className} />
+ {label && <span className="icon-button-label">{label}</span>}
+ </div>
+ );
+ }
+}
+
+IconButton.propTypes = propTypes;
+IconButton.defaultProps = defaultProps;
diff --git a/superset/assets/src/dashboard/components/MissingChart.jsx b/superset/assets/src/dashboard/components/MissingChart.jsx
new file mode 100644
index 0000000000..c45c445167
--- /dev/null
+++ b/superset/assets/src/dashboard/components/MissingChart.jsx
@@ -0,0 +1,29 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import Loading from '../../components/Loading';
+import { t } from '../../locales';
+
+const propTypes = {
+ height: PropTypes.number.isRequired,
+};
+
+export default function MissingChart({ height }) {
+ return (
+ <div className="missing-chart-container" style={{ height: height + 20 }}>
+ <div className="loading-container">
+ <Loading />
+ </div>
+ <div className="missing-chart-body">
+ {t(
+ 'There is no chart definition associated with this component, could it have been deleted?',
+ )}
+ <br />
+ <br />
+ {t('Delete this container and save to remove this message.')}
+ </div>
+ </div>
+ );
+}
+
+MissingChart.propTypes = propTypes;
diff --git a/superset/assets/src/dashboard/components/RefreshIntervalModal.jsx b/superset/assets/src/dashboard/components/RefreshIntervalModal.jsx
index 4cba010d95..1ed4f82bb4 100644
--- a/superset/assets/src/dashboard/components/RefreshIntervalModal.jsx
+++ b/superset/assets/src/dashboard/components/RefreshIntervalModal.jsx
@@ -16,11 +16,16 @@ const defaultProps = {
};
const options = [
- [0, t('Don\'t refresh')],
+ [0, t("Don't refresh")],
[10, t('10 seconds')],
[30, t('30 seconds')],
[60, t('1 minute')],
[300, t('5 minutes')],
+ [1800, t('30 minutes')],
+ [3600, t('1 hour')],
+ [21600, t('6 hours')],
+ [43200, t('12 hours')],
+ [86400, t('24 hours')],
].map(o => ({ value: o[0], label: o[1] }));
class RefreshIntervalModal extends React.PureComponent {
@@ -42,9 +47,12 @@ class RefreshIntervalModal extends React.PureComponent {
<Select
options={options}
value={this.state.refreshFrequency}
- onChange={(opt) => {
- this.setState({ refreshFrequency: opt.value });
- this.props.onChange(opt.value);
+ onChange={opt => {
+ const value = opt ? opt.value : options[0].value;
+ this.setState({
+ refreshFrequency: value,
+ });
+ this.props.onChange(value);
}}
/>
</div>
diff --git a/superset/assets/src/dashboard/components/SaveModal.jsx b/superset/assets/src/dashboard/components/SaveModal.jsx
index d693385455..8194f4662e 100644
--- a/superset/assets/src/dashboard/components/SaveModal.jsx
+++ b/superset/assets/src/dashboard/components/SaveModal.jsx
@@ -1,128 +1,129 @@
-/* global notify */
+/* eslint-env browser */
import React from 'react';
import PropTypes from 'prop-types';
+
import { Button, FormControl, FormGroup, Radio } from 'react-bootstrap';
-import { getAjaxErrorMsg } from '../../modules/utils';
import ModalTrigger from '../../components/ModalTrigger';
import { t } from '../../locales';
import Checkbox from '../../components/Checkbox';
-
-const $ = window.$ = require('jquery');
+import { SAVE_TYPE_OVERWRITE, SAVE_TYPE_NEWDASHBOARD } from '../util/constants';
const propTypes = {
- css: PropTypes.string,
- dashboard: PropTypes.object.isRequired,
+ addSuccessToast: PropTypes.func.isRequired,
+ addDangerToast: PropTypes.func.isRequired,
+ dashboardId: PropTypes.number.isRequired,
+ dashboardTitle: PropTypes.string.isRequired,
+ expandedSlices: PropTypes.object.isRequired,
+ layout: PropTypes.object.isRequired,
+ saveType: PropTypes.oneOf([SAVE_TYPE_OVERWRITE, SAVE_TYPE_NEWDASHBOARD]),
triggerNode: PropTypes.node.isRequired,
filters: PropTypes.object.isRequired,
- serialize: PropTypes.func,
- onSave: PropTypes.func,
+ css: PropTypes.string.isRequired,
+ onSave: PropTypes.func.isRequired,
+ isMenuItem: PropTypes.bool,
+ canOverwrite: PropTypes.bool.isRequired,
+};
+
+const defaultProps = {
+ isMenuItem: false,
+ saveType: SAVE_TYPE_OVERWRITE,
};
class SaveModal extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
- dashboard: props.dashboard,
- css: props.css,
- saveType: 'overwrite',
- newDashName: props.dashboard.dashboard_title + ' [copy]',
+ saveType: props.saveType,
+ newDashName: `${props.dashboardTitle} [copy]`,
duplicateSlices: false,
};
this.modal = null;
this.handleSaveTypeChange = this.handleSaveTypeChange.bind(this);
this.handleNameChange = this.handleNameChange.bind(this);
this.saveDashboard = this.saveDashboard.bind(this);
+ this.setModalRef = this.setModalRef.bind(this);
+ this.toggleDuplicateSlices = this.toggleDuplicateSlices.bind(this);
+ this.onSave = this.props.onSave.bind(this);
+ }
+
+ setModalRef(ref) {
+ this.modal = ref;
}
+
toggleDuplicateSlices() {
this.setState({ duplicateSlices: !this.state.duplicateSlices });
}
+
handleSaveTypeChange(event) {
this.setState({
saveType: event.target.value,
});
}
+
handleNameChange(event) {
this.setState({
newDashName: event.target.value,
- saveType: 'newDashboard',
+ saveType: SAVE_TYPE_NEWDASHBOARD,
});
}
- saveDashboardRequest(data, url, saveType) {
- const saveModal = this.modal;
- const onSaveDashboard = this.props.onSave;
- Object.assign(data, { css: this.props.css });
- $.ajax({
- type: 'POST',
- url,
- data: {
- data: JSON.stringify(data),
- },
- success(resp) {
- saveModal.close();
- onSaveDashboard();
- if (saveType === 'newDashboard') {
- window.location = `/superset/dashboard/${resp.id}/`;
- } else {
- notify.success(t('This dashboard was saved successfully.'));
- }
- },
- error(error) {
- saveModal.close();
- const errorMsg = getAjaxErrorMsg(error);
- notify.error(t('Sorry, there was an error saving this dashboard: ') + errorMsg);
- },
- });
- }
- saveDashboard(saveType, newDashboardTitle) {
- const dashboard = this.props.dashboard;
- const positions = this.props.serialize();
+
+ saveDashboard() {
+ const { saveType, newDashName } = this.state;
+ const {
+ dashboardTitle,
+ layout: positions,
+ css,
+ expandedSlices,
+ filters,
+ dashboardId,
+ } = this.props;
+
const data = {
positions,
- css: this.state.css,
- expanded_slices: dashboard.metadata.expanded_slices || {},
- dashboard_title: dashboard.dashboard_title,
- default_filters: JSON.stringify(this.props.filters),
+ css,
+ expanded_slices: expandedSlices,
+ dashboard_title:
+ saveType === SAVE_TYPE_NEWDASHBOARD ? newDashName : dashboardTitle,
+ default_filters: JSON.stringify(filters),
duplicate_slices: this.state.duplicateSlices,
};
- let url = null;
- if (saveType === 'overwrite') {
- url = `/superset/save_dash/${dashboard.id}/`;
- this.saveDashboardRequest(data, url, saveType);
- } else if (saveType === 'newDashboard') {
- if (!newDashboardTitle) {
- this.modal.close();
- showModal({
- title: t('Error'),
- body: t('You must pick a name for the new dashboard'),
- });
- } else {
- data.dashboard_title = newDashboardTitle;
- url = `/superset/copy_dash/${dashboard.id}/`;
- this.saveDashboardRequest(data, url, saveType);
- }
+
+ if (saveType === SAVE_TYPE_NEWDASHBOARD && !newDashName) {
+ this.props.addDangerToast(
+ t('You must pick a name for the new dashboard'),
+ );
+ } else {
+ this.onSave(data, dashboardId, saveType).done(resp => {
+ if (saveType === SAVE_TYPE_NEWDASHBOARD) {
+ window.location = `/superset/dashboard/${resp.id}/`;
+ }
+ });
+ this.modal.close();
}
}
+
render() {
return (
<ModalTrigger
- ref={(modal) => { this.modal = modal; }}
- isMenuItem
+ ref={this.setModalRef}
+ isMenuItem={this.props.isMenuItem}
triggerNode={this.props.triggerNode}
modalTitle={t('Save Dashboard')}
modalBody={
<FormGroup>
<Radio
- value="overwrite"
+ value={SAVE_TYPE_OVERWRITE}
onChange={this.handleSaveTypeChange}
- checked={this.state.saveType === 'overwrite'}
+ checked={this.state.saveType === SAVE_TYPE_OVERWRITE}
+ disabled={!this.props.canOverwrite}
>
- {t('Overwrite Dashboard [%s]', this.props.dashboard.dashboard_title)}
+ {t('Overwrite Dashboard [%s]', this.props.dashboardTitle)}
</Radio>
<hr />
<Radio
- value="newDashboard"
+ value={SAVE_TYPE_NEWDASHBOARD}
onChange={this.handleSaveTypeChange}
- checked={this.state.saveType === 'newDashboard'}
+ checked={this.state.saveType === SAVE_TYPE_NEWDASHBOARD}
>
{t('Save as:')}
</Radio>
@@ -136,18 +137,15 @@ class SaveModal extends React.PureComponent {
<div className="m-l-25 m-t-5">
<Checkbox
checked={this.state.duplicateSlices}
- onChange={this.toggleDuplicateSlices.bind(this)}
+ onChange={this.toggleDuplicateSlices}
/>
- <span className="m-l-5">also copy (duplicate) charts</span>
+ <span className="m-l-5">{t('also copy (duplicate) charts')}</span>
</div>
</FormGroup>
}
modalFooter={
<div>
- <Button
- bsStyle="primary"
- onClick={() => { this.saveDashboard(this.state.saveType, this.state.newDashName); }}
- >
+ <Button bsStyle="primary" onClick={this.saveDashboard}>
{t('Save')}
</Button>
</div>
@@ -156,6 +154,8 @@ class SaveModal extends React.PureComponent {
);
}
}
+
SaveModal.propTypes = propTypes;
+SaveModal.defaultProps = defaultProps;
export default SaveModal;
diff --git a/superset/assets/src/dashboard/components/SliceAdder.jsx b/superset/assets/src/dashboard/components/SliceAdder.jsx
index e99d00f339..f237a06dd2 100644
--- a/superset/assets/src/dashboard/components/SliceAdder.jsx
+++ b/superset/assets/src/dashboard/components/SliceAdder.jsx
@@ -1,219 +1,255 @@
+/* eslint-env browser */
import React from 'react';
-import $ from 'jquery';
import PropTypes from 'prop-types';
-import { BootstrapTable, TableHeaderColumn } from 'react-bootstrap-table';
-
-import ModalTrigger from '../../components/ModalTrigger';
-import { t } from '../../locales';
-
-require('react-bootstrap-table/css/react-bootstrap-table.css');
+import { DropdownButton, MenuItem } from 'react-bootstrap';
+import { CellMeasurer, CellMeasurerCache, List } from 'react-virtualized';
+import SearchInput, { createFilter } from 'react-search-input';
+
+import AddSliceCard from './AddSliceCard';
+import AddSliceDragPreview from './dnd/AddSliceDragPreview';
+import DragDroppable from './dnd/DragDroppable';
+import Loading from '../../components/Loading';
+import { CHART_TYPE, NEW_COMPONENT_SOURCE_TYPE } from '../util/componentTypes';
+import { NEW_CHART_ID, NEW_COMPONENTS_SOURCE_ID } from '../util/constants';
+import { slicePropShape } from '../util/propShapes';
const propTypes = {
- dashboard: PropTypes.object.isRequired,
- triggerNode: PropTypes.node.isRequired,
+ fetchAllSlices: PropTypes.func.isRequired,
+ isLoading: PropTypes.bool.isRequired,
+ slices: PropTypes.objectOf(slicePropShape).isRequired,
+ lastUpdated: PropTypes.number.isRequired,
+ errorMessage: PropTypes.string,
userId: PropTypes.string.isRequired,
- addSlicesToDashboard: PropTypes.func,
+ selectedSliceIds: PropTypes.arrayOf(PropTypes.number).isRequired,
+ editMode: PropTypes.bool,
+ height: PropTypes.number,
+};
+
+const defaultProps = {
+ selectedSliceIds: [],
+ editMode: false,
+ errorMessage: '',
+ height: window.innerHeight,
};
+const KEYS_TO_FILTERS = ['slice_name', 'viz_type', 'datasource_name'];
+const KEYS_TO_SORT = [
+ { key: 'slice_name', label: 'Name' },
+ { key: 'viz_type', label: 'Vis type' },
+ { key: 'datasource_name', label: 'Datasource' },
+ { key: 'changed_on', label: 'Recent' },
+];
+
+const MARGIN_BOTTOM = 16;
+const SIDEPANE_HEADER_HEIGHT = 55;
+const SLICE_ADDER_CONTROL_HEIGHT = 64;
+const DEFAULT_CELL_HEIGHT = 136;
+
+const cache = new CellMeasurerCache({
+ defaultHeight: DEFAULT_CELL_HEIGHT,
+ fixedWidth: true,
+});
+
class SliceAdder extends React.Component {
+ static sortByComparator(attr) {
+ const desc = attr === 'changed_on' ? -1 : 1;
+
+ return (a, b) => {
+ if (a[attr] < b[attr]) {
+ return -1 * desc;
+ } else if (a[attr] > b[attr]) {
+ return 1 * desc;
+ }
+ return 0;
+ };
+ }
+
constructor(props) {
super(props);
this.state = {
- slices: [],
- slicesLoaded: false,
- selectionMap: {},
+ filteredSlices: [],
+ searchTerm: '',
+ sortBy: KEYS_TO_SORT.findIndex(item => item.key === 'changed_on'),
+ selectedSliceIdsSet: new Set(props.selectedSliceIds),
};
- this.options = {
- defaultSortOrder: 'desc',
- defaultSortName: 'modified',
- sizePerPage: 10,
- };
+ this.rowRenderer = this.rowRenderer.bind(this);
+ this.searchUpdated = this.searchUpdated.bind(this);
+ this.handleKeyPress = this.handleKeyPress.bind(this);
+ this.handleSelect = this.handleSelect.bind(this);
+ }
+
+ componentDidMount() {
+ this.slicesRequest = this.props.fetchAllSlices(this.props.userId);
+ }
- this.addSlices = this.addSlices.bind(this);
- this.toggleSlice = this.toggleSlice.bind(this);
+ componentWillReceiveProps(nextProps) {
+ const nextState = {};
+ if (nextProps.lastUpdated !== this.props.lastUpdated) {
+ nextState.filteredSlices = Object.values(nextProps.slices)
+ .filter(createFilter(this.state.searchTerm, KEYS_TO_FILTERS))
+ .sort(SliceAdder.sortByComparator(KEYS_TO_SORT[this.state.sortBy].key));
+ }
- this.selectRowProp = {
- mode: 'checkbox',
- clickToSelect: true,
- onSelect: this.toggleSlice,
- };
+ if (nextProps.selectedSliceIds !== this.props.selectedSliceIds) {
+ nextState.selectedSliceIdsSet = new Set(nextProps.selectedSliceIds);
+ }
+
+ if (Object.keys(nextState).length) {
+ this.setState(nextState);
+ }
}
componentWillUnmount() {
- if (this.slicesRequest) {
+ if (this.slicesRequest && this.slicesRequest.abort) {
this.slicesRequest.abort();
}
}
- onEnterModal() {
- const uri = `/sliceaddview/api/read?_flt_0_created_by=${this.props.userId}`;
- this.slicesRequest = $.ajax({
- url: uri,
- type: 'GET',
- success: (response) => {
- // Prepare slice data for table
- const slices = response.result.map(slice => ({
- id: slice.id,
- sliceName: slice.slice_name,
- vizType: slice.viz_type,
- datasourceLink: slice.datasource_link,
- modified: slice.modified,
- }));
-
- this.setState({
- slices,
- selectionMap: {},
- slicesLoaded: true,
- });
- },
- error: (error) => {
- this.errored = true;
- this.setState({
- errorMsg: t('Sorry, there was an error fetching charts to this dashboard: ') +
- this.getAjaxErrorMsg(error),
- });
- },
- });
+ getFilteredSortedSlices(searchTerm, sortBy) {
+ return Object.values(this.props.slices)
+ .filter(createFilter(searchTerm, KEYS_TO_FILTERS))
+ .sort(SliceAdder.sortByComparator(KEYS_TO_SORT[sortBy].key));
}
- getAjaxErrorMsg(error) {
- const respJSON = error.responseJSON;
- return (respJSON && respJSON.message) ? respJSON.message :
- error.responseText;
+ handleKeyPress(ev) {
+ if (ev.key === 'Enter') {
+ ev.preventDefault();
+
+ this.searchUpdated(ev.target.value);
+ }
}
- addSlices() {
- const adder = this;
- this.props.addSlicesToDashboard(Object.keys(this.state.selectionMap))
- // if successful, page will be reloaded.
- .fail((error) => {
- adder.errored = true;
- adder.setState({
- errorMsg: t('Sorry, there was an error adding charts to this dashboard: ') +
- this.getAjaxErrorMsg(error),
- });
- });
+ searchUpdated(searchTerm) {
+ this.setState({
+ searchTerm,
+ filteredSlices: this.getFilteredSortedSlices(
+ searchTerm,
+ this.state.sortBy,
+ ),
+ });
}
- toggleSlice(slice) {
- const selectionMap = Object.assign({}, this.state.selectionMap);
- selectionMap[slice.id] = !selectionMap[slice.id];
- this.setState({ selectionMap });
+ handleSelect(sortBy) {
+ this.setState({
+ sortBy,
+ filteredSlices: this.getFilteredSortedSlices(
+ this.state.searchTerm,
+ sortBy,
+ ),
+ });
}
- modifiedDateComparator(a, b, order) {
- if (order === 'desc') {
- if (a.changed_on > b.changed_on) {
- return -1;
- } else if (a.changed_on < b.changed_on) {
- return 1;
- }
- return 0;
- }
+ rowRenderer({ key, index, style, parent }) {
+ const { filteredSlices, selectedSliceIdsSet } = this.state;
+ const cellData = filteredSlices[index];
+ const isSelected = selectedSliceIdsSet.has(cellData.slice_id);
+ const type = CHART_TYPE;
+ const id = NEW_CHART_ID;
- if (a.changed_on < b.changed_on) {
- return -1;
- } else if (a.changed_on > b.changed_on) {
- return 1;
- }
- return 0;
- }
+ const meta = {
+ chartId: cellData.slice_id,
+ sliceName: cellData.slice_name,
+ };
- render() {
- const hideLoad = this.state.slicesLoaded || this.errored;
- let enableAddSlice = this.state.selectionMap && Object.keys(this.state.selectionMap);
- if (enableAddSlice) {
- enableAddSlice = enableAddSlice.some(function (key) {
- return this.state.selectionMap[key];
- }, this);
- }
- const modalContent = (
- <div>
- <img
- src="/static/assets/images/loading.gif"
- className={'loading ' + (hideLoad ? 'hidden' : '')}
- alt={hideLoad ? '' : 'loading'}
- />
- <div className={this.errored ? '' : 'hidden'}>
- {this.state.errorMsg}
- </div>
- <div className={this.state.slicesLoaded ? '' : 'hidden'}>
- <BootstrapTable
- ref="table"
- data={this.state.slices}
- selectRow={this.selectRowProp}
- options={this.options}
- hover
- search
- pagination
- condensed
- height="auto"
+ return (
+ <DragDroppable
+ key={key}
+ component={{ type, id, meta }}
+ parentComponent={{
+ id: NEW_COMPONENTS_SOURCE_ID,
+ type: NEW_COMPONENT_SOURCE_TYPE,
+ }}
+ index={index}
+ depth={0}
+ disableDragDrop={isSelected}
+ editMode={this.props.editMode}
+ // we must use a custom drag preview within the List because
+ // it does not seem to work within a fixed-position container
+ useEmptyDragPreview
+ // List library expect style props here
+ // actual style should be applied to nested AddSliceCard component
+ style={{}}
+ >
+ {({ dragSourceRef }) => (
+ <CellMeasurer
+ cache={cache}
+ columnIndex={0}
+ key={key}
+ parent={parent}
+ rowIndex={index}
>
- <TableHeaderColumn
- dataField="id"
- isKey
- dataSort
- hidden
+ <AddSliceCard
+ innerRef={dragSourceRef}
+ style={style}
+ sliceName={cellData.slice_name}
+ lastModified={cellData.modified}
+ visType={cellData.viz_type}
+ datasourceLink={cellData.datasource_link}
+ isSelected={isSelected}
/>
- <TableHeaderColumn
- dataField="sliceName"
- dataSort
- >
- {t('Name')}
- </TableHeaderColumn>
- <TableHeaderColumn
- dataField="vizType"
- dataSort
- >
- {t('Viz')}
- </TableHeaderColumn>
- <TableHeaderColumn
- dataField="datasourceLink"
- dataSort
- // Will cause react-bootstrap-table to interpret the HTML returned
- dataFormat={datasourceLink => datasourceLink}
- >
- {t('Datasource')}
- </TableHeaderColumn>
- <TableHeaderColumn
- dataField="modified"
- dataSort
- sortFunc={this.modifiedDateComparator}
- // Will cause react-bootstrap-table to interpret the HTML returned
- dataFormat={modified => modified}
- >
- {t('Modified')}
- </TableHeaderColumn>
- </BootstrapTable>
- <button
- type="button"
- className="btn btn-default"
- data-dismiss="modal"
- onClick={this.addSlices}
- disabled={!enableAddSlice}
- >
- {t('Add Charts')}
- </button>
- </div>
- </div>
+ </CellMeasurer>
+ )}
+ </DragDroppable>
);
+ }
+ render() {
+ const slicesListHeight =
+ this.props.height -
+ SIDEPANE_HEADER_HEIGHT -
+ SLICE_ADDER_CONTROL_HEIGHT -
+ MARGIN_BOTTOM;
return (
- <ModalTrigger
- triggerNode={this.props.triggerNode}
- tooltip={t('Add a new chart to the dashboard')}
- beforeOpen={this.onEnterModal.bind(this)}
- isMenuItem
- modalBody={modalContent}
- bsSize="large"
- setModalAsTriggerChildren
- modalTitle={t('Add Charts to Dashboard')}
- />
+ <div className="slice-adder-container">
+ <div className="controls">
+ <SearchInput
+ placeholder="Filter your charts"
+ className="search-input"
+ onChange={this.searchUpdated}
+ onKeyPress={this.handleKeyPress}
+ />
+
+ <DropdownButton
+ title={`Sort by ${KEYS_TO_SORT[this.state.sortBy].label}`}
+ onSelect={this.handleSelect}
+ id="slice-adder-sortby"
+ >
+ {KEYS_TO_SORT.map((item, index) => (
+ <MenuItem key={item.key} eventKey={index}>
+ Sort by {item.label}
+ </MenuItem>
+ ))}
+ </DropdownButton>
+ </div>
+
+ {this.props.isLoading && <Loading />}
+
+ {this.props.errorMessage && <div>{this.props.errorMessage}</div>}
+
+ {!this.props.isLoading &&
+ this.state.filteredSlices.length > 0 && (
+ <List
+ width={376}
+ height={slicesListHeight}
+ rowCount={this.state.filteredSlices.length}
+ deferredMeasurementCache={cache}
+ rowHeight={cache.rowHeight}
+ rowRenderer={this.rowRenderer}
+ searchTerm={this.state.searchTerm}
+ sortBy={this.state.sortBy}
+ selectedSliceIds={this.props.selectedSliceIds}
+ />
+ )}
+
+ {/* Drag preview is just a single fixed-position element */}
+ <AddSliceDragPreview slices={this.state.filteredSlices} />
+ </div>
);
}
}
SliceAdder.propTypes = propTypes;
+SliceAdder.defaultProps = defaultProps;
export default SliceAdder;
diff --git a/superset/assets/src/dashboard/components/SliceHeader.jsx b/superset/assets/src/dashboard/components/SliceHeader.jsx
index 6db9c683c9..7631ba867e 100644
--- a/superset/assets/src/dashboard/components/SliceHeader.jsx
+++ b/superset/assets/src/dashboard/components/SliceHeader.jsx
@@ -1,20 +1,18 @@
import React from 'react';
import PropTypes from 'prop-types';
-import moment from 'moment';
-import { connect } from 'react-redux';
import { t } from '../../locales';
import EditableTitle from '../../components/EditableTitle';
import TooltipWrapper from '../../components/TooltipWrapper';
+import SliceHeaderControls from './SliceHeaderControls';
const propTypes = {
+ innerRef: PropTypes.func,
slice: PropTypes.object.isRequired,
- supersetCanExplore: PropTypes.bool,
- sliceCanEdit: PropTypes.bool,
isExpanded: PropTypes.bool,
isCached: PropTypes.bool,
cachedDttm: PropTypes.string,
- removeSlice: PropTypes.func,
+ updatedDttm: PropTypes.number,
updateSliceName: PropTypes.func,
toggleExpandSlice: PropTypes.func,
forceRefresh: PropTypes.func,
@@ -23,9 +21,13 @@ const propTypes = {
editMode: PropTypes.bool,
annotationQuery: PropTypes.object,
annotationError: PropTypes.object,
+ sliceName: PropTypes.string,
+ supersetCanExplore: PropTypes.bool,
+ sliceCanEdit: PropTypes.bool,
};
const defaultProps = {
+ innerRef: null,
forceRefresh: () => ({}),
removeSlice: () => ({}),
updateSliceName: () => ({}),
@@ -33,147 +35,89 @@ const defaultProps = {
exploreChart: () => ({}),
exportCSV: () => ({}),
editMode: false,
+ annotationQuery: {},
+ annotationError: {},
+ cachedDttm: null,
+ updatedDttm: null,
+ isCached: false,
+ isExpanded: false,
+ sliceName: '',
+ supersetCanExplore: false,
+ sliceCanEdit: false,
};
-class SliceHeader extends React.PureComponent {
- constructor(props) {
- super(props);
-
- this.onSaveTitle = this.onSaveTitle.bind(this);
- this.onToggleExpandSlice = this.onToggleExpandSlice.bind(this);
- this.exportCSV = this.props.exportCSV.bind(this, this.props.slice);
- this.exploreChart = this.props.exploreChart.bind(this, this.props.slice);
- this.forceRefresh = this.props.forceRefresh.bind(this, this.props.slice.slice_id);
- this.removeSlice = this.props.removeSlice.bind(this, this.props.slice);
- }
-
- onSaveTitle(newTitle) {
- if (this.props.updateSliceName) {
- this.props.updateSliceName(this.props.slice.slice_id, newTitle);
- }
- }
-
- onToggleExpandSlice() {
- this.props.toggleExpandSlice(this.props.slice, !this.props.isExpanded);
- }
+const annoationsLoading = t('Annotation layers are still loading.');
+const annoationsError = t('One ore more annotation layers failed loading.');
+class SliceHeader extends React.PureComponent {
render() {
- const slice = this.props.slice;
- const isCached = this.props.isCached;
- const cachedWhen = moment.utc(this.props.cachedDttm).fromNow();
- const refreshTooltip = isCached ?
- t('Served from data cached %s . Click to force refresh.', cachedWhen) :
- t('Force refresh data');
- const annoationsLoading = t('Annotation layers are still loading.');
- const annoationsError = t('One ore more annotation layers failed loading.');
+ const {
+ slice,
+ isExpanded,
+ isCached,
+ cachedDttm,
+ updatedDttm,
+ toggleExpandSlice,
+ forceRefresh,
+ exploreChart,
+ exportCSV,
+ innerRef,
+ sliceName,
+ supersetCanExplore,
+ sliceCanEdit,
+ editMode,
+ updateSliceName,
+ annotationQuery,
+ annotationError,
+ } = this.props;
return (
- <div className="row chart-header">
- <div className="col-md-12">
- <div className="header">
- <EditableTitle
- title={slice.slice_name}
- canEdit={!!this.props.updateSliceName && this.props.editMode}
- onSaveTitle={this.onSaveTitle}
- showTooltip={this.props.editMode}
- noPermitTooltip={'You don\'t have the rights to alter this dashboard.'}
- />
- {!!Object.values(this.props.annotationQuery || {}).length &&
- <TooltipWrapper
- label="annotations-loading"
- placement="top"
- tooltip={annoationsLoading}
- >
- <i className="fa fa-refresh warning" />
- </TooltipWrapper>
+ <div className="chart-header" ref={innerRef}>
+ <div className="header">
+ <EditableTitle
+ title={
+ sliceName ||
+ (editMode
+ ? '---' // this makes an empty title clickable
+ : '')
}
- {!!Object.values(this.props.annotationError || {}).length &&
- <TooltipWrapper
- label="annoation-errors"
- placement="top"
- tooltip={annoationsError}
- >
- <i className="fa fa-exclamation-circle danger" />
- </TooltipWrapper>
- }
- </div>
- <div className="chart-controls">
- <div id={'controls_' + slice.slice_id} className="pull-right">
- {this.props.editMode &&
- <a>
- <TooltipWrapper
- placement="top"
- label="move"
- tooltip={t('Move chart')}
- >
- <i className="fa fa-arrows drag" />
- </TooltipWrapper>
- </a>
- }
- <a className={`refresh ${isCached ? 'danger' : ''}`} onClick={this.forceRefresh}>
- <TooltipWrapper
- placement="top"
- label="refresh"
- tooltip={refreshTooltip}
- >
- <i className="fa fa-repeat" />
- </TooltipWrapper>
- </a>
- {slice.description &&
- <a onClick={this.onToggleExpandSlice}>
- <TooltipWrapper
- placement="top"
- label="description"
- tooltip={t('Toggle chart description')}
- >
- <i className="fa fa-info-circle slice_info" />
- </TooltipWrapper>
- </a>
- }
- {this.props.sliceCanEdit &&
- <a href={slice.edit_url} target="_blank">
- <TooltipWrapper
- placement="top"
- label="edit"
- tooltip={t('Edit chart')}
- >
- <i className="fa fa-pencil" />
- </TooltipWrapper>
- </a>
- }
- <a className="exportCSV" onClick={this.exportCSV}>
- <TooltipWrapper
- placement="top"
- label="exportCSV"
- tooltip={t('Export CSV')}
- >
- <i className="fa fa-table" />
- </TooltipWrapper>
- </a>
- {this.props.supersetCanExplore &&
- <a className="exploreChart" onClick={this.exploreChart}>
- <TooltipWrapper
- placement="top"
- label="exploreChart"
- tooltip={t('Explore chart')}
- >
- <i className="fa fa-share" />
- </TooltipWrapper>
- </a>
- }
- {this.props.editMode &&
- <a className="remove-chart" onClick={this.removeSlice}>
- <TooltipWrapper
- placement="top"
- label="close"
- tooltip={t('Remove chart from dashboard')}
- >
- <i className="fa fa-close" />
- </TooltipWrapper>
- </a>
- }
- </div>
- </div>
+ canEdit={editMode}
+ onSaveTitle={updateSliceName}
+ showTooltip={false}
+ />
+ {!!Object.values(annotationQuery).length && (
+ <TooltipWrapper
+ label="annotations-loading"
+ placement="top"
+ tooltip={annoationsLoading}
+ >
+ <i className="fa fa-refresh warning" />
+ </TooltipWrapper>
+ )}
+ {!!Object.values(annotationError).length && (
+ <TooltipWrapper
+ label="annoation-errors"
+ placement="top"
+ tooltip={annoationsError}
+ >
+ <i className="fa fa-exclamation-circle danger" />
+ </TooltipWrapper>
+ )}
+ {!editMode && (
+ <SliceHeaderControls
+ slice={slice}
+ isCached={isCached}
+ isExpanded={isExpanded}
+ cachedDttm={cachedDttm}
+ updatedDttm={updatedDttm}
+ toggleExpandSlice={toggleExpandSlice}
+ forceRefresh={forceRefresh}
+ exploreChart={exploreChart}
+ exportCSV={exportCSV}
+ supersetCanExplore={supersetCanExplore}
+ sliceCanEdit={sliceCanEdit}
+ />
+ )}
</div>
</div>
);
@@ -183,12 +127,4 @@ class SliceHeader extends React.PureComponent {
SliceHeader.propTypes = propTypes;
SliceHeader.defaultProps = defaultProps;
-function mapStateToProps({ dashboard }) {
- return {
- supersetCanExplore: dashboard.dashboard.superset_can_explore,
- sliceCanEdit: dashboard.dashboard.slice_can_edit,
- };
-}
-
-export { SliceHeader };
-export default connect(mapStateToProps, () => ({}))(SliceHeader);
+export default SliceHeader;
diff --git a/superset/assets/src/dashboard/components/SliceHeaderControls.jsx b/superset/assets/src/dashboard/components/SliceHeaderControls.jsx
new file mode 100644
index 0000000000..4116a3431f
--- /dev/null
+++ b/superset/assets/src/dashboard/components/SliceHeaderControls.jsx
@@ -0,0 +1,160 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import moment from 'moment';
+import { Dropdown, MenuItem } from 'react-bootstrap';
+import {
+ Logger,
+ LOG_ACTIONS_EXPLORE_DASHBOARD_CHART,
+ LOG_ACTIONS_EXPORT_CSV_DASHBOARD_CHART,
+ LOG_ACTIONS_REFRESH_CHART,
+} from '../../logger';
+
+import { t } from '../../locales';
+
+const propTypes = {
+ slice: PropTypes.object.isRequired,
+ isCached: PropTypes.bool,
+ isExpanded: PropTypes.bool,
+ cachedDttm: PropTypes.string,
+ updatedDttm: PropTypes.number,
+ supersetCanExplore: PropTypes.bool,
+ sliceCanEdit: PropTypes.bool,
+ toggleExpandSlice: PropTypes.func,
+ forceRefresh: PropTypes.func,
+ exploreChart: PropTypes.func,
+ exportCSV: PropTypes.func,
+};
+
+const defaultProps = {
+ forceRefresh: () => ({}),
+ toggleExpandSlice: () => ({}),
+ exploreChart: () => ({}),
+ exportCSV: () => ({}),
+ cachedDttm: null,
+ updatedDttm: null,
+ isCached: false,
+ isExpanded: false,
+ supersetCanExplore: false,
+ sliceCanEdit: false,
+};
+
+const VerticalDotsTrigger = () => (
+ <div className="vertical-dots-container">
+ <span className="dot" />
+ <span className="dot" />
+ <span className="dot" />
+ </div>
+);
+
+class SliceHeaderControls extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.exportCSV = this.exportCSV.bind(this);
+ this.exploreChart = this.exploreChart.bind(this);
+ this.toggleControls = this.toggleControls.bind(this);
+ this.refreshChart = this.refreshChart.bind(this);
+ this.toggleExpandSlice = this.props.toggleExpandSlice.bind(
+ this,
+ this.props.slice.slice_id,
+ );
+
+ this.state = {
+ showControls: false,
+ };
+ }
+
+ exportCSV() {
+ this.props.exportCSV(this.props.slice.slice_id);
+ Logger.append(
+ LOG_ACTIONS_EXPORT_CSV_DASHBOARD_CHART,
+ {
+ slice_id: this.props.slice.slice_id,
+ is_cached: this.props.isCached,
+ },
+ true,
+ );
+ }
+
+ exploreChart() {
+ this.props.exploreChart(this.props.slice.slice_id);
+ Logger.append(
+ LOG_ACTIONS_EXPLORE_DASHBOARD_CHART,
+ {
+ slice_id: this.props.slice.slice_id,
+ is_cached: this.props.isCached,
+ },
+ true,
+ );
+ }
+
+ refreshChart() {
+ this.props.forceRefresh(this.props.slice.slice_id);
+ Logger.append(LOG_ACTIONS_REFRESH_CHART, {
+ slice_id: this.props.slice.slice_id,
+ is_cached: this.props.isCached,
+ });
+ }
+
+ toggleControls() {
+ this.setState({
+ showControls: !this.state.showControls,
+ });
+ }
+
+ render() {
+ const { slice, isCached, cachedDttm, updatedDttm } = this.props;
+ const cachedWhen = moment.utc(cachedDttm).fromNow();
+ const updatedWhen = updatedDttm ? moment.utc(updatedDttm).fromNow() : '';
+ const refreshTooltip = isCached
+ ? t('Cached %s', cachedWhen)
+ : (updatedWhen && t('Fetched %s', updatedWhen)) || '';
+
+ return (
+ <Dropdown
+ id={`slice_${slice.slice_id}-controls`}
+ pullRight
+ // react-bootstrap handles visibility, but call toggle to force a re-render
+ // and update the fetched/cached timestamps
+ onToggle={this.toggleControls}
+ >
+ <Dropdown.Toggle className="slice-header-controls-trigger" noCaret>
+ <VerticalDotsTrigger />
+ </Dropdown.Toggle>
+
+ <Dropdown.Menu>
+ <MenuItem onClick={this.refreshChart}>
+ {t('Force refresh')}
+ <div className="refresh-tooltip">{refreshTooltip}</div>
+ </MenuItem>
+
+ <MenuItem divider />
+
+ {slice.description && (
+ <MenuItem onClick={this.toggleExpandSlice}>
+ {t('Toggle chart description')}
+ </MenuItem>
+ )}
+
+ {this.props.sliceCanEdit && (
+ <MenuItem href={slice.edit_url} target="_blank">
+ {t('Edit chart metadata')}
+ </MenuItem>
+ )}
+
+ <MenuItem onClick={this.exportCSV}>{t('Export CSV')}</MenuItem>
+
+ {this.props.supersetCanExplore && (
+ <MenuItem onClick={this.exploreChart}>
+ {t('Explore chart')}
+ </MenuItem>
+ )}
+ </Dropdown.Menu>
+ </Dropdown>
+ );
+ }
+}
+
+SliceHeaderControls.propTypes = propTypes;
+SliceHeaderControls.defaultProps = defaultProps;
+
+export default SliceHeaderControls;
diff --git a/superset/assets/src/dashboard/components/UndoRedoKeylisteners.jsx b/superset/assets/src/dashboard/components/UndoRedoKeylisteners.jsx
new file mode 100644
index 0000000000..c6de151182
--- /dev/null
+++ b/superset/assets/src/dashboard/components/UndoRedoKeylisteners.jsx
@@ -0,0 +1,46 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+const propTypes = {
+ onUndo: PropTypes.func.isRequired,
+ onRedo: PropTypes.func.isRequired,
+};
+
+class UndoRedoKeylisteners extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.handleKeydown = this.handleKeydown.bind(this);
+ }
+
+ componentDidMount() {
+ document.addEventListener('keydown', this.handleKeydown);
+ }
+
+ componentWillUnmount() {
+ document.removeEventListener('keydown', this.handleKeydown);
+ }
+
+ handleKeydown(event) {
+ const controlOrCommand = event.ctrlKey || event.metaKey;
+ if (controlOrCommand) {
+ const isZChar = event.key === 'z' || event.keyCode === 90;
+ const isYChar = event.key === 'y' || event.keyCode === 89;
+ const isEditingMarkdown =
+ document && document.querySelector('.dashboard-markdown--editing');
+
+ if (!isEditingMarkdown && (isZChar || isYChar)) {
+ event.preventDefault();
+ const func = isZChar ? this.props.onUndo : this.props.onRedo;
+ func();
+ }
+ }
+ }
+
+ render() {
+ return null;
+ }
+}
+
+UndoRedoKeylisteners.propTypes = propTypes;
+
+export default UndoRedoKeylisteners;
diff --git a/superset/assets/src/dashboard/components/dnd/AddSliceDragPreview.jsx b/superset/assets/src/dashboard/components/dnd/AddSliceDragPreview.jsx
new file mode 100644
index 0000000000..485e4e3171
--- /dev/null
+++ b/superset/assets/src/dashboard/components/dnd/AddSliceDragPreview.jsx
@@ -0,0 +1,74 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { DragLayer } from 'react-dnd';
+
+import AddSliceCard from '../AddSliceCard';
+import { slicePropShape } from '../../util/propShapes';
+import {
+ NEW_COMPONENT_SOURCE_TYPE,
+ CHART_TYPE,
+} from '../../util/componentTypes';
+
+const staticCardStyles = {
+ position: 'fixed',
+ pointerEvents: 'none',
+ top: 0,
+ left: 0,
+ zIndex: 101, // this should be higher than top-level tabs
+ width: 376 - 2 * 16,
+};
+
+const propTypes = {
+ dragItem: PropTypes.shape({
+ index: PropTypes.number.isRequired,
+ }),
+ slices: PropTypes.arrayOf(slicePropShape),
+ isDragging: PropTypes.bool.isRequired,
+ currentOffset: PropTypes.shape({
+ x: PropTypes.number.isRequired,
+ y: PropTypes.number.isRequired,
+ }),
+};
+
+const defaultProps = {
+ currentOffset: null,
+ dragItem: null,
+ slices: null,
+};
+
+function AddSliceDragPreview({ dragItem, slices, isDragging, currentOffset }) {
+ if (!isDragging || !currentOffset || !dragItem || !slices) return null;
+
+ const slice = slices[dragItem.index];
+
+ // make sure it's a new component and a chart
+ const shouldRender =
+ slice &&
+ dragItem.parentType === NEW_COMPONENT_SOURCE_TYPE &&
+ dragItem.type === CHART_TYPE;
+
+ return !shouldRender ? null : (
+ <AddSliceCard
+ style={{
+ ...staticCardStyles,
+ transform: `translate(${currentOffset.x}px, ${currentOffset.y}px)`,
+ }}
+ sliceName={slice.slice_name}
+ lastModified={
+ slice.modified ? slice.modified.replace(/<[^>]*>/g, '') : ''
+ }
+ visType={slice.viz_type}
+ datasourceLink={slice.datasource_link}
+ />
+ );
+}
+
+AddSliceDragPreview.propTypes = propTypes;
+AddSliceDragPreview.defaultProps = defaultProps;
+
+// This injects these props into the component
+export default DragLayer(monitor => ({
+ dragItem: monitor.getItem(),
+ currentOffset: monitor.getSourceClientOffset(),
+ isDragging: monitor.isDragging(),
+}))(AddSliceDragPreview);
diff --git a/superset/assets/src/dashboard/components/dnd/DragDroppable.jsx b/superset/assets/src/dashboard/components/dnd/DragDroppable.jsx
new file mode 100644
index 0000000000..ef116ea132
--- /dev/null
+++ b/superset/assets/src/dashboard/components/dnd/DragDroppable.jsx
@@ -0,0 +1,141 @@
+import { getEmptyImage } from 'react-dnd-html5-backend';
+import React from 'react';
+import PropTypes from 'prop-types';
+import { DragSource, DropTarget } from 'react-dnd';
+import cx from 'classnames';
+
+import { componentShape } from '../../util/propShapes';
+import { dragConfig, dropConfig } from './dragDroppableConfig';
+import {
+ DROP_TOP,
+ DROP_RIGHT,
+ DROP_BOTTOM,
+ DROP_LEFT,
+} from '../../util/getDropPosition';
+
+const propTypes = {
+ children: PropTypes.func,
+ className: PropTypes.string,
+ component: componentShape.isRequired,
+ parentComponent: componentShape,
+ depth: PropTypes.number.isRequired,
+ disableDragDrop: PropTypes.bool,
+ orientation: PropTypes.oneOf(['row', 'column']),
+ index: PropTypes.number.isRequired,
+ style: PropTypes.object,
+ onDrop: PropTypes.func,
+ editMode: PropTypes.bool.isRequired,
+ useEmptyDragPreview: PropTypes.bool,
+
+ // from react-dnd
+ isDragging: PropTypes.bool.isRequired,
+ isDraggingOver: PropTypes.bool.isRequired,
+ isDraggingOverShallow: PropTypes.bool.isRequired,
+ droppableRef: PropTypes.func.isRequired,
+ dragSourceRef: PropTypes.func.isRequired,
+ dragPreviewRef: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+ className: null,
+ style: null,
+ parentComponent: null,
+ disableDragDrop: false,
+ children() {},
+ onDrop() {},
+ orientation: 'row',
+ useEmptyDragPreview: false,
+};
+
+// export unwrapped component for testing
+export class UnwrappedDragDroppable extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ dropIndicator: null, // this gets set/modified by the react-dnd HOCs
+ };
+ this.setRef = this.setRef.bind(this);
+ }
+
+ componentDidMount() {
+ this.mounted = true;
+ }
+
+ componentWillUnmount() {
+ this.mounted = false;
+ }
+
+ setRef(ref) {
+ this.ref = ref;
+ // this is needed for a custom drag preview
+ if (this.props.useEmptyDragPreview) {
+ this.props.dragPreviewRef(getEmptyImage(), {
+ // IE fallback: specify that we'd rather screenshot the node
+ // when it already knows it's being dragged so we can hide it with CSS.
+ captureDraggingState: true,
+ });
+ } else {
+ this.props.dragPreviewRef(ref);
+ }
+ this.props.droppableRef(ref);
+ }
+
+ render() {
+ const {
+ children,
+ className,
+ orientation,
+ dragSourceRef,
+ isDragging,
+ isDraggingOver,
+ style,
+ editMode,
+ } = this.props;
+
+ const { dropIndicator } = this.state;
+ const dropIndicatorProps =
+ isDraggingOver && dropIndicator
+ ? {
+ className: cx(
+ 'drop-indicator',
+ dropIndicator === DROP_TOP && 'drop-indicator--top',
+ dropIndicator === DROP_BOTTOM && 'drop-indicator--bottom',
+ dropIndicator === DROP_LEFT && 'drop-indicator--left',
+ dropIndicator === DROP_RIGHT && 'drop-indicator--right',
+ ),
+ }
+ : null;
+
+ const childProps = editMode
+ ? {
+ dragSourceRef,
+ dropIndicatorProps,
+ }
+ : {};
+
+ return (
+ <div
+ style={style}
+ ref={this.setRef}
+ className={cx(
+ 'dragdroppable',
+ orientation === 'row' && 'dragdroppable-row',
+ orientation === 'column' && 'dragdroppable-column',
+ isDragging && 'dragdroppable--dragging',
+ className,
+ )}
+ >
+ {children(childProps)}
+ </div>
+ );
+ }
+}
+
+UnwrappedDragDroppable.propTypes = propTypes;
+UnwrappedDragDroppable.defaultProps = defaultProps;
+
+// note that the composition order here determines using
+// component.method() vs decoratedComponentInstance.method() in the drag/drop config
+export default DropTarget(...dropConfig)(
+ DragSource(...dragConfig)(UnwrappedDragDroppable),
+);
diff --git a/superset/assets/src/dashboard/components/dnd/DragHandle.jsx b/superset/assets/src/dashboard/components/dnd/DragHandle.jsx
new file mode 100644
index 0000000000..23d7d11e83
--- /dev/null
+++ b/superset/assets/src/dashboard/components/dnd/DragHandle.jsx
@@ -0,0 +1,40 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import cx from 'classnames';
+
+const propTypes = {
+ position: PropTypes.oneOf(['left', 'top']),
+ innerRef: PropTypes.func,
+ dotCount: PropTypes.number,
+};
+
+const defaultProps = {
+ position: 'left',
+ innerRef: null,
+ dotCount: 8,
+};
+
+export default class DragHandle extends React.PureComponent {
+ render() {
+ const { innerRef, position, dotCount } = this.props;
+ return (
+ <div
+ ref={innerRef}
+ className={cx(
+ 'drag-handle',
+ position === 'left' && 'drag-handle--left',
+ position === 'top' && 'drag-handle--top',
+ )}
+ >
+ {Array(dotCount)
+ .fill(null)
+ .map((_, i) => (
+ <div key={`handle-dot-${i}`} className="drag-handle-dot" />
+ ))}
+ </div>
+ );
+ }
+}
+
+DragHandle.propTypes = propTypes;
+DragHandle.defaultProps = defaultProps;
diff --git a/superset/assets/src/dashboard/components/dnd/dragDroppableConfig.js b/superset/assets/src/dashboard/components/dnd/dragDroppableConfig.js
new file mode 100644
index 0000000000..36c34a0e40
--- /dev/null
+++ b/superset/assets/src/dashboard/components/dnd/dragDroppableConfig.js
@@ -0,0 +1,67 @@
+import handleHover from './handleHover';
+import handleDrop from './handleDrop';
+
+// note: the 'type' hook is not useful for us as dropping is contigent on other properties
+const TYPE = 'DRAG_DROPPABLE';
+
+export const dragConfig = [
+ TYPE,
+ {
+ canDrag(props) {
+ return !props.disableDragDrop;
+ },
+
+ // this defines the dragging item object returned by monitor.getItem()
+ beginDrag(props /* , monitor, component */) {
+ const { component, index, parentComponent = {} } = props;
+ return {
+ type: component.type,
+ id: component.id,
+ meta: component.meta,
+ index,
+ parentId: parentComponent.id,
+ parentType: parentComponent.type,
+ };
+ },
+ },
+ function dragStateToProps(connect, monitor) {
+ return {
+ dragSourceRef: connect.dragSource(),
+ dragPreviewRef: connect.dragPreview(),
+ isDragging: monitor.isDragging(),
+ };
+ },
+];
+
+export const dropConfig = [
+ TYPE,
+ {
+ hover(props, monitor, component) {
+ if (
+ component &&
+ component.decoratedComponentInstance &&
+ component.decoratedComponentInstance.mounted
+ ) {
+ handleHover(props, monitor, component.decoratedComponentInstance);
+ }
+ },
+ // note:
+ // the react-dnd api requires that the drop() method return a result or undefined
+ // monitor.didDrop() cannot be used because it returns true only for the most-nested target
+ drop(props, monitor, component) {
+ const Component = component.decoratedComponentInstance;
+ const dropResult = monitor.getDropResult();
+ if ((!dropResult || !dropResult.destination) && Component.mounted) {
+ return handleDrop(props, monitor, Component);
+ }
+ return undefined;
+ },
+ },
+ function dropStateToProps(connect, monitor) {
+ return {
+ droppableRef: connect.dropTarget(),
+ isDraggingOver: monitor.isOver(),
+ isDraggingOverShallow: monitor.isOver({ shallow: true }),
+ };
+ },
+];
diff --git a/superset/assets/src/dashboard/components/dnd/handleDrop.js b/superset/assets/src/dashboard/components/dnd/handleDrop.js
new file mode 100644
index 0000000000..faeeffa871
--- /dev/null
+++ b/superset/assets/src/dashboard/components/dnd/handleDrop.js
@@ -0,0 +1,82 @@
+import getDropPosition, {
+ clearDropCache,
+ DROP_TOP,
+ DROP_RIGHT,
+ DROP_BOTTOM,
+ DROP_LEFT,
+} from '../../util/getDropPosition';
+
+export default function handleDrop(props, monitor, Component) {
+ // this may happen due to throttling
+ if (!Component.mounted) return undefined;
+
+ Component.setState(() => ({ dropIndicator: null }));
+ const dropPosition = getDropPosition(monitor, Component);
+
+ if (!dropPosition) {
+ return undefined;
+ }
+
+ const {
+ parentComponent,
+ component,
+ index: componentIndex,
+ onDrop,
+ orientation,
+ } = Component.props;
+
+ const draggingItem = monitor.getItem();
+
+ const dropAsChildOrSibling =
+ (orientation === 'row' &&
+ (dropPosition === DROP_TOP || dropPosition === DROP_BOTTOM)) ||
+ (orientation === 'column' &&
+ (dropPosition === DROP_LEFT || dropPosition === DROP_RIGHT))
+ ? 'sibling'
+ : 'child';
+
+ const dropResult = {
+ source: {
+ id: draggingItem.parentId,
+ type: draggingItem.parentType,
+ index: draggingItem.index,
+ },
+ dragging: {
+ id: draggingItem.id,
+ type: draggingItem.type,
+ meta: draggingItem.meta,
+ },
+ };
+
+ // simplest case, append as child
+ if (dropAsChildOrSibling === 'child') {
+ dropResult.destination = {
+ id: component.id,
+ type: component.type,
+ index: component.children.length,
+ };
+ } else {
+ // if the item is in the same list with a smaller index, you must account for the
+ // "missing" index upon movement within the list
+ const sameParent =
+ parentComponent && draggingItem.parentId === parentComponent.id;
+ const sameParentLowerIndex =
+ sameParent && draggingItem.index < componentIndex;
+
+ let nextIndex = sameParentLowerIndex ? componentIndex - 1 : componentIndex;
+ if (dropPosition === DROP_BOTTOM || dropPosition === DROP_RIGHT) {
+ nextIndex += 1;
+ }
+
+ dropResult.destination = {
+ id: parentComponent.id,
+ type: parentComponent.type,
+ index: nextIndex,
+ };
+ }
+
+ onDrop(dropResult);
+ clearDropCache();
+
+ return dropResult;
+}
diff --git a/superset/assets/src/dashboard/components/dnd/handleHover.js b/superset/assets/src/dashboard/components/dnd/handleHover.js
new file mode 100644
index 0000000000..a3b16aac4c
--- /dev/null
+++ b/superset/assets/src/dashboard/components/dnd/handleHover.js
@@ -0,0 +1,23 @@
+import throttle from 'lodash.throttle';
+import getDropPosition from '../../util/getDropPosition';
+
+const HOVER_THROTTLE_MS = 100;
+
+function handleHover(props, monitor, Component) {
+ // this may happen due to throttling
+ if (!Component.mounted) return;
+
+ const dropPosition = getDropPosition(monitor, Component);
+
+ if (!dropPosition) {
+ Component.setState(() => ({ dropIndicator: null }));
+ return;
+ }
+
+ Component.setState(() => ({
+ dropIndicator: dropPosition,
+ }));
+}
+
+// this is called very frequently by react-dnd
+export default throttle(handleHover, HOVER_THROTTLE_MS);
diff --git a/superset/assets/src/dashboard/components/gridComponents/Chart.jsx b/superset/assets/src/dashboard/components/gridComponents/Chart.jsx
new file mode 100644
index 0000000000..7e2e6a65d5
--- /dev/null
+++ b/superset/assets/src/dashboard/components/gridComponents/Chart.jsx
@@ -0,0 +1,247 @@
+import cx from 'classnames';
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import { exportChart } from '../../../explore/exploreUtils';
+import SliceHeader from '../SliceHeader';
+import ChartContainer from '../../../chart/ChartContainer';
+import MissingChart from '../MissingChart';
+import { chartPropType } from '../../../chart/chartReducer';
+import { slicePropShape } from '../../util/propShapes';
+import { VIZ_TYPES } from '../../../visualizations';
+
+const propTypes = {
+ id: PropTypes.number.isRequired,
+ width: PropTypes.number.isRequired,
+ height: PropTypes.number.isRequired,
+ updateSliceName: PropTypes.func.isRequired,
+
+ // from redux
+ chart: PropTypes.shape(chartPropType).isRequired,
+ formData: PropTypes.object.isRequired,
+ datasource: PropTypes.object.isRequired,
+ slice: slicePropShape.isRequired,
+ sliceName: PropTypes.string.isRequired,
+ timeout: PropTypes.number.isRequired,
+ filters: PropTypes.object.isRequired,
+ refreshChart: PropTypes.func.isRequired,
+ toggleExpandSlice: PropTypes.func.isRequired,
+ addFilter: PropTypes.func.isRequired,
+ editMode: PropTypes.bool.isRequired,
+ isExpanded: PropTypes.bool.isRequired,
+ supersetCanExplore: PropTypes.bool.isRequired,
+ sliceCanEdit: PropTypes.bool.isRequired,
+};
+
+// we use state + shouldComponentUpdate() logic to prevent perf-wrecking
+// resizing across all slices on a dashboard on every update
+const RESIZE_TIMEOUT = 350;
+const SHOULD_UPDATE_ON_PROP_CHANGES = Object.keys(propTypes).filter(
+ prop => prop !== 'width' && prop !== 'height',
+);
+const OVERFLOWABLE_VIZ_TYPES = new Set([VIZ_TYPES.filter_box]);
+
+class Chart extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ width: props.width,
+ height: props.height,
+ };
+
+ this.addFilter = this.addFilter.bind(this);
+ this.exploreChart = this.exploreChart.bind(this);
+ this.exportCSV = this.exportCSV.bind(this);
+ this.forceRefresh = this.forceRefresh.bind(this);
+ this.getFilters = this.getFilters.bind(this);
+ this.resize = this.resize.bind(this);
+ this.setDescriptionRef = this.setDescriptionRef.bind(this);
+ this.setHeaderRef = this.setHeaderRef.bind(this);
+ }
+
+ shouldComponentUpdate(nextProps, nextState) {
+ // this logic mostly pertains to chart resizing. we keep a copy of the dimensions in
+ // state so that we can buffer component size updates and only update on the final call
+ // which improves performance significantly
+ if (
+ nextState.width !== this.state.width ||
+ nextState.height !== this.state.height
+ ) {
+ return true;
+ }
+
+ for (let i = 0; i < SHOULD_UPDATE_ON_PROP_CHANGES.length; i += 1) {
+ const prop = SHOULD_UPDATE_ON_PROP_CHANGES[i];
+ if (nextProps[prop] !== this.props[prop]) {
+ return true;
+ }
+ }
+
+ if (
+ nextProps.width !== this.props.width ||
+ nextProps.height !== this.props.height
+ ) {
+ clearTimeout(this.resizeTimeout);
+ this.resizeTimeout = setTimeout(this.resize, RESIZE_TIMEOUT);
+ }
+
+ return false;
+ }
+
+ componentWillUnmount() {
+ clearTimeout(this.resizeTimeout);
+ }
+
+ getFilters() {
+ return this.props.filters;
+ }
+
+ getChartHeight() {
+ const headerHeight = this.getHeaderHeight();
+ const descriptionHeight =
+ this.props.isExpanded && this.descriptionRef
+ ? this.descriptionRef.offsetHeight
+ : 0;
+
+ return this.state.height - headerHeight - descriptionHeight;
+ }
+
+ getHeaderHeight() {
+ return (this.headerRef && this.headerRef.offsetHeight) || 30;
+ }
+
+ setDescriptionRef(ref) {
+ this.descriptionRef = ref;
+ }
+
+ setHeaderRef(ref) {
+ this.headerRef = ref;
+ }
+
+ resize() {
+ const { width, height } = this.props;
+ this.setState(() => ({ width, height }));
+ }
+
+ addFilter(...args) {
+ this.props.addFilter(this.props.chart, ...args);
+ }
+
+ exploreChart() {
+ exportChart(this.props.formData);
+ }
+
+ exportCSV() {
+ exportChart(this.props.formData, 'csv');
+ }
+
+ forceRefresh() {
+ return this.props.refreshChart(this.props.chart, true, this.props.timeout);
+ }
+
+ render() {
+ const {
+ id,
+ chart,
+ slice,
+ datasource,
+ isExpanded,
+ editMode,
+ formData,
+ updateSliceName,
+ sliceName,
+ toggleExpandSlice,
+ timeout,
+ supersetCanExplore,
+ sliceCanEdit,
+ } = this.props;
+
+ const { width } = this.state;
+
+ // this prevents throwing in the case that a gridComponent
+ // references a chart that is not associated with the dashboard
+ if (!chart || !slice) {
+ return <MissingChart height={this.getChartHeight()} />;
+ }
+
+ const { queryResponse, chartUpdateEndTime } = chart;
+ const isCached = queryResponse && queryResponse.is_cached;
+ const cachedDttm = queryResponse && queryResponse.cached_dttm;
+ const isOverflowable = OVERFLOWABLE_VIZ_TYPES.has(slice && slice.viz_type);
+
+ return (
+ <div>
+ <SliceHeader
+ innerRef={this.setHeaderRef}
+ slice={slice}
+ isExpanded={!!isExpanded}
+ isCached={isCached}
+ cachedDttm={cachedDttm}
+ updatedDttm={chartUpdateEndTime}
+ toggleExpandSlice={toggleExpandSlice}
+ forceRefresh={this.forceRefresh}
+ editMode={editMode}
+ annotationQuery={chart.annotationQuery}
+ exploreChart={this.exploreChart}
+ exportCSV={this.exportCSV}
+ updateSliceName={updateSliceName}
+ sliceName={sliceName}
+ supersetCanExplore={supersetCanExplore}
+ sliceCanEdit={sliceCanEdit}
+ />
+
+ {/*
+ This usage of dangerouslySetInnerHTML is safe since it is being used to render
+ markdown that is sanitized with bleach. See:
+ https://github.com/apache/incubator-superset/pull/4390
+ and
+ https://github.com/apache/incubator-superset/commit/b6fcc22d5a2cb7a5e92599ed5795a0169385a825
+ */}
+ {isExpanded &&
+ slice.description_markeddown && (
+ <div
+ className="slice_description bs-callout bs-callout-default"
+ ref={this.setDescriptionRef}
+ // eslint-disable-next-line react/no-danger
+ dangerouslySetInnerHTML={{ __html: slice.description_markeddown }}
+ />
+ )}
+
+ <div
+ className={cx(
+ 'dashboard-chart',
+ isOverflowable && 'dashboard-chart--overflowable',
+ )}
+ >
+ <ChartContainer
+ containerId={`slice-container-${id}`}
+ chartId={id}
+ datasource={datasource}
+ formData={formData}
+ headerHeight={this.getHeaderHeight()}
+ height={this.getChartHeight()}
+ width={width}
+ timeout={timeout}
+ vizType={slice.viz_type}
+ addFilter={this.addFilter}
+ getFilters={this.getFilters}
+ annotationData={chart.annotationData}
+ chartAlert={chart.chartAlert}
+ chartStatus={chart.chartStatus}
+ chartUpdateEndTime={chart.chartUpdateEndTime}
+ chartUpdateStartTime={chart.chartUpdateStartTime}
+ latestQueryFormData={chart.latestQueryFormData}
+ lastRendered={chart.lastRendered}
+ queryResponse={chart.queryResponse}
+ queryRequest={chart.queryRequest}
+ triggerQuery={chart.triggerQuery}
+ />
+ </div>
+ </div>
+ );
+ }
+}
+
+Chart.propTypes = propTypes;
+
+export default Chart;
diff --git a/superset/assets/src/dashboard/components/gridComponents/ChartHolder.jsx b/superset/assets/src/dashboard/components/gridComponents/ChartHolder.jsx
new file mode 100644
index 0000000000..9ad9522965
--- /dev/null
+++ b/superset/assets/src/dashboard/components/gridComponents/ChartHolder.jsx
@@ -0,0 +1,159 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import Chart from '../../containers/Chart';
+import DeleteComponentButton from '../DeleteComponentButton';
+import DragDroppable from '../dnd/DragDroppable';
+import HoverMenu from '../menu/HoverMenu';
+import ResizableContainer from '../resizable/ResizableContainer';
+import { componentShape } from '../../util/propShapes';
+import { ROW_TYPE, COLUMN_TYPE } from '../../util/componentTypes';
+import {
+ GRID_MIN_COLUMN_COUNT,
+ GRID_MIN_ROW_UNITS,
+ GRID_BASE_UNIT,
+} from '../../util/constants';
+
+const CHART_MARGIN = 32;
+
+const propTypes = {
+ id: PropTypes.string.isRequired,
+ parentId: PropTypes.string.isRequired,
+ component: componentShape.isRequired,
+ parentComponent: componentShape.isRequired,
+ index: PropTypes.number.isRequired,
+ depth: PropTypes.number.isRequired,
+ editMode: PropTypes.bool.isRequired,
+
+ // grid related
+ availableColumnCount: PropTypes.number.isRequired,
+ columnWidth: PropTypes.number.isRequired,
+ onResizeStart: PropTypes.func.isRequired,
+ onResize: PropTypes.func.isRequired,
+ onResizeStop: PropTypes.func.isRequired,
+
+ // dnd
+ deleteComponent: PropTypes.func.isRequired,
+ updateComponents: PropTypes.func.isRequired,
+ handleComponentDrop: PropTypes.func.isRequired,
+};
+
+const defaultProps = {};
+
+class ChartHolder extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ isFocused: false,
+ };
+
+ this.handleChangeFocus = this.handleChangeFocus.bind(this);
+ this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
+ this.handleUpdateSliceName = this.handleUpdateSliceName.bind(this);
+ }
+
+ handleChangeFocus(nextFocus) {
+ this.setState(() => ({ isFocused: nextFocus }));
+ }
+
+ handleDeleteComponent() {
+ const { deleteComponent, id, parentId } = this.props;
+ deleteComponent(id, parentId);
+ }
+
+ handleUpdateSliceName(nextName) {
+ const { component, updateComponents } = this.props;
+ updateComponents({
+ [component.id]: {
+ ...component,
+ meta: {
+ ...component.meta,
+ sliceName: nextName,
+ },
+ },
+ });
+ }
+
+ render() {
+ const { isFocused } = this.state;
+
+ const {
+ component,
+ parentComponent,
+ index,
+ depth,
+ availableColumnCount,
+ columnWidth,
+ onResizeStart,
+ onResize,
+ onResizeStop,
+ handleComponentDrop,
+ editMode,
+ } = this.props;
+
+ // inherit the size of parent columns
+ const widthMultiple =
+ parentComponent.type === COLUMN_TYPE
+ ? parentComponent.meta.width || GRID_MIN_COLUMN_COUNT
+ : component.meta.width || GRID_MIN_COLUMN_COUNT;
+
+ return (
+ <DragDroppable
+ component={component}
+ parentComponent={parentComponent}
+ orientation={parentComponent.type === ROW_TYPE ? 'column' : 'row'}
+ index={index}
+ depth={depth}
+ onDrop={handleComponentDrop}
+ disableDragDrop={isFocused}
+ editMode={editMode}
+ >
+ {({ dropIndicatorProps, dragSourceRef }) => (
+ <ResizableContainer
+ id={component.id}
+ adjustableWidth={parentComponent.type === ROW_TYPE}
+ adjustableHeight
+ widthStep={columnWidth}
+ widthMultiple={widthMultiple}
+ heightStep={GRID_BASE_UNIT}
+ heightMultiple={component.meta.height}
+ minWidthMultiple={GRID_MIN_COLUMN_COUNT}
+ minHeightMultiple={GRID_MIN_ROW_UNITS}
+ maxWidthMultiple={availableColumnCount + widthMultiple}
+ onResizeStart={onResizeStart}
+ onResize={onResize}
+ onResizeStop={onResizeStop}
+ editMode={editMode}
+ >
+ <div
+ ref={dragSourceRef}
+ className="dashboard-component dashboard-component-chart-holder"
+ >
+ <Chart
+ id={component.meta.chartId}
+ width={widthMultiple * columnWidth}
+ height={component.meta.height * GRID_BASE_UNIT - CHART_MARGIN}
+ sliceName={component.meta.sliceName || ''}
+ updateSliceName={this.handleUpdateSliceName}
+ />
+ {editMode && (
+ <HoverMenu position="top">
+ <DeleteComponentButton
+ onDelete={this.handleDeleteComponent}
+ />
+ </HoverMenu>
+ )}
+ </div>
+
+ {dropIndicatorProps && <div {...dropIndicatorProps} />}
+ </ResizableContainer>
+ )}
+ </DragDroppable>
+ );
+ }
+}
+
+ChartHolder.propTypes = propTypes;
+ChartHolder.defaultProps = defaultProps;
+
+export default ChartHolder;
diff --git a/superset/assets/src/dashboard/components/gridComponents/Column.jsx b/superset/assets/src/dashboard/components/gridComponents/Column.jsx
new file mode 100644
index 0000000000..7249034e69
--- /dev/null
+++ b/superset/assets/src/dashboard/components/gridComponents/Column.jsx
@@ -0,0 +1,192 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import cx from 'classnames';
+
+import DashboardComponent from '../../containers/DashboardComponent';
+import DeleteComponentButton from '../DeleteComponentButton';
+import DragDroppable from '../dnd/DragDroppable';
+import DragHandle from '../dnd/DragHandle';
+import HoverMenu from '../menu/HoverMenu';
+import IconButton from '../IconButton';
+import ResizableContainer from '../resizable/ResizableContainer';
+import BackgroundStyleDropdown from '../menu/BackgroundStyleDropdown';
+import WithPopoverMenu from '../menu/WithPopoverMenu';
+
+import backgroundStyleOptions from '../../util/backgroundStyleOptions';
+import { componentShape } from '../../util/propShapes';
+
+import { BACKGROUND_TRANSPARENT } from '../../util/constants';
+
+const propTypes = {
+ id: PropTypes.string.isRequired,
+ parentId: PropTypes.string.isRequired,
+ component: componentShape.isRequired,
+ parentComponent: componentShape.isRequired,
+ index: PropTypes.number.isRequired,
+ depth: PropTypes.number.isRequired,
+ editMode: PropTypes.bool.isRequired,
+
+ // grid related
+ availableColumnCount: PropTypes.number.isRequired,
+ columnWidth: PropTypes.number.isRequired,
+ minColumnWidth: PropTypes.number.isRequired,
+ onResizeStart: PropTypes.func.isRequired,
+ onResize: PropTypes.func.isRequired,
+ onResizeStop: PropTypes.func.isRequired,
+
+ // dnd
+ deleteComponent: PropTypes.func.isRequired,
+ handleComponentDrop: PropTypes.func.isRequired,
+ updateComponents: PropTypes.func.isRequired,
+};
+
+const defaultProps = {};
+
+class Column extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = {
+ isFocused: false,
+ };
+ this.handleChangeBackground = this.handleUpdateMeta.bind(
+ this,
+ 'background',
+ );
+ this.handleChangeFocus = this.handleChangeFocus.bind(this);
+ this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
+ }
+
+ handleDeleteComponent() {
+ const { deleteComponent, id, parentId } = this.props;
+ deleteComponent(id, parentId);
+ }
+
+ handleChangeFocus(nextFocus) {
+ this.setState(() => ({ isFocused: Boolean(nextFocus) }));
+ }
+
+ handleUpdateMeta(metaKey, nextValue) {
+ const { updateComponents, component } = this.props;
+ if (nextValue && component.meta[metaKey] !== nextValue) {
+ updateComponents({
+ [component.id]: {
+ ...component,
+ meta: {
+ ...component.meta,
+ [metaKey]: nextValue,
+ },
+ },
+ });
+ }
+ }
+
+ render() {
+ const {
+ component: columnComponent,
+ parentComponent,
+ index,
+ availableColumnCount,
+ columnWidth,
+ minColumnWidth,
+ depth,
+ onResizeStart,
+ onResize,
+ onResizeStop,
+ handleComponentDrop,
+ editMode,
+ } = this.props;
+
+ const columnItems = columnComponent.children || [];
+ const backgroundStyle = backgroundStyleOptions.find(
+ opt =>
+ opt.value ===
+ (columnComponent.meta.background || BACKGROUND_TRANSPARENT),
+ );
+
+ return (
+ <DragDroppable
+ component={columnComponent}
+ parentComponent={parentComponent}
+ orientation="column"
+ index={index}
+ depth={depth}
+ onDrop={handleComponentDrop}
+ editMode={editMode}
+ >
+ {({ dropIndicatorProps, dragSourceRef }) => (
+ <ResizableContainer
+ id={columnComponent.id}
+ adjustableWidth
+ adjustableHeight={false}
+ widthStep={columnWidth}
+ widthMultiple={columnComponent.meta.width}
+ minWidthMultiple={minColumnWidth}
+ maxWidthMultiple={
+ availableColumnCount + (columnComponent.meta.width || 0)
+ }
+ onResizeStart={onResizeStart}
+ onResize={onResize}
+ onResizeStop={onResizeStop}
+ editMode={editMode}
+ >
+ <WithPopoverMenu
+ isFocused={this.state.isFocused}
+ onChangeFocus={this.handleChangeFocus}
+ disableClick
+ menuItems={[
+ <BackgroundStyleDropdown
+ id={`${columnComponent.id}-background`}
+ value={columnComponent.meta.background}
+ onChange={this.handleChangeBackground}
+ />,
+ ]}
+ editMode={editMode}
+ >
+ {editMode && (
+ <HoverMenu innerRef={dragSourceRef} position="top">
+ <DragHandle position="top" />
+ <DeleteComponentButton
+ onDelete={this.handleDeleteComponent}
+ />
+ <IconButton
+ onClick={this.handleChangeFocus}
+ className="fa fa-cog"
+ />
+ </HoverMenu>
+ )}
+ <div
+ className={cx(
+ 'grid-column',
+ columnItems.length === 0 && 'grid-column--empty',
+ backgroundStyle.className,
+ )}
+ >
+ {columnItems.map((componentId, itemIndex) => (
+ <DashboardComponent
+ key={componentId}
+ id={componentId}
+ parentId={columnComponent.id}
+ depth={depth + 1}
+ index={itemIndex}
+ availableColumnCount={columnComponent.meta.width}
+ columnWidth={columnWidth}
+ onResizeStart={onResizeStart}
+ onResize={onResize}
+ onResizeStop={onResizeStop}
+ />
+ ))}
+
+ {dropIndicatorProps && <div {...dropIndicatorProps} />}
+ </div>
+ </WithPopoverMenu>
+ </ResizableContainer>
+ )}
+ </DragDroppable>
+ );
+ }
+}
+
+Column.propTypes = propTypes;
+Column.defaultProps = defaultProps;
+
+export default Column;
diff --git a/superset/assets/src/dashboard/components/gridComponents/Divider.jsx b/superset/assets/src/dashboard/components/gridComponents/Divider.jsx
new file mode 100644
index 0000000000..7c7936d857
--- /dev/null
+++ b/superset/assets/src/dashboard/components/gridComponents/Divider.jsx
@@ -0,0 +1,72 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import DragDroppable from '../dnd/DragDroppable';
+import HoverMenu from '../menu/HoverMenu';
+import DeleteComponentButton from '../DeleteComponentButton';
+import { componentShape } from '../../util/propShapes';
+
+const propTypes = {
+ id: PropTypes.string.isRequired,
+ parentId: PropTypes.string.isRequired,
+ component: componentShape.isRequired,
+ depth: PropTypes.number.isRequired,
+ parentComponent: componentShape.isRequired,
+ index: PropTypes.number.isRequired,
+ editMode: PropTypes.bool.isRequired,
+ handleComponentDrop: PropTypes.func.isRequired,
+ deleteComponent: PropTypes.func.isRequired,
+};
+
+class Divider extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
+ }
+
+ handleDeleteComponent() {
+ const { deleteComponent, id, parentId } = this.props;
+ deleteComponent(id, parentId);
+ }
+
+ render() {
+ const {
+ component,
+ depth,
+ parentComponent,
+ index,
+ handleComponentDrop,
+ editMode,
+ } = this.props;
+
+ return (
+ <DragDroppable
+ component={component}
+ parentComponent={parentComponent}
+ orientation="row"
+ index={index}
+ depth={depth}
+ onDrop={handleComponentDrop}
+ editMode={editMode}
+ >
+ {({ dropIndicatorProps, dragSourceRef }) => (
+ <div ref={dragSourceRef}>
+ {editMode && (
+ <HoverMenu position="left">
+ <DeleteComponentButton onDelete={this.handleDeleteComponent} />
+ </HoverMenu>
+ )}
+
+ <div className="dashboard-component dashboard-component-divider" />
+
+ {dropIndicatorProps && <div {...dropIndicatorProps} />}
+ </div>
+ )}
+ </DragDroppable>
+ );
+ }
+}
+
+Divider.propTypes = propTypes;
+
+export default Divider;
diff --git a/superset/assets/src/dashboard/components/gridComponents/Header.jsx b/superset/assets/src/dashboard/components/gridComponents/Header.jsx
new file mode 100644
index 0000000000..683af9e957
--- /dev/null
+++ b/superset/assets/src/dashboard/components/gridComponents/Header.jsx
@@ -0,0 +1,164 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import cx from 'classnames';
+
+import DragDroppable from '../dnd/DragDroppable';
+import DragHandle from '../dnd/DragHandle';
+import EditableTitle from '../../../components/EditableTitle';
+import HoverMenu from '../menu/HoverMenu';
+import WithPopoverMenu from '../menu/WithPopoverMenu';
+import BackgroundStyleDropdown from '../menu/BackgroundStyleDropdown';
+import DeleteComponentButton from '../DeleteComponentButton';
+import PopoverDropdown from '../menu/PopoverDropdown';
+import headerStyleOptions from '../../util/headerStyleOptions';
+import backgroundStyleOptions from '../../util/backgroundStyleOptions';
+import { componentShape } from '../../util/propShapes';
+import { SMALL_HEADER, BACKGROUND_TRANSPARENT } from '../../util/constants';
+
+const propTypes = {
+ id: PropTypes.string.isRequired,
+ parentId: PropTypes.string.isRequired,
+ component: componentShape.isRequired,
+ depth: PropTypes.number.isRequired,
+ parentComponent: componentShape.isRequired,
+ index: PropTypes.number.isRequired,
+ editMode: PropTypes.bool.isRequired,
+
+ // redux
+ handleComponentDrop: PropTypes.func.isRequired,
+ deleteComponent: PropTypes.func.isRequired,
+ updateComponents: PropTypes.func.isRequired,
+};
+
+const defaultProps = {};
+
+class Header extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = {
+ isFocused: false,
+ };
+ this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
+ this.handleChangeFocus = this.handleChangeFocus.bind(this);
+ this.handleUpdateMeta = this.handleUpdateMeta.bind(this);
+ this.handleChangeSize = this.handleUpdateMeta.bind(this, 'headerSize');
+ this.handleChangeBackground = this.handleUpdateMeta.bind(
+ this,
+ 'background',
+ );
+ this.handleChangeText = this.handleUpdateMeta.bind(this, 'text');
+ }
+
+ handleChangeFocus(nextFocus) {
+ this.setState(() => ({ isFocused: nextFocus }));
+ }
+
+ handleUpdateMeta(metaKey, nextValue) {
+ const { updateComponents, component } = this.props;
+ if (nextValue && component.meta[metaKey] !== nextValue) {
+ updateComponents({
+ [component.id]: {
+ ...component,
+ meta: {
+ ...component.meta,
+ [metaKey]: nextValue,
+ },
+ },
+ });
+ }
+ }
+
+ handleDeleteComponent() {
+ const { deleteComponent, id, parentId } = this.props;
+ deleteComponent(id, parentId);
+ }
+
+ render() {
+ const { isFocused } = this.state;
+
+ const {
+ component,
+ depth,
+ parentComponent,
+ index,
+ handleComponentDrop,
+ editMode,
+ } = this.props;
+
+ const headerStyle = headerStyleOptions.find(
+ opt => opt.value === (component.meta.headerSize || SMALL_HEADER),
+ );
+
+ const rowStyle = backgroundStyleOptions.find(
+ opt =>
+ opt.value === (component.meta.background || BACKGROUND_TRANSPARENT),
+ );
+
+ return (
+ <DragDroppable
+ component={component}
+ parentComponent={parentComponent}
+ orientation="row"
+ index={index}
+ depth={depth}
+ onDrop={handleComponentDrop}
+ disableDragDrop={isFocused}
+ editMode={editMode}
+ >
+ {({ dropIndicatorProps, dragSourceRef }) => (
+ <div ref={dragSourceRef}>
+ {editMode &&
+ depth <= 2 && ( // drag handle looks bad when nested
+ <HoverMenu position="left">
+ <DragHandle position="left" />
+ </HoverMenu>
+ )}
+
+ <WithPopoverMenu
+ onChangeFocus={this.handleChangeFocus}
+ menuItems={[
+ <PopoverDropdown
+ id={`${component.id}-header-style`}
+ options={headerStyleOptions}
+ value={component.meta.headerSize}
+ onChange={this.handleChangeSize}
+ renderTitle={option => `${option.label} header`}
+ />,
+ <BackgroundStyleDropdown
+ id={`${component.id}-background`}
+ value={component.meta.background}
+ onChange={this.handleChangeBackground}
+ />,
+ <DeleteComponentButton onDelete={this.handleDeleteComponent} />,
+ ]}
+ editMode={editMode}
+ >
+ <div
+ className={cx(
+ 'dashboard-component',
+ 'dashboard-component-header',
+ headerStyle.className,
+ rowStyle.className,
+ )}
+ >
+ <EditableTitle
+ title={component.meta.text}
+ canEdit={editMode}
+ onSaveTitle={this.handleChangeText}
+ showTooltip={false}
+ />
+ </div>
+ </WithPopoverMenu>
+
+ {dropIndicatorProps && <div {...dropIndicatorProps} />}
+ </div>
+ )}
+ </DragDroppable>
+ );
+ }
+}
+
+Header.propTypes = propTypes;
+Header.defaultProps = defaultProps;
+
+export default Header;
diff --git a/superset/assets/src/dashboard/components/gridComponents/Markdown.jsx b/superset/assets/src/dashboard/components/gridComponents/Markdown.jsx
new file mode 100644
index 0000000000..aba76cad05
--- /dev/null
+++ b/superset/assets/src/dashboard/components/gridComponents/Markdown.jsx
@@ -0,0 +1,259 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ReactMarkdown from 'react-markdown';
+import cx from 'classnames';
+import AceEditor from 'react-ace';
+import 'brace/mode/markdown';
+import 'brace/theme/textmate';
+
+import DeleteComponentButton from '../DeleteComponentButton';
+import DragDroppable from '../dnd/DragDroppable';
+import ResizableContainer from '../resizable/ResizableContainer';
+import MarkdownModeDropdown from '../menu/MarkdownModeDropdown';
+import WithPopoverMenu from '../menu/WithPopoverMenu';
+import { componentShape } from '../../util/propShapes';
+import { ROW_TYPE, COLUMN_TYPE } from '../../util/componentTypes';
+import {
+ GRID_MIN_COLUMN_COUNT,
+ GRID_MIN_ROW_UNITS,
+ GRID_BASE_UNIT,
+} from '../../util/constants';
+
+const propTypes = {
+ id: PropTypes.string.isRequired,
+ parentId: PropTypes.string.isRequired,
+ component: componentShape.isRequired,
+ parentComponent: componentShape.isRequired,
+ index: PropTypes.number.isRequired,
+ depth: PropTypes.number.isRequired,
+ editMode: PropTypes.bool.isRequired,
+
+ // grid related
+ availableColumnCount: PropTypes.number.isRequired,
+ columnWidth: PropTypes.number.isRequired,
+ onResizeStart: PropTypes.func.isRequired,
+ onResize: PropTypes.func.isRequired,
+ onResizeStop: PropTypes.func.isRequired,
+
+ // dnd
+ deleteComponent: PropTypes.func.isRequired,
+ handleComponentDrop: PropTypes.func.isRequired,
+ updateComponents: PropTypes.func.isRequired,
+};
+
+const defaultProps = {};
+
+const markdownPlaceHolder = `# ✨Markdown
+## ✨Markdown
+### ✨Markdown
+
+<br />
+
+Click here to edit [markdown](https://bit.ly/1dQOfRK)`;
+
+class Markdown extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = {
+ isFocused: false,
+ markdownSource: props.component.meta.code,
+ editor: null,
+ editorMode: 'preview',
+ };
+
+ this.handleChangeFocus = this.handleChangeFocus.bind(this);
+ this.handleChangeEditorMode = this.handleChangeEditorMode.bind(this);
+ this.handleMarkdownChange = this.handleMarkdownChange.bind(this);
+ this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
+ this.setEditor = this.setEditor.bind(this);
+ }
+
+ componentWillReceiveProps(nextProps) {
+ const nextSource = nextProps.component.meta.code;
+ if (this.state.markdownSource !== nextSource) {
+ this.setState({ markdownSource: nextSource });
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ if (
+ this.state.editor &&
+ (prevProps.component.meta.width !== this.props.component.meta.width ||
+ prevProps.columnWidth !== this.props.columnWidth)
+ ) {
+ this.state.editor.resize(true);
+ }
+ }
+
+ setEditor(editor) {
+ editor.getSession().setUseWrapMode(true);
+ this.setState({
+ editor,
+ });
+ }
+
+ handleChangeFocus(nextFocus) {
+ const nextFocused = !!nextFocus;
+ const nextEditMode = nextFocused ? 'edit' : 'preview';
+ this.setState(() => ({ isFocused: nextFocused }));
+ this.handleChangeEditorMode(nextEditMode);
+ }
+
+ handleChangeEditorMode(mode) {
+ if (this.state.editorMode === 'edit') {
+ const { updateComponents, component } = this.props;
+ if (component.meta.code !== this.state.markdownSource) {
+ updateComponents({
+ [component.id]: {
+ ...component,
+ meta: {
+ ...component.meta,
+ code: this.state.markdownSource,
+ },
+ },
+ });
+ }
+ }
+
+ this.setState(() => ({
+ editorMode: mode,
+ }));
+ }
+
+ handleMarkdownChange(nextValue) {
+ this.setState({
+ markdownSource: nextValue,
+ });
+ }
+
+ handleDeleteComponent() {
+ const { deleteComponent, id, parentId } = this.props;
+ deleteComponent(id, parentId);
+ }
+
+ renderEditMode() {
+ return (
+ <AceEditor
+ mode="markdown"
+ theme="textmate"
+ onChange={this.handleMarkdownChange}
+ width="100%"
+ height="100%"
+ showGutter={false}
+ editorProps={{ $blockScrolling: true }}
+ value={
+ // thisl allows "select all => delete" to give an empty editor
+ typeof this.state.markdownSource === 'string'
+ ? this.state.markdownSource
+ : markdownPlaceHolder
+ }
+ readOnly={false}
+ onLoad={this.setEditor}
+ />
+ );
+ }
+
+ renderPreviewMode() {
+ return (
+ <ReactMarkdown
+ source={this.state.markdownSource || markdownPlaceHolder}
+ escapeHtml={false}
+ />
+ );
+ }
+
+ render() {
+ const { isFocused, editorMode } = this.state;
+
+ const {
+ component,
+ parentComponent,
+ index,
+ depth,
+ availableColumnCount,
+ columnWidth,
+ onResizeStart,
+ onResize,
+ onResizeStop,
+ handleComponentDrop,
+ editMode,
+ } = this.props;
+
+ // inherit the size of parent columns
+ const widthMultiple =
+ parentComponent.type === COLUMN_TYPE
+ ? parentComponent.meta.width || GRID_MIN_COLUMN_COUNT
+ : component.meta.width || GRID_MIN_COLUMN_COUNT;
+
+ const isEditing = editorMode === 'edit';
+
+ return (
+ <DragDroppable
+ component={component}
+ parentComponent={parentComponent}
+ orientation={parentComponent.type === ROW_TYPE ? 'column' : 'row'}
+ index={index}
+ depth={depth}
+ onDrop={handleComponentDrop}
+ disableDragDrop={isFocused}
+ editMode={editMode}
+ >
+ {({ dropIndicatorProps, dragSourceRef }) => (
+ <WithPopoverMenu
+ onChangeFocus={this.handleChangeFocus}
+ menuItems={[
+ <MarkdownModeDropdown
+ id={`${component.id}-mode`}
+ value={this.state.editorMode}
+ onChange={this.handleChangeEditorMode}
+ />,
+ <DeleteComponentButton onDelete={this.handleDeleteComponent} />,
+ ]}
+ editMode={editMode}
+ >
+ <div
+ className={cx(
+ 'dashboard-markdown',
+ isEditing && 'dashboard-markdown--editing',
+ )}
+ >
+ <ResizableContainer
+ id={component.id}
+ adjustableWidth={parentComponent.type === ROW_TYPE}
+ adjustableHeight
+ widthStep={columnWidth}
+ widthMultiple={widthMultiple}
+ heightStep={GRID_BASE_UNIT}
+ heightMultiple={component.meta.height}
+ minWidthMultiple={GRID_MIN_COLUMN_COUNT}
+ minHeightMultiple={GRID_MIN_ROW_UNITS}
+ maxWidthMultiple={availableColumnCount + widthMultiple}
+ onResizeStart={onResizeStart}
+ onResize={onResize}
+ onResizeStop={onResizeStop}
+ // disable resize when editing because if state is not synced
+ // with props it will reset the editor text to whatever props is
+ editMode={isFocused ? false : editMode}
+ >
+ <div
+ ref={dragSourceRef}
+ className="dashboard-component dashboard-component-chart-holder"
+ >
+ {editMode && isEditing
+ ? this.renderEditMode()
+ : this.renderPreviewMode()}
+ </div>
+ </ResizableContainer>
+ </div>
+ {dropIndicatorProps && <div {...dropIndicatorProps} />}
+ </WithPopoverMenu>
+ )}
+ </DragDroppable>
+ );
+ }
+}
+
+Markdown.propTypes = propTypes;
+Markdown.defaultProps = defaultProps;
+
+export default Markdown;
diff --git a/superset/assets/src/dashboard/components/gridComponents/Row.jsx b/superset/assets/src/dashboard/components/gridComponents/Row.jsx
new file mode 100644
index 0000000000..28e7042c04
--- /dev/null
+++ b/superset/assets/src/dashboard/components/gridComponents/Row.jsx
@@ -0,0 +1,177 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import cx from 'classnames';
+
+import DragDroppable from '../dnd/DragDroppable';
+import DragHandle from '../dnd/DragHandle';
+import DashboardComponent from '../../containers/DashboardComponent';
+import DeleteComponentButton from '../DeleteComponentButton';
+import HoverMenu from '../menu/HoverMenu';
+import IconButton from '../IconButton';
+import BackgroundStyleDropdown from '../menu/BackgroundStyleDropdown';
+import WithPopoverMenu from '../menu/WithPopoverMenu';
+
+import { componentShape } from '../../util/propShapes';
+import backgroundStyleOptions from '../../util/backgroundStyleOptions';
+import { BACKGROUND_TRANSPARENT } from '../../util/constants';
+
+const propTypes = {
+ id: PropTypes.string.isRequired,
+ parentId: PropTypes.string.isRequired,
+ component: componentShape.isRequired,
+ parentComponent: componentShape.isRequired,
+ index: PropTypes.number.isRequired,
+ depth: PropTypes.number.isRequired,
+ editMode: PropTypes.bool.isRequired,
+
+ // grid related
+ availableColumnCount: PropTypes.number.isRequired,
+ columnWidth: PropTypes.number.isRequired,
+ occupiedColumnCount: PropTypes.number.isRequired,
+ onResizeStart: PropTypes.func.isRequired,
+ onResize: PropTypes.func.isRequired,
+ onResizeStop: PropTypes.func.isRequired,
+
+ // dnd
+ handleComponentDrop: PropTypes.func.isRequired,
+ deleteComponent: PropTypes.func.isRequired,
+ updateComponents: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+ rowHeight: null,
+};
+
+class Row extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = {
+ isFocused: false,
+ };
+ this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
+ this.handleUpdateMeta = this.handleUpdateMeta.bind(this);
+ this.handleChangeBackground = this.handleUpdateMeta.bind(
+ this,
+ 'background',
+ );
+ this.handleChangeFocus = this.handleChangeFocus.bind(this);
+ }
+
+ handleChangeFocus(nextFocus) {
+ this.setState(() => ({ isFocused: Boolean(nextFocus) }));
+ }
+
+ handleUpdateMeta(metaKey, nextValue) {
+ const { updateComponents, component } = this.props;
+ if (nextValue && component.meta[metaKey] !== nextValue) {
+ updateComponents({
+ [component.id]: {
+ ...component,
+ meta: {
+ ...component.meta,
+ [metaKey]: nextValue,
+ },
+ },
+ });
+ }
+ }
+
+ handleDeleteComponent() {
+ const { deleteComponent, component, parentId } = this.props;
+ deleteComponent(component.id, parentId);
+ }
+
+ render() {
+ const {
+ component: rowComponent,
+ parentComponent,
+ index,
+ availableColumnCount,
+ columnWidth,
+ occupiedColumnCount,
+ depth,
+ onResizeStart,
+ onResize,
+ onResizeStop,
+ handleComponentDrop,
+ editMode,
+ } = this.props;
+
+ const rowItems = rowComponent.children || [];
+
+ const backgroundStyle = backgroundStyleOptions.find(
+ opt =>
+ opt.value === (rowComponent.meta.background || BACKGROUND_TRANSPARENT),
+ );
+
+ return (
+ <DragDroppable
+ component={rowComponent}
+ parentComponent={parentComponent}
+ orientation="row"
+ index={index}
+ depth={depth}
+ onDrop={handleComponentDrop}
+ editMode={editMode}
+ >
+ {({ dropIndicatorProps, dragSourceRef }) => (
+ <WithPopoverMenu
+ isFocused={this.state.isFocused}
+ onChangeFocus={this.handleChangeFocus}
+ disableClick
+ menuItems={[
+ <BackgroundStyleDropdown
+ id={`${rowComponent.id}-background`}
+ value={backgroundStyle.value}
+ onChange={this.handleChangeBackground}
+ />,
+ ]}
+ editMode={editMode}
+ >
+ {editMode && (
+ <HoverMenu innerRef={dragSourceRef} position="left">
+ <DragHandle position="left" />
+ <DeleteComponentButton onDelete={this.handleDeleteComponent} />
+ <IconButton
+ onClick={this.handleChangeFocus}
+ className="fa fa-cog"
+ />
+ </HoverMenu>
+ )}
+ <div
+ className={cx(
+ 'grid-row',
+ rowItems.length === 0 && 'grid-row--empty',
+ backgroundStyle.className,
+ )}
+ >
+ {rowItems.map((componentId, itemIndex) => (
+ <DashboardComponent
+ key={componentId}
+ id={componentId}
+ parentId={rowComponent.id}
+ depth={depth + 1}
+ index={itemIndex}
+ availableColumnCount={
+ availableColumnCount - occupiedColumnCount
+ }
+ columnWidth={columnWidth}
+ onResizeStart={onResizeStart}
+ onResize={onResize}
+ onResizeStop={onResizeStop}
+ />
+ ))}
+
+ {dropIndicatorProps && <div {...dropIndicatorProps} />}
+ </div>
+ </WithPopoverMenu>
+ )}
+ </DragDroppable>
+ );
+ }
+}
+
+Row.propTypes = propTypes;
+Row.defaultProps = defaultProps;
+
+export default Row;
diff --git a/superset/assets/src/dashboard/components/gridComponents/Tab.jsx b/superset/assets/src/dashboard/components/gridComponents/Tab.jsx
new file mode 100644
index 0000000000..b91d8089fd
--- /dev/null
+++ b/superset/assets/src/dashboard/components/gridComponents/Tab.jsx
@@ -0,0 +1,235 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import DashboardComponent from '../../containers/DashboardComponent';
+import DragDroppable from '../dnd/DragDroppable';
+import EditableTitle from '../../../components/EditableTitle';
+import DeleteComponentButton from '../DeleteComponentButton';
+import WithPopoverMenu from '../menu/WithPopoverMenu';
+import { componentShape } from '../../util/propShapes';
+import { DASHBOARD_ROOT_DEPTH } from '../../util/constants';
+
+export const RENDER_TAB = 'RENDER_TAB';
+export const RENDER_TAB_CONTENT = 'RENDER_TAB_CONTENT';
+
+const propTypes = {
+ id: PropTypes.string.isRequired,
+ parentId: PropTypes.string.isRequired,
+ component: componentShape.isRequired,
+ parentComponent: componentShape.isRequired,
+ index: PropTypes.number.isRequired,
+ depth: PropTypes.number.isRequired,
+ renderType: PropTypes.oneOf([RENDER_TAB, RENDER_TAB_CONTENT]).isRequired,
+ onDropOnTab: PropTypes.func,
+ onDeleteTab: PropTypes.func,
+ editMode: PropTypes.bool.isRequired,
+
+ // grid related
+ availableColumnCount: PropTypes.number,
+ columnWidth: PropTypes.number,
+ onResizeStart: PropTypes.func,
+ onResize: PropTypes.func,
+ onResizeStop: PropTypes.func,
+
+ // redux
+ handleComponentDrop: PropTypes.func.isRequired,
+ deleteComponent: PropTypes.func.isRequired,
+ updateComponents: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+ availableColumnCount: 0,
+ columnWidth: 0,
+ onDropOnTab() {},
+ onDeleteTab() {},
+ onResizeStart() {},
+ onResize() {},
+ onResizeStop() {},
+};
+
+export default class Tab extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = {
+ isFocused: false,
+ };
+ this.handleChangeFocus = this.handleChangeFocus.bind(this);
+ this.handleChangeText = this.handleChangeText.bind(this);
+ this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
+ this.handleDrop = this.handleDrop.bind(this);
+ this.handleTopDropTargetDrop = this.handleTopDropTargetDrop.bind(this);
+ }
+
+ handleChangeFocus(nextFocus) {
+ this.setState(() => ({ isFocused: nextFocus }));
+ }
+
+ handleChangeText(nextTabText) {
+ const { updateComponents, component } = this.props;
+ if (nextTabText && nextTabText !== component.meta.text) {
+ updateComponents({
+ [component.id]: {
+ ...component,
+ meta: {
+ ...component.meta,
+ text: nextTabText,
+ },
+ },
+ });
+ }
+ }
+
+ handleDeleteComponent() {
+ const { index, id, parentId } = this.props;
+ this.props.deleteComponent(id, parentId);
+ this.props.onDeleteTab(index);
+ }
+
+ handleDrop(dropResult) {
+ this.props.handleComponentDrop(dropResult);
+ this.props.onDropOnTab(dropResult);
+ }
+
+ handleTopDropTargetDrop(dropResult) {
+ if (dropResult) {
+ this.props.handleComponentDrop({
+ ...dropResult,
+ destination: {
+ ...dropResult.destination,
+ // force appending as the first child if top drop target
+ index: 0,
+ },
+ });
+ }
+ }
+
+ renderTabContent() {
+ const {
+ component: tabComponent,
+ parentComponent: tabParentComponent,
+ depth,
+ availableColumnCount,
+ columnWidth,
+ onResizeStart,
+ onResize,
+ onResizeStop,
+ editMode,
+ } = this.props;
+
+ return (
+ <div className="dashboard-component-tabs-content">
+ {/* Make top of tab droppable */}
+ {editMode && (
+ <DragDroppable
+ component={tabComponent}
+ parentComponent={tabParentComponent}
+ orientation="column"
+ index={0}
+ depth={depth}
+ onDrop={this.handleTopDropTargetDrop}
+ editMode
+ className="empty-droptarget"
+ >
+ {({ dropIndicatorProps }) =>
+ dropIndicatorProps && (
+ <div className="drop-indicator drop-indicator--top" />
+ )
+ }
+ </DragDroppable>
+ )}
+ {tabComponent.children.map((componentId, componentIndex) => (
+ <DashboardComponent
+ key={componentId}
+ id={componentId}
+ parentId={tabComponent.id}
+ depth={depth} // see isValidChild.js for why tabs don't increment child depth
+ index={componentIndex}
+ onDrop={this.handleDrop}
+ availableColumnCount={availableColumnCount}
+ columnWidth={columnWidth}
+ onResizeStart={onResizeStart}
+ onResize={onResize}
+ onResizeStop={onResizeStop}
+ />
+ ))}
+ {/* Make bottom of tab droppable */}
+ {editMode && (
+ <DragDroppable
+ component={tabComponent}
+ parentComponent={tabParentComponent}
+ orientation="column"
+ index={tabComponent.children.length}
+ depth={depth}
+ onDrop={this.handleDrop}
+ editMode
+ className="empty-droptarget"
+ >
+ {({ dropIndicatorProps }) =>
+ dropIndicatorProps && (
+ <div className="drop-indicator drop-indicator--bottom" />
+ )
+ }
+ </DragDroppable>
+ )}
+ </div>
+ );
+ }
+
+ renderTab() {
+ const { isFocused } = this.state;
+ const { component, parentComponent, index, depth, editMode } = this.props;
+
+ return (
+ <DragDroppable
+ component={component}
+ parentComponent={parentComponent}
+ orientation="column"
+ index={index}
+ depth={depth}
+ onDrop={this.handleDrop}
+ // disable drag drop of top-level Tab's to prevent invalid nesting of a child in
+ // itself, e.g. if a top-level Tab has a Tabs child, dragging the Tab into the Tabs would
+ // reusult in circular children
+ disableDragDrop={depth <= DASHBOARD_ROOT_DEPTH + 1}
+ editMode={editMode}
+ >
+ {({ dropIndicatorProps, dragSourceRef }) => (
+ <div className="dragdroppable-tab" ref={dragSourceRef}>
+ <WithPopoverMenu
+ onChangeFocus={this.handleChangeFocus}
+ menuItems={
+ parentComponent.children.length <= 1
+ ? []
+ : [
+ <DeleteComponentButton
+ onDelete={this.handleDeleteComponent}
+ />,
+ ]
+ }
+ editMode={editMode}
+ >
+ <EditableTitle
+ title={component.meta.text}
+ canEdit={editMode && isFocused}
+ onSaveTitle={this.handleChangeText}
+ showTooltip={false}
+ />
+ </WithPopoverMenu>
+
+ {dropIndicatorProps && <div {...dropIndicatorProps} />}
+ </div>
+ )}
+ </DragDroppable>
+ );
+ }
+
+ render() {
+ const { renderType } = this.props;
+ return renderType === RENDER_TAB
+ ? this.renderTab()
+ : this.renderTabContent();
+ }
+}
+
+Tab.propTypes = propTypes;
+Tab.defaultProps = defaultProps;
diff --git a/superset/assets/src/dashboard/components/gridComponents/Tabs.jsx b/superset/assets/src/dashboard/components/gridComponents/Tabs.jsx
new file mode 100644
index 0000000000..1bf6e3e1b3
--- /dev/null
+++ b/superset/assets/src/dashboard/components/gridComponents/Tabs.jsx
@@ -0,0 +1,240 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Tabs as BootstrapTabs, Tab as BootstrapTab } from 'react-bootstrap';
+
+import DragDroppable from '../dnd/DragDroppable';
+import DragHandle from '../dnd/DragHandle';
+import DashboardComponent from '../../containers/DashboardComponent';
+import DeleteComponentButton from '../DeleteComponentButton';
+import HoverMenu from '../menu/HoverMenu';
+import { componentShape } from '../../util/propShapes';
+import { NEW_TAB_ID, DASHBOARD_ROOT_ID } from '../../util/constants';
+import { RENDER_TAB, RENDER_TAB_CONTENT } from './Tab';
+import { TAB_TYPE } from '../../util/componentTypes';
+
+const NEW_TAB_INDEX = -1;
+const MAX_TAB_COUNT = 7;
+
+const propTypes = {
+ id: PropTypes.string.isRequired,
+ parentId: PropTypes.string.isRequired,
+ component: componentShape.isRequired,
+ parentComponent: componentShape.isRequired,
+ index: PropTypes.number.isRequired,
+ depth: PropTypes.number.isRequired,
+ renderTabContent: PropTypes.bool, // whether to render tabs + content or just tabs
+ editMode: PropTypes.bool.isRequired,
+ renderHoverMenu: PropTypes.bool,
+
+ // grid related
+ availableColumnCount: PropTypes.number,
+ columnWidth: PropTypes.number,
+ onResizeStart: PropTypes.func,
+ onResize: PropTypes.func,
+ onResizeStop: PropTypes.func,
+
+ // dnd
+ createComponent: PropTypes.func.isRequired,
+ handleComponentDrop: PropTypes.func.isRequired,
+ onChangeTab: PropTypes.func,
+ deleteComponent: PropTypes.func.isRequired,
+ updateComponents: PropTypes.func.isRequired,
+};
+
+const defaultProps = {
+ children: null,
+ renderTabContent: true,
+ renderHoverMenu: true,
+ availableColumnCount: 0,
+ columnWidth: 0,
+ onChangeTab() {},
+ onResizeStart() {},
+ onResize() {},
+ onResizeStop() {},
+};
+
+class Tabs extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = {
+ tabIndex: 0,
+ };
+ this.handleClickTab = this.handleClickTab.bind(this);
+ this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
+ this.handleDeleteTab = this.handleDeleteTab.bind(this);
+ this.handleDropOnTab = this.handleDropOnTab.bind(this);
+ }
+
+ componentWillReceiveProps(nextProps) {
+ const maxIndex = Math.max(0, nextProps.component.children.length - 1);
+ if (this.state.tabIndex > maxIndex) {
+ this.setState(() => ({ tabIndex: maxIndex }));
+ }
+ }
+
+ handleClickTab(tabIndex) {
+ const { component, createComponent } = this.props;
+
+ if (tabIndex === NEW_TAB_INDEX) {
+ createComponent({
+ destination: {
+ id: component.id,
+ type: component.type,
+ index: component.children.length,
+ },
+ dragging: {
+ id: NEW_TAB_ID,
+ type: TAB_TYPE,
+ },
+ });
+ } else if (tabIndex !== this.state.tabIndex) {
+ this.setState(() => ({ tabIndex }));
+ this.props.onChangeTab({ tabIndex, tabId: component.children[tabIndex] });
+ }
+ }
+
+ handleDeleteComponent() {
+ const { deleteComponent, id, parentId } = this.props;
+ deleteComponent(id, parentId);
+ }
+
+ handleDeleteTab(tabIndex) {
+ this.handleClickTab(Math.max(0, tabIndex - 1));
+ }
+
+ handleDropOnTab(dropResult) {
+ const { component } = this.props;
+
+ // Ensure dropped tab is visible
+ const { destination } = dropResult;
+ if (destination) {
+ const dropTabIndex =
+ destination.id === component.id
+ ? destination.index // dropped ON tabs
+ : component.children.indexOf(destination.id); // dropped IN tab
+
+ if (dropTabIndex > -1) {
+ setTimeout(() => {
+ this.handleClickTab(dropTabIndex);
+ }, 30);
+ }
+ }
+ }
+
+ render() {
+ const {
+ depth,
+ component: tabsComponent,
+ parentComponent,
+ index,
+ availableColumnCount,
+ columnWidth,
+ onResizeStart,
+ onResize,
+ onResizeStop,
+ handleComponentDrop,
+ renderTabContent,
+ renderHoverMenu,
+ editMode,
+ } = this.props;
+
+ const { tabIndex: selectedTabIndex } = this.state;
+ const { children: tabIds } = tabsComponent;
+
+ return (
+ <DragDroppable
+ component={tabsComponent}
+ parentComponent={parentComponent}
+ orientation="row"
+ index={index}
+ depth={depth}
+ onDrop={handleComponentDrop}
+ editMode={editMode}
+ >
+ {({
+ dropIndicatorProps: tabsDropIndicatorProps,
+ dragSourceRef: tabsDragSourceRef,
+ }) => (
+ <div className="dashboard-component dashboard-component-tabs">
+ {editMode &&
+ renderHoverMenu && (
+ <HoverMenu innerRef={tabsDragSourceRef} position="left">
+ <DragHandle position="left" />
+ <DeleteComponentButton
+ onDelete={this.handleDeleteComponent}
+ />
+ </HoverMenu>
+ )}
+
+ <BootstrapTabs
+ id={tabsComponent.id}
+ activeKey={selectedTabIndex}
+ onSelect={this.handleClickTab}
+ animation
+ mountOnEnter
+ unmountOnExit={false}
+ >
+ {tabIds.map((tabId, tabIndex) => (
+ // react-bootstrap doesn't render a Tab if we move this to its own Tab.jsx so we
+ // use `renderType` to indicate what the DashboardComponent should render. This
+ // prevents us from passing the entire dashboard component lookup to render Tabs.jsx
+ <BootstrapTab
+ key={tabId}
+ eventKey={tabIndex}
+ title={
+ <DashboardComponent
+ id={tabId}
+ parentId={tabsComponent.id}
+ depth={depth}
+ index={tabIndex}
+ renderType={RENDER_TAB}
+ availableColumnCount={availableColumnCount}
+ columnWidth={columnWidth}
+ onDropOnTab={this.handleDropOnTab}
+ onDeleteTab={this.handleDeleteTab}
+ />
+ }
+ >
+ {renderTabContent && (
+ <DashboardComponent
+ id={tabId}
+ parentId={tabsComponent.id}
+ depth={depth} // see isValidChild.js for why tabs don't increment child depth
+ index={tabIndex}
+ renderType={RENDER_TAB_CONTENT}
+ availableColumnCount={availableColumnCount}
+ columnWidth={columnWidth}
+ onResizeStart={onResizeStart}
+ onResize={onResize}
+ onResizeStop={onResizeStop}
+ onDropOnTab={this.handleDropOnTab}
+ />
+ )}
+ </BootstrapTab>
+ ))}
+
+ {editMode &&
+ tabIds.length < MAX_TAB_COUNT && (
+ <BootstrapTab
+ eventKey={NEW_TAB_INDEX}
+ title={<div className="fa fa-plus" />}
+ />
+ )}
+ </BootstrapTabs>
+
+ {/* don't indicate that a drop on root is allowed when tabs already exist */}
+ {tabsDropIndicatorProps &&
+ parentComponent.id !== DASHBOARD_ROOT_ID && (
+ <div {...tabsDropIndicatorProps} />
+ )}
+ </div>
+ )}
+ </DragDroppable>
+ );
+ }
+}
+
+Tabs.propTypes = propTypes;
+Tabs.defaultProps = defaultProps;
+
+export default Tabs;
diff --git a/superset/assets/src/dashboard/components/gridComponents/index.js b/superset/assets/src/dashboard/components/gridComponents/index.js
new file mode 100644
index 0000000000..c56bed01cd
--- /dev/null
+++ b/superset/assets/src/dashboard/components/gridComponents/index.js
@@ -0,0 +1,39 @@
+import {
+ CHART_TYPE,
+ MARKDOWN_TYPE,
+ COLUMN_TYPE,
+ DIVIDER_TYPE,
+ HEADER_TYPE,
+ ROW_TYPE,
+ TAB_TYPE,
+ TABS_TYPE,
+} from '../../util/componentTypes';
+
+import ChartHolder from './ChartHolder';
+import Markdown from './Markdown';
+import Column from './Column';
+import Divider from './Divider';
+import Header from './Header';
+import Row from './Row';
+import Tab from './Tab';
+import Tabs from './Tabs';
+
+export { default as ChartHolder } from './ChartHolder';
+export { default as Markdown } from './Markdown';
+export { default as Column } from './Column';
+export { default as Divider } from './Divider';
+export { default as Header } from './Header';
+export { default as Row } from './Row';
+export { default as Tab } from './Tab';
+export { default as Tabs } from './Tabs';
+
+export default {
+ [CHART_TYPE]: ChartHolder,
+ [MARKDOWN_TYPE]: Markdown,
+ [COLUMN_TYPE]: Column,
+ [DIVIDER_TYPE]: Divider,
+ [HEADER_TYPE]: Header,
+ [ROW_TYPE]: Row,
+ [TAB_TYPE]: Tab,
+ [TABS_TYPE]: Tabs,
+};
diff --git a/superset/assets/src/dashboard/components/gridComponents/new/DraggableNewComponent.jsx b/superset/assets/src/dashboard/components/gridComponents/new/DraggableNewComponent.jsx
new file mode 100644
index 0000000000..d579dc1624
--- /dev/null
+++ b/superset/assets/src/dashboard/components/gridComponents/new/DraggableNewComponent.jsx
@@ -0,0 +1,46 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import cx from 'classnames';
+
+import DragDroppable from '../../dnd/DragDroppable';
+import { NEW_COMPONENTS_SOURCE_ID } from '../../../util/constants';
+import { NEW_COMPONENT_SOURCE_TYPE } from '../../../util/componentTypes';
+
+const propTypes = {
+ id: PropTypes.string.isRequired,
+ type: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired,
+ className: PropTypes.string,
+};
+
+const defaultProps = {
+ className: null,
+};
+
+export default class DraggableNewComponent extends React.PureComponent {
+ render() {
+ const { label, id, type, className } = this.props;
+ return (
+ <DragDroppable
+ component={{ type, id }}
+ parentComponent={{
+ id: NEW_COMPONENTS_SOURCE_ID,
+ type: NEW_COMPONENT_SOURCE_TYPE,
+ }}
+ index={0}
+ depth={0}
+ editMode
+ >
+ {({ dragSourceRef }) => (
+ <div ref={dragSourceRef} className="new-component">
+ <div className={cx('new-component-placeholder', className)} />
+ {label}
+ </div>
+ )}
+ </DragDroppable>
+ );
+ }
+}
+
+DraggableNewComponent.propTypes = propTypes;
+DraggableNewComponent.defaultProps = defaultProps;
diff --git a/superset/assets/src/dashboard/components/gridComponents/new/NewColumn.jsx b/superset/assets/src/dashboard/components/gridComponents/new/NewColumn.jsx
new file mode 100644
index 0000000000..f624e58a37
--- /dev/null
+++ b/superset/assets/src/dashboard/components/gridComponents/new/NewColumn.jsx
@@ -0,0 +1,16 @@
+import React from 'react';
+
+import { COLUMN_TYPE } from '../../../util/componentTypes';
+import { NEW_COLUMN_ID } from '../../../util/constants';
+import DraggableNewComponent from './DraggableNewComponent';
+
+export default function DraggableNewColumn() {
+ return (
+ <DraggableNewComponent
+ id={NEW_COLUMN_ID}
+ type={COLUMN_TYPE}
+ label="Column"
+ className="fa fa-long-arrow-down"
+ />
+ );
+}
diff --git a/superset/assets/src/dashboard/components/gridComponents/new/NewDivider.jsx b/superset/assets/src/dashboard/components/gridComponents/new/NewDivider.jsx
new file mode 100644
index 0000000000..de07a24aa3
--- /dev/null
+++ b/superset/assets/src/dashboard/components/gridComponents/new/NewDivider.jsx
@@ -0,0 +1,16 @@
+import React from 'react';
+
+import { DIVIDER_TYPE } from '../../../util/componentTypes';
+import { NEW_DIVIDER_ID } from '../../../util/constants';
+import DraggableNewComponent from './DraggableNewComponent';
+
+export default function DraggableNewDivider() {
+ return (
+ <DraggableNewComponent
+ id={NEW_DIVIDER_ID}
+ type={DIVIDER_TYPE}
+ label="Divider"
+ className="divider-placeholder"
+ />
+ );
+}
diff --git a/superset/assets/src/dashboard/components/gridComponents/new/NewHeader.jsx b/superset/assets/src/dashboard/components/gridComponents/new/NewHeader.jsx
new file mode 100644
index 0000000000..50bd600399
--- /dev/null
+++ b/superset/assets/src/dashboard/components/gridComponents/new/NewHeader.jsx
@@ -0,0 +1,16 @@
+import React from 'react';
+
+import { HEADER_TYPE } from '../../../util/componentTypes';
+import { NEW_HEADER_ID } from '../../../util/constants';
+import DraggableNewComponent from './DraggableNewComponent';
+
+export default function DraggableNewHeader() {
+ return (
+ <DraggableNewComponent
+ id={NEW_HEADER_ID}
+ type={HEADER_TYPE}
+ label="Header"
+ className="fa fa-header"
+ />
+ );
+}
diff --git a/superset/assets/src/dashboard/components/gridComponents/new/NewMarkdown.jsx b/superset/assets/src/dashboard/components/gridComponents/new/NewMarkdown.jsx
new file mode 100644
index 0000000000..e4c8892145
--- /dev/null
+++ b/superset/assets/src/dashboard/components/gridComponents/new/NewMarkdown.jsx
@@ -0,0 +1,16 @@
+import React from 'react';
+
+import { MARKDOWN_TYPE } from '../../../util/componentTypes';
+import { NEW_MARKDOWN_ID } from '../../../util/constants';
+import DraggableNewComponent from './DraggableNewComponent';
+
+export default function DraggableNewDivider() {
+ return (
+ <DraggableNewComponent
+ id={NEW_MARKDOWN_ID}
+ type={MARKDOWN_TYPE}
+ label="Markdown"
+ className="fa fa-code"
+ />
+ );
+}
diff --git a/superset/assets/src/dashboard/components/gridComponents/new/NewRow.jsx b/superset/assets/src/dashboard/components/gridComponents/new/NewRow.jsx
new file mode 100644
index 0000000000..81bdc93d18
--- /dev/null
+++ b/superset/assets/src/dashboard/components/gridComponents/new/NewRow.jsx
@@ -0,0 +1,16 @@
+import React from 'react';
+
+import { ROW_TYPE } from '../../../util/componentTypes';
+import { NEW_ROW_ID } from '../../../util/constants';
+import DraggableNewComponent from './DraggableNewComponent';
+
+export default function DraggableNewRow() {
+ return (
+ <DraggableNewComponent
+ id={NEW_ROW_ID}
+ type={ROW_TYPE}
+ label="Row"
+ className="fa fa-long-arrow-right"
+ />
+ );
+}
diff --git a/superset/assets/src/dashboard/components/gridComponents/new/NewTabs.jsx b/superset/assets/src/dashboard/components/gridComponents/new/NewTabs.jsx
new file mode 100644
index 0000000000..fd9366bb55
--- /dev/null
+++ b/superset/assets/src/dashboard/components/gridComponents/new/NewTabs.jsx
@@ -0,0 +1,16 @@
+import React from 'react';
+
+import { TABS_TYPE } from '../../../util/componentTypes';
+import { NEW_TABS_ID } from '../../../util/constants';
+import DraggableNewComponent from './DraggableNewComponent';
+
+export default function DraggableNewTabs() {
+ return (
+ <DraggableNewComponent
+ id={NEW_TABS_ID}
+ type={TABS_TYPE}
+ label="Tabs"
+ className="fa fa-window-restore"
+ />
+ );
+}
diff --git a/superset/assets/src/dashboard/components/menu/BackgroundStyleDropdown.jsx b/superset/assets/src/dashboard/components/menu/BackgroundStyleDropdown.jsx
new file mode 100644
index 0000000000..41cf1df72a
--- /dev/null
+++ b/superset/assets/src/dashboard/components/menu/BackgroundStyleDropdown.jsx
@@ -0,0 +1,46 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import cx from 'classnames';
+
+import backgroundStyleOptions from '../../util/backgroundStyleOptions';
+import PopoverDropdown from './PopoverDropdown';
+
+const propTypes = {
+ id: PropTypes.string.isRequired,
+ value: PropTypes.string.isRequired,
+ onChange: PropTypes.func.isRequired,
+};
+
+function renderButton(option) {
+ return (
+ <div className={cx('background-style-option', option.className)}>
+ {`${option.label} background`}
+ </div>
+ );
+}
+
+function renderOption(option) {
+ return (
+ <div className={cx('background-style-option', option.className)}>
+ {option.label}
+ </div>
+ );
+}
+
+export default class BackgroundStyleDropdown extends React.PureComponent {
+ render() {
+ const { id, value, onChange } = this.props;
+ return (
+ <PopoverDropdown
+ id={id}
+ options={backgroundStyleOptions}
+ value={value}
+ onChange={onChange}
+ renderButton={renderButton}
+ renderOption={renderOption}
+ />
+ );
+ }
+}
+
+BackgroundStyleDropdown.propTypes = propTypes;
diff --git a/superset/assets/src/dashboard/components/menu/HoverMenu.jsx b/superset/assets/src/dashboard/components/menu/HoverMenu.jsx
new file mode 100644
index 0000000000..c238d023d8
--- /dev/null
+++ b/superset/assets/src/dashboard/components/menu/HoverMenu.jsx
@@ -0,0 +1,36 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import cx from 'classnames';
+
+const propTypes = {
+ position: PropTypes.oneOf(['left', 'top']),
+ innerRef: PropTypes.func,
+ children: PropTypes.node,
+};
+
+const defaultProps = {
+ position: 'left',
+ innerRef: null,
+ children: null,
+};
+
+export default class HoverMenu extends React.PureComponent {
+ render() {
+ const { innerRef, position, children } = this.props;
+ return (
+ <div
+ ref={innerRef}
+ className={cx(
+ 'hover-menu',
+ position === 'left' && 'hover-menu--left',
+ position === 'top' && 'hover-menu--top',
+ )}
+ >
+ {children}
+ </div>
+ );
+ }
+}
+
+HoverMenu.propTypes = propTypes;
+HoverMenu.defaultProps = defaultProps;
diff --git a/superset/assets/src/dashboard/components/menu/MarkdownModeDropdown.jsx b/superset/assets/src/dashboard/components/menu/MarkdownModeDropdown.jsx
new file mode 100644
index 0000000000..10aa932233
--- /dev/null
+++ b/superset/assets/src/dashboard/components/menu/MarkdownModeDropdown.jsx
@@ -0,0 +1,39 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { t } from '../../../locales';
+
+import PopoverDropdown from './PopoverDropdown';
+
+const propTypes = {
+ id: PropTypes.string.isRequired,
+ value: PropTypes.string.isRequired,
+ onChange: PropTypes.func.isRequired,
+};
+
+const dropdownOptions = [
+ {
+ value: 'edit',
+ label: t('Edit'),
+ },
+ {
+ value: 'preview',
+ label: t('Preview'),
+ },
+];
+
+export default class MarkdownModeDropdown extends React.PureComponent {
+ render() {
+ const { id, value, onChange } = this.props;
+
+ return (
+ <PopoverDropdown
+ id={id}
+ options={dropdownOptions}
+ value={value}
+ onChange={onChange}
+ />
+ );
+ }
+}
+
+MarkdownModeDropdown.propTypes = propTypes;
diff --git a/superset/assets/src/dashboard/components/menu/PopoverDropdown.jsx b/superset/assets/src/dashboard/components/menu/PopoverDropdown.jsx
new file mode 100644
index 0000000000..4971793026
--- /dev/null
+++ b/superset/assets/src/dashboard/components/menu/PopoverDropdown.jsx
@@ -0,0 +1,66 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { DropdownButton, MenuItem } from 'react-bootstrap';
+
+const propTypes = {
+ id: PropTypes.string.isRequired,
+ options: PropTypes.arrayOf(
+ PropTypes.shape({
+ value: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired,
+ className: PropTypes.string,
+ }),
+ ).isRequired,
+ onChange: PropTypes.func.isRequired,
+ value: PropTypes.string.isRequired,
+ renderButton: PropTypes.func,
+ renderOption: PropTypes.func,
+};
+
+const defaultProps = {
+ renderButton: option => option.label,
+ renderOption: option => (
+ <div className={option.className}>{option.label}</div>
+ ),
+};
+
+class PopoverDropdown extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.handleSelect = this.handleSelect.bind(this);
+ }
+
+ handleSelect(nextValue) {
+ this.props.onChange(nextValue);
+ }
+
+ render() {
+ const { id, value, options, renderButton, renderOption } = this.props;
+ const selected = options.find(opt => opt.value === value);
+ return (
+ <DropdownButton
+ id={id}
+ bsSize="small"
+ title={renderButton(selected)}
+ className="popover-dropdown"
+ >
+ {options.map(option => (
+ <MenuItem
+ key={option.value}
+ eventKey={option.value}
+ active={option.value === value}
+ onSelect={this.handleSelect}
+ className="dropdown-item"
+ >
+ {renderOption(option)}
+ </MenuItem>
+ ))}
+ </DropdownButton>
+ );
+ }
+}
+
+PopoverDropdown.propTypes = propTypes;
+PopoverDropdown.defaultProps = defaultProps;
+
+export default PopoverDropdown;
diff --git a/superset/assets/src/dashboard/components/menu/WithPopoverMenu.jsx b/superset/assets/src/dashboard/components/menu/WithPopoverMenu.jsx
new file mode 100644
index 0000000000..2a047ac573
--- /dev/null
+++ b/superset/assets/src/dashboard/components/menu/WithPopoverMenu.jsx
@@ -0,0 +1,123 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import cx from 'classnames';
+
+const propTypes = {
+ children: PropTypes.node,
+ disableClick: PropTypes.bool,
+ menuItems: PropTypes.arrayOf(PropTypes.node),
+ onChangeFocus: PropTypes.func,
+ isFocused: PropTypes.bool,
+ shouldFocus: PropTypes.func,
+ editMode: PropTypes.bool.isRequired,
+ style: PropTypes.object,
+};
+
+const defaultProps = {
+ children: null,
+ disableClick: false,
+ onChangeFocus: null,
+ onPressDelete() {},
+ menuItems: [],
+ isFocused: false,
+ shouldFocus: (event, container) => container.contains(event.target),
+ style: null,
+};
+
+class WithPopoverMenu extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.state = {
+ isFocused: props.isFocused,
+ };
+ this.setRef = this.setRef.bind(this);
+ this.handleClick = this.handleClick.bind(this);
+ }
+
+ componentWillReceiveProps(nextProps) {
+ if (nextProps.editMode && nextProps.isFocused && !this.state.isFocused) {
+ document.addEventListener('click', this.handleClick, true);
+ document.addEventListener('drag', this.handleClick, true);
+ this.setState({ isFocused: true });
+ } else if (this.state.isFocused && !nextProps.editMode) {
+ document.removeEventListener('click', this.handleClick, true);
+ document.removeEventListener('drag', this.handleClick, true);
+ this.setState({ isFocused: false });
+ }
+ }
+
+ componentWillUnmount() {
+ document.removeEventListener('click', this.handleClick, true);
+ document.removeEventListener('drag', this.handleClick, true);
+ }
+
+ setRef(ref) {
+ this.container = ref;
+ }
+
+ handleClick(event) {
+ if (!this.props.editMode) {
+ return;
+ }
+ const {
+ onChangeFocus,
+ shouldFocus: shouldFocusFunc,
+ disableClick,
+ } = this.props;
+ const shouldFocus = shouldFocusFunc(event, this.container);
+
+ if (!disableClick && shouldFocus && !this.state.isFocused) {
+ // if not focused, set focus and add a window event listener to capture outside clicks
+ // this enables us to not set a click listener for ever item on a dashboard
+ document.addEventListener('click', this.handleClick, true);
+ document.addEventListener('drag', this.handleClick, true);
+ this.setState(() => ({ isFocused: true }));
+ if (onChangeFocus) {
+ onChangeFocus(true);
+ }
+ } else if (!shouldFocus && this.state.isFocused) {
+ document.removeEventListener('click', this.handleClick, true);
+ document.removeEventListener('drag', this.handleClick, true);
+ this.setState(() => ({ isFocused: false }));
+ if (onChangeFocus) {
+ onChangeFocus(false);
+ }
+ }
+ }
+
+ render() {
+ const { children, menuItems, editMode, style } = this.props;
+ const { isFocused } = this.state;
+
+ return (
+ <div
+ ref={this.setRef}
+ onClick={this.handleClick}
+ role="none"
+ className={cx(
+ 'with-popover-menu',
+ editMode && isFocused && 'with-popover-menu--focused',
+ )}
+ style={style}
+ >
+ {children}
+ {editMode &&
+ isFocused &&
+ menuItems.length > 0 && (
+ <div className="popover-menu">
+ {menuItems.map((node, i) => (
+ <div className="menu-item" key={`menu-item-${i}`}>
+ {node}
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ );
+ }
+}
+
+WithPopoverMenu.propTypes = propTypes;
+WithPopoverMenu.defaultProps = defaultProps;
+
+export default WithPopoverMenu;
diff --git a/superset/assets/src/dashboard/components/resizable/ResizableContainer.jsx b/superset/assets/src/dashboard/components/resizable/ResizableContainer.jsx
new file mode 100644
index 0000000000..7e09e73d91
--- /dev/null
+++ b/superset/assets/src/dashboard/components/resizable/ResizableContainer.jsx
@@ -0,0 +1,213 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Resizable from 're-resizable';
+import cx from 'classnames';
+
+import ResizableHandle from './ResizableHandle';
+import resizableConfig from '../../util/resizableConfig';
+import { GRID_BASE_UNIT, GRID_GUTTER_SIZE } from '../../util/constants';
+
+const propTypes = {
+ id: PropTypes.string.isRequired,
+ children: PropTypes.node,
+ adjustableWidth: PropTypes.bool,
+ adjustableHeight: PropTypes.bool,
+ gutterWidth: PropTypes.number,
+ widthStep: PropTypes.number,
+ heightStep: PropTypes.number,
+ widthMultiple: PropTypes.number,
+ heightMultiple: PropTypes.number,
+ minWidthMultiple: PropTypes.number,
+ maxWidthMultiple: PropTypes.number,
+ minHeightMultiple: PropTypes.number,
+ maxHeightMultiple: PropTypes.number,
+ staticHeight: PropTypes.number,
+ staticHeightMultiple: PropTypes.number,
+ staticWidth: PropTypes.number,
+ staticWidthMultiple: PropTypes.number,
+ onResizeStop: PropTypes.func,
+ onResize: PropTypes.func,
+ onResizeStart: PropTypes.func,
+ editMode: PropTypes.bool.isRequired,
+};
+
+const defaultProps = {
+ children: null,
+ adjustableWidth: true,
+ adjustableHeight: true,
+ gutterWidth: GRID_GUTTER_SIZE,
+ widthStep: GRID_BASE_UNIT,
+ heightStep: GRID_BASE_UNIT,
+ widthMultiple: null,
+ heightMultiple: null,
+ minWidthMultiple: 1,
+ maxWidthMultiple: Infinity,
+ minHeightMultiple: 1,
+ maxHeightMultiple: Infinity,
+ staticHeight: null,
+ staticHeightMultiple: null,
+ staticWidth: null,
+ staticWidthMultiple: null,
+ onResizeStop: null,
+ onResize: null,
+ onResizeStart: null,
+};
+
+// because columns are not multiples of a single variable (width = n*cols + (n-1) * gutters)
+// we snap to the base unit and then snap to _actual_ column multiples on stop
+const SNAP_TO_GRID = [GRID_BASE_UNIT, GRID_BASE_UNIT];
+const HANDLE_CLASSES = {
+ right: 'resizable-container-handle--right',
+ bottom: 'resizable-container-handle--bottom',
+};
+class ResizableContainer extends React.PureComponent {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ isResizing: false,
+ };
+
+ this.handleResizeStart = this.handleResizeStart.bind(this);
+ this.handleResize = this.handleResize.bind(this);
+ this.handleResizeStop = this.handleResizeStop.bind(this);
+ }
+
+ handleResizeStart(event, direction, ref) {
+ const { id, onResizeStart } = this.props;
+
+ if (onResizeStart) {
+ onResizeStart({ id, direction, ref });
+ }
+
+ this.setState(() => ({ isResizing: true }));
+ }
+
+ handleResize(event, direction, ref) {
+ const { onResize, id } = this.props;
+ if (onResize) {
+ onResize({ id, direction, ref });
+ }
+ }
+
+ handleResizeStop(event, direction, ref, delta) {
+ const {
+ id,
+ onResizeStop,
+ widthStep,
+ heightStep,
+ widthMultiple,
+ heightMultiple,
+ adjustableHeight,
+ adjustableWidth,
+ gutterWidth,
+ } = this.props;
+
+ if (onResizeStop) {
+ const nextWidthMultiple =
+ widthMultiple + Math.round(delta.width / (widthStep + gutterWidth));
+ const nextHeightMultiple =
+ heightMultiple + Math.round(delta.height / heightStep);
+
+ onResizeStop({
+ id,
+ widthMultiple: adjustableWidth ? nextWidthMultiple : null,
+ heightMultiple: adjustableHeight ? nextHeightMultiple : null,
+ });
+
+ this.setState(() => ({ isResizing: false }));
+ }
+ }
+
+ render() {
+ const {
+ children,
+ adjustableWidth,
+ adjustableHeight,
+ widthStep,
+ heightStep,
+ widthMultiple,
+ heightMultiple,
+ staticHeight,
+ staticHeightMultiple,
+ staticWidth,
+ staticWidthMultiple,
+ minWidthMultiple,
+ maxWidthMultiple,
+ minHeightMultiple,
+ maxHeightMultiple,
+ gutterWidth,
+ editMode,
+ } = this.props;
+
+ const size = {
+ width: adjustableWidth
+ ? (widthStep + gutterWidth) * widthMultiple - gutterWidth
+ : (staticWidthMultiple && staticWidthMultiple * widthStep) ||
+ staticWidth ||
+ undefined,
+ height: adjustableHeight
+ ? heightStep * heightMultiple
+ : (staticHeightMultiple && staticHeightMultiple * heightStep) ||
+ staticHeight ||
+ undefined,
+ };
+
+ let enableConfig = resizableConfig.notAdjustable;
+
+ if (editMode && adjustableWidth && adjustableHeight) {
+ enableConfig = resizableConfig.widthAndHeight;
+ } else if (editMode && adjustableWidth) {
+ enableConfig = resizableConfig.widthOnly;
+ } else if (editMode && adjustableHeight) {
+ enableConfig = resizableConfig.heightOnly;
+ }
+
+ const { isResizing } = this.state;
+
+ return (
+ <Resizable
+ enable={enableConfig}
+ grid={SNAP_TO_GRID}
+ minWidth={
+ adjustableWidth
+ ? minWidthMultiple * (widthStep + gutterWidth) - gutterWidth
+ : undefined
+ }
+ minHeight={
+ adjustableHeight ? minHeightMultiple * heightStep : undefined
+ }
+ maxWidth={
+ adjustableWidth
+ ? Math.max(
+ size.width,
+ maxWidthMultiple * (widthStep + gutterWidth) - gutterWidth,
+ )
+ : undefined
+ }
+ maxHeight={
+ adjustableHeight
+ ? Math.max(size.height, maxHeightMultiple * heightStep)
+ : undefined
+ }
+ size={size}
+ onResizeStart={this.handleResizeStart}
+ onResize={this.handleResize}
+ onResizeStop={this.handleResizeStop}
+ handleComponent={ResizableHandle}
+ className={cx(
+ 'resizable-container',
+ isResizing && 'resizable-container--resizing',
+ )}
+ handleClasses={HANDLE_CLASSES}
+ >
+ {children}
+ </Resizable>
+ );
+ }
+}
+
+ResizableContainer.propTypes = propTypes;
+ResizableContainer.defaultProps = defaultProps;
+
+export default ResizableContainer;
diff --git a/superset/assets/src/dashboard/components/resizable/ResizableHandle.jsx b/superset/assets/src/dashboard/components/resizable/ResizableHandle.jsx
new file mode 100644
index 0000000000..b696b26fa4
--- /dev/null
+++ b/superset/assets/src/dashboard/components/resizable/ResizableHandle.jsx
@@ -0,0 +1,19 @@
+import React from 'react';
+
+export function BottomRightResizeHandle() {
+ return <div className="resize-handle resize-handle--bottom-right" />;
+}
+
+export function RightResizeHandle() {
+ return <div className="resize-handle resize-handle--right" />;
+}
+
+export function BottomResizeHandle() {
+ return <div className="resize-handle resize-handle--bottom" />;
+}
+
+export default {
+ right: RightResizeHandle,
+ bottom: BottomResizeHandle,
+ bottomRight: BottomRightResizeHandle,
+};
diff --git a/superset/assets/src/dashboard/containers/Chart.jsx b/superset/assets/src/dashboard/containers/Chart.jsx
new file mode 100644
index 0000000000..97b2bb8e18
--- /dev/null
+++ b/superset/assets/src/dashboard/containers/Chart.jsx
@@ -0,0 +1,66 @@
+import { bindActionCreators } from 'redux';
+import { connect } from 'react-redux';
+
+import {
+ changeFilter as addFilter,
+ toggleExpandSlice,
+} from '../actions/dashboardState';
+import { refreshChart } from '../../chart/chartAction';
+import getFormDataWithExtraFilters from '../util/charts/getFormDataWithExtraFilters';
+import { updateComponents } from '../actions/dashboardLayout';
+import Chart from '../components/gridComponents/Chart';
+
+const EMPTY_FILTERS = {};
+
+function mapStateToProps(
+ {
+ charts: chartQueries,
+ dashboardInfo,
+ dashboardState,
+ datasources,
+ sliceEntities,
+ },
+ ownProps,
+) {
+ const { id } = ownProps;
+ const chart = chartQueries[id] || {};
+ const { filters } = dashboardState;
+
+ return {
+ chart,
+ datasource:
+ (chart && chart.form_data && datasources[chart.form_data.datasource]) ||
+ {},
+ slice: sliceEntities.slices[id],
+ timeout: dashboardInfo.common.conf.SUPERSET_WEBSERVER_TIMEOUT,
+ filters: filters[id] || EMPTY_FILTERS,
+ // note: this method caches filters if possible to prevent render cascades
+ formData: getFormDataWithExtraFilters({
+ chart,
+ dashboardMetadata: dashboardInfo.metadata,
+ filters,
+ sliceId: id,
+ }),
+ editMode: dashboardState.editMode,
+ isExpanded: !!dashboardState.expandedSlices[id],
+ supersetCanExplore: !!dashboardInfo.superset_can_explore,
+ sliceCanEdit: !!dashboardInfo.slice_can_edit,
+ };
+}
+
+function mapDispatchToProps(dispatch) {
+ return bindActionCreators(
+ {
+ updateComponents,
+ toggleExpandSlice,
+ addFilter,
+ refreshChart,
+ },
+ dispatch,
+ );
+}
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps,
+)(Chart);
diff --git a/superset/assets/src/dashboard/containers/Dashboard.jsx b/superset/assets/src/dashboard/containers/Dashboard.jsx
new file mode 100644
index 0000000000..d43e448793
--- /dev/null
+++ b/superset/assets/src/dashboard/containers/Dashboard.jsx
@@ -0,0 +1,56 @@
+import { bindActionCreators } from 'redux';
+import { connect } from 'react-redux';
+
+import Dashboard from '../components/Dashboard';
+
+import {
+ addSliceToDashboard,
+ removeSliceFromDashboard,
+} from '../actions/dashboardState';
+import { runQuery } from '../../chart/chartAction';
+import getLoadStatsPerTopLevelComponent from '../util/logging/getLoadStatsPerTopLevelComponent';
+
+function mapStateToProps({
+ datasources,
+ sliceEntities,
+ charts,
+ dashboardInfo,
+ dashboardState,
+ dashboardLayout,
+ impressionId,
+}) {
+ return {
+ initMessages: dashboardInfo.common.flash_messages,
+ timeout: dashboardInfo.common.conf.SUPERSET_WEBSERVER_TIMEOUT,
+ userId: dashboardInfo.userId,
+ dashboardInfo,
+ dashboardState,
+ charts,
+ datasources,
+ slices: sliceEntities.slices,
+ layout: dashboardLayout.present,
+ impressionId,
+ loadStats: getLoadStatsPerTopLevelComponent({
+ layout: dashboardLayout.present,
+ chartQueries: charts,
+ }),
+ };
+}
+
+function mapDispatchToProps(dispatch) {
+ return {
+ actions: bindActionCreators(
+ {
+ addSliceToDashboard,
+ removeSliceFromDashboard,
+ runQuery,
+ },
+ dispatch,
+ ),
+ };
+}
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps,
+)(Dashboard);
diff --git a/superset/assets/src/dashboard/containers/DashboardBuilder.jsx b/superset/assets/src/dashboard/containers/DashboardBuilder.jsx
new file mode 100644
index 0000000000..fb82b2a06b
--- /dev/null
+++ b/superset/assets/src/dashboard/containers/DashboardBuilder.jsx
@@ -0,0 +1,33 @@
+import { bindActionCreators } from 'redux';
+import { connect } from 'react-redux';
+import DashboardBuilder from '../components/DashboardBuilder';
+
+import { toggleBuilderPane } from '../actions/dashboardState';
+import {
+ deleteTopLevelTabs,
+ handleComponentDrop,
+} from '../actions/dashboardLayout';
+
+function mapStateToProps({ dashboardLayout: undoableLayout, dashboardState }) {
+ return {
+ dashboardLayout: undoableLayout.present,
+ editMode: dashboardState.editMode,
+ showBuilderPane: dashboardState.showBuilderPane,
+ };
+}
+
+function mapDispatchToProps(dispatch) {
+ return bindActionCreators(
+ {
+ deleteTopLevelTabs,
+ handleComponentDrop,
+ toggleBuilderPane,
+ },
+ dispatch,
+ );
+}
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps,
+)(DashboardBuilder);
diff --git a/superset/assets/src/dashboard/containers/DashboardComponent.jsx b/superset/assets/src/dashboard/containers/DashboardComponent.jsx
new file mode 100644
index 0000000000..c138f881d9
--- /dev/null
+++ b/superset/assets/src/dashboard/containers/DashboardComponent.jsx
@@ -0,0 +1,81 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { bindActionCreators } from 'redux';
+import { connect } from 'react-redux';
+
+import ComponentLookup from '../components/gridComponents';
+import getDetailedComponentWidth from '../util/getDetailedComponentWidth';
+import { componentShape } from '../util/propShapes';
+import { COLUMN_TYPE, ROW_TYPE } from '../util/componentTypes';
+
+import {
+ createComponent,
+ deleteComponent,
+ updateComponents,
+ handleComponentDrop,
+} from '../actions/dashboardLayout';
+
+const propTypes = {
+ component: componentShape.isRequired,
+ parentComponent: componentShape.isRequired,
+ createComponent: PropTypes.func.isRequired,
+ deleteComponent: PropTypes.func.isRequired,
+ updateComponents: PropTypes.func.isRequired,
+ handleComponentDrop: PropTypes.func.isRequired,
+};
+
+function mapStateToProps(
+ { dashboardLayout: undoableLayout, dashboardState },
+ ownProps,
+) {
+ const dashboardLayout = undoableLayout.present;
+ const { id, parentId } = ownProps;
+ const component = dashboardLayout[id];
+ const props = {
+ component,
+ parentComponent: dashboardLayout[parentId],
+ editMode: dashboardState.editMode,
+ };
+
+ // rows and columns need more data about their child dimensions
+ // doing this allows us to not pass the entire component lookup to all Components
+ const componentType = component.type;
+ if (componentType === ROW_TYPE || componentType === COLUMN_TYPE) {
+ const { occupiedWidth, minimumWidth } = getDetailedComponentWidth({
+ id,
+ components: dashboardLayout,
+ });
+
+ if (componentType === ROW_TYPE) props.occupiedColumnCount = occupiedWidth;
+ if (componentType === COLUMN_TYPE) props.minColumnWidth = minimumWidth;
+ }
+
+ return props;
+}
+
+function mapDispatchToProps(dispatch) {
+ return bindActionCreators(
+ {
+ createComponent,
+ deleteComponent,
+ updateComponents,
+ handleComponentDrop,
+ },
+ dispatch,
+ );
+}
+
+class DashboardComponent extends React.PureComponent {
+ render() {
+ const { component } = this.props;
+ const Component = ComponentLookup[component.type];
+ return Component ? <Component {...this.props} /> : null;
+ }
+}
+
+DashboardComponent.propTypes = propTypes;
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps,
+)(DashboardComponent);
diff --git a/superset/assets/src/dashboard/containers/DashboardGrid.jsx b/superset/assets/src/dashboard/containers/DashboardGrid.jsx
new file mode 100644
index 0000000000..d4ddd5ef13
--- /dev/null
+++ b/superset/assets/src/dashboard/containers/DashboardGrid.jsx
@@ -0,0 +1,29 @@
+import { bindActionCreators } from 'redux';
+import { connect } from 'react-redux';
+import DashboardGrid from '../components/DashboardGrid';
+
+import {
+ handleComponentDrop,
+ resizeComponent,
+} from '../actions/dashboardLayout';
+
+function mapStateToProps({ dashboardState }) {
+ return {
+ editMode: dashboardState.editMode,
+ };
+}
+
+function mapDispatchToProps(dispatch) {
+ return bindActionCreators(
+ {
+ handleComponentDrop,
+ resizeComponent,
+ },
+ dispatch,
+ );
+}
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps,
+)(DashboardGrid);
diff --git a/superset/assets/src/dashboard/containers/DashboardHeader.jsx b/superset/assets/src/dashboard/containers/DashboardHeader.jsx
new file mode 100644
index 0000000000..3e54c62469
--- /dev/null
+++ b/superset/assets/src/dashboard/containers/DashboardHeader.jsx
@@ -0,0 +1,89 @@
+import { bindActionCreators } from 'redux';
+import { connect } from 'react-redux';
+
+import DashboardHeader from '../components/Header';
+
+import {
+ setEditMode,
+ toggleBuilderPane,
+ fetchFaveStar,
+ saveFaveStar,
+ fetchCharts,
+ startPeriodicRender,
+ updateCss,
+ onChange,
+ saveDashboardRequest,
+ setMaxUndoHistoryExceeded,
+ maxUndoHistoryToast,
+} from '../actions/dashboardState';
+
+import {
+ undoLayoutAction,
+ redoLayoutAction,
+ updateDashboardTitle,
+} from '../actions/dashboardLayout';
+
+import {
+ addSuccessToast,
+ addDangerToast,
+ addWarningToast,
+} from '../../messageToasts/actions';
+
+import { DASHBOARD_HEADER_ID } from '../util/constants';
+
+function mapStateToProps({
+ dashboardLayout: undoableLayout,
+ dashboardState,
+ dashboardInfo,
+ charts,
+}) {
+ return {
+ dashboardInfo,
+ undoLength: undoableLayout.past.length,
+ redoLength: undoableLayout.future.length,
+ layout: undoableLayout.present,
+ filters: dashboardState.filters,
+ dashboardTitle: (
+ (undoableLayout.present[DASHBOARD_HEADER_ID] || {}).meta || {}
+ ).text,
+ expandedSlices: dashboardState.expandedSlices,
+ css: dashboardState.css,
+ charts,
+ userId: dashboardInfo.userId,
+ isStarred: !!dashboardState.isStarred,
+ hasUnsavedChanges: !!dashboardState.hasUnsavedChanges,
+ maxUndoHistoryExceeded: !!dashboardState.maxUndoHistoryExceeded,
+ editMode: !!dashboardState.editMode,
+ showBuilderPane: !!dashboardState.showBuilderPane,
+ };
+}
+
+function mapDispatchToProps(dispatch) {
+ return bindActionCreators(
+ {
+ addSuccessToast,
+ addDangerToast,
+ addWarningToast,
+ onUndo: undoLayoutAction,
+ onRedo: redoLayoutAction,
+ setEditMode,
+ toggleBuilderPane,
+ fetchFaveStar,
+ saveFaveStar,
+ fetchCharts,
+ startPeriodicRender,
+ updateDashboardTitle,
+ updateCss,
+ onChange,
+ onSave: saveDashboardRequest,
+ setMaxUndoHistoryExceeded,
+ maxUndoHistoryToast,
+ },
+ dispatch,
+ );
+}
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps,
+)(DashboardHeader);
diff --git a/superset/assets/src/dashboard/containers/SliceAdder.jsx b/superset/assets/src/dashboard/containers/SliceAdder.jsx
new file mode 100644
index 0000000000..5e58802fcf
--- /dev/null
+++ b/superset/assets/src/dashboard/containers/SliceAdder.jsx
@@ -0,0 +1,31 @@
+import { bindActionCreators } from 'redux';
+import { connect } from 'react-redux';
+
+import { fetchAllSlices } from '../actions/sliceEntities';
+import SliceAdder from '../components/SliceAdder';
+
+function mapStateToProps({ sliceEntities, dashboardInfo, dashboardState }) {
+ return {
+ userId: dashboardInfo.userId,
+ selectedSliceIds: dashboardState.sliceIds,
+ slices: sliceEntities.slices,
+ isLoading: sliceEntities.isLoading,
+ errorMessage: sliceEntities.errorMessage,
+ lastUpdated: sliceEntities.lastUpdated,
+ editMode: dashboardState.editMode,
+ };
+}
+
+function mapDispatchToProps(dispatch) {
+ return bindActionCreators(
+ {
+ fetchAllSlices,
+ },
+ dispatch,
+ );
+}
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps,
+)(SliceAdder);
diff --git a/superset/assets/src/dashboard/fixtures/emptyDashboardLayout.js b/superset/assets/src/dashboard/fixtures/emptyDashboardLayout.js
new file mode 100644
index 0000000000..e306288766
--- /dev/null
+++ b/superset/assets/src/dashboard/fixtures/emptyDashboardLayout.js
@@ -0,0 +1,34 @@
+import {
+ DASHBOARD_GRID_TYPE,
+ HEADER_TYPE,
+ DASHBOARD_ROOT_TYPE,
+} from '../util/componentTypes';
+
+import {
+ DASHBOARD_ROOT_ID,
+ DASHBOARD_HEADER_ID,
+ DASHBOARD_GRID_ID,
+} from '../util/constants';
+
+export default {
+ [DASHBOARD_ROOT_ID]: {
+ type: DASHBOARD_ROOT_TYPE,
+ id: DASHBOARD_ROOT_ID,
+ children: [DASHBOARD_GRID_ID],
+ },
+
+ [DASHBOARD_GRID_ID]: {
+ type: DASHBOARD_GRID_TYPE,
+ id: DASHBOARD_GRID_ID,
+ children: [],
+ meta: {},
+ },
+
+ [DASHBOARD_HEADER_ID]: {
+ type: HEADER_TYPE,
+ id: DASHBOARD_HEADER_ID,
+ meta: {
+ text: 'New dashboard',
+ },
+ },
+};
diff --git a/superset/assets/src/dashboard/index.jsx b/superset/assets/src/dashboard/index.jsx
index 774e07101f..e0e98301a7 100644
--- a/superset/assets/src/dashboard/index.jsx
+++ b/superset/assets/src/dashboard/index.jsx
@@ -7,18 +7,25 @@ import thunk from 'redux-thunk';
import { initEnhancer } from '../reduxUtils';
import { appSetup } from '../common';
import { initJQueryAjax } from '../modules/utils';
-import DashboardContainer from './components/DashboardContainer';
-import rootReducer, { getInitialState } from './reducers';
+import DashboardContainer from './containers/Dashboard';
+import getInitialState from './reducers/getInitialState';
+import rootReducer from './reducers/index';
appSetup();
initJQueryAjax();
const appContainer = document.getElementById('app');
const bootstrapData = JSON.parse(appContainer.getAttribute('data-bootstrap'));
-const initState = Object.assign({}, getInitialState(bootstrapData));
+const initState = getInitialState(bootstrapData);
const store = createStore(
- rootReducer, initState, compose(applyMiddleware(thunk), initEnhancer(false)));
+ rootReducer,
+ initState,
+ compose(
+ applyMiddleware(thunk),
+ initEnhancer(false),
+ ),
+);
ReactDOM.render(
<Provider store={store}>
@@ -26,4 +33,3 @@ ReactDOM.render(
</Provider>,
appContainer,
);
-
diff --git a/superset/assets/src/dashboard/reducers.js b/superset/assets/src/dashboard/reducers.js
deleted file mode 100644
index 01e6dc23ab..0000000000
--- a/superset/assets/src/dashboard/reducers.js
+++ /dev/null
@@ -1,214 +0,0 @@
-/* eslint-disable camelcase */
-import { combineReducers } from 'redux';
-import d3 from 'd3';
-import shortid from 'shortid';
-
-import charts, { chart } from '../chart/chartReducer';
-import * as actions from './actions';
-import { getParam } from '../modules/utils';
-import { alterInArr, removeFromArr } from '../reduxUtils';
-import { applyDefaultFormData } from '../explore/store';
-import { getColorFromScheme } from '../modules/colors';
-
-export function getInitialState(bootstrapData) {
- const { user_id, datasources, common, editMode } = bootstrapData;
- delete common.locale;
- delete common.language_pack;
-
- const dashboard = { ...bootstrapData.dashboard_data };
- let filters = {};
- try {
- // allow request parameter overwrite dashboard metadata
- filters = JSON.parse(getParam('preselect_filters') || dashboard.metadata.default_filters);
- } catch (e) {
- //
- }
-
- // Priming the color palette with user's label-color mapping provided in
- // the dashboard's JSON metadata
- if (dashboard.metadata && dashboard.metadata.label_colors) {
- const colorMap = dashboard.metadata.label_colors;
- for (const label in colorMap) {
- getColorFromScheme(label, null, colorMap[label]);
- }
- }
-
- dashboard.posDict = {};
- dashboard.layout = [];
- if (Array.isArray(dashboard.position_json)) {
- dashboard.position_json.forEach((position) => {
- dashboard.posDict[position.slice_id] = position;
- });
- } else {
- dashboard.position_json = [];
- }
-
- const lastRowId = Math.max(0, Math.max.apply(null,
- dashboard.position_json.map(pos => (pos.row + pos.size_y))));
- let newSliceCounter = 0;
- dashboard.slices.forEach((slice) => {
- const sliceId = slice.slice_id;
- let pos = dashboard.posDict[sliceId];
- if (!pos) {
- // append new slices to dashboard bottom, 3 slices per row
- pos = {
- col: (newSliceCounter % 3) * 16 + 1,
- row: lastRowId + Math.floor(newSliceCounter / 3) * 16,
- size_x: 16,
- size_y: 16,
- };
- newSliceCounter++;
- }
-
- dashboard.layout.push({
- i: String(sliceId),
- x: pos.col - 1,
- y: pos.row,
- w: pos.size_x,
- minW: 2,
- h: pos.size_y,
- });
- });
-
- // will use charts action/reducers to handle chart render
- const initCharts = {};
- dashboard.slices.forEach((slice) => {
- const chartKey = 'slice_' + slice.slice_id;
- initCharts[chartKey] = { ...chart,
- chartKey,
- slice_id: slice.slice_id,
- form_data: slice.form_data,
- formData: applyDefaultFormData(slice.form_data),
- };
- });
-
- // also need to add formData for dashboard.slices
- dashboard.slices = dashboard.slices.map(slice =>
- ({ ...slice, formData: applyDefaultFormData(slice.form_data) }),
- );
-
- return {
- charts: initCharts,
- dashboard: { filters, dashboard, userId: user_id, datasources, common, editMode },
- };
-}
-
-export const dashboard = function (state = {}, action) {
- const actionHandlers = {
- [actions.UPDATE_DASHBOARD_TITLE]() {
- const newDashboard = { ...state.dashboard, dashboard_title: action.title };
- return { ...state, dashboard: newDashboard };
- },
- [actions.UPDATE_DASHBOARD_LAYOUT]() {
- const newDashboard = { ...state.dashboard, layout: action.layout };
- return { ...state, dashboard: newDashboard };
- },
- [actions.REMOVE_SLICE]() {
- const key = String(action.slice.slice_id);
- const newLayout = state.dashboard.layout.filter(reactPos => (reactPos.i !== key));
- const newDashboard = removeFromArr(state.dashboard, 'slices', action.slice, 'slice_id');
- // if this slice is a filter
- const newFilter = { ...state.filters };
- let refresh = false;
- if (state.filters[key]) {
- delete newFilter[key];
- refresh = true;
- }
- return {
- ...state,
- dashboard: { ...newDashboard, layout: newLayout },
- filters: newFilter,
- refresh,
- };
- },
- [actions.TOGGLE_FAVE_STAR]() {
- return { ...state, isStarred: action.isStarred };
- },
- [actions.SET_EDIT_MODE]() {
- return { ...state, editMode: action.editMode };
- },
- [actions.TOGGLE_EXPAND_SLICE]() {
- const updatedExpandedSlices = { ...state.dashboard.metadata.expanded_slices };
- const sliceId = action.slice.slice_id;
- if (action.isExpanded) {
- updatedExpandedSlices[sliceId] = true;
- } else {
- delete updatedExpandedSlices[sliceId];
- }
- const metadata = { ...state.dashboard.metadata, expanded_slices: updatedExpandedSlices };
- const newDashboard = { ...state.dashboard, metadata };
- return { ...state, dashboard: newDashboard };
- },
-
- // filters
- [actions.ADD_FILTER]() {
- const selectedSlice = state.dashboard.slices
- .find(slice => (slice.slice_id === action.sliceId));
- if (!selectedSlice) {
- return state;
- }
-
- let filters = state.filters;
- const { sliceId, col, vals, merge, refresh } = action;
- const filterKeys = ['__from', '__to', '__time_col',
- '__time_grain', '__time_origin', '__granularity'];
- if (filterKeys.indexOf(col) >= 0 ||
- selectedSlice.formData.groupby.indexOf(col) !== -1) {
- let newFilter = {};
- if (!(sliceId in filters)) {
- // Straight up set the filters if none existed for the slice
- newFilter = { [col]: vals };
- } else if (filters[sliceId] && !(col in filters[sliceId]) || !merge) {
- newFilter = { ...filters[sliceId], [col]: vals };
- // d3.merge pass in array of arrays while some value form filter components
- // from and to filter box require string to be process and return
- } else if (filters[sliceId][col] instanceof Array) {
- newFilter[col] = d3.merge([filters[sliceId][col], vals]);
- } else {
- newFilter[col] = d3.merge([[filters[sliceId][col]], vals])[0] || '';
- }
- filters = { ...filters, [sliceId]: newFilter };
- }
- return { ...state, filters, refresh };
- },
- [actions.CLEAR_FILTER]() {
- const newFilters = { ...state.filters };
- delete newFilters[action.sliceId];
- return { ...state, filter: newFilters, refresh: true };
- },
- [actions.REMOVE_FILTER]() {
- const { sliceId, col, vals, refresh } = action;
- const excluded = new Set(vals);
- const valFilter = val => !excluded.has(val);
-
- let filters = state.filters;
- // Have to be careful not to modify the dashboard state so that
- // the render actually triggers
- if (sliceId in state.filters && col in state.filters[sliceId]) {
- const newFilter = filters[sliceId][col].filter(valFilter);
- filters = { ...filters, [sliceId]: newFilter };
- }
- return { ...state, filters, refresh };
- },
-
- // slice reducer
- [actions.UPDATE_SLICE_NAME]() {
- const newDashboard = alterInArr(
- state.dashboard, 'slices',
- action.slice, { slice_name: action.sliceName },
- 'slice_id');
- return { ...state, dashboard: newDashboard };
- },
- };
-
- if (action.type in actionHandlers) {
- return actionHandlers[action.type]();
- }
- return state;
-};
-
-export default combineReducers({
- charts,
- dashboard,
- impressionId: () => (shortid.generate()),
-});
diff --git a/superset/assets/src/dashboard/reducers/dashboardLayout.js b/superset/assets/src/dashboard/reducers/dashboardLayout.js
new file mode 100644
index 0000000000..51cd02a162
--- /dev/null
+++ b/superset/assets/src/dashboard/reducers/dashboardLayout.js
@@ -0,0 +1,249 @@
+import {
+ DASHBOARD_ROOT_ID,
+ DASHBOARD_GRID_ID,
+ NEW_COMPONENTS_SOURCE_ID,
+} from '../util/constants';
+import componentIsResizable from '../util/componentIsResizable';
+import findParentId from '../util/findParentId';
+import getComponentWidthFromDrop from '../util/getComponentWidthFromDrop';
+import newComponentFactory from '../util/newComponentFactory';
+import newEntitiesFromDrop from '../util/newEntitiesFromDrop';
+import reorderItem from '../util/dnd-reorder';
+import shouldWrapChildInRow from '../util/shouldWrapChildInRow';
+import { ROW_TYPE, TAB_TYPE, TABS_TYPE } from '../util/componentTypes';
+
+import {
+ UPDATE_COMPONENTS,
+ DELETE_COMPONENT,
+ CREATE_COMPONENT,
+ MOVE_COMPONENT,
+ CREATE_TOP_LEVEL_TABS,
+ DELETE_TOP_LEVEL_TABS,
+} from '../actions/dashboardLayout';
+
+const actionHandlers = {
+ [UPDATE_COMPONENTS](state, action) {
+ const {
+ payload: { nextComponents },
+ } = action;
+ return {
+ ...state,
+ ...nextComponents,
+ };
+ },
+
+ [DELETE_COMPONENT](state, action) {
+ const {
+ payload: { id, parentId },
+ } = action;
+
+ if (!parentId || !id || !state[id] || !state[parentId]) return state;
+
+ const nextComponents = { ...state };
+
+ function recursivelyDeleteChildren(componentId, componentParentId) {
+ // delete child and it's children
+ const component = nextComponents[componentId];
+ delete nextComponents[componentId];
+
+ const { children = [] } = component;
+ children.forEach(childId => {
+ recursivelyDeleteChildren(childId, componentId);
+ });
+
+ const parent = nextComponents[componentParentId];
+ if (parent) {
+ // may have been deleted in another recursion
+ const componentIndex = (parent.children || []).indexOf(componentId);
+ if (componentIndex > -1) {
+ const nextChildren = [...parent.children];
+ nextChildren.splice(componentIndex, 1);
+ nextComponents[componentParentId] = {
+ ...parent,
+ children: nextChildren,
+ };
+ }
+ }
+ }
+
+ recursivelyDeleteChildren(id, parentId);
+ const nextParent = nextComponents[parentId];
+ if (nextParent.type === ROW_TYPE && nextParent.children.length === 0) {
+ const grandparentId = findParentId({
+ childId: parentId,
+ layout: nextComponents,
+ });
+ recursivelyDeleteChildren(parentId, grandparentId);
+ }
+
+ return nextComponents;
+ },
+
+ [CREATE_COMPONENT](state, action) {
+ const {
+ payload: { dropResult },
+ } = action;
+
+ const newEntities = newEntitiesFromDrop({ dropResult, layout: state });
+
+ return {
+ ...state,
+ ...newEntities,
+ };
+ },
+
+ [MOVE_COMPONENT](state, action) {
+ const {
+ payload: { dropResult },
+ } = action;
+ const { source, destination, dragging } = dropResult;
+
+ if (!source || !destination || !dragging) return state;
+
+ const nextEntities = reorderItem({
+ entitiesMap: state,
+ source,
+ destination,
+ });
+
+ if (componentIsResizable(nextEntities[dragging.id])) {
+ // update component width if it changed
+ const nextWidth =
+ getComponentWidthFromDrop({
+ dropResult,
+ layout: state,
+ }) || undefined; // don't set a 0 width
+ if ((nextEntities[dragging.id].meta || {}).width !== nextWidth) {
+ nextEntities[dragging.id] = {
+ ...nextEntities[dragging.id],
+ meta: {
+ ...nextEntities[dragging.id].meta,
+ width: nextWidth,
+ },
+ };
+ }
+ }
+
+ // wrap the dragged component in a row depending on destination type
+ const wrapInRow = shouldWrapChildInRow({
+ parentType: destination.type,
+ childType: dragging.type,
+ });
+
+ if (wrapInRow) {
+ const destinationEntity = nextEntities[destination.id];
+ const destinationChildren = destinationEntity.children;
+ const newRow = newComponentFactory(ROW_TYPE);
+ newRow.children = [destinationChildren[destination.index]];
+ destinationChildren[destination.index] = newRow.id;
+ nextEntities[newRow.id] = newRow;
+ }
+
+ return {
+ ...state,
+ ...nextEntities,
+ };
+ },
+
+ [CREATE_TOP_LEVEL_TABS](state, action) {
+ const {
+ payload: { dropResult },
+ } = action;
+ const { source, dragging } = dropResult;
+
+ // move children of current root to be children of the dragging tab
+ const rootComponent = state[DASHBOARD_ROOT_ID];
+ const topLevelId = rootComponent.children[0];
+ const topLevelComponent = state[topLevelId];
+
+ if (source.id !== NEW_COMPONENTS_SOURCE_ID) {
+ // component already exists
+ const draggingTabs = state[dragging.id];
+ const draggingTabId = draggingTabs.children[0];
+ const draggingTab = state[draggingTabId];
+
+ // move all children except the one that is dragging
+ const childrenToMove = [...topLevelComponent.children].filter(
+ id => id !== dragging.id,
+ );
+
+ return {
+ ...state,
+ [DASHBOARD_ROOT_ID]: {
+ ...rootComponent,
+ children: [dragging.id],
+ },
+ [topLevelId]: {
+ ...topLevelComponent,
+ children: [],
+ },
+ [draggingTabId]: {
+ ...draggingTab,
+ children: [...draggingTab.children, ...childrenToMove],
+ },
+ };
+ }
+
+ // create new component
+ const newEntities = newEntitiesFromDrop({ dropResult, layout: state });
+ const newEntitiesArray = Object.values(newEntities);
+ const tabComponent = newEntitiesArray.find(
+ component => component.type === TAB_TYPE,
+ );
+ const tabsComponent = newEntitiesArray.find(
+ component => component.type === TABS_TYPE,
+ );
+
+ tabComponent.children = [...topLevelComponent.children];
+ newEntities[topLevelId] = { ...topLevelComponent, children: [] };
+ newEntities[DASHBOARD_ROOT_ID] = {
+ ...rootComponent,
+ children: [tabsComponent.id],
+ };
+
+ return {
+ ...state,
+ ...newEntities,
+ };
+ },
+
+ [DELETE_TOP_LEVEL_TABS](state) {
+ const rootComponent = state[DASHBOARD_ROOT_ID];
+ const topLevelId = rootComponent.children[0];
+ const topLevelTabs = state[topLevelId];
+
+ if (topLevelTabs.type !== TABS_TYPE) return state;
+
+ let childrenToMove = [];
+ const nextEntities = { ...state };
+
+ topLevelTabs.children.forEach(tabId => {
+ const tabComponent = state[tabId];
+ childrenToMove = [...childrenToMove, ...tabComponent.children];
+ delete nextEntities[tabId];
+ });
+
+ delete nextEntities[topLevelId];
+
+ nextEntities[DASHBOARD_ROOT_ID] = {
+ ...rootComponent,
+ children: [DASHBOARD_GRID_ID],
+ };
+
+ nextEntities[DASHBOARD_GRID_ID] = {
+ ...state[DASHBOARD_GRID_ID],
+ children: childrenToMove,
+ };
+
+ return nextEntities;
+ },
+};
+
+export default function layoutReducer(state = {}, action) {
+ if (action.type in actionHandlers) {
+ const handler = actionHandlers[action.type];
+ return handler(state, action);
+ }
+
+ return state;
+}
diff --git a/superset/assets/src/dashboard/reducers/dashboardState.js b/superset/assets/src/dashboard/reducers/dashboardState.js
new file mode 100644
index 0000000000..df4c9142f7
--- /dev/null
+++ b/superset/assets/src/dashboard/reducers/dashboardState.js
@@ -0,0 +1,151 @@
+/* eslint-disable camelcase */
+import {
+ ADD_SLICE,
+ CHANGE_FILTER,
+ ON_CHANGE,
+ ON_SAVE,
+ REMOVE_SLICE,
+ SET_EDIT_MODE,
+ SET_MAX_UNDO_HISTORY_EXCEEDED,
+ SET_UNSAVED_CHANGES,
+ TOGGLE_BUILDER_PANE,
+ TOGGLE_EXPAND_SLICE,
+ TOGGLE_FAVE_STAR,
+ UPDATE_CSS,
+} from '../actions/dashboardState';
+
+export default function dashboardStateReducer(state = {}, action) {
+ const actionHandlers = {
+ [UPDATE_CSS]() {
+ return { ...state, css: action.css };
+ },
+ [ADD_SLICE]() {
+ const updatedSliceIds = new Set(state.sliceIds);
+ updatedSliceIds.add(action.slice.slice_id);
+ return {
+ ...state,
+ sliceIds: Array.from(updatedSliceIds),
+ };
+ },
+ [REMOVE_SLICE]() {
+ const sliceId = action.sliceId;
+ const updatedSliceIds = new Set(state.sliceIds);
+ updatedSliceIds.delete(sliceId);
+
+ const key = sliceId;
+ // if this slice is a filter
+ const newFilter = { ...state.filters };
+ let refresh = false;
+ if (state.filters[key]) {
+ delete newFilter[key];
+ refresh = true;
+ }
+ return {
+ ...state,
+ sliceIds: Array.from(updatedSliceIds),
+ filters: newFilter,
+ refresh,
+ };
+ },
+ [TOGGLE_FAVE_STAR]() {
+ return { ...state, isStarred: action.isStarred };
+ },
+ [SET_EDIT_MODE]() {
+ return {
+ ...state,
+ editMode: action.editMode,
+ showBuilderPane: !!action.editMode,
+ };
+ },
+ [SET_MAX_UNDO_HISTORY_EXCEEDED]() {
+ const { maxUndoHistoryExceeded = true } = action.payload;
+ return { ...state, maxUndoHistoryExceeded };
+ },
+ [TOGGLE_BUILDER_PANE]() {
+ return { ...state, showBuilderPane: !state.showBuilderPane };
+ },
+ [TOGGLE_EXPAND_SLICE]() {
+ const updatedExpandedSlices = { ...state.expandedSlices };
+ const sliceId = action.sliceId;
+ if (updatedExpandedSlices[sliceId]) {
+ delete updatedExpandedSlices[sliceId];
+ } else {
+ updatedExpandedSlices[sliceId] = true;
+ }
+ return { ...state, expandedSlices: updatedExpandedSlices };
+ },
+ [ON_CHANGE]() {
+ return { ...state, hasUnsavedChanges: true };
+ },
+ [ON_SAVE]() {
+ return {
+ ...state,
+ hasUnsavedChanges: false,
+ maxUndoHistoryExceeded: false,
+ editMode: false,
+ };
+ },
+
+ [CHANGE_FILTER]() {
+ const hasSelectedFilter = state.sliceIds.includes(action.chart.id);
+ if (!hasSelectedFilter) {
+ return state;
+ }
+
+ let filters = state.filters;
+ const { chart, col, vals: nextVals, merge, refresh } = action;
+ const sliceId = chart.id;
+ const filterKeys = [
+ '__from',
+ '__to',
+ '__time_col',
+ '__time_grain',
+ '__time_origin',
+ '__granularity',
+ ];
+ if (
+ filterKeys.indexOf(col) >= 0 ||
+ action.chart.formData.groupby.indexOf(col) !== -1
+ ) {
+ let newFilter = {};
+ if (!(sliceId in filters)) {
+ // if no filters existed for the slice, set them
+ newFilter = { [col]: nextVals };
+ } else if ((filters[sliceId] && !(col in filters[sliceId])) || !merge) {
+ // If no filters exist for this column, or we are overwriting them
+ newFilter = { ...filters[sliceId], [col]: nextVals };
+ } else if (filters[sliceId][col] instanceof Array) {
+ newFilter[col] = [...filters[sliceId][col], ...nextVals];
+ } else {
+ newFilter[col] = [filters[sliceId][col], ...nextVals];
+ }
+ filters = { ...filters, [sliceId]: newFilter };
+
+ // remove any empty filters so they don't pollute the logs
+ Object.keys(filters).forEach(chartId => {
+ Object.keys(filters[chartId]).forEach(column => {
+ if (
+ !filters[chartId][column] ||
+ filters[chartId][column].length === 0
+ ) {
+ delete filters[chartId][column];
+ }
+ });
+ if (Object.keys(filters[chartId]).length === 0) {
+ delete filters[chartId];
+ }
+ });
+ }
+ return { ...state, filters, refresh };
+ },
+ [SET_UNSAVED_CHANGES]() {
+ const { hasUnsavedChanges } = action.payload;
+ return { ...state, hasUnsavedChanges };
+ },
+ };
+
+ if (action.type in actionHandlers) {
+ return actionHandlers[action.type]();
+ }
+ return state;
+}
diff --git a/superset/assets/src/dashboard/reducers/datasources.js b/superset/assets/src/dashboard/reducers/datasources.js
new file mode 100644
index 0000000000..87f6d093e2
--- /dev/null
(This diff was longer than 20,000 lines, and has been truncated...)
----------------------------------------------------------------
This is an automated message from the Apache Git Service.
To respond to the message, please log on GitHub and use the
URL above to go to the specific comment.
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
With regards,
Apache Git Services
---------------------------------------------------------------------
To unsubscribe, e-mail: notifications-unsubscribe@superset.apache.org
For additional commands, e-mail: notifications-help@superset.apache.org