diff --git a/RESOURCES/FEATURE_FLAGS.md b/RESOURCES/FEATURE_FLAGS.md index 40db66f80b2cd..aa4d6c635565d 100644 --- a/RESOURCES/FEATURE_FLAGS.md +++ b/RESOURCES/FEATURE_FLAGS.md @@ -51,6 +51,7 @@ These features are **finished** but currently being tested. They are usable, but - ALERT_REPORTS: [(docs)](https://superset.apache.org/docs/installation/alerts-reports) - ALLOW_FULL_CSV_EXPORT - CACHE_IMPERSONATION +- CONFIRM_DASHBOARD_DIFF - DASHBOARD_EDIT_CHART_IN_NEW_TAB - DASHBOARD_FILTERS_EXPERIMENTAL - DASHBOARD_NATIVE_FILTERS diff --git a/UPDATING.md b/UPDATING.md index ec3a15a157aae..4ca468ecbce3c 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -31,6 +31,7 @@ assists people when migrating to a new version. - [21002](https://github.com/apache/superset/pull/21002): Support Python 3.10 and bump pandas 1.4 and pyarrow 6. - [21163](https://github.com/apache/superset/pull/21163): When `GENERIC_CHART_AXES` feature flags set to `True`, the Time Grain control will move below the X-Axis control. - [21284](https://github.com/apache/superset/pull/21284): The non-functional `MAX_TABLE_NAMES` config key has been removed. +- [21794](https://github.com/apache/superset/pull/21794): Deprecates the undocumented `PRESTO_SPLIT_VIEWS_FROM_TABLES` feature flag. Now for Presto, like other engines, only physical tables are treated as tables. ### Breaking Changes diff --git a/helm/superset/Chart.yaml b/helm/superset/Chart.yaml index cfe3d3fdd92ae..7394d150cafbd 100644 --- a/helm/superset/Chart.yaml +++ b/helm/superset/Chart.yaml @@ -29,7 +29,7 @@ maintainers: - name: craig-rueda email: craig@craigrueda.com url: https://github.com/craig-rueda -version: 0.7.6 +version: 0.7.7 dependencies: - name: postgresql version: 11.1.22 diff --git a/helm/superset/README.md b/helm/superset/README.md index 5b704ab78af5e..3564c205f5e7c 100644 --- a/helm/superset/README.md +++ b/helm/superset/README.md @@ -19,7 +19,7 @@ # superset -![Version: 0.7.6](https://img.shields.io/badge/Version-0.7.6-informational?style=flat-square) +![Version: 0.7.7](https://img.shields.io/badge/Version-0.7.7-informational?style=flat-square) Apache Superset is a modern, enterprise-ready business intelligence web application diff --git a/helm/superset/templates/configmap-superset.yaml b/helm/superset/templates/configmap-superset.yaml index a7d7b09339a0a..eb8564619b187 100644 --- a/helm/superset/templates/configmap-superset.yaml +++ b/helm/superset/templates/configmap-superset.yaml @@ -24,6 +24,7 @@ metadata: chart: {{ template "superset.chart" . }} release: {{ .Release.Name }} heritage: {{ .Release.Service }} + namespace: {{ .Release.Namespace }} data: {{- range $path, $config := .Values.extraConfigs }} {{ $path }}: | diff --git a/helm/superset/templates/deployment-beat.yaml b/helm/superset/templates/deployment-beat.yaml index 2988c7755367a..01e66a83b645a 100644 --- a/helm/superset/templates/deployment-beat.yaml +++ b/helm/superset/templates/deployment-beat.yaml @@ -28,6 +28,7 @@ metadata: annotations: {{- toYaml .Values.supersetCeleryBeat.deploymentAnnotations | nindent 4 }} {{- end }} + namespace: {{ .Release.Namespace }} spec: # This must be a singleton replicas: 1 diff --git a/helm/superset/templates/deployment-flower.yaml b/helm/superset/templates/deployment-flower.yaml index 35b31cf55e238..197aa5822fdcf 100644 --- a/helm/superset/templates/deployment-flower.yaml +++ b/helm/superset/templates/deployment-flower.yaml @@ -28,6 +28,7 @@ metadata: annotations: {{- toYaml .Values.supersetCeleryFlower.deploymentAnnotations | nindent 4 }} {{- end }} + namespace: {{ .Release.Namespace }} spec: replicas: {{ .Values.supersetCeleryFlower.replicaCount }} selector: diff --git a/helm/superset/templates/deployment-worker.yaml b/helm/superset/templates/deployment-worker.yaml index c2e924438e7c0..06b52a7c7c663 100644 --- a/helm/superset/templates/deployment-worker.yaml +++ b/helm/superset/templates/deployment-worker.yaml @@ -27,6 +27,7 @@ metadata: annotations: {{- toYaml .Values.supersetWorker.deploymentAnnotations | nindent 4 }} {{- end }} + namespace: {{ .Release.Namespace }} spec: replicas: {{ .Values.supersetWorker.replicaCount }} selector: diff --git a/helm/superset/templates/deployment-ws.yaml b/helm/superset/templates/deployment-ws.yaml index 735edc8330ffb..1713ee74c5337 100644 --- a/helm/superset/templates/deployment-ws.yaml +++ b/helm/superset/templates/deployment-ws.yaml @@ -28,6 +28,7 @@ metadata: annotations: {{- toYaml .Values.supersetWebsockets.deploymentAnnotations | nindent 4 }} {{- end }} + namespace: {{ .Release.Namespace }} spec: replicas: {{ .Values.supersetWebsockets.replicaCount }} selector: diff --git a/helm/superset/templates/deployment.yaml b/helm/superset/templates/deployment.yaml index a02c9cf293dcf..d668cb7a0b358 100644 --- a/helm/superset/templates/deployment.yaml +++ b/helm/superset/templates/deployment.yaml @@ -27,6 +27,7 @@ metadata: annotations: {{- toYaml .Values.supersetNode.deploymentAnnotations | nindent 4 }} {{- end }} + namespace: {{ .Release.Namespace }} spec: replicas: {{ .Values.supersetNode.replicaCount }} {{- if .Values.supersetNode.strategy }} diff --git a/helm/superset/templates/ingress.yaml b/helm/superset/templates/ingress.yaml index c0df1e90e6f7f..d166149c00ba1 100644 --- a/helm/superset/templates/ingress.yaml +++ b/helm/superset/templates/ingress.yaml @@ -29,6 +29,7 @@ metadata: annotations: {{- toYaml . | nindent 4 }} {{- end }} + namespace: {{ .Release.Namespace }} spec: {{- if .Values.ingress.ingressClassName }} ingressClassName: {{ .Values.ingress.ingressClassName }} diff --git a/helm/superset/templates/init-job.yaml b/helm/superset/templates/init-job.yaml index 878e93095853b..96b063ff4f043 100644 --- a/helm/superset/templates/init-job.yaml +++ b/helm/superset/templates/init-job.yaml @@ -22,6 +22,7 @@ metadata: annotations: "helm.sh/hook": post-install,post-upgrade "helm.sh/hook-delete-policy": "before-hook-creation" + namespace: {{ .Release.Namespace }} spec: template: metadata: diff --git a/helm/superset/templates/secret-env.yaml b/helm/superset/templates/secret-env.yaml index 4126507324439..0164d96a8c129 100644 --- a/helm/superset/templates/secret-env.yaml +++ b/helm/superset/templates/secret-env.yaml @@ -23,6 +23,7 @@ metadata: chart: {{ template "superset.chart" . }} release: "{{ .Release.Name }}" heritage: "{{ .Release.Service }}" + namespace: {{ .Release.Namespace }} type: Opaque stringData: REDIS_HOST: {{ tpl .Values.supersetNode.connections.redis_host . | quote }} diff --git a/helm/superset/templates/secret-superset-config.yaml b/helm/superset/templates/secret-superset-config.yaml index ddf0befcd2f2b..c1f4102858d93 100644 --- a/helm/superset/templates/secret-superset-config.yaml +++ b/helm/superset/templates/secret-superset-config.yaml @@ -23,6 +23,7 @@ metadata: chart: {{ template "superset.chart" . }} release: "{{ .Release.Name }}" heritage: "{{ .Release.Service }}" + namespace: {{ .Release.Namespace }} type: Opaque stringData: superset_config.py: | diff --git a/helm/superset/templates/secret-ws.yaml b/helm/superset/templates/secret-ws.yaml index 0e48e0377e591..c3ac55d96cb07 100644 --- a/helm/superset/templates/secret-ws.yaml +++ b/helm/superset/templates/secret-ws.yaml @@ -24,6 +24,7 @@ metadata: chart: {{ template "superset.chart" . }} release: "{{ .Release.Name }}" heritage: "{{ .Release.Service }}" + namespace: {{ .Release.Namespace }} type: Opaque stringData: config.json: | diff --git a/helm/superset/templates/service-account.yaml b/helm/superset/templates/service-account.yaml index 8d1171fad4445..994ad8333afd8 100755 --- a/helm/superset/templates/service-account.yaml +++ b/helm/superset/templates/service-account.yaml @@ -31,4 +31,5 @@ metadata: {{- if .Values.serviceAccount.annotations }} annotations: {{- toYaml .Values.serviceAccount.annotations | nindent 4 }} {{- end }} + namespace: {{ .Release.Namespace }} {{- end -}} diff --git a/helm/superset/templates/service.yaml b/helm/superset/templates/service.yaml index 431d03704e551..6ac950d1da6b2 100644 --- a/helm/superset/templates/service.yaml +++ b/helm/superset/templates/service.yaml @@ -27,6 +27,7 @@ metadata: annotations: {{- toYaml . | nindent 4 }} {{- end }} + namespace: {{ .Release.Namespace }} spec: type: {{ .Values.service.type }} ports: @@ -55,6 +56,7 @@ metadata: annotations: {{- toYaml . | nindent 4 }} {{- end }} + namespace: {{ .Release.Namespace }} spec: type: {{ .Values.supersetCeleryFlower.service.type }} ports: @@ -84,6 +86,7 @@ metadata: annotations: {{- toYaml . | nindent 4 }} {{- end }} + namespace: {{ .Release.Namespace }} spec: type: {{ .Values.supersetWebsockets.service.type }} ports: diff --git a/requirements/base.txt b/requirements/base.txt index 8387d380bc5ab..3646395b4f59d 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -108,8 +108,6 @@ geopy==2.2.0 # via apache-superset graphlib-backport==1.0.3 # via apache-superset -greenlet==1.1.2 - # via sqlalchemy gunicorn==20.1.0 # via apache-superset hashids==1.3.1 diff --git a/requirements/development.txt b/requirements/development.txt index 9990012ab32ee..1bce530eab733 100644 --- a/requirements/development.txt +++ b/requirements/development.txt @@ -78,7 +78,7 @@ pure-eval==0.2.2 # via stack-data pure-sasl==0.6.2 # via thrift-sasl -pydruid==0.6.2 +pydruid==0.6.5 # via apache-superset pygments==2.12.0 # via ipython diff --git a/requirements/docker.txt b/requirements/docker.txt index 0c2d36159e4d0..307064dbdeddf 100644 --- a/requirements/docker.txt +++ b/requirements/docker.txt @@ -12,6 +12,8 @@ # -r requirements/docker.in gevent==21.8.0 # via -r requirements/docker.in +greenlet==1.1.3.post0 + # via gevent psycopg2-binary==2.9.1 # via apache-superset zope-event==4.5.0 diff --git a/requirements/testing.txt b/requirements/testing.txt index 191fa99c07f5a..8068f3718c818 100644 --- a/requirements/testing.txt +++ b/requirements/testing.txt @@ -130,7 +130,7 @@ rsa==4.7.2 # via google-auth statsd==3.3.0 # via -r requirements/testing.in -trino==0.315.0 +trino==0.319.0 # via apache-superset typing-inspect==0.7.1 # via libcst diff --git a/setup.py b/setup.py index cad76a9572997..4ca6b910865eb 100644 --- a/setup.py +++ b/setup.py @@ -141,7 +141,7 @@ def get_git_sha() -> str: "db2": ["ibm-db-sa>=0.3.5, <0.4"], "dremio": ["sqlalchemy-dremio>=1.1.5, <1.3"], "drill": ["sqlalchemy-drill==0.1.dev"], - "druid": ["pydruid>=0.6.1,<0.7"], + "druid": ["pydruid>=0.6.5,<0.7"], "solr": ["sqlalchemy-solr >= 0.2.0"], "elasticsearch": ["elasticsearch-dbapi>=0.2.9, <0.3.0"], "exasol": ["sqlalchemy-exasol >= 2.4.0, <3.0"], @@ -160,7 +160,7 @@ def get_git_sha() -> str: "pinot": ["pinotdb>=0.3.3, <0.4"], "postgres": ["psycopg2-binary==2.9.1"], "presto": ["pyhive[presto]>=0.6.5"], - "trino": ["trino>=0.313.0"], + "trino": ["trino>=0.319.0"], "prophet": ["prophet>=1.0.1, <1.1", "pystan<3.0"], "redshift": ["sqlalchemy-redshift>=0.8.1, < 0.9"], "rockset": ["rockset>=0.8.10, <0.9"], diff --git a/superset-frontend/.storybook/main.js b/superset-frontend/.storybook/main.js index 8a004ba3e2928..b8f15b569f3d9 100644 --- a/superset-frontend/.storybook/main.js +++ b/superset-frontend/.storybook/main.js @@ -24,7 +24,8 @@ module.exports = { builder: 'webpack5', }, stories: [ - '../src/@(components|common|filters|explore)/**/*.stories.@(tsx|jsx|mdx)', + '../src/@(components|common|filters|explore)/**/*.stories.@(tsx|jsx)', + '../src/@(components|common|filters|explore)/**/*.*.@(mdx)', ], addons: [ '@storybook/addon-essentials', diff --git a/superset-frontend/.storybook/preview.jsx b/superset-frontend/.storybook/preview.jsx index d98a55506eaca..fa0c9088735a2 100644 --- a/superset-frontend/.storybook/preview.jsx +++ b/superset-frontend/.storybook/preview.jsx @@ -68,7 +68,15 @@ addParameters({ ['Controls', 'Display', 'Feedback', 'Input', '*'], ['Overview', 'Examples', '*'], 'Design System', - ['Foundations', 'Components', 'Patterns', '*'], + [ + 'Introduction', + 'Foundations', + 'Components', + ['Overview', 'Examples', '*'], + 'Patterns', + '*', + ], + ['Overview', 'Examples', '*'], '*', ], }, diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 2d5debb43583e..cf956ff74d4b2 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -100,12 +100,14 @@ "react-checkbox-tree": "^1.5.1", "react-color": "^2.13.8", "react-datetime": "^3.0.4", + "react-diff-viewer": "^3.1.1", "react-dnd": "^11.1.3", "react-dnd-html5-backend": "^11.1.3", "react-dom": "^16.13.0", "react-draggable": "^4.4.3", "react-gravatar": "^2.6.1", "react-hot-loader": "^4.12.20", + "react-intersection-observer": "^8.26.2", "react-js-cron": "^1.2.0", "react-json-tree": "^0.11.2", "react-jsonschema-form": "^1.2.0", @@ -24790,6 +24792,28 @@ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz", "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==" }, + "node_modules/create-emotion": { + "version": "10.0.27", + "resolved": "https://registry.npmjs.org/create-emotion/-/create-emotion-10.0.27.tgz", + "integrity": "sha512-fIK73w82HPPn/RsAij7+Zt8eCE8SptcJ3WoRMfxMtjteYxud8GDTKKld7MYwAX2TVhrw29uR1N/bVGxeStHILg==", + "dependencies": { + "@emotion/cache": "^10.0.27", + "@emotion/serialize": "^0.11.15", + "@emotion/sheet": "0.9.4", + "@emotion/utils": "0.11.3" + } + }, + "node_modules/create-emotion/node_modules/@emotion/cache": { + "version": "10.0.29", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-10.0.29.tgz", + "integrity": "sha512-fU2VtSVlHiF27empSbxi1O2JFdNWZO+2NFHfwO0pxgTep6Xa3uGb+3pVKfLww2l/IBGLNEZl5Xf/++A4wAYDYQ==", + "dependencies": { + "@emotion/sheet": "0.9.4", + "@emotion/stylis": "0.8.5", + "@emotion/utils": "0.11.3", + "@emotion/weak-memoize": "0.2.5" + } + }, "node_modules/create-hash": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", @@ -27124,7 +27148,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "dev": true, "engines": { "node": ">=0.3.1" } @@ -27585,6 +27608,15 @@ "node": ">= 4" } }, + "node_modules/emotion": { + "version": "10.0.27", + "resolved": "https://registry.npmjs.org/emotion/-/emotion-10.0.27.tgz", + "integrity": "sha512-2xdDzdWWzue8R8lu4G76uWX5WhyQuzATon9LmNeCy/2BHVC6dsEpfhN1a0qhELgtDVdjyEA6J8Y/VlI5ZnaH0g==", + "dependencies": { + "babel-plugin-emotion": "^10.0.27", + "create-emotion": "^10.0.27" + } + }, "node_modules/emotion-rgba": { "version": "0.0.9", "resolved": "https://registry.npmjs.org/emotion-rgba/-/emotion-rgba-0.0.9.tgz", @@ -45270,6 +45302,26 @@ "react": "^16.5.0" } }, + "node_modules/react-diff-viewer": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/react-diff-viewer/-/react-diff-viewer-3.1.1.tgz", + "integrity": "sha512-rmvwNdcClp6ZWdS11m1m01UnBA4OwYaLG/li0dB781e/bQEzsGyj+qewVd6W5ztBwseQ72pO7nwaCcq5jnlzcw==", + "dependencies": { + "classnames": "^2.2.6", + "create-emotion": "^10.0.14", + "diff": "^4.0.1", + "emotion": "^10.0.14", + "memoize-one": "^5.0.4", + "prop-types": "^15.6.2" + }, + "engines": { + "node": ">= 8" + }, + "peerDependencies": { + "react": "^15.3.0 || ^16.0.0", + "react-dom": "^15.3.0 || ^16.0.0" + } + }, "node_modules/react-dnd": { "version": "11.1.3", "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-11.1.3.tgz", @@ -45505,6 +45557,17 @@ "react": "^16.8.4 || ^17.0.0" } }, + "node_modules/react-intersection-observer": { + "version": "8.26.2", + "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-8.26.2.tgz", + "integrity": "sha512-GmSjLNK+oV7kS+BHfrJSaA4wF61ELA33gizKHmN+tk59UT6/aW8kkqvlrFGPwxGoaIzLKS2evfG5fgkw5MIIsg==", + "dependencies": { + "tiny-invariant": "^1.1.0" + }, + "peerDependencies": { + "react": "^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, "node_modules/react-is": { "version": "16.6.3", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.6.3.tgz", @@ -77238,6 +77301,30 @@ } } }, + "create-emotion": { + "version": "10.0.27", + "resolved": "https://registry.npmjs.org/create-emotion/-/create-emotion-10.0.27.tgz", + "integrity": "sha512-fIK73w82HPPn/RsAij7+Zt8eCE8SptcJ3WoRMfxMtjteYxud8GDTKKld7MYwAX2TVhrw29uR1N/bVGxeStHILg==", + "requires": { + "@emotion/cache": "^10.0.27", + "@emotion/serialize": "^0.11.15", + "@emotion/sheet": "0.9.4", + "@emotion/utils": "0.11.3" + }, + "dependencies": { + "@emotion/cache": { + "version": "10.0.29", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-10.0.29.tgz", + "integrity": "sha512-fU2VtSVlHiF27empSbxi1O2JFdNWZO+2NFHfwO0pxgTep6Xa3uGb+3pVKfLww2l/IBGLNEZl5Xf/++A4wAYDYQ==", + "requires": { + "@emotion/sheet": "0.9.4", + "@emotion/stylis": "0.8.5", + "@emotion/utils": "0.11.3", + "@emotion/weak-memoize": "0.2.5" + } + } + } + }, "create-hash": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", @@ -79012,8 +79099,7 @@ "diff": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "dev": true + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==" }, "diff-match-patch": { "version": "1.0.5", @@ -79431,6 +79517,15 @@ "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==" }, + "emotion": { + "version": "10.0.27", + "resolved": "https://registry.npmjs.org/emotion/-/emotion-10.0.27.tgz", + "integrity": "sha512-2xdDzdWWzue8R8lu4G76uWX5WhyQuzATon9LmNeCy/2BHVC6dsEpfhN1a0qhELgtDVdjyEA6J8Y/VlI5ZnaH0g==", + "requires": { + "babel-plugin-emotion": "^10.0.27", + "create-emotion": "^10.0.27" + } + }, "emotion-rgba": { "version": "0.0.9", "resolved": "https://registry.npmjs.org/emotion-rgba/-/emotion-rgba-0.0.9.tgz", @@ -92970,6 +93065,19 @@ "prop-types": "^15.5.7" } }, + "react-diff-viewer": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/react-diff-viewer/-/react-diff-viewer-3.1.1.tgz", + "integrity": "sha512-rmvwNdcClp6ZWdS11m1m01UnBA4OwYaLG/li0dB781e/bQEzsGyj+qewVd6W5ztBwseQ72pO7nwaCcq5jnlzcw==", + "requires": { + "classnames": "^2.2.6", + "create-emotion": "^10.0.14", + "diff": "^4.0.1", + "emotion": "^10.0.14", + "memoize-one": "^5.0.4", + "prop-types": "^15.6.2" + } + }, "react-dnd": { "version": "11.1.3", "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-11.1.3.tgz", @@ -93154,6 +93262,14 @@ "prop-types": "^15.0.0" } }, + "react-intersection-observer": { + "version": "8.26.2", + "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-8.26.2.tgz", + "integrity": "sha512-GmSjLNK+oV7kS+BHfrJSaA4wF61ELA33gizKHmN+tk59UT6/aW8kkqvlrFGPwxGoaIzLKS2evfG5fgkw5MIIsg==", + "requires": { + "tiny-invariant": "^1.1.0" + } + }, "react-is": { "version": "16.6.3", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.6.3.tgz", diff --git a/superset-frontend/package.json b/superset-frontend/package.json index 67f7af9b7c542..d121f296dc5cd 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -164,12 +164,14 @@ "react-checkbox-tree": "^1.5.1", "react-color": "^2.13.8", "react-datetime": "^3.0.4", + "react-diff-viewer": "^3.1.1", "react-dnd": "^11.1.3", "react-dnd-html5-backend": "^11.1.3", "react-dom": "^16.13.0", "react-draggable": "^4.4.3", "react-gravatar": "^2.6.1", "react-hot-loader": "^4.12.20", + "react-intersection-observer": "^8.26.2", "react-js-cron": "^1.2.0", "react-json-tree": "^0.11.2", "react-jsonschema-form": "^1.2.0", diff --git a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts index 4eb90ea0afffa..012bb4ebaf9ec 100644 --- a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts +++ b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts @@ -28,6 +28,7 @@ export enum FeatureFlag { DASHBOARD_CROSS_FILTERS = 'DASHBOARD_CROSS_FILTERS', DASHBOARD_EDIT_CHART_IN_NEW_TAB = 'DASHBOARD_EDIT_CHART_IN_NEW_TAB', DASHBOARD_FILTERS_EXPERIMENTAL = 'DASHBOARD_FILTERS_EXPERIMENTAL', + CONFIRM_DASHBOARD_DIFF = 'CONFIRM_DASHBOARD_DIFF', DASHBOARD_NATIVE_FILTERS = 'DASHBOARD_NATIVE_FILTERS', DASHBOARD_NATIVE_FILTERS_SET = 'DASHBOARD_NATIVE_FILTERS_SET', DASHBOARD_VIRTUALIZATION = 'DASHBOARD_VIRTUALIZATION', diff --git a/superset-frontend/spec/fixtures/mockDashboardState.js b/superset-frontend/spec/fixtures/mockDashboardState.js index 49b71053710a8..b605443af6b02 100644 --- a/superset-frontend/spec/fixtures/mockDashboardState.js +++ b/superset-frontend/spec/fixtures/mockDashboardState.js @@ -1,3 +1,4 @@ +/* eslint-disable theme-colors/no-literal-colors */ /** * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file @@ -30,3 +31,88 @@ export default { focusedFilterField: null, refreshFrequency: 0, }; + +export const overwriteConfirmMetadata = { + updatedAt: '2022-10-07T16:35:30.924212', + updatedBy: 'Superset Admin', + overwriteConfirmItems: [ + { + keyPath: 'css', + oldValue: '', + newValue: ` + .navbar { + transition: opacity 0.5s ease; + } +`, + }, + { + keyPath: 'json_metadata.filter_scopes', + oldValue: `{ + "122": { + "ethnic_minority": { + "scope": ["ROOT_ID"], + "immune": [] + }, + "gender": { + "scope": ["ROOT_ID"], + "immune": [] + }, + "developer_type": { + "scope": ["ROOT_ID"], + "immune": [] + }, + "lang_at_home": { + "scope": ["ROOT_ID"], + "immune": [] + }, + "country_live": { + "scope": ["ROOT_ID"], + "immune": [] + } + } +}`, + newValue: `{ + "122": { + "ethnic_minority": { + "scope": ["ROOT_ID"], + "immune": [ + 131, + 115, + 123, + 89, + 94, + 71 + ] + }, + "gender": { + "scope": ["ROOT_ID"], + "immune": [] + }, + "developer_type": { + "scope": ["ROOT_ID"], + "immune": [] + }, + "lang_at_home": { + "scope": ["ROOT_ID"], + "immune": [] + }, + "country_live": { + "scope": ["ROOT_ID"], + "immune": [] + } + } +}`, + }, + ], + dashboardId: 9, + data: { + certified_by: '', + certification_details: '', + css: ".navbar {\n transition: opacity 0.5s ease;\n opacity: 0.05;\n}\n.navbar:hover {\n opacity: 1;\n}\n.chart-header .header{\n font-weight: @font-weight-normal;\n font-size: 12px;\n}\n/*\nvar bnbColors = [\n //rausch hackb kazan babu lima beach tirol\n '#ff5a5f', '#7b0051', '#007A87', '#00d1c1', '#8ce071', '#ffb400', '#b4a76c',\n '#ff8083', '#cc0086', '#00a1b3', '#00ffeb', '#bbedab', '#ffd266', '#cbc29a',\n '#ff3339', '#ff1ab1', '#005c66', '#00b3a5', '#55d12e', '#b37e00', '#988b4e',\n ];\n*/\n", + dashboard_title: 'FCC New Coder Survey 2018', + slug: null, + owners: [], + json_metadata: + '{"timed_refresh_immune_slices":[],"expanded_slices":{},"refresh_frequency":0,"default_filters":"{}","color_scheme":"supersetColors","label_colors":{"0":"#FCC700","1":"#A868B7","15":"#3CCCCB","30":"#A38F79","45":"#8FD3E4","age":"#1FA8C9","Yes,":"#1FA8C9","Female":"#454E7C","Prefer":"#5AC189","No,":"#FF7F44","Male":"#666666","Prefer not to say":"#E04355","Ph.D.":"#FCC700","associate\'s degree":"#A868B7","bachelor\'s degree":"#3CCCCB","high school diploma or equivalent (GED)":"#A38F79","master\'s degree (non-professional)":"#8FD3E4","no high school (secondary school)":"#A1A6BD","professional degree (MBA, MD, JD, etc.)":"#ACE1C4","some college credit, no degree":"#FEC0A1","some high school":"#B2B2B2","trade, technical, or vocational training":"#EFA1AA","No, not an ethnic minority":"#1FA8C9","Yes, an ethnic minority":"#454E7C","":"#5AC189","Yes":"#FF7F44","No":"#666666","last_yr_income":"#E04355","More":"#A1A6BD","Less":"#ACE1C4","I":"#FEC0A1","expected_earn":"#B2B2B2","Yes: Willing To":"#EFA1AA","No: Not Willing to":"#FDE380","No Answer":"#D3B3DA","In an Office (with Other Developers)":"#9EE5E5","No Preference":"#D1C6BC","From Home":"#1FA8C9"},"show_native_filters":true,"color_scheme_domain":["#1FA8C9","#454E7C","#5AC189","#FF7F44","#666666","#E04355","#FCC700","#A868B7","#3CCCCB","#A38F79","#8FD3E4","#A1A6BD","#ACE1C4","#FEC0A1","#B2B2B2","#EFA1AA","#FDE380","#D3B3DA","#9EE5E5","#D1C6BC"],"shared_label_colors":{"Male":"#5ac19e","Female":"#1f86c9","":"#5AC189","Prefer not to say":"#47457c","No Answer":"#e05043","Yes, an ethnic minority":"#666666","No, not an ethnic minority":"#ffa444","age":"#1FA8C9"},"filter_scopes":{},"chart_configuration":{},"positions":{}}', + }, +}; diff --git a/superset-frontend/src/components/Button/index.tsx b/superset-frontend/src/components/Button/index.tsx index b4152ea98dc17..05a1a3ad79881 100644 --- a/superset-frontend/src/components/Button/index.tsx +++ b/superset-frontend/src/components/Button/index.tsx @@ -39,11 +39,13 @@ export type ButtonStyle = | 'link' | 'dashed'; +export type ButtonSize = 'default' | 'small' | 'xsmall'; + export type ButtonProps = Omit & Pick & { tooltip?: string; className?: string; - buttonSize?: 'default' | 'small' | 'xsmall'; + buttonSize?: ButtonSize; buttonStyle?: ButtonStyle; cta?: boolean; showMarginRight?: boolean; diff --git a/superset-frontend/src/components/DesignSystem.stories.mdx b/superset-frontend/src/components/DesignSystem.stories.mdx new file mode 100644 index 0000000000000..e00612c5be40a --- /dev/null +++ b/superset-frontend/src/components/DesignSystem.stories.mdx @@ -0,0 +1,25 @@ +import { Meta, Source } from '@storybook/addon-docs'; +import AtomicDesign from './atomic-design.png'; + + + +# Superset Design System + +A design system is a complete set of standards intended to manage design at scale using reusable components and patterns. + +You can get an overview of Atomic Design concepts and a link to the full book on the topic here: + + + Intro to Atomic Design + + +While the Superset Design System will use Atomic Design principles, we choose a different language to describe the elements. + +| Atomic Design | Atoms | Molecules | Organisms | Templates | Pages / Screens | +| :-------------- | :---------: | :--------: | :-------: | :-------: | :-------------: | +| Superset Design | Foundations | Components | Patterns | Templates | Features | + +Atoms = Foundations, Molecules = Components, Organisms = Patterns, Templates = Templates, Pages / Screens = Features diff --git a/superset-frontend/src/components/Dropdown/index.tsx b/superset-frontend/src/components/Dropdown/index.tsx index bd01aabb4d558..c40f479579d2e 100644 --- a/superset-frontend/src/components/Dropdown/index.tsx +++ b/superset-frontend/src/components/Dropdown/index.tsx @@ -20,6 +20,7 @@ import React, { RefObject } from 'react'; import { AntdDropdown } from 'src/components'; import { DropDownProps } from 'antd/lib/dropdown'; import { styled } from '@superset-ui/core'; +import Icons from 'src/components/Icons'; const MenuDots = styled.div` width: ${({ theme }) => theme.gridUnit * 0.75}px; @@ -66,14 +67,35 @@ const MenuDotsWrapper = styled.div` padding-left: ${({ theme }) => theme.gridUnit}px; `; +export enum IconOrientation { + VERTICAL = 'vertical', + HORIZONTAL = 'horizontal', +} export interface DropdownProps extends DropDownProps { overlay: React.ReactElement; + iconOrientation?: IconOrientation; } -export const Dropdown = ({ overlay, ...rest }: DropdownProps) => ( +const RenderIcon = ( + iconOrientation: IconOrientation = IconOrientation.VERTICAL, +) => { + const component = + iconOrientation === IconOrientation.HORIZONTAL ? ( + + ) : ( + + ); + return component; +}; + +export const Dropdown = ({ + overlay, + iconOrientation = IconOrientation.VERTICAL, + ...rest +}: DropdownProps) => ( - + {RenderIcon(iconOrientation)} ); diff --git a/superset-frontend/src/components/Loading/Loading.stories.tsx b/superset-frontend/src/components/Loading/Loading.stories.tsx index 9f079848b8a2e..0c80c6f0ff618 100644 --- a/superset-frontend/src/components/Loading/Loading.stories.tsx +++ b/superset-frontend/src/components/Loading/Loading.stories.tsx @@ -40,7 +40,7 @@ export const LoadingGallery = () => ( }} >

{position}

- + ))} @@ -71,7 +71,7 @@ InteractiveLoading.story = { }; InteractiveLoading.args = { - image: '/src/assets/images/loading.gif', + image: '', className: '', }; diff --git a/superset-frontend/src/components/Loading/Loading.test.tsx b/superset-frontend/src/components/Loading/Loading.test.tsx index d6ea8581c5105..7325c9304b587 100644 --- a/superset-frontend/src/components/Loading/Loading.test.tsx +++ b/superset-frontend/src/components/Loading/Loading.test.tsx @@ -26,11 +26,9 @@ test('Rerendering correctly with default props', () => { render(); const loading = screen.getByRole('status'); const classNames = loading.getAttribute('class')?.split(' '); - const imagePath = loading.getAttribute('src'); const ariaLive = loading.getAttribute('aria-live'); const ariaLabel = loading.getAttribute('aria-label'); expect(loading).toBeInTheDocument(); - expect(imagePath).toBe('/static/assets/images/loading.gif'); expect(classNames).toContain('floating'); expect(classNames).toContain('loading'); expect(ariaLive).toContain('polite'); @@ -56,7 +54,7 @@ test('support for extra classes', () => { expect(classNames).toContain('extra-class'); }); -test('Diferent image path', () => { +test('Different image path', () => { render(); const loading = screen.getByRole('status'); const imagePath = loading.getAttribute('src'); diff --git a/superset-frontend/src/components/Loading/index.tsx b/superset-frontend/src/components/Loading/index.tsx index 6ba6fb45c5443..97cd553ad5b7b 100644 --- a/superset-frontend/src/components/Loading/index.tsx +++ b/superset-frontend/src/components/Loading/index.tsx @@ -20,6 +20,7 @@ import React from 'react'; import { styled } from '@superset-ui/core'; import cls from 'classnames'; +import Loader from 'src/assets/images/loading.gif'; export type PositionOption = | 'floating' @@ -35,6 +36,7 @@ export interface Props { const LoaderImg = styled.img` z-index: 99; width: 50px; + height: unset; position: relative; margin: 10px; &.inline { @@ -57,14 +59,14 @@ const LoaderImg = styled.img` `; export default function Loading({ position = 'floating', - image = '/static/assets/images/loading.gif', + image, className, }: Props) { return ( + -# Usage +# Metadata bar -The metadata bar component is used to display additional information about an entity. Some of the common applications in Superset are: +The metadata bar component is used to display additional information about an entity. + +## Usage + +Some of the common applications in Superset are: - Display the chart's metadata in Explore to help the user understand what dashboards this chart is added to and get to know the details of the chart - Display the database's metadata in a drill to detail modal to help the user understand what data they are looking at while accessing the feature in the dashboard -# Variations +## Basic example + + + +## Variations The metadata bar is by default a static component (besides the links in text). The variations in this component are related to content and entity type as all of the details are predefined @@ -25,7 +33,7 @@ have the same icon and when hovered it will present who created the entity, its To extend the list of content types, a developer needs to request the inclusion of the new type in the design system. This process is important to make sure the new type is reviewed by the design team, improving Superset consistency. -To check each content type in detail and its interactions, check the [MetadataBar](/story/metadatabar--component) page. +To check each content type in detail and its interactions, check the [MetadataBar](/story/design-system-components-metadatabar-examples--basic) page. Below you can find the configurations for each content type: + +# Table + +A table is UI that allows the user to explore data in a tabular format. + +## Usage + +Common table applications in Superset: + +- Display lists of user-generated entities (e.g. dashboard, charts, queries) for further exploration and use +- Display data that can help the user make a decision (e.g. query results) + +This component provides a general use Table. + +--- + +### [Basic example](./?path=/docs/design-system-components-table-examples--basic) + + + +### Data and Columns + +To set the visible columns and data for the table you use the `columns` and `data` props. + +
+ +The basic table example for the `columns` prop is: + +``` +const basicColumns: = [ + { + title: 'Name', + dataIndex: 'name', + key: 'name', + width: 150, + sorter: (a: BasicData, b: BasicData) => + alphabeticalSort('name', a, b), + }, + { + title: 'Category', + dataIndex: 'category', + key: 'category', + sorter: (a: BasicData, b: BasicData) => + alphabeticalSort('category', a, b), + }, + { + title: 'Price', + dataIndex: 'price', + key: 'price', + sorter: (a: BasicData, b: BasicData) => + numericalSort('price', a, b), + }, + { + title: 'Description', + dataIndex: 'description', + key: 'description', + }, +]; +``` + +The data prop is: + +``` +const basicData: = [ + { + key: 1, + name: 'Floppy Disk 10 pack', + category: 'Disk Storage', + price: '9.99' + description: 'A real blast from the past', + }, + { + key: 2, + name: 'DVD 100 pack', + category: 'Optical Storage', + price: '27.99' + description: 'Still pretty ancient', + }, + { + key: 3, + name: '128 GB SSD', + category: 'Hardrive', + price: '49.99' + description: 'Reliable and fast data storage', + }, +]; +``` + +
+ +### Column Sort Functions + +To ensure consistency for column sorting and to avoid redundant definitions for common column sorters, reusable sort functions are provided. +When defining the object for the `columns` prop you can provide an optional attribute `sorter`. +The function provided in the `sorter` prop is given the entire record representing a row as props `a` and `b`. +When using a provided sorter function the pattern is to wrap the call to the sorter with an inline function, then specify the specific attribute value from `dataIndex`, representing a column +of the data object for that row, as the first argument of the sorter function. + +#### alphabeticalSort + +The alphabeticalSort is for columns that display a string of text. + +
+ +``` +import { alphabeticalSort } from 'src/components/Table/sorters'; + +const basicColumns = [ + { + title: 'Column Name', + dataIndex: 'columnName', + key: 'columnName', + sorter: (a, b) => + alphabeticalSort('columnName', a, b), + } +] +``` + +
+ +#### numericSort + +The numericalSort is for columns that display a numeric value. + +
+ +``` +import { numericalSort } from './sorters'; + +const basicColumns = [ + { + title: 'Height', + dataIndex: 'height', + key: 'height', + sorter: (a, b) => + numericalSort('height', a, b), + } +] +``` + +
+ +If a different sort option is needed, consider adding it as a reusable sort function following the pattern provided above. + +--- + +### Cell Content Renderers + +By default, each column will render the value as simple text. Often you will want to show formatted values, such as a numeric column showing as currency, or a more complex component such as a button or action menu as a cell value. +Cell Renderers are React components provided to the optional `render` attribute on a column definition that enables injecting a specific React component to enable this. + + + +For convenience and consistency, the Table component provides pre-built Cell Renderers for: +The following data types can be displayed in table cells. + +- Text (default) +- [Button Cell](./?path=/docs/design-system-components-table-cell-renderers-buttoncell--basic) +- [Numeric Cell](./docs/design-system-components-table-cell-renderers-numericcell--basic) + - Support Locale and currency formatting + - w/ icons - Coming Soon +- [Action Menu Cell](./?path=/docs/design-system-components-table-cell-renderers-actioncell-overview--page) +- Provide a list of menu options with callback functions that retain a reference to the row the menu is defined for +- Custom + - You can provide your own React component as a cell renderer in cases not supported + +--- + +### Loading + +The table can be set to a loading state simply by setting the loading prop to true | false + + + +--- + +### Pagination + +The table displays a set number of rows at a time, the user navigates the table via pagination. Use in scenarios where the user is searching for a specific piece of content. +The default page size and page size options for the menu are configurable via the `pageSizeOptions` and `defaultPageSize` props. +NOTE: Pagination controls will only display when the data for the table has more records than the default page size. + + + +``` + +``` + +--- + +## Integration Checklist + +The following specifications are required every time a table is used. These choices should be intentional based on the specific user needs for the table instance. + +
+ +- [ ] Size + - Large + - Small +- Columns + - [ ] Number of + - [ ] Contents + - [ ] Order + - [ ] Widths +- Column headers + - [ ] Labels + - [ ] Has tooltip + - [ ] Tooltip text +- [ ] Default sort +- Functionality + - [ ] Can sort columns + - [ ] Can filter columns +- [ ] Loading + - Pagination + - [ ] Number of rows per page + - Infinite scroll +- [ ] Has toolbar + - [ ] Has table title + - [ ] Label + - [ ] Has buttons + - [ ] Labels + - [ ] Actions + - [ ] Has search + +
+ +--- + +## Experimental features + +The Table component has features that are still experimental and can be used at your own risk. +These features are intended to be made fully stable in future releases. + +### Resizable Columns + +The prop `resizable` enables table columns to be resized by the user dragging from the right edge of each +column to increase or decrease the columns' width + + + +### Drag & Drop Columns + +The prop `reorderable` can enable column drag and drop reordering as well as dragging a column to another component. If you want to accept the drop event of a Table Column +you can register `onDragOver` and `onDragDrop` event handlers on the destination component. In the `onDragDrop` handler you can check for `SUPERSET_TABLE_COLUMN` +as the getData key as shown below. + +``` +import { SUPERSET_TABLE_COLUMN } from 'src/components/table'; + +const handleDrop = (ev:Event) => { + const json = ev.dataTransfer?.getData?.(SUPERSET_TABLE_COLUMN); + const data = JSON.parse(json); + // ... do something with the data here +} +``` + + diff --git a/superset-frontend/src/components/Table/Table.stories.tsx b/superset-frontend/src/components/Table/Table.stories.tsx new file mode 100644 index 0000000000000..90ee3448c67ec --- /dev/null +++ b/superset-frontend/src/components/Table/Table.stories.tsx @@ -0,0 +1,432 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React, { useState } from 'react'; +import { ComponentStory, ComponentMeta } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import { supersetTheme, ThemeProvider } from '@superset-ui/core'; +import { Table, TableSize, SUPERSET_TABLE_COLUMN, ColumnsType } from './index'; +import { numericalSort, alphabeticalSort } from './sorters'; +import ButtonCell from './cell-renderers/ButtonCell'; +import ActionCell from './cell-renderers/ActionCell'; +import { exampleMenuOptions } from './cell-renderers/ActionCell/fixtures'; +import NumericCell, { + CurrencyCode, + LocaleCode, + Style, +} from './cell-renderers/NumericCell'; + +export default { + title: 'Design System/Components/Table/Examples', + component: Table, + argTypes: { onClick: { action: 'clicked' } }, +} as ComponentMeta; + +export interface BasicData { + name: string; + category: string; + price: number; + description?: string; + key: number; +} + +export interface RendererData { + key: number; + buttonCell: string; + textCell: string; + euroCell: number; + dollarCell: number; +} + +export interface ExampleData { + title: string; + name: string; + age: number; + address: string; + tags?: string[]; + key: number; +} + +function generateValues(amount: number): object { + const cells = {}; + for (let i = 0; i < amount; i += 1) { + cells[`col-${i}`] = `Text ${i}`; + } + return cells; +} + +function generateColumns(amount: number): ColumnsType[] { + const newCols: any[] = []; + for (let i = 0; i < amount; i += 1) { + newCols.push({ + title: `Column Header ${i}`, + dataIndex: `col-${i}`, + key: `col-${i}`, + }); + } + return newCols as ColumnsType[]; +} +const recordCount = 200; +const columnCount = 12; +const randomCols: ColumnsType[] = generateColumns(columnCount); + +const basicData: BasicData[] = [ + { + key: 1, + name: 'Floppy Disk 10 pack', + category: 'Disk Storage', + price: 9.99, + description: 'A real blast from the past', + }, + { + key: 2, + name: 'DVD 100 pack', + category: 'Optical Storage', + price: 27.99, + description: 'Still pretty ancient', + }, + { + key: 3, + name: '128 GB SSD', + category: 'Hardrive', + price: 49.99, + description: 'Reliable and fast data storage', + }, +]; + +const basicColumns: ColumnsType = [ + { + title: 'Name', + dataIndex: 'name', + key: 'name', + width: 150, + sorter: (a: BasicData, b: BasicData) => alphabeticalSort('name', a, b), + }, + { + title: 'Category', + dataIndex: 'category', + key: 'category', + sorter: (a: BasicData, b: BasicData) => alphabeticalSort('category', a, b), + }, + { + title: 'Price', + dataIndex: 'price', + key: 'price', + sorter: (a: BasicData, b: BasicData) => numericalSort('price', a, b), + }, + { + title: 'Description', + dataIndex: 'description', + key: 'description', + }, +]; + +const bigColumns: ColumnsType = [ + { + title: 'Name', + dataIndex: 'name', + key: 'name', + render: (text: string, row: object, index: number) => ( + + ), + width: 150, + }, + { + title: 'Age', + dataIndex: 'age', + key: 'age', + }, + { + title: 'Address', + dataIndex: 'address', + key: 'address', + }, + ...(randomCols as ColumnsType), +]; + +const rendererColumns: ColumnsType = [ + { + title: 'Button Cell', + dataIndex: 'buttonCell', + key: 'buttonCell', + width: 150, + render: (text: string, data: object, index: number) => ( + + ), + }, + { + title: 'Text Cell', + dataIndex: 'textCell', + key: 'textCell', + }, + { + title: 'Euro Cell', + dataIndex: 'euroCell', + key: 'euroCell', + render: (value: number) => ( + + ), + }, + { + title: 'Dollar Cell', + dataIndex: 'dollarCell', + key: 'dollarCell', + render: (value: number) => ( + + ), + }, + { + dataIndex: 'actions', + key: 'actions', + render: (text: string, row: object) => ( + + ), + width: 32, + fixed: 'right', + }, +]; + +const baseData: any[] = [ + { + key: 1, + name: 'John Brown', + age: 32, + address: 'New York No. 1 Lake Park', + tags: ['nice', 'developer'], + ...generateValues(columnCount), + }, + { + key: 2, + name: 'Jim Green', + age: 42, + address: 'London No. 1 Lake Park', + tags: ['loser'], + ...generateValues(columnCount), + }, + { + key: 3, + name: 'Joe Black', + age: 32, + address: 'Sidney No. 1 Lake Park', + tags: ['cool', 'teacher'], + ...generateValues(columnCount), + }, +]; + +const bigdata: any[] = []; +for (let i = 0; i < recordCount; i += 1) { + bigdata.push({ + key: i + baseData.length, + name: `Dynamic record ${i}`, + age: 32 + i, + address: `DynamoCity, Dynamic Lane no. ${i}`, + ...generateValues(columnCount), + }); +} + +export const Basic: ComponentStory = args => ( + +
+
+ + +); + +function handlers(record: object, rowIndex: number) { + return { + onClick: action( + `row onClick, row: ${rowIndex}, record: ${JSON.stringify(record)}`, + ), // click row + onDoubleClick: action( + `row onDoubleClick, row: ${rowIndex}, record: ${JSON.stringify(record)}`, + ), // double click row + onContextMenu: action( + `row onContextMenu, row: ${rowIndex}, record: ${JSON.stringify(record)}`, + ), // right button click row + onMouseEnter: action(`Mouse Enter, row: ${rowIndex}`), // mouse enter row + onMouseLeave: action(`Mouse Leave, row: ${rowIndex}`), // mouse leave row + }; +} + +Basic.args = { + data: basicData, + columns: basicColumns, + size: TableSize.SMALL, + onRow: handlers, + pageSizeOptions: ['5', '10', '15', '20', '25'], + defaultPageSize: 10, +}; + +export const ManyColumns: ComponentStory = args => ( + +
+
+ + +); + +ManyColumns.args = { + data: bigdata, + columns: bigColumns, + size: TableSize.SMALL, + resizable: true, + reorderable: true, + height: 350, +}; + +export const Loading: ComponentStory = args => ( + +
+ +); + +Loading.args = { + data: basicData, + columns: basicColumns, + size: TableSize.SMALL, + loading: true, +}; + +export const ResizableColumns: ComponentStory = args => ( + +
+
+ + +); + +ResizableColumns.args = { + data: basicData, + columns: basicColumns, + size: TableSize.SMALL, + resizable: true, +}; + +export const ReorderableColumns: ComponentStory = args => { + const [droppedItem, setDroppedItem] = useState(); + const dragOver = (ev: React.DragEvent) => { + ev.preventDefault(); + const element: HTMLElement | null = ev?.currentTarget as HTMLElement; + if (element?.style) { + element.style.border = '1px dashed green'; + } + }; + + const dragOut = (ev: React.DragEvent) => { + ev.preventDefault(); + const element: HTMLElement | null = ev?.currentTarget as HTMLElement; + if (element?.style) { + element.style.border = '1px solid grey'; + } + }; + + const dragDrop = (ev: React.DragEvent) => { + const data = ev.dataTransfer?.getData?.(SUPERSET_TABLE_COLUMN); + const element: HTMLElement | null = ev?.currentTarget as HTMLElement; + if (element?.style) { + element.style.border = '1px solid grey'; + } + setDroppedItem(data); + }; + return ( + +
+
) => dragOver(ev)} + onDragLeave={(ev: React.DragEvent) => dragOut(ev)} + onDrop={(ev: React.DragEvent) => dragDrop(ev)} + style={{ + width: '100%', + height: '40px', + border: '1px solid grey', + marginBottom: '8px', + padding: '8px', + borderRadius: '4px', + }} + > + {droppedItem ?? 'Drop column here...'} +
+
+ + + ); +}; + +ReorderableColumns.args = { + data: basicData, + columns: basicColumns, + size: TableSize.SMALL, + reorderable: true, +}; + +const rendererData: RendererData[] = [ + { + key: 1, + buttonCell: 'Click Me', + textCell: 'Some text', + euroCell: 45.5, + dollarCell: 45.5, + }, + { + key: 2, + buttonCell: 'I am a button', + textCell: 'More text', + euroCell: 1700, + dollarCell: 1700, + }, + { + key: 3, + buttonCell: 'Button 3', + textCell: 'The third string of text', + euroCell: 500.567, + dollarCell: 500.567, + }, +]; + +export const CellRenderers: ComponentStory = args => ( + +
+
+ + +); + +CellRenderers.args = { + data: rendererData, + columns: rendererColumns, + size: TableSize.SMALL, + reorderable: true, +}; diff --git a/superset-frontend/src/components/Table/Table.test.tsx b/superset-frontend/src/components/Table/Table.test.tsx new file mode 100644 index 0000000000000..eded7efeb96f3 --- /dev/null +++ b/superset-frontend/src/components/Table/Table.test.tsx @@ -0,0 +1,80 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import { render, screen, waitFor } from 'spec/helpers/testing-library'; +import type { ColumnsType } from 'antd/es/table'; +import { Table, TableSize } from './index'; + +interface BasicData { + columnName: string; + columnType: string; + dataType: string; +} + +const testData: BasicData[] = [ + { + columnName: 'Number', + columnType: 'Numerical', + dataType: 'number', + }, + { + columnName: 'String', + columnType: 'Physical', + dataType: 'string', + }, + { + columnName: 'Date', + columnType: 'Virtual', + dataType: 'date', + }, +]; + +const testColumns: ColumnsType = [ + { + title: 'Column Name', + dataIndex: 'columnName', + key: 'columnName', + }, + { + title: 'Column Type', + dataIndex: 'columnType', + key: 'columnType', + }, + { + title: 'Data Type', + dataIndex: 'dataType', + key: 'dataType', + }, +]; + +test('renders with default props', async () => { + render( +
, + ); + await waitFor(() => + testColumns.forEach(column => + expect(screen.getByText(column.title as string)).toBeInTheDocument(), + ), + ); + testData.forEach(row => { + expect(screen.getByText(row.columnName)).toBeInTheDocument(); + expect(screen.getByText(row.columnType)).toBeInTheDocument(); + expect(screen.getByText(row.dataType)).toBeInTheDocument(); + }); +}); diff --git a/superset-frontend/src/components/Table/cell-renderers/ActionCell/ActionCell.overview.mdx b/superset-frontend/src/components/Table/cell-renderers/ActionCell/ActionCell.overview.mdx new file mode 100644 index 0000000000000..09e1b5ed6b17b --- /dev/null +++ b/superset-frontend/src/components/Table/cell-renderers/ActionCell/ActionCell.overview.mdx @@ -0,0 +1,69 @@ +import { Meta, Source, Story, ArgsTable } from '@storybook/addon-docs'; + + + +# ActionCell + +An ActionCell is used to display an overflow icon that opens a menu allowing the user to take actions +specific to the data in the table row that the cell is a member of. + +### [Basic example](./?path=/docs/design-system-components-table-cell-renderers-actioncell--basic) + + + +--- + +## Usage + +The action cell accepts an array of objects that define the label, tooltip, onClick callback functions, +and an optional data payload to be provided back to the onClick handler function. + +### [Basic example](./?path=/docs/design-system-components-table-cell-renderers-actioncell--basic) + + + +``` +import { ActionMenuItem } from 'src/components/Table/cell-renderers/index'; + +export const exampleMenuOptions: ActionMenuItem[] = [ + { + label: 'Action 1', + tooltip: "This is a tip, don't spend it all in one place", + onClick: (item: ActionMenuItem) => { + // eslint-disable-next-line no-alert + alert(JSON.stringify(item)); + }, + payload: { + taco: 'spicy chicken', + }, + }, + { + label: 'Action 2', + tooltip: 'This is another tip', + onClick: (item: ActionMenuItem) => { + // eslint-disable-next-line no-alert + alert(JSON.stringify(item)); + }, + payload: { + taco: 'saucy tofu', + }, + }, +]; + +``` + +Within the context of adding an action cell to cell definitions provided to the table using the ActionCell component +for the return value from the render function on the cell definition. See the [Basic example](./?path=/docs/design-system-components-table-examples--basic) + +``` +import ActionCell from './index'; + +const cellExample = [ + { + title: 'Actions', + dataIndex: 'actions', + key: 'actions', + render: () => , + } +] +``` diff --git a/superset-frontend/src/components/Table/cell-renderers/ActionCell/ActionCell.stories.tsx b/superset-frontend/src/components/Table/cell-renderers/ActionCell/ActionCell.stories.tsx new file mode 100644 index 0000000000000..d51dbcc559fdd --- /dev/null +++ b/superset-frontend/src/components/Table/cell-renderers/ActionCell/ActionCell.stories.tsx @@ -0,0 +1,36 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * License); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * AS IS BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import { ComponentStory, ComponentMeta } from '@storybook/react'; +import ActionCell from './index'; +import { exampleMenuOptions, exampleRow } from './fixtures'; + +export default { + title: 'Design System/Components/Table/Cell Renderers/ActionCell', + component: ActionCell, +} as ComponentMeta; + +export const Basic: ComponentStory = args => ( + +); + +Basic.args = { + menuOptions: exampleMenuOptions, + row: exampleRow, +}; diff --git a/superset-frontend/src/components/Table/cell-renderers/ActionCell/ActionCell.test.tsx b/superset-frontend/src/components/Table/cell-renderers/ActionCell/ActionCell.test.tsx new file mode 100644 index 0000000000000..5da7453aa9d7f --- /dev/null +++ b/superset-frontend/src/components/Table/cell-renderers/ActionCell/ActionCell.test.tsx @@ -0,0 +1,50 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import { render, screen } from 'spec/helpers/testing-library'; +import userEvent from '@testing-library/user-event'; +import ActionCell, { appendDataToMenu } from './index'; +import { exampleMenuOptions, exampleRow } from './fixtures'; + +test('renders with default props', async () => { + const clickHandler = jest.fn(); + exampleMenuOptions[0].onClick = clickHandler; + render(); + // Open the menu + userEvent.click(await screen.findByTestId('dropdown-trigger')); + // verify all of the menu items are being displayed + exampleMenuOptions.forEach((item, index) => { + expect(screen.getByText(item.label)).toBeInTheDocument(); + if (index === 0) { + // verify the menu items' onClick gets invoked + userEvent.click(screen.getByText(item.label)); + } + }); + expect(clickHandler).toHaveBeenCalled(); +}); + +/** + * Validate that the appendDataToMenu utility function used within the + * Action cell menu rendering works as expected + */ +test('appendDataToMenu utility', () => { + exampleMenuOptions.forEach(item => expect(item?.row).toBeUndefined()); + const modifiedMenuOptions = appendDataToMenu(exampleMenuOptions, exampleRow); + modifiedMenuOptions.forEach(item => expect(item?.row).toBeDefined()); +}); diff --git a/superset-frontend/src/components/Table/cell-renderers/ActionCell/fixtures.ts b/superset-frontend/src/components/Table/cell-renderers/ActionCell/fixtures.ts new file mode 100644 index 0000000000000..a0569b69906bc --- /dev/null +++ b/superset-frontend/src/components/Table/cell-renderers/ActionCell/fixtures.ts @@ -0,0 +1,47 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * License); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * AS IS BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { action } from '@storybook/addon-actions'; +import { ActionMenuItem } from './index'; + +export const exampleMenuOptions: ActionMenuItem[] = [ + { + label: 'Action 1', + tooltip: "This is a tip, don't spend it all in one place", + onClick: action('menu item onClick'), + payload: { + taco: 'spicy chicken', + }, + }, + { + label: 'Action 2', + tooltip: 'This is another tip', + onClick: action('menu item onClick'), + payload: { + taco: 'saucy tofu', + }, + }, +]; + +export const exampleRow = { + key: 1, + buttonCell: 'Click Me', + textCell: 'Some text', + euroCell: 45.5, + dollarCell: 45.5, +}; diff --git a/superset-frontend/src/components/Table/cell-renderers/ActionCell/index.tsx b/superset-frontend/src/components/Table/cell-renderers/ActionCell/index.tsx new file mode 100644 index 0000000000000..b6ba57420c6b4 --- /dev/null +++ b/superset-frontend/src/components/Table/cell-renderers/ActionCell/index.tsx @@ -0,0 +1,145 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React, { useState, useEffect } from 'react'; +import { styled } from '@superset-ui/core'; +import { Dropdown, IconOrientation } from 'src/components/Dropdown'; +import { Menu } from 'src/components/Menu'; +import { MenuProps } from 'antd/lib/menu'; + +/** + * Props interface for Action Cell Renderer + */ +export interface ActionCellProps { + /** + * The Menu option presented to user when menu displays + */ + menuOptions: ActionMenuItem[]; + /** + * Object representing the data rendering the Table row with attribute for each column + */ + row: object; +} + +export interface ActionMenuItem { + /** + * Click handler specific to the menu item + * @param menuItem The definition of the menu item that was clicked + * @returns ActionMenuItem + */ + onClick: (menuItem: ActionMenuItem) => void; + /** + * Label user will see displayed in the list of menu options + */ + label: string; + /** + * Optional tooltip user will see if they hover over the menu option to get more context + */ + tooltip?: string; + /** + * Optional variable that can contain data relevant to the menu item that you + * want easy access to in the callback function for the menu + */ + payload?: any; + /** + * Object representing the data rendering the Table row with attribute for each column + */ + row?: object; +} + +/** + * Props interface for ActionMenu + */ +export interface ActionMenuProps { + menuOptions: ActionMenuItem[]; + setVisible: (visible: boolean) => void; +} + +const SHADOW = + 'box-shadow: 0px 3px 6px -4px rgba(0, 0, 0, 0.12), 0px 9px 28px 8px rgba(0, 0, 0, 0.05)'; +const FILTER = 'drop-shadow(0px 6px 16px rgba(0, 0, 0, 0.08))'; + +const StyledMenu = styled(Menu)` + box-shadow: ${SHADOW} !important; + filter: ${FILTER} !important; + border-radius: 2px !important; + -webkit-box-shadow: ${SHADOW} !important; +`; + +export const appendDataToMenu = ( + options: ActionMenuItem[], + row: object, +): ActionMenuItem[] => { + const newOptions = options?.map?.(option => ({ + ...option, + row, + })); + return newOptions; +}; + +function ActionMenu(props: ActionMenuProps) { + const { menuOptions, setVisible } = props; + const handleClick: MenuProps['onClick'] = ({ key }) => { + setVisible?.(false); + const menuItem = menuOptions[key]; + if (menuItem) { + menuItem?.onClick?.(menuItem); + } + }; + + return ( + + {menuOptions?.map?.((option: ActionMenuItem, index: number) => ( + {option?.label} + ))} + + ); +} + +export function ActionCell(props: ActionCellProps) { + const { menuOptions, row } = props; + const [visible, setVisible] = useState(false); + const [appendedMenuOptions, setAppendedMenuOptions] = useState( + appendDataToMenu(menuOptions, row), + ); + + useEffect(() => { + const newOptions = appendDataToMenu(menuOptions, row); + setAppendedMenuOptions(newOptions); + }, [menuOptions, row]); + + const handleVisibleChange = (flag: boolean) => { + setVisible(flag); + }; + return ( + + } + disabled={ + !(appendedMenuOptions?.length && appendedMenuOptions.length > 0) + } + visible={visible} + /> + ); +} + +export default ActionCell; diff --git a/superset-frontend/src/components/Table/cell-renderers/ButtonCell/ButtonCell.stories.tsx b/superset-frontend/src/components/Table/cell-renderers/ButtonCell/ButtonCell.stories.tsx new file mode 100644 index 0000000000000..707e758eedb81 --- /dev/null +++ b/superset-frontend/src/components/Table/cell-renderers/ButtonCell/ButtonCell.stories.tsx @@ -0,0 +1,62 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import { ComponentStory, ComponentMeta } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import { ButtonCell } from './index'; + +export default { + title: 'Design System/Components/Table/Cell Renderers/ButtonCell', + component: ButtonCell, +} as ComponentMeta; + +const clickHandler = action('button cell onClick'); + +export const Basic: ComponentStory = args => ( + +); + +Basic.args = { + onClick: clickHandler, + label: 'Primary', + row: { + key: 1, + buttonCell: 'Click Me', + textCell: 'Some text', + euroCell: 45.5, + dollarCell: 45.5, + }, +}; + +export const Secondary: ComponentStory = args => ( + +); + +Secondary.args = { + onClick: clickHandler, + label: 'Secondary', + buttonStyle: 'secondary', + row: { + key: 1, + buttonCell: 'Click Me', + textCell: 'Some text', + euroCell: 45.5, + dollarCell: 45.5, + }, +}; diff --git a/superset-frontend/src/components/Table/cell-renderers/ButtonCell/ButtonCell.test.tsx b/superset-frontend/src/components/Table/cell-renderers/ButtonCell/ButtonCell.test.tsx new file mode 100644 index 0000000000000..dbdb8fd4f2f2e --- /dev/null +++ b/superset-frontend/src/components/Table/cell-renderers/ButtonCell/ButtonCell.test.tsx @@ -0,0 +1,40 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import { render, screen } from 'spec/helpers/testing-library'; +import userEvent from '@testing-library/user-event'; +import ButtonCell from './index'; +import { exampleRow } from '../fixtures'; + +test('renders with default props', async () => { + const clickHandler = jest.fn(); + const BUTTON_LABEL = 'Button Label'; + + render( + , + ); + await userEvent.click(screen.getByText(BUTTON_LABEL)); + expect(clickHandler).toHaveBeenCalled(); +}); diff --git a/superset-frontend/src/components/Table/cell-renderers/ButtonCell/index.tsx b/superset-frontend/src/components/Table/cell-renderers/ButtonCell/index.tsx new file mode 100644 index 0000000000000..c5739a386ced8 --- /dev/null +++ b/superset-frontend/src/components/Table/cell-renderers/ButtonCell/index.tsx @@ -0,0 +1,58 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import Button, { ButtonStyle, ButtonSize } from 'src/components/Button'; + +type onClickFunction = (row: object, index: number) => void; + +export interface ButtonCellProps { + label: string; + onClick: onClickFunction; + row: object; + index: number; + tooltip?: string; + buttonStyle?: ButtonStyle; + buttonSize?: ButtonSize; +} + +export function ButtonCell(props: ButtonCellProps) { + const { + label, + onClick, + row, + index, + tooltip, + buttonStyle = 'primary', + buttonSize = 'small', + } = props; + + return ( + + ); +} + +export default ButtonCell; diff --git a/superset-frontend/src/components/Table/cell-renderers/NumericCell/NumericCell.stories.tsx b/superset-frontend/src/components/Table/cell-renderers/NumericCell/NumericCell.stories.tsx new file mode 100644 index 0000000000000..bb0b52fe625f4 --- /dev/null +++ b/superset-frontend/src/components/Table/cell-renderers/NumericCell/NumericCell.stories.tsx @@ -0,0 +1,47 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import { ComponentStory, ComponentMeta } from '@storybook/react'; +import { CurrencyCode, LocaleCode, NumericCell, Style } from './index'; + +export default { + title: 'Design System/Components/Table/Cell Renderers/NumericCell', + component: NumericCell, +} as ComponentMeta; + +export const Basic: ComponentStory = args => ( + +); + +Basic.args = { + value: 5678943, +}; + +export const FrenchLocale: ComponentStory = args => ( + +); + +FrenchLocale.args = { + value: 5678943, + locale: LocaleCode.fr, + options: { + style: Style.CURRENCY, + currency: CurrencyCode.EUR, + }, +}; diff --git a/superset-frontend/src/components/Table/cell-renderers/NumericCell/NumericCell.test.tsx b/superset-frontend/src/components/Table/cell-renderers/NumericCell/NumericCell.test.tsx new file mode 100644 index 0000000000000..b76a5bef65f87 --- /dev/null +++ b/superset-frontend/src/components/Table/cell-renderers/NumericCell/NumericCell.test.tsx @@ -0,0 +1,49 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import { render, screen } from 'spec/helpers/testing-library'; +import NumericCell, { CurrencyCode, LocaleCode, Style } from './index'; + +test('renders with French locale and Euro currency format', () => { + render( + , + ); + expect(screen.getByText('5 678 943,00 €')).toBeInTheDocument(); +}); + +test('renders with English US locale and USD currency format', () => { + render( + , + ); + expect(screen.getByText('$5,678,943.00')).toBeInTheDocument(); +}); diff --git a/superset-frontend/src/components/Table/cell-renderers/NumericCell/index.tsx b/superset-frontend/src/components/Table/cell-renderers/NumericCell/index.tsx new file mode 100644 index 0000000000000..5e6d61aa47829 --- /dev/null +++ b/superset-frontend/src/components/Table/cell-renderers/NumericCell/index.tsx @@ -0,0 +1,418 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import { logging } from '@superset-ui/core'; + +export interface NumericCellProps { + /** + * The number to display (before optional formatting applied) + */ + value: number; + /** + * ISO 639-1 language code with optional region or script modifier (e.g. en_US). + */ + locale?: LocaleCode; + /** + * Options for number formatting + */ + options?: NumberOptions; +} + +interface NumberOptions { + /** + * Style of number to display + */ + style?: Style; + + /** + * ISO 4217 currency code + */ + currency?: CurrencyCode; + + /** + * Languages in the form of a ISO 639-1 language code with optional region or script modifier (e.g. de_AT). + */ + maximumFractionDigits?: number; + + /** + * A number from 1 to 21 (default is 21) + */ + maximumSignificantDigits?: number; + + /** + * A number from 0 to 20 (default is 3) + */ + minimumFractionDigits?: number; + + /** + * A number from 1 to 21 (default is 1) + */ + minimumIntegerDigits?: number; + + /** + * A number from 1 to 21 (default is 21) + */ + minimumSignificantDigits?: number; +} + +export enum Style { + CURRENCY = 'currency', + DECIMAL = 'decimal', + PERCENT = 'percent', +} + +export enum CurrencyDisplay { + SYMBOL = 'symbol', + CODE = 'code', + NAME = 'name', +} + +export enum LocaleCode { + af = 'af', + ak = 'ak', + sq = 'sq', + am = 'am', + ar = 'ar', + hy = 'hy', + as = 'as', + az = 'az', + bm = 'bm', + bn = 'bn', + eu = 'eu', + be = 'be', + bs = 'bs', + br = 'br', + bg = 'bg', + my = 'my', + ca = 'ca', + ce = 'ce', + zh = 'zh', + zh_Hans = 'zh-Hans', + zh_Hant = 'zh-Hant', + cu = 'cu', + kw = 'kw', + co = 'co', + hr = 'hr', + cs = 'cs', + da = 'da', + nl = 'nl', + nl_BE = 'nl-BE', + dz = 'dz', + en = 'en', + en_AU = 'en-AU', + en_CA = 'en-CA', + en_GB = 'en-GB', + en_US = 'en-US', + eo = 'eo', + et = 'et', + ee = 'ee', + fo = 'fo', + fi = 'fi', + fr = 'fr', + fr_CA = 'fr-CA', + fr_CH = 'fr-CH', + ff = 'ff', + gl = 'gl', + lg = 'lg', + ka = 'ka', + de = 'de', + de_AT = 'de-AT', + de_CH = 'de-CH', + el = 'el', + gu = 'gu', + ht = 'ht', + ha = 'ha', + he = 'he', + hi = 'hi', + hu = 'hu', + is = 'is', + ig = 'ig', + id = 'id', + ia = 'ia', + ga = 'ga', + it = 'it', + ja = 'ja', + jv = 'jv', + kl = 'kl', + kn = 'kn', + ks = 'ks', + kk = 'kk', + km = 'km', + ki = 'ki', + rw = 'rw', + ko = 'ko', + ku = 'ku', + ky = 'ky', + lo = 'lo', + la = 'la', + lv = 'lv', + ln = 'ln', + lt = 'lt', + lu = 'lu', + lb = 'lb', + mk = 'mk', + mg = 'mg', + ms = 'ms', + ml = 'ml', + mt = 'mt', + gv = 'gv', + mi = 'mi', + mr = 'mr', + mn = 'mn', + ne = 'ne', + nd = 'nd', + se = 'se', + nb = 'nb', + nn = 'nn', + ny = 'ny', + or = 'or', + om = 'om', + os = 'os', + ps = 'ps', + fa = 'fa', + fa_AF = 'fa-AF', + pl = 'pl', + pt = 'pt', + pt_BR = 'pt-BR', + pt_PT = 'pt-PT', + pa = 'pa', + qu = 'qu', + ro = 'ro', + ro_MD = 'ro-MD', + rm = 'rm', + rn = 'rn', + ru = 'ru', + sm = 'sm', + sg = 'sg', + sa = 'sa', + gd = 'gd', + sr = 'sr', + sn = 'sn', + ii = 'ii', + sd = 'sd', + si = 'si', + sk = 'sk', + sl = 'sl', + so = 'so', + st = 'st', + es = 'es', + es_ES = 'es-ES', + es_MX = 'es-MX', + su = 'su', + sw = 'sw', + sw_CD = 'sw-CD', + sv = 'sv', + tg = 'tg', + ta = 'ta', + tt = 'tt', + te = 'te', + th = 'th', + bo = 'bo', + ti = 'ti', + to = 'to', + tr = 'tr', + tk = 'tk', + uk = 'uk', + ur = 'ur', + ug = 'ug', + uz = 'uz', + vi = 'vi', + vo = 'vo', + cy = 'cy', + fy = 'fy', + wo = 'wo', + xh = 'xh', + yi = 'yi', + yo = 'yo', + zu = 'zu', +} + +export enum CurrencyCode { + AED = 'AED', + AFN = 'AFN', + ALL = 'ALL', + AMD = 'AMD', + ANG = 'ANG', + AOA = 'AOA', + ARS = 'ARS', + AUD = 'AUD', + AWG = 'AWG', + AZN = 'AZN', + BAM = 'BAM', + BBD = 'BBD', + BDT = 'BDT', + BGN = 'BGN', + BHD = 'BHD', + BIF = 'BIF', + BMD = 'BMD', + BND = 'BND', + BOB = 'BOB', + BRL = 'BRL', + BSD = 'BSD', + BTN = 'BTN', + BWP = 'BWP', + BYN = 'BYN', + BZD = 'BZD', + CAD = 'CAD', + CDF = 'CDF', + CHF = 'CHF', + CLP = 'CLP', + CNY = 'CNY', + COP = 'COP', + CRC = 'CRC', + CUC = 'CUC', + CUP = 'CUP', + CVE = 'CVE', + CZK = 'CZK', + DJF = 'DJF', + DKK = 'DKK', + DOP = 'DOP', + DZD = 'DZD', + EGP = 'EGP', + ERN = 'ERN', + ETB = 'ETB', + EUR = 'EUR', + FJD = 'FJD', + FKP = 'FKP', + GBP = 'GBP', + GEL = 'GEL', + GHS = 'GHS', + GIP = 'GIP', + GMD = 'GMD', + GNF = 'GNF', + GTQ = 'GTQ', + GYD = 'GYD', + HKD = 'HKD', + HNL = 'HNL', + HRK = 'HRK', + HTG = 'HTG', + HUF = 'HUF', + IDR = 'IDR', + ILS = 'ILS', + INR = 'INR', + IQD = 'IQD', + IRR = 'IRR', + ISK = 'ISK', + JMD = 'JMD', + JOD = 'JOD', + JPY = 'JPY', + KES = 'KES', + KGS = 'KGS', + KHR = 'KHR', + KMF = 'KMF', + KPW = 'KPW', + KRW = 'KRW', + KWD = 'KWD', + KYD = 'KYD', + KZT = 'KZT', + LAK = 'LAK', + LBP = 'LBP', + LKR = 'LKR', + LRD = 'LRD', + LSL = 'LSL', + LYD = 'LYD', + MAD = 'MAD', + MDL = 'MDL', + MGA = 'MGA', + MKD = 'MKD', + MMK = 'MMK', + MNT = 'MNT', + MOP = 'MOP', + MRU = 'MRU', + MUR = 'MUR', + MVR = 'MVR', + MWK = 'MWK', + MXN = 'MXN', + MYR = 'MYR', + MZN = 'MZN', + NAD = 'NAD', + NGN = 'NGN', + NIO = 'NIO', + NOK = 'NOK', + NPR = 'NPR', + NZD = 'NZD', + OMR = 'OMR', + PAB = 'PAB', + PEN = 'PEN', + PGK = 'PGK', + PHP = 'PHP', + PKR = 'PKR', + PLN = 'PLN', + PYG = 'PYG', + QAR = 'QAR', + RON = 'RON', + RSD = 'RSD', + RUB = 'RUB', + RWF = 'RWF', + SAR = 'SAR', + SBD = 'SBD', + SCR = 'SCR', + SDG = 'SDG', + SEK = 'SEK', + SGD = 'SGD', + SHP = 'SHP', + SLL = 'SLL', + SOS = 'SOS', + SRD = 'SRD', + SSP = 'SSP', + STN = 'STN', + SVC = 'SVC', + SYP = 'SYP', + SZL = 'SZL', + THB = 'THB', + TJS = 'TJS', + TMT = 'TMT', + TND = 'TND', + TOP = 'TOP', + TRY = 'TRY', + TTD = 'TTD', + TWD = 'TWD', + TZS = 'TZS', + UAH = 'UAH', + UGX = 'UGX', + USD = 'USD', + UYU = 'UYU', + UZS = 'UZS', + VES = 'VES', + VND = 'VND', + VUV = 'VUV', + WST = 'WST', + XAF = 'XAF', + XCD = 'XCD', + XOF = 'XOF', + XPF = 'XPF', + YER = 'YER', + ZAR = 'ZAR', + ZMW = 'ZMW', + ZWL = 'ZWL', +} + +export function NumericCell(props: NumericCellProps) { + const { value, locale = LocaleCode.en_US, options } = props; + let displayValue = value?.toString() ?? value; + try { + displayValue = value?.toLocaleString?.(locale, options); + } catch (e) { + logging.error(e); + } + + return {displayValue}; +} + +export default NumericCell; diff --git a/superset-frontend/src/components/Table/cell-renderers/fixtures.ts b/superset-frontend/src/components/Table/cell-renderers/fixtures.ts new file mode 100644 index 0000000000000..9b2070b0359bb --- /dev/null +++ b/superset-frontend/src/components/Table/cell-renderers/fixtures.ts @@ -0,0 +1,25 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * License); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * AS IS BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +export const exampleRow = { + key: 1, + buttonCell: 'Click Me', + textCell: 'Some text', + euroCell: 45.5, + dollarCell: 45.5, +}; diff --git a/superset-frontend/src/components/Table/index.tsx b/superset-frontend/src/components/Table/index.tsx new file mode 100644 index 0000000000000..d5f449c752875 --- /dev/null +++ b/superset-frontend/src/components/Table/index.tsx @@ -0,0 +1,326 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * License); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * AS IS BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React, { useState, useEffect, useRef, ReactElement } from 'react'; +import { Table as AntTable, ConfigProvider } from 'antd'; +import type { + ColumnType, + ColumnGroupType, + TableProps as AntTableProps, +} from 'antd/es/table'; +import { t, useTheme, logging } from '@superset-ui/core'; +import Loading from 'src/components/Loading'; +import styled, { StyledComponent } from '@emotion/styled'; +import InteractiveTableUtils from './utils/InteractiveTableUtils'; + +export const SUPERSET_TABLE_COLUMN = 'superset/table-column'; +export interface TableDataType { + key: React.Key; +} + +export declare type ColumnsType = ( + | ColumnGroupType + | ColumnType +)[]; + +export enum SelectionType { + 'DISABLED' = 'disabled', + 'SINGLE' = 'single', + 'MULTI' = 'multi', +} + +export interface Locale { + /** + * Text contained within the Table UI. + */ + filterTitle: string; + filterConfirm: string; + filterReset: string; + filterEmptyText: string; + filterCheckall: string; + filterSearchPlaceholder: string; + emptyText: string; + selectAll: string; + selectInvert: string; + selectNone: string; + selectionAll: string; + sortTitle: string; + expand: string; + collapse: string; + triggerDesc: string; + triggerAsc: string; + cancelSort: string; +} + +export interface TableProps extends AntTableProps { + /** + * Data that will populate the each row and map to the column key. + */ + data: object[]; + /** + * Table column definitions. + */ + columns: ColumnsType; + /** + * Array of row keys to represent list of selected rows. + */ + selectedRows?: React.Key[]; + /** + * Callback function invoked when a row is selected by user. + */ + handleRowSelection?: Function; + /** + * Controls the size of the table. + */ + size: TableSize; + /** + * Adjusts the padding around elements for different amounts of spacing between elements. + */ + selectionType?: SelectionType; + /* + * Places table in visual loading state. Use while waiting to retrieve data or perform an async operation that will update the table. + */ + loading?: boolean; + /** + * Uses a sticky header which always displays when vertically scrolling the table. Default: true + */ + sticky?: boolean; + /** + * Controls if columns are resizable by user. + */ + resizable?: boolean; + /** + * EXPERIMENTAL: Controls if columns are re-orderable by user drag drop. + */ + reorderable?: boolean; + /** + * Default number of rows table will display per page of data. + */ + defaultPageSize?: number; + /** + * Array of numeric options for the number of rows table will display per page of data. + * The user can select from these options in the page size drop down menu. + */ + pageSizeOptions?: string[]; + /** + * Set table to display no data even if data has been provided + */ + hideData?: boolean; + /** + * emptyComponent + */ + emptyComponent?: ReactElement; + /** + * Enables setting the text displayed in various components and tooltips within the Table UI. + */ + locale?: Locale; + /** + * Restricts the visible height of the table and allows for internal scrolling within the table + * when the number of rows exceeds the visible space. + */ + height?: number; +} + +export enum TableSize { + SMALL = 'small', + MIDDLE = 'middle', +} + +const defaultRowSelection: React.Key[] = []; +// This accounts for the tables header and pagination if user gives table instance a height. this is a temp solution +const HEIGHT_OFFSET = 108; + +const StyledTable: StyledComponent = styled(AntTable)` + ${({ theme, height }) => ` + .ant-table-body { + overflow: scroll; + height: ${height ? `${height - HEIGHT_OFFSET}px` : undefined}; + } + + th.ant-table-cell { + font-weight: ${theme.typography.weights.bold}; + color: ${theme.colors.grayscale.dark1}; + user-select: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .ant-pagination-item-active { + border-color: ${theme.colors.primary.base}; + } + `} +`; + +const defaultLocale = { + filterTitle: t('Filter menu'), + filterConfirm: t('OK'), + filterReset: t('Reset'), + filterEmptyText: t('No filters'), + filterCheckall: t('Select all items'), + filterSearchPlaceholder: t('Search in filters'), + emptyText: t('No data'), + selectAll: t('Select current page'), + selectInvert: t('Invert current page'), + selectNone: t('Clear all data'), + selectionAll: t('Select all data'), + sortTitle: t('Sort'), + expand: t('Expand row'), + collapse: t('Collapse row'), + triggerDesc: t('Click to sort descending'), + triggerAsc: t('Click to sort ascending'), + cancelSort: t('Click to cancel sorting'), +}; + +const selectionMap = {}; +selectionMap[SelectionType.MULTI] = 'checkbox'; +selectionMap[SelectionType.SINGLE] = 'radio'; +selectionMap[SelectionType.DISABLED] = null; + +export function Table(props: TableProps) { + const { + data, + columns, + selectedRows = defaultRowSelection, + handleRowSelection, + size, + selectionType = SelectionType.DISABLED, + sticky = true, + loading = false, + resizable = false, + reorderable = false, + defaultPageSize = 15, + pageSizeOptions = ['5', '15', '25', '50', '100'], + hideData = false, + emptyComponent, + locale, + ...rest + } = props; + + const wrapperRef = useRef(null); + const [derivedColumns, setDerivedColumns] = useState(columns); + const [pageSize, setPageSize] = useState(defaultPageSize); + const [mergedLocale, setMergedLocale] = useState({ ...defaultLocale }); + const [selectedRowKeys, setSelectedRowKeys] = + useState(selectedRows); + const interactiveTableUtils = useRef(null); + + const onSelectChange = (newSelectedRowKeys: React.Key[]) => { + setSelectedRowKeys(newSelectedRowKeys); + handleRowSelection?.(newSelectedRowKeys); + }; + + const selectionTypeValue = selectionMap[selectionType]; + const rowSelection = { + type: selectionTypeValue, + selectedRowKeys, + onChange: onSelectChange, + }; + + const renderEmpty = () => + emptyComponent ??
{mergedLocale.emptyText}
; + + // Log use of experimental features + useEffect(() => { + if (reorderable === true) { + logging.warn( + 'EXPERIMENTAL FEATURE ENABLED: The "reorderable" prop of Table is experimental and NOT recommended for use in production deployments.', + ); + } + if (resizable === true) { + logging.warn( + 'EXPERIMENTAL FEATURE ENABLED: The "resizable" prop of Table is experimental and NOT recommended for use in production deployments.', + ); + } + }, [reorderable, resizable]); + + useEffect(() => { + let updatedLocale; + if (locale) { + // This spread allows for locale to only contain a subset of locale overrides on props + updatedLocale = { ...defaultLocale, ...locale }; + } else { + updatedLocale = { ...defaultLocale }; + } + setMergedLocale(updatedLocale); + }, [locale]); + + useEffect(() => { + if (interactiveTableUtils.current) { + interactiveTableUtils.current?.clearListeners(); + } + const table = wrapperRef.current?.getElementsByTagName('table')[0]; + if (table) { + interactiveTableUtils.current = new InteractiveTableUtils( + table, + derivedColumns, + setDerivedColumns, + ); + if (reorderable) { + interactiveTableUtils?.current?.initializeDragDropColumns( + reorderable, + table, + ); + } + if (resizable) { + interactiveTableUtils?.current?.initializeResizableColumns( + resizable, + table, + ); + } + } + return () => { + interactiveTableUtils?.current?.clearListeners?.(); + }; + /** + * We DO NOT want this effect to trigger when derivedColumns changes as it will break functionality + * The exclusion from the effect dependencies is intentional and should not be modified + */ + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [wrapperRef, reorderable, resizable, interactiveTableUtils]); + + const theme = useTheme(); + + return ( + +
+ }} + hasData={hideData ? false : data} + rowSelection={selectionTypeValue ? rowSelection : undefined} + columns={derivedColumns} + dataSource={hideData ? [undefined] : data} + size={size} + sticky={sticky} + pagination={{ + hideOnSinglePage: true, + pageSize, + pageSizeOptions, + onShowSizeChange: (page: number, size: number) => setPageSize(size), + }} + showSorterTooltip={false} + locale={mergedLocale} + theme={theme} + /> +
+
+ ); +} + +export default Table; diff --git a/superset-frontend/src/components/Table/sorters.test.ts b/superset-frontend/src/components/Table/sorters.test.ts new file mode 100644 index 0000000000000..80bc0a20c42c5 --- /dev/null +++ b/superset-frontend/src/components/Table/sorters.test.ts @@ -0,0 +1,100 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { alphabeticalSort, numericalSort } from './sorters'; + +const rows = [ + { + name: 'Deathstar Lamp', + category: 'Lamp', + cost: 75.99, + }, + { + name: 'Desk Lamp', + category: 'Lamp', + cost: 15.99, + }, + { + name: 'Bedside Lamp', + category: 'Lamp', + cost: 15.99, + }, + { name: 'Drafting Desk', category: 'Desk', cost: 125 }, + { name: 'Sit / Stand Desk', category: 'Desk', cost: 275.99 }, +]; + +/** + * NOTE: Sorters for antd table use < 0, 0, > 0 for sorting + * -1 or less means the first item comes after the second item + * 0 means the items sort values is equivalent + * 1 or greater means the first item comes before the second item + */ +test('alphabeticalSort sorts correctly', () => { + expect(alphabeticalSort('name', rows[0], rows[1])).toBe(-1); + expect(alphabeticalSort('name', rows[1], rows[0])).toBe(1); + expect(alphabeticalSort('category', rows[1], rows[0])).toBe(0); +}); + +test('numericalSort sorts correctly', () => { + expect(numericalSort('cost', rows[1], rows[2])).toBe(0); + expect(numericalSort('cost', rows[1], rows[0])).toBeLessThan(0); + expect(numericalSort('cost', rows[4], rows[1])).toBeGreaterThan(0); +}); + +/** + * We want to make sure our sorters do not throw runtime errors given bad inputs. + * Runtime Errors in a sorter will cause a catastrophic React lifecycle error and produce white screen of death + * In the case the sorter cannot perform the comparison it should return undefined and the next sort step will proceed without error + */ +test('alphabeticalSort bad inputs no errors', () => { + // @ts-ignore + expect(alphabeticalSort('name', null, null)).toBe(undefined); + // incorrect non-object values + // @ts-ignore + expect(alphabeticalSort('name', 3, [])).toBe(undefined); + // incorrect object values without specificed key + expect(alphabeticalSort('name', {}, {})).toBe(undefined); + // Object as value for name when it should be a string + expect( + alphabeticalSort( + 'name', + { name: { title: 'the name attribute should not be an object' } }, + { name: 'Doug' }, + ), + ).toBe(undefined); +}); + +test('numericalSort bad inputs no errors', () => { + // @ts-ignore + expect(numericalSort('name', undefined, undefined)).toBe(NaN); + // @ts-ignore + expect(numericalSort('name', null, null)).toBe(NaN); + // incorrect non-object values + // @ts-ignore + expect(numericalSort('name', 3, [])).toBe(NaN); + // incorrect object values without specified key + expect(numericalSort('name', {}, {})).toBe(NaN); + // Object as value for name when it should be a string + expect( + numericalSort( + 'name', + { name: { title: 'the name attribute should not be an object' } }, + { name: 'Doug' }, + ), + ).toBe(NaN); +}); diff --git a/superset-frontend/src/components/Table/sorters.ts b/superset-frontend/src/components/Table/sorters.ts new file mode 100644 index 0000000000000..3f06071aacc69 --- /dev/null +++ b/superset-frontend/src/components/Table/sorters.ts @@ -0,0 +1,36 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * @param key The name of the row's attribute used to compare values for alphabetical sorting + * @param a First row object to compare + * @param b Second row object to compare + * @returns number + */ +export const alphabeticalSort = (key: string, a: object, b: object): number => + a?.[key]?.localeCompare?.(b?.[key]); + +/** + * @param key The name of the row's attribute used to compare values for numerical sorting + * @param a First row object to compare + * @param b Second row object to compare + * @returns number + */ +export const numericalSort = (key: string, a: object, b: object): number => + a?.[key] - b?.[key]; diff --git a/superset-frontend/src/components/Table/utils/InteractiveTableUtils.ts b/superset-frontend/src/components/Table/utils/InteractiveTableUtils.ts new file mode 100644 index 0000000000000..94977413e2cdc --- /dev/null +++ b/superset-frontend/src/components/Table/utils/InteractiveTableUtils.ts @@ -0,0 +1,233 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import type { ColumnsType } from 'antd/es/table'; +import { SUPERSET_TABLE_COLUMN } from 'src/components/Table'; +import { withinRange } from './utils'; + +interface IInteractiveColumn extends HTMLElement { + mouseDown: boolean; + oldX: number; + oldWidth: number; + draggable: boolean; +} +export default class InteractiveTableUtils { + tableRef: HTMLTableElement | null; + + columnRef: IInteractiveColumn | null; + + setDerivedColumns: Function; + + isDragging: boolean; + + resizable: boolean; + + reorderable: boolean; + + derivedColumns: ColumnsType; + + RESIZE_INDICATOR_THRESHOLD: number; + + constructor( + tableRef: HTMLTableElement, + derivedColumns: ColumnsType, + setDerivedColumns: Function, + ) { + this.setDerivedColumns = setDerivedColumns; + this.tableRef = tableRef; + this.isDragging = false; + this.RESIZE_INDICATOR_THRESHOLD = 8; + this.resizable = false; + this.reorderable = false; + this.derivedColumns = [...derivedColumns]; + document.addEventListener('mouseup', this.handleMouseup); + } + + clearListeners = () => { + document.removeEventListener('mouseup', this.handleMouseup); + this.initializeResizableColumns(false, this.tableRef); + this.initializeDragDropColumns(false, this.tableRef); + }; + + setTableRef = (table: HTMLTableElement) => { + this.tableRef = table; + }; + + getColumnIndex = (): number => { + let index = -1; + const parent = this.columnRef?.parentNode; + if (parent) { + index = Array.prototype.indexOf.call(parent.children, this.columnRef); + } + return index; + }; + + handleColumnDragStart = (ev: DragEvent): void => { + const target = ev?.currentTarget as IInteractiveColumn; + if (target) { + this.columnRef = target; + } + this.isDragging = true; + const index = this.getColumnIndex(); + const columnData = this.derivedColumns[index]; + const dragData = { index, columnData }; + ev?.dataTransfer?.setData(SUPERSET_TABLE_COLUMN, JSON.stringify(dragData)); + }; + + handleDragDrop = (ev: DragEvent): void => { + const data = ev.dataTransfer?.getData?.(SUPERSET_TABLE_COLUMN); + if (data) { + ev.preventDefault(); + const parent = (ev.currentTarget as HTMLElement) + ?.parentNode as HTMLElement; + const dropIndex = Array.prototype.indexOf.call( + parent.children, + ev.currentTarget, + ); + const dragIndex = this.getColumnIndex(); + const columnsCopy = [...this.derivedColumns]; + const removedItem = columnsCopy.slice(dragIndex, dragIndex + 1); + columnsCopy.splice(dragIndex, 1); + columnsCopy.splice(dropIndex, 0, removedItem[0]); + this.derivedColumns = [...columnsCopy]; + this.setDerivedColumns(columnsCopy); + } + }; + + allowDrop = (ev: DragEvent): void => { + ev.preventDefault(); + }; + + handleMouseDown = (event: MouseEvent) => { + const target = event?.currentTarget as IInteractiveColumn; + if (target) { + this.columnRef = target; + if ( + event && + withinRange( + event.offsetX, + target.offsetWidth, + this.RESIZE_INDICATOR_THRESHOLD, + ) + ) { + target.mouseDown = true; + target.oldX = event.x; + target.oldWidth = target.offsetWidth; + target.draggable = false; + } else if (this.reorderable) { + target.draggable = true; + } + } + }; + + handleMouseMove = (event: MouseEvent) => { + if (this.resizable === true && !this.isDragging) { + const target = event.currentTarget as IInteractiveColumn; + if ( + event && + withinRange( + event.offsetX, + target.offsetWidth, + this.RESIZE_INDICATOR_THRESHOLD, + ) + ) { + target.style.cursor = 'col-resize'; + } else { + target.style.cursor = 'default'; + } + + const column = this.columnRef; + if (column?.mouseDown) { + let width = column.oldWidth; + const diff = event.x - column.oldX; + if (column.oldWidth + (event.x - column.oldX) > 0) { + width = column.oldWidth + diff; + } + const colIndex = this.getColumnIndex(); + if (!Number.isNaN(colIndex)) { + const columnDef = { ...this.derivedColumns[colIndex] }; + columnDef.width = width; + this.derivedColumns[colIndex] = columnDef; + this.setDerivedColumns([...this.derivedColumns]); + } + } + } + }; + + handleMouseup = () => { + if (this.columnRef) { + this.columnRef.mouseDown = false; + this.columnRef.style.cursor = 'default'; + this.columnRef.draggable = false; + } + this.isDragging = false; + }; + + initializeResizableColumns = ( + resizable = false, + table: HTMLTableElement | null, + ) => { + this.tableRef = table; + const header: HTMLTableRowElement | undefined = this.tableRef?.rows?.[0]; + if (header) { + const { cells } = header; + const len = cells.length; + for (let i = 0; i < len; i += 1) { + const cell = cells[i]; + if (resizable === true) { + this.resizable = true; + cell.addEventListener('mousedown', this.handleMouseDown); + cell.addEventListener('mousemove', this.handleMouseMove, true); + } else { + this.resizable = false; + cell.removeEventListener('mousedown', this.handleMouseDown); + cell.removeEventListener('mousemove', this.handleMouseMove, true); + } + } + } + }; + + initializeDragDropColumns = ( + reorderable = false, + table: HTMLTableElement | null, + ) => { + this.tableRef = table; + const header: HTMLTableRowElement | undefined = this.tableRef?.rows?.[0]; + if (header) { + const { cells } = header; + const len = cells.length; + for (let i = 0; i < len; i += 1) { + const cell = cells[i]; + if (reorderable === true) { + this.reorderable = true; + cell.addEventListener('mousedown', this.handleMouseDown); + cell.addEventListener('dragover', this.allowDrop); + cell.addEventListener('dragstart', this.handleColumnDragStart); + cell.addEventListener('drop', this.handleDragDrop); + } else { + this.reorderable = false; + cell.draggable = false; + cell.removeEventListener('mousedown', this.handleMouseDown); + cell.removeEventListener('dragover', this.allowDrop); + cell.removeEventListener('dragstart', this.handleColumnDragStart); + cell.removeEventListener('drop', this.handleDragDrop); + } + } + } + }; +} diff --git a/superset-frontend/src/components/Table/utils/utils.test.ts b/superset-frontend/src/components/Table/utils/utils.test.ts new file mode 100644 index 0000000000000..eff50f1580fc0 --- /dev/null +++ b/superset-frontend/src/components/Table/utils/utils.test.ts @@ -0,0 +1,48 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { withinRange } from './utils'; + +test('withinRange supported positive numbers', () => { + // Valid inputs within range + expect(withinRange(50, 60, 16)).toBeTruthy(); + + // Valid inputs outside of range + expect(withinRange(40, 60, 16)).toBeFalsy(); +}); + +test('withinRange unsupported negative numbers', () => { + // Negative numbers not supported + expect(withinRange(65, 60, -16)).toBeFalsy(); + expect(withinRange(-60, -65, 16)).toBeFalsy(); + expect(withinRange(-60, -65, 16)).toBeFalsy(); + expect(withinRange(-60, 65, 16)).toBeFalsy(); +}); + +test('withinRange invalid inputs', () => { + // Invalid inputs should return falsy and not throw an error + // We need ts-ignore here to be able to pass invalid values and pass linting + // @ts-ignore + expect(withinRange(null, 60, undefined)).toBeFalsy(); + // @ts-ignore + expect(withinRange([], 'hello', {})).toBeFalsy(); + // @ts-ignore + expect(withinRange([], undefined, {})).toBeFalsy(); + // @ts-ignore + expect(withinRange([], 'hello', {})).toBeFalsy(); +}); diff --git a/superset-frontend/src/components/Table/utils/utils.ts b/superset-frontend/src/components/Table/utils/utils.ts new file mode 100644 index 0000000000000..5b4e4d13baa85 --- /dev/null +++ b/superset-frontend/src/components/Table/utils/utils.ts @@ -0,0 +1,40 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Method to check if a number is within inclusive range between a maximum value minus a threshold + * Invalid non numeric inputs will not error, but will return false + * + * @param value number coordinate to determine if it is within bounds of the targetCoordinate - threshold. Must be positive and less than maximum. + * @param maximum number max value for the test range. Must be positive and greater than value + * @param threshold number values to determine a range from maximum - threshold. Must be positive and greater than zero. + * @returns boolean + */ +export const withinRange = ( + value: number, + maximum: number, + threshold: number, +): boolean => { + let within = false; + const diff = maximum - value; + if (diff > 0 && diff <= threshold) { + within = true; + } + return within; +}; diff --git a/superset-frontend/src/components/atomic-design.png b/superset-frontend/src/components/atomic-design.png new file mode 100644 index 0000000000000..e44c5f34a54eb Binary files /dev/null and b/superset-frontend/src/components/atomic-design.png differ diff --git a/superset-frontend/src/dashboard/actions/dashboardState.js b/superset-frontend/src/dashboard/actions/dashboardState.js index 027288cdbc9b4..9bbe618c88da7 100644 --- a/superset-frontend/src/dashboard/actions/dashboardState.js +++ b/superset-frontend/src/dashboard/actions/dashboardState.js @@ -32,7 +32,10 @@ import { import { chart as initChart } from 'src/components/Chart/chartReducer'; import { applyDefaultFormData } from 'src/explore/store'; import { getClientErrorObject } from 'src/utils/getClientErrorObject'; -import { SAVE_TYPE_OVERWRITE } from 'src/dashboard/util/constants'; +import { + SAVE_TYPE_OVERWRITE, + SAVE_TYPE_OVERWRITE_CONFIRMED, +} from 'src/dashboard/util/constants'; import { addSuccessToast, addWarningToast, @@ -43,6 +46,8 @@ import serializeFilterScopes from 'src/dashboard/util/serializeFilterScopes'; import { getActiveFilters } from 'src/dashboard/util/activeDashboardFilters'; import { safeStringify } from 'src/utils/safeStringify'; import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags'; +import { logEvent } from 'src/logger/actions'; +import { LOG_ACTIONS_CONFIRM_OVERWRITE_DASHBOARD_METADATA } from 'src/logger/LogUtils'; import { UPDATE_COMPONENTS_PARENTS_LIST } from './dashboardLayout'; import { setChartConfiguration, @@ -56,6 +61,7 @@ import { updateDirectPathToFilter, } from './dashboardFilters'; import { SET_FILTER_CONFIG_COMPLETE } from './nativeFilters'; +import getOverwriteItems from '../util/getOverwriteItems'; export const SET_UNSAVED_CHANGES = 'SET_UNSAVED_CHANGES'; export function setUnsavedChanges(hasUnsavedChanges) { @@ -189,6 +195,14 @@ export function saveDashboardRequestSuccess(lastModifiedTime) { }; } +export const SET_OVERRIDE_CONFIRM = 'SET_OVERRIDE_CONFIRM'; +export function setOverrideConfirm(overwriteConfirmMetadata) { + return { + type: SET_OVERRIDE_CONFIRM, + overwriteConfirmMetadata, + }; +} + export function saveDashboardRequest(data, id, saveType) { return (dispatch, getState) => { dispatch({ type: UPDATE_COMPONENTS_PARENTS_LIST }); @@ -316,6 +330,7 @@ export function saveDashboardRequest(data, id, saveType) { ); dispatch(addSuccessToast(t('This dashboard was saved successfully.'))); + dispatch(setOverrideConfirm(undefined)); return response; }; @@ -335,34 +350,85 @@ export function saveDashboardRequest(data, id, saveType) { dispatch(addDangerToast(errorText)); }; - if (saveType === SAVE_TYPE_OVERWRITE) { + if ( + [SAVE_TYPE_OVERWRITE, SAVE_TYPE_OVERWRITE_CONFIRMED].includes(saveType) + ) { let chartConfiguration = {}; if (isFeatureEnabled(FeatureFlag.DASHBOARD_CROSS_FILTERS)) { chartConfiguration = handleChartConfiguration(); } - const updatedDashboard = { - certified_by: cleanedData.certified_by, - certification_details: cleanedData.certification_details, - css: cleanedData.css, - dashboard_title: cleanedData.dashboard_title, - slug: cleanedData.slug, - owners: cleanedData.owners, - roles: cleanedData.roles, - json_metadata: safeStringify({ - ...(cleanedData?.metadata || {}), - default_filters: safeStringify(serializedFilters), - filter_scopes: serializedFilterScopes, - chart_configuration: chartConfiguration, - }), - }; - - return SupersetClient.put({ - endpoint: `/api/v1/dashboard/${id}`, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(updatedDashboard), + const updatedDashboard = + saveType === SAVE_TYPE_OVERWRITE_CONFIRMED + ? data + : { + certified_by: cleanedData.certified_by, + certification_details: cleanedData.certification_details, + css: cleanedData.css, + dashboard_title: cleanedData.dashboard_title, + slug: cleanedData.slug, + owners: cleanedData.owners, + roles: cleanedData.roles, + json_metadata: safeStringify({ + ...(cleanedData?.metadata || {}), + default_filters: safeStringify(serializedFilters), + filter_scopes: serializedFilterScopes, + chart_configuration: chartConfiguration, + }), + }; + + const updateDashboard = () => + SupersetClient.put({ + endpoint: `/api/v1/dashboard/${id}`, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updatedDashboard), + }) + .then(response => onUpdateSuccess(response)) + .catch(response => onError(response)); + return new Promise((resolve, reject) => { + if ( + !isFeatureEnabled(FeatureFlag.CONFIRM_DASHBOARD_DIFF) || + saveType === SAVE_TYPE_OVERWRITE_CONFIRMED + ) { + // skip overwrite precheck + resolve(); + return; + } + + // precheck for overwrite items + SupersetClient.get({ + endpoint: `/api/v1/dashboard/${id}`, + }).then(response => { + const dashboard = response.json.result; + const overwriteConfirmItems = getOverwriteItems( + dashboard, + updatedDashboard, + ); + if (overwriteConfirmItems.length > 0) { + dispatch( + setOverrideConfirm({ + updatedAt: dashboard.changed_on, + updatedBy: dashboard.changed_by_name, + overwriteConfirmItems, + dashboardId: id, + data: updatedDashboard, + }), + ); + return reject(overwriteConfirmItems); + } + return resolve(); + }); }) - .then(response => onUpdateSuccess(response)) - .catch(response => onError(response)); + .then(updateDashboard) + .catch(overwriteConfirmItems => { + const errorText = t('Please confirm the overwrite values.'); + dispatch( + logEvent(LOG_ACTIONS_CONFIRM_OVERWRITE_DASHBOARD_METADATA, { + dashboard_id: id, + items: overwriteConfirmItems, + }), + ); + dispatch(addDangerToast(errorText)); + }); } // changing the data as the endpoint requires const copyData = { ...cleanedData }; diff --git a/superset-frontend/src/dashboard/actions/dashboardState.test.js b/superset-frontend/src/dashboard/actions/dashboardState.test.js index c985496f892d8..25aa54ed6638a 100644 --- a/superset-frontend/src/dashboard/actions/dashboardState.test.js +++ b/superset-frontend/src/dashboard/actions/dashboardState.test.js @@ -18,14 +18,21 @@ */ import sinon from 'sinon'; import { SupersetClient } from '@superset-ui/core'; +import { waitFor } from '@testing-library/react'; import { removeSliceFromDashboard, saveDashboardRequest, + SET_OVERRIDE_CONFIRM, } from 'src/dashboard/actions/dashboardState'; import { REMOVE_FILTER } from 'src/dashboard/actions/dashboardFilters'; +import * as featureFlags from 'src/featureFlags'; import { UPDATE_COMPONENTS_PARENTS_LIST } from 'src/dashboard/actions/dashboardLayout'; -import { DASHBOARD_GRID_ID } from 'src/dashboard/util/constants'; +import { + DASHBOARD_GRID_ID, + SAVE_TYPE_OVERWRITE, + SAVE_TYPE_OVERWRITE_CONFIRMED, +} from 'src/dashboard/util/constants'; import { filterId, sliceEntitiesForDashboard as sliceEntities, @@ -55,13 +62,32 @@ describe('dashboardState actions', () => { const newDashboardData = mockDashboardData; let postStub; + let getStub; + let putStub; + const updatedCss = '.updated_css_value {\n color: black;\n}'; + beforeEach(() => { postStub = sinon .stub(SupersetClient, 'post') .resolves('the value you want to return'); + getStub = sinon.stub(SupersetClient, 'get').resolves({ + json: { + result: { + ...mockDashboardData, + css: updatedCss, + }, + }, + }); + putStub = sinon.stub(SupersetClient, 'put').resolves({ + json: { + result: mockDashboardData, + }, + }); }); afterEach(() => { postStub.restore(); + getStub.restore(); + putStub.restore(); }); function setup(stateOverrides) { @@ -111,6 +137,58 @@ describe('dashboardState actions', () => { mockParentsList, ); }); + + describe('FeatureFlag.CONFIRM_DASHBOARD_DIFF', () => { + let isFeatureEnabledMock; + beforeEach(() => { + isFeatureEnabledMock = jest + .spyOn(featureFlags, 'isFeatureEnabled') + .mockImplementation(feature => feature === 'CONFIRM_DASHBOARD_DIFF'); + }); + + afterEach(() => { + isFeatureEnabledMock.mockRestore(); + }); + + it('dispatches SET_OVERRIDE_CONFIRM when an inspect value has diff', async () => { + const id = 192; + const { getState, dispatch } = setup(); + const thunk = saveDashboardRequest( + newDashboardData, + id, + SAVE_TYPE_OVERWRITE, + ); + thunk(dispatch, getState); + expect(getStub.callCount).toBe(1); + expect(postStub.callCount).toBe(0); + await waitFor(() => + expect(dispatch.getCall(1).args[0].type).toBe(SET_OVERRIDE_CONFIRM), + ); + expect( + dispatch.getCall(1).args[0].overwriteConfirmMetadata.dashboardId, + ).toBe(id); + }); + + it('should post dashboard data with after confirm the overwrite values', async () => { + const id = 192; + const { getState, dispatch } = setup(); + const confirmedDashboardData = { + ...newDashboardData, + css: updatedCss, + }; + const thunk = saveDashboardRequest( + confirmedDashboardData, + id, + SAVE_TYPE_OVERWRITE_CONFIRMED, + ); + thunk(dispatch, getState); + expect(getStub.callCount).toBe(0); + expect(postStub.callCount).toBe(0); + await waitFor(() => expect(putStub.callCount).toBe(1)); + const { body } = putStub.getCall(0).args[0]; + expect(body).toBe(JSON.stringify(confirmedDashboardData)); + }); + }); }); it('should dispatch removeFilter if a removed slice is a filter_box', () => { diff --git a/superset-frontend/src/dashboard/components/Header/index.jsx b/superset-frontend/src/dashboard/components/Header/index.jsx index c8b4c0cdde50b..ca3566345b6dd 100644 --- a/superset-frontend/src/dashboard/components/Header/index.jsx +++ b/superset-frontend/src/dashboard/components/Header/index.jsx @@ -56,6 +56,7 @@ import setPeriodicRunner, { import { FILTER_BOX_MIGRATION_STATES } from 'src/explore/constants'; import { PageHeaderWithActions } from 'src/components/PageHeaderWithActions'; import { DashboardEmbedModal } from '../DashboardEmbedControls'; +import OverwriteConfirm from '../OverwriteConfirm'; const uiOverrideRegistry = getUiOverrideRegistry(); @@ -696,6 +697,8 @@ class Header extends React.PureComponent { /> )} + + {userCanCurate && ( { + const { queryByText } = render(, { + useRedux: true, + store: mockStore({ dashboardState: {} }), + }); + expect(queryByText('Confirm overwrite')).not.toBeInTheDocument(); +}); + +test('renders confirm modal on overwriteConfirmMetadata is provided', async () => { + const { queryByText } = render(, { + useRedux: true, + store: mockStore({ + dashboardState: { + overwriteConfirmMetadata, + }, + }), + }); + await waitFor(() => + expect(queryByText('Confirm overwrite')).toBeInTheDocument(), + ); +}); diff --git a/superset-frontend/src/dashboard/components/OverwriteConfirm/OverwriteConfirmModal.test.tsx b/superset-frontend/src/dashboard/components/OverwriteConfirm/OverwriteConfirmModal.test.tsx new file mode 100644 index 0000000000000..751def6ebb65b --- /dev/null +++ b/superset-frontend/src/dashboard/components/OverwriteConfirm/OverwriteConfirmModal.test.tsx @@ -0,0 +1,90 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import thunk from 'redux-thunk'; +import configureStore from 'redux-mock-store'; +import fetchMock from 'fetch-mock'; +import { mockAllIsIntersecting } from 'react-intersection-observer/test-utils'; + +import { fireEvent, render, waitFor } from 'spec/helpers/testing-library'; +import { overwriteConfirmMetadata } from 'spec/fixtures/mockDashboardState'; +import OverwriteConfirmModal from './OverwriteConfirmModal'; + +const middlewares = [thunk]; +const mockStore = configureStore(middlewares); + +jest.mock('react-diff-viewer', () => () => ( +
+)); + +test('renders diff viewer when it contains overwriteConfirmMetadata', async () => { + const { queryByText, findAllByTestId } = render( + , + { + useRedux: true, + store: mockStore(), + }, + ); + expect(queryByText('Confirm overwrite')).toBeInTheDocument(); + const diffViewers = await findAllByTestId('mock-diff-viewer'); + expect(diffViewers).toHaveLength( + overwriteConfirmMetadata.overwriteConfirmItems.length, + ); +}); + +test('requests update dashboard api when save button is clicked', async () => { + const updateDashboardEndpoint = `glob:*/api/v1/dashboard/${overwriteConfirmMetadata.dashboardId}`; + fetchMock.put(updateDashboardEndpoint, { + id: overwriteConfirmMetadata.dashboardId, + last_modified_time: +new Date(), + result: overwriteConfirmMetadata.data, + }); + const store = mockStore({ + dashboardLayout: {}, + dashboardFilters: {}, + }); + const { findByTestId } = render( + , + { + useRedux: true, + store, + }, + ); + const saveButton = await findByTestId('overwrite-confirm-save-button'); + expect(fetchMock.calls(updateDashboardEndpoint)).toHaveLength(0); + fireEvent.click(saveButton); + expect(fetchMock.calls(updateDashboardEndpoint)).toHaveLength(0); + mockAllIsIntersecting(true); + fireEvent.click(saveButton); + await waitFor(() => + expect(fetchMock.calls(updateDashboardEndpoint)?.[0]?.[1]?.body).toEqual( + JSON.stringify(overwriteConfirmMetadata.data), + ), + ); + await waitFor(() => + expect(store.getActions()).toContainEqual({ + type: 'SET_OVERRIDE_CONFIRM', + overwriteConfirmMetadata: undefined, + }), + ); +}); diff --git a/superset-frontend/src/dashboard/components/OverwriteConfirm/OverwriteConfirmModal.tsx b/superset-frontend/src/dashboard/components/OverwriteConfirm/OverwriteConfirmModal.tsx new file mode 100644 index 0000000000000..32fea9b4fc140 --- /dev/null +++ b/superset-frontend/src/dashboard/components/OverwriteConfirm/OverwriteConfirmModal.tsx @@ -0,0 +1,209 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React, { useMemo, useCallback, RefObject, createRef } from 'react'; +import moment from 'moment'; +import { useDispatch } from 'react-redux'; +import ReactDiffViewer from 'react-diff-viewer'; +import { useInView } from 'react-intersection-observer'; +import Modal from 'src/components/Modal'; +import Button from 'src/components/Button'; +import { DashboardState } from 'src/dashboard/types'; +import { + saveDashboardRequest, + setOverrideConfirm, +} from 'src/dashboard/actions/dashboardState'; +import { t, styled } from '@superset-ui/core'; +import { SAVE_TYPE_OVERWRITE_CONFIRMED } from 'src/dashboard/util/constants'; + +const STICKY_HEADER_TOP = 16; +const STICKY_HEADER_HEIGHT = 32; + +const StyledTitle = styled.h2` + ${({ theme }) => ` + color: ${theme.colors.grayscale.dark1} + `} +`; + +const StyledEditor = styled.div` + ${({ theme }) => ` + table { + border: 1px ${theme.colors.grayscale.light2} solid; + } + pre { + font-size: 11px; + padding: 0px; + background-color: transparent; + border: 0px; + line-height: 110%; + } + `} +`; + +const StackableHeader = styled(Button)<{ top: number }>` + ${({ theme, top }) => ` + position: sticky; + top: ${top}px; + background-color: ${theme.colors.grayscale.light5}; + margin: 0px; + padding: 8px 4px; + z-index: 1; + border: 0px; + border-radius: 0px; + width: 100%; + justify-content: flex-start; + border-bottom: 1px ${theme.colors.grayscale.light1} solid; + &::before { + display: inline-block; + position: relative; + opacity: 1; + content: "\\00BB"; + } + `} +`; + +const StyledBottom = styled.div<{ inView: boolean }>` + ${({ inView }) => ` + margin: 8px auto; + text-align: center; + opacity: ${inView ? 0 : 1}; + `} +`; + +type Props = { + overwriteConfirmMetadata: DashboardState['overwriteConfirmMetadata']; +}; + +const OverrideConfirmModal = ({ overwriteConfirmMetadata }: Props) => { + const [bottomRef, hasReviewed] = useInView({ triggerOnce: true }); + const dispatch = useDispatch(); + const onHide = useCallback( + () => dispatch(setOverrideConfirm(undefined)), + [dispatch], + ); + const anchors = useMemo[]>( + () => + overwriteConfirmMetadata + ? overwriteConfirmMetadata.overwriteConfirmItems.map(() => + createRef(), + ) + : [], + [overwriteConfirmMetadata], + ); + const onAnchorClicked = useCallback( + (index: number) => { + anchors[index]?.current?.scrollIntoView({ behavior: 'smooth' }); + }, + [anchors], + ); + const onConfirmOverwrite = useCallback(() => { + if (overwriteConfirmMetadata) { + dispatch( + saveDashboardRequest( + overwriteConfirmMetadata.data, + overwriteConfirmMetadata.dashboardId, + SAVE_TYPE_OVERWRITE_CONFIRMED, + ), + ); + } + }, [dispatch, overwriteConfirmMetadata]); + + return ( + + {t('Scroll down to the bottom to enable overwriting changes. ')} + + + + } + onHide={onHide} + > + {overwriteConfirmMetadata && ( + <> + + {t('Are you sure you intend to overwrite the following values?')} + + + {overwriteConfirmMetadata.overwriteConfirmItems.map( + ({ keyPath, oldValue, newValue }, index) => ( + +
+ onAnchorClicked(index)} + > + {keyPath} + + + + ), + )} + + {/* Add submit button at the bottom in case of intersection-observer fallback */} + + + + + )} + + ); +}; + +export default OverrideConfirmModal; diff --git a/superset-frontend/src/dashboard/components/OverwriteConfirm/index.tsx b/superset-frontend/src/dashboard/components/OverwriteConfirm/index.tsx new file mode 100644 index 0000000000000..46e4f3ab50146 --- /dev/null +++ b/superset-frontend/src/dashboard/components/OverwriteConfirm/index.tsx @@ -0,0 +1,41 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import { useSelector } from 'react-redux'; +import AsyncEsmComponent from 'src/components/AsyncEsmComponent'; +import { DashboardState, RootState } from 'src/dashboard/types'; + +const Modal = AsyncEsmComponent(() => import('./OverwriteConfirmModal')); + +const OverrideConfirm = () => { + const overwriteConfirmMetadata = useSelector< + RootState, + DashboardState['overwriteConfirmMetadata'] + >(({ dashboardState }) => dashboardState.overwriteConfirmMetadata); + + return ( + <> + {overwriteConfirmMetadata && ( + + )} + + ); +}; + +export default OverrideConfirm; diff --git a/superset-frontend/src/dashboard/constants.ts b/superset-frontend/src/dashboard/constants.ts index ab9d9b5967a95..97753abe45c54 100644 --- a/superset-frontend/src/dashboard/constants.ts +++ b/superset-frontend/src/dashboard/constants.ts @@ -41,3 +41,4 @@ export const OPEN_FILTER_BAR_MAX_WIDTH = 550; export const FILTER_BAR_HEADER_HEIGHT = 80; export const FILTER_BAR_TABS_HEIGHT = 46; export const BUILDER_SIDEPANEL_WIDTH = 374; +export const OVERWRITE_INSPECT_FIELDS = ['css', 'json_metadata.filter_scopes']; diff --git a/superset-frontend/src/dashboard/reducers/dashboardState.js b/superset-frontend/src/dashboard/reducers/dashboardState.js index 14b35caef04a7..1c339001d17d6 100644 --- a/superset-frontend/src/dashboard/reducers/dashboardState.js +++ b/superset-frontend/src/dashboard/reducers/dashboardState.js @@ -42,6 +42,7 @@ import { ON_FILTERS_REFRESH, ON_FILTERS_REFRESH_SUCCESS, SET_DATASETS_STATUS, + SET_OVERRIDE_CONFIRM, } from '../actions/dashboardState'; import { HYDRATE_DASHBOARD } from '../actions/hydrate'; @@ -173,6 +174,12 @@ export default function dashboardStateReducer(state = {}, action) { activeTabs: Array.from(newActiveTabs), }; }, + [SET_OVERRIDE_CONFIRM]() { + return { + ...state, + overwriteConfirmMetadata: action.overwriteConfirmMetadata, + }; + }, [SET_FOCUSED_FILTER_FIELD]() { return { ...state, diff --git a/superset-frontend/src/dashboard/types.ts b/superset-frontend/src/dashboard/types.ts index 023120f62fbcd..b809f405ac026 100644 --- a/superset-frontend/src/dashboard/types.ts +++ b/superset-frontend/src/dashboard/types.ts @@ -77,6 +77,17 @@ export type DashboardState = { chartId: number; column: string; }; + overwriteConfirmMetadata?: { + updatedAt: string; + updatedBy: string; + overwriteConfirmItems: { + keyPath: string; + oldValue: string; + newValue: string; + }[]; + dashboardId: number; + data: JsonObject; + }; }; export type DashboardInfo = { id: number; diff --git a/superset-frontend/src/dashboard/util/constants.ts b/superset-frontend/src/dashboard/util/constants.ts index 640028eb4e947..0743d7a5a42d3 100644 --- a/superset-frontend/src/dashboard/util/constants.ts +++ b/superset-frontend/src/dashboard/util/constants.ts @@ -58,6 +58,7 @@ export const UNDO_LIMIT = 50; // save dash options export const SAVE_TYPE_OVERWRITE = 'overwrite'; +export const SAVE_TYPE_OVERWRITE_CONFIRMED = 'overwriteConfirmed'; export const SAVE_TYPE_NEWDASHBOARD = 'newDashboard'; // default dashboard layout data size limit diff --git a/superset-frontend/src/dashboard/util/getOverwriteItems.test.ts b/superset-frontend/src/dashboard/util/getOverwriteItems.test.ts new file mode 100644 index 0000000000000..e4328fb08897d --- /dev/null +++ b/superset-frontend/src/dashboard/util/getOverwriteItems.test.ts @@ -0,0 +1,57 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import getOverwriteItems from './getOverwriteItems'; + +test('returns diff items', () => { + const prevFilterScopes = { + filter1: { + scope: ['abc'], + immune: [], + }, + }; + const nextFilterScopes = { + scope: ['ROOT_ID'], + immune: ['efg'], + }; + + const prevValue = { + css: '', + json_metadata: JSON.stringify({ + filter_scopes: prevFilterScopes, + default_filters: {}, + }), + }; + + const nextValue = { + css: '.updated_css {color: white;}', + json_metadata: JSON.stringify({ + filter_scopes: nextFilterScopes, + default_filters: {}, + }), + }; + expect(getOverwriteItems(prevValue, nextValue)).toEqual([ + { keyPath: 'css', newValue: nextValue.css, oldValue: prevValue.css }, + { + keyPath: 'json_metadata.filter_scopes', + newValue: JSON.stringify(nextFilterScopes, null, 2), + oldValue: JSON.stringify(prevFilterScopes, null, 2), + }, + ]); +}); diff --git a/superset-frontend/src/dashboard/util/getOverwriteItems.ts b/superset-frontend/src/dashboard/util/getOverwriteItems.ts new file mode 100644 index 0000000000000..5301cb03af4de --- /dev/null +++ b/superset-frontend/src/dashboard/util/getOverwriteItems.ts @@ -0,0 +1,44 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { JsonObject } from '@superset-ui/core'; +import { OVERWRITE_INSPECT_FIELDS } from 'src/dashboard/constants'; + +const JSON_KEYS = new Set(['json_metadata', 'position_json']); + +function extractValue(object: JsonObject, keyPath: string) { + return keyPath.split('.').reduce((obj: JsonObject, key: string) => { + const value = obj?.[key]; + return JSON_KEYS.has(key) && value ? JSON.parse(value) : value; + }, object); +} + +export default function getOverwriteItems(prev: JsonObject, next: JsonObject) { + return OVERWRITE_INSPECT_FIELDS.map(keyPath => ({ + keyPath, + ...(keyPath.split('.').find(key => JSON_KEYS.has(key)) + ? { + oldValue: JSON.stringify(extractValue(prev, keyPath), null, 2) || '', + newValue: JSON.stringify(extractValue(next, keyPath), null, 2) || '', + } + : { + oldValue: extractValue(prev, keyPath) || '', + newValue: extractValue(next, keyPath) || '', + }), + })).filter(({ oldValue, newValue }) => oldValue !== newValue); +} diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/Option.test.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/Option.test.tsx index 744fe03a0955c..042cd73a763b1 100644 --- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/Option.test.tsx +++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/Option.test.tsx @@ -21,47 +21,53 @@ import { render, screen } from 'spec/helpers/testing-library'; import userEvent from '@testing-library/user-event'; import Option from 'src/explore/components/controls/DndColumnSelectControl/Option'; -test('renders with default props', () => { +test('renders with default props', async () => { const { container } = render( , ); expect(container).toBeInTheDocument(); - expect(screen.getByRole('img', { name: 'x-small' })).toBeInTheDocument(); + expect( + await screen.findByRole('img', { name: 'x-small' }), + ).toBeInTheDocument(); expect( screen.queryByRole('img', { name: 'caret-right' }), ).not.toBeInTheDocument(); }); -test('renders with caret', () => { +test('renders with caret', async () => { render( , ); - expect(screen.getByRole('img', { name: 'x-small' })).toBeInTheDocument(); - expect(screen.getByRole('img', { name: 'caret-right' })).toBeInTheDocument(); + expect( + await screen.findByRole('img', { name: 'x-small' }), + ).toBeInTheDocument(); + expect( + await screen.findByRole('img', { name: 'caret-right' }), + ).toBeInTheDocument(); }); -test('renders with extra triangle', () => { +test('renders with extra triangle', async () => { render( , ); expect( - screen.getByRole('button', { name: 'Show info tooltip' }), + await screen.findByRole('button', { name: 'Show info tooltip' }), ).toBeInTheDocument(); }); -test('triggers onClose', () => { +test('triggers onClose', async () => { const clickClose = jest.fn(); render( , ); - userEvent.click(screen.getByRole('img', { name: 'x-small' })); + userEvent.click(await screen.findByRole('img', { name: 'x-small' })); expect(clickClose).toHaveBeenCalled(); }); diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/OptionWrapper.test.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/OptionWrapper.test.tsx index e237cea989a5c..d7d362996fe1e 100644 --- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/OptionWrapper.test.tsx +++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/OptionWrapper.test.tsx @@ -21,7 +21,7 @@ import { render, screen, fireEvent } from 'spec/helpers/testing-library'; import { DndItemType } from 'src/explore/components/DndItemType'; import OptionWrapper from 'src/explore/components/controls/DndColumnSelectControl/OptionWrapper'; -test('renders with default props', () => { +test('renders with default props', async () => { const { container } = render( { { useDnd: true }, ); expect(container).toBeInTheDocument(); - expect(screen.getByRole('img', { name: 'x-small' })).toBeInTheDocument(); + expect( + await screen.findByRole('img', { name: 'x-small' }), + ).toBeInTheDocument(); }); -test('triggers onShiftOptions on drop', () => { +test('triggers onShiftOptions on drop', async () => { const onShiftOptions = jest.fn(); render( <> @@ -58,7 +60,7 @@ test('triggers onShiftOptions on drop', () => { { useDnd: true }, ); - fireEvent.dragStart(screen.getByText('Option 1')); - fireEvent.drop(screen.getByText('Option 2')); + fireEvent.dragStart(await screen.findByText('Option 1')); + fireEvent.drop(await screen.findByText('Option 2')); expect(onShiftOptions).toHaveBeenCalled(); }); diff --git a/superset-frontend/src/logger/LogUtils.ts b/superset-frontend/src/logger/LogUtils.ts index 986bde816fb69..60f0fe183301e 100644 --- a/superset-frontend/src/logger/LogUtils.ts +++ b/superset-frontend/src/logger/LogUtils.ts @@ -45,6 +45,8 @@ export const LOG_ACTIONS_DATASET_CREATION_TABLE_CANCELLATION = 'dataset_creation_table_cancellation'; export const LOG_ACTIONS_DATASET_CREATION_SUCCESS = 'dataset_creation_success'; export const LOG_ACTIONS_SPA_NAVIGATION = 'spa_navigation'; +export const LOG_ACTIONS_CONFIRM_OVERWRITE_DASHBOARD_METADATA = + 'confirm_overwrite_dashboard_metadata'; // Log event types -------------------------------------------------------------- export const LOG_EVENT_TYPE_TIMING = new Set([ @@ -64,6 +66,7 @@ export const LOG_EVENT_TYPE_USER = new Set([ LOG_ACTIONS_FORCE_REFRESH_DASHBOARD, LOG_ACTIONS_PERIODIC_RENDER_DASHBOARD, LOG_ACTIONS_MOUNT_EXPLORER, + LOG_ACTIONS_CONFIRM_OVERWRITE_DASHBOARD_METADATA, ]); export const LOG_EVENT_DATASET_TYPE_DATASET_CREATION = [ diff --git a/superset-frontend/src/types/files.d.ts b/superset-frontend/src/types/files.d.ts index c694d13cfbf22..c4f304b57f636 100644 --- a/superset-frontend/src/types/files.d.ts +++ b/superset-frontend/src/types/files.d.ts @@ -18,3 +18,4 @@ */ declare module '*.svg'; +declare module '*.gif'; diff --git a/superset-frontend/webpack.config.js b/superset-frontend/webpack.config.js index 10284097e8150..9994b1dd7911d 100644 --- a/superset-frontend/webpack.config.js +++ b/superset-frontend/webpack.config.js @@ -447,7 +447,7 @@ const config = { type: 'asset/resource', }, { - test: /\.(stories|story)\.mdx$/, + test: /\.mdx$/, use: [ { loader: 'babel-loader', diff --git a/superset/databases/api.py b/superset/databases/api.py index c3efda9501d4b..aced8e7c6faa3 100644 --- a/superset/databases/api.py +++ b/superset/databases/api.py @@ -19,7 +19,7 @@ import logging from datetime import datetime from io import BytesIO -from typing import Any, Dict, List, Optional +from typing import Any, cast, Dict, List, Optional from zipfile import is_zipfile, ZipFile from flask import request, Response, send_file @@ -611,7 +611,7 @@ def table_extra_metadata( self.incr_stats("init", self.table_metadata.__name__) parsed_schema = parse_js_uri_path_item(schema_name, eval_undefined=True) - table_name = parse_js_uri_path_item(table_name) # type: ignore + table_name = cast(str, parse_js_uri_path_item(table_name)) payload = database.db_engine_spec.extra_table_metadata( database, table_name, parsed_schema ) diff --git a/superset/db_engine_specs/base.py b/superset/db_engine_specs/base.py index d0ab4eb8bb871..1781ef7ef453d 100644 --- a/superset/db_engine_specs/base.py +++ b/superset/db_engine_specs/base.py @@ -1027,13 +1027,17 @@ def get_table_names( # pylint: disable=unused-argument schema: Optional[str], ) -> List[str]: """ - Get all tables from schema + Get all the real table names within the specified schema. - :param database: The database to get info - :param inspector: SqlAlchemy inspector - :param schema: Schema to inspect. If omitted, uses default schema for database - :return: All tables in schema + Per the SQLAlchemy definition if the schema is omitted the database’s default + schema is used, however some dialects infer the request as schema agnostic. + + :param database: The database to inspect + :param inspector: The SQLAlchemy inspector + :param schema: The schema to inspect + :returns: The physical table names """ + try: tables = inspector.get_table_names(schema) except Exception as ex: @@ -1051,13 +1055,17 @@ def get_view_names( # pylint: disable=unused-argument schema: Optional[str], ) -> List[str]: """ - Get all views from schema + Get all the view names within the specified schema. - :param database: The database to get info - :param inspector: SqlAlchemy inspector - :param schema: Schema name. If omitted, uses default schema for database - :return: All views in schema + Per the SQLAlchemy definition if the schema is omitted the database’s default + schema is used, however some dialects infer the request as schema agnostic. + + :param database: The database to inspect + :param inspector: The SQLAlchemy inspector + :param schema: The schema to inspect + :returns: The view names """ + try: views = inspector.get_view_names(schema) except Exception as ex: diff --git a/superset/db_engine_specs/druid.py b/superset/db_engine_specs/druid.py index 0fa47eeb54001..6cdc9f85e3ec2 100644 --- a/superset/db_engine_specs/druid.py +++ b/superset/db_engine_specs/druid.py @@ -19,7 +19,6 @@ from datetime import datetime from typing import Any, Dict, List, Optional, Type, TYPE_CHECKING -from sqlalchemy import types from sqlalchemy.engine.reflection import Inspector from superset import is_feature_enabled @@ -131,11 +130,6 @@ def get_columns( """ Update the Druid type map. """ - # pylint: disable=import-outside-toplevel - from pydruid.db.sqlalchemy import type_map - - type_map["complex"] = types.BLOB - return super().get_columns(inspector, table_name, schema) @classmethod diff --git a/superset/db_engine_specs/presto.py b/superset/db_engine_specs/presto.py index f8f5e1bc18b73..b513db0a61958 100644 --- a/superset/db_engine_specs/presto.py +++ b/superset/db_engine_specs/presto.py @@ -19,13 +19,13 @@ import logging import re -import textwrap import time from abc import ABCMeta from collections import defaultdict, deque from contextlib import closing from datetime import datetime from distutils.version import StrictVersion +from textwrap import dedent from typing import Any, cast, Dict, List, Optional, Pattern, Tuple, TYPE_CHECKING, Union from urllib import parse @@ -392,37 +392,74 @@ def update_impersonation_config( @classmethod def get_table_names( - cls, database: Database, inspector: Inspector, schema: Optional[str] + cls, + database: Database, + inspector: Inspector, + schema: Optional[str], ) -> List[str]: - tables = super().get_table_names(database, inspector, schema) - if not is_feature_enabled("PRESTO_SPLIT_VIEWS_FROM_TABLES"): - return tables + """ + Get all the real table names within the specified schema. + + Per the SQLAlchemy definition if the schema is omitted the database’s default + schema is used, however some dialects infer the request as schema agnostic. - views = set(cls.get_view_names(database, inspector, schema)) - actual_tables = set(tables) - views - return list(actual_tables) + Note that PyHive's Hive and Presto SQLAlchemy dialects do not adhere to the + specification where the `get_table_names` method returns both real tables and + views. Futhermore the dialects wrongfully infer the request as schema agnostic + when the schema is omitted. + + :param database: The database to inspect + :param inspector: The SQLAlchemy inspector + :param schema: The schema to inspect + :returns: The physical table names + """ + + return sorted( + list( + set(super().get_table_names(database, inspector, schema)) + - set(cls.get_view_names(database, inspector, schema)) + ) + ) @classmethod def get_view_names( - cls, database: Database, inspector: Inspector, schema: Optional[str] + cls, + database: Database, + inspector: Inspector, + schema: Optional[str], ) -> List[str]: - """Returns an empty list + """ + Get all the view names within the specified schema. + + Per the SQLAlchemy definition if the schema is omitted the database’s default + schema is used, however some dialects infer the request as schema agnostic. - get_table_names() function returns all table names and view names, - and get_view_names() is not implemented in sqlalchemy_presto.py - https://github.com/dropbox/PyHive/blob/e25fc8440a0686bbb7a5db5de7cb1a77bdb4167a/pyhive/sqlalchemy_presto.py + Note that PyHive's Hive and Presto SQLAlchemy dialects do not implement the + `get_view_names` method. To ensure consistency with the `get_table_names` method + the request is deemed schema agnostic when the schema is omitted. + + :param database: The database to inspect + :param inspector: The SQLAlchemy inspector + :param schema: The schema to inspect + :returns: The view names """ - if not is_feature_enabled("PRESTO_SPLIT_VIEWS_FROM_TABLES"): - return [] if schema: - sql = ( - "SELECT table_name FROM information_schema.views " - "WHERE table_schema=%(schema)s" - ) + sql = dedent( + """ + SELECT table_name FROM information_schema.tables + WHERE table_schema = %(schema)s + AND table_type = 'VIEW' + """ + ).strip() params = {"schema": schema} else: - sql = "SELECT table_name FROM information_schema.views" + sql = dedent( + """ + SELECT table_name FROM information_schema.tables + WHERE table_type = 'VIEW' + """ + ).strip() params = {} with cls.get_engine(database, schema=schema) as engine: @@ -431,7 +468,7 @@ def get_view_names( cursor.execute(sql, params) results = cursor.fetchall() - return [row[0] for row in results] + return sorted([row[0] for row in results]) @classmethod def _create_column_info( @@ -1087,7 +1124,7 @@ def _partition_query( # pylint: disable=too-many-arguments,too-many-locals else f"SHOW PARTITIONS FROM {table_name}" ) - sql = textwrap.dedent( + sql = dedent( f"""\ {partition_select_clause} {where_clause} diff --git a/tests/integration_tests/db_engine_specs/hive_tests.py b/tests/integration_tests/db_engine_specs/hive_tests.py index 7fc96e23f7ce5..366648effa988 100644 --- a/tests/integration_tests/db_engine_specs/hive_tests.py +++ b/tests/integration_tests/db_engine_specs/hive_tests.py @@ -150,10 +150,6 @@ def test_hive_error_msg(): ) -def test_hive_get_view_names_return_empty_list(): # pylint: disable=invalid-name - assert HiveEngineSpec.get_view_names(mock.ANY, mock.ANY, mock.ANY) == [] - - def test_convert_dttm(): dttm = datetime.strptime("2019-01-02 03:04:05.678900", "%Y-%m-%d %H:%M:%S.%f") assert HiveEngineSpec.convert_dttm("DATE", dttm) == "CAST('2019-01-02' AS DATE)" diff --git a/tests/integration_tests/db_engine_specs/presto_tests.py b/tests/integration_tests/db_engine_specs/presto_tests.py index 2363b1c8741b9..a38617e8a9a85 100644 --- a/tests/integration_tests/db_engine_specs/presto_tests.py +++ b/tests/integration_tests/db_engine_specs/presto_tests.py @@ -15,6 +15,7 @@ # specific language governing permissions and limitations # under the License. from collections import namedtuple +from textwrap import dedent from unittest import mock, skipUnless import pandas as pd @@ -33,49 +34,48 @@ class TestPrestoDbEngineSpec(TestDbEngineSpec): def test_get_datatype_presto(self): self.assertEqual("STRING", PrestoEngineSpec.get_datatype("string")) - def test_presto_get_view_names_return_empty_list( - self, - ): # pylint: disable=invalid-name - self.assertEqual( - [], PrestoEngineSpec.get_view_names(mock.ANY, mock.ANY, mock.ANY) - ) - - @mock.patch("superset.db_engine_specs.presto.is_feature_enabled") - def test_get_view_names(self, mock_is_feature_enabled): - mock_is_feature_enabled.return_value = True - mock_execute = mock.MagicMock() - mock_fetchall = mock.MagicMock(return_value=[["a", "b,", "c"], ["d", "e"]]) + def test_get_view_names_with_schema(self): database = mock.MagicMock() + mock_execute = mock.MagicMock() database.get_sqla_engine_with_context.return_value.__enter__.return_value.raw_connection.return_value.cursor.return_value.execute = ( mock_execute ) - database.get_sqla_engine_with_context.return_value.__enter__.return_value.raw_connection.return_value.cursor.return_value.fetchall = ( - mock_fetchall + database.get_sqla_engine_with_context.return_value.__enter__.return_value.raw_connection.return_value.cursor.return_value.fetchall = mock.MagicMock( + return_value=[["a", "b,", "c"], ["d", "e"]] ) - result = PrestoEngineSpec.get_view_names(database, mock.Mock(), None) + + schema = "schema" + result = PrestoEngineSpec.get_view_names(database, mock.Mock(), schema) mock_execute.assert_called_once_with( - "SELECT table_name FROM information_schema.views", {} + dedent( + """ + SELECT table_name FROM information_schema.tables + WHERE table_schema = %(schema)s + AND table_type = 'VIEW' + """ + ).strip(), + {"schema": schema}, ) assert result == ["a", "d"] - @mock.patch("superset.db_engine_specs.presto.is_feature_enabled") - def test_get_view_names_with_schema(self, mock_is_feature_enabled): - mock_is_feature_enabled.return_value = True - mock_execute = mock.MagicMock() - mock_fetchall = mock.MagicMock(return_value=[["a", "b,", "c"], ["d", "e"]]) + def test_get_view_names_without_schema(self): database = mock.MagicMock() + mock_execute = mock.MagicMock() database.get_sqla_engine_with_context.return_value.__enter__.return_value.raw_connection.return_value.cursor.return_value.execute = ( mock_execute ) - database.get_sqla_engine_with_context.return_value.__enter__.return_value.raw_connection.return_value.cursor.return_value.fetchall = ( - mock_fetchall + database.get_sqla_engine_with_context.return_value.__enter__.return_value.raw_connection.return_value.cursor.return_value.fetchall = mock.MagicMock( + return_value=[["a", "b,", "c"], ["d", "e"]] ) - schema = "schema" - result = PrestoEngineSpec.get_view_names(database, mock.Mock(), schema) + result = PrestoEngineSpec.get_view_names(database, mock.Mock(), None) mock_execute.assert_called_once_with( - "SELECT table_name FROM information_schema.views " - "WHERE table_schema=%(schema)s", - {"schema": schema}, + dedent( + """ + SELECT table_name FROM information_schema.tables + WHERE table_type = 'VIEW' + """ + ).strip(), + {}, ) assert result == ["a", "d"] @@ -663,50 +663,17 @@ def test_get_sqla_column_type(self): sqla_type = PrestoEngineSpec.get_sqla_column_type(None) assert sqla_type is None - @mock.patch( - "superset.utils.feature_flag_manager.FeatureFlagManager.is_feature_enabled" - ) @mock.patch("superset.db_engine_specs.base.BaseEngineSpec.get_table_names") @mock.patch("superset.db_engine_specs.presto.PrestoEngineSpec.get_view_names") - def test_get_table_names_no_split_views_from_tables( - self, mock_get_view_names, mock_get_table_names, mock_is_feature_enabled - ): - mock_get_view_names.return_value = ["view1", "view2"] - table_names = ["table1", "table2", "view1", "view2"] - mock_get_table_names.return_value = table_names - mock_is_feature_enabled.return_value = False - tables = PrestoEngineSpec.get_table_names(mock.Mock(), mock.Mock(), None) - assert tables == table_names - - @mock.patch( - "superset.utils.feature_flag_manager.FeatureFlagManager.is_feature_enabled" - ) - @mock.patch("superset.db_engine_specs.base.BaseEngineSpec.get_table_names") - @mock.patch("superset.db_engine_specs.presto.PrestoEngineSpec.get_view_names") - def test_get_table_names_split_views_from_tables( - self, mock_get_view_names, mock_get_table_names, mock_is_feature_enabled + def test_get_table_names( + self, + mock_get_view_names, + mock_get_table_names, ): mock_get_view_names.return_value = ["view1", "view2"] - table_names = ["table1", "table2", "view1", "view2"] - mock_get_table_names.return_value = table_names - mock_is_feature_enabled.return_value = True - tables = PrestoEngineSpec.get_table_names(mock.Mock(), mock.Mock(), None) - assert sorted(tables) == sorted(table_names) - - @mock.patch( - "superset.utils.feature_flag_manager.FeatureFlagManager.is_feature_enabled" - ) - @mock.patch("superset.db_engine_specs.base.BaseEngineSpec.get_table_names") - @mock.patch("superset.db_engine_specs.presto.PrestoEngineSpec.get_view_names") - def test_get_table_names_split_views_from_tables_no_tables( - self, mock_get_view_names, mock_get_table_names, mock_is_feature_enabled - ): - mock_get_view_names.return_value = [] - table_names = [] - mock_get_table_names.return_value = table_names - mock_is_feature_enabled.return_value = True + mock_get_table_names.return_value = ["table1", "table2", "view1", "view2"] tables = PrestoEngineSpec.get_table_names(mock.Mock(), mock.Mock(), None) - assert tables == [] + assert tables == ["table1", "table2"] def test_get_full_name(self): names = [