Compare commits

...

38 Commits

Author SHA1 Message Date
Maxime Beauchemin
7a7c12be4c 0.27.0 2018-08-21 13:16:10 -07:00
Maxime Beauchemin
ef096da839 0.27.0rc1 2018-08-12 10:03:03 -07:00
Maxime Beauchemin
29c97661fc linting 2018-08-12 09:51:33 -07:00
Maxime Beauchemin
ac1ad5ccdd [sqllab] fix unexpected keyword argument 'ignore_nan' (#5490) 2018-08-12 09:48:58 -07:00
Maxime Beauchemin
8c158b6f5e Apply SQL_QUERY_MUTATOR to explore & dashboard (#5493)
* Apply SQL_QUERY_MUTATOR kn explore & dashboard

* Add unit test

(cherry picked from commit 94cb20cf96)
2018-08-12 09:48:58 -07:00
Maxime Beauchemin
9db1d9b87d [sql lab] fix Hive 'Transport' not open issue (#5494) 2018-08-12 09:48:58 -07:00
Maxime Beauchemin
ae26b369b1 [sql lab] extract Hive error messages (#5495)
* [sql lab] extract Hive error messages

So pyhive returns an exception object with a stringified thrift error
object. This PR uses a regex to extract the errorMessage portion of that
string.

* Unit test

(cherry picked from commit 41286b7545)
2018-08-12 09:48:58 -07:00
Maxime Beauchemin
d7485dbcb0 [bugfix] make MetricsControl work with DECK visualizations (#5376)
* [bugfix] make MetricsControl work with DECK visualizations

* Add unit tests
2018-08-12 09:48:41 -07:00
Maxime Beauchemin
2be25329b2 Add row_limit control to line chart (#5426)
Somehow it's not possible to set the row_limit for a line chart,
resulting to a 10k cap in our environment. In some cases the user may
want more than that.
2018-08-12 09:44:38 -07:00
Maxime Beauchemin
ba116a85c1 Clarify title when importing a table (#5454)
The flow to import a table definition in Superset is confusing, user may
think they are creating a table or what not. This makes the flow a bit
more clear.
2018-08-12 09:44:38 -07:00
Maxime Beauchemin
ccc68baffd [big_number] tooltip shows in the wrong place (#5404) 2018-08-12 09:44:38 -07:00
Grace Guo
d4afa40e66 [Table Viz] columns not match with group_by control (#5329) 2018-08-12 09:44:38 -07:00
Hugh A. Miles II
ac81848150 Add IS NOT NULL and IS NULL as filter options (#5375)
* wip

* disable value selector on IS NOT NULL or NOT NULL

* remove this
2018-08-12 09:44:38 -07:00
Maxime Beauchemin
b9a6373295 [pie] improvements to pie charts (#5236)
Pie chart was using `limit` instead of `row_limit` which didn't work.

Also set a lower default limit to 25 to prevent the chart from getting
super crowded.

Unrelated drive-by fix of setting "percentage metric" to default to `[]`
2018-08-12 09:44:38 -07:00
Maxime Beauchemin
7bc24b35ee Set control 'percent_metrics's default to [] (#5357) 2018-08-12 09:44:38 -07:00
John Bodley
73b7e87da3 [get_df] Fix datetime conversion (#5274) 2018-08-12 09:44:38 -07:00
Maxime Beauchemin
c8c49d1ac0 react-grid-layout: 0.16.5 2018-08-12 09:43:23 -07:00
Hugh A. Miles II
bda64018ab Set ignore NaN as true for TableViz (#5371)
* set ignore NaN as true for TableViz

* linting
2018-08-12 09:43:23 -07:00
Maxime Beauchemin
2243e73a97 [bugfix] README encoding-related UnicodeDecodeError on setup.py (#5309)
Seeing UnicodeDecodeError on our build system running py3.6, though I
couldn't reproduce on my local 3.6. This fix addresses the issue.
2018-08-12 09:42:57 -07:00
Grace Guo
8bad987ea4 [dashboard] should use forceV2Edit property name (#5362) 2018-08-12 09:41:43 -07:00
Grace Guo
67b13867b2 [dashboard] Fix save issue at Force_V2_Edit mode (#5360) 2018-08-12 09:41:43 -07:00
Chris Williams
53661db1ff [dashboard v2] add MissingChart component in the case that chart component has no slice definition, add tests. (#5296) 2018-08-12 09:41:43 -07:00
Grace Guo
5e98523125 [dashobard fix]: fix validation check for default_filters (#5297) 2018-08-12 09:41:43 -07:00
Grace Guo
ea13f4fb0d [dashboard fix] force refresh charts under tabs (#5291) 2018-08-12 09:41:43 -07:00
Chris Williams
8397ad05be fix sqllab <Loading /> css, fix double AddSliceCard margin and drag border (#5293)
* fix sqllab <Loading /> css, fix double AddSliceCard margin and drag border

* [dashboard v2] improve cached visual indicator, add last fetched messaging

* [dashboard v2] fix ctrl + cmd + z + UndoRedoKeylisteners
2018-08-12 09:41:43 -07:00
Chris Williams
10357ba58b [wip] dashboard builder v2 (#4528)
* [dashboard builder] Add dir structure for dashboard/v2, simplified Header, split pane, Draggable side panel

[grid] add <DashboardGrid />, <ResizableContainer />, and initial grid components.

[grid] gridComponents/ directory, add fixtures/ directory and test layout, add <Column />

[grid] working grid with gutters

[grid] design tweaks and polish, add <Tabs />

[header] add gradient header logo and favicon

[dnd] begin adding dnd functionality

[dnd] add util/isValidChild.js

[react-beautiful-dnd] iterate on dnd until blocked

[dnd] refactor to use react-dnd

[react-dnd] refactor to use composable <DashboardComponent /> structure

[dnd] factor out DashboardComponent, let components render dropInidcator and set draggableRef, add draggable tabs

[dnd] refactor to use redux, add DashboardComponent and DashboardGrid containers

[dragdroppable] rename horizontal/vertical => row/column

[builder] refactor into HoverMenu, add WithPopoverMenu

[builder] add editable header and disableDragDrop prop for Dragdroppable's

[builder] make tabs editable

[builder] add generic popover dropdown and header row style editability

[builder] add hover rowStyle dropdown, make row styles editable

[builder] add some new component icons, add popover with delete to charts

[builder] add preview icons, add popover menu to rows.

[builder] add IconButton and RowStyleDropdown

[resizable] use ResizableContainer instead of DimensionProvider, fix resize and delete bugs

[builder] fix bug with spacer

[builder] clean up, header.size => header.headerSize

[builder] support more drag/drop combinations by wrapping some components in rows upon drop. fix within list drop index. refactor some utils.

[builder][tabs] fix broken add tab button

[dashboard builder] don't pass dashboard layout to all dashboard components, improve drop indicator logic, fix delete component pure component bug

[dnd] refactor drop position logic

* fix rebase error, clean up css organization and use @less vars

* [dashboard-builder] add top-level tabs + undo-redo (#4626)

* [top-level-tabs] initial working version of top-level tabs

* [top-level-tabs] simplify redux and disable ability to displace top-level tabs with other tabs

* [top-level-tabs] improve tab drag and drop css

* [undo-redo] add redux undo redo

* [dnd] clean up dropResult shape, add new component source id + type, use css for drop indicator instead of styles and fix tab indicators.

* [top-level-tabs] add 'Collapse tab content' to delete tabs button

* [dnd] add depth validation to drag and drop logic

* [dashboard-builder] add resize action, enforce minimum width of columns, column children inherit column size when necessary, meta.rowStyle => meta.background, add background to columns

* [dashboard-builder] make sure getChildWidth returns a number

* [dashboard builder] static layout + toasts (#4763)

* [dashboard-builder] remove spacer component

* [dashboard-builder] better transparent indicator, better grid gutter logic, no dragging top-level tabs, headers are multiples of grid unit, fix row height granularity, update redux state key dashboard => dashboardLayout

* [dashboard-builder] don't blast column child dimensions on resize

* [dashboard-builder] ResizableContainer min size can't be smaller than size, fix row style, role=none on WithPopoverMenu container

* [edit mode] add edit mode to redux and propogate to all <DashboardComponent />s

* [toasts] add Toast component, ToastPresenter container and component, and toast redux actions + reducers

* [dashboard-builder] add info toast when dropResult overflows parent

* [dashboard builder] git mv to src/ post-rebase

* Dashboard builder rebased + linted (#4849)

* define dashboard redux state

* update dashboard state reducer

* dashboard layout converter + grid render

* builder pane + slice adder

* Dashboard header + slice header controls

* fix linting

* 2nd code review comments

* [dashboard builder] improve perf (#4855)

* address major perf + css issues

[dashboard builder] fix dashboard filters and some css

[dashboard builder] use VIZ_TYPES, move stricter .eslintrc to dashboard/, more css fixes

[builder] delete GridCell and GridLayout, remove some unused css. fix broken tabs.

* [builder] fix errors post-rebase

* [builder] add support for custom DragDroppable drag layer and add AddSliceDragPreview

* [AddSliceDragPreview] fix type check

* [dashboard builder] add prettier and update all files

* [dashboard builder] merge v2/ directory int dashboard/

* [dashboard builder] move component/*Container => containers/*

* add sticky tabs + sidepane, better tabs perf, better container hierarchy, better chart header (#4893)

* dashboard header, slice header UI improvement

* add slider and sticky

* dashboard header, slice header UI improvement

* make builder pane floating

* [dashboard builder] add sticky top-level tabs, refactor for performant tabs

* [dashboard builder] visually distinct containers, icons for undo-redo, fix some isValidChild bugs

* [dashboard builder] better undo redo <> save changes state, notify upon reaching undo limit

* [dashboard builder] hook up edit + create component actions to saved-state pop.

* [dashboard builder] visual refinement, refactor Dashboard header content and updates into layout for undo-redo, refactor save dashboard modal to use toasts instead of notify.

* [dashboard builder] refactor chart name update logic to use layout for undo redo, save slice name changes on dashboard save

* add slider and sticky

* [dashboard builder] fix layout converter slice_id + chartId type casting, don't change grid size upon edit (perf)

* [dashboard builder] don't set version key in getInitialState

* [dashboard builder] make top level tabs addition/removal undoable, fix double sticky tabs + side panel.

* [dashboard builder] fix sticky tabs offset bug

* [dashboard builder] fix drag preview width, css polish, fix rebase issue

* [dashboard builder] fix side pane labels and hove z-index

* Markdown for dashboard (#4962)

* fix dashboard server-side unit tests (#5009)

* Dashboard save button (#4979)

* save button

* fix slices list height

* save custom css

* merge save-dash changes from dashboard v1
https://github.com/apache/incubator-superset/pull/4900
https://github.com/apache/incubator-superset/pull/5051

* [dashboard v2] check for default_filters before json_loads-ing them (#5064)

[dashboard v2] check for default_filters before json-loads-ing them

* [dashboard v2] fix bugs from rebase

* [dashboard v2] tests! (#5066)

* [dashboard v2][tests] add tests for newComponentFactory, isValidChild, dropOverflowsParent, and dnd-reorder

* [dashboard v2][tests] add tests for componentIsResizable, findParentId, getChartIdsFromLayout, newEntitiesFromDrop, and getDropPosition

* [dashboard v2][tests] add mockStore, mockState, and tests for DragDroppable, DashboardBuilder, DashboardGrid, ToastPresenter, and Toast

* [dashboard builder][tests] separate files for state tree fixtures, add ChartHolder, Chart, Divider, Header, Row tests and WithDragDropContext helper

* [dashboard v2][tests] fix dragdrop context with util/getDragDropManager, add test for menu/* and resizable/*, and new components

* [dashboard v2][tests] fix and re-write Dashboard tests, add getFormDataWithExtraFilters_spec

* [dashboard v2][tests] add reducer tests, fix lint error

* [dashboard-v2][tests] add actions/dashboardLayout_spec

* [dashboard v2] fix some prop bugs, open side pane on edit, fix slice name bug

* [dashboard v2] fix slice name save bug

* [dashboard v2] fix lint errors

* [dashboard v2] fix filters bug and add test

* [dashboard v2] fix getFormDataWithExtraFilters_spec

* [dashboard v2] logging updates (#5087)

* [dashboard v2] initial logging refactor

* [dashboard v2] clean up logger

* [logger] update explore with new log events, add refresh dashboard + refresh dashboard chart actions

* [logging] add logger_spec.js, fix reducers/dashboardState_spec + gridComponents/Chart_spec

* [dashboard v2][logging] refactor for bulk logging in python

* [logging] tweak python, fix and remove dup start_offset entries

* [dashboard v2][logging] add dashboard_first_load event

* [dashboard v2][logging] add slice_ids to dashboard pane load event

* [tests] fix npm test script

* Fix: update slices list when add/remove multiple slices (#5138)

* [dashboard v2] add v1 switch (#5126)

* [dashboard] copy all dashboard v1 into working v1 switch

* [dashboard] add functional v1 <> v2 switch with messaging

* [dashboard] add v2 logging to v1 dashboard, add read-v2-changes link, add client logging to track v1 <> v2 switches

* [dashboard] Remove default values for feedback url + v2 auto convert date

* [dashboard v2] fix misc UI/UX issues

* [dashboard v2] fix Markdown persistance issues and css, fix copy dash title, don't enforce shallow hovering with drop indicator

* [dashboard v2] improve non-shallow drop target UX, fix Markdown drop indicator, clarify slice adder filter/sort

* [dashboard v2] delete empty rows on drag or delete events that leave them without children, add test

* [dashboard v2] improve v1<>v2 switch modals, add convert to v2 badge in v1, fix unsaved changes issue in preview mode, don't auto convert column child widths for now

* [dashboard v2][dnd] add drop position cache to fix non-shallow drops

* [dashboard] fix test script with glob instead of recurse, fix tests, add temp fix for tab nesting, ignore v1 lint errors

* [dashboard] v2 badge style tweaks, add back v1 _set_dash_metadata for v1 editing

* [dashboard] fix python linting and tests

* [dashboard] lint tests

* add slice from explore view (#5141)

* Fix dashboard position row data (#5131)

* add slice_name to markdown

(cherry picked from commit 14b01f1)

* set min grid width be 1 column

* remove empty column

* check total columns count <= 12

* scan position data and fix rows

* fix dashboard url with default_filters

* [dashboard v2]  better grid drop ux, fix tab bugs 🐛 (#5151)

* [dashboard v2] add empty droptarget to dashboard grid for better ux and update test

* [dashboard] reset tab index upon top-level tab deletion, fix findparentid bug

* [dashboard] update v1<>v2 modal link for tracking

* Fix: Should pass slice_can_edit flag down (#5159)

* [dash builder fix] combine markdown and slice name, slice picker height (#5165)

* combine markdown code and markdown slice name

* allow dynamic height for slice picker cell

* add word break for long datasource name

* [fix] new dashboard state (#5213)

* [dashboard v2] ui + ux fixes (#5208)

* [dashboard v2] use <Loading /> throughout, small loading gif, improve row/column visual hierarchy, add cached data pop

* [dashboard v2] lots of polish

* [dashboard v2] remove markdown padding on edit, more opaque slice drag preview, unsavedChanges=true upon moving a component, fix initial load logging.

* [dashboard v2] gray loading.gif, sticky header, undo/redo keyboard shortcuts, fix move component saved changes update, v0 double scrollbar fix

* [dashboard v2] move UndoRedoKeylisteners into Header, render only in edit mode, show visual feedback for keyboard shortcut, hide hover menu in top-level tabs

* [dashboard v2] fix grid + sidepane height issues

* [dashboard v2] add auto-resize functionality, update tests. cache findParentId results.

* [dashboard v2][tests] add getDetailedComponentWidth_spec.js

* [dashboard v2] fix lint

* [fix] layout converter fix (#5218)

* [fix] layout converter fix

* add changed_on into initial sliceEntity data

* add unit tests for SliceAdder component

* remove old fixtures file

* [dashboard v2] remove webpack-cli, fresh yarn.lock post-rebase

* [dashboard v2] lint javascript

* [dashboard v2] fix python tests

* [Fix] import/export dash in V2 (#5273)

* [dashboard v2] add markdown tests (#5275)

* [dashboard v2] add Markdown tests

* [dashboard v2][mocks] fix markdown mock

(cherry picked from commit c065319508)
2018-08-12 09:41:36 -07:00
Maxime Beauchemin
b25c14da09 0.26.3 2018-07-05 09:47:37 -04:00
Maxime Beauchemin
721230098d 0.26.2 2018-07-05 09:37:44 -04:00
Maxime Beauchemin
988465c379 0.26.1 2018-07-04 17:16:24 -04:00
Riccardo Magliocchetti
dddcb141db A couple of setup.py fixes (#5338)
* setup: fix long description read in python2

* setup: fix git_get_sha in python3

Fix #5317

(cherry picked from commit 81bd5cc4c3)
2018-07-04 17:15:38 -04:00
Maxime Beauchemin
112b67a01b 0.26.0 2018-07-03 10:13:49 -04:00
Hugh A. Miles II
2a19b4baaa [DeckGL] Raise error with null values (#5302)
* raise errors with null values

* linting

* linting some more

* use get

* change ordering

* linting

(cherry picked from commit 089037f1aa)
2018-07-03 10:13:22 -04:00
Maxime Beauchemin
226b4fb5f4 [bugfix] README encoding-related UnicodeDecodeError on setup.py (#5309)
Seeing UnicodeDecodeError on our build system running py3.6, though I
couldn't reproduce on my local 3.6. This fix addresses the issue.

(cherry picked from commit 885d7791a0)
2018-07-03 10:13:13 -04:00
Maxime Beauchemin
fae7a146f2 0.26.0rc2 2018-06-27 21:40:43 -07:00
Maxime Beauchemin
9119091af7 Improve database type inference (#4724)
* Improve database type inference

Python's DBAPI isn't super clear and homogeneous on the
cursor.description specification, and this PR attempts to improve
inferring the datatypes returned in the cursor.

This work started around Presto's TIMESTAMP type being mishandled as
string as the database driver (pyhive) returns it as a string. The work
here fixes this bug and does a better job at inferring MySQL and Presto types.
It also creates a new method in db_engine_specs allowing for other
databases engines to implement and become more precise on type-inference
as needed.

* Fixing tests

* Adressing comments

* Using infer_objects

* Removing faulty line

* Addressing PrestoSpec redundant method comment

* Fix rebase issue

* Fix tests

(cherry picked from commit 777d876a52)
2018-06-27 21:40:14 -07:00
Jeffrey Wang
af74c1b8bb Pin boto3 to 1.4.7 (#5290)
(cherry picked from commit fb988fee2e)
2018-06-27 21:40:01 -07:00
timifasubaa
7bf8920b64 add more precise types to hive table from csv (#5267)
(cherry picked from commit b0eee129e9)
2018-06-27 21:39:48 -07:00
timifasubaa
bc6819a2ee specify hve namespace for tables (#5268)
(cherry picked from commit bd24f854c9)
2018-06-27 21:39:41 -07:00
287 changed files with 20919 additions and 5434 deletions

View File

@@ -34,6 +34,7 @@ six==1.11.0
sqlalchemy==1.2.2
sqlalchemy-utils==0.32.21
sqlparse==0.2.4
tableschema==1.1.0
thrift==0.11.0
thrift-sasl==0.3.0
unicodecsv==0.14.1

View File

@@ -4,6 +4,7 @@ from __future__ import division
from __future__ import print_function
from __future__ import unicode_literals
import io
import json
import os
import subprocess
@@ -16,14 +17,14 @@ PACKAGE_FILE = os.path.join(PACKAGE_DIR, 'package.json')
with open(PACKAGE_FILE) as package_file:
version_string = json.load(package_file)['version']
with open('README.md') as readme:
long_description = readme.read()
with io.open('README.md', encoding='utf-8') as f:
long_description = f.read()
def get_git_sha():
try:
s = str(subprocess.check_output(['git', 'rev-parse', 'HEAD']))
return s.strip()
s = subprocess.check_output(['git', 'rev-parse', 'HEAD'])
return s.decode().strip()
except Exception:
return ''
@@ -47,6 +48,7 @@ setup(
description=(
'A modern, enterprise-ready business intelligence web application'),
long_description=long_description,
long_description_content_type='text/markdown',
version=version_string,
packages=find_packages(),
include_package_data=True,
@@ -54,7 +56,7 @@ setup(
scripts=['superset/bin/superset'],
install_requires=[
'bleach',
'boto3>=1.4.6',
'boto3==1.4.7',
'botocore>=1.7.0, <1.8.0',
'celery>=4.2.0',
'colorama',
@@ -90,6 +92,7 @@ setup(
'sqlalchemy',
'sqlalchemy-utils',
'sqlparse',
'tableschema',
'thrift>=0.9.3',
'thrift-sasl>=0.2.1',
'unicodecsv',

View File

@@ -8,3 +8,4 @@ node_modules*/*
stylesheets/*
vendor/*
docs/*
src/dashboard/deprecated/*

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 77 KiB

View File

@@ -1,6 +1,6 @@
{
"name": "superset",
"version": "0.999.0dev",
"version": "0.27.0",
"description": "Superset is a data exploration platform designed to be visual, intuitive, and interactive.",
"license": "Apache-2.0",
"directories": {
@@ -8,8 +8,8 @@
"test": "spec"
},
"scripts": {
"test": "mocha --require ignore-styles --compilers js:babel-core/register --require spec/helpers/browser.js --recursive spec/**/*_spec.*",
"cover": "babel-node node_modules/.bin/babel-istanbul cover _mocha -- --require ignore-styles spec/helpers/browser.js --recursive spec/**/*_spec.*",
"test": "mocha --require ignore-styles --compilers js:babel-core/register --require spec/helpers/browser.js 'spec/**/*_spec.*'",
"cover": "babel-node node_modules/.bin/babel-istanbul cover _mocha -- --require ignore-styles spec/helpers/browser.js 'spec/**/*_spec.*'",
"dev": "NODE_ENV=dev webpack --watch --colors --progress --debug --output-pathinfo --devtool eval-cheap-source-map",
"dev-slow": "NODE_ENV=dev webpack --watch --colors --progress --debug --output-pathinfo --devtool inline-source-map",
"dev-fast": "echo 'dev-fast in now replaced by dev'",
@@ -41,9 +41,9 @@
},
"homepage": "http://superset.apache.org/",
"dependencies": {
"//": "known issues with react-bootstrap>=0.32",
"@data-ui/event-flow": "^0.0.54",
"@data-ui/sparkline": "^0.0.54",
"@vx/responsive": "0.0.153",
"babel-register": "^6.24.1",
"bootstrap": "^3.3.6",
"bootstrap-slider": "^10.0.0",
@@ -61,6 +61,7 @@
"deck.gl": "^5.1.4",
"deep-equal": "^1.0.1",
"distributions": "^1.0.0",
"dnd-core": "^2.6.0",
"dompurify": "^1.0.3",
"fastdom": "^1.0.6",
"geojson-extent": "^0.3.2",
@@ -83,8 +84,9 @@
"parse-iso-duration": "^1.0.0",
"po2json": "^0.4.5",
"prop-types": "^15.6.0",
"re-resizable": "^4.3.1",
"react": "^15.6.2",
"react-ace": "^5.0.1",
"react-ace": "^5.10.0",
"react-addons-css-transition-group": "^15.6.0",
"react-addons-shallow-compare": "^15.4.2",
"react-alert": "^2.3.0",
@@ -93,16 +95,21 @@
"react-bootstrap-table": "^4.3.1",
"react-color": "^2.13.8",
"react-datetime": "2.14.0",
"react-dnd": "^2.5.4",
"react-dnd-html5-backend": "^2.5.4",
"react-dom": "^15.6.2",
"react-gravatar": "^2.6.1",
"react-grid-layout": "0.16.6",
"react-grid-layout": "0.16.5",
"react-map-gl": "^3.0.4",
"react-markdown": "^3.3.0",
"react-redux": "^5.0.2",
"react-resizable": "^1.3.3",
"react-search-input": "^0.11.3",
"react-select": "1.2.1",
"react-select-fast-filter-options": "^0.2.1",
"react-sortable-hoc": "^0.8.3",
"react-split-pane": "^0.1.66",
"react-sticky": "^6.0.2",
"react-syntax-highlighter": "^7.0.4",
"react-virtualized": "9.19.1",
"react-virtualized-select": "^2.4.0",
@@ -110,14 +117,14 @@
"redux": "^3.5.2",
"redux-localstorage": "^0.4.1",
"redux-thunk": "^2.1.0",
"redux-undo": "^1.0.0-beta9-9-7",
"shortid": "^2.2.6",
"sprintf-js": "^1.1.1",
"srcdoc-polyfill": "^1.0.0",
"supercluster": "https://github.com/georgeke/supercluster/tarball/ac3492737e7ce98e07af679623aad452373bbc40",
"underscore": "^1.8.3",
"urijs": "^1.18.10",
"viewport-mercator-project": "^5.0.0",
"webpack-cli": "^2.1.4"
"viewport-mercator-project": "^5.0.0"
},
"devDependencies": {
"babel-cli": "^6.14.0",
@@ -133,8 +140,10 @@
"enzyme": "^2.0.0",
"eslint": "^4.19.0",
"eslint-config-airbnb": "^15.0.1",
"eslint-config-prettier": "^2.9.0",
"eslint-plugin-import": "^2.2.0",
"eslint-plugin-jsx-a11y": "^5.1.1",
"eslint-plugin-prettier": "^2.6.0",
"eslint-plugin-react": "^7.0.1",
"exports-loader": "^0.7.0",
"extract-text-webpack-plugin": "3.0.2",
@@ -149,6 +158,7 @@
"less-loader": "^4.0.3",
"mocha": "^3.2.0",
"npm-check-updates": "^2.14.0",
"prettier": "^1.12.1",
"react-addons-test-utils": "^15.6.2",
"react-test-renderer": "^15.6.2",
"redux-mock-store": "^1.2.3",

View File

@@ -10,6 +10,8 @@ const exposedProperties = ['window', 'navigator', 'document'];
global.jsdom = jsdom.jsdom;
global.document = global.jsdom('<!doctype html><html><body></body></html>');
global.window = document.defaultView;
global.HTMLElement = window.HTMLElement;
Object.keys(document.defaultView).forEach((property) => {
if (typeof global[property] === 'undefined') {
exposedProperties.push(property);
@@ -38,5 +40,5 @@ global.sinon.useFakeXMLHttpRequest();
global.window.XMLHttpRequest = global.XMLHttpRequest;
global.window.location = { href: 'about:blank' };
global.window.performance = { now: () => (new Date().getTime()) };
global.window.performance = { now: () => new Date().getTime() };
global.$ = require('jquery')(global.window);

View File

@@ -20,7 +20,7 @@ describe('Chart', () => {
};
const mockedProps = {
...chart,
chartKey: 'slice_223',
id: 223,
containerId: 'slice-container-223',
datasource: {},
formData: {},

View File

@@ -0,0 +1,33 @@
{
"extends": "prettier",
"plugins": ["prettier"],
"rules": {
"prefer-template": 2,
"new-cap": 2,
"no-restricted-syntax": 2,
"guard-for-in": 2,
"prefer-arrow-callback": 2,
"func-names": 2,
"react/jsx-no-bind": 2,
"no-confusing-arrow": 2,
"jsx-a11y/no-static-element-interactions": 2,
"jsx-a11y/anchor-has-content": 2,
"react/require-default-props": 2,
"no-plusplus": 2,
"no-mixed-operators": 0,
"no-continue": 2,
"no-bitwise": 2,
"no-undef": 2,
"no-multi-assign": 2,
"no-restricted-properties": 2,
"no-prototype-builtins": 2,
"jsx-a11y/href-no-hash": 2,
"class-methods-use-this": 2,
"import/no-named-as-default": 2,
"import/prefer-default-export": 2,
"react/no-unescaped-entities": 2,
"react/no-string-refs": 2,
"react/jsx-indent": 0,
"prettier/prettier": "error"
}
}

View File

@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

View File

@@ -1,182 +0,0 @@
import React from 'react';
import { shallow } from 'enzyme';
import { describe, it } from 'mocha';
import { expect } from 'chai';
import sinon from 'sinon';
import * as dashboardActions from '../../../src/dashboard/actions';
import * as chartActions from '../../../src/chart/chartAction';
import Dashboard from '../../../src/dashboard/components/Dashboard';
import { defaultFilters, dashboard, charts } from './fixtures';
describe('Dashboard', () => {
const mockedProps = {
actions: { ...chartActions, ...dashboardActions },
initMessages: [],
dashboard: dashboard.dashboard,
slices: charts,
filters: dashboard.filters,
datasources: dashboard.datasources,
refresh: false,
timeout: 60,
isStarred: false,
userId: dashboard.userId,
};
it('should render', () => {
const wrapper = shallow(<Dashboard {...mockedProps} />);
expect(wrapper.find('#dashboard-container')).to.have.length(1);
expect(wrapper.instance().getAllSlices()).to.have.length(3);
});
it('should handle metadata default_filters', () => {
const wrapper = shallow(<Dashboard {...mockedProps} />);
expect(wrapper.instance().props.filters).deep.equal(defaultFilters);
});
describe('getFormDataExtra', () => {
let wrapper;
let selectedSlice;
beforeEach(() => {
wrapper = shallow(<Dashboard {...mockedProps} />);
selectedSlice = wrapper.instance().props.dashboard.slices[1];
});
it('should carry default_filters', () => {
const extraFilters = wrapper.instance().getFormDataExtra(selectedSlice).extra_filters;
expect(extraFilters[0]).to.deep.equal({ col: 'region', op: 'in', val: [] });
expect(extraFilters[1]).to.deep.equal({ col: 'country_name', op: 'in', val: ['United States'] });
});
it('should carry updated filter', () => {
wrapper.setProps({
filters: {
256: { region: [] },
257: { country_name: ['France'] },
},
});
const extraFilters = wrapper.instance().getFormDataExtra(selectedSlice).extra_filters;
expect(extraFilters[1]).to.deep.equal({ col: 'country_name', op: 'in', val: ['France'] });
});
});
describe('refreshExcept', () => {
let wrapper;
let spy;
beforeEach(() => {
wrapper = shallow(<Dashboard {...mockedProps} />);
spy = sinon.spy(wrapper.instance(), 'fetchSlices');
});
afterEach(() => {
spy.restore();
});
it('should not refresh filter slice', () => {
const filterKey = Object.keys(defaultFilters)[1];
wrapper.instance().refreshExcept(filterKey);
expect(spy.callCount).to.equal(1);
expect(spy.getCall(0).args[0].length).to.equal(1);
});
it('should refresh all slices', () => {
wrapper.instance().refreshExcept();
expect(spy.callCount).to.equal(1);
expect(spy.getCall(0).args[0].length).to.equal(3);
});
});
describe('componentDidUpdate', () => {
let wrapper;
let refreshExceptSpy;
let fetchSlicesStub;
let prevProp;
beforeEach(() => {
wrapper = shallow(<Dashboard {...mockedProps} />);
prevProp = wrapper.instance().props;
refreshExceptSpy = sinon.spy(wrapper.instance(), 'refreshExcept');
fetchSlicesStub = sinon.stub(wrapper.instance(), 'fetchSlices');
});
afterEach(() => {
fetchSlicesStub.restore();
refreshExceptSpy.restore();
});
describe('should check if filter has change', () => {
beforeEach(() => {
refreshExceptSpy.reset();
});
it('no change', () => {
wrapper.setProps({
refresh: true,
filters: {
256: { region: [] },
257: { country_name: ['United States'] },
},
});
wrapper.instance().componentDidUpdate(prevProp);
expect(refreshExceptSpy.callCount).to.equal(0);
});
it('remove filter', () => {
wrapper.setProps({
refresh: true,
filters: {
256: { region: [] },
},
});
wrapper.instance().componentDidUpdate(prevProp);
expect(refreshExceptSpy.callCount).to.equal(1);
});
it('change filter', () => {
wrapper.setProps({
refresh: true,
filters: {
256: { region: [] },
257: { country_name: ['Canada'] },
},
});
wrapper.instance().componentDidUpdate(prevProp);
expect(refreshExceptSpy.callCount).to.equal(1);
});
it('add filter', () => {
wrapper.setProps({
refresh: true,
filters: {
256: { region: [] },
257: { country_name: ['Canada'] },
258: { another_filter: ['new'] },
},
});
wrapper.instance().componentDidUpdate(prevProp);
expect(refreshExceptSpy.callCount).to.equal(1);
});
});
it('should refresh if refresh flag is true', () => {
wrapper.setProps({
refresh: true,
filters: {
256: { region: ['Asian'] },
},
});
wrapper.instance().componentDidUpdate(prevProp);
const fetchArgs = fetchSlicesStub.lastCall.args[0];
expect(fetchArgs).to.have.length(2);
});
it('should not refresh filter_immune_slices', () => {
wrapper.setProps({
refresh: true,
filters: {
256: { region: [] },
257: { country_name: ['Canada'] },
},
});
wrapper.instance().componentDidUpdate(prevProp);
const fetchArgs = fetchSlicesStub.lastCall.args[0];
expect(fetchArgs).to.have.length(1);
});
});
});

View File

@@ -0,0 +1,454 @@
import { describe, it } from 'mocha';
import { expect } from 'chai';
import sinon from 'sinon';
import { ActionCreators as UndoActionCreators } from 'redux-undo';
import {
UPDATE_COMPONENTS,
updateComponents,
DELETE_COMPONENT,
deleteComponent,
CREATE_COMPONENT,
CREATE_TOP_LEVEL_TABS,
createTopLevelTabs,
DELETE_TOP_LEVEL_TABS,
deleteTopLevelTabs,
resizeComponent,
MOVE_COMPONENT,
handleComponentDrop,
updateDashboardTitle,
undoLayoutAction,
redoLayoutAction,
} from '../../../../src/dashboard/actions/dashboardLayout';
import { setUnsavedChanges } from '../../../../src/dashboard/actions/dashboardState';
import { addInfoToast } from '../../../../src/dashboard/actions/messageToasts';
import {
DASHBOARD_GRID_TYPE,
ROW_TYPE,
CHART_TYPE,
TABS_TYPE,
TAB_TYPE,
} from '../../../../src/dashboard/util/componentTypes';
import {
DASHBOARD_HEADER_ID,
DASHBOARD_GRID_ID,
DASHBOARD_ROOT_ID,
NEW_COMPONENTS_SOURCE_ID,
NEW_ROW_ID,
} from '../../../../src/dashboard/util/constants';
describe('dashboardLayout actions', () => {
const mockState = {
dashboardState: {
hasUnsavedChanges: true, // don't dispatch setUnsavedChanges() after every action
},
dashboardInfo: {},
dashboardLayout: {
past: [],
present: {},
future: {},
},
};
function setup(stateOverrides) {
const state = { ...mockState, ...stateOverrides };
const getState = sinon.spy(() => state);
const dispatch = sinon.spy();
return { getState, dispatch, state };
}
describe('updateComponents', () => {
it('should dispatch an updateLayout action', () => {
const { getState, dispatch } = setup();
const nextComponents = { 1: {} };
const thunk = updateComponents(nextComponents);
thunk(dispatch, getState);
expect(dispatch.callCount).to.equal(1);
expect(dispatch.getCall(0).args[0]).to.deep.equal({
type: UPDATE_COMPONENTS,
payload: { nextComponents },
});
});
it('should dispatch a setUnsavedChanges action if hasUnsavedChanges=false', () => {
const { getState, dispatch } = setup({
dashboardState: { hasUnsavedChanges: false },
});
const nextComponents = { 1: {} };
const thunk = updateComponents(nextComponents);
thunk(dispatch, getState);
expect(dispatch.callCount).to.equal(2);
expect(dispatch.getCall(1).args[0]).to.deep.equal(
setUnsavedChanges(true),
);
});
});
describe('deleteComponents', () => {
it('should dispatch an deleteComponent action', () => {
const { getState, dispatch } = setup();
const thunk = deleteComponent('id', 'parentId');
thunk(dispatch, getState);
expect(dispatch.callCount).to.equal(1);
expect(dispatch.getCall(0).args[0]).to.deep.equal({
type: DELETE_COMPONENT,
payload: { id: 'id', parentId: 'parentId' },
});
});
it('should dispatch a setUnsavedChanges action if hasUnsavedChanges=false', () => {
const { getState, dispatch } = setup({
dashboardState: { hasUnsavedChanges: false },
});
const thunk = deleteComponent('id', 'parentId');
thunk(dispatch, getState);
expect(dispatch.callCount).to.equal(2);
expect(dispatch.getCall(1).args[0]).to.deep.equal(
setUnsavedChanges(true),
);
});
});
describe('updateDashboardTitle', () => {
it('should dispatch an updateComponent action for the header component', () => {
const { getState, dispatch } = setup();
const thunk1 = updateDashboardTitle('new text');
thunk1(dispatch, getState);
const thunk2 = dispatch.getCall(0).args[0];
thunk2(dispatch, getState);
expect(dispatch.getCall(1).args[0]).to.deep.equal({
type: UPDATE_COMPONENTS,
payload: {
nextComponents: {
[DASHBOARD_HEADER_ID]: {
meta: { text: 'new text' },
},
},
},
});
expect(dispatch.callCount).to.equal(2);
});
});
describe('createTopLevelTabs', () => {
it('should dispatch a createTopLevelTabs action', () => {
const { getState, dispatch } = setup();
const dropResult = {};
const thunk = createTopLevelTabs(dropResult);
thunk(dispatch, getState);
expect(dispatch.callCount).to.equal(1);
expect(dispatch.getCall(0).args[0]).to.deep.equal({
type: CREATE_TOP_LEVEL_TABS,
payload: { dropResult },
});
});
it('should dispatch a setUnsavedChanges action if hasUnsavedChanges=false', () => {
const { getState, dispatch } = setup({
dashboardState: { hasUnsavedChanges: false },
});
const dropResult = {};
const thunk = createTopLevelTabs(dropResult);
thunk(dispatch, getState);
expect(dispatch.callCount).to.equal(2);
expect(dispatch.getCall(1).args[0]).to.deep.equal(
setUnsavedChanges(true),
);
});
});
describe('deleteTopLevelTabs', () => {
it('should dispatch a deleteTopLevelTabs action', () => {
const { getState, dispatch } = setup();
const dropResult = {};
const thunk = deleteTopLevelTabs(dropResult);
thunk(dispatch, getState);
expect(dispatch.callCount).to.equal(1);
expect(dispatch.getCall(0).args[0]).to.deep.equal({
type: DELETE_TOP_LEVEL_TABS,
payload: {},
});
});
it('should dispatch a setUnsavedChanges action if hasUnsavedChanges=false', () => {
const { getState, dispatch } = setup({
dashboardState: { hasUnsavedChanges: false },
});
const dropResult = {};
const thunk = deleteTopLevelTabs(dropResult);
thunk(dispatch, getState);
expect(dispatch.callCount).to.equal(2);
expect(dispatch.getCall(1).args[0]).to.deep.equal(
setUnsavedChanges(true),
);
});
});
describe('resizeComponent', () => {
const dashboardLayout = {
...mockState.dashboardLayout,
present: {
1: {
id: 1,
children: [],
meta: {
width: 1,
height: 1,
},
},
},
};
it('should update the size of the component', () => {
const { getState, dispatch } = setup({
dashboardLayout,
});
const thunk1 = resizeComponent({ id: 1, width: 10, height: 3 });
thunk1(dispatch, getState);
const thunk2 = dispatch.getCall(0).args[0];
thunk2(dispatch, getState);
expect(dispatch.callCount).to.equal(2);
expect(dispatch.getCall(1).args[0]).to.deep.equal({
type: UPDATE_COMPONENTS,
payload: {
nextComponents: {
1: {
id: 1,
children: [],
meta: {
width: 10,
height: 3,
},
},
},
},
});
expect(dispatch.callCount).to.equal(2);
});
it('should dispatch a setUnsavedChanges action if hasUnsavedChanges=false', () => {
const { getState, dispatch } = setup({
dashboardState: { hasUnsavedChanges: false },
dashboardLayout,
});
const thunk1 = resizeComponent({ id: 1, width: 10, height: 3 });
thunk1(dispatch, getState);
const thunk2 = dispatch.getCall(0).args[0];
thunk2(dispatch, getState);
expect(dispatch.callCount).to.equal(3);
});
});
describe('handleComponentDrop', () => {
it('should create a component if it is new', () => {
const { getState, dispatch } = setup();
const dropResult = {
source: { id: NEW_COMPONENTS_SOURCE_ID },
destination: { id: DASHBOARD_GRID_ID, type: DASHBOARD_GRID_TYPE },
dragging: { id: NEW_ROW_ID, type: ROW_TYPE },
};
const handleComponentDropThunk = handleComponentDrop(dropResult);
handleComponentDropThunk(dispatch, getState);
const createComponentThunk = dispatch.getCall(0).args[0];
createComponentThunk(dispatch, getState);
expect(dispatch.getCall(1).args[0]).to.deep.equal({
type: CREATE_COMPONENT,
payload: {
dropResult,
},
});
expect(dispatch.callCount).to.equal(2);
});
it('should move a component if the component is not new', () => {
const { getState, dispatch } = setup({
dashboardLayout: {
// if 'dragging' is not only child will dispatch deleteComponent thunk
present: { id: { type: ROW_TYPE, children: ['_'] } },
},
});
const dropResult = {
source: { id: 'id', index: 0, type: ROW_TYPE },
destination: { id: DASHBOARD_GRID_ID, type: DASHBOARD_GRID_TYPE },
dragging: { id: 'dragging', type: ROW_TYPE },
};
const handleComponentDropThunk = handleComponentDrop(dropResult);
handleComponentDropThunk(dispatch, getState);
const moveComponentThunk = dispatch.getCall(0).args[0];
moveComponentThunk(dispatch, getState);
expect(dispatch.getCall(1).args[0]).to.deep.equal({
type: MOVE_COMPONENT,
payload: {
dropResult,
},
});
expect(dispatch.callCount).to.equal(2);
});
it('should dispatch a toast if the drop overflows the destination', () => {
const { getState, dispatch } = setup({
dashboardLayout: {
present: {
source: { type: ROW_TYPE },
destination: { type: ROW_TYPE, children: ['rowChild'] },
dragging: { type: CHART_TYPE, meta: { width: 1 } },
rowChild: { type: CHART_TYPE, meta: { width: 12 } },
},
},
});
const dropResult = {
source: { id: 'source', type: ROW_TYPE },
destination: { id: 'destination', type: ROW_TYPE },
dragging: { id: 'dragging', type: CHART_TYPE },
};
const thunk = handleComponentDrop(dropResult);
thunk(dispatch, getState);
expect(dispatch.getCall(0).args[0].type).to.deep.equal(
addInfoToast('').type,
);
expect(dispatch.callCount).to.equal(1);
});
it('should delete a parent Row or Tabs if the moved child was the only child', () => {
const { getState, dispatch } = setup({
dashboardLayout: {
present: {
parentId: { id: 'parentId', children: ['tabsId'] },
tabsId: { id: 'tabsId', type: TABS_TYPE, children: [] },
[DASHBOARD_GRID_ID]: {
id: DASHBOARD_GRID_ID,
type: DASHBOARD_GRID_TYPE,
},
tabId: { id: 'tabId', type: TAB_TYPE },
},
},
});
const dropResult = {
source: { id: 'tabsId', type: TABS_TYPE },
destination: { id: DASHBOARD_GRID_ID, type: DASHBOARD_GRID_TYPE },
dragging: { id: 'tabId', type: TAB_TYPE },
};
const moveThunk = handleComponentDrop(dropResult);
moveThunk(dispatch, getState);
// first call is move action which is not a thunk
const deleteThunk = dispatch.getCall(1).args[0];
deleteThunk(dispatch, getState);
expect(dispatch.getCall(2).args[0]).to.deep.equal({
type: DELETE_COMPONENT,
payload: {
id: 'tabsId',
parentId: 'parentId',
},
});
// move thunk, delete thunk, delete result actions
expect(dispatch.callCount).to.equal(3);
});
it('should create top-level tabs if dropped on root', () => {
const { getState, dispatch } = setup();
const dropResult = {
source: { id: NEW_COMPONENTS_SOURCE_ID },
destination: { id: DASHBOARD_ROOT_ID },
dragging: { id: NEW_ROW_ID, type: ROW_TYPE },
};
const thunk1 = handleComponentDrop(dropResult);
thunk1(dispatch, getState);
const thunk2 = dispatch.getCall(0).args[0];
thunk2(dispatch, getState);
expect(dispatch.getCall(1).args[0]).to.deep.equal({
type: CREATE_TOP_LEVEL_TABS,
payload: {
dropResult,
},
});
expect(dispatch.callCount).to.equal(2);
});
});
describe('undoLayoutAction', () => {
it('should dispatch a redux-undo .undo() action ', () => {
const { getState, dispatch } = setup({
dashboardLayout: { past: ['non-empty'] },
});
const thunk = undoLayoutAction();
thunk(dispatch, getState);
expect(dispatch.callCount).to.equal(1);
expect(dispatch.getCall(0).args[0]).to.deep.equal(
UndoActionCreators.undo(),
);
});
it('should dispatch a setUnsavedChanges(false) action history length is zero', () => {
const { getState, dispatch } = setup({
dashboardLayout: { past: [] },
});
const thunk = undoLayoutAction();
thunk(dispatch, getState);
expect(dispatch.callCount).to.equal(2);
expect(dispatch.getCall(1).args[0]).to.deep.equal(
setUnsavedChanges(false),
);
});
});
describe('redoLayoutAction', () => {
it('should dispatch a redux-undo .redo() action ', () => {
const { getState, dispatch } = setup();
const thunk = redoLayoutAction();
thunk(dispatch, getState);
expect(dispatch.callCount).to.equal(1);
expect(dispatch.getCall(0).args[0]).to.deep.equal(
UndoActionCreators.redo(),
);
});
it('should dispatch a setUnsavedChanges(true) action if hasUnsavedChanges=false', () => {
const { getState, dispatch } = setup({
dashboardState: { hasUnsavedChanges: false },
});
const thunk = redoLayoutAction();
thunk(dispatch, getState);
expect(dispatch.callCount).to.equal(2);
expect(dispatch.getCall(1).args[0]).to.deep.equal(
setUnsavedChanges(true),
);
});
});
});

View File

@@ -3,16 +3,14 @@ import { mount } from 'enzyme';
import { describe, it } from 'mocha';
import { expect } from 'chai';
import CodeModal from '../../../src/dashboard/components/CodeModal';
import CodeModal from '../../../../src/dashboard/components/CodeModal';
describe('CodeModal', () => {
const mockedProps = {
triggerNode: <i className="fa fa-edit" />,
};
it('is valid', () => {
expect(
React.isValidElement(<CodeModal {...mockedProps} />),
).to.equal(true);
expect(React.isValidElement(<CodeModal {...mockedProps} />)).to.equal(true);
});
it('renders the trigger node', () => {
const wrapper = mount(<CodeModal {...mockedProps} />);

View File

@@ -3,16 +3,14 @@ import { mount } from 'enzyme';
import { describe, it } from 'mocha';
import { expect } from 'chai';
import CssEditor from '../../../src/dashboard/components/CssEditor';
import CssEditor from '../../../../src/dashboard/components/CssEditor';
describe('CssEditor', () => {
const mockedProps = {
triggerNode: <i className="fa fa-edit" />,
};
it('is valid', () => {
expect(
React.isValidElement(<CssEditor {...mockedProps} />),
).to.equal(true);
expect(React.isValidElement(<CssEditor {...mockedProps} />)).to.equal(true);
});
it('renders the trigger node', () => {
const wrapper = mount(<CssEditor {...mockedProps} />);

View File

@@ -0,0 +1,138 @@
import { Provider } from 'react-redux';
import React from 'react';
import { shallow, mount } from 'enzyme';
import { describe, it } from 'mocha';
import { expect } from 'chai';
import ParentSize from '@vx/responsive/build/components/ParentSize';
import { Sticky, StickyContainer } from 'react-sticky';
import { TabContainer, TabContent, TabPane } from 'react-bootstrap';
import BuilderComponentPane from '../../../../src/dashboard/components/BuilderComponentPane';
import DashboardBuilder from '../../../../src/dashboard/components/DashboardBuilder';
import DashboardComponent from '../../../../src/dashboard/containers/DashboardComponent';
import DashboardHeader from '../../../../src/dashboard/containers/DashboardHeader';
import DashboardGrid from '../../../../src/dashboard/containers/DashboardGrid';
import WithDragDropContext from '../helpers/WithDragDropContext';
import {
dashboardLayout as undoableDashboardLayout,
dashboardLayoutWithTabs as undoableDashboardLayoutWithTabs,
} from '../fixtures/mockDashboardLayout';
import { mockStore, mockStoreWithTabs } from '../fixtures/mockStore';
const dashboardLayout = undoableDashboardLayout.present;
const layoutWithTabs = undoableDashboardLayoutWithTabs.present;
describe('DashboardBuilder', () => {
const props = {
dashboardLayout,
deleteTopLevelTabs() {},
editMode: false,
showBuilderPane: false,
handleComponentDrop() {},
toggleBuilderPane() {},
};
function setup(overrideProps, useProvider = false, store = mockStore) {
const builder = <DashboardBuilder {...props} {...overrideProps} />;
return useProvider
? mount(
<Provider store={store}>
<WithDragDropContext>{builder}</WithDragDropContext>
</Provider>,
)
: shallow(builder);
}
it('should render a StickyContainer with class "dashboard"', () => {
const wrapper = setup();
const stickyContainer = wrapper.find(StickyContainer);
expect(stickyContainer).to.have.length(1);
expect(stickyContainer.prop('className')).to.equal('dashboard');
});
it('should add the "dashboard--editing" class if editMode=true', () => {
const wrapper = setup({ editMode: true });
const stickyContainer = wrapper.find(StickyContainer);
expect(stickyContainer.prop('className')).to.equal(
'dashboard dashboard--editing',
);
});
it('should render a DragDroppable DashboardHeader', () => {
const wrapper = setup(null, true);
expect(wrapper.find(DashboardHeader)).to.have.length(1);
});
it('should render a Sticky top-level Tabs if the dashboard has tabs', () => {
const wrapper = setup(
{ dashboardLayout: layoutWithTabs },
true,
mockStoreWithTabs,
);
const sticky = wrapper.find(Sticky);
const dashboardComponent = sticky.find(DashboardComponent);
const tabChildren = layoutWithTabs.TABS_ID.children;
expect(sticky).to.have.length(1);
expect(dashboardComponent).to.have.length(1 + tabChildren.length); // tab + tabs
expect(dashboardComponent.at(0).prop('id')).to.equal('TABS_ID');
tabChildren.forEach((tabId, i) => {
expect(dashboardComponent.at(i + 1).prop('id')).to.equal(tabId);
});
});
it('should render a TabContainer and TabContent', () => {
const wrapper = setup({ dashboardLayout: layoutWithTabs });
const parentSize = wrapper.find(ParentSize).dive();
expect(parentSize.find(TabContainer)).to.have.length(1);
expect(parentSize.find(TabContent)).to.have.length(1);
});
it('should set animation=true, mountOnEnter=true, and unmounOnExit=false on TabContainer for perf', () => {
const wrapper = setup({ dashboardLayout: layoutWithTabs });
const tabProps = wrapper
.find(ParentSize)
.dive()
.find(TabContainer)
.props();
expect(tabProps.animation).to.equal(true);
expect(tabProps.mountOnEnter).to.equal(true);
expect(tabProps.unmountOnExit).to.equal(false);
});
it('should render a TabPane and DashboardGrid for each Tab', () => {
const wrapper = setup({ dashboardLayout: layoutWithTabs });
const parentSize = wrapper.find(ParentSize).dive();
const expectedCount = layoutWithTabs.TABS_ID.children.length;
expect(parentSize.find(TabPane)).to.have.length(expectedCount);
expect(parentSize.find(DashboardGrid)).to.have.length(expectedCount);
});
it('should render a BuilderComponentPane if editMode=showBuilderPane=true', () => {
const wrapper = setup();
expect(wrapper.find(BuilderComponentPane)).to.have.length(0);
wrapper.setProps({ ...props, editMode: true, showBuilderPane: true });
expect(wrapper.find(BuilderComponentPane)).to.have.length(1);
});
it('should change tabs if a top-level Tab is clicked', () => {
const wrapper = setup(
{ dashboardLayout: layoutWithTabs },
true,
mockStoreWithTabs,
);
expect(wrapper.find(TabContainer).prop('activeKey')).to.equal(0);
wrapper
.find('.dashboard-component-tabs .nav-tabs a')
.at(1)
.simulate('click');
expect(wrapper.find(TabContainer).prop('activeKey')).to.equal(1);
});
});

View File

@@ -0,0 +1,84 @@
import React from 'react';
import { shallow } from 'enzyme';
import { describe, it } from 'mocha';
import { expect } from 'chai';
import sinon from 'sinon';
import DashboardComponent from '../../../../src/dashboard/containers/DashboardComponent';
import DashboardGrid from '../../../../src/dashboard/components/DashboardGrid';
import DragDroppable from '../../../../src/dashboard/components/dnd/DragDroppable';
import newComponentFactory from '../../../../src/dashboard/util/newComponentFactory';
import { DASHBOARD_GRID_TYPE } from '../../../../src/dashboard/util/componentTypes';
import { GRID_COLUMN_COUNT } from '../../../../src/dashboard/util/constants';
describe('DashboardGrid', () => {
const props = {
depth: 1,
editMode: false,
gridComponent: {
...newComponentFactory(DASHBOARD_GRID_TYPE),
children: ['a'],
},
handleComponentDrop() {},
resizeComponent() {},
width: 500,
};
function setup(overrideProps) {
const wrapper = shallow(<DashboardGrid {...props} {...overrideProps} />);
return wrapper;
}
it('should render a div with class "dashboard-grid"', () => {
const wrapper = setup();
expect(wrapper.find('.dashboard-grid')).to.have.length(1);
});
it('should render one DashboardComponent for each gridComponent child', () => {
const wrapper = setup({
gridComponent: { ...props.gridComponent, children: ['a', 'b'] },
});
expect(wrapper.find(DashboardComponent)).to.have.length(2);
});
it('should render two empty DragDroppables in editMode to increase the drop target zone', () => {
const viewMode = setup({ editMode: false });
const editMode = setup({ editMode: true });
expect(viewMode.find(DragDroppable)).to.have.length(0);
expect(editMode.find(DragDroppable)).to.have.length(2);
});
it('should render grid column guides when resizing', () => {
const wrapper = setup({ editMode: true });
expect(wrapper.find('.grid-column-guide')).to.have.length(0);
wrapper.setState({ isResizing: true });
expect(wrapper.find('.grid-column-guide')).to.have.length(
GRID_COLUMN_COUNT,
);
});
it('should render a grid row guide when resizing', () => {
const wrapper = setup();
expect(wrapper.find('.grid-row-guide')).to.have.length(0);
wrapper.setState({ isResizing: true, rowGuideTop: 10 });
expect(wrapper.find('.grid-row-guide')).to.have.length(1);
});
it('should call resizeComponent when a child DashboardComponent calls resizeStop', () => {
const resizeComponent = sinon.spy();
const args = { id: 'id', widthMultiple: 1, heightMultiple: 3 };
const wrapper = setup({ resizeComponent });
const dashboardComponent = wrapper.find(DashboardComponent).first();
dashboardComponent.prop('onResizeStop')(args);
expect(resizeComponent.callCount).to.equal(1);
expect(resizeComponent.getCall(0).args[0]).to.deep.equal({
id: 'id',
width: 1,
height: 3,
});
});
});

View File

@@ -0,0 +1,250 @@
import React from 'react';
import { shallow } from 'enzyme';
import { describe, it } from 'mocha';
import { expect } from 'chai';
import sinon from 'sinon';
import Dashboard from '../../../../src/dashboard/components/Dashboard';
import DashboardBuilder from '../../../../src/dashboard/containers/DashboardBuilder';
// mock data
import chartQueries, { sliceId as chartId } from '../fixtures/mockChartQueries';
import datasources from '../fixtures/mockDatasource';
import dashboardInfo from '../fixtures/mockDashboardInfo';
import { dashboardLayout } from '../fixtures/mockDashboardLayout';
import dashboardState from '../fixtures/mockDashboardState';
import { sliceEntitiesForChart as sliceEntities } from '../fixtures/mockSliceEntities';
import { CHART_TYPE } from '../../../../src/dashboard/util/componentTypes';
import newComponentFactory from '../../../../src/dashboard/util/newComponentFactory';
describe('Dashboard', () => {
const props = {
actions: {
addSliceToDashboard() {},
removeSliceFromDashboard() {},
runQuery() {},
},
initMessages: [],
dashboardState,
dashboardInfo,
charts: chartQueries,
slices: sliceEntities.slices,
datasources,
layout: dashboardLayout.present,
timeout: 60,
userId: dashboardInfo.userId,
impressionId: 'id',
loadStats: {},
};
function setup(overrideProps) {
const wrapper = shallow(<Dashboard {...props} {...overrideProps} />);
return wrapper;
}
it('should render a DashboardBuilder', () => {
const wrapper = setup();
expect(wrapper.find(DashboardBuilder)).to.have.length(1);
});
describe('refreshExcept', () => {
const overrideCharts = {
...chartQueries,
1001: {
...chartQueries[chartId],
id: 1001,
},
};
const overrideSlices = {
...props.slices,
1001: {
...props.slices[chartId],
slice_id: 1001,
},
};
it('should call runQuery for all non-exempt slices', () => {
const wrapper = setup({ charts: overrideCharts, slices: overrideSlices });
const spy = sinon.spy(props.actions, 'runQuery');
wrapper.instance().refreshExcept('1001');
spy.restore();
expect(spy.callCount).to.equal(Object.keys(overrideCharts).length - 1);
});
it('should not call runQuery for filter_immune_slices', () => {
const wrapper = setup({
charts: overrideCharts,
dashboardInfo: {
...dashboardInfo,
metadata: {
...dashboardInfo.metadata,
filter_immune_slices: Object.keys(overrideCharts).map(id =>
Number(id),
),
},
},
});
const spy = sinon.spy(props.actions, 'runQuery');
wrapper.instance().refreshExcept();
spy.restore();
expect(spy.callCount).to.equal(0);
});
});
describe('componentWillReceiveProps', () => {
const layoutWithExtraChart = {
...props.layout,
1001: newComponentFactory(CHART_TYPE, { chartId: 1001 }),
};
it('should call addSliceToDashboard if a new slice is added to the layout', () => {
const wrapper = setup();
const spy = sinon.spy(props.actions, 'addSliceToDashboard');
wrapper.instance().componentWillReceiveProps({
...props,
layout: layoutWithExtraChart,
});
spy.restore();
expect(spy.callCount).to.equal(1);
});
it('should call removeSliceFromDashboard if a slice is removed from the layout', () => {
const wrapper = setup({ layout: layoutWithExtraChart });
const spy = sinon.spy(props.actions, 'removeSliceFromDashboard');
const nextLayout = { ...layoutWithExtraChart };
delete nextLayout[1001];
wrapper.instance().componentWillReceiveProps({
...props,
layout: nextLayout,
});
spy.restore();
expect(spy.callCount).to.equal(1);
});
});
describe('componentDidUpdate', () => {
const overrideDashboardState = {
...dashboardState,
filters: {
1: { region: [] },
2: { country_name: ['USA'] },
},
refresh: true,
};
it('should not call refresh when there is no change', () => {
const wrapper = setup({ dashboardState: overrideDashboardState });
const refreshExceptSpy = sinon.spy(wrapper.instance(), 'refreshExcept');
const prevProps = wrapper.instance().props;
wrapper.setProps({
dashboardState: {
...overrideDashboardState,
},
});
wrapper.instance().componentDidUpdate(prevProps);
refreshExceptSpy.restore();
expect(refreshExceptSpy.callCount).to.equal(0);
});
it('should call refresh if a filter is added', () => {
const wrapper = setup({ dashboardState: overrideDashboardState });
const refreshExceptSpy = sinon.spy(wrapper.instance(), 'refreshExcept');
const prevProps = wrapper.instance().props;
wrapper.setProps({
dashboardState: {
...overrideDashboardState,
filters: {
...overrideDashboardState.filters,
3: { another_filter: ['please'] },
},
},
});
wrapper.instance().componentDidUpdate(prevProps);
refreshExceptSpy.restore();
expect(refreshExceptSpy.callCount).to.equal(1);
});
it('should call refresh if a filter is removed', () => {
const wrapper = setup({ dashboardState: overrideDashboardState });
const refreshExceptSpy = sinon.spy(wrapper.instance(), 'refreshExcept');
const prevProps = wrapper.instance().props;
wrapper.setProps({
dashboardState: {
...overrideDashboardState,
filters: {},
},
});
wrapper.instance().componentDidUpdate(prevProps);
refreshExceptSpy.restore();
expect(refreshExceptSpy.callCount).to.equal(1);
});
it('should call refresh if a filter is changed', () => {
const wrapper = setup({ dashboardState: overrideDashboardState });
const refreshExceptSpy = sinon.spy(wrapper.instance(), 'refreshExcept');
const prevProps = wrapper.instance().props;
wrapper.setProps({
dashboardState: {
...overrideDashboardState,
filters: {
...overrideDashboardState.filters,
2: { country_name: ['Canada'] },
},
},
});
wrapper.instance().componentDidUpdate(prevProps);
refreshExceptSpy.restore();
expect(refreshExceptSpy.callCount).to.equal(1);
});
it('should not call refresh if filters change and refresh is false', () => {
const wrapper = setup({ dashboardState: overrideDashboardState });
const refreshExceptSpy = sinon.spy(wrapper.instance(), 'refreshExcept');
const prevProps = wrapper.instance().props;
wrapper.setProps({
dashboardState: {
...overrideDashboardState,
filters: {
...overrideDashboardState.filters,
2: { country_name: ['Canada'] },
},
refresh: false,
},
});
wrapper.instance().componentDidUpdate(prevProps);
refreshExceptSpy.restore();
expect(refreshExceptSpy.callCount).to.equal(0);
});
it('should not refresh filter_immune_slices', () => {
const wrapper = setup({
dashboardState: overrideDashboardState,
dashboardInfo: {
...dashboardInfo,
metadata: {
...dashboardInfo.metadata,
filter_immune_slices: [chartId],
},
},
});
const refreshExceptSpy = sinon.spy(wrapper.instance(), 'refreshExcept');
const prevProps = wrapper.instance().props;
wrapper.setProps({
dashboardState: {
...overrideDashboardState,
filters: {
...overrideDashboardState.filters,
2: { country_name: ['Canada'] },
},
refresh: false,
},
});
wrapper.instance().componentDidUpdate(prevProps);
refreshExceptSpy.restore();
expect(refreshExceptSpy.callCount).to.equal(0);
});
});
});

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { shallow } from 'enzyme';
import { describe, it } from 'mocha';
import { expect } from 'chai';
import Loading from '../../../../src/components/Loading';
import MissingChart from '../../../../src/dashboard/components/MissingChart';
describe('MissingChart', () => {
function setup(overrideProps) {
const wrapper = shallow(<MissingChart height={100} {...overrideProps} />);
return wrapper;
}
it('renders a .missing-chart-container', () => {
const wrapper = setup();
expect(wrapper.find('.missing-chart-container')).to.have.length(1);
});
it('renders a .missing-chart-body', () => {
const wrapper = setup();
expect(wrapper.find('.missing-chart-body')).to.have.length(1);
});
it('renders a Loading', () => {
const wrapper = setup();
expect(wrapper.find(Loading)).to.have.length(1);
});
});

View File

@@ -3,7 +3,7 @@ import { mount } from 'enzyme';
import { describe, it } from 'mocha';
import { expect } from 'chai';
import RefreshIntervalModal from '../../../src/dashboard/components/RefreshIntervalModal';
import RefreshIntervalModal from '../../../../src/dashboard/components/RefreshIntervalModal';
describe('RefreshIntervalModal', () => {
const mockedProps = {

View File

@@ -0,0 +1,154 @@
import React from 'react';
import { shallow } from 'enzyme';
import { describe, it, beforeEach, afterEach } from 'mocha';
import sinon from 'sinon';
import { expect } from 'chai';
import { List } from 'react-virtualized';
import SliceAdder from '../../../../src/dashboard/components/SliceAdder';
import { sliceEntitiesForDashboard as mockSliceEntities } from '../fixtures/mockSliceEntities';
describe('SliceAdder', () => {
const mockEvent = {
key: 'Enter',
target: {
value: 'mock event target',
},
preventDefault: () => {},
};
const props = {
...mockSliceEntities,
fetchAllSlices: () => {},
selectedSliceIds: [127, 128],
userId: '1',
height: 100,
};
const errorProps = {
...props,
errorMessage: 'this is error',
};
describe('SliceAdder.sortByComparator', () => {
it('should sort by timestamp descending', () => {
const sortedTimestamps = Object.values(props.slices)
.sort(SliceAdder.sortByComparator('changed_on'))
.map(slice => slice.changed_on);
expect(
sortedTimestamps.every((currentTimestamp, index) => {
if (index === 0) {
return true;
}
return currentTimestamp < sortedTimestamps[index - 1];
}),
).to.equal(true);
});
it('should sort by slice_name', () => {
const sortedNames = Object.values(props.slices)
.sort(SliceAdder.sortByComparator('slice_name'))
.map(slice => slice.slice_name);
const expectedNames = Object.values(props.slices)
.map(slice => slice.slice_name)
.sort();
expect(sortedNames).to.deep.equal(expectedNames);
});
});
it('render List', () => {
const wrapper = shallow(<SliceAdder {...props} />);
wrapper.setState({ filteredSlices: Object.values(props.slices) });
expect(wrapper.find(List)).to.have.length(1);
});
it('render error', () => {
const wrapper = shallow(<SliceAdder {...errorProps} />);
wrapper.setState({ filteredSlices: Object.values(props.slices) });
expect(wrapper.text()).to.have.string(errorProps.errorMessage);
});
it('componentDidMount', () => {
sinon.spy(SliceAdder.prototype, 'componentDidMount');
sinon.spy(props, 'fetchAllSlices');
shallow(<SliceAdder {...props} />, {
lifecycleExperimental: true,
});
expect(SliceAdder.prototype.componentDidMount.calledOnce).to.equal(true);
expect(props.fetchAllSlices.calledOnce).to.equal(true);
SliceAdder.prototype.componentDidMount.restore();
props.fetchAllSlices.restore();
});
describe('componentWillReceiveProps', () => {
let wrapper;
beforeEach(() => {
wrapper = shallow(<SliceAdder {...props} />);
wrapper.setState({ filteredSlices: Object.values(props.slices) });
sinon.spy(wrapper.instance(), 'setState');
});
afterEach(() => {
wrapper.instance().setState.restore();
});
it('fetch slices should update state', () => {
wrapper.instance().componentWillReceiveProps({
...props,
lastUpdated: new Date().getTime(),
});
expect(wrapper.instance().setState.calledOnce).to.equal(true);
const stateKeys = Object.keys(
wrapper.instance().setState.lastCall.args[0],
);
expect(stateKeys).to.include('filteredSlices');
});
it('select slices should update state', () => {
wrapper.instance().componentWillReceiveProps({
...props,
selectedSliceIds: [127],
});
expect(wrapper.instance().setState.calledOnce).to.equal(true);
const stateKeys = Object.keys(
wrapper.instance().setState.lastCall.args[0],
);
expect(stateKeys).to.include('selectedSliceIdsSet');
});
});
describe('should rerun filter and sort', () => {
let wrapper;
let spy;
beforeEach(() => {
wrapper = shallow(<SliceAdder {...props} />);
wrapper.setState({ filteredSlices: Object.values(props.slices) });
spy = sinon.spy(wrapper.instance(), 'getFilteredSortedSlices');
});
afterEach(() => {
spy.restore();
});
it('searchUpdated', () => {
const newSearchTerm = 'new search term';
wrapper.instance().searchUpdated(newSearchTerm);
expect(spy.calledOnce).to.equal(true);
expect(spy.lastCall.args[0]).to.equal(newSearchTerm);
});
it('handleSelect', () => {
const newSortBy = 1;
wrapper.instance().handleSelect(newSortBy);
expect(spy.calledOnce).to.equal(true);
expect(spy.lastCall.args[1]).to.equal(newSortBy);
});
it('handleKeyPress', () => {
wrapper.instance().handleKeyPress(mockEvent);
expect(spy.calledOnce).to.equal(true);
expect(spy.lastCall.args[0]).to.equal(mockEvent.target.value);
});
});
});

View File

@@ -0,0 +1,41 @@
import React from 'react';
import { shallow } from 'enzyme';
import { describe, it } from 'mocha';
import { expect } from 'chai';
import mockMessageToasts from '../fixtures/mockMessageToasts';
import Toast from '../../../../src/dashboard/components/Toast';
import ToastPresenter from '../../../../src/dashboard/components/ToastPresenter';
describe('ToastPresenter', () => {
const props = {
toasts: mockMessageToasts,
removeToast() {},
};
function setup(overrideProps) {
const wrapper = shallow(<ToastPresenter {...props} {...overrideProps} />);
return wrapper;
}
it('should render a div with class toast-presenter', () => {
const wrapper = setup();
expect(wrapper.find('.toast-presenter')).to.have.length(1);
});
it('should render a Toast for each toast object', () => {
const wrapper = setup();
expect(wrapper.find(Toast)).to.have.length(props.toasts.length);
});
it('should pass removeToast to the Toast component', () => {
const removeToast = () => {};
const wrapper = setup({ removeToast });
expect(
wrapper
.find(Toast)
.first()
.prop('onCloseToast'),
).to.equal(removeToast);
});
});

View File

@@ -0,0 +1,43 @@
import { Alert } from 'react-bootstrap';
import React from 'react';
import { shallow } from 'enzyme';
import { describe, it } from 'mocha';
import { expect } from 'chai';
import mockMessageToasts from '../fixtures/mockMessageToasts';
import Toast from '../../../../src/dashboard/components/Toast';
describe('Toast', () => {
const props = {
toast: mockMessageToasts[0],
onCloseToast() {},
};
function setup(overrideProps) {
const wrapper = shallow(<Toast {...props} {...overrideProps} />);
return wrapper;
}
it('should render an Alert', () => {
const wrapper = setup();
expect(wrapper.find(Alert)).to.have.length(1);
});
it('should render toastText within the alert', () => {
const wrapper = setup();
const alert = wrapper.find(Alert).dive();
expect(alert.childAt(1).text()).to.equal(props.toast.text);
});
it('should call onCloseToast upon alert dismissal', done => {
const onCloseToast = id => {
expect(id).to.equal(props.toast.id);
done();
};
const wrapper = setup({ onCloseToast });
const handleClosePress = wrapper.instance().handleClosePress;
expect(wrapper.find(Alert).prop('onDismiss')).to.equal(handleClosePress);
handleClosePress(); // there is a timeout for onCloseToast to be called
});
});

View File

@@ -0,0 +1,90 @@
import React from 'react';
import { shallow, mount } from 'enzyme';
import { describe, it } from 'mocha';
import { expect } from 'chai';
import sinon from 'sinon';
import newComponentFactory from '../../../../../src/dashboard/util/newComponentFactory';
import {
CHART_TYPE,
ROW_TYPE,
} from '../../../../../src/dashboard/util/componentTypes';
import { UnwrappedDragDroppable as DragDroppable } from '../../../../../src/dashboard/components/dnd/DragDroppable';
describe('DragDroppable', () => {
const props = {
component: newComponentFactory(CHART_TYPE),
parentComponent: newComponentFactory(ROW_TYPE),
editMode: false,
depth: 1,
index: 0,
isDragging: false,
isDraggingOver: false,
isDraggingOverShallow: false,
droppableRef() {},
dragSourceRef() {},
dragPreviewRef() {},
};
function setup(overrideProps, shouldMount = false) {
const method = shouldMount ? mount : shallow;
const wrapper = method(<DragDroppable {...props} {...overrideProps} />);
return wrapper;
}
it('should render a div with class dragdroppable', () => {
const wrapper = setup();
expect(wrapper.find('.dragdroppable')).to.have.length(1);
});
it('should add class dragdroppable--dragging when dragging', () => {
const wrapper = setup({ isDragging: true });
expect(wrapper.find('.dragdroppable')).to.have.length(1);
});
it('should call its child function', () => {
const childrenSpy = sinon.spy();
setup({ children: childrenSpy });
expect(childrenSpy.callCount).to.equal(1);
});
it('should call its child function with "dragSourceRef" if editMode=true', () => {
const children = sinon.spy();
const dragSourceRef = () => {};
setup({ children, editMode: false, dragSourceRef });
setup({ children, editMode: true, dragSourceRef });
expect(children.getCall(0).args[0].dragSourceRef).to.equal(undefined);
expect(children.getCall(1).args[0].dragSourceRef).to.equal(dragSourceRef);
});
it('should call its child function with "dropIndicatorProps" dependent on editMode, isDraggingOver, state.dropIndicator is set', () => {
const children = sinon.spy();
const wrapper = setup({ children, editMode: false, isDraggingOver: false });
wrapper.setState({ dropIndicator: 'nonsense' });
wrapper.setProps({ ...props, editMode: true, isDraggingOver: true });
expect(children.callCount).to.equal(3); // initial + setState + setProps
expect(children.getCall(0).args[0].dropIndicatorProps).to.equal(undefined);
expect(children.getCall(2).args[0].dropIndicatorProps).to.deep.equal({
className: 'drop-indicator',
});
});
it('should call props.dragPreviewRef and props.droppableRef on mount', () => {
const dragPreviewRef = sinon.spy();
const droppableRef = sinon.spy();
setup({ dragPreviewRef, droppableRef }, true);
expect(dragPreviewRef.callCount).to.equal(1);
expect(droppableRef.callCount).to.equal(1);
});
it('should set this.mounted dependent on life cycle', () => {
const wrapper = setup({}, true);
const instance = wrapper.instance();
expect(instance.mounted).to.equal(true);
wrapper.unmount();
expect(instance.mounted).to.equal(false);
});
});

View File

@@ -0,0 +1,112 @@
import { Provider } from 'react-redux';
import React from 'react';
import { mount } from 'enzyme';
import { describe, it } from 'mocha';
import { expect } from 'chai';
import sinon from 'sinon';
import Chart from '../../../../../src/dashboard/containers/Chart';
import ChartHolder from '../../../../../src/dashboard/components/gridComponents/ChartHolder';
import DeleteComponentButton from '../../../../../src/dashboard/components/DeleteComponentButton';
import DragDroppable from '../../../../../src/dashboard/components/dnd/DragDroppable';
import HoverMenu from '../../../../../src/dashboard/components/menu/HoverMenu';
import ResizableContainer from '../../../../../src/dashboard/components/resizable/ResizableContainer';
import { mockStore } from '../../fixtures/mockStore';
import { sliceId } from '../../fixtures/mockSliceEntities';
import { dashboardLayout as mockLayout } from '../../fixtures/mockDashboardLayout';
import WithDragDropContext from '../../helpers/WithDragDropContext';
describe('ChartHolder', () => {
const props = {
id: String(sliceId),
parentId: 'ROW_ID',
component: mockLayout.present.CHART_ID,
depth: 2,
parentComponent: mockLayout.present.ROW_ID,
index: 0,
editMode: false,
availableColumnCount: 12,
columnWidth: 50,
onResizeStart() {},
onResize() {},
onResizeStop() {},
handleComponentDrop() {},
updateComponents() {},
deleteComponent() {},
};
function setup(overrideProps) {
// We have to wrap provide DragDropContext for the underlying DragDroppable
// otherwise we cannot assert on DragDroppable children
const wrapper = mount(
<Provider store={mockStore}>
<WithDragDropContext>
<ChartHolder {...props} {...overrideProps} />
</WithDragDropContext>
</Provider>,
);
return wrapper;
}
it('should render a DragDroppable', () => {
const wrapper = setup();
expect(wrapper.find(DragDroppable)).to.have.length(1);
});
it('should render a ResizableContainer', () => {
const wrapper = setup();
expect(wrapper.find(ResizableContainer)).to.have.length(1);
});
it('should only have an adjustableWidth if its parent is a Row', () => {
let wrapper = setup();
expect(wrapper.find(ResizableContainer).prop('adjustableWidth')).to.equal(
true,
);
wrapper = setup({ ...props, parentComponent: mockLayout.present.CHART_ID });
expect(wrapper.find(ResizableContainer).prop('adjustableWidth')).to.equal(
false,
);
});
it('should pass correct props to ResizableContainer', () => {
const wrapper = setup();
const resizableProps = wrapper.find(ResizableContainer).props();
expect(resizableProps.widthStep).to.equal(props.columnWidth);
expect(resizableProps.widthMultiple).to.equal(props.component.meta.width);
expect(resizableProps.heightMultiple).to.equal(props.component.meta.height);
expect(resizableProps.maxWidthMultiple).to.equal(
props.component.meta.width + props.availableColumnCount,
);
});
it('should render a div with class "dashboard-component-chart-holder"', () => {
const wrapper = setup();
expect(wrapper.find('.dashboard-component-chart-holder')).to.have.length(1);
});
it('should render a Chart', () => {
const wrapper = setup();
expect(wrapper.find(Chart)).to.have.length(1);
});
it('should render a HoverMenu with DeleteComponentButton in editMode', () => {
let wrapper = setup();
expect(wrapper.find(HoverMenu)).to.have.length(0);
expect(wrapper.find(DeleteComponentButton)).to.have.length(0);
// we cannot set props on the Divider because of the WithDragDropContext wrapper
wrapper = setup({ editMode: true });
expect(wrapper.find(HoverMenu)).to.have.length(1);
expect(wrapper.find(DeleteComponentButton)).to.have.length(1);
});
it('should call deleteComponent when deleted', () => {
const deleteComponent = sinon.spy();
const wrapper = setup({ editMode: true, deleteComponent });
wrapper.find(DeleteComponentButton).simulate('click');
expect(deleteComponent.callCount).to.equal(1);
});
});

View File

@@ -0,0 +1,89 @@
import React from 'react';
import { shallow } from 'enzyme';
import { describe, it } from 'mocha';
import { expect } from 'chai';
import sinon from 'sinon';
import Chart from '../../../../../src/dashboard/components/gridComponents/Chart';
import SliceHeader from '../../../../../src/dashboard/components/SliceHeader';
import ChartContainer from '../../../../../src/chart/ChartContainer';
import mockDatasource from '../../fixtures/mockDatasource';
import {
sliceEntitiesForChart as sliceEntities,
sliceId,
} from '../../fixtures/mockSliceEntities';
import chartQueries, {
sliceId as queryId,
} from '../../fixtures/mockChartQueries';
describe('Chart', () => {
const props = {
id: sliceId,
width: 100,
height: 100,
updateSliceName() {},
// from redux
chart: chartQueries[queryId],
formData: chartQueries[queryId].formData,
datasource: mockDatasource[sliceEntities.slices[sliceId].datasource],
slice: {
...sliceEntities.slices[sliceId],
description_markeddown: 'markdown',
},
sliceName: sliceEntities.slices[sliceId].slice_name,
timeout: 60,
filters: {},
refreshChart() {},
toggleExpandSlice() {},
addFilter() {},
editMode: false,
isExpanded: false,
supersetCanExplore: false,
sliceCanEdit: false,
};
function setup(overrideProps) {
const wrapper = shallow(<Chart {...props} {...overrideProps} />);
return wrapper;
}
it('should render a SliceHeader', () => {
const wrapper = setup();
expect(wrapper.find(SliceHeader)).to.have.length(1);
});
it('should render a ChartContainer', () => {
const wrapper = setup();
expect(wrapper.find(ChartContainer)).to.have.length(1);
});
it('should render a description if it has one and isExpanded=true', () => {
const wrapper = setup();
expect(wrapper.find('.slice_description')).to.have.length(0);
wrapper.setProps({ ...props, isExpanded: true });
expect(wrapper.find('.slice_description')).to.have.length(1);
});
it('should call refreshChart when SliceHeader calls forceRefresh', () => {
const refreshChart = sinon.spy();
const wrapper = setup({ refreshChart });
wrapper.instance().forceRefresh();
expect(refreshChart.callCount).to.equal(1);
});
it('should call addFilter when ChartContainer calls addFilter', () => {
const addFilter = sinon.spy();
const wrapper = setup({ addFilter });
wrapper.instance().addFilter();
expect(addFilter.callCount).to.equal(1);
});
it('should return props.filters when its getFilters method is called', () => {
const filters = { column: ['value'] };
const wrapper = setup({ filters });
expect(wrapper.instance().getFilters()).to.equal(filters);
});
});

View File

@@ -0,0 +1,144 @@
import { Provider } from 'react-redux';
import React from 'react';
import { mount } from 'enzyme';
import { describe, it } from 'mocha';
import { expect } from 'chai';
import sinon from 'sinon';
import BackgroundStyleDropdown from '../../../../../src/dashboard/components/menu/BackgroundStyleDropdown';
import Column from '../../../../../src/dashboard/components/gridComponents/Column';
import DashboardComponent from '../../../../../src/dashboard/containers/DashboardComponent';
import DeleteComponentButton from '../../../../../src/dashboard/components/DeleteComponentButton';
import DragDroppable from '../../../../../src/dashboard/components/dnd/DragDroppable';
import HoverMenu from '../../../../../src/dashboard/components/menu/HoverMenu';
import IconButton from '../../../../../src/dashboard/components/IconButton';
import ResizableContainer from '../../../../../src/dashboard/components/resizable/ResizableContainer';
import WithPopoverMenu from '../../../../../src/dashboard/components/menu/WithPopoverMenu';
import { mockStore } from '../../fixtures/mockStore';
import { dashboardLayout as mockLayout } from '../../fixtures/mockDashboardLayout';
import WithDragDropContext from '../../helpers/WithDragDropContext';
describe('Column', () => {
const columnWithoutChildren = {
...mockLayout.present.COLUMN_ID,
children: [],
};
const props = {
id: 'COLUMN_ID',
parentId: 'ROW_ID',
component: mockLayout.present.COLUMN_ID,
parentComponent: mockLayout.present.ROW_ID,
index: 0,
depth: 2,
editMode: false,
availableColumnCount: 12,
minColumnWidth: 2,
columnWidth: 50,
occupiedColumnCount: 6,
onResizeStart() {},
onResize() {},
onResizeStop() {},
handleComponentDrop() {},
deleteComponent() {},
updateComponents() {},
};
function setup(overrideProps) {
// We have to wrap provide DragDropContext for the underlying DragDroppable
// otherwise we cannot assert on DragDroppable children
const wrapper = mount(
<Provider store={mockStore}>
<WithDragDropContext>
<Column {...props} {...overrideProps} />
</WithDragDropContext>
</Provider>,
);
return wrapper;
}
it('should render a DragDroppable', () => {
// don't count child DragDroppables
const wrapper = setup({ component: columnWithoutChildren });
expect(wrapper.find(DragDroppable)).to.have.length(1);
});
it('should render a WithPopoverMenu', () => {
// don't count child DragDroppables
const wrapper = setup({ component: columnWithoutChildren });
expect(wrapper.find(WithPopoverMenu)).to.have.length(1);
});
it('should render a ResizableContainer', () => {
// don't count child DragDroppables
const wrapper = setup({ component: columnWithoutChildren });
expect(wrapper.find(ResizableContainer)).to.have.length(1);
});
it('should render a HoverMenu in editMode', () => {
let wrapper = setup({ component: columnWithoutChildren });
expect(wrapper.find(HoverMenu)).to.have.length(0);
// we cannot set props on the Row because of the WithDragDropContext wrapper
wrapper = setup({ component: columnWithoutChildren, editMode: true });
expect(wrapper.find(HoverMenu)).to.have.length(1);
});
it('should render a DeleteComponentButton in editMode', () => {
let wrapper = setup({ component: columnWithoutChildren });
expect(wrapper.find(DeleteComponentButton)).to.have.length(0);
// we cannot set props on the Row because of the WithDragDropContext wrapper
wrapper = setup({ component: columnWithoutChildren, editMode: true });
expect(wrapper.find(DeleteComponentButton)).to.have.length(1);
});
it('should render a BackgroundStyleDropdown when focused', () => {
let wrapper = setup({ component: columnWithoutChildren });
expect(wrapper.find(BackgroundStyleDropdown)).to.have.length(0);
// we cannot set props on the Row because of the WithDragDropContext wrapper
wrapper = setup({ component: columnWithoutChildren, editMode: true });
wrapper
.find(IconButton)
.at(1) // first one is delete button
.simulate('click');
expect(wrapper.find(BackgroundStyleDropdown)).to.have.length(1);
});
it('should call deleteComponent when deleted', () => {
const deleteComponent = sinon.spy();
const wrapper = setup({ editMode: true, deleteComponent });
wrapper.find(DeleteComponentButton).simulate('click');
expect(deleteComponent.callCount).to.equal(1);
});
it('should pass its own width as availableColumnCount to children', () => {
const wrapper = setup();
const dashboardComponent = wrapper.find(DashboardComponent).first();
expect(dashboardComponent.props().availableColumnCount).to.equal(
props.component.meta.width,
);
});
it('should pass appropriate dimensions to ResizableContainer', () => {
const wrapper = setup({ component: columnWithoutChildren });
const columnWidth = columnWithoutChildren.meta.width;
const resizableProps = wrapper.find(ResizableContainer).props();
expect(resizableProps.adjustableWidth).to.equal(true);
expect(resizableProps.adjustableHeight).to.equal(false);
expect(resizableProps.widthStep).to.equal(props.columnWidth);
expect(resizableProps.widthMultiple).to.equal(columnWidth);
expect(resizableProps.minWidthMultiple).to.equal(props.minColumnWidth);
expect(resizableProps.maxWidthMultiple).to.equal(
props.availableColumnCount + columnWidth,
);
});
it('should increment the depth of its children', () => {
const wrapper = setup();
const dashboardComponent = wrapper.find(DashboardComponent);
expect(dashboardComponent.props().depth).to.equal(props.depth + 1);
});
});

View File

@@ -0,0 +1,70 @@
import React from 'react';
import { mount } from 'enzyme';
import { describe, it } from 'mocha';
import { expect } from 'chai';
import sinon from 'sinon';
import DeleteComponentButton from '../../../../../src/dashboard/components/DeleteComponentButton';
import HoverMenu from '../../../../../src/dashboard/components/menu/HoverMenu';
import DragDroppable from '../../../../../src/dashboard/components/dnd/DragDroppable';
import Divider from '../../../../../src/dashboard/components/gridComponents/Divider';
import newComponentFactory from '../../../../../src/dashboard/util/newComponentFactory';
import {
DIVIDER_TYPE,
DASHBOARD_GRID_TYPE,
} from '../../../../../src/dashboard/util/componentTypes';
import WithDragDropContext from '../../helpers/WithDragDropContext';
describe('Divider', () => {
const props = {
id: 'id',
parentId: 'parentId',
component: newComponentFactory(DIVIDER_TYPE),
depth: 1,
parentComponent: newComponentFactory(DASHBOARD_GRID_TYPE),
index: 0,
editMode: false,
handleComponentDrop() {},
deleteComponent() {},
};
function setup(overrideProps) {
// We have to wrap provide DragDropContext for the underlying DragDroppable
// otherwise we cannot assert on DragDroppable children
const wrapper = mount(
<WithDragDropContext>
<Divider {...props} {...overrideProps} />
</WithDragDropContext>,
);
return wrapper;
}
it('should render a DragDroppable', () => {
const wrapper = setup();
expect(wrapper.find(DragDroppable)).to.have.length(1);
});
it('should render a div with class "dashboard-component-divider"', () => {
const wrapper = setup();
expect(wrapper.find('.dashboard-component-divider')).to.have.length(1);
});
it('should render a HoverMenu with DeleteComponentButton in editMode', () => {
let wrapper = setup();
expect(wrapper.find(HoverMenu)).to.have.length(0);
expect(wrapper.find(DeleteComponentButton)).to.have.length(0);
// we cannot set props on the Divider because of the WithDragDropContext wrapper
wrapper = setup({ editMode: true });
expect(wrapper.find(HoverMenu)).to.have.length(1);
expect(wrapper.find(DeleteComponentButton)).to.have.length(1);
});
it('should call deleteComponent when deleted', () => {
const deleteComponent = sinon.spy();
const wrapper = setup({ editMode: true, deleteComponent });
wrapper.find(DeleteComponentButton).simulate('click');
expect(deleteComponent.callCount).to.equal(1);
});
});

View File

@@ -0,0 +1,100 @@
import React from 'react';
import { mount } from 'enzyme';
import { describe, it } from 'mocha';
import { expect } from 'chai';
import sinon from 'sinon';
import DeleteComponentButton from '../../../../../src/dashboard/components/DeleteComponentButton';
import EditableTitle from '../../../../../src/components/EditableTitle';
import HoverMenu from '../../../../../src/dashboard/components/menu/HoverMenu';
import WithPopoverMenu from '../../../../../src/dashboard/components/menu/WithPopoverMenu';
import DragDroppable from '../../../../../src/dashboard/components/dnd/DragDroppable';
import Header from '../../../../../src/dashboard/components/gridComponents/Header';
import newComponentFactory from '../../../../../src/dashboard/util/newComponentFactory';
import {
HEADER_TYPE,
DASHBOARD_GRID_TYPE,
} from '../../../../../src/dashboard/util/componentTypes';
import WithDragDropContext from '../../helpers/WithDragDropContext';
describe('Header', () => {
const props = {
id: 'id',
parentId: 'parentId',
component: newComponentFactory(HEADER_TYPE),
depth: 1,
parentComponent: newComponentFactory(DASHBOARD_GRID_TYPE),
index: 0,
editMode: false,
handleComponentDrop() {},
deleteComponent() {},
updateComponents() {},
};
function setup(overrideProps) {
// We have to wrap provide DragDropContext for the underlying DragDroppable
// otherwise we cannot assert on DragDroppable children
const wrapper = mount(
<WithDragDropContext>
<Header {...props} {...overrideProps} />
</WithDragDropContext>,
);
return wrapper;
}
it('should render a DragDroppable', () => {
const wrapper = setup();
expect(wrapper.find(DragDroppable)).to.have.length(1);
});
it('should render a WithPopoverMenu', () => {
const wrapper = setup();
expect(wrapper.find(WithPopoverMenu)).to.have.length(1);
});
it('should render a HoverMenu in editMode', () => {
let wrapper = setup();
expect(wrapper.find(HoverMenu)).to.have.length(0);
// we cannot set props on the Header because of the WithDragDropContext wrapper
wrapper = setup({ editMode: true });
expect(wrapper.find(HoverMenu)).to.have.length(1);
});
it('should render an EditableTitle with meta.text', () => {
const wrapper = setup();
expect(wrapper.find(EditableTitle)).to.have.length(1);
expect(wrapper.find('input').prop('value')).to.equal(
props.component.meta.text,
);
});
it('should call updateComponents when EditableTitle changes', () => {
const updateComponents = sinon.spy();
const wrapper = setup({ editMode: true, updateComponents });
wrapper.find(EditableTitle).prop('onSaveTitle')('New title');
const headerId = props.component.id;
expect(updateComponents.callCount).to.equal(1);
expect(updateComponents.getCall(0).args[0][headerId].meta.text).to.equal(
'New title',
);
});
it('should render a DeleteComponentButton when focused in editMode', () => {
const wrapper = setup({ editMode: true });
wrapper.find(WithPopoverMenu).simulate('click'); // focus
expect(wrapper.find(DeleteComponentButton)).to.have.length(1);
});
it('should call deleteComponent when deleted', () => {
const deleteComponent = sinon.spy();
const wrapper = setup({ editMode: true, deleteComponent });
wrapper.find(WithPopoverMenu).simulate('click'); // focus
wrapper.find(DeleteComponentButton).simulate('click');
expect(deleteComponent.callCount).to.equal(1);
});
});

View File

@@ -0,0 +1,156 @@
import { Provider } from 'react-redux';
import React from 'react';
import { mount } from 'enzyme';
import { describe, it } from 'mocha';
import { expect } from 'chai';
import sinon from 'sinon';
import AceEditor from 'react-ace';
import ReactMarkdown from 'react-markdown';
import Markdown from '../../../../../src/dashboard/components/gridComponents/Markdown';
import MarkdownModeDropdown from '../../../../../src/dashboard/components/menu/MarkdownModeDropdown';
import DeleteComponentButton from '../../../../../src/dashboard/components/DeleteComponentButton';
import DragDroppable from '../../../../../src/dashboard/components/dnd/DragDroppable';
import WithPopoverMenu from '../../../../../src/dashboard/components/menu/WithPopoverMenu';
import ResizableContainer from '../../../../../src/dashboard/components/resizable/ResizableContainer';
import { mockStore } from '../../fixtures/mockStore';
import { dashboardLayout as mockLayout } from '../../fixtures/mockDashboardLayout';
import WithDragDropContext from '../../helpers/WithDragDropContext';
describe('Markdown', () => {
const props = {
id: 'id',
parentId: 'parentId',
component: mockLayout.present.MARKDOWN_ID,
depth: 2,
parentComponent: mockLayout.present.ROW_ID,
index: 0,
editMode: false,
availableColumnCount: 12,
columnWidth: 50,
onResizeStart() {},
onResize() {},
onResizeStop() {},
handleComponentDrop() {},
updateComponents() {},
deleteComponent() {},
};
function setup(overrideProps) {
// We have to wrap provide DragDropContext for the underlying DragDroppable
// otherwise we cannot assert on DragDroppable children
const wrapper = mount(
<Provider store={mockStore}>
<WithDragDropContext>
<Markdown {...props} {...overrideProps} />
</WithDragDropContext>
</Provider>,
);
return wrapper;
}
it('should render a DragDroppable', () => {
const wrapper = setup();
expect(wrapper.find(DragDroppable)).to.have.length(1);
});
it('should render a WithPopoverMenu', () => {
const wrapper = setup();
expect(wrapper.find(WithPopoverMenu)).to.have.length(1);
});
it('should render a ResizableContainer', () => {
const wrapper = setup();
expect(wrapper.find(ResizableContainer)).to.have.length(1);
});
it('should only have an adjustableWidth if its parent is a Row', () => {
let wrapper = setup();
expect(wrapper.find(ResizableContainer).prop('adjustableWidth')).to.equal(
true,
);
wrapper = setup({ ...props, parentComponent: mockLayout.present.CHART_ID });
expect(wrapper.find(ResizableContainer).prop('adjustableWidth')).to.equal(
false,
);
});
it('should pass correct props to ResizableContainer', () => {
const wrapper = setup();
const resizableProps = wrapper.find(ResizableContainer).props();
expect(resizableProps.widthStep).to.equal(props.columnWidth);
expect(resizableProps.widthMultiple).to.equal(props.component.meta.width);
expect(resizableProps.heightMultiple).to.equal(props.component.meta.height);
expect(resizableProps.maxWidthMultiple).to.equal(
props.component.meta.width + props.availableColumnCount,
);
});
it('should render an Markdown when NOT focused', () => {
const wrapper = setup();
expect(wrapper.find(AceEditor)).to.have.length(0);
expect(wrapper.find(ReactMarkdown)).to.have.length(1);
});
it('should render an AceEditor when focused and editMode=true and editorMode=edit', () => {
const wrapper = setup({ editMode: true });
expect(wrapper.find(AceEditor)).to.have.length(0);
expect(wrapper.find(ReactMarkdown)).to.have.length(1);
wrapper.find(WithPopoverMenu).simulate('click'); // focus + edit
expect(wrapper.find(AceEditor)).to.have.length(1);
expect(wrapper.find(ReactMarkdown)).to.have.length(0);
});
it('should render a ReactMarkdown when focused and editMode=true and editorMode=preview', () => {
const wrapper = setup({ editMode: true });
wrapper.find(WithPopoverMenu).simulate('click'); // focus + edit
expect(wrapper.find(AceEditor)).to.have.length(1);
expect(wrapper.find(ReactMarkdown)).to.have.length(0);
// we can't call setState on Markdown bc it's not the root component, so call
// the mode dropdown onchange instead
const dropdown = wrapper.find(MarkdownModeDropdown);
dropdown.prop('onChange')('preview');
expect(wrapper.find(AceEditor)).to.have.length(0);
expect(wrapper.find(ReactMarkdown)).to.have.length(1);
});
it('should call updateComponents when editMode changes from edit => preview, and there are markdownSource changes', () => {
const updateComponents = sinon.spy();
const wrapper = setup({ editMode: true, updateComponents });
wrapper.find(WithPopoverMenu).simulate('click'); // focus + edit
// we can't call setState on Markdown bc it's not the root component, so call
// the mode dropdown onchange instead
const dropdown = wrapper.find(MarkdownModeDropdown);
dropdown.prop('onChange')('preview');
expect(updateComponents.callCount).to.equal(0);
dropdown.prop('onChange')('edit');
// because we can't call setState on Markdown, change it through the editor
// then go back to preview mode to invoke updateComponents
const editor = wrapper.find(AceEditor);
editor.prop('onChange')('new markdown!');
dropdown.prop('onChange')('preview');
expect(updateComponents.callCount).to.equal(1);
});
it('should render a DeleteComponentButton when focused in editMode', () => {
const wrapper = setup({ editMode: true });
wrapper.find(WithPopoverMenu).simulate('click'); // focus
expect(wrapper.find(DeleteComponentButton)).to.have.length(1);
});
it('should call deleteComponent when deleted', () => {
const deleteComponent = sinon.spy();
const wrapper = setup({ editMode: true, deleteComponent });
wrapper.find(WithPopoverMenu).simulate('click'); // focus
wrapper.find(DeleteComponentButton).simulate('click');
expect(deleteComponent.callCount).to.equal(1);
});
});

View File

@@ -0,0 +1,120 @@
import { Provider } from 'react-redux';
import React from 'react';
import { mount } from 'enzyme';
import { describe, it } from 'mocha';
import { expect } from 'chai';
import sinon from 'sinon';
import BackgroundStyleDropdown from '../../../../../src/dashboard/components/menu/BackgroundStyleDropdown';
import DashboardComponent from '../../../../../src/dashboard/containers/DashboardComponent';
import DeleteComponentButton from '../../../../../src/dashboard/components/DeleteComponentButton';
import DragDroppable from '../../../../../src/dashboard/components/dnd/DragDroppable';
import HoverMenu from '../../../../../src/dashboard/components/menu/HoverMenu';
import IconButton from '../../../../../src/dashboard/components/IconButton';
import Row from '../../../../../src/dashboard/components/gridComponents/Row';
import WithPopoverMenu from '../../../../../src/dashboard/components/menu/WithPopoverMenu';
import { mockStore } from '../../fixtures/mockStore';
import { DASHBOARD_GRID_ID } from '../../../../../src/dashboard/util/constants';
import { dashboardLayout as mockLayout } from '../../fixtures/mockDashboardLayout';
import WithDragDropContext from '../../helpers/WithDragDropContext';
describe('Row', () => {
const rowWithoutChildren = { ...mockLayout.present.ROW_ID, children: [] };
const props = {
id: 'ROW_ID',
parentId: DASHBOARD_GRID_ID,
component: mockLayout.present.ROW_ID,
parentComponent: mockLayout.present[DASHBOARD_GRID_ID],
index: 0,
depth: 2,
editMode: false,
availableColumnCount: 12,
columnWidth: 50,
occupiedColumnCount: 6,
onResizeStart() {},
onResize() {},
onResizeStop() {},
handleComponentDrop() {},
deleteComponent() {},
updateComponents() {},
};
function setup(overrideProps) {
// We have to wrap provide DragDropContext for the underlying DragDroppable
// otherwise we cannot assert on DragDroppable children
const wrapper = mount(
<Provider store={mockStore}>
<WithDragDropContext>
<Row {...props} {...overrideProps} />
</WithDragDropContext>
</Provider>,
);
return wrapper;
}
it('should render a DragDroppable', () => {
// don't count child DragDroppables
const wrapper = setup({ component: rowWithoutChildren });
expect(wrapper.find(DragDroppable)).to.have.length(1);
});
it('should render a WithPopoverMenu', () => {
// don't count child DragDroppables
const wrapper = setup({ component: rowWithoutChildren });
expect(wrapper.find(WithPopoverMenu)).to.have.length(1);
});
it('should render a HoverMenu in editMode', () => {
let wrapper = setup({ component: rowWithoutChildren });
expect(wrapper.find(HoverMenu)).to.have.length(0);
// we cannot set props on the Row because of the WithDragDropContext wrapper
wrapper = setup({ component: rowWithoutChildren, editMode: true });
expect(wrapper.find(HoverMenu)).to.have.length(1);
});
it('should render a DeleteComponentButton in editMode', () => {
let wrapper = setup({ component: rowWithoutChildren });
expect(wrapper.find(DeleteComponentButton)).to.have.length(0);
// we cannot set props on the Row because of the WithDragDropContext wrapper
wrapper = setup({ component: rowWithoutChildren, editMode: true });
expect(wrapper.find(DeleteComponentButton)).to.have.length(1);
});
it('should render a BackgroundStyleDropdown when focused', () => {
let wrapper = setup({ component: rowWithoutChildren });
expect(wrapper.find(BackgroundStyleDropdown)).to.have.length(0);
// we cannot set props on the Row because of the WithDragDropContext wrapper
wrapper = setup({ component: rowWithoutChildren, editMode: true });
wrapper
.find(IconButton)
.at(1) // first one is delete button
.simulate('click');
expect(wrapper.find(BackgroundStyleDropdown)).to.have.length(1);
});
it('should call deleteComponent when deleted', () => {
const deleteComponent = sinon.spy();
const wrapper = setup({ editMode: true, deleteComponent });
wrapper.find(DeleteComponentButton).simulate('click');
expect(deleteComponent.callCount).to.equal(1);
});
it('should pass appropriate availableColumnCount to children', () => {
const wrapper = setup();
const dashboardComponent = wrapper.find(DashboardComponent).first();
expect(dashboardComponent.props().availableColumnCount).to.equal(
props.availableColumnCount - props.occupiedColumnCount,
);
});
it('should increment the depth of its children', () => {
const wrapper = setup();
const dashboardComponent = wrapper.find(DashboardComponent).first();
expect(dashboardComponent.props().depth).to.equal(props.depth + 1);
});
});

View File

@@ -0,0 +1,126 @@
import { Provider } from 'react-redux';
import React from 'react';
import { mount } from 'enzyme';
import { describe, it } from 'mocha';
import { expect } from 'chai';
import sinon from 'sinon';
import DashboardComponent from '../../../../../src/dashboard/containers/DashboardComponent';
import DeleteComponentButton from '../../../../../src/dashboard/components/DeleteComponentButton';
import DragDroppable from '../../../../../src/dashboard/components/dnd/DragDroppable';
import EditableTitle from '../../../../../src/components/EditableTitle';
import WithPopoverMenu from '../../../../../src/dashboard/components/menu/WithPopoverMenu';
import Tab, {
RENDER_TAB,
RENDER_TAB_CONTENT,
} from '../../../../../src/dashboard/components/gridComponents/Tab';
import WithDragDropContext from '../../helpers/WithDragDropContext';
import { dashboardLayoutWithTabs } from '../../fixtures/mockDashboardLayout';
import { mockStoreWithTabs } from '../../fixtures/mockStore';
describe('Tabs', () => {
const props = {
id: 'TAB_ID',
parentId: 'TABS_ID',
component: dashboardLayoutWithTabs.present.TAB_ID,
parentComponent: dashboardLayoutWithTabs.present.TABS_ID,
index: 0,
depth: 1,
editMode: false,
renderType: RENDER_TAB,
onDropOnTab() {},
onDeleteTab() {},
availableColumnCount: 12,
columnWidth: 50,
onResizeStart() {},
onResize() {},
onResizeStop() {},
createComponent() {},
handleComponentDrop() {},
onChangeTab() {},
deleteComponent() {},
updateComponents() {},
};
function setup(overrideProps) {
// We have to wrap provide DragDropContext for the underlying DragDroppable
// otherwise we cannot assert on DragDroppable children
const wrapper = mount(
<Provider store={mockStoreWithTabs}>
<WithDragDropContext>
<Tab {...props} {...overrideProps} />
</WithDragDropContext>
</Provider>,
);
return wrapper;
}
describe('renderType=RENDER_TAB', () => {
it('should render a DragDroppable', () => {
const wrapper = setup();
expect(wrapper.find(DragDroppable)).to.have.length(1);
});
it('should render an EditableTitle with meta.text', () => {
const wrapper = setup();
const title = wrapper.find(EditableTitle);
expect(title).to.have.length(1);
expect(title.find('input').prop('value')).to.equal(
props.component.meta.text,
);
});
it('should call updateComponents when EditableTitle changes', () => {
const updateComponents = sinon.spy();
const wrapper = setup({ editMode: true, updateComponents });
wrapper.find(EditableTitle).prop('onSaveTitle')('New title');
expect(updateComponents.callCount).to.equal(1);
expect(updateComponents.getCall(0).args[0].TAB_ID.meta.text).to.equal(
'New title',
);
});
it('should render a WithPopoverMenu', () => {
const wrapper = setup();
expect(wrapper.find(WithPopoverMenu)).to.have.length(1);
});
it('should render a DeleteComponentButton when focused if its not the only tab', () => {
let wrapper = setup();
wrapper.find(WithPopoverMenu).simulate('click'); // focus
expect(wrapper.find(DeleteComponentButton)).to.have.length(0);
wrapper = setup({ editMode: true });
wrapper.find(WithPopoverMenu).simulate('click');
expect(wrapper.find(DeleteComponentButton)).to.have.length(1);
wrapper = setup({
editMode: true,
parentComponent: {
...props.parentComponent,
children: props.parentComponent.children.slice(0, 1),
},
});
wrapper.find(WithPopoverMenu).simulate('click');
expect(wrapper.find(DeleteComponentButton)).to.have.length(0);
});
it('should call deleteComponent when deleted', () => {
const deleteComponent = sinon.spy();
const wrapper = setup({ editMode: true, deleteComponent });
wrapper.find(WithPopoverMenu).simulate('click'); // focus
wrapper.find(DeleteComponentButton).simulate('click');
expect(deleteComponent.callCount).to.equal(1);
});
});
describe('renderType=RENDER_TAB_CONTENT', () => {
it('should render a DashboardComponent', () => {
const wrapper = setup({ renderType: RENDER_TAB_CONTENT });
// We expect 2 because this Tab has a Row child and the row has a Chart
expect(wrapper.find(DashboardComponent)).to.have.length(2);
});
});
});

View File

@@ -0,0 +1,140 @@
import { Provider } from 'react-redux';
import React from 'react';
import { mount } from 'enzyme';
import { describe, it } from 'mocha';
import { expect } from 'chai';
import sinon from 'sinon';
import { Tabs as BootstrapTabs, Tab as BootstrapTab } from 'react-bootstrap';
import DashboardComponent from '../../../../../src/dashboard/containers/DashboardComponent';
import DeleteComponentButton from '../../../../../src/dashboard/components/DeleteComponentButton';
import HoverMenu from '../../../../../src/dashboard/components/menu/HoverMenu';
import DragDroppable from '../../../../../src/dashboard/components/dnd/DragDroppable';
import Tabs from '../../../../../src/dashboard/components/gridComponents/Tabs';
import WithDragDropContext from '../../helpers/WithDragDropContext';
import { dashboardLayoutWithTabs } from '../../fixtures/mockDashboardLayout';
import { mockStoreWithTabs } from '../../fixtures/mockStore';
import { DASHBOARD_ROOT_ID } from '../../../../../src/dashboard/util/constants';
describe('Tabs', () => {
const props = {
id: 'TABS_ID',
parentId: DASHBOARD_ROOT_ID,
component: dashboardLayoutWithTabs.present.TABS_ID,
parentComponent: dashboardLayoutWithTabs.present[DASHBOARD_ROOT_ID],
index: 0,
depth: 1,
renderTabContent: true,
editMode: false,
availableColumnCount: 12,
columnWidth: 50,
onResizeStart() {},
onResize() {},
onResizeStop() {},
createComponent() {},
handleComponentDrop() {},
onChangeTab() {},
deleteComponent() {},
updateComponents() {},
};
function setup(overrideProps) {
// We have to wrap provide DragDropContext for the underlying DragDroppable
// otherwise we cannot assert on DragDroppable children
const wrapper = mount(
<Provider store={mockStoreWithTabs}>
<WithDragDropContext>
<Tabs {...props} {...overrideProps} />
</WithDragDropContext>
</Provider>,
);
return wrapper;
}
it('should render a DragDroppable', () => {
// test just Tabs with no children DragDroppables
const wrapper = setup({ component: { ...props.component, children: [] } });
expect(wrapper.find(DragDroppable)).to.have.length(1);
});
it('should render BootstrapTabs', () => {
const wrapper = setup();
expect(wrapper.find(BootstrapTabs)).to.have.length(1);
});
it('should set animation=true, mountOnEnter=true, and unmounOnExit=false on BootstrapTabs for perf', () => {
const wrapper = setup();
const tabProps = wrapper.find(BootstrapTabs).props();
expect(tabProps.animation).to.equal(true);
expect(tabProps.mountOnEnter).to.equal(true);
expect(tabProps.unmountOnExit).to.equal(false);
});
it('should render a BootstrapTab for each child', () => {
const wrapper = setup();
expect(wrapper.find(BootstrapTab)).to.have.length(
props.component.children.length,
);
});
it('should render an extra (+) BootstrapTab in editMode', () => {
const wrapper = setup({ editMode: true });
expect(wrapper.find(BootstrapTab)).to.have.length(
props.component.children.length + 1,
);
});
it('should render a DashboardComponent for each child', () => {
// note: this does not test Tab content
const wrapper = setup({ renderTabContent: false });
expect(wrapper.find(DashboardComponent)).to.have.length(
props.component.children.length,
);
});
it('should call createComponent if the (+) tab is clicked', () => {
const createComponent = sinon.spy();
const wrapper = setup({ editMode: true, createComponent });
wrapper
.find('.dashboard-component-tabs .nav-tabs a')
.last()
.simulate('click');
expect(createComponent.callCount).to.equal(1);
});
it('should call onChangeTab when a tab is clicked', () => {
const onChangeTab = sinon.spy();
const wrapper = setup({ editMode: true, onChangeTab });
wrapper
.find('.dashboard-component-tabs .nav-tabs a')
.at(1) // will not call if it is already selected
.simulate('click');
expect(onChangeTab.callCount).to.equal(1);
});
it('should render a HoverMenu in editMode', () => {
let wrapper = setup();
expect(wrapper.find(HoverMenu)).to.have.length(0);
wrapper = setup({ editMode: true });
expect(wrapper.find(HoverMenu)).to.have.length(1);
});
it('should render a DeleteComponentButton in editMode', () => {
let wrapper = setup();
expect(wrapper.find(DeleteComponentButton)).to.have.length(0);
wrapper = setup({ editMode: true });
expect(wrapper.find(DeleteComponentButton)).to.have.length(1);
});
it('should call deleteComponent when deleted', () => {
const deleteComponent = sinon.spy();
const wrapper = setup({ editMode: true, deleteComponent });
wrapper.find(DeleteComponentButton).simulate('click');
expect(deleteComponent.callCount).to.equal(1);
});
});

View File

@@ -0,0 +1,68 @@
import React from 'react';
import { mount } from 'enzyme';
import { describe, it } from 'mocha';
import { expect } from 'chai';
import DragDroppable from '../../../../../../src/dashboard/components/dnd/DragDroppable';
import DraggableNewComponent from '../../../../../../src/dashboard/components/gridComponents/new/DraggableNewComponent';
import WithDragDropContext from '../../../helpers/WithDragDropContext';
import { NEW_COMPONENTS_SOURCE_ID } from '../../../../../../src/dashboard/util/constants';
import {
NEW_COMPONENT_SOURCE_TYPE,
CHART_TYPE,
} from '../../../../../../src/dashboard/util/componentTypes';
describe('DraggableNewComponent', () => {
const props = {
id: 'id',
type: CHART_TYPE,
label: 'label!',
className: 'a_class',
};
function setup(overrideProps) {
// We have to wrap provide DragDropContext for the underlying DragDroppable
// otherwise we cannot assert on DragDroppable children
const wrapper = mount(
<WithDragDropContext>
<DraggableNewComponent {...props} {...overrideProps} />
</WithDragDropContext>,
);
return wrapper;
}
it('should render a DragDroppable', () => {
const wrapper = setup();
expect(wrapper.find(DragDroppable)).to.have.length(1);
});
it('should pass component={ type, id } to DragDroppable', () => {
const wrapper = setup();
const dragdroppable = wrapper.find(DragDroppable);
expect(dragdroppable.prop('component')).to.deep.equal({
id: props.id,
type: props.type,
});
});
it('should pass appropriate parent source and id to DragDroppable', () => {
const wrapper = setup();
const dragdroppable = wrapper.find(DragDroppable);
expect(dragdroppable.prop('parentComponent')).to.deep.equal({
id: NEW_COMPONENTS_SOURCE_ID,
type: NEW_COMPONENT_SOURCE_TYPE,
});
});
it('should render the passed label', () => {
const wrapper = setup();
expect(wrapper.find('.new-component').text()).to.equal(props.label);
});
it('should add the passed className', () => {
const wrapper = setup();
const className = `.new-component-placeholder.${props.className}`;
expect(wrapper.find(className)).to.have.length(1);
});
});

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { shallow } from 'enzyme';
import { describe, it } from 'mocha';
import { expect } from 'chai';
import DraggableNewComponent from '../../../../../../src/dashboard/components/gridComponents/new/DraggableNewComponent';
import NewColumn from '../../../../../../src/dashboard/components/gridComponents/new/NewColumn';
import { NEW_COLUMN_ID } from '../../../../../../src/dashboard/util/constants';
import { COLUMN_TYPE } from '../../../../../../src/dashboard/util/componentTypes';
describe('NewColumn', () => {
function setup() {
return shallow(<NewColumn />);
}
it('should render a DraggableNewComponent', () => {
const wrapper = setup();
expect(wrapper.find(DraggableNewComponent)).to.have.length(1);
});
it('should set appropriate type and id', () => {
const wrapper = setup();
expect(wrapper.find(DraggableNewComponent).props()).to.include({
type: COLUMN_TYPE,
id: NEW_COLUMN_ID,
});
});
});

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { shallow } from 'enzyme';
import { describe, it } from 'mocha';
import { expect } from 'chai';
import DraggableNewComponent from '../../../../../../src/dashboard/components/gridComponents/new/DraggableNewComponent';
import NewDivider from '../../../../../../src/dashboard/components/gridComponents/new/NewDivider';
import { NEW_DIVIDER_ID } from '../../../../../../src/dashboard/util/constants';
import { DIVIDER_TYPE } from '../../../../../../src/dashboard/util/componentTypes';
describe('NewDivider', () => {
function setup() {
return shallow(<NewDivider />);
}
it('should render a DraggableNewComponent', () => {
const wrapper = setup();
expect(wrapper.find(DraggableNewComponent)).to.have.length(1);
});
it('should set appropriate type and id', () => {
const wrapper = setup();
expect(wrapper.find(DraggableNewComponent).props()).to.include({
type: DIVIDER_TYPE,
id: NEW_DIVIDER_ID,
});
});
});

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { shallow } from 'enzyme';
import { describe, it } from 'mocha';
import { expect } from 'chai';
import DraggableNewComponent from '../../../../../../src/dashboard/components/gridComponents/new/DraggableNewComponent';
import NewHeader from '../../../../../../src/dashboard/components/gridComponents/new/NewHeader';
import { NEW_HEADER_ID } from '../../../../../../src/dashboard/util/constants';
import { HEADER_TYPE } from '../../../../../../src/dashboard/util/componentTypes';
describe('NewHeader', () => {
function setup() {
return shallow(<NewHeader />);
}
it('should render a DraggableNewComponent', () => {
const wrapper = setup();
expect(wrapper.find(DraggableNewComponent)).to.have.length(1);
});
it('should set appropriate type and id', () => {
const wrapper = setup();
expect(wrapper.find(DraggableNewComponent).props()).to.include({
type: HEADER_TYPE,
id: NEW_HEADER_ID,
});
});
});

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { shallow } from 'enzyme';
import { describe, it } from 'mocha';
import { expect } from 'chai';
import DraggableNewComponent from '../../../../../../src/dashboard/components/gridComponents/new/DraggableNewComponent';
import NewRow from '../../../../../../src/dashboard/components/gridComponents/new/NewRow';
import { NEW_ROW_ID } from '../../../../../../src/dashboard/util/constants';
import { ROW_TYPE } from '../../../../../../src/dashboard/util/componentTypes';
describe('NewRow', () => {
function setup() {
return shallow(<NewRow />);
}
it('should render a DraggableNewComponent', () => {
const wrapper = setup();
expect(wrapper.find(DraggableNewComponent)).to.have.length(1);
});
it('should set appropriate type and id', () => {
const wrapper = setup();
expect(wrapper.find(DraggableNewComponent).props()).to.include({
type: ROW_TYPE,
id: NEW_ROW_ID,
});
});
});

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { shallow } from 'enzyme';
import { describe, it } from 'mocha';
import { expect } from 'chai';
import DraggableNewComponent from '../../../../../../src/dashboard/components/gridComponents/new/DraggableNewComponent';
import NewTabs from '../../../../../../src/dashboard/components/gridComponents/new/NewTabs';
import { NEW_TABS_ID } from '../../../../../../src/dashboard/util/constants';
import { TABS_TYPE } from '../../../../../../src/dashboard/util/componentTypes';
describe('NewTabs', () => {
function setup() {
return shallow(<NewTabs />);
}
it('should render a DraggableNewComponent', () => {
const wrapper = setup();
expect(wrapper.find(DraggableNewComponent)).to.have.length(1);
});
it('should set appropriate type and id', () => {
const wrapper = setup();
expect(wrapper.find(DraggableNewComponent).props()).to.include({
type: TABS_TYPE,
id: NEW_TABS_ID,
});
});
});

View File

@@ -0,0 +1,13 @@
import React from 'react';
import { shallow } from 'enzyme';
import { describe, it } from 'mocha';
import { expect } from 'chai';
import HoverMenu from '../../../../../src/dashboard/components/menu/HoverMenu';
describe('HoverMenu', () => {
it('should render a div.hover-menu', () => {
const wrapper = shallow(<HoverMenu />);
expect(wrapper.find('.hover-menu')).to.have.length(1);
});
});

View File

@@ -0,0 +1,71 @@
import React from 'react';
import { shallow } from 'enzyme';
import { describe, it } from 'mocha';
import { expect } from 'chai';
import WithPopoverMenu from '../../../../../src/dashboard/components/menu/WithPopoverMenu';
describe('WithPopoverMenu', () => {
const props = {
children: <div id="child" />,
disableClick: false,
menuItems: [<div id="menu1" />, <div id="menu2" />],
onChangeFocus() {},
shouldFocus: () => true, // needed for mock
isFocused: false,
editMode: false,
};
function setup(overrideProps) {
const wrapper = shallow(<WithPopoverMenu {...props} {...overrideProps} />);
return wrapper;
}
it('should render a div with class "with-popover-menu"', () => {
const wrapper = setup();
expect(wrapper.find('.with-popover-menu')).to.have.length(1);
});
it('should render the passed children', () => {
const wrapper = setup();
expect(wrapper.find('#child')).to.have.length(1);
});
it('should focus on click in editMode', () => {
const wrapper = setup();
expect(wrapper.state('isFocused')).to.equal(false);
wrapper.simulate('click');
expect(wrapper.state('isFocused')).to.equal(false);
wrapper.setProps({ ...props, editMode: true });
wrapper.simulate('click');
expect(wrapper.state('isFocused')).to.equal(true);
});
it('should render menuItems when focused', () => {
const wrapper = setup({ editMode: true });
expect(wrapper.find('#menu1')).to.have.length(0);
expect(wrapper.find('#menu2')).to.have.length(0);
wrapper.simulate('click');
expect(wrapper.find('#menu1')).to.have.length(1);
expect(wrapper.find('#menu2')).to.have.length(1);
});
it('should not focus when disableClick=true', () => {
const wrapper = setup({ disableClick: true, editMode: true });
expect(wrapper.state('isFocused')).to.equal(false);
wrapper.simulate('click');
expect(wrapper.state('isFocused')).to.equal(false);
});
it('should use the passed shouldFocus func to determine if it should focus', () => {
const wrapper = setup({ editMode: true, shouldFocus: () => false });
expect(wrapper.state('isFocused')).to.equal(false);
wrapper.simulate('click');
expect(wrapper.state('isFocused')).to.equal(false);
});
});

View File

@@ -0,0 +1,20 @@
import React from 'react';
import Resizable from 're-resizable';
import { shallow } from 'enzyme';
import { describe, it } from 'mocha';
import { expect } from 'chai';
import ResizableContainer from '../../../../../src/dashboard/components/resizable/ResizableContainer';
describe('ResizableContainer', () => {
const props = { editMode: false, id: 'id' };
function setup(propOverrides) {
return shallow(<ResizableContainer {...props} {...propOverrides} />);
}
it('should render a Resizable', () => {
const wrapper = setup();
expect(wrapper.find(Resizable)).to.have.length(1);
});
});

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { shallow } from 'enzyme';
import { describe, it } from 'mocha';
import { expect } from 'chai';
import ResizableHandle from '../../../../../src/dashboard/components/resizable/ResizableHandle';
describe('ResizableHandle', () => {
it('should render a right resize handle', () => {
const wrapper = shallow(<ResizableHandle.right />);
expect(wrapper.find('.resize-handle.resize-handle--right')).to.have.length(
1,
);
});
it('should render a bottom resize handle', () => {
const wrapper = shallow(<ResizableHandle.bottom />);
expect(wrapper.find('.resize-handle.resize-handle--bottom')).to.have.length(
1,
);
});
it('should render a bottomRight resize handle', () => {
const wrapper = shallow(<ResizableHandle.bottomRight />);
expect(
wrapper.find('.resize-handle.resize-handle--bottom-right'),
).to.have.length(1);
});
});

View File

@@ -1,161 +0,0 @@
import { getInitialState } from '../../../src/dashboard/reducers';
export const defaultFilters = {
256: { region: [] },
257: { country_name: ['United States'] },
};
export const regionFilter = {
datasource: null,
description: null,
description_markeddown: '',
edit_url: '/slicemodelview/edit/256',
form_data: {
datasource: '2__table',
date_filter: false,
filters: [{
col: 'country_name',
op: 'in',
val: ['United States', 'France', 'Japan'],
}],
granularity_sqla: null,
groupby: ['region', 'country_name'],
having: '',
instant_filtering: true,
metric: 'sum__SP_POP_TOTL',
show_druid_time_granularity: false,
show_druid_time_origin: false,
show_sqla_time_column: false,
show_sqla_time_granularity: false,
since: '100 years ago',
slice_id: 256,
time_grain_sqla: null,
until: 'now',
viz_type: 'filter_box',
where: '',
},
slice_id: 256,
slice_name: 'Region Filters',
slice_url: '/superset/explore/table/2/?form_data=%7B%22slice_id%22%3A%20256%7D',
};
export const countryFilter = {
datasource: null,
description: null,
description_markeddown: '',
edit_url: '/slicemodelview/edit/257',
form_data: {
datasource: '2__table',
date_filter: false,
filters: [],
granularity_sqla: null,
groupby: ['country_name'],
having: '',
instant_filtering: true,
metric: 'sum__SP_POP_TOTL',
show_druid_time_granularity: false,
show_druid_time_origin: false,
show_sqla_time_column: false,
show_sqla_time_granularity: false,
since: '100 years ago',
slice_id: 257,
time_grain_sqla: null,
until: 'now',
viz_type: 'filter_box',
where: '',
},
slice_id: 257,
slice_name: 'Country Filters',
slice_url: '/superset/explore/table/2/?form_data=%7B%22slice_id%22%3A%20257%7D',
};
export const slice = {
datasource: null,
description: null,
description_markeddown: '',
edit_url: '/slicemodelview/edit/248',
form_data: {
annotation_layers: [],
bottom_margin: 'auto',
color_scheme: 'bnbColors',
contribution: false,
datasource: '2__table',
filters: [],
granularity_sqla: null,
groupby: [],
having: '',
left_margin: 'auto',
limit: 50,
line_interpolation: 'linear',
metrics: ['sum__SP_POP_TOTL'],
num_period_compare: '',
order_desc: true,
period_ratio_type: 'growth',
resample_fillmethod: null,
resample_how: null,
resample_rule: null,
rich_tooltip: true,
rolling_type: 'None',
show_brush: false,
show_legend: true,
show_markers: false,
since: '1961-01-01T00:00:00',
slice_id: 248,
time_compare: null,
time_grain_sqla: null,
timeseries_limit_metric: null,
until: '2014-12-31T00:00:00',
viz_type: 'line',
where: '',
x_axis_format: 'smart_date',
x_axis_label: '',
x_axis_showminmax: true,
y_axis_bounds: [null, null],
y_axis_format: '.3s',
y_axis_label: '',
y_axis_showminmax: true,
y_log_scale: false,
},
slice_id: 248,
slice_name: 'Filtered Population',
slice_url: '/superset/explore/table/2/?form_data=%7B%22slice_id%22%3A%20248%7D',
};
const datasources = {};
const mockDashboardData = {
css: '',
dash_edit_perm: true,
dash_save_perm: true,
dashboard_title: 'Births',
id: 2,
metadata: {
default_filters: JSON.stringify(defaultFilters),
filter_immune_slices: [256],
timed_refresh_immune_slices: [],
filter_immune_slice_fields: {},
expanded_slices: {},
},
position_json: [
{
size_x: 4,
slice_id: '256',
row: 0,
size_y: 4,
col: 5,
},
{
size_x: 4,
slice_id: '248',
row: 0,
size_y: 4,
col: 1,
},
],
slug: 'births',
slices: [regionFilter, slice, countryFilter],
standalone_mode: false,
};
export const { dashboard, charts } = getInitialState({
common: {},
dashboard_data: mockDashboardData,
datasources,
user_id: '1',
});

View File

@@ -0,0 +1,61 @@
import { datasourceId } from './mockDatasource';
export const sliceId = 18;
export default {
[sliceId]: {
id: sliceId,
chartAlert: null,
chartStatus: 'rendered',
chartUpdateEndTime: 1525852456388,
chartUpdateStartTime: 1525852454838,
latestQueryFormData: {},
queryRequest: {},
queryResponse: {},
triggerQuery: false,
lastRendered: 0,
form_data: {
slice_id: sliceId,
viz_type: 'pie',
row_limit: 50000,
metric: 'sum__num',
since: '100 years ago',
groupby: ['gender'],
metrics: ['sum__num'],
compare_lag: '10',
limit: '25',
until: 'now',
granularity: 'ds',
markup_type: 'markdown',
where: '',
compare_suffix: 'o10Y',
datasource: datasourceId,
},
formData: {
datasource: datasourceId,
viz_type: 'pie',
slice_id: sliceId,
granularity_sqla: null,
time_grain_sqla: null,
since: '100 years ago',
until: 'now',
metrics: ['sum__num'],
groupby: ['gender'],
limit: '25',
pie_label_type: 'key',
donut: false,
show_legend: true,
labels_outside: true,
color_scheme: 'bnbColors',
where: '',
having: '',
filters: [],
row_limit: 50000,
metric: 'sum__num',
compare_lag: '10',
granularity: 'ds',
markup_type: 'markdown',
compare_suffix: 'o10Y',
},
},
};

View File

@@ -0,0 +1,12 @@
export default {
id: 1234,
slug: 'dashboardSlug',
metadata: {},
userId: 'mock_user_id',
dash_edit_perm: true,
dash_save_perm: true,
common: {
flash_messages: [],
conf: { ENABLE_JAVASCRIPT_CONTROLS: false, SUPERSET_WEBSERVER_TIMEOUT: 60 },
},
};

View File

@@ -0,0 +1,146 @@
import {
DASHBOARD_GRID_TYPE,
DASHBOARD_HEADER_TYPE,
DASHBOARD_ROOT_TYPE,
TABS_TYPE,
TAB_TYPE,
CHART_TYPE,
ROW_TYPE,
COLUMN_TYPE,
MARKDOWN_TYPE,
} from '../../../../src/dashboard/util/componentTypes';
import {
DASHBOARD_ROOT_ID,
DASHBOARD_HEADER_ID,
DASHBOARD_GRID_ID,
} from '../../../../src/dashboard/util/constants';
import newComponentFactory from '../../../../src/dashboard/util/newComponentFactory';
import { sliceId as chartId } from './mockChartQueries';
export const sliceId = chartId;
export const dashboardLayout = {
past: [],
present: {
[DASHBOARD_ROOT_ID]: {
type: DASHBOARD_ROOT_TYPE,
id: DASHBOARD_ROOT_ID,
children: [DASHBOARD_GRID_ID],
},
[DASHBOARD_GRID_ID]: {
type: DASHBOARD_GRID_TYPE,
id: DASHBOARD_GRID_ID,
children: ['ROW_ID'],
meta: {},
},
[DASHBOARD_HEADER_ID]: {
type: DASHBOARD_HEADER_TYPE,
id: DASHBOARD_HEADER_ID,
meta: {
text: 'New dashboard',
},
},
ROW_ID: {
...newComponentFactory(ROW_TYPE),
id: 'ROW_ID',
children: ['COLUMN_ID'],
},
COLUMN_ID: {
...newComponentFactory(COLUMN_TYPE),
id: 'COLUMN_ID',
children: ['CHART_ID'],
},
CHART_ID: {
...newComponentFactory(CHART_TYPE),
id: 'CHART_ID',
meta: {
chartId,
width: 3,
height: 10,
chartName: 'Mock chart name',
},
},
MARKDOWN_ID: {
...newComponentFactory(MARKDOWN_TYPE),
id: 'MARKDOWN_ID',
},
},
future: [],
};
export const dashboardLayoutWithTabs = {
past: [],
present: {
[DASHBOARD_ROOT_ID]: {
type: DASHBOARD_ROOT_TYPE,
id: DASHBOARD_ROOT_ID,
children: ['TABS_ID'],
},
TABS_ID: {
id: 'TABS_ID',
type: TABS_TYPE,
children: ['TAB_ID', 'TAB_ID2'],
},
TAB_ID: {
id: 'TAB_ID',
type: TAB_TYPE,
children: ['ROW_ID'],
meta: {
text: 'tab1',
},
},
TAB_ID2: {
id: 'TAB_ID2',
type: TAB_TYPE,
children: [],
meta: {
text: 'tab2',
},
},
CHART_ID: {
...newComponentFactory(CHART_TYPE),
id: 'CHART_ID',
meta: {
chartId,
width: 3,
height: 10,
chartName: 'Mock chart name',
},
},
ROW_ID: {
...newComponentFactory(ROW_TYPE),
id: 'ROW_ID',
children: ['CHART_ID'],
},
[DASHBOARD_GRID_ID]: {
type: DASHBOARD_GRID_TYPE,
id: DASHBOARD_GRID_ID,
children: [],
meta: {},
},
[DASHBOARD_HEADER_ID]: {
type: DASHBOARD_HEADER_TYPE,
id: DASHBOARD_HEADER_ID,
meta: {
text: 'New dashboard',
},
},
},
future: [],
};

View File

@@ -0,0 +1,15 @@
import { id as sliceId } from './mockChartQueries';
export default {
sliceIds: [sliceId],
refresh: false,
filters: {},
expandedSlices: {},
editMode: false,
showBuilderPane: false,
hasUnsavedChanges: false,
maxUndoHistoryExceeded: false,
isStarred: true,
css: '',
isV2Preview: false, // @TODO remove upon v1 deprecation
};

View File

@@ -0,0 +1,206 @@
export const id = 7;
export const datasourceId = `${id}__table`;
export default {
[datasourceId]: {
verbose_map: {
count: 'COUNT(*)',
__timestamp: 'Time',
sum__sum_girls: 'sum__sum_girls',
name: 'name',
avg__sum_girls: 'avg__sum_girls',
gender: 'gender',
sum_girls: 'sum_girls',
ds: 'ds',
sum__sum_boys: 'sum__sum_boys',
state: 'state',
num: 'num',
sum__num: 'sum__num',
sum_boys: 'sum_boys',
avg__num: 'avg__num',
avg__sum_boys: 'avg__sum_boys',
},
gb_cols: [['gender', 'gender'], ['name', 'name'], ['state', 'state']],
metrics: [
{
expression: 'SUM(birth_names.num)',
warning_text: null,
verbose_name: 'sum__num',
metric_name: 'sum__num',
description: null,
},
{
expression: 'AVG(birth_names.num)',
warning_text: null,
verbose_name: 'avg__num',
metric_name: 'avg__num',
description: null,
},
{
expression: 'SUM(birth_names.sum_boys)',
warning_text: null,
verbose_name: 'sum__sum_boys',
metric_name: 'sum__sum_boys',
description: null,
},
{
expression: 'AVG(birth_names.sum_boys)',
warning_text: null,
verbose_name: 'avg__sum_boys',
metric_name: 'avg__sum_boys',
description: null,
},
{
expression: 'SUM(birth_names.sum_girls)',
warning_text: null,
verbose_name: 'sum__sum_girls',
metric_name: 'sum__sum_girls',
description: null,
},
{
expression: 'AVG(birth_names.sum_girls)',
warning_text: null,
verbose_name: 'avg__sum_girls',
metric_name: 'avg__sum_girls',
description: null,
},
{
expression: 'COUNT(*)',
warning_text: null,
verbose_name: 'COUNT(*)',
metric_name: 'count',
description: null,
},
],
column_formats: {},
columns: [
{
type: 'DATETIME',
description: null,
filterable: false,
verbose_name: null,
is_dttm: true,
expression: '',
groupby: false,
column_name: 'ds',
},
{
type: 'VARCHAR(16)',
description: null,
filterable: true,
verbose_name: null,
is_dttm: false,
expression: '',
groupby: true,
column_name: 'gender',
},
{
type: 'VARCHAR(255)',
description: null,
filterable: true,
verbose_name: null,
is_dttm: false,
expression: '',
groupby: true,
column_name: 'name',
},
{
type: 'BIGINT',
description: null,
filterable: false,
verbose_name: null,
is_dttm: false,
expression: '',
groupby: false,
column_name: 'num',
},
{
type: 'VARCHAR(10)',
description: null,
filterable: true,
verbose_name: null,
is_dttm: false,
expression: '',
groupby: true,
column_name: 'state',
},
{
type: 'BIGINT',
description: null,
filterable: false,
verbose_name: null,
is_dttm: false,
expression: '',
groupby: false,
column_name: 'sum_boys',
},
{
type: 'BIGINT',
description: null,
filterable: false,
verbose_name: null,
is_dttm: false,
expression: '',
groupby: false,
column_name: 'sum_girls',
},
],
id,
granularity_sqla: [['ds', 'ds']],
name: 'birth_names',
database: {
allow_multi_schema_metadata_fetch: null,
name: 'main',
backend: 'sqlite',
},
time_grain_sqla: [
[null, 'Time Column'],
['PT1H', 'hour'],
['P1D', 'day'],
['P1W', 'week'],
['P1M', 'month'],
],
filterable_cols: [
['gender', 'gender'],
['name', 'name'],
['state', 'state'],
],
all_cols: [
['ds', 'ds'],
['gender', 'gender'],
['name', 'name'],
['num', 'num'],
['state', 'state'],
['sum_boys', 'sum_boys'],
['sum_girls', 'sum_girls'],
],
filter_select: true,
order_by_choices: [
['["ds", true]', 'ds [asc]'],
['["ds", false]', 'ds [desc]'],
['["gender", true]', 'gender [asc]'],
['["gender", false]', 'gender [desc]'],
['["name", true]', 'name [asc]'],
['["name", false]', 'name [desc]'],
['["num", true]', 'num [asc]'],
['["num", false]', 'num [desc]'],
['["state", true]', 'state [asc]'],
['["state", false]', 'state [desc]'],
['["sum_boys", true]', 'sum_boys [asc]'],
['["sum_boys", false]', 'sum_boys [desc]'],
['["sum_girls", true]', 'sum_girls [asc]'],
['["sum_girls", false]', 'sum_girls [desc]'],
],
metrics_combo: [
['count', 'COUNT(*)'],
['avg__num', 'avg__num'],
['avg__sum_boys', 'avg__sum_boys'],
['avg__sum_girls', 'avg__sum_girls'],
['sum__num', 'sum__num'],
['sum__sum_boys', 'sum__sum_boys'],
['sum__sum_girls', 'sum__sum_girls'],
],
type: 'table',
edit_url: '/tablemodelview/edit/7',
},
};

View File

@@ -0,0 +1,9 @@
import {
INFO_TOAST,
DANGER_TOAST,
} from '../../../../src/dashboard/util/constants';
export default [
{ id: 'info_id', toastType: INFO_TOAST, text: 'info toast' },
{ id: 'danger_id', toastType: DANGER_TOAST, text: 'danger toast' },
];

View File

@@ -0,0 +1,177 @@
import { sliceId as id } from './mockChartQueries';
import { datasourceId } from './mockDatasource';
export const sliceId = id;
export const sliceEntitiesForChart = {
slices: {
[sliceId]: {
slice_id: sliceId,
slice_url: '/superset/explore/?form_data=%7B%22slice_id%22%3A%2018%7D',
slice_name: 'Genders',
form_data: {
slice_id: sliceId,
viz_type: 'pie',
row_limit: 50000,
metric: 'sum__num',
since: '100 years ago',
groupby: ['gender'],
metrics: ['sum__num'],
compare_lag: '10',
limit: '25',
until: 'now',
granularity: 'ds',
markup_type: 'markdown',
where: '',
compare_suffix: 'o10Y',
datasource: datasourceId,
},
edit_url: `/slicemodelview/edit/${sliceId}`,
viz_type: 'pie',
datasource: datasourceId,
description: null,
description_markeddown: '',
},
},
isLoading: false,
errorMessage: null,
lastUpdated: 0,
};
export const sliceEntitiesForDashboard = {
slices: {
127: {
slice_id: 127,
slice_url: '/superset/explore/?form_data=%7B%22slice_id%22%3A%20127%7D',
slice_name: 'Region Filter',
form_data: {},
edit_url: '/slicemodelview/edit/127',
viz_type: 'filter_box',
datasource: '2__table',
description: null,
description_markeddown: '',
modified: '23 hours ago',
changed_on: 1529453332615,
},
128: {
slice_id: 128,
slice_url: '/superset/explore/?form_data=%7B%22slice_id%22%3A%20128%7D',
slice_name: "World's Population",
form_data: {},
edit_url: '/slicemodelview/edit/128',
viz_type: 'big_number',
datasource: '2__table',
description: null,
description_markeddown: '',
modified: '23 hours ago',
changed_on: 1529453332628,
},
129: {
slice_id: 129,
slice_url: '/superset/explore/?form_data=%7B%22slice_id%22%3A%20129%7D',
slice_name: 'Most Populated Countries',
form_data: {},
edit_url: '/slicemodelview/edit/129',
viz_type: 'table',
datasource: '2__table',
description: null,
description_markeddown: '',
modified: '23 hours ago',
changed_on: 1529453332637,
},
130: {
slice_id: 130,
slice_url: '/superset/explore/?form_data=%7B%22slice_id%22%3A%20130%7D',
slice_name: 'Growth Rate',
form_data: {},
edit_url: '/slicemodelview/edit/130',
viz_type: 'line',
datasource: '2__table',
description: null,
description_markeddown: '',
modified: '23 hours ago',
changed_on: 1529453332645,
},
131: {
slice_id: 131,
slice_url: '/superset/explore/?form_data=%7B%22slice_id%22%3A%20131%7D',
slice_name: '% Rural',
form_data: {},
edit_url: '/slicemodelview/edit/131',
viz_type: 'world_map',
datasource: '2__table',
description: null,
description_markeddown: '',
modified: '23 hours ago',
changed_on: 1529453332654,
},
132: {
slice_id: 132,
slice_url: '/superset/explore/?form_data=%7B%22slice_id%22%3A%20132%7D',
slice_name: 'Life Expectancy VS Rural %',
form_data: {},
edit_url: '/slicemodelview/edit/132',
viz_type: 'bubble',
datasource: '2__table',
description: null,
description_markeddown: '',
modified: '23 hours ago',
changed_on: 1529453332663,
},
133: {
slice_id: 133,
slice_url: '/superset/explore/?form_data=%7B%22slice_id%22%3A%20133%7D',
slice_name: 'Rural Breakdown',
form_data: {},
edit_url: '/slicemodelview/edit/133',
viz_type: 'sunburst',
datasource: '2__table',
description: null,
description_markeddown: '',
modified: '23 hours ago',
changed_on: 1529453332673,
},
134: {
slice_id: 134,
slice_url: '/superset/explore/?form_data=%7B%22slice_id%22%3A%20134%7D',
slice_name: "World's Pop Growth",
form_data: {},
edit_url: '/slicemodelview/edit/134',
viz_type: 'area',
datasource: '2__table',
description: null,
description_markeddown: '',
modified: '23 hours ago',
changed_on: 1529453332680,
},
135: {
slice_id: 135,
slice_url: '/superset/explore/?form_data=%7B%22slice_id%22%3A%20135%7D',
slice_name: 'Box plot',
form_data: {},
edit_url: '/slicemodelview/edit/135',
viz_type: 'box_plot',
datasource: '2__table',
description: null,
description_markeddown: '',
modified: '23 hours ago',
changed_on: 1529453332688,
},
136: {
slice_id: 136,
slice_url: '/superset/explore/?form_data=%7B%22slice_id%22%3A%20136%7D',
slice_name: 'Treemap',
form_data: {},
edit_url: '/slicemodelview/edit/136',
viz_type: 'treemap',
datasource: '2__table',
description: null,
description_markeddown: '',
modified: '23 hours ago',
changed_on: 1529453332700,
},
},
isLoading: false,
errorMessage: null,
lastUpdated: 0,
};

View File

@@ -0,0 +1,18 @@
import chartQueries from './mockChartQueries';
import { dashboardLayout } from './mockDashboardLayout';
import dashboardInfo from './mockDashboardInfo';
import dashboardState from './mockDashboardState';
import messageToasts from './mockMessageToasts';
import datasources from './mockDatasource';
import sliceEntities from './mockSliceEntities';
export default {
datasources,
sliceEntities,
charts: chartQueries,
dashboardInfo,
dashboardState,
dashboardLayout,
messageToasts,
impressionId: 'mock_impression_id',
};

View File

@@ -0,0 +1,22 @@
import { createStore, applyMiddleware, compose } from 'redux';
import thunk from 'redux-thunk';
import rootReducer from '../../../../src/dashboard/reducers/index';
import mockState from './mockState';
import { dashboardLayoutWithTabs } from './mockDashboardLayout';
export const mockStore = createStore(
rootReducer,
mockState,
compose(applyMiddleware(thunk)),
);
export const mockStoreWithTabs = createStore(
rootReducer,
{
...mockState,
dashboardLayout: dashboardLayoutWithTabs,
},
compose(applyMiddleware(thunk)),
);

View File

@@ -0,0 +1,27 @@
import React from 'react';
import PropTypes from 'prop-types';
import getDragDropManager from '../../../../src/dashboard/util/getDragDropManager';
// A helper component that provides a DragDropContext for components that require it
class WithDragDropContext extends React.Component {
getChildContext() {
return {
dragDropManager: this.context.dragDropManager || getDragDropManager(),
};
}
render() {
return this.props.children;
}
}
WithDragDropContext.propTypes = {
children: PropTypes.node.isRequired,
};
WithDragDropContext.childContextTypes = {
dragDropManager: PropTypes.object.isRequired,
};
export default WithDragDropContext;

View File

@@ -0,0 +1,443 @@
import { describe, it } from 'mocha';
import { expect } from 'chai';
import layoutReducer from '../../../../src/dashboard/reducers/dashboardLayout';
import {
UPDATE_COMPONENTS,
DELETE_COMPONENT,
CREATE_COMPONENT,
MOVE_COMPONENT,
CREATE_TOP_LEVEL_TABS,
DELETE_TOP_LEVEL_TABS,
} from '../../../../src/dashboard/actions/dashboardLayout';
import {
CHART_TYPE,
DASHBOARD_GRID_TYPE,
DASHBOARD_ROOT_TYPE,
ROW_TYPE,
TAB_TYPE,
TABS_TYPE,
} from '../../../../src/dashboard/util/componentTypes';
import {
DASHBOARD_ROOT_ID,
DASHBOARD_GRID_ID,
NEW_COMPONENTS_SOURCE_ID,
NEW_TABS_ID,
NEW_ROW_ID,
} from '../../../../src/dashboard/util/constants';
describe('dashboardLayout reducer', () => {
it('should return initial state for unrecognized actions', () => {
expect(layoutReducer(undefined, {})).to.deep.equal({});
});
it('should delete a component, remove its reference in its parent, and recursively all of its children', () => {
expect(
layoutReducer(
{
toDelete: {
id: 'toDelete',
children: ['child1'],
},
child1: {
id: 'child1',
children: ['child2'],
},
child2: {
id: 'child2',
children: [],
},
parentId: {
id: 'parentId',
type: ROW_TYPE,
children: ['toDelete', 'anotherId'],
},
},
{
type: DELETE_COMPONENT,
payload: { id: 'toDelete', parentId: 'parentId' },
},
),
).to.deep.equal({
parentId: {
id: 'parentId',
children: ['anotherId'],
type: ROW_TYPE,
},
});
});
it('should delete a parent if the parent was a row and no longer has children', () => {
expect(
layoutReducer(
{
grandparentId: {
id: 'grandparentId',
children: ['parentId'],
},
parentId: {
id: 'parentId',
type: ROW_TYPE,
children: ['toDelete'],
},
toDelete: {
id: 'toDelete',
children: ['child1'],
},
child1: {
id: 'child1',
children: [],
},
},
{
type: DELETE_COMPONENT,
payload: { id: 'toDelete', parentId: 'parentId' },
},
),
).to.deep.equal({
grandparentId: {
id: 'grandparentId',
children: [],
},
});
});
it('should update components', () => {
expect(
layoutReducer(
{
update: {
id: 'update',
children: [],
},
update2: {
id: 'update2',
children: [],
},
dontUpdate: {
id: 'dontUpdate',
something: 'something',
children: ['abcd'],
},
},
{
type: UPDATE_COMPONENTS,
payload: {
nextComponents: {
update: {
id: 'update',
newField: 'newField',
},
update2: {
id: 'update2',
newField: 'newField',
},
},
},
},
),
).to.deep.equal({
update: {
id: 'update',
newField: 'newField',
},
update2: {
id: 'update2',
newField: 'newField',
},
dontUpdate: {
id: 'dontUpdate',
something: 'something',
children: ['abcd'],
},
});
});
it('should move a component', () => {
const layout = {
source: {
id: 'source',
type: ROW_TYPE,
children: ['dontMove', 'toMove'],
},
destination: {
id: 'destination',
type: ROW_TYPE,
children: ['anotherChild'],
},
toMove: {
id: 'toMove',
type: CHART_TYPE,
children: [],
},
};
const dropResult = {
source: { id: 'source', type: ROW_TYPE, index: 1 },
destination: { id: 'destination', type: ROW_TYPE, index: 0 },
dragging: { id: 'toMove', type: CHART_TYPE },
};
expect(
layoutReducer(layout, {
type: MOVE_COMPONENT,
payload: { dropResult },
}),
).to.deep.equal({
source: {
id: 'source',
type: ROW_TYPE,
children: ['dontMove'],
},
destination: {
id: 'destination',
type: ROW_TYPE,
children: ['toMove', 'anotherChild'],
},
toMove: {
id: 'toMove',
type: CHART_TYPE,
children: [],
},
});
});
it('should wrap a moved component in a row if need be', () => {
const layout = {
source: {
id: 'source',
type: ROW_TYPE,
children: ['dontMove', 'toMove'],
},
destination: {
id: 'destination',
type: DASHBOARD_GRID_TYPE,
children: [],
},
toMove: {
id: 'toMove',
type: CHART_TYPE,
children: [],
},
};
const dropResult = {
source: { id: 'source', type: ROW_TYPE, index: 1 },
destination: { id: 'destination', type: DASHBOARD_GRID_TYPE, index: 0 },
dragging: { id: 'toMove', type: CHART_TYPE },
};
const result = layoutReducer(layout, {
type: MOVE_COMPONENT,
payload: { dropResult },
});
const newRow = Object.values(result).find(
component =>
['source', 'destination', 'toMove'].indexOf(component.id) === -1,
);
expect(newRow.children[0]).to.equal('toMove');
expect(result.destination.children[0]).to.equal(newRow.id);
expect(Object.keys(result)).to.have.length(4);
});
it('should add top-level tabs from a new tabs component, moving grid children to new tab', () => {
const layout = {
[DASHBOARD_ROOT_ID]: {
id: DASHBOARD_ROOT_ID,
children: [DASHBOARD_GRID_ID],
},
[DASHBOARD_GRID_ID]: {
id: DASHBOARD_GRID_ID,
children: ['child'],
},
child: {
id: 'child',
children: [],
},
};
const dropResult = {
source: { id: NEW_COMPONENTS_SOURCE_ID, type: '' },
destination: {
id: DASHBOARD_ROOT_ID,
type: DASHBOARD_ROOT_TYPE,
index: 0,
},
dragging: { id: NEW_TABS_ID, type: TABS_TYPE },
};
const result = layoutReducer(layout, {
type: CREATE_TOP_LEVEL_TABS,
payload: { dropResult },
});
const tabComponent = Object.values(result).find(
component => component.type === TAB_TYPE,
);
const tabsComponent = Object.values(result).find(
component => component.type === TABS_TYPE,
);
expect(Object.keys(result)).to.have.length(5); // initial + Tabs + Tab
expect(result[DASHBOARD_ROOT_ID].children[0]).to.equal(tabsComponent.id);
expect(result[tabsComponent.id].children[0]).to.equal(tabComponent.id);
expect(result[tabComponent.id].children[0]).to.equal('child');
expect(result[DASHBOARD_GRID_ID].children).to.have.length(0);
});
it('should add top-level tabs from an existing tabs component, moving grid children to new tab', () => {
const layout = {
[DASHBOARD_ROOT_ID]: {
id: DASHBOARD_ROOT_ID,
children: [DASHBOARD_GRID_ID],
},
[DASHBOARD_GRID_ID]: {
id: DASHBOARD_GRID_ID,
children: ['child', 'tabs', 'child2'],
},
child: {
id: 'child',
children: [],
},
child2: {
id: 'child2',
children: [],
},
tabs: {
id: 'tabs',
type: TABS_TYPE,
children: ['tab'],
},
tab: {
id: 'tab',
type: TAB_TYPE,
children: [],
},
};
const dropResult = {
source: { id: DASHBOARD_GRID_ID, type: DASHBOARD_GRID_TYPE, index: 1 },
destination: {
id: DASHBOARD_ROOT_ID,
type: DASHBOARD_ROOT_TYPE,
index: 0,
},
dragging: { id: 'tabs', type: TABS_TYPE },
};
const result = layoutReducer(layout, {
type: CREATE_TOP_LEVEL_TABS,
payload: { dropResult },
});
expect(Object.keys(result)).to.have.length(Object.keys(layout).length);
expect(result[DASHBOARD_ROOT_ID].children[0]).to.equal('tabs');
expect(result.tabs.children[0]).to.equal('tab');
expect(result.tab.children).to.deep.equal(['child', 'child2']);
expect(result[DASHBOARD_GRID_ID].children).to.have.length(0);
});
it('should remove top-level tabs, moving children to the grid', () => {
const layout = {
[DASHBOARD_ROOT_ID]: {
id: DASHBOARD_ROOT_ID,
children: ['tabs'],
},
[DASHBOARD_GRID_ID]: {
id: DASHBOARD_GRID_ID,
children: [],
},
child: {
id: 'child',
children: [],
},
child2: {
id: 'child2',
children: [],
},
tabs: {
id: 'tabs',
type: TABS_TYPE,
children: ['tab'],
},
tab: {
id: 'tab',
type: TAB_TYPE,
children: ['child', 'child2'],
},
};
const dropResult = {
source: { id: DASHBOARD_GRID_ID, type: DASHBOARD_GRID_TYPE, index: 1 },
destination: {
id: DASHBOARD_ROOT_ID,
type: DASHBOARD_ROOT_TYPE,
index: 0,
},
dragging: { id: 'tabs', type: TABS_TYPE },
};
const result = layoutReducer(layout, {
type: DELETE_TOP_LEVEL_TABS,
payload: { dropResult },
});
expect(result).to.deep.equal({
[DASHBOARD_ROOT_ID]: {
id: DASHBOARD_ROOT_ID,
children: [DASHBOARD_GRID_ID],
},
[DASHBOARD_GRID_ID]: {
id: DASHBOARD_GRID_ID,
children: ['child', 'child2'],
},
child: {
id: 'child',
children: [],
},
child2: {
id: 'child2',
children: [],
},
});
});
it('should create a component', () => {
const layout = {
[DASHBOARD_ROOT_ID]: {
id: DASHBOARD_ROOT_ID,
children: [DASHBOARD_GRID_ID],
},
[DASHBOARD_GRID_ID]: {
id: DASHBOARD_GRID_ID,
children: ['child'],
},
child: { id: 'child' },
};
const dropResult = {
source: { id: NEW_COMPONENTS_SOURCE_ID, type: '' },
destination: {
id: DASHBOARD_GRID_ID,
type: DASHBOARD_GRID_TYPE,
index: 1,
},
dragging: { id: NEW_ROW_ID, type: ROW_TYPE },
};
const result = layoutReducer(layout, {
type: CREATE_COMPONENT,
payload: { dropResult },
});
const newId = result[DASHBOARD_GRID_ID].children[1];
expect(result[DASHBOARD_GRID_ID].children).to.have.length(2);
expect(result[newId].type).to.equal(ROW_TYPE);
});
});

View File

@@ -0,0 +1,241 @@
import { describe, it } from 'mocha';
import { expect } from 'chai';
import {
ADD_SLICE,
CHANGE_FILTER,
ON_CHANGE,
ON_SAVE,
REMOVE_SLICE,
SET_EDIT_MODE,
SET_MAX_UNDO_HISTORY_EXCEEDED,
SET_UNSAVED_CHANGES,
TOGGLE_BUILDER_PANE,
TOGGLE_EXPAND_SLICE,
TOGGLE_FAVE_STAR,
} from '../../../../src/dashboard/actions/dashboardState';
import dashboardStateReducer from '../../../../src/dashboard/reducers/dashboardState';
describe('dashboardState reducer', () => {
it('should return initial state', () => {
expect(dashboardStateReducer(undefined, {})).to.deep.equal({});
});
it('should add a slice', () => {
expect(
dashboardStateReducer(
{ sliceIds: [1] },
{ type: ADD_SLICE, slice: { slice_id: 2 } },
),
).to.deep.equal({ sliceIds: [1, 2] });
});
it('should remove a slice', () => {
expect(
dashboardStateReducer(
{ sliceIds: [1, 2], filters: {} },
{ type: REMOVE_SLICE, sliceId: 2 },
),
).to.deep.equal({ sliceIds: [1], refresh: false, filters: {} });
});
it('should reset filters if a removed slice is a filter', () => {
expect(
dashboardStateReducer(
{ sliceIds: [1, 2], filters: { 2: {}, 1: {} } },
{ type: REMOVE_SLICE, sliceId: 2 },
),
).to.deep.equal({ sliceIds: [1], filters: { 1: {} }, refresh: true });
});
it('should toggle fav star', () => {
expect(
dashboardStateReducer(
{ isStarred: false },
{ type: TOGGLE_FAVE_STAR, isStarred: true },
),
).to.deep.equal({ isStarred: true });
});
it('should toggle edit mode', () => {
expect(
dashboardStateReducer(
{ editMode: false },
{ type: SET_EDIT_MODE, editMode: true },
),
).to.deep.equal({ editMode: true, showBuilderPane: true });
});
it('should toggle builder pane', () => {
expect(
dashboardStateReducer(
{ showBuilderPane: false },
{ type: TOGGLE_BUILDER_PANE },
),
).to.deep.equal({ showBuilderPane: true });
expect(
dashboardStateReducer(
{ showBuilderPane: true },
{ type: TOGGLE_BUILDER_PANE },
),
).to.deep.equal({ showBuilderPane: false });
});
it('should toggle expanded slices', () => {
expect(
dashboardStateReducer(
{ expandedSlices: { 1: true, 2: false } },
{ type: TOGGLE_EXPAND_SLICE, sliceId: 1 },
),
).to.deep.equal({ expandedSlices: { 2: false } });
expect(
dashboardStateReducer(
{ expandedSlices: { 1: true, 2: false } },
{ type: TOGGLE_EXPAND_SLICE, sliceId: 2 },
),
).to.deep.equal({ expandedSlices: { 1: true, 2: true } });
});
it('should set hasUnsavedChanges', () => {
expect(dashboardStateReducer({}, { type: ON_CHANGE })).to.deep.equal({
hasUnsavedChanges: true,
});
expect(
dashboardStateReducer(
{},
{ type: SET_UNSAVED_CHANGES, payload: { hasUnsavedChanges: false } },
),
).to.deep.equal({
hasUnsavedChanges: false,
});
});
it('should set maxUndoHistoryExceeded', () => {
expect(
dashboardStateReducer(
{},
{
type: SET_MAX_UNDO_HISTORY_EXCEEDED,
payload: { maxUndoHistoryExceeded: true },
},
),
).to.deep.equal({
maxUndoHistoryExceeded: true,
});
});
it('should set unsaved changes, max undo history, and editMode to false on save', () => {
expect(
dashboardStateReducer({ hasUnsavedChanges: true }, { type: ON_SAVE }),
).to.deep.equal({
hasUnsavedChanges: false,
maxUndoHistoryExceeded: false,
editMode: false,
isV2Preview: false, // @TODO remove upon v1 deprecation
});
});
describe('change filter', () => {
it('should add a new filter if it does not exist', () => {
expect(
dashboardStateReducer(
{
filters: {},
sliceIds: [1],
},
{
type: CHANGE_FILTER,
chart: { id: 1, formData: { groupby: 'column' } },
col: 'column',
vals: ['b', 'a'],
refresh: true,
merge: true,
},
),
).to.deep.equal({
filters: { 1: { column: ['b', 'a'] } },
refresh: true,
sliceIds: [1],
});
});
it('should overwrite a filter if merge is false', () => {
expect(
dashboardStateReducer(
{
filters: {
1: { column: ['z'] },
},
sliceIds: [1],
},
{
type: CHANGE_FILTER,
chart: { id: 1, formData: { groupby: 'column' } },
col: 'column',
vals: ['b', 'a'],
refresh: true,
merge: false,
},
),
).to.deep.equal({
filters: { 1: { column: ['b', 'a'] } },
refresh: true,
sliceIds: [1],
});
});
it('should merge a filter if merge is true', () => {
expect(
dashboardStateReducer(
{
filters: {
1: { column: ['z'] },
},
sliceIds: [1],
},
{
type: CHANGE_FILTER,
chart: { id: 1, formData: { groupby: 'column' } },
col: 'column',
vals: ['b', 'a'],
refresh: true,
merge: true,
},
),
).to.deep.equal({
filters: { 1: { column: ['z', 'b', 'a'] } },
refresh: true,
sliceIds: [1],
});
});
it('should remove the filter if values are empty', () => {
expect(
dashboardStateReducer(
{
filters: {
1: { column: ['z'] },
},
sliceIds: [1],
},
{
type: CHANGE_FILTER,
chart: { id: 1, formData: { groupby: 'column' } },
col: 'column',
vals: [],
refresh: true,
merge: false,
},
),
).to.deep.equal({
filters: {},
refresh: true,
sliceIds: [1],
});
});
});
});

View File

@@ -0,0 +1,32 @@
import { describe, it } from 'mocha';
import { expect } from 'chai';
import {
ADD_TOAST,
REMOVE_TOAST,
} from '../../../../src/dashboard/actions/messageToasts';
import messageToastsReducer from '../../../../src/dashboard/reducers/messageToasts';
describe('messageToasts reducer', () => {
it('should return initial state', () => {
expect(messageToastsReducer(undefined, {})).to.deep.equal([]);
});
it('should add a toast', () => {
expect(
messageToastsReducer([], {
type: ADD_TOAST,
payload: { text: 'test', id: 'id', type: 'test_type' },
}),
).to.deep.equal([{ text: 'test', id: 'id', type: 'test_type' }]);
});
it('should add a toast', () => {
expect(
messageToastsReducer([{ id: 'id' }, { id: 'id2' }], {
type: REMOVE_TOAST,
payload: { id: 'id' },
}),
).to.deep.equal([{ id: 'id2' }]);
});
});

View File

@@ -0,0 +1,51 @@
import { describe, it } from 'mocha';
import { expect } from 'chai';
import {
FETCH_ALL_SLICES_FAILED,
FETCH_ALL_SLICES_STARTED,
SET_ALL_SLICES,
} from '../../../../src/dashboard/actions/sliceEntities';
import sliceEntitiesReducer from '../../../../src/dashboard/reducers/sliceEntities';
describe('sliceEntities reducer', () => {
it('should return initial state', () => {
expect(sliceEntitiesReducer({}, {})).to.deep.equal({});
});
it('should set loading when fetching slices', () => {
expect(
sliceEntitiesReducer(
{ isLoading: false },
{ type: FETCH_ALL_SLICES_STARTED },
).isLoading,
).to.equal(true);
});
it('should set slices', () => {
const result = sliceEntitiesReducer(
{ slices: { a: {} } },
{ type: SET_ALL_SLICES, slices: { 1: {}, 2: {} } },
);
expect(result.slices).to.deep.equal({
1: {},
2: {},
a: {},
});
expect(result.isLoading).to.equal(false);
});
it('should set an error on error', () => {
const result = sliceEntitiesReducer(
{},
{
type: FETCH_ALL_SLICES_FAILED,
error: { responseJSON: { message: 'errorrr' } },
},
);
expect(result.isLoading).to.equal(false);
expect(result.errorMessage.indexOf('errorrr')).to.be.above(-1);
});
});

View File

@@ -1,35 +0,0 @@
import { describe, it } from 'mocha';
import { expect } from 'chai';
import { dashboard as reducers } from '../../../src/dashboard/reducers';
import * as actions from '../../../src/dashboard/actions';
import { defaultFilters, dashboard as initState } from './fixtures';
describe('Dashboard reducers', () => {
it('should remove slice', () => {
const action = {
type: actions.REMOVE_SLICE,
slice: initState.dashboard.slices[1],
};
expect(initState.dashboard.slices).to.have.length(3);
const { dashboard, filters, refresh } = reducers(initState, action);
expect(dashboard.slices).to.have.length(2);
expect(filters).to.deep.equal(defaultFilters);
expect(refresh).to.equal(false);
});
it('should remove filter slice', () => {
const action = {
type: actions.REMOVE_SLICE,
slice: initState.dashboard.slices[0],
};
const initFilters = Object.keys(initState.filters);
expect(initFilters).to.have.length(2);
const { dashboard, filters, refresh } = reducers(initState, action);
expect(dashboard.slices).to.have.length(2);
expect(Object.keys(filters)).to.have.length(1);
expect(refresh).to.equal(true);
});
});

View File

@@ -0,0 +1,42 @@
import { describe, it } from 'mocha';
import { expect } from 'chai';
import componentIsResizable from '../../../../src/dashboard/util/componentIsResizable';
import {
CHART_TYPE,
COLUMN_TYPE,
DASHBOARD_GRID_TYPE,
DASHBOARD_ROOT_TYPE,
DIVIDER_TYPE,
HEADER_TYPE,
MARKDOWN_TYPE,
ROW_TYPE,
TABS_TYPE,
TAB_TYPE,
} from '../../../../src/dashboard/util/componentTypes';
const notResizable = [
DASHBOARD_GRID_TYPE,
DASHBOARD_ROOT_TYPE,
DIVIDER_TYPE,
HEADER_TYPE,
ROW_TYPE,
TABS_TYPE,
TAB_TYPE,
];
const resizable = [COLUMN_TYPE, CHART_TYPE, MARKDOWN_TYPE];
describe('componentIsResizable', () => {
resizable.forEach(type => {
it(`should return true for ${type}`, () => {
expect(componentIsResizable({ type })).to.equal(true);
});
});
notResizable.forEach(type => {
it(`should return false for ${type}`, () => {
expect(componentIsResizable({ type })).to.equal(false);
});
});
});

View File

@@ -0,0 +1,62 @@
import { describe, it } from 'mocha';
import { expect } from 'chai';
import reorderItem from '../../../../src/dashboard/util/dnd-reorder';
describe('dnd-reorderItem', () => {
it('should remove the item from its source entity and add it to its destination entity', () => {
const result = reorderItem({
entitiesMap: {
a: {
id: 'a',
children: ['x', 'y', 'z'],
},
b: {
id: 'b',
children: ['banana'],
},
},
source: { id: 'a', index: 2 },
destination: { id: 'b', index: 1 },
});
expect(result.a.children).to.deep.equal(['x', 'y']);
expect(result.b.children).to.deep.equal(['banana', 'z']);
});
it('should correctly move elements within the same list', () => {
const result = reorderItem({
entitiesMap: {
a: {
id: 'a',
children: ['x', 'y', 'z'],
},
},
source: { id: 'a', index: 2 },
destination: { id: 'a', index: 0 },
});
expect(result.a.children).to.deep.equal(['z', 'x', 'y']);
});
it('should copy items that do not move into the result', () => {
const extraEntity = {};
const result = reorderItem({
entitiesMap: {
a: {
id: 'a',
children: ['x', 'y', 'z'],
},
b: {
id: 'b',
children: ['banana'],
},
iAmExtra: extraEntity,
},
source: { id: 'a', index: 2 },
destination: { id: 'b', index: 1 },
});
expect(result.iAmExtra === extraEntity).to.equal(true);
});
});

View File

@@ -0,0 +1,227 @@
import { describe, it } from 'mocha';
import { expect } from 'chai';
import dropOverflowsParent from '../../../../src/dashboard/util/dropOverflowsParent';
import { NEW_COMPONENTS_SOURCE_ID } from '../../../../src/dashboard/util/constants';
import {
CHART_TYPE,
COLUMN_TYPE,
ROW_TYPE,
HEADER_TYPE,
TAB_TYPE,
} from '../../../../src/dashboard/util/componentTypes';
describe('dropOverflowsParent', () => {
it('returns true if a parent does NOT have adequate width for child', () => {
const dropResult = {
source: { id: '_' },
destination: { id: 'a' },
dragging: { id: 'z' },
};
const layout = {
a: {
id: 'a',
type: ROW_TYPE,
children: ['b', 'b', 'b', 'b'], // width = 4x bs = 12
},
b: {
id: 'b',
type: CHART_TYPE,
meta: {
width: 3,
},
},
z: {
id: 'z',
type: CHART_TYPE,
meta: {
width: 2,
},
},
};
expect(dropOverflowsParent(dropResult, layout)).to.equal(true);
});
it('returns false if a parent DOES have adequate width for child', () => {
const dropResult = {
source: { id: '_' },
destination: { id: 'a' },
dragging: { id: 'z' },
};
const layout = {
a: {
id: 'a',
type: ROW_TYPE,
children: ['b', 'b'],
},
b: {
id: 'b',
type: CHART_TYPE,
meta: {
width: 3,
},
},
z: {
id: 'z',
type: CHART_TYPE,
meta: {
width: 2,
},
},
};
expect(dropOverflowsParent(dropResult, layout)).to.equal(false);
});
it('returns false if a child CAN shrink to available parent space', () => {
const dropResult = {
source: { id: '_' },
destination: { id: 'a' },
dragging: { id: 'z' },
};
const layout = {
a: {
id: 'a',
type: ROW_TYPE,
children: ['b', 'b'], // 2x b = 10
},
b: {
id: 'b',
type: CHART_TYPE,
meta: {
width: 5,
},
},
z: {
id: 'z',
type: CHART_TYPE,
meta: {
width: 10,
},
},
};
expect(dropOverflowsParent(dropResult, layout)).to.equal(false);
});
it('returns true if a child CANNOT shrink to available parent space', () => {
const dropResult = {
source: { id: '_' },
destination: { id: 'a' },
dragging: { id: 'b' },
};
const layout = {
a: {
id: 'a',
type: COLUMN_TYPE,
meta: {
width: 6,
},
},
// rows with children cannot shrink
b: {
id: 'b',
type: ROW_TYPE,
children: ['bChild', 'bChild', 'bChild'],
},
bChild: {
id: 'bChild',
type: CHART_TYPE,
meta: {
width: 3,
},
},
};
expect(dropOverflowsParent(dropResult, layout)).to.equal(true);
});
it('returns true if a column has children that CANNOT shrink to available parent space', () => {
const dropResult = {
source: { id: '_' },
destination: { id: 'destination' },
dragging: { id: 'dragging' },
};
const layout = {
destination: {
id: 'destination',
type: ROW_TYPE,
children: ['b', 'b'], // 2x b = 10, 2 available
},
b: {
id: 'b',
type: CHART_TYPE,
meta: {
width: 5,
},
},
dragging: {
id: 'dragging',
type: COLUMN_TYPE,
meta: {
width: 10,
},
children: ['rowWithChildren'], // 2x b = width 10
},
rowWithChildren: {
id: 'rowWithChildren',
type: ROW_TYPE,
children: ['b', 'b'],
},
};
expect(dropOverflowsParent(dropResult, layout)).to.equal(true);
// remove children
expect(
dropOverflowsParent(dropResult, {
...layout,
dragging: { ...layout.dragging, children: [] },
}),
).to.equal(false);
});
it('should work with new components that are not in the layout', () => {
const dropResult = {
source: { id: NEW_COMPONENTS_SOURCE_ID },
destination: { id: 'a' },
dragging: { type: CHART_TYPE },
};
const layout = {
a: {
id: 'a',
type: ROW_TYPE,
children: [],
},
};
expect(dropOverflowsParent(dropResult, layout)).to.equal(false);
});
it('source/destination without widths should not overflow parent', () => {
const dropResult = {
source: { id: '_' },
destination: { id: 'tab' },
dragging: { id: 'header' },
};
const layout = {
tab: {
id: 'tab',
type: TAB_TYPE,
},
header: {
id: 'header',
type: HEADER_TYPE,
},
};
expect(dropOverflowsParent(dropResult, layout)).to.equal(false);
});
});

View File

@@ -0,0 +1,116 @@
import { describe, it } from 'mocha';
import { expect } from 'chai';
import findFirstParentContainerId from '../../../../src/dashboard/util/findFirstParentContainer';
import {
DASHBOARD_GRID_ID,
DASHBOARD_ROOT_ID,
} from '../../../../src/dashboard/util/constants';
describe('findFirstParentContainer', () => {
const mockGridLayout = {
DASHBOARD_VERSION_KEY: 'v2',
DASHBOARD_ROOT_ID: {
type: 'DASHBOARD_ROOT_TYPE',
id: 'DASHBOARD_ROOT_ID',
children: ['DASHBOARD_GRID_ID'],
},
DASHBOARD_GRID_ID: {
type: 'DASHBOARD_GRID_TYPE',
id: 'DASHBOARD_GRID_ID',
children: ['DASHBOARD_ROW_TYPE-Bk45URrlQ'],
},
'DASHBOARD_ROW_TYPE-Bk45URrlQ': {
type: 'DASHBOARD_ROW_TYPE',
id: 'DASHBOARD_ROW_TYPE-Bk45URrlQ',
children: ['DASHBOARD_CHART_TYPE-ryxVc8RHlX'],
},
'DASHBOARD_CHART_TYPE-ryxVc8RHlX': {
type: 'DASHBOARD_CHART_TYPE',
id: 'DASHBOARD_CHART_TYPE-ryxVc8RHlX',
children: [],
},
DASHBOARD_HEADER_ID: {
id: 'DASHBOARD_HEADER_ID',
type: 'DASHBOARD_HEADER_TYPE',
},
};
const mockTabsLayout = {
'DASHBOARD_CHART_TYPE-S1gilYABe7': {
children: [],
id: 'DASHBOARD_CHART_TYPE-S1gilYABe7',
type: 'DASHBOARD_CHART_TYPE',
},
'DASHBOARD_CHART_TYPE-SJli5K0HlQ': {
children: [],
id: 'DASHBOARD_CHART_TYPE-SJli5K0HlQ',
type: 'DASHBOARD_CHART_TYPE',
},
DASHBOARD_GRID_ID: {
children: [],
id: 'DASHBOARD_GRID_ID',
type: 'DASHBOARD_GRID_TYPE',
},
DASHBOARD_HEADER_ID: {
id: 'DASHBOARD_HEADER_ID',
type: 'DASHBOARD_HEADER_TYPE',
},
DASHBOARD_ROOT_ID: {
children: ['DASHBOARD_TABS_TYPE-SkgJ5t0Bem'],
id: 'DASHBOARD_ROOT_ID',
type: 'DASHBOARD_ROOT_TYPE',
},
'DASHBOARD_ROW_TYPE-S1B8-JLgX': {
children: ['DASHBOARD_CHART_TYPE-SJli5K0HlQ'],
id: 'DASHBOARD_ROW_TYPE-S1B8-JLgX',
type: 'DASHBOARD_ROW_TYPE',
},
'DASHBOARD_ROW_TYPE-S1bUb1Ilm': {
children: ['DASHBOARD_CHART_TYPE-S1gilYABe7'],
id: 'DASHBOARD_ROW_TYPE-S1bUb1Ilm',
type: 'DASHBOARD_ROW_TYPE',
},
'DASHBOARD_TABS_TYPE-ByeLSWyLe7': {
children: ['DASHBOARD_TAB_TYPE-BJbLSZ1UeQ'],
id: 'DASHBOARD_TABS_TYPE-ByeLSWyLe7',
type: 'DASHBOARD_TABS_TYPE',
},
'DASHBOARD_TABS_TYPE-SkgJ5t0Bem': {
children: [
'DASHBOARD_TAB_TYPE-HkWJcFCHxQ',
'DASHBOARD_TAB_TYPE-ByDBbkLlQ',
],
id: 'DASHBOARD_TABS_TYPE-SkgJ5t0Bem',
meta: {},
type: 'DASHBOARD_TABS_TYPE',
},
'DASHBOARD_TAB_TYPE-BJbLSZ1UeQ': {
children: ['DASHBOARD_ROW_TYPE-S1bUb1Ilm'],
id: 'DASHBOARD_TAB_TYPE-BJbLSZ1UeQ',
type: 'DASHBOARD_TAB_TYPE',
},
'DASHBOARD_TAB_TYPE-ByDBbkLlQ': {
children: ['DASHBOARD_ROW_TYPE-S1B8-JLgX'],
id: 'DASHBOARD_TAB_TYPE-ByDBbkLlQ',
type: 'DASHBOARD_TAB_TYPE',
},
'DASHBOARD_TAB_TYPE-HkWJcFCHxQ': {
children: ['DASHBOARD_TABS_TYPE-ByeLSWyLe7'],
id: 'DASHBOARD_TAB_TYPE-HkWJcFCHxQ',
type: 'DASHBOARD_TAB_TYPE',
},
DASHBOARD_VERSION_KEY: 'v2',
};
it('should return grid root', () => {
expect(findFirstParentContainerId(mockGridLayout)).to.equal(
DASHBOARD_GRID_ID,
);
});
it('should return first tab', () => {
const tabsId = mockTabsLayout[DASHBOARD_ROOT_ID].children[0];
const firstTabId = mockTabsLayout[tabsId].children[0];
expect(findFirstParentContainerId(mockTabsLayout)).to.equal(firstTabId);
});
});

View File

@@ -0,0 +1,29 @@
import { describe, it } from 'mocha';
import { expect } from 'chai';
import findParentId from '../../../../src/dashboard/util/findParentId';
describe('findParentId', () => {
const layout = {
a: {
id: 'a',
children: ['b', 'r', 'k'],
},
b: {
id: 'b',
children: ['x', 'y', 'z'],
},
z: {
id: 'z',
children: [],
},
};
it('should return the correct parentId', () => {
expect(findParentId({ childId: 'b', layout })).to.equal('a');
expect(findParentId({ childId: 'z', layout })).to.equal('b');
});
it('should return null if the parent cannot be found', () => {
expect(findParentId({ childId: 'a', layout })).to.equal(null);
});
});

View File

@@ -0,0 +1,41 @@
import { describe, it } from 'mocha';
import { expect } from 'chai';
import getChartIdsFromLayout from '../../../../src/dashboard/util/getChartIdsFromLayout';
import {
ROW_TYPE,
CHART_TYPE,
} from '../../../../src/dashboard/util/componentTypes';
describe('getChartIdsFromLayout', () => {
const mockLayout = {
a: {
id: 'a',
type: CHART_TYPE,
meta: { chartId: 'A' },
},
b: {
id: 'b',
type: CHART_TYPE,
meta: { chartId: 'B' },
},
c: {
id: 'c',
type: ROW_TYPE,
meta: { chartId: 'C' },
},
};
it('should return an array of chartIds', () => {
const result = getChartIdsFromLayout(mockLayout);
expect(Array.isArray(result)).to.equal(true);
expect(result.includes('A')).to.equal(true);
expect(result.includes('B')).to.equal(true);
});
it('should return ids only from CHART_TYPE components', () => {
const result = getChartIdsFromLayout(mockLayout);
expect(result.length).to.equal(2);
expect(result.includes('C')).to.equal(false);
});
});

View File

@@ -0,0 +1,223 @@
import { describe, it } from 'mocha';
import { expect } from 'chai';
import getDetailedComponentWidth from '../../../../src/dashboard/util/getDetailedComponentWidth';
import * as types from '../../../../src/dashboard/util/componentTypes';
import {
GRID_COLUMN_COUNT,
GRID_MIN_COLUMN_COUNT,
} from '../../../../src/dashboard/util/constants';
describe('getDetailedComponentWidth', () => {
it('should return an object with width, minimumWidth, and occupiedWidth', () => {
expect(
getDetailedComponentWidth({ id: '_', components: {} }),
).to.have.all.keys(['minimumWidth', 'occupiedWidth', 'width']);
});
describe('width', () => {
it('should be undefined if the component is not resizable and has no defined width', () => {
const empty = {
width: undefined,
occupiedWidth: undefined,
minimumWidth: undefined,
};
expect(
getDetailedComponentWidth({
component: { id: '', type: types.HEADER_TYPE },
}),
).to.deep.equal(empty);
expect(
getDetailedComponentWidth({
component: { id: '', type: types.DIVIDER_TYPE },
}),
).to.deep.equal(empty);
expect(
getDetailedComponentWidth({
component: { id: '', type: types.TAB_TYPE },
}),
).to.deep.equal(empty);
});
it('should match component meta width for resizeable components', () => {
expect(
getDetailedComponentWidth({
component: { id: '', type: types.CHART_TYPE, meta: { width: 1 } },
}),
).to.deep.equal({ width: 1, occupiedWidth: 1, minimumWidth: 1 });
expect(
getDetailedComponentWidth({
component: { id: '', type: types.MARKDOWN_TYPE, meta: { width: 2 } },
}),
).to.deep.equal({ width: 2, occupiedWidth: 2, minimumWidth: 1 });
expect(
getDetailedComponentWidth({
component: { id: '', type: types.COLUMN_TYPE, meta: { width: 3 } },
}),
// note: occupiedWidth is zero for colunns/see test below
).to.deep.equal({ width: 3, occupiedWidth: 0, minimumWidth: 1 });
});
it('should be GRID_COLUMN_COUNT for row components WITHOUT parents', () => {
expect(
getDetailedComponentWidth({
id: 'row',
components: { row: { id: 'row', type: types.ROW_TYPE } },
}),
).to.deep.equal({
width: GRID_COLUMN_COUNT,
occupiedWidth: 0,
minimumWidth: GRID_MIN_COLUMN_COUNT,
});
});
it('should match parent width for row components WITH parents', () => {
expect(
getDetailedComponentWidth({
id: 'row',
components: {
row: { id: 'row', type: types.ROW_TYPE },
parent: {
id: 'parent',
type: types.COLUMN_TYPE,
children: ['row'],
meta: { width: 7 },
},
},
}),
).to.deep.equal({
width: 7,
occupiedWidth: 0,
minimumWidth: GRID_MIN_COLUMN_COUNT,
});
});
it('should use either id or component (to support new components)', () => {
expect(
getDetailedComponentWidth({
id: 'id',
components: {
id: { id: 'id', type: types.CHART_TYPE, meta: { width: 6 } },
},
}).width,
).to.equal(6);
expect(
getDetailedComponentWidth({
component: { id: 'id', type: types.CHART_TYPE, meta: { width: 6 } },
}).width,
).to.equal(6);
});
});
describe('occupiedWidth', () => {
it('should reflect the sum of child widths for row components', () => {
expect(
getDetailedComponentWidth({
id: 'row',
components: {
row: {
id: 'row',
type: types.ROW_TYPE,
children: ['child', 'child'],
},
child: { id: 'child', meta: { width: 3.5 } },
},
}),
).to.deep.equal({ width: 12, occupiedWidth: 7, minimumWidth: 7 });
});
it('should always be zero for column components', () => {
expect(
getDetailedComponentWidth({
component: { id: '', type: types.COLUMN_TYPE, meta: { width: 2 } },
}),
).to.deep.equal({ width: 2, occupiedWidth: 0, minimumWidth: 1 });
});
});
describe('minimumWidth', () => {
it('should equal GRID_MIN_COLUMN_COUNT for resizable components', () => {
expect(
getDetailedComponentWidth({
component: { id: '', type: types.CHART_TYPE, meta: { width: 1 } },
}),
).to.deep.equal({
width: 1,
minimumWidth: GRID_MIN_COLUMN_COUNT,
occupiedWidth: 1,
});
expect(
getDetailedComponentWidth({
component: { id: '', type: types.MARKDOWN_TYPE, meta: { width: 2 } },
}),
).to.deep.equal({
width: 2,
minimumWidth: GRID_MIN_COLUMN_COUNT,
occupiedWidth: 2,
});
expect(
getDetailedComponentWidth({
component: { id: '', type: types.COLUMN_TYPE, meta: { width: 3 } },
}),
).to.deep.equal({
width: 3,
minimumWidth: GRID_MIN_COLUMN_COUNT,
occupiedWidth: 0,
});
});
it('should equal the width of row children for column components with row children', () => {
expect(
getDetailedComponentWidth({
id: 'column',
components: {
column: {
id: 'column',
type: types.COLUMN_TYPE,
children: ['rowChild', 'ignoredChartChild'],
meta: { width: 12 },
},
rowChild: {
id: 'rowChild',
type: types.ROW_TYPE,
children: ['rowChildChild', 'rowChildChild'],
},
rowChildChild: {
id: 'rowChildChild',
meta: { width: 3.5 },
},
ignoredChartChild: {
id: 'ignoredChartChild',
meta: { width: 100 },
},
},
}),
// occupiedWidth is zero for colunns/see test below
).to.deep.equal({ width: 12, occupiedWidth: 0, minimumWidth: 7 });
});
it('should equal occupiedWidth for row components', () => {
expect(
getDetailedComponentWidth({
id: 'row',
components: {
row: {
id: 'row',
type: types.ROW_TYPE,
children: ['child', 'child'],
},
child: { id: 'child', meta: { width: 3.5 } },
},
}),
).to.deep.equal({ width: 12, occupiedWidth: 7, minimumWidth: 7 });
});
});
});

View File

@@ -0,0 +1,422 @@
import { describe, it } from 'mocha';
import { expect } from 'chai';
import getDropPosition, {
DROP_TOP,
DROP_RIGHT,
DROP_BOTTOM,
DROP_LEFT,
} from '../../../../src/dashboard/util/getDropPosition';
import {
CHART_TYPE,
DASHBOARD_GRID_TYPE,
DASHBOARD_ROOT_TYPE,
HEADER_TYPE,
ROW_TYPE,
TAB_TYPE,
} from '../../../../src/dashboard/util/componentTypes';
describe('getDropPosition', () => {
// helper to easily configure test
function getMocks({
parentType,
componentType,
draggingType,
depth = 1,
hasChildren = false,
orientation = 'row',
clientOffset = { x: 0, y: 0 },
boundingClientRect = {
top: 0,
right: 0,
bottom: 0,
left: 0,
},
isDraggingOverShallow = true,
}) {
const monitorMock = {
getItem: () => ({
id: 'id',
type: draggingType,
}),
getClientOffset: () => clientOffset,
};
const ComponentMock = {
props: {
depth,
parentComponent: {
type: parentType,
},
component: {
type: componentType,
children: hasChildren ? [''] : [],
},
orientation,
isDraggingOverShallow,
},
ref: {
getBoundingClientRect: () => boundingClientRect,
},
};
return [monitorMock, ComponentMock];
}
describe('invalid child + invalid sibling', () => {
it('should return null', () => {
const result = getDropPosition(
// TAB is an invalid child + sibling of GRID > ROW
...getMocks({
parentType: DASHBOARD_GRID_TYPE,
componentType: ROW_TYPE,
draggingType: TAB_TYPE,
}),
);
expect(result).to.equal(null);
});
});
describe('valid child + invalid sibling', () => {
it('should return DROP_LEFT if component has NO children, and orientation is "row"', () => {
// HEADER is a valid child + invalid sibling of ROOT > GRID
const result = getDropPosition(
...getMocks({
parentType: DASHBOARD_ROOT_TYPE,
componentType: DASHBOARD_GRID_TYPE,
draggingType: HEADER_TYPE,
}),
);
expect(result).to.equal(DROP_LEFT);
});
it('should return DROP_RIGHT if component HAS children, and orientation is "row"', () => {
const result = getDropPosition(
...getMocks({
parentType: DASHBOARD_ROOT_TYPE,
componentType: DASHBOARD_GRID_TYPE,
draggingType: HEADER_TYPE,
hasChildren: true,
}),
);
expect(result).to.equal(DROP_RIGHT);
});
it('should return DROP_TOP if component has NO children, and orientation is "column"', () => {
const result = getDropPosition(
...getMocks({
parentType: DASHBOARD_ROOT_TYPE,
componentType: DASHBOARD_GRID_TYPE,
draggingType: HEADER_TYPE,
orientation: 'column',
}),
);
expect(result).to.equal(DROP_TOP);
});
it('should return DROP_BOTTOM if component HAS children, and orientation is "column"', () => {
const result = getDropPosition(
...getMocks({
parentType: DASHBOARD_ROOT_TYPE,
componentType: DASHBOARD_GRID_TYPE,
draggingType: HEADER_TYPE,
orientation: 'column',
hasChildren: true,
}),
);
expect(result).to.equal(DROP_BOTTOM);
});
});
describe('invalid child + valid sibling', () => {
it('should return DROP_TOP if orientation="row" and clientOffset is closer to component top than bottom', () => {
const result = getDropPosition(
// HEADER is an invalid child but valid sibling of GRID > ROW
...getMocks({
parentType: DASHBOARD_GRID_TYPE,
componentType: ROW_TYPE,
draggingType: HEADER_TYPE,
clientOffset: { y: 10 },
boundingClientRect: {
top: 0,
bottom: 100,
},
}),
);
expect(result).to.equal(DROP_TOP);
});
it('should return DROP_BOTTOM if orientation="row" and clientOffset is closer to component bottom than top', () => {
const result = getDropPosition(
...getMocks({
parentType: DASHBOARD_GRID_TYPE,
componentType: ROW_TYPE,
draggingType: HEADER_TYPE,
clientOffset: { y: 55 },
boundingClientRect: {
top: 0,
bottom: 100,
},
}),
);
expect(result).to.equal(DROP_BOTTOM);
});
it('should return DROP_LEFT if orientation="column" and clientOffset is closer to component left than right', () => {
const result = getDropPosition(
...getMocks({
parentType: DASHBOARD_GRID_TYPE,
componentType: ROW_TYPE,
draggingType: HEADER_TYPE,
orientation: 'column',
clientOffset: { x: 45 },
boundingClientRect: {
left: 0,
right: 100,
},
}),
);
expect(result).to.equal(DROP_LEFT);
});
it('should return DROP_RIGHT if orientation="column" and clientOffset is closer to component right than left', () => {
const result = getDropPosition(
...getMocks({
parentType: DASHBOARD_GRID_TYPE,
componentType: ROW_TYPE,
draggingType: HEADER_TYPE,
orientation: 'column',
clientOffset: { x: 55 },
boundingClientRect: {
left: 0,
right: 100,
},
}),
);
expect(result).to.equal(DROP_RIGHT);
});
});
describe('child + valid sibling (row orientation)', () => {
it('should return DROP_LEFT if component has NO children, and clientOffset is NOT near top/bottom sibling boundary', () => {
const result = getDropPosition(
// CHART is a valid child + sibling of GRID > ROW
...getMocks({
parentType: DASHBOARD_GRID_TYPE,
componentType: ROW_TYPE,
draggingType: CHART_TYPE,
clientOffset: { x: 10, y: 50 },
boundingClientRect: {
left: 0,
right: 100,
top: 0,
bottom: 100,
},
}),
);
expect(result).to.equal(DROP_LEFT);
});
it('should return DROP_RIGHT if component HAS children, and clientOffset is NOT near top/bottom sibling boundary', () => {
const result = getDropPosition(
...getMocks({
parentType: DASHBOARD_GRID_TYPE,
componentType: ROW_TYPE,
draggingType: CHART_TYPE,
hasChildren: true,
clientOffset: { x: 10, y: 50 },
boundingClientRect: {
left: 0,
right: 100,
top: 0,
bottom: 100,
},
}),
);
expect(result).to.equal(DROP_RIGHT);
});
it('should return DROP_TOP regardless of component children if clientOffset IS near top sibling boundary', () => {
const noChildren = getDropPosition(
...getMocks({
parentType: DASHBOARD_GRID_TYPE,
componentType: ROW_TYPE,
draggingType: CHART_TYPE,
clientOffset: { x: 10, y: 2 },
boundingClientRect: {
left: 0,
right: 100,
top: 0,
bottom: 100,
},
}),
);
const withChildren = getDropPosition(
...getMocks({
parentType: DASHBOARD_GRID_TYPE,
componentType: ROW_TYPE,
draggingType: CHART_TYPE,
hasChildren: true,
clientOffset: { x: 10, y: 2 },
boundingClientRect: {
left: 0,
right: 100,
top: 0,
bottom: 100,
},
}),
);
expect(noChildren).to.equal(DROP_TOP);
expect(withChildren).to.equal(DROP_TOP);
});
it('should return DROP_BOTTOM regardless of component children if clientOffset IS near bottom sibling boundary', () => {
const noChildren = getDropPosition(
...getMocks({
parentType: DASHBOARD_GRID_TYPE,
componentType: ROW_TYPE,
draggingType: CHART_TYPE,
clientOffset: { x: 10, y: 95 },
boundingClientRect: {
left: 0,
right: 100,
top: 0,
bottom: 100,
},
}),
);
const withChildren = getDropPosition(
...getMocks({
parentType: DASHBOARD_GRID_TYPE,
componentType: ROW_TYPE,
draggingType: CHART_TYPE,
hasChildren: true,
clientOffset: { x: 10, y: 95 },
boundingClientRect: {
left: 0,
right: 100,
top: 0,
bottom: 100,
},
}),
);
expect(noChildren).to.equal(DROP_BOTTOM);
expect(withChildren).to.equal(DROP_BOTTOM);
});
});
describe('child + valid sibling (column orientation)', () => {
it('should return DROP_TOP if component has NO children, and clientOffset is NOT near left/right sibling boundary', () => {
const result = getDropPosition(
// CHART is a valid child + sibling of GRID > ROW
...getMocks({
parentType: DASHBOARD_GRID_TYPE,
componentType: ROW_TYPE,
draggingType: CHART_TYPE,
orientation: 'column',
clientOffset: { x: 50, y: 0 },
boundingClientRect: {
left: 0,
right: 100,
top: 0,
bottom: 100,
},
}),
);
expect(result).to.equal(DROP_TOP);
});
it('should return DROP_BOTTOM if component HAS children, and clientOffset is NOT near left/right sibling boundary', () => {
const result = getDropPosition(
...getMocks({
parentType: DASHBOARD_GRID_TYPE,
componentType: ROW_TYPE,
draggingType: CHART_TYPE,
orientation: 'column',
hasChildren: true,
clientOffset: { x: 50, y: 0 },
boundingClientRect: {
left: 0,
right: 100,
top: 0,
bottom: 100,
},
}),
);
expect(result).to.equal(DROP_BOTTOM);
});
it('should return DROP_LEFT regardless of component children if clientOffset IS near left sibling boundary', () => {
const noChildren = getDropPosition(
...getMocks({
parentType: DASHBOARD_GRID_TYPE,
componentType: ROW_TYPE,
draggingType: CHART_TYPE,
orientation: 'column',
clientOffset: { x: 10, y: 2 },
boundingClientRect: {
left: 0,
right: 100,
top: 0,
bottom: 100,
},
}),
);
const withChildren = getDropPosition(
...getMocks({
parentType: DASHBOARD_GRID_TYPE,
componentType: ROW_TYPE,
draggingType: CHART_TYPE,
orientation: 'column',
hasChildren: true,
clientOffset: { x: 10, y: 2 },
boundingClientRect: {
left: 0,
right: 100,
top: 0,
bottom: 100,
},
}),
);
expect(noChildren).to.equal(DROP_LEFT);
expect(withChildren).to.equal(DROP_LEFT);
});
it('should return DROP_RIGHT regardless of component children if clientOffset IS near right sibling boundary', () => {
const noChildren = getDropPosition(
...getMocks({
parentType: DASHBOARD_GRID_TYPE,
componentType: ROW_TYPE,
draggingType: CHART_TYPE,
orientation: 'column',
clientOffset: { x: 90, y: 95 },
boundingClientRect: {
left: 0,
right: 100,
top: 0,
bottom: 100,
},
}),
);
const withChildren = getDropPosition(
...getMocks({
parentType: DASHBOARD_GRID_TYPE,
componentType: ROW_TYPE,
draggingType: CHART_TYPE,
orientation: 'column',
hasChildren: true,
clientOffset: { x: 90, y: 95 },
boundingClientRect: {
left: 0,
right: 100,
top: 0,
bottom: 100,
},
}),
);
expect(noChildren).to.equal(DROP_RIGHT);
expect(withChildren).to.equal(DROP_RIGHT);
});
});
});

View File

@@ -0,0 +1,70 @@
import { describe, it } from 'mocha';
import { expect } from 'chai';
import getFormDataWithExtraFilters from '../../../../src/dashboard/util/charts/getFormDataWithExtraFilters';
describe('getFormDataWithExtraFilters', () => {
const chartId = 'chartId';
const mockArgs = {
chart: {
id: chartId,
formData: {
filters: [
{
col: 'country_name',
op: 'in',
val: ['United States'],
},
],
},
},
dashboardMetadata: {
filter_immune_slices: [],
filter_immune_slice_fields: {},
},
filters: {
filterId: {
region: ['Spain'],
color: ['pink', 'purple'],
},
},
sliceId: chartId,
};
it('should include filters from the passed filters', () => {
const result = getFormDataWithExtraFilters(mockArgs);
expect(result.extra_filters).to.have.length(2);
expect(result.extra_filters[0]).to.deep.equal({
col: 'region',
op: 'in',
val: ['Spain'],
});
expect(result.extra_filters[1]).to.deep.equal({
col: 'color',
op: 'in',
val: ['pink', 'purple'],
});
});
it('should not add additional filters if the slice is immune to them', () => {
const result = getFormDataWithExtraFilters({
...mockArgs,
dashboardMetadata: {
filter_immune_slices: [chartId],
},
});
expect(result.extra_filters).to.have.length(0);
});
it('should not add additional filters for fields to which the slice is immune', () => {
const result = getFormDataWithExtraFilters({
...mockArgs,
dashboardMetadata: {
filter_immune_slice_fields: {
[chartId]: ['region'],
},
},
});
expect(result.extra_filters).to.have.length(1);
});
});

View File

@@ -0,0 +1,147 @@
import { describe, it } from 'mocha';
import { expect } from 'chai';
import isValidChild from '../../../../src/dashboard/util/isValidChild';
import {
CHART_TYPE as CHART,
COLUMN_TYPE as COLUMN,
DASHBOARD_GRID_TYPE as GRID,
DASHBOARD_ROOT_TYPE as ROOT,
DIVIDER_TYPE as DIVIDER,
HEADER_TYPE as HEADER,
MARKDOWN_TYPE as MARKDOWN,
ROW_TYPE as ROW,
TABS_TYPE as TABS,
TAB_TYPE as TAB,
} from '../../../../src/dashboard/util/componentTypes';
const getIndentation = depth =>
Array(depth * 3)
.fill('')
.join('-');
describe('isValidChild', () => {
describe('valid calls', () => {
// these are representations of nested structures for easy testing
// [ROOT (depth 0) > GRID (depth 1) > HEADER (depth 2)]
// every unique parent > child relationship is tested, but because this
// test representation WILL result in duplicates, we hash each test
// to keep track of which we've run
const didTest = {};
const validExamples = [
[ROOT, GRID, CHART], // chart is valid because it is wrapped in a row
[ROOT, GRID, MARKDOWN], // markdown is valid because it is wrapped in a row
[ROOT, GRID, COLUMN], // column is valid because it is wrapped in a row
[ROOT, GRID, HEADER],
[ROOT, GRID, ROW, MARKDOWN],
[ROOT, GRID, ROW, CHART],
[ROOT, GRID, ROW, COLUMN, HEADER],
[ROOT, GRID, ROW, COLUMN, DIVIDER],
[ROOT, GRID, ROW, COLUMN, CHART],
[ROOT, GRID, ROW, COLUMN, MARKDOWN],
[ROOT, GRID, ROW, COLUMN, ROW, CHART],
[ROOT, GRID, ROW, COLUMN, ROW, MARKDOWN],
[ROOT, GRID, ROW, COLUMN, ROW, COLUMN, CHART],
[ROOT, GRID, ROW, COLUMN, ROW, COLUMN, MARKDOWN],
[ROOT, GRID, TABS, TAB, ROW, COLUMN, ROW, COLUMN, MARKDOWN],
// tab equivalents
[ROOT, TABS, TAB, CHART],
[ROOT, TABS, TAB, MARKDOWN],
[ROOT, TABS, TAB, COLUMN],
[ROOT, TABS, TAB, HEADER],
[ROOT, TABS, TAB, ROW, MARKDOWN],
[ROOT, TABS, TAB, ROW, CHART],
[ROOT, TABS, TAB, ROW, COLUMN, HEADER],
[ROOT, TABS, TAB, ROW, COLUMN, DIVIDER],
[ROOT, TABS, TAB, ROW, COLUMN, CHART],
[ROOT, TABS, TAB, ROW, COLUMN, MARKDOWN],
[ROOT, TABS, TAB, ROW, COLUMN, ROW, CHART],
[ROOT, TABS, TAB, ROW, COLUMN, ROW, MARKDOWN],
[ROOT, TABS, TAB, ROW, COLUMN, ROW, COLUMN, CHART],
[ROOT, TABS, TAB, ROW, COLUMN, ROW, COLUMN, MARKDOWN],
[ROOT, TABS, TAB, TABS, TAB, ROW, COLUMN, ROW, COLUMN, MARKDOWN],
];
validExamples.forEach((example, exampleIdx) => {
let childDepth = 0;
example.forEach((childType, i) => {
const parentDepth = childDepth - 1;
const parentType = example[i - 1];
const testKey = `${parentType}-${childType}-${parentDepth}`;
if (i > 0 && !didTest[testKey]) {
didTest[testKey] = true;
it(`(${exampleIdx})${getIndentation(
childDepth,
)}${parentType} (depth ${parentDepth}) > ${childType}`, () => {
expect(
isValidChild({
parentDepth,
parentType,
childType,
}),
).to.equal(true);
});
}
// see isValidChild.js for why tabs do not increment the depth of their children
childDepth += childType !== TABS && childType !== TAB ? 1 : 0;
});
});
});
describe('invalid calls', () => {
// In order to assert that a parent > child hierarchy at a given depth is invalid
// we also define some valid hierarchies in doing so. we indicate which
// parent > [child] relationships should be asserted as invalid using a nested array
const invalidExamples = [
[ROOT, [DIVIDER]],
[ROOT, [CHART]],
[ROOT, [MARKDOWN]],
[ROOT, GRID, [TAB]],
[ROOT, GRID, TABS, [ROW]],
// [ROOT, GRID, TABS, TAB, [TABS]], // @TODO this needs to be fixed
[ROOT, GRID, ROW, [TABS]],
[ROOT, GRID, ROW, [TAB]],
[ROOT, GRID, ROW, [DIVIDER]],
[ROOT, GRID, ROW, COLUMN, [TABS]],
[ROOT, GRID, ROW, COLUMN, [TAB]],
[ROOT, GRID, ROW, COLUMN, ROW, [DIVIDER]],
[ROOT, GRID, ROW, COLUMN, ROW, COLUMN, [ROW]], // too nested
];
invalidExamples.forEach((example, exampleIdx) => {
let childDepth = 0;
example.forEach((childType, i) => {
const shouldTestChild = Array.isArray(childType);
if (i > 0 && shouldTestChild) {
const parentDepth = childDepth - 1;
const parentType = example[i - 1];
it(`(${exampleIdx})${getIndentation(
childDepth,
)}${parentType} (depth ${parentDepth}) > ${childType}`, () => {
expect(
isValidChild({
parentDepth,
parentType,
childType,
}),
).to.equal(false);
});
}
// see isValidChild.js for why tabs do not increment the depth of their children
childDepth += childType !== TABS && childType !== TAB ? 1 : 0;
});
});
});
});

View File

@@ -0,0 +1,51 @@
import { describe, it } from 'mocha';
import { expect } from 'chai';
import newComponentFactory from '../../../../src/dashboard/util/newComponentFactory';
import {
CHART_TYPE,
COLUMN_TYPE,
DASHBOARD_GRID_TYPE,
DASHBOARD_ROOT_TYPE,
DIVIDER_TYPE,
HEADER_TYPE,
MARKDOWN_TYPE,
NEW_COMPONENT_SOURCE_TYPE,
ROW_TYPE,
TABS_TYPE,
TAB_TYPE,
} from '../../../../src/dashboard/util/componentTypes';
const types = [
CHART_TYPE,
COLUMN_TYPE,
DASHBOARD_GRID_TYPE,
DASHBOARD_ROOT_TYPE,
DIVIDER_TYPE,
HEADER_TYPE,
MARKDOWN_TYPE,
NEW_COMPONENT_SOURCE_TYPE,
ROW_TYPE,
TABS_TYPE,
TAB_TYPE,
];
describe('newEntityFactory', () => {
types.forEach(type => {
it(`returns a new ${type}`, () => {
const result = newComponentFactory(type);
expect(result.type).to.equal(type);
expect(typeof result.id).to.equal('string');
expect(typeof result.meta).to.equal('object');
expect(Array.isArray(result.children)).to.equal(true);
});
});
it('adds passed meta data to the entity', () => {
const banana = 'banana';
const result = newComponentFactory(CHART_TYPE, { banana });
expect(result.meta.banana).to.equal(banana);
});
});

View File

@@ -0,0 +1,85 @@
import { describe, it } from 'mocha';
import { expect } from 'chai';
import newEntitiesFromDrop from '../../../../src/dashboard/util/newEntitiesFromDrop';
import {
CHART_TYPE,
DASHBOARD_GRID_TYPE,
ROW_TYPE,
TABS_TYPE,
TAB_TYPE,
} from '../../../../src/dashboard/util/componentTypes';
describe('newEntitiesFromDrop', () => {
it('should return a new Entity of appropriate type, and add it to the drop target children', () => {
const result = newEntitiesFromDrop({
dropResult: {
destination: { id: 'a', index: 0 },
dragging: { type: CHART_TYPE },
source: { id: 'b', index: 0 },
},
layout: {
a: {
id: 'a',
type: ROW_TYPE,
children: [],
},
},
});
const newId = result.a.children[0];
expect(result.a.children.length).to.equal(1);
expect(Object.keys(result).length).to.equal(2);
expect(result[newId].type).to.equal(CHART_TYPE);
});
it('should create Tab AND Tabs components if the drag entity is Tabs', () => {
const result = newEntitiesFromDrop({
dropResult: {
destination: { id: 'a', index: 0 },
dragging: { type: TABS_TYPE },
source: { id: 'b', index: 0 },
},
layout: {
a: {
id: 'a',
type: DASHBOARD_GRID_TYPE,
children: [],
},
},
});
const newTabsId = result.a.children[0];
const newTabId = result[newTabsId].children[0];
expect(result.a.children.length).to.equal(1);
expect(Object.keys(result).length).to.equal(3);
expect(result[newTabsId].type).to.equal(TABS_TYPE);
expect(result[newTabId].type).to.equal(TAB_TYPE);
});
it('should create a Row if the drag entity should be wrapped in a row', () => {
const result = newEntitiesFromDrop({
dropResult: {
destination: { id: 'a', index: 0 },
dragging: { type: CHART_TYPE },
source: { id: 'b', index: 0 },
},
layout: {
a: {
id: 'a',
type: DASHBOARD_GRID_TYPE,
children: [],
},
},
});
const newRowId = result.a.children[0];
const newChartId = result[newRowId].children[0];
expect(result.a.children.length).to.equal(1);
expect(Object.keys(result).length).to.equal(3);
expect(result[newRowId].type).to.equal(ROW_TYPE);
expect(result[newChartId].type).to.equal(CHART_TYPE);
});
});

View File

@@ -0,0 +1,143 @@
import $ from 'jquery';
import { describe, it } from 'mocha';
import { expect } from 'chai';
import sinon from 'sinon';
import { Logger, ActionLog } from '../../src/logger';
describe('ActionLog', () => {
it('should be a constructor', () => {
const newLogger = new ActionLog({});
expect(newLogger instanceof ActionLog).to.equal(true);
});
it('should set the eventNames, impressionId, source, sourceId, and sendNow init parameters', () => {
const eventNames = [];
const impressionId = 'impressionId';
const source = 'source';
const sourceId = 'sourceId';
const sendNow = true;
const log = new ActionLog({ eventNames, impressionId, source, sourceId, sendNow });
expect(log.eventNames).to.equal(eventNames);
expect(log.impressionId).to.equal(impressionId);
expect(log.source).to.equal(source);
expect(log.sourceId).to.equal(sourceId);
expect(log.sendNow).to.equal(sendNow);
});
it('should set attributes with the setAttribute method', () => {
const log = new ActionLog({});
expect(log.test).to.equal(undefined);
log.setAttribute('test', 'testValue');
expect(log.test).to.equal('testValue');
});
it('should track added events', () => {
const log = new ActionLog({});
const eventName = 'myEventName';
const eventBody = { test: 'event' };
expect(log.events[eventName]).to.equal(undefined);
log.addEvent(eventName, eventBody);
expect(log.events[eventName]).to.have.length(1);
expect(log.events[eventName][0]).to.deep.include(eventBody);
});
});
describe('Logger', () => {
it('should add events when .append(eventName, eventBody) is called', () => {
const eventName = 'testEvent';
const eventBody = { test: 'event' };
const log = new ActionLog({ eventNames: [eventName] });
Logger.start(log);
Logger.append(eventName, eventBody);
expect(log.events[eventName]).to.have.length(1);
expect(log.events[eventName][0]).to.deep.include(eventBody);
Logger.end(log);
});
describe('.send()', () => {
beforeEach(() => {
sinon.spy($, 'ajax');
});
afterEach(() => {
$.ajax.restore();
});
const eventNames = ['test'];
function setup(overrides = {}) {
const log = new ActionLog({ eventNames, ...overrides });
return log;
}
it('should POST an event to /superset/log/ when called', () => {
const log = setup();
Logger.start(log);
Logger.append(eventNames[0], { test: 'event' });
expect(log.events[eventNames[0]]).to.have.length(1);
Logger.end(log);
expect($.ajax.calledOnce).to.equal(true);
const args = $.ajax.getCall(0).args[0];
expect(args.url).to.equal('/superset/log/');
expect(args.method).to.equal('POST');
});
it("should flush the log's events", () => {
const log = setup();
Logger.start(log);
Logger.append(eventNames[0], { test: 'event' });
const event = log.events[eventNames[0]][0];
expect(event).to.deep.include({ test: 'event' });
Logger.end(log);
expect(log.events).to.deep.equal({});
});
it('should include ts, start_offset, event_name, impression_id, source, and source_id in every event', () => {
const config = {
eventNames: ['event1', 'event2'],
impressionId: 'impress_me',
source: 'superset',
sourceId: 'lolz',
};
const log = setup(config);
Logger.start(log);
Logger.append('event1', { key: 'value' });
Logger.append('event2', { foo: 'bar' });
Logger.end(log);
const args = $.ajax.getCall(0).args[0];
const events = JSON.parse(args.data.events);
expect(events).to.have.length(2);
expect(events[0]).to.deep.include({
key: 'value',
event_name: 'event1',
impression_id: config.impressionId,
source: config.source,
source_id: config.sourceId,
});
expect(events[1]).to.deep.include({
foo: 'bar',
event_name: 'event2',
impression_id: config.impressionId,
source: config.source,
source_id: config.sourceId,
});
expect(typeof events[0].ts).to.equal('number');
expect(typeof events[1].ts).to.equal('number');
expect(typeof events[0].start_offset).to.equal('number');
expect(typeof events[1].start_offset).to.equal('number');
});
it('should send() a log immediately if .append() is called with sendNow=true', () => {
const log = setup();
Logger.start(log);
Logger.append(eventNames[0], { test: 'event' }, true);
expect($.ajax.calledOnce).to.equal(true);
Logger.end(log);
});
});
});

View File

@@ -1,19 +0,0 @@
# TODO
* Figure out how to organize the left panel, integrate Search
* collapse sql beyond 10 lines
* Security per-database (dropdown)
* Get a to work
## Cosmetic
* Result set font is too big
* lmiit/timer/buttons wrap
* table label is transparent
* SqlEditor buttons
* use react-bootstrap-prompt for query title input
* Make tabs look great
# PROJECT
* Write Runbook
* Confirm backups
* merge chef branch

View File

@@ -2,14 +2,19 @@ import React from 'react';
import PropTypes from 'prop-types';
import { Button } from 'react-bootstrap';
import Select from 'react-select';
import Loading from '../../components/Loading';
import QueryTable from './QueryTable';
import { now, epochTimeXHoursAgo,
epochTimeXDaysAgo, epochTimeXYearsAgo } from '../../modules/dates';
import {
now,
epochTimeXHoursAgo,
epochTimeXDaysAgo,
epochTimeXYearsAgo,
} from '../../modules/dates';
import { STATUS_OPTIONS, TIME_OPTIONS } from '../constants';
import AsyncSelect from '../../components/AsyncSelect';
import { t } from '../../locales';
const $ = window.$ = require('jquery');
const $ = (window.$ = require('jquery'));
const propTypes = {
actions: PropTypes.object.isRequired,
@@ -47,13 +52,17 @@ class QuerySearch extends React.PureComponent {
this.refreshQueries();
}
onUserClicked(userId) {
this.setState({ userId }, () => { this.refreshQueries(); });
this.setState({ userId }, () => {
this.refreshQueries();
});
}
onDbClicked(dbId) {
this.setState({ databaseId: dbId }, () => { this.refreshQueries(); });
this.setState({ databaseId: dbId }, () => {
this.refreshQueries();
});
}
onChange(db) {
const val = (db) ? db.value : null;
const val = db ? db.value : null;
this.setState({ databaseId: val });
}
getTimeFromSelection(selection) {
@@ -77,25 +86,25 @@ class QuerySearch extends React.PureComponent {
}
}
changeFrom(user) {
const val = (user) ? user.value : null;
const val = user ? user.value : null;
this.setState({ from: val });
}
changeTo(status) {
const val = (status) ? status.value : null;
const val = status ? status.value : null;
this.setState({ to: val });
}
changeUser(user) {
const val = (user) ? user.value : null;
const val = user ? user.value : null;
this.setState({ userId: val });
}
insertParams(baseUrl, params) {
const validParams = params.filter(
function (p) { return p !== ''; },
);
const validParams = params.filter(function (p) {
return p !== '';
});
return baseUrl + '?' + validParams.join('&');
}
changeStatus(status) {
const val = (status) ? status.value : null;
const val = status ? status.value : null;
this.setState({ status: val });
}
changeSearch(event) {
@@ -120,7 +129,7 @@ class QuerySearch extends React.PureComponent {
if (data.result.length === 0) {
this.props.actions.addAlert({
bsStyle: 'danger',
msg: t('It seems you don\'t have access to any database'),
msg: t("It seems you don't have access to any database"),
});
}
return options;
@@ -175,8 +184,10 @@ class QuerySearch extends React.PureComponent {
<Select
name="select-from"
placeholder={t('[From]-')}
options={TIME_OPTIONS
.slice(1, TIME_OPTIONS.length).map(xt => ({ value: xt, label: xt }))}
options={TIME_OPTIONS.slice(1, TIME_OPTIONS.length).map(xt => ({
value: xt,
label: xt,
}))}
value={this.state.from}
autosize={false}
onChange={this.changeFrom}
@@ -206,29 +217,21 @@ class QuerySearch extends React.PureComponent {
</Button>
</div>
</div>
{this.state.queriesLoading ?
(<img className="loading" alt="Loading..." src="/static/assets/images/loading.gif" />)
:
(
<div className="scrollbar-container">
<div
className="scrollbar-content"
style={{ height: this.props.height }}
>
<QueryTable
columns={[
'state', 'db', 'user', 'time',
'progress', 'rows', 'sql', 'querylink',
]}
onUserClicked={this.onUserClicked}
onDbClicked={this.onDbClicked}
queries={this.state.queriesArray}
actions={this.props.actions}
/>
</div>
<div className="scrollbar-container">
{this.state.queriesLoading ? (
<Loading />
) : (
<div className="scrollbar-content" style={{ height: this.props.height }}>
<QueryTable
columns={['state', 'db', 'user', 'time', 'progress', 'rows', 'sql', 'querylink']}
onUserClicked={this.onUserClicked}
onDbClicked={this.onDbClicked}
queries={this.state.queriesArray}
actions={this.props.actions}
/>
</div>
)
}
)}
</div>
</div>
);
}

View File

@@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import { Alert, Button, ButtonGroup, ProgressBar } from 'react-bootstrap';
import shortid from 'shortid';
import Loading from '../../components/Loading';
import VisualizeModal from './VisualizeModal';
import HighlightedSql from './HighlightedSql';
import FilterableTable from '../../components/FilterableTable/FilterableTable';
@@ -30,6 +31,8 @@ const defaultProps = {
const SEARCH_HEIGHT = 46;
const LOADING_STYLES = { position: 'relative', height: 50 };
export default class ResultSet extends React.PureComponent {
constructor(props) {
super(props);
@@ -237,9 +240,9 @@ export default class ResultSet extends React.PureComponent {
);
}
return (
<div>
<img className="loading" alt={t('Loading...')} src="/static/assets/images/loading.gif" />
<div style={LOADING_STYLES}>
<QueryStateLabel query={query} />
{!progressBar && <Loading />}
{progressBar}
<div>
{trackingUrl}

View File

@@ -86,7 +86,7 @@ class SouthPane extends React.PureComponent {
title={t('Query History')}
eventKey="History"
>
<div style={{ height: `${innerTabHeight}px`, overflow: 'scroll' }}>
<div style={{ height: `${innerTabHeight}px`, overflow: 'auto' }}>
<QueryHistory queries={props.editorQueries} actions={props.actions} />
</div>
</Tab>

View File

@@ -45,7 +45,7 @@ body {
left: 0px;
right: 0px;
bottom: 0px;
overflow: scroll;
overflow: auto;
margin-right: 0px;
margin-bottom: 0px;
}
@@ -302,7 +302,7 @@ a.Link {
margin-top: 10px;
position: absolute;
width: 100%;
overflow: scroll;
overflow: auto;
}
.nav-tabs > li.active > a,
.nav-tabs > li.active > a:hover,

View File

@@ -7,7 +7,7 @@ import { Tooltip } from 'react-bootstrap';
import { d3format } from '../modules/utils';
import ChartBody from './ChartBody';
import Loading from '../components/Loading';
import { Logger, LOG_ACTIONS_RENDER_EVENT } from '../logger';
import { Logger, LOG_ACTIONS_RENDER_CHART } from '../logger';
import StackTraceMessage from '../components/StackTraceMessage';
import RefreshChartOverlay from '../components/RefreshChartOverlay';
import visMap from '../visualizations';
@@ -17,7 +17,7 @@ import './chart.css';
const propTypes = {
annotationData: PropTypes.object,
actions: PropTypes.object,
chartKey: PropTypes.string.isRequired,
chartId: PropTypes.number.isRequired,
containerId: PropTypes.string.isRequired,
datasource: PropTypes.object.isRequired,
formData: PropTypes.object.isRequired,
@@ -42,8 +42,6 @@ const propTypes = {
// dashboard callbacks
addFilter: PropTypes.func,
getFilters: PropTypes.func,
clearFilter: PropTypes.func,
removeFilter: PropTypes.func,
onQuery: PropTypes.func,
onDismissRefreshOverlay: PropTypes.func,
};
@@ -51,8 +49,6 @@ const propTypes = {
const defaultProps = {
addFilter: () => ({}),
getFilters: () => ({}),
clearFilter: () => ({}),
removeFilter: () => ({}),
};
class Chart extends React.PureComponent {
@@ -67,8 +63,6 @@ class Chart extends React.PureComponent {
this.datasource = props.datasource;
this.addFilter = this.addFilter.bind(this);
this.getFilters = this.getFilters.bind(this);
this.clearFilter = this.clearFilter.bind(this);
this.removeFilter = this.removeFilter.bind(this);
this.headerHeight = this.headerHeight.bind(this);
this.height = this.height.bind(this);
this.width = this.width.bind(this);
@@ -76,10 +70,11 @@ class Chart extends React.PureComponent {
componentDidMount() {
if (this.props.triggerQuery) {
this.props.actions.runQuery(this.props.formData, false,
this.props.timeout,
this.props.chartKey,
);
const { formData } = this.props;
this.props.actions.runQuery(formData, false, this.props.timeout, this.props.chartId);
} else {
// when drag/dropping in a dashboard, a chart may be unmounted/remounted but still have data
this.renderViz();
}
}
@@ -93,10 +88,10 @@ class Chart extends React.PureComponent {
componentDidUpdate(prevProps) {
if (
this.props.queryResponse &&
['success', 'rendered'].indexOf(this.props.chartStatus) > -1 &&
!this.props.queryResponse.error && (
prevProps.annotationData !== this.props.annotationData ||
this.props.queryResponse &&
['success', 'rendered'].indexOf(this.props.chartStatus) > -1 &&
!this.props.queryResponse.error &&
(prevProps.annotationData !== this.props.annotationData ||
prevProps.queryResponse !== this.props.queryResponse ||
prevProps.height !== this.props.height ||
prevProps.width !== this.props.width ||
@@ -118,20 +113,14 @@ class Chart extends React.PureComponent {
this.props.addFilter(col, vals, merge, refresh);
}
clearFilter() {
this.props.clearFilter();
}
removeFilter(col, vals, refresh = true) {
this.props.removeFilter(col, vals, refresh);
}
clearError() {
this.setState({ errorMsg: null });
}
width() {
return this.props.width || this.container.el.offsetWidth;
return (
this.props.width || (this.container && this.container.el && this.container.el.offsetWidth)
);
}
headerHeight() {
@@ -139,7 +128,9 @@ class Chart extends React.PureComponent {
}
height() {
return this.props.height || this.container.el.offsetHeight;
return (
this.props.height || (this.container && this.container.el && this.container.el.offsetHeight)
);
}
d3format(col, number) {
@@ -150,7 +141,7 @@ class Chart extends React.PureComponent {
}
error(e) {
this.props.actions.chartRenderingFailed(e, this.props.chartKey);
this.props.actions.chartRenderingFailed(e, this.props.chartId);
}
verboseMetricName(metric) {
@@ -167,7 +158,6 @@ class Chart extends React.PureComponent {
renderTooltip() {
if (this.state.tooltip) {
/* eslint-disable react/no-danger */
return (
<Tooltip
className="chart-tooltip"
@@ -177,77 +167,83 @@ class Chart extends React.PureComponent {
positionLeft={this.state.tooltip.x + 30}
arrowOffsetTop={10}
>
<div dangerouslySetInnerHTML={{ __html: this.state.tooltip.content }} />
<div // eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: this.state.tooltip.content }}
/>
</Tooltip>
);
/* eslint-enable react/no-danger */
}
return null;
}
renderViz() {
const viz = visMap[this.props.vizType];
const fd = this.props.formData;
const qr = this.props.queryResponse;
const { vizType, formData, queryResponse, setControlValue, chartId, chartStatus } = this.props;
const visRenderer = visMap[vizType];
const renderStart = Logger.getTimestamp();
try {
// Executing user-defined data mutator function
if (fd.js_data) {
qr.data = sandboxedEval(fd.js_data)(qr.data);
if (formData.js_data) {
queryResponse.data = sandboxedEval(formData.js_data)(queryResponse.data);
}
// [re]rendering the visualization
viz(this, qr, this.props.setControlValue);
Logger.append(LOG_ACTIONS_RENDER_EVENT, {
label: this.props.chartKey,
vis_type: this.props.vizType,
visRenderer(this, queryResponse, setControlValue);
if (chartStatus !== 'rendered') {
this.props.actions.chartRenderingSucceeded(chartId);
}
Logger.append(LOG_ACTIONS_RENDER_CHART, {
slice_id: 'slice_' + chartId,
viz_type: vizType,
start_offset: renderStart,
duration: Logger.getTimestamp() - renderStart,
});
this.props.actions.chartRenderingSucceeded(this.props.chartKey);
this.props.actions.chartRenderingSucceeded(chartId);
} catch (e) {
this.props.actions.chartRenderingFailed(e, this.props.chartKey);
console.error(e); // eslint-disable-line no-console
this.props.actions.chartRenderingFailed(e, chartId);
}
}
render() {
const isLoading = this.props.chartStatus === 'loading';
// this allows <Loading /> to be positioned in the middle of the chart
const containerStyles = isLoading ? { height: this.height(), width: this.width() } : null;
return (
<div className={`token col-md-12 ${isLoading ? 'is-loading' : ''}`}>
<div className={`chart-container ${isLoading ? 'is-loading' : ''}`} style={containerStyles}>
{this.renderTooltip()}
{isLoading &&
<Loading size={25} />
}
{this.props.chartAlert &&
<StackTraceMessage
message={this.props.chartAlert}
queryResponse={this.props.queryResponse}
/>
}
{isLoading && <Loading size={75} />}
{this.props.chartAlert && (
<StackTraceMessage
message={this.props.chartAlert}
queryResponse={this.props.queryResponse}
/>
)}
{!isLoading &&
!this.props.chartAlert &&
this.props.refreshOverlayVisible &&
!this.props.errorMessage &&
this.container &&
<RefreshChartOverlay
height={this.height()}
width={this.width()}
onQuery={this.props.onQuery}
onDismiss={this.props.onDismissRefreshOverlay}
/>
}
{!isLoading && !this.props.chartAlert &&
<ChartBody
containerId={this.containerId}
vizType={this.props.vizType}
height={this.height}
width={this.width}
faded={this.props.refreshOverlayVisible && !this.props.errorMessage}
ref={(inner) => {
this.container = inner;
}}
/>
}
this.container && (
<RefreshChartOverlay
height={this.height()}
width={this.width()}
onQuery={this.props.onQuery}
onDismiss={this.props.onDismissRefreshOverlay}
/>
)}
{!isLoading &&
!this.props.chartAlert && (
<ChartBody
containerId={this.containerId}
vizType={this.props.vizType}
height={this.height}
width={this.width}
faded={this.props.refreshOverlayVisible && !this.props.errorMessage}
ref={(inner) => {
this.container = inner;
}}
/>
)}
</div>
);
}

View File

@@ -1,29 +1,13 @@
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import * as Actions from './chartAction';
import * as actions from './chartAction';
import Chart from './Chart';
function mapStateToProps({ charts }, ownProps) {
const chart = charts[ownProps.chartKey];
return {
annotationData: chart.annotationData,
chartAlert: chart.chartAlert,
chartStatus: chart.chartStatus,
chartUpdateEndTime: chart.chartUpdateEndTime,
chartUpdateStartTime: chart.chartUpdateStartTime,
latestQueryFormData: chart.latestQueryFormData,
lastRendered: chart.lastRendered,
queryResponse: chart.queryResponse,
queryRequest: chart.queryRequest,
triggerQuery: chart.triggerQuery,
};
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators(Actions, dispatch),
actions: bindActionCreators(actions, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(Chart);
export default connect(null, mapDispatchToProps)(Chart);

View File

@@ -1,10 +1,10 @@
import { getExploreUrlAndPayload, getAnnotationJsonUrl } from '../explore/exploreUtils';
import { requiresQuery, ANNOTATION_SOURCE_TYPES } from '../modules/AnnotationTypes';
import { Logger, LOG_ACTIONS_LOAD_EVENT } from '../logger';
import { Logger, LOG_ACTIONS_LOAD_CHART } from '../logger';
import { COMMON_ERR_MESSAGES } from '../common';
import { t } from '../locales';
const $ = window.$ = require('jquery');
const $ = (window.$ = require('jquery'));
export const CHART_UPDATE_STARTED = 'CHART_UPDATE_STARTED';
export function chartUpdateStarted(queryRequest, latestQueryFormData, key) {
@@ -74,11 +74,13 @@ export function runAnnotationQuery(annotation, timeout = 60, formData = null, ke
fd.time_grain_sqla = granularity;
fd.granularity = granularity;
const sliceFormData = Object.keys(annotation.overrides)
.reduce((d, k) => ({
const sliceFormData = Object.keys(annotation.overrides).reduce(
(d, k) => ({
...d,
[k]: annotation.overrides[k] || fd[k],
}), {});
}),
{},
);
const isNative = annotation.sourceType === ANNOTATION_SOURCE_TYPES.NATIVE;
const url = getAnnotationJsonUrl(annotation.value, sliceFormData, isNative);
const queryRequest = $.ajax({
@@ -117,6 +119,11 @@ export function updateQueryFormData(value, key) {
return { type: UPDATE_QUERY_FORM_DATA, value, key };
}
export const ADD_CHART = 'ADD_CHART';
export function addChart(chart, key) {
return { type: ADD_CHART, chart, key };
}
export const RUN_QUERY = 'RUN_QUERY';
export function runQuery(formData, force = false, timeout = 60, key) {
return (dispatch) => {
@@ -138,19 +145,22 @@ export function runQuery(formData, force = false, timeout = 60, key) {
const queryPromise = Promise.resolve(dispatch(chartUpdateStarted(queryRequest, payload, key)))
.then(() => queryRequest)
.then((queryResponse) => {
Logger.append(LOG_ACTIONS_LOAD_EVENT, {
label: key,
Logger.append(LOG_ACTIONS_LOAD_CHART, {
slice_id: 'slice_' + key,
is_cached: queryResponse.is_cached,
force_refresh: force,
row_count: queryResponse.rowcount,
datasource: formData.datasource,
start_offset: logStart,
duration: Logger.getTimestamp() - logStart,
has_extra_filters: formData.extra_filters && formData.extra_filters.length > 0,
viz_type: formData.viz_type,
});
return dispatch(chartUpdateSucceeded(queryResponse, key));
})
.catch((err) => {
Logger.append(LOG_ACTIONS_LOAD_EVENT, {
label: key,
Logger.append(LOG_ACTIONS_LOAD_CHART, {
slice_id: 'slice_' + key,
has_err: true,
datasource: formData.datasource,
start_offset: logStart,
@@ -190,3 +200,12 @@ export function runQuery(formData, force = false, timeout = 60, key) {
]);
};
}
export function refreshChart(chart, force, timeout) {
return (dispatch) => {
if (!chart.latestQueryFormData || Object.keys(chart.latestQueryFormData).length === 0) {
return;
}
dispatch(runQuery(chart.latestQueryFormData, force, timeout, chart.id));
};
}

View File

@@ -1,25 +1,10 @@
/* eslint camelcase: 0 */
import PropTypes from 'prop-types';
import { now } from '../modules/dates';
import * as actions from './chartAction';
import { t } from '../locales';
export const chartPropType = {
chartKey: PropTypes.string.isRequired,
chartAlert: PropTypes.string,
chartStatus: PropTypes.string,
chartUpdateEndTime: PropTypes.number,
chartUpdateStartTime: PropTypes.number,
latestQueryFormData: PropTypes.object,
queryRequest: PropTypes.object,
queryResponse: PropTypes.object,
triggerQuery: PropTypes.bool,
lastRendered: PropTypes.number,
};
export const chart = {
chartKey: '',
id: 0,
chartAlert: null,
chartStatus: 'loading',
chartUpdateEndTime: null,
@@ -33,6 +18,12 @@ export const chart = {
export default function chartReducer(charts = {}, action) {
const actionHandlers = {
[actions.ADD_CHART]() {
return {
...chart,
...action.chart,
};
},
[actions.CHART_UPDATE_SUCCEEDED](state) {
return { ...state,
chartStatus: 'success',
@@ -70,12 +61,12 @@ export default function chartReducer(charts = {}, action) {
return { ...state,
chartStatus: 'failed',
chartAlert: (
`${t('Query timeout')} - ` +
t(`visualization queries are set to timeout at ${action.timeout} seconds. `) +
t('Perhaps your data has grown, your database is under unusual load, ' +
'or you are simply querying a data source that is too large ' +
'to be processed within the timeout range. ' +
'If that is the case, we recommend that you summarize your data further.')),
`${t('Query timeout')} - ` +
t(`visualization queries are set to timeout at ${action.timeout} seconds. `) +
t('Perhaps your data has grown, your database is under unusual load, ' +
'or you are simply querying a data source that is too large ' +
'to be processed within the timeout range. ' +
'If that is the case, we recommend that you summarize your data further.')),
};
},
[actions.CHART_UPDATE_FAILED](state) {
@@ -151,7 +142,10 @@ export default function chartReducer(charts = {}, action) {
}
if (action.type in actionHandlers) {
return { ...charts, [action.key]: actionHandlers[action.type](charts[action.key], action) };
return {
...charts,
[action.key]: actionHandlers[action.type](charts[action.key], action),
};
}
return charts;

View File

@@ -0,0 +1,65 @@
import React from 'react';
import PropTypes from 'prop-types';
import { MenuItem } from 'react-bootstrap';
import InfoTooltipWithTrigger from './InfoTooltipWithTrigger';
export function MenuItemContent({ faIcon, text, tooltip, children }) {
return (
<span>
{faIcon && <i className={`fa fa-${faIcon}`}>&nbsp;</i>}
{text} {''}
<InfoTooltipWithTrigger
tooltip={tooltip}
label={faIcon ? `dash-${faIcon}` : ''}
placement="top"
/>
{children}
</span>
);
}
MenuItemContent.propTypes = {
faIcon: PropTypes.string,
text: PropTypes.string,
tooltip: PropTypes.string,
children: PropTypes.node,
};
MenuItemContent.defaultProps = {
faIcon: '',
text: '',
tooltip: null,
children: null,
};
export function ActionMenuItem({
onClick,
href,
target,
text,
tooltip,
children,
faIcon,
}) {
return (
<MenuItem onClick={onClick} href={href} target={target}>
<MenuItemContent faIcon={faIcon} text={text} tooltip={tooltip}>
{children}
</MenuItemContent>
</MenuItem>
);
}
ActionMenuItem.propTypes = {
onClick: PropTypes.func,
href: PropTypes.string,
target: PropTypes.string,
...MenuItemContent.propTypes,
};
ActionMenuItem.defaultProps = {
onClick() {},
href: null,
target: null,
};

View File

@@ -1,5 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import cx from 'classnames';
import TooltipWrapper from './TooltipWrapper';
import { t } from '../locales';
@@ -27,8 +28,10 @@ class EditableTitle extends React.PureComponent {
this.handleClick = this.handleClick.bind(this);
this.handleBlur = this.handleBlur.bind(this);
this.handleChange = this.handleChange.bind(this);
this.handleKeyUp = this.handleKeyUp.bind(this);
this.handleKeyPress = this.handleKeyPress.bind(this);
}
componentWillReceiveProps(nextProps) {
if (nextProps.title !== this.state.title) {
this.setState({
@@ -37,8 +40,9 @@ class EditableTitle extends React.PureComponent {
});
}
}
handleClick() {
if (!this.props.canEdit) {
if (!this.props.canEdit || this.state.isEditing) {
return;
}
@@ -46,6 +50,7 @@ class EditableTitle extends React.PureComponent {
isEditing: true,
});
}
handleBlur() {
if (!this.props.canEdit) {
return;
@@ -67,9 +72,31 @@ class EditableTitle extends React.PureComponent {
this.setState({
lastTitle: this.state.title,
});
}
if (this.props.title !== this.state.title) {
this.props.onSaveTitle(this.state.title);
}
}
handleKeyUp(ev) {
// this entire method exists to support using EditableTitle as the title of a
// react-bootstrap Tab, as a workaround for this line in react-bootstrap https://goo.gl/ZVLmv4
//
// tl;dr when a Tab EditableTitle is being edited, typically the Tab it's within has been
// clicked and is focused/active. for accessibility, when focused the Tab <a /> intercepts
// the ' ' key (among others, including all arrows) and onChange() doesn't fire. somehow
// keydown is still called so we can detect this and manually add a ' ' to the current title
if (ev.key === ' ') {
let title = ev.target.value;
const titleLength = (title || '').length;
if (title && title[titleLength - 1] !== ' ') {
title = `${title} `;
this.setState(() => ({ title }));
}
}
}
handleChange(ev) {
if (!this.props.canEdit) {
return;
@@ -79,6 +106,7 @@ class EditableTitle extends React.PureComponent {
title: ev.target.value,
});
}
handleKeyPress(ev) {
if (ev.key === 'Enter') {
ev.preventDefault();
@@ -86,12 +114,14 @@ class EditableTitle extends React.PureComponent {
this.handleBlur();
}
}
render() {
let input = (
let content = (
<input
required
type={this.state.isEditing ? 'text' : 'button'}
value={this.state.title}
onKeyUp={this.handleKeyUp}
onChange={this.handleChange}
onBlur={this.handleBlur}
onClick={this.handleClick}
@@ -99,18 +129,26 @@ class EditableTitle extends React.PureComponent {
/>
);
if (this.props.showTooltip) {
input = (
content = (
<TooltipWrapper
label="title"
tooltip={this.props.canEdit ? t('click to edit title') :
this.props.noPermitTooltip || t('You don\'t have the rights to alter this title.')}
>
{input}
{content}
</TooltipWrapper>
);
}
return (
<span className="editable-title">{input}</span>
<span
className={cx(
'editable-title',
this.props.canEdit && 'editable-title--editable',
this.state.isEditing && 'editable-title--editing',
)}
>
{content}
</span>
);
}
}

View File

@@ -5,7 +5,7 @@ const propTypes = {
size: PropTypes.number,
};
const defaultProps = {
size: 25,
size: 50,
};
export default function Loading(props) {
@@ -15,14 +15,18 @@ export default function Loading(props) {
alt="Loading..."
src="/static/assets/images/loading.gif"
style={{
width: props.size,
height: props.size,
width: Math.min(props.size, 50),
// height is auto
padding: 0,
margin: 0,
position: 'absolute',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
}}
/>
);
}
Loading.propTypes = propTypes;
Loading.defaultProps = defaultProps;

View File

@@ -0,0 +1,33 @@
{
"extends": "prettier",
"plugins": ["prettier"],
"rules": {
"prefer-template": 2,
"new-cap": 2,
"no-restricted-syntax": 2,
"guard-for-in": 2,
"prefer-arrow-callback": 2,
"func-names": 2,
"react/jsx-no-bind": 2,
"no-confusing-arrow": 2,
"jsx-a11y/no-static-element-interactions": 2,
"jsx-a11y/anchor-has-content": 2,
"react/require-default-props": 2,
"no-plusplus": 2,
"no-mixed-operators": 0,
"no-continue": 2,
"no-bitwise": 2,
"no-undef": 2,
"no-multi-assign": 2,
"no-restricted-properties": 2,
"no-prototype-builtins": 2,
"jsx-a11y/href-no-hash": 2,
"class-methods-use-this": 2,
"import/no-named-as-default": 2,
"import/prefer-default-export": 2,
"react/no-unescaped-entities": 2,
"react/no-string-refs": 2,
"react/jsx-indent": 0,
"prettier/prettier": "error"
}
}

View File

@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

View File

@@ -0,0 +1,203 @@
import { ActionCreators as UndoActionCreators } from 'redux-undo';
import { addInfoToast } from './messageToasts';
import { setUnsavedChanges } from './dashboardState';
import { TABS_TYPE, ROW_TYPE } from '../util/componentTypes';
import {
DASHBOARD_ROOT_ID,
NEW_COMPONENTS_SOURCE_ID,
DASHBOARD_HEADER_ID,
} from '../util/constants';
import dropOverflowsParent from '../util/dropOverflowsParent';
import findParentId from '../util/findParentId';
// this is a helper that takes an action as input and dispatches
// an additional setUnsavedChanges(true) action after the dispatch in the case
// that dashboardState.hasUnsavedChanges is false.
function setUnsavedChangesAfterAction(action) {
return (...args) => (dispatch, getState) => {
const result = action(...args);
if (typeof result === 'function') {
dispatch(result(dispatch, getState));
} else {
dispatch(result);
}
if (!getState().dashboardState.hasUnsavedChanges) {
dispatch(setUnsavedChanges(true));
}
};
}
// Component CRUD -------------------------------------------------------------
export const UPDATE_COMPONENTS = 'UPDATE_COMPONENTS';
export const updateComponents = setUnsavedChangesAfterAction(
nextComponents => ({
type: UPDATE_COMPONENTS,
payload: {
nextComponents,
},
}),
);
export function updateDashboardTitle(text) {
return (dispatch, getState) => {
const { dashboardLayout } = getState();
dispatch(
updateComponents({
[DASHBOARD_HEADER_ID]: {
...dashboardLayout.present[DASHBOARD_HEADER_ID],
meta: {
text,
},
},
}),
);
};
}
export const DELETE_COMPONENT = 'DELETE_COMPONENT';
export const deleteComponent = setUnsavedChangesAfterAction((id, parentId) => ({
type: DELETE_COMPONENT,
payload: {
id,
parentId,
},
}));
export const CREATE_COMPONENT = 'CREATE_COMPONENT';
export const createComponent = setUnsavedChangesAfterAction(dropResult => ({
type: CREATE_COMPONENT,
payload: {
dropResult,
},
}));
// Tabs -----------------------------------------------------------------------
export const CREATE_TOP_LEVEL_TABS = 'CREATE_TOP_LEVEL_TABS';
export const createTopLevelTabs = setUnsavedChangesAfterAction(dropResult => ({
type: CREATE_TOP_LEVEL_TABS,
payload: {
dropResult,
},
}));
export const DELETE_TOP_LEVEL_TABS = 'DELETE_TOP_LEVEL_TABS';
export const deleteTopLevelTabs = setUnsavedChangesAfterAction(() => ({
type: DELETE_TOP_LEVEL_TABS,
payload: {},
}));
// Resize ---------------------------------------------------------------------
export const RESIZE_COMPONENT = 'RESIZE_COMPONENT';
export function resizeComponent({ id, width, height }) {
return (dispatch, getState) => {
const { dashboardLayout: undoableLayout } = getState();
const { present: dashboard } = undoableLayout;
const component = dashboard[id];
const widthChanged = width && component.meta.width !== width;
const heightChanged = height && component.meta.height !== height;
if (component && (widthChanged || heightChanged)) {
// update the size of this component
const updatedComponents = {
[id]: {
...component,
meta: {
...component.meta,
width: width || component.meta.width,
height: height || component.meta.height,
},
},
};
dispatch(updateComponents(updatedComponents));
}
};
}
// Drag and drop --------------------------------------------------------------
export const MOVE_COMPONENT = 'MOVE_COMPONENT';
const moveComponent = setUnsavedChangesAfterAction(dropResult => ({
type: MOVE_COMPONENT,
payload: {
dropResult,
},
}));
export const HANDLE_COMPONENT_DROP = 'HANDLE_COMPONENT_DROP';
export function handleComponentDrop(dropResult) {
return (dispatch, getState) => {
const overflowsParent = dropOverflowsParent(
dropResult,
getState().dashboardLayout.present,
);
if (overflowsParent) {
return dispatch(
addInfoToast(
`There is not enough space for this component. Try decreasing its width, or increasing the destination width.`,
),
);
}
const { source, destination } = dropResult;
const droppedOnRoot = destination && destination.id === DASHBOARD_ROOT_ID;
const isNewComponent = source.id === NEW_COMPONENTS_SOURCE_ID;
if (droppedOnRoot) {
dispatch(createTopLevelTabs(dropResult));
} else if (destination && isNewComponent) {
dispatch(createComponent(dropResult));
} else if (
destination &&
source &&
!// ensure it has moved
(destination.id === source.id && destination.index === source.index)
) {
dispatch(moveComponent(dropResult));
}
const { dashboardLayout: undoableLayout } = getState();
// if we moved a child from a Tab or Row parent and it was the only child, delete the parent.
if (!isNewComponent) {
const { present: layout } = undoableLayout;
const sourceComponent = layout[source.id];
if (
(sourceComponent.type === TABS_TYPE ||
sourceComponent.type === ROW_TYPE) &&
sourceComponent.children &&
sourceComponent.children.length === 0
) {
const parentId = findParentId({
childId: source.id,
layout,
});
dispatch(deleteComponent(source.id, parentId));
}
}
return null;
};
}
// Undo redo ------------------------------------------------------------------
export function undoLayoutAction() {
return (dispatch, getState) => {
dispatch(UndoActionCreators.undo());
const { dashboardLayout, dashboardState } = getState();
if (
dashboardLayout.past.length === 0 &&
!dashboardState.maxUndoHistoryExceeded
) {
dispatch(setUnsavedChanges(false));
}
};
}
export const redoLayoutAction = setUnsavedChangesAfterAction(
UndoActionCreators.redo,
);

View File

@@ -0,0 +1,261 @@
/* eslint camelcase: 0 */
import $ from 'jquery';
import { ActionCreators as UndoActionCreators } from 'redux-undo';
import { addChart, removeChart, refreshChart } from '../../chart/chartAction';
import { chart as initChart } from '../../chart/chartReducer';
import { fetchDatasourceMetadata } from '../../dashboard/actions/datasources';
import { applyDefaultFormData } from '../../explore/store';
import { getAjaxErrorMsg } from '../../modules/utils';
import {
Logger,
LOG_ACTIONS_CHANGE_DASHBOARD_FILTER,
LOG_ACTIONS_REFRESH_DASHBOARD,
} from '../../logger';
import { SAVE_TYPE_OVERWRITE } from '../util/constants';
import { t } from '../../locales';
import {
addSuccessToast,
addWarningToast,
addDangerToast,
} from './messageToasts';
export const SET_UNSAVED_CHANGES = 'SET_UNSAVED_CHANGES';
export function setUnsavedChanges(hasUnsavedChanges) {
return { type: SET_UNSAVED_CHANGES, payload: { hasUnsavedChanges } };
}
export const CHANGE_FILTER = 'CHANGE_FILTER';
export function changeFilter(chart, col, vals, merge = true, refresh = true) {
Logger.append(LOG_ACTIONS_CHANGE_DASHBOARD_FILTER, {
id: chart.id,
column: col,
value_count: Array.isArray(vals) ? vals.length : (vals && 1) || 0,
merge,
refresh,
});
return { type: CHANGE_FILTER, chart, col, vals, merge, refresh };
}
export const ADD_SLICE = 'ADD_SLICE';
export function addSlice(slice) {
return { type: ADD_SLICE, slice };
}
export const REMOVE_SLICE = 'REMOVE_SLICE';
export function removeSlice(sliceId) {
return { type: REMOVE_SLICE, sliceId };
}
const FAVESTAR_BASE_URL = '/superset/favstar/Dashboard';
export const TOGGLE_FAVE_STAR = 'TOGGLE_FAVE_STAR';
export function toggleFaveStar(isStarred) {
return { type: TOGGLE_FAVE_STAR, isStarred };
}
export const FETCH_FAVE_STAR = 'FETCH_FAVE_STAR';
export function fetchFaveStar(id) {
return function fetchFaveStarThunk(dispatch) {
const url = `${FAVESTAR_BASE_URL}/${id}/count`;
return $.get(url).done(data => {
if (data.count > 0) {
dispatch(toggleFaveStar(true));
}
});
};
}
export const SAVE_FAVE_STAR = 'SAVE_FAVE_STAR';
export function saveFaveStar(id, isStarred) {
return function saveFaveStarThunk(dispatch) {
const urlSuffix = isStarred ? 'unselect' : 'select';
const url = `${FAVESTAR_BASE_URL}/${id}/${urlSuffix}/`;
$.get(url);
dispatch(toggleFaveStar(!isStarred));
};
}
export const TOGGLE_EXPAND_SLICE = 'TOGGLE_EXPAND_SLICE';
export function toggleExpandSlice(sliceId) {
return { type: TOGGLE_EXPAND_SLICE, sliceId };
}
export const UPDATE_CSS = 'UPDATE_CSS';
export function updateCss(css) {
return { type: UPDATE_CSS, css };
}
export const SET_EDIT_MODE = 'SET_EDIT_MODE';
export function setEditMode(editMode) {
return { type: SET_EDIT_MODE, editMode };
}
export const ON_CHANGE = 'ON_CHANGE';
export function onChange() {
return { type: ON_CHANGE };
}
export const ON_SAVE = 'ON_SAVE';
export function onSave() {
return { type: ON_SAVE };
}
export function saveDashboardRequestSuccess() {
return dispatch => {
dispatch(onSave());
// clear layout undo history
dispatch(UndoActionCreators.clearHistory());
};
}
export function saveDashboardRequest(data, id, saveType) {
const path = saveType === SAVE_TYPE_OVERWRITE ? 'save_dash' : 'copy_dash';
const url = `/superset/${path}/${id}/`;
return dispatch =>
$.ajax({
type: 'POST',
url,
data: {
data: JSON.stringify(data),
},
success: () => {
dispatch(saveDashboardRequestSuccess());
dispatch(addSuccessToast(t('This dashboard was saved successfully.')));
},
error: error => {
const errorMsg = getAjaxErrorMsg(error);
dispatch(
addDangerToast(
`${t('Sorry, there was an error saving this dashboard: ')}
${errorMsg}`,
),
);
},
});
}
export function fetchCharts(chartList = [], force = false, interval = 0) {
return (dispatch, getState) => {
Logger.append(LOG_ACTIONS_REFRESH_DASHBOARD, {
force,
interval,
chartCount: chartList.length,
});
const timeout = getState().dashboardInfo.common.conf
.SUPERSET_WEBSERVER_TIMEOUT;
if (!interval) {
chartList.forEach(chart => dispatch(refreshChart(chart, force, timeout)));
return;
}
const { metadata: meta } = getState().dashboardInfo;
const refreshTime = Math.max(interval, meta.stagger_time || 5000); // default 5 seconds
if (typeof meta.stagger_refresh !== 'boolean') {
meta.stagger_refresh =
meta.stagger_refresh === undefined
? true
: meta.stagger_refresh === 'true';
}
const delay = meta.stagger_refresh
? refreshTime / (chartList.length - 1)
: 0;
chartList.forEach((chart, i) => {
setTimeout(
() => dispatch(refreshChart(chart, force, timeout)),
delay * i,
);
});
};
}
let refreshTimer = null;
export function startPeriodicRender(interval) {
const stopPeriodicRender = () => {
if (refreshTimer) {
clearTimeout(refreshTimer);
refreshTimer = null;
}
};
return (dispatch, getState) => {
stopPeriodicRender();
const { metadata } = getState().dashboardInfo;
const immune = metadata.timed_refresh_immune_slices || [];
const refreshAll = () => {
const affected = Object.values(getState().charts).filter(
chart => immune.indexOf(chart.id) === -1,
);
return dispatch(fetchCharts(affected, true, interval * 0.2));
};
const fetchAndRender = () => {
refreshAll();
if (interval > 0) {
refreshTimer = setTimeout(fetchAndRender, interval);
}
};
fetchAndRender();
};
}
export const TOGGLE_BUILDER_PANE = 'TOGGLE_BUILDER_PANE';
export function toggleBuilderPane() {
return { type: TOGGLE_BUILDER_PANE };
}
export function addSliceToDashboard(id) {
return (dispatch, getState) => {
const { sliceEntities } = getState();
const selectedSlice = sliceEntities.slices[id];
if (!selectedSlice) {
return dispatch(
addWarningToast(
'Sorry, there is no chart definition associated with the chart trying to be added.',
),
);
}
const form_data = selectedSlice.form_data;
const newChart = {
...initChart,
id,
form_data,
formData: applyDefaultFormData(form_data),
};
return Promise.all([
dispatch(addChart(newChart, id)),
dispatch(fetchDatasourceMetadata(form_data.datasource)),
]).then(() => dispatch(addSlice(selectedSlice)));
};
}
export function removeSliceFromDashboard(id) {
return dispatch => {
dispatch(removeSlice(id));
dispatch(removeChart(id));
};
}
// Undo history ---------------------------------------------------------------
export const SET_MAX_UNDO_HISTORY_EXCEEDED = 'SET_MAX_UNDO_HISTORY_EXCEEDED';
export function setMaxUndoHistoryExceeded(maxUndoHistoryExceeded = true) {
return {
type: SET_MAX_UNDO_HISTORY_EXCEEDED,
payload: { maxUndoHistoryExceeded },
};
}
export function maxUndoHistoryToast() {
return (dispatch, getState) => {
const { dashboardLayout } = getState();
const historyLength = dashboardLayout.past.length;
return dispatch(
addWarningToast(
`You have used all ${historyLength} undo slots and will not be able to fully undo subsequent actions. You may save your current state to reset the history.`,
),
);
};
}

View File

@@ -0,0 +1,36 @@
import $ from 'jquery';
export const SET_DATASOURCE = 'SET_DATASOURCE';
export function setDatasource(datasource, key) {
return { type: SET_DATASOURCE, datasource, key };
}
export const FETCH_DATASOURCE_STARTED = 'FETCH_DATASOURCE_STARTED';
export function fetchDatasourceStarted(key) {
return { type: FETCH_DATASOURCE_STARTED, key };
}
export const FETCH_DATASOURCE_FAILED = 'FETCH_DATASOURCE_FAILED';
export function fetchDatasourceFailed(error, key) {
return { type: FETCH_DATASOURCE_FAILED, error, key };
}
export function fetchDatasourceMetadata(key) {
return (dispatch, getState) => {
const { datasources } = getState();
const datasource = datasources[key];
if (datasource) {
return dispatch(setDatasource(datasource, key));
}
const url = `/superset/fetch_datasource_metadata?datasourceKey=${key}`;
return $.ajax({
type: 'GET',
url,
success: data => dispatch(setDatasource(data, key)),
error: error =>
dispatch(fetchDatasourceFailed(error.responseJSON.error, key)),
});
};
}

View File

@@ -0,0 +1,59 @@
import shortid from 'shortid';
import {
INFO_TOAST,
SUCCESS_TOAST,
WARNING_TOAST,
DANGER_TOAST,
} from '../util/constants';
function getToastUuid(type) {
return `${type}-${shortid.generate()}`;
}
export const ADD_TOAST = 'ADD_TOAST';
export function addToast({ toastType, text, duration }) {
return {
type: ADD_TOAST,
payload: {
id: getToastUuid(toastType),
toastType,
text,
duration,
},
};
}
export const REMOVE_TOAST = 'REMOVE_TOAST';
export function removeToast(id) {
return {
type: REMOVE_TOAST,
payload: {
id,
},
};
}
// Different types of toasts
export const ADD_INFO_TOAST = 'ADD_INFO_TOAST';
export function addInfoToast(text) {
return dispatch =>
dispatch(addToast({ text, toastType: INFO_TOAST, duration: 4000 }));
}
export const ADD_SUCCESS_TOAST = 'ADD_SUCCESS_TOAST';
export function addSuccessToast(text) {
return dispatch =>
dispatch(addToast({ text, toastType: SUCCESS_TOAST, duration: 4000 }));
}
export const ADD_WARNING_TOAST = 'ADD_WARNING_TOAST';
export function addWarningToast(text) {
return dispatch =>
dispatch(addToast({ text, toastType: WARNING_TOAST, duration: 4000 }));
}
export const ADD_DANGER_TOAST = 'ADD_DANGER_TOAST';
export function addDangerToast(text) {
return dispatch => dispatch(addToast({ text, toastType: DANGER_TOAST }));
}

View File

@@ -0,0 +1,73 @@
/* eslint camelcase: 0 */
import $ from 'jquery';
import { getDatasourceParameter } from '../../modules/utils';
export const SET_ALL_SLICES = 'SET_ALL_SLICES';
export function setAllSlices(slices) {
return { type: SET_ALL_SLICES, slices };
}
export const FETCH_ALL_SLICES_STARTED = 'FETCH_ALL_SLICES_STARTED';
export function fetchAllSlicesStarted() {
return { type: FETCH_ALL_SLICES_STARTED };
}
export const FETCH_ALL_SLICES_FAILED = 'FETCH_ALL_SLICES_FAILED';
export function fetchAllSlicesFailed(error) {
return { type: FETCH_ALL_SLICES_FAILED, error };
}
export function fetchAllSlices(userId) {
return (dispatch, getState) => {
const { sliceEntities } = getState();
if (sliceEntities.lastUpdated === 0) {
dispatch(fetchAllSlicesStarted());
const uri = `/sliceaddview/api/read?_flt_0_created_by=${userId}`;
return $.ajax({
url: uri,
type: 'GET',
success: response => {
const slices = {};
response.result.forEach(slice => {
let form_data = JSON.parse(slice.params);
let datasource = form_data.datasource;
if (!datasource) {
datasource = getDatasourceParameter(
slice.datasource_id,
slice.datasource_type,
);
form_data = {
...form_data,
datasource,
};
}
if (['markup', 'separator'].indexOf(slice.viz_type) === -1) {
slices[slice.id] = {
slice_id: slice.id,
slice_url: slice.slice_url,
slice_name: slice.slice_name,
edit_url: slice.edit_url,
form_data,
datasource_name: slice.datasource_name_text,
datasource_link: slice.datasource_link,
changed_on: new Date(slice.changed_on).getTime(),
description: slice.description,
description_markdown: slice.description_markeddown,
viz_type: slice.viz_type,
modified: slice.modified
? slice.modified.replace(/<[^>]*>/g, '')
: '',
};
}
});
return dispatch(setAllSlices(slices));
},
error: error => dispatch(fetchAllSlicesFailed(error)),
});
}
return dispatch(setAllSlices(sliceEntities.slices));
};
}

View File

@@ -0,0 +1,61 @@
import cx from 'classnames';
import React from 'react';
import PropTypes from 'prop-types';
import { t } from '../../locales';
const propTypes = {
datasourceLink: PropTypes.string,
innerRef: PropTypes.func,
isSelected: PropTypes.bool,
lastModified: PropTypes.string.isRequired,
sliceName: PropTypes.string.isRequired,
style: PropTypes.object,
visType: PropTypes.string.isRequired,
};
const defaultProps = {
datasourceLink: '—',
innerRef: null,
isSelected: false,
style: null,
};
function AddSliceCard({
datasourceLink,
innerRef,
isSelected,
lastModified,
sliceName,
style,
visType,
}) {
return (
<div ref={innerRef} className="chart-card-container" style={style}>
<div className={cx('chart-card', isSelected && 'is-selected')}>
<div className="card-title">{sliceName}</div>
<div className="card-body">
<div className="item">
<span>Modified </span>
<span>{lastModified}</span>
</div>
<div className="item">
<span>Visualization </span>
<span>{visType}</span>
</div>
<div className="item">
<span>Data source </span>
<span // eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: datasourceLink }}
/>
</div>
</div>
</div>
{isSelected && <div className="is-added-label">{t('Added')}</div>}
</div>
);
}
AddSliceCard.propTypes = propTypes;
AddSliceCard.defaultProps = defaultProps;
export default AddSliceCard;

View File

@@ -0,0 +1,127 @@
/* eslint-env browser */
import PropTypes from 'prop-types';
import React from 'react';
import cx from 'classnames';
import { StickyContainer, Sticky } from 'react-sticky';
import ParentSize from '@vx/responsive/build/components/ParentSize';
import NewColumn from './gridComponents/new/NewColumn';
import NewDivider from './gridComponents/new/NewDivider';
import NewHeader from './gridComponents/new/NewHeader';
import NewRow from './gridComponents/new/NewRow';
import NewTabs from './gridComponents/new/NewTabs';
import NewMarkdown from './gridComponents/new/NewMarkdown';
import SliceAdder from '../containers/SliceAdder';
import { t } from '../../locales';
const SUPERSET_HEADER_HEIGHT = 59;
const propTypes = {
topOffset: PropTypes.number,
toggleBuilderPane: PropTypes.func.isRequired,
};
const defaultProps = {
topOffset: 0,
};
class BuilderComponentPane extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
slideDirection: 'slide-out',
};
this.openSlicesPane = this.slide.bind(this, 'slide-in');
this.closeSlicesPane = this.slide.bind(this, 'slide-out');
}
slide(direction) {
this.setState({
slideDirection: direction,
});
}
render() {
const { topOffset } = this.props;
return (
<div
className="dashboard-builder-sidepane"
style={{
height: `calc(100vh - ${topOffset + SUPERSET_HEADER_HEIGHT}px)`,
}}
>
<ParentSize>
{({ height }) => (
<StickyContainer>
<Sticky topOffset={-topOffset} bottomOffset={Infinity}>
{({ style, isSticky }) => (
<div
className="viewport"
style={isSticky ? { ...style, top: topOffset } : null}
>
<div
className={cx(
'slider-container',
this.state.slideDirection,
)}
>
<div className="component-layer slide-content">
<div className="dashboard-builder-sidepane-header">
<span>{t('Insert')}</span>
<i
className="fa fa-times trigger"
onClick={this.props.toggleBuilderPane}
role="none"
/>
</div>
<div
className="new-component static"
role="none"
onClick={this.openSlicesPane}
>
<div className="new-component-placeholder fa fa-area-chart" />
<div className="new-component-label">
{t('Your charts & filters')}
</div>
<i className="fa fa-arrow-right trigger" />
</div>
<NewTabs />
<NewRow />
<NewColumn />
<NewHeader />
<NewMarkdown />
<NewDivider />
</div>
<div className="slices-layer slide-content">
<div
className="dashboard-builder-sidepane-header"
onClick={this.closeSlicesPane}
role="none"
>
<i className="fa fa-arrow-left trigger" />
<span>{t('All components')}</span>
</div>
<SliceAdder
height={
height + (isSticky ? SUPERSET_HEADER_HEIGHT : 0)
}
/>
</div>
</div>
</div>
)}
</Sticky>
</StickyContainer>
)}
</ParentSize>
</div>
);
}
}
BuilderComponentPane.propTypes = propTypes;
BuilderComponentPane.defaultProps = defaultProps;
export default BuilderComponentPane;

View File

@@ -12,13 +12,16 @@ const propTypes = {
const defaultProps = {
codeCallback: () => {},
code: '',
};
export default class CodeModal extends React.PureComponent {
constructor(props) {
super(props);
this.state = { code: props.code };
this.beforeOpen = this.beforeOpen.bind(this);
}
beforeOpen() {
let code = this.props.code;
if (!code && this.props.codeCallback) {
@@ -26,18 +29,17 @@ export default class CodeModal extends React.PureComponent {
}
this.setState({ code });
}
render() {
return (
<ModalTrigger
triggerNode={this.props.triggerNode}
isButton
beforeOpen={this.beforeOpen.bind(this)}
beforeOpen={this.beforeOpen}
modalTitle={t('Active Dashboard Filters')}
modalBody={
<div className="CodeModal">
<pre>
{this.state.code}
</pre>
<pre>{this.state.code}</pre>
</div>
}
/>

View File

@@ -29,15 +29,20 @@ class CssEditor extends React.PureComponent {
css: props.initialCss,
cssTemplateOptions: [],
};
this.changeCss = this.changeCss.bind(this);
this.changeCssTemplate = this.changeCssTemplate.bind(this);
}
changeCss(css) {
this.setState({ css }, () => {
this.props.onChange(css);
});
}
changeCssTemplate(opt) {
this.changeCss(opt.css);
}
renderTemplateSelector() {
if (this.props.templates) {
return (
@@ -46,13 +51,14 @@ class CssEditor extends React.PureComponent {
<Select
options={this.props.templates}
placeholder={t('Load a CSS template')}
onChange={this.changeCssTemplate.bind(this)}
onChange={this.changeCssTemplate}
/>
</div>
);
}
return null;
}
render() {
return (
<ModalTrigger
@@ -70,7 +76,7 @@ class CssEditor extends React.PureComponent {
theme="github"
minLines={8}
maxLines={30}
onChange={this.changeCss.bind(this)}
onChange={this.changeCss}
height="200px"
width="100%"
editorProps={{ $blockScrolling: true }}
@@ -85,6 +91,7 @@ class CssEditor extends React.PureComponent {
);
}
}
CssEditor.propTypes = propTypes;
CssEditor.defaultProps = defaultProps;

View File

@@ -1,348 +1,229 @@
/* global window */
import React from 'react';
import PropTypes from 'prop-types';
import AlertsWrapper from '../../components/AlertsWrapper';
import GridLayout from './GridLayout';
import Header from './Header';
import { exportChart } from '../../explore/exploreUtils';
import getChartIdsFromLayout from '../util/getChartIdsFromLayout';
import DashboardBuilder from '../containers/DashboardBuilder';
import {
chartPropShape,
slicePropShape,
dashboardInfoPropShape,
dashboardStatePropShape,
loadStatsPropShape,
} from '../util/propShapes';
import { areObjectsEqual } from '../../reduxUtils';
import { Logger, ActionLog, LOG_ACTIONS_PAGE_LOAD,
LOG_ACTIONS_LOAD_EVENT, LOG_ACTIONS_RENDER_EVENT } from '../../logger';
import getFormDataWithExtraFilters from '../util/charts/getFormDataWithExtraFilters';
import {
Logger,
ActionLog,
DASHBOARD_EVENT_NAMES,
LOG_ACTIONS_MOUNT_DASHBOARD,
LOG_ACTIONS_LOAD_DASHBOARD_PANE,
LOG_ACTIONS_FIRST_DASHBOARD_LOAD,
} from '../../logger';
import { t } from '../../locales';
import '../../../stylesheets/dashboard.css';
import '../stylesheets/index.less';
const propTypes = {
actions: PropTypes.object,
actions: PropTypes.shape({
addSliceToDashboard: PropTypes.func.isRequired,
removeSliceFromDashboard: PropTypes.func.isRequired,
runQuery: PropTypes.func.isRequired,
}).isRequired,
dashboardInfo: dashboardInfoPropShape.isRequired,
dashboardState: dashboardStatePropShape.isRequired,
charts: PropTypes.objectOf(chartPropShape).isRequired,
slices: PropTypes.objectOf(slicePropShape).isRequired,
datasources: PropTypes.object.isRequired,
loadStats: loadStatsPropShape.isRequired,
layout: PropTypes.object.isRequired,
impressionId: PropTypes.string.isRequired,
initMessages: PropTypes.array,
dashboard: PropTypes.object.isRequired,
slices: PropTypes.object,
datasources: PropTypes.object,
filters: PropTypes.object,
refresh: PropTypes.bool,
timeout: PropTypes.number,
userId: PropTypes.string,
isStarred: PropTypes.bool,
editMode: PropTypes.bool,
impressionId: PropTypes.string,
};
const defaultProps = {
initMessages: [],
dashboard: {},
slices: {},
datasources: {},
filters: {},
refresh: false,
timeout: 60,
userId: '',
isStarred: false,
editMode: false,
};
class Dashboard extends React.PureComponent {
constructor(props) {
super(props);
this.refreshTimer = null;
this.firstLoad = true;
this.loadingLog = new ActionLog({
impressionId: props.impressionId,
actionType: LOG_ACTIONS_PAGE_LOAD,
source: 'dashboard',
sourceId: props.dashboard.id,
eventNames: [LOG_ACTIONS_LOAD_EVENT, LOG_ACTIONS_RENDER_EVENT],
});
Logger.start(this.loadingLog);
// alert for unsaved changes
this.state = { unsavedChanges: false };
this.rerenderCharts = this.rerenderCharts.bind(this);
this.updateDashboardTitle = this.updateDashboardTitle.bind(this);
this.onSave = this.onSave.bind(this);
this.onChange = this.onChange.bind(this);
this.serialize = this.serialize.bind(this);
this.fetchAllSlices = this.fetchSlices.bind(this, this.getAllSlices());
this.startPeriodicRender = this.startPeriodicRender.bind(this);
this.addSlicesToDashboard = this.addSlicesToDashboard.bind(this);
this.fetchSlice = this.fetchSlice.bind(this);
this.getFormDataExtra = this.getFormDataExtra.bind(this);
this.exploreChart = this.exploreChart.bind(this);
this.exportCSV = this.exportCSV.bind(this);
this.props.actions.fetchFaveStar = this.props.actions.fetchFaveStar.bind(this);
this.props.actions.saveFaveStar = this.props.actions.saveFaveStar.bind(this);
this.props.actions.saveSlice = this.props.actions.saveSlice.bind(this);
this.props.actions.removeSlice = this.props.actions.removeSlice.bind(this);
this.props.actions.removeChart = this.props.actions.removeChart.bind(this);
this.props.actions.updateDashboardLayout = this.props.actions.updateDashboardLayout.bind(this);
this.props.actions.toggleExpandSlice = this.props.actions.toggleExpandSlice.bind(this);
this.props.actions.addFilter = this.props.actions.addFilter.bind(this);
this.props.actions.clearFilter = this.props.actions.clearFilter.bind(this);
this.props.actions.removeFilter = this.props.actions.removeFilter.bind(this);
}
componentDidMount() {
window.addEventListener('resize', this.rerenderCharts);
}
componentWillReceiveProps(nextProps) {
if (this.firstLoad &&
Object.values(nextProps.slices)
.every(slice => (['rendered', 'failed', 'stopped'].indexOf(slice.chartStatus) > -1))
) {
Logger.end(this.loadingLog);
this.firstLoad = false;
}
}
componentDidUpdate(prevProps) {
if (this.props.refresh) {
let changedFilterKey;
const prevFiltersKeySet = new Set(Object.keys(prevProps.filters));
Object.keys(this.props.filters).some((key) => {
prevFiltersKeySet.delete(key);
if (prevProps.filters[key] === undefined ||
!areObjectsEqual(prevProps.filters[key], this.props.filters[key])) {
changedFilterKey = key;
return true;
}
return false;
});
// has changed filter or removed a filter?
if (!!changedFilterKey || prevFiltersKeySet.size) {
this.refreshExcept(changedFilterKey);
}
}
}
componentWillUnmount() {
window.removeEventListener('resize', this.rerenderCharts);
}
onBeforeUnload(hasChanged) {
// eslint-disable-next-line react/sort-comp
static onBeforeUnload(hasChanged) {
if (hasChanged) {
window.addEventListener('beforeunload', this.unload);
window.addEventListener('beforeunload', Dashboard.unload);
} else {
window.removeEventListener('beforeunload', this.unload);
window.removeEventListener('beforeunload', Dashboard.unload);
}
}
onChange() {
this.onBeforeUnload(true);
this.setState({ unsavedChanges: true });
}
onSave() {
this.onBeforeUnload(false);
this.setState({ unsavedChanges: false });
}
// return charts in array
getAllSlices() {
return Object.values(this.props.slices);
}
getFormDataExtra(slice) {
const formDataExtra = Object.assign({}, slice.formData);
formDataExtra.extra_filters = this.effectiveExtraFilters(slice.slice_id);
return formDataExtra;
}
getFilters(sliceId) {
return this.props.filters[sliceId];
}
unload() {
static unload() {
const message = t('You have unsaved changes.');
window.event.returnValue = message; // Gecko + IE
return message; // Gecko + Webkit, Safari, Chrome etc.
}
effectiveExtraFilters(sliceId) {
const metadata = this.props.dashboard.metadata;
const filters = this.props.filters;
const f = [];
const immuneSlices = metadata.filter_immune_slices || [];
if (sliceId && immuneSlices.includes(sliceId)) {
// The slice is immune to dashboard filters
return f;
constructor(props) {
super(props);
this.isFirstLoad = true;
this.actionLog = new ActionLog({
impressionId: props.impressionId,
source: 'dashboard',
sourceId: props.dashboardInfo.id,
eventNames: DASHBOARD_EVENT_NAMES,
});
Logger.start(this.actionLog);
this.initTs = new Date().getTime();
}
componentDidMount() {
Logger.append(LOG_ACTIONS_MOUNT_DASHBOARD);
}
componentWillReceiveProps(nextProps) {
if (!nextProps.dashboardState.editMode) {
const version = nextProps.dashboardState.isV2Preview
? 'v2-preview'
: 'v2';
// log pane loads
const loadedPaneIds = [];
let minQueryStartTime = Infinity;
const allVisiblePanesDidLoad = Object.entries(nextProps.loadStats).every(
([paneId, stats]) => {
const {
didLoad,
minQueryStartTime: paneMinQueryStart,
...restStats
} = stats;
if (
didLoad &&
this.props.loadStats[paneId] &&
!this.props.loadStats[paneId].didLoad
) {
Logger.append(LOG_ACTIONS_LOAD_DASHBOARD_PANE, {
...restStats,
duration: new Date().getTime() - paneMinQueryStart,
version,
});
if (!this.isFirstLoad) {
Logger.send(this.actionLog);
}
}
if (this.isFirstLoad && didLoad && stats.slice_ids.length > 0) {
loadedPaneIds.push(paneId);
minQueryStartTime = Math.min(minQueryStartTime, paneMinQueryStart);
}
// return true if it is loaded, or it's index is not 0
return didLoad || stats.index !== 0;
},
);
if (allVisiblePanesDidLoad && this.isFirstLoad) {
Logger.append(LOG_ACTIONS_FIRST_DASHBOARD_LOAD, {
pane_ids: loadedPaneIds,
duration: new Date().getTime() - minQueryStartTime,
version,
});
Logger.send(this.actionLog);
this.isFirstLoad = false;
}
}
// Building a list of fields the slice is immune to filters on
let immuneToFields = [];
if (
sliceId &&
metadata.filter_immune_slice_fields &&
metadata.filter_immune_slice_fields[sliceId]) {
immuneToFields = metadata.filter_immune_slice_fields[sliceId];
const currentChartIds = getChartIdsFromLayout(this.props.layout);
const nextChartIds = getChartIdsFromLayout(nextProps.layout);
if (currentChartIds.length < nextChartIds.length) {
const newChartIds = nextChartIds.filter(
key => currentChartIds.indexOf(key) === -1,
);
newChartIds.forEach(newChartId =>
this.props.actions.addSliceToDashboard(newChartId),
);
} else if (currentChartIds.length > nextChartIds.length) {
// remove chart
const removedChartIds = currentChartIds.filter(
key => nextChartIds.indexOf(key) === -1,
);
removedChartIds.forEach(removedChartId =>
this.props.actions.removeSliceFromDashboard(removedChartId),
);
}
for (const filteringSliceId in filters) {
if (filteringSliceId === sliceId.toString()) {
// Filters applied by the slice don't apply to itself
continue;
}
for (const field in filters[filteringSliceId]) {
if (!immuneToFields.includes(field)) {
f.push({
col: field,
op: 'in',
val: filters[filteringSliceId][field],
});
}
componentDidUpdate(prevProps) {
const { refresh, filters, hasUnsavedChanges } = this.props.dashboardState;
if (refresh) {
// refresh charts if a filter was removed, added, or changed
let changedFilterKey = null;
const currFilterKeys = Object.keys(filters);
const prevFilterKeys = Object.keys(prevProps.dashboardState.filters);
currFilterKeys.forEach(key => {
const prevFilter = prevProps.dashboardState.filters[key];
if (
// filter was added or changed
typeof prevFilter === 'undefined' ||
!areObjectsEqual(prevFilter, filters[key])
) {
changedFilterKey = key;
}
});
if (
!!changedFilterKey ||
currFilterKeys.length !== prevFilterKeys.length
) {
this.refreshExcept(changedFilterKey);
}
}
return f;
if (hasUnsavedChanges) {
Dashboard.onBeforeUnload(true);
} else {
Dashboard.onBeforeUnload(false);
}
}
// return charts in array
getAllCharts() {
return Object.values(this.props.charts);
}
refreshExcept(filterKey) {
const immune = this.props.dashboard.metadata.filter_immune_slices || [];
let slices = this.getAllSlices();
if (filterKey) {
slices = slices.filter(slice => (
String(slice.slice_id) !== filterKey &&
immune.indexOf(slice.slice_id) === -1
));
}
this.fetchSlices(slices);
}
const immune = this.props.dashboardInfo.metadata.filter_immune_slices || [];
stopPeriodicRender() {
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
this.refreshTimer = null;
}
}
this.getAllCharts().forEach(chart => {
// filterKey is a string, immune array contains numbers
if (String(chart.id) !== filterKey && immune.indexOf(chart.id) === -1) {
const updatedFormData = getFormDataWithExtraFilters({
chart,
dashboardMetadata: this.props.dashboardInfo.metadata,
filters: this.props.dashboardState.filters,
sliceId: chart.id,
});
startPeriodicRender(interval) {
this.stopPeriodicRender();
const immune = this.props.dashboard.metadata.timed_refresh_immune_slices || [];
const refreshAll = () => {
const affectedSlices = this.getAllSlices()
.filter(slice => immune.indexOf(slice.slice_id) === -1);
this.fetchSlices(affectedSlices, true, interval * 0.2);
};
const fetchAndRender = () => {
refreshAll();
if (interval > 0) {
this.refreshTimer = setTimeout(fetchAndRender, interval);
this.props.actions.runQuery(
updatedFormData,
false,
this.props.timeout,
chart.id,
);
}
};
fetchAndRender();
}
updateDashboardTitle(title) {
this.props.actions.updateDashboardTitle(title);
this.onChange();
}
serialize() {
return this.props.dashboard.layout.map(reactPos => ({
slice_id: reactPos.i,
col: reactPos.x + 1,
row: reactPos.y,
size_x: reactPos.w,
size_y: reactPos.h,
}));
}
addSlicesToDashboard(sliceIds) {
return this.props.actions.addSlicesToDashboard(this.props.dashboard.id, sliceIds);
}
fetchSlice(slice, force = false) {
return this.props.actions.runQuery(
this.getFormDataExtra(slice), force, this.props.timeout, slice.chartKey,
);
}
// fetch and render an list of slices
fetchSlices(slc, force = false, interval = 0) {
const slices = slc || this.getAllSlices();
if (!interval) {
slices.forEach((slice) => { this.fetchSlice(slice, force); });
return;
}
const meta = this.props.dashboard.metadata;
const refreshTime = Math.max(interval, meta.stagger_time || 5000); // default 5 seconds
if (typeof meta.stagger_refresh !== 'boolean') {
meta.stagger_refresh = meta.stagger_refresh === undefined ?
true : meta.stagger_refresh === 'true';
}
const delay = meta.stagger_refresh ? refreshTime / (slices.length - 1) : 0;
slices.forEach((slice, i) => {
setTimeout(() => { this.fetchSlice(slice, force); }, delay * i);
});
}
exploreChart(slice) {
const formData = this.getFormDataExtra(slice);
exportChart(formData);
}
exportCSV(slice) {
const formData = this.getFormDataExtra(slice);
exportChart(formData, 'csv');
}
// re-render chart without fetch
rerenderCharts() {
this.getAllSlices().forEach((slice) => {
setTimeout(() => {
this.props.actions.renderTriggered(new Date().getTime(), slice.chartKey);
}, 50);
});
}
render() {
return (
<div id="dashboard-container">
<div id="dashboard-header">
<AlertsWrapper initMessages={this.props.initMessages} />
<Header
dashboard={this.props.dashboard}
unsavedChanges={this.state.unsavedChanges}
filters={this.props.filters}
userId={this.props.userId}
isStarred={this.props.isStarred}
updateDashboardTitle={this.updateDashboardTitle}
onSave={this.onSave}
onChange={this.onChange}
serialize={this.serialize}
fetchFaveStar={this.props.actions.fetchFaveStar}
saveFaveStar={this.props.actions.saveFaveStar}
renderSlices={this.fetchAllSlices}
startPeriodicRender={this.startPeriodicRender}
addSlicesToDashboard={this.addSlicesToDashboard}
editMode={this.props.editMode}
setEditMode={this.props.actions.setEditMode}
/>
</div>
<div id="grid-container" className="slice-grid gridster">
<GridLayout
dashboard={this.props.dashboard}
datasources={this.props.datasources}
filters={this.props.filters}
charts={this.props.slices}
timeout={this.props.timeout}
onChange={this.onChange}
getFormDataExtra={this.getFormDataExtra}
exploreChart={this.exploreChart}
exportCSV={this.exportCSV}
fetchSlice={this.fetchSlice}
saveSlice={this.props.actions.saveSlice}
removeSlice={this.props.actions.removeSlice}
removeChart={this.props.actions.removeChart}
updateDashboardLayout={this.props.actions.updateDashboardLayout}
toggleExpandSlice={this.props.actions.toggleExpandSlice}
addFilter={this.props.actions.addFilter}
getFilters={this.getFilters}
clearFilter={this.props.actions.clearFilter}
removeFilter={this.props.actions.removeFilter}
editMode={this.props.editMode}
/>
</div>
<div>
<AlertsWrapper initMessages={this.props.initMessages} />
<DashboardBuilder />
</div>
);
}

View File

@@ -0,0 +1,208 @@
/* eslint-env browser */
import cx from 'classnames';
// ParentSize uses resize observer so the dashboard will update size
// when its container size changes, due to e.g., builder side panel opening
import ParentSize from '@vx/responsive/build/components/ParentSize';
import PropTypes from 'prop-types';
import React from 'react';
import { Sticky, StickyContainer } from 'react-sticky';
import { TabContainer, TabContent, TabPane } from 'react-bootstrap';
import BuilderComponentPane from './BuilderComponentPane';
import DashboardHeader from '../containers/DashboardHeader';
import DashboardGrid from '../containers/DashboardGrid';
import IconButton from './IconButton';
import DragDroppable from './dnd/DragDroppable';
import DashboardComponent from '../containers/DashboardComponent';
import ToastPresenter from '../containers/ToastPresenter';
import WithPopoverMenu from './menu/WithPopoverMenu';
import getDragDropManager from '../util/getDragDropManager';
import {
DASHBOARD_GRID_ID,
DASHBOARD_ROOT_ID,
DASHBOARD_ROOT_DEPTH,
} from '../util/constants';
const TABS_HEIGHT = 47;
const HEADER_HEIGHT = 67;
const propTypes = {
// redux
dashboardLayout: PropTypes.object.isRequired,
deleteTopLevelTabs: PropTypes.func.isRequired,
editMode: PropTypes.bool.isRequired,
showBuilderPane: PropTypes.bool,
handleComponentDrop: PropTypes.func.isRequired,
toggleBuilderPane: PropTypes.func.isRequired,
};
const defaultProps = {
showBuilderPane: false,
};
class DashboardBuilder extends React.Component {
static shouldFocusTabs(event, container) {
// don't focus the tabs when we click on a tab
return (
event.target.tagName === 'UL' ||
(/icon-button/.test(event.target.className) &&
container.contains(event.target))
);
}
constructor(props) {
super(props);
this.state = {
tabIndex: 0, // top-level tabs
};
this.handleChangeTab = this.handleChangeTab.bind(this);
this.handleDeleteTopLevelTabs = this.handleDeleteTopLevelTabs.bind(this);
}
getChildContext() {
return {
dragDropManager: this.context.dragDropManager || getDragDropManager(),
};
}
handleDeleteTopLevelTabs() {
this.props.deleteTopLevelTabs();
this.setState({ tabIndex: 0 });
}
handleChangeTab({ tabIndex }) {
this.setState(() => ({ tabIndex }));
setTimeout(() => {
if (window)
window.scrollTo({
top: 0,
behavior: 'smooth',
});
}, 100);
}
render() {
const { handleComponentDrop, dashboardLayout, editMode } = this.props;
const { tabIndex } = this.state;
const dashboardRoot = dashboardLayout[DASHBOARD_ROOT_ID];
const rootChildId = dashboardRoot.children[0];
const topLevelTabs =
rootChildId !== DASHBOARD_GRID_ID && dashboardLayout[rootChildId];
const childIds = topLevelTabs ? topLevelTabs.children : [DASHBOARD_GRID_ID];
return (
<StickyContainer
className={cx('dashboard', editMode && 'dashboard--editing')}
>
<Sticky>
{({ style }) => (
<DragDroppable
component={dashboardRoot}
parentComponent={null}
depth={DASHBOARD_ROOT_DEPTH}
index={0}
orientation="column"
onDrop={handleComponentDrop}
editMode={editMode}
// you cannot drop on/displace tabs if they already exist
disableDragdrop={!!topLevelTabs}
style={{ zIndex: 100, ...style }}
>
{({ dropIndicatorProps }) => (
<div>
<DashboardHeader />
{dropIndicatorProps && <div {...dropIndicatorProps} />}
{topLevelTabs && (
<WithPopoverMenu
shouldFocus={DashboardBuilder.shouldFocusTabs}
menuItems={[
<IconButton
className="fa fa-level-down"
label="Collapse tab content"
onClick={this.handleDeleteTopLevelTabs}
/>,
]}
editMode={editMode}
>
<DashboardComponent
id={topLevelTabs.id}
parentId={DASHBOARD_ROOT_ID}
depth={DASHBOARD_ROOT_DEPTH + 1}
index={0}
renderTabContent={false}
renderHoverMenu={false}
onChangeTab={this.handleChangeTab}
/>
</WithPopoverMenu>
)}
</div>
)}
</DragDroppable>
)}
</Sticky>
<div className="dashboard-content">
<div className="grid-container">
<ParentSize>
{({ width }) => (
/*
We use a TabContainer irrespective of whether top-level tabs exist to maintain
a consistent React component tree. This avoids expensive mounts/unmounts of
the entire dashboard upon adding/removing top-level tabs, which would otherwise
happen because of React's diffing algorithm
*/
<TabContainer
id={DASHBOARD_GRID_ID}
activeKey={Math.min(tabIndex, childIds.length - 1)}
onSelect={this.handleChangeTab}
animation
mountOnEnter
unmountOnExit={false}
>
<TabContent>
{childIds.map((id, index) => (
// Matching the key of the first TabPane irrespective of topLevelTabs
// lets us keep the same React component tree when !!topLevelTabs changes.
// This avoids expensive mounts/unmounts of the entire dashboard.
<TabPane
key={index === 0 ? DASHBOARD_GRID_ID : id}
eventKey={index}
>
<DashboardGrid
gridComponent={dashboardLayout[id]}
// see isValidChild for why tabs do not increment the depth of their children
depth={DASHBOARD_ROOT_DEPTH + 1} // (topLevelTabs ? 0 : 1)}
width={width}
/>
</TabPane>
))}
</TabContent>
</TabContainer>
)}
</ParentSize>
</div>
{this.props.editMode &&
this.props.showBuilderPane && (
<BuilderComponentPane
topOffset={HEADER_HEIGHT + (topLevelTabs ? TABS_HEIGHT : 0)}
toggleBuilderPane={this.props.toggleBuilderPane}
/>
)}
</div>
<ToastPresenter />
</StickyContainer>
);
}
}
DashboardBuilder.propTypes = propTypes;
DashboardBuilder.defaultProps = defaultProps;
DashboardBuilder.childContextTypes = {
dragDropManager: PropTypes.object.isRequired,
};
export default DashboardBuilder;

View File

@@ -0,0 +1,198 @@
import React from 'react';
import PropTypes from 'prop-types';
import { componentShape } from '../util/propShapes';
import DashboardComponent from '../containers/DashboardComponent';
import DragDroppable from './dnd/DragDroppable';
import { GRID_GUTTER_SIZE, GRID_COLUMN_COUNT } from '../util/constants';
const propTypes = {
depth: PropTypes.number.isRequired,
editMode: PropTypes.bool.isRequired,
gridComponent: componentShape.isRequired,
handleComponentDrop: PropTypes.func.isRequired,
resizeComponent: PropTypes.func.isRequired,
width: PropTypes.number.isRequired,
};
const defaultProps = {};
class DashboardGrid extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
isResizing: false,
rowGuideTop: null,
};
this.handleResizeStart = this.handleResizeStart.bind(this);
this.handleResize = this.handleResize.bind(this);
this.handleResizeStop = this.handleResizeStop.bind(this);
this.handleTopDropTargetDrop = this.handleTopDropTargetDrop.bind(this);
this.getRowGuidePosition = this.getRowGuidePosition.bind(this);
this.setGridRef = this.setGridRef.bind(this);
}
getRowGuidePosition(resizeRef) {
if (resizeRef && this.grid) {
return (
resizeRef.getBoundingClientRect().bottom -
this.grid.getBoundingClientRect().top -
2
);
}
return null;
}
setGridRef(ref) {
this.grid = ref;
}
handleResizeStart({ ref, direction }) {
let rowGuideTop = null;
if (direction === 'bottom' || direction === 'bottomRight') {
rowGuideTop = this.getRowGuidePosition(ref);
}
this.setState(() => ({
isResizing: true,
rowGuideTop,
}));
}
handleResize({ ref, direction }) {
if (direction === 'bottom' || direction === 'bottomRight') {
this.setState(() => ({ rowGuideTop: this.getRowGuidePosition(ref) }));
}
}
handleResizeStop({ id, widthMultiple: width, heightMultiple: height }) {
this.props.resizeComponent({ id, width, height });
this.setState(() => ({
isResizing: false,
rowGuideTop: null,
}));
}
handleTopDropTargetDrop(dropResult) {
if (dropResult) {
this.props.handleComponentDrop({
...dropResult,
destination: {
...dropResult.destination,
// force appending as the first child if top drop target
index: 0,
},
});
}
}
render() {
const {
gridComponent,
handleComponentDrop,
depth,
editMode,
width,
} = this.props;
const columnPlusGutterWidth =
(width + GRID_GUTTER_SIZE) / GRID_COLUMN_COUNT;
const columnWidth = columnPlusGutterWidth - GRID_GUTTER_SIZE;
const { isResizing, rowGuideTop } = this.state;
return width < 100 ? null : (
<div className="dashboard-grid" ref={this.setGridRef}>
<div className="grid-content">
{/* make the area above components droppable */}
{editMode && (
<DragDroppable
component={gridComponent}
depth={depth}
parentComponent={null}
index={0}
orientation="column"
onDrop={this.handleTopDropTargetDrop}
className="empty-droptarget"
editMode
>
{({ dropIndicatorProps }) =>
dropIndicatorProps && (
<div className="drop-indicator drop-indicator--bottom" />
)
}
</DragDroppable>
)}
{gridComponent.children.map((id, index) => (
<DashboardComponent
key={id}
id={id}
parentId={gridComponent.id}
depth={depth + 1}
index={index}
availableColumnCount={GRID_COLUMN_COUNT}
columnWidth={columnWidth}
onResizeStart={this.handleResizeStart}
onResize={this.handleResize}
onResizeStop={this.handleResizeStop}
/>
))}
{/* make the area below components droppable */}
{editMode && (
<DragDroppable
component={gridComponent}
depth={depth}
parentComponent={null}
index={gridComponent.children.length}
orientation="column"
onDrop={handleComponentDrop}
className="empty-droptarget"
editMode
>
{({ dropIndicatorProps }) =>
dropIndicatorProps && (
<div className="drop-indicator drop-indicator--top" />
)
}
</DragDroppable>
)}
{isResizing &&
Array(GRID_COLUMN_COUNT)
.fill(null)
.map((_, i) => (
<div
key={`grid-column-${i}`}
className="grid-column-guide"
style={{
left: i * GRID_GUTTER_SIZE + i * columnWidth,
width: columnWidth,
}}
/>
))}
{isResizing &&
rowGuideTop && (
<div
className="grid-row-guide"
style={{
top: rowGuideTop,
width,
}}
/>
)}
</div>
</div>
);
}
}
DashboardGrid.propTypes = propTypes;
DashboardGrid.defaultProps = defaultProps;
export default DashboardGrid;

View File

@@ -0,0 +1,20 @@
import React from 'react';
import PropTypes from 'prop-types';
import IconButton from './IconButton';
const propTypes = {
onDelete: PropTypes.func.isRequired,
};
const defaultProps = {};
export default class DeleteComponentButton extends React.PureComponent {
render() {
const { onDelete } = this.props;
return <IconButton onClick={onDelete} className="fa fa-trash" />;
}
}
DeleteComponentButton.propTypes = propTypes;
DeleteComponentButton.defaultProps = defaultProps;

View File

@@ -1,123 +1,333 @@
/* eslint-env browser */
import React from 'react';
import PropTypes from 'prop-types';
import Controls from './Controls';
import HeaderActionsDropdown from './HeaderActionsDropdown';
import EditableTitle from '../../components/EditableTitle';
import Button from '../../components/Button';
import FaveStar from '../../components/FaveStar';
import URLShortLinkButton from '../../components/URLShortLinkButton';
import InfoTooltipWithTrigger from '../../components/InfoTooltipWithTrigger';
import UndoRedoKeylisteners from './UndoRedoKeylisteners';
import V2PreviewModal from '../deprecated/V2PreviewModal';
import { chartPropShape } from '../util/propShapes';
import { t } from '../../locales';
import { UNDO_LIMIT, SAVE_TYPE_OVERWRITE } from '../util/constants';
const propTypes = {
dashboard: PropTypes.object.isRequired,
addSuccessToast: PropTypes.func.isRequired,
addDangerToast: PropTypes.func.isRequired,
dashboardInfo: PropTypes.object.isRequired,
dashboardTitle: PropTypes.string.isRequired,
charts: PropTypes.objectOf(chartPropShape).isRequired,
layout: PropTypes.object.isRequired,
filters: PropTypes.object.isRequired,
userId: PropTypes.string.isRequired,
isStarred: PropTypes.bool,
addSlicesToDashboard: PropTypes.func,
onSave: PropTypes.func,
onChange: PropTypes.func,
fetchFaveStar: PropTypes.func,
renderSlices: PropTypes.func,
saveFaveStar: PropTypes.func,
serialize: PropTypes.func,
startPeriodicRender: PropTypes.func,
updateDashboardTitle: PropTypes.func,
expandedSlices: PropTypes.object.isRequired,
css: PropTypes.string.isRequired,
isStarred: PropTypes.bool.isRequired,
onSave: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
fetchFaveStar: PropTypes.func.isRequired,
fetchCharts: PropTypes.func.isRequired,
saveFaveStar: PropTypes.func.isRequired,
startPeriodicRender: PropTypes.func.isRequired,
updateDashboardTitle: PropTypes.func.isRequired,
editMode: PropTypes.bool.isRequired,
isV2Preview: PropTypes.bool.isRequired,
setEditMode: PropTypes.func.isRequired,
unsavedChanges: PropTypes.bool.isRequired,
showBuilderPane: PropTypes.bool.isRequired,
toggleBuilderPane: PropTypes.func.isRequired,
updateCss: PropTypes.func.isRequired,
hasUnsavedChanges: PropTypes.bool.isRequired,
maxUndoHistoryExceeded: PropTypes.bool.isRequired,
// redux
onUndo: PropTypes.func.isRequired,
onRedo: PropTypes.func.isRequired,
undoLength: PropTypes.number.isRequired,
redoLength: PropTypes.number.isRequired,
setMaxUndoHistoryExceeded: PropTypes.func.isRequired,
maxUndoHistoryToast: PropTypes.func.isRequired,
};
class Header extends React.PureComponent {
static discardChanges() {
window.location.reload();
}
constructor(props) {
super(props);
this.handleSaveTitle = this.handleSaveTitle.bind(this);
this.state = {
didNotifyMaxUndoHistoryToast: false,
emphasizeUndo: false,
hightlightRedo: false,
showV2PreviewModal: props.isV2Preview,
};
this.handleChangeText = this.handleChangeText.bind(this);
this.handleCtrlZ = this.handleCtrlZ.bind(this);
this.handleCtrlY = this.handleCtrlY.bind(this);
this.toggleEditMode = this.toggleEditMode.bind(this);
this.forceRefresh = this.forceRefresh.bind(this);
this.overwriteDashboard = this.overwriteDashboard.bind(this);
this.toggleShowV2PreviewModal = this.toggleShowV2PreviewModal.bind(this);
}
handleSaveTitle(title) {
this.props.updateDashboardTitle(title);
componentWillReceiveProps(nextProps) {
if (
UNDO_LIMIT - nextProps.undoLength <= 0 &&
!this.state.didNotifyMaxUndoHistoryToast
) {
this.setState(() => ({ didNotifyMaxUndoHistoryToast: true }));
this.props.maxUndoHistoryToast();
}
if (
nextProps.undoLength > UNDO_LIMIT &&
!this.props.maxUndoHistoryExceeded
) {
this.props.setMaxUndoHistoryExceeded();
}
}
componentWillUnmount() {
clearTimeout(this.ctrlYTimeout);
clearTimeout(this.ctrlZTimeout);
}
forceRefresh() {
return this.props.fetchCharts(Object.values(this.props.charts), true);
}
handleChangeText(nextText) {
const { updateDashboardTitle, onChange } = this.props;
if (nextText && this.props.dashboardTitle !== nextText) {
updateDashboardTitle(nextText);
onChange();
}
}
handleCtrlY() {
this.props.onRedo();
this.setState({ emphasizeRedo: true }, () => {
if (this.ctrlYTimeout) clearTimeout(this.ctrlYTimeout);
this.ctrlYTimeout = setTimeout(() => {
this.setState({ emphasizeRedo: false });
}, 100);
});
}
handleCtrlZ() {
this.props.onUndo();
this.setState({ emphasizeUndo: true }, () => {
if (this.ctrlZTimeout) clearTimeout(this.ctrlZTimeout);
this.ctrlZTimeout = setTimeout(() => {
this.setState({ emphasizeUndo: false });
}, 100);
});
}
toggleEditMode() {
this.props.setEditMode(!this.props.editMode);
}
renderUnsaved() {
if (!this.props.unsavedChanges) {
return null;
}
return (
<InfoTooltipWithTrigger
label="unsaved"
tooltip={t('Unsaved changes')}
icon="exclamation-triangle"
className="text-danger m-r-5"
placement="top"
/>
);
toggleShowV2PreviewModal() {
this.setState({ showV2PreviewModal: !this.state.showV2PreviewModal });
}
renderEditButton() {
if (!this.props.dashboard.dash_save_perm) {
return null;
}
const btnText = this.props.editMode ? 'Switch to View Mode' : 'Edit Dashboard';
return (
<Button
bsStyle="default"
className="m-r-5"
style={{ width: '150px' }}
onClick={this.toggleEditMode}
>
{btnText}
</Button>);
overwriteDashboard() {
const {
dashboardTitle,
layout: positions,
expandedSlices,
css,
filters,
dashboardInfo,
} = this.props;
const data = {
positions,
expanded_slices: expandedSlices,
css,
dashboard_title: dashboardTitle,
default_filters: JSON.stringify(filters),
};
this.props.onSave(data, dashboardInfo.id, SAVE_TYPE_OVERWRITE);
}
render() {
const dashboard = this.props.dashboard;
const {
dashboardTitle,
layout,
filters,
expandedSlices,
css,
isV2Preview,
onUndo,
onRedo,
undoLength,
redoLength,
onChange,
onSave,
updateCss,
editMode,
showBuilderPane,
dashboardInfo,
hasUnsavedChanges,
} = this.props;
const userCanEdit = dashboardInfo.dash_edit_perm;
const userCanSaveAs = dashboardInfo.dash_save_perm;
const popButton = hasUnsavedChanges || isV2Preview;
return (
<div className="title">
<div className="pull-left">
<h1 className="outer-container pull-left">
<EditableTitle
title={dashboard.dashboard_title}
canEdit={dashboard.dash_save_perm && this.props.editMode}
onSaveTitle={this.handleSaveTitle}
showTooltip={this.props.editMode}
/>
<span className="favstar m-r-5">
<FaveStar
itemId={dashboard.id}
fetchFaveStar={this.props.fetchFaveStar}
saveFaveStar={this.props.saveFaveStar}
isStarred={this.props.isStarred}
/>
</span>
{this.renderUnsaved()}
</h1>
</div>
<div className="pull-right" style={{ marginTop: '35px' }}>
<span className="m-r-5">
<URLShortLinkButton
emailSubject="Superset Dashboard"
emailContent="Check out this dashboard: "
<div className="dashboard-header">
<div className="dashboard-component-header header-large">
<EditableTitle
title={dashboardTitle}
canEdit={userCanEdit && editMode}
onSaveTitle={this.handleChangeText}
showTooltip={false}
/>
<span className="favstar">
<FaveStar
itemId={dashboardInfo.id}
fetchFaveStar={this.props.fetchFaveStar}
saveFaveStar={this.props.saveFaveStar}
isStarred={this.props.isStarred}
/>
</span>
{this.renderEditButton()}
<Controls
dashboard={dashboard}
filters={this.props.filters}
userId={this.props.userId}
addSlicesToDashboard={this.props.addSlicesToDashboard}
onSave={this.props.onSave}
onChange={this.props.onChange}
renderSlices={this.props.renderSlices}
serialize={this.props.serialize}
startPeriodicRender={this.props.startPeriodicRender}
editMode={this.props.editMode}
/>
{isV2Preview && (
<div
role="none"
className="v2-preview-badge"
onClick={this.toggleShowV2PreviewModal}
>
{t('v2 Preview')}
<span className="fa fa-info-circle m-l-5" />
</div>
)}
{isV2Preview &&
this.state.showV2PreviewModal && (
<V2PreviewModal onClose={this.toggleShowV2PreviewModal} />
)}
</div>
<div className="clearfix" />
{userCanSaveAs && (
<div className="button-container">
{editMode && (
<Button
bsSize="small"
onClick={onUndo}
disabled={undoLength < 1}
bsStyle={this.state.emphasizeUndo ? 'primary' : undefined}
>
<div title="Undo" className="undo-action fa fa-reply" />
</Button>
)}
{editMode && (
<Button
bsSize="small"
onClick={onRedo}
disabled={redoLength < 1}
bsStyle={this.state.emphasizeRedo ? 'primary' : undefined}
>
<div title="Redo" className="redo-action fa fa-share" />
</Button>
)}
{editMode && (
<Button bsSize="small" onClick={this.props.toggleBuilderPane}>
{showBuilderPane
? t('Hide components')
: t('Insert components')}
</Button>
)}
{editMode &&
(hasUnsavedChanges || isV2Preview) && (
<Button
bsSize="small"
bsStyle={popButton ? 'primary' : undefined}
onClick={this.overwriteDashboard}
>
{isV2Preview
? t('Persist as Dashboard v2')
: t('Save changes')}
</Button>
)}
{!editMode &&
isV2Preview && (
<Button
bsSize="small"
onClick={this.toggleEditMode}
bsStyle={popButton ? 'primary' : undefined}
disabled={!userCanEdit}
>
{t('Edit to persist Dashboard v2')}
</Button>
)}
{!editMode &&
!isV2Preview &&
!hasUnsavedChanges && (
<Button
bsSize="small"
onClick={this.toggleEditMode}
bsStyle={popButton ? 'primary' : undefined}
disabled={!userCanEdit}
>
{t('Edit dashboard')}
</Button>
)}
{editMode &&
!isV2Preview &&
!hasUnsavedChanges && (
<Button
bsSize="small"
onClick={this.toggleEditMode}
bsStyle={undefined}
disabled={!userCanEdit}
>
{t('Switch to view mode')}
</Button>
)}
<HeaderActionsDropdown
addSuccessToast={this.props.addSuccessToast}
addDangerToast={this.props.addDangerToast}
dashboardId={dashboardInfo.id}
dashboardTitle={dashboardTitle}
layout={layout}
filters={filters}
expandedSlices={expandedSlices}
css={css}
onSave={onSave}
onChange={onChange}
forceRefreshAllCharts={this.forceRefresh}
startPeriodicRender={this.props.startPeriodicRender}
updateCss={updateCss}
editMode={editMode}
hasUnsavedChanges={hasUnsavedChanges}
userCanEdit={userCanEdit}
isV2Preview={isV2Preview}
/>
{editMode && (
<UndoRedoKeylisteners
onUndo={this.handleCtrlZ}
onRedo={this.handleCtrlY}
/>
)}
</div>
)}
</div>
);
}
}
Header.propTypes = propTypes;
export default Header;

View File

@@ -0,0 +1,163 @@
/* global window */
import React from 'react';
import PropTypes from 'prop-types';
import $ from 'jquery';
import { DropdownButton, MenuItem } from 'react-bootstrap';
import CssEditor from './CssEditor';
import RefreshIntervalModal from './RefreshIntervalModal';
import SaveModal from './SaveModal';
import injectCustomCss from '../util/injectCustomCss';
import { SAVE_TYPE_NEWDASHBOARD } from '../util/constants';
import { t } from '../../locales';
const propTypes = {
addSuccessToast: PropTypes.func.isRequired,
addDangerToast: PropTypes.func.isRequired,
dashboardId: PropTypes.number.isRequired,
dashboardTitle: PropTypes.string.isRequired,
hasUnsavedChanges: PropTypes.bool.isRequired,
css: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
updateCss: PropTypes.func.isRequired,
forceRefreshAllCharts: PropTypes.func.isRequired,
startPeriodicRender: PropTypes.func.isRequired,
editMode: PropTypes.bool.isRequired,
userCanEdit: PropTypes.bool.isRequired,
layout: PropTypes.object.isRequired,
filters: PropTypes.object.isRequired,
expandedSlices: PropTypes.object.isRequired,
onSave: PropTypes.func.isRequired,
isV2Preview: PropTypes.bool.isRequired,
};
const defaultProps = {};
class HeaderActionsDropdown extends React.PureComponent {
static discardChanges() {
window.location.reload();
}
constructor(props) {
super(props);
this.state = {
css: props.css,
cssTemplates: [],
};
this.changeCss = this.changeCss.bind(this);
}
componentWillMount() {
injectCustomCss(this.state.css);
$.get('/csstemplateasyncmodelview/api/read', data => {
const cssTemplates = data.result.map(row => ({
value: row.template_name,
css: row.css,
label: row.template_name,
}));
this.setState({ cssTemplates });
});
}
changeCss(css) {
this.setState({ css }, () => {
injectCustomCss(css);
});
this.props.onChange();
this.props.updateCss(css);
}
render() {
const {
dashboardTitle,
dashboardId,
startPeriodicRender,
forceRefreshAllCharts,
editMode,
css,
hasUnsavedChanges,
layout,
filters,
expandedSlices,
onSave,
userCanEdit,
isV2Preview,
} = this.props;
const emailBody = t('Check out this dashboard: %s', window.location.href);
const emailLink = `mailto:?Subject=Superset%20Dashboard%20${dashboardTitle}&Body=${emailBody}`;
return (
<DropdownButton
title=""
id="save-dash-split-button"
bsStyle={hasUnsavedChanges || isV2Preview ? 'primary' : undefined}
bsSize="small"
pullRight
>
<SaveModal
addSuccessToast={this.props.addSuccessToast}
addDangerToast={this.props.addDangerToast}
dashboardId={dashboardId}
dashboardTitle={dashboardTitle}
saveType={SAVE_TYPE_NEWDASHBOARD}
layout={layout}
filters={filters}
expandedSlices={expandedSlices}
css={css}
onSave={onSave}
isMenuItem
triggerNode={<span>{t('Save as')}</span>}
canOverwrite={userCanEdit}
isV2Preview={isV2Preview}
/>
{(isV2Preview || hasUnsavedChanges) && (
<MenuItem
eventKey="discard"
onSelect={HeaderActionsDropdown.discardChanges}
>
{t('Discard changes')}
</MenuItem>
)}
<MenuItem divider />
<MenuItem onClick={forceRefreshAllCharts}>
{t('Force refresh dashboard')}
</MenuItem>
<RefreshIntervalModal
onChange={refreshInterval =>
startPeriodicRender(refreshInterval * 1000)
}
triggerNode={<span>{t('Set auto-refresh interval')}</span>}
/>
{editMode && (
<MenuItem
target="_blank"
href={`/dashboardmodelview/edit/${dashboardId}`}
>
{t('Edit dashboard metadata')}
</MenuItem>
)}
{editMode && (
<MenuItem href={emailLink}>{t('Email dashboard link')}</MenuItem>
)}
{editMode && (
<CssEditor
triggerNode={<span>{t('Edit CSS')}</span>}
initialCss={this.state.css}
templates={this.state.cssTemplates}
onChange={this.changeCss}
/>
)}
</DropdownButton>
);
}
}
HeaderActionsDropdown.propTypes = propTypes;
HeaderActionsDropdown.defaultProps = defaultProps;
export default HeaderActionsDropdown;

View File

@@ -0,0 +1,44 @@
import React from 'react';
import PropTypes from 'prop-types';
const propTypes = {
onClick: PropTypes.func.isRequired,
className: PropTypes.string,
label: PropTypes.string,
};
const defaultProps = {
className: null,
label: null,
};
export default class IconButton extends React.PureComponent {
constructor(props) {
super(props);
this.handleClick = this.handleClick.bind(this);
}
handleClick(event) {
event.preventDefault();
const { onClick } = this.props;
onClick(event);
}
render() {
const { className, label } = this.props;
return (
<div
className="icon-button"
onClick={this.handleClick}
tabIndex="0"
role="button"
>
<span className={className} />
{label && <span className="icon-button-label">{label}</span>}
</div>
);
}
}
IconButton.propTypes = propTypes;
IconButton.defaultProps = defaultProps;

Some files were not shown because too many files have changed in this diff Show More