mirror of https://github.com/apache/superset.git
[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
This commit is contained in:
parent
ca5cc672ca
commit
c065319508
|
@ -8,3 +8,4 @@ node_modules*/*
|
||||||
stylesheets/*
|
stylesheets/*
|
||||||
vendor/*
|
vendor/*
|
||||||
docs/*
|
docs/*
|
||||||
|
src/dashboard/deprecated/*
|
||||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 77 KiB |
|
@ -8,8 +8,8 @@
|
||||||
"test": "spec"
|
"test": "spec"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "mocha --require ignore-styles --compilers js:babel-core/register --require 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 --recursive 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": "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-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'",
|
"dev-fast": "echo 'dev-fast in now replaced by dev'",
|
||||||
|
@ -41,9 +41,9 @@
|
||||||
},
|
},
|
||||||
"homepage": "http://superset.apache.org/",
|
"homepage": "http://superset.apache.org/",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"//": "known issues with react-bootstrap>=0.32",
|
|
||||||
"@data-ui/event-flow": "^0.0.54",
|
"@data-ui/event-flow": "^0.0.54",
|
||||||
"@data-ui/sparkline": "^0.0.54",
|
"@data-ui/sparkline": "^0.0.54",
|
||||||
|
"@vx/responsive": "0.0.153",
|
||||||
"babel-register": "^6.24.1",
|
"babel-register": "^6.24.1",
|
||||||
"bootstrap": "^3.3.6",
|
"bootstrap": "^3.3.6",
|
||||||
"bootstrap-slider": "^10.0.0",
|
"bootstrap-slider": "^10.0.0",
|
||||||
|
@ -61,6 +61,7 @@
|
||||||
"deck.gl": "^5.1.4",
|
"deck.gl": "^5.1.4",
|
||||||
"deep-equal": "^1.0.1",
|
"deep-equal": "^1.0.1",
|
||||||
"distributions": "^1.0.0",
|
"distributions": "^1.0.0",
|
||||||
|
"dnd-core": "^2.6.0",
|
||||||
"dompurify": "^1.0.3",
|
"dompurify": "^1.0.3",
|
||||||
"fastdom": "^1.0.6",
|
"fastdom": "^1.0.6",
|
||||||
"geojson-extent": "^0.3.2",
|
"geojson-extent": "^0.3.2",
|
||||||
|
@ -83,8 +84,9 @@
|
||||||
"parse-iso-duration": "^1.0.0",
|
"parse-iso-duration": "^1.0.0",
|
||||||
"po2json": "^0.4.5",
|
"po2json": "^0.4.5",
|
||||||
"prop-types": "^15.6.0",
|
"prop-types": "^15.6.0",
|
||||||
|
"re-resizable": "^4.3.1",
|
||||||
"react": "^15.6.2",
|
"react": "^15.6.2",
|
||||||
"react-ace": "^5.0.1",
|
"react-ace": "^5.10.0",
|
||||||
"react-addons-css-transition-group": "^15.6.0",
|
"react-addons-css-transition-group": "^15.6.0",
|
||||||
"react-addons-shallow-compare": "^15.4.2",
|
"react-addons-shallow-compare": "^15.4.2",
|
||||||
"react-alert": "^2.3.0",
|
"react-alert": "^2.3.0",
|
||||||
|
@ -93,16 +95,21 @@
|
||||||
"react-bootstrap-table": "^4.3.1",
|
"react-bootstrap-table": "^4.3.1",
|
||||||
"react-color": "^2.13.8",
|
"react-color": "^2.13.8",
|
||||||
"react-datetime": "2.14.0",
|
"react-datetime": "2.14.0",
|
||||||
|
"react-dnd": "^2.5.4",
|
||||||
|
"react-dnd-html5-backend": "^2.5.4",
|
||||||
"react-dom": "^15.6.2",
|
"react-dom": "^15.6.2",
|
||||||
"react-gravatar": "^2.6.1",
|
"react-gravatar": "^2.6.1",
|
||||||
"react-grid-layout": "0.16.6",
|
"react-grid-layout": "0.16.6",
|
||||||
"react-map-gl": "^3.0.4",
|
"react-map-gl": "^3.0.4",
|
||||||
|
"react-markdown": "^3.3.0",
|
||||||
"react-redux": "^5.0.2",
|
"react-redux": "^5.0.2",
|
||||||
"react-resizable": "^1.3.3",
|
"react-resizable": "^1.3.3",
|
||||||
|
"react-search-input": "^0.11.3",
|
||||||
"react-select": "1.2.1",
|
"react-select": "1.2.1",
|
||||||
"react-select-fast-filter-options": "^0.2.1",
|
"react-select-fast-filter-options": "^0.2.1",
|
||||||
"react-sortable-hoc": "^0.8.3",
|
"react-sortable-hoc": "^0.8.3",
|
||||||
"react-split-pane": "^0.1.66",
|
"react-split-pane": "^0.1.66",
|
||||||
|
"react-sticky": "^6.0.2",
|
||||||
"react-syntax-highlighter": "^7.0.4",
|
"react-syntax-highlighter": "^7.0.4",
|
||||||
"react-virtualized": "9.19.1",
|
"react-virtualized": "9.19.1",
|
||||||
"react-virtualized-select": "^2.4.0",
|
"react-virtualized-select": "^2.4.0",
|
||||||
|
@ -110,14 +117,14 @@
|
||||||
"redux": "^3.5.2",
|
"redux": "^3.5.2",
|
||||||
"redux-localstorage": "^0.4.1",
|
"redux-localstorage": "^0.4.1",
|
||||||
"redux-thunk": "^2.1.0",
|
"redux-thunk": "^2.1.0",
|
||||||
|
"redux-undo": "^1.0.0-beta9-9-7",
|
||||||
"shortid": "^2.2.6",
|
"shortid": "^2.2.6",
|
||||||
"sprintf-js": "^1.1.1",
|
"sprintf-js": "^1.1.1",
|
||||||
"srcdoc-polyfill": "^1.0.0",
|
"srcdoc-polyfill": "^1.0.0",
|
||||||
"supercluster": "https://github.com/georgeke/supercluster/tarball/ac3492737e7ce98e07af679623aad452373bbc40",
|
"supercluster": "https://github.com/georgeke/supercluster/tarball/ac3492737e7ce98e07af679623aad452373bbc40",
|
||||||
"underscore": "^1.8.3",
|
"underscore": "^1.8.3",
|
||||||
"urijs": "^1.18.10",
|
"urijs": "^1.18.10",
|
||||||
"viewport-mercator-project": "^5.0.0",
|
"viewport-mercator-project": "^5.0.0"
|
||||||
"webpack-cli": "^2.1.4"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"babel-cli": "^6.14.0",
|
"babel-cli": "^6.14.0",
|
||||||
|
@ -133,8 +140,10 @@
|
||||||
"enzyme": "^2.0.0",
|
"enzyme": "^2.0.0",
|
||||||
"eslint": "^4.19.0",
|
"eslint": "^4.19.0",
|
||||||
"eslint-config-airbnb": "^15.0.1",
|
"eslint-config-airbnb": "^15.0.1",
|
||||||
|
"eslint-config-prettier": "^2.9.0",
|
||||||
"eslint-plugin-import": "^2.2.0",
|
"eslint-plugin-import": "^2.2.0",
|
||||||
"eslint-plugin-jsx-a11y": "^5.1.1",
|
"eslint-plugin-jsx-a11y": "^5.1.1",
|
||||||
|
"eslint-plugin-prettier": "^2.6.0",
|
||||||
"eslint-plugin-react": "^7.0.1",
|
"eslint-plugin-react": "^7.0.1",
|
||||||
"exports-loader": "^0.7.0",
|
"exports-loader": "^0.7.0",
|
||||||
"extract-text-webpack-plugin": "3.0.2",
|
"extract-text-webpack-plugin": "3.0.2",
|
||||||
|
@ -149,6 +158,7 @@
|
||||||
"less-loader": "^4.0.3",
|
"less-loader": "^4.0.3",
|
||||||
"mocha": "^3.2.0",
|
"mocha": "^3.2.0",
|
||||||
"npm-check-updates": "^2.14.0",
|
"npm-check-updates": "^2.14.0",
|
||||||
|
"prettier": "^1.12.1",
|
||||||
"react-addons-test-utils": "^15.6.2",
|
"react-addons-test-utils": "^15.6.2",
|
||||||
"react-test-renderer": "^15.6.2",
|
"react-test-renderer": "^15.6.2",
|
||||||
"redux-mock-store": "^1.2.3",
|
"redux-mock-store": "^1.2.3",
|
||||||
|
|
|
@ -10,6 +10,8 @@ const exposedProperties = ['window', 'navigator', 'document'];
|
||||||
global.jsdom = jsdom.jsdom;
|
global.jsdom = jsdom.jsdom;
|
||||||
global.document = global.jsdom('<!doctype html><html><body></body></html>');
|
global.document = global.jsdom('<!doctype html><html><body></body></html>');
|
||||||
global.window = document.defaultView;
|
global.window = document.defaultView;
|
||||||
|
global.HTMLElement = window.HTMLElement;
|
||||||
|
|
||||||
Object.keys(document.defaultView).forEach((property) => {
|
Object.keys(document.defaultView).forEach((property) => {
|
||||||
if (typeof global[property] === 'undefined') {
|
if (typeof global[property] === 'undefined') {
|
||||||
exposedProperties.push(property);
|
exposedProperties.push(property);
|
||||||
|
@ -38,5 +40,5 @@ global.sinon.useFakeXMLHttpRequest();
|
||||||
|
|
||||||
global.window.XMLHttpRequest = global.XMLHttpRequest;
|
global.window.XMLHttpRequest = global.XMLHttpRequest;
|
||||||
global.window.location = { href: 'about:blank' };
|
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);
|
global.$ = require('jquery')(global.window);
|
||||||
|
|
|
@ -20,7 +20,7 @@ describe('Chart', () => {
|
||||||
};
|
};
|
||||||
const mockedProps = {
|
const mockedProps = {
|
||||||
...chart,
|
...chart,
|
||||||
chartKey: 'slice_223',
|
id: 223,
|
||||||
containerId: 'slice-container-223',
|
containerId: 'slice-container-223',
|
||||||
datasource: {},
|
datasource: {},
|
||||||
formData: {},
|
formData: {},
|
||||||
|
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -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),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -3,16 +3,14 @@ import { mount } from 'enzyme';
|
||||||
import { describe, it } from 'mocha';
|
import { describe, it } from 'mocha';
|
||||||
import { expect } from 'chai';
|
import { expect } from 'chai';
|
||||||
|
|
||||||
import CodeModal from '../../../src/dashboard/components/CodeModal';
|
import CodeModal from '../../../../src/dashboard/components/CodeModal';
|
||||||
|
|
||||||
describe('CodeModal', () => {
|
describe('CodeModal', () => {
|
||||||
const mockedProps = {
|
const mockedProps = {
|
||||||
triggerNode: <i className="fa fa-edit" />,
|
triggerNode: <i className="fa fa-edit" />,
|
||||||
};
|
};
|
||||||
it('is valid', () => {
|
it('is valid', () => {
|
||||||
expect(
|
expect(React.isValidElement(<CodeModal {...mockedProps} />)).to.equal(true);
|
||||||
React.isValidElement(<CodeModal {...mockedProps} />),
|
|
||||||
).to.equal(true);
|
|
||||||
});
|
});
|
||||||
it('renders the trigger node', () => {
|
it('renders the trigger node', () => {
|
||||||
const wrapper = mount(<CodeModal {...mockedProps} />);
|
const wrapper = mount(<CodeModal {...mockedProps} />);
|
|
@ -3,16 +3,14 @@ import { mount } from 'enzyme';
|
||||||
import { describe, it } from 'mocha';
|
import { describe, it } from 'mocha';
|
||||||
import { expect } from 'chai';
|
import { expect } from 'chai';
|
||||||
|
|
||||||
import CssEditor from '../../../src/dashboard/components/CssEditor';
|
import CssEditor from '../../../../src/dashboard/components/CssEditor';
|
||||||
|
|
||||||
describe('CssEditor', () => {
|
describe('CssEditor', () => {
|
||||||
const mockedProps = {
|
const mockedProps = {
|
||||||
triggerNode: <i className="fa fa-edit" />,
|
triggerNode: <i className="fa fa-edit" />,
|
||||||
};
|
};
|
||||||
it('is valid', () => {
|
it('is valid', () => {
|
||||||
expect(
|
expect(React.isValidElement(<CssEditor {...mockedProps} />)).to.equal(true);
|
||||||
React.isValidElement(<CssEditor {...mockedProps} />),
|
|
||||||
).to.equal(true);
|
|
||||||
});
|
});
|
||||||
it('renders the trigger node', () => {
|
it('renders the trigger node', () => {
|
||||||
const wrapper = mount(<CssEditor {...mockedProps} />);
|
const wrapper = mount(<CssEditor {...mockedProps} />);
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -3,7 +3,7 @@ import { mount } from 'enzyme';
|
||||||
import { describe, it } from 'mocha';
|
import { describe, it } from 'mocha';
|
||||||
import { expect } from 'chai';
|
import { expect } from 'chai';
|
||||||
|
|
||||||
import RefreshIntervalModal from '../../../src/dashboard/components/RefreshIntervalModal';
|
import RefreshIntervalModal from '../../../../src/dashboard/components/RefreshIntervalModal';
|
||||||
|
|
||||||
describe('RefreshIntervalModal', () => {
|
describe('RefreshIntervalModal', () => {
|
||||||
const mockedProps = {
|
const mockedProps = {
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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',
|
|
||||||
});
|
|
||||||
|
|
|
@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
|
@ -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 },
|
||||||
|
},
|
||||||
|
};
|
|
@ -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: [],
|
||||||
|
};
|
|
@ -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
|
||||||
|
};
|
|
@ -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',
|
||||||
|
},
|
||||||
|
};
|
|
@ -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' },
|
||||||
|
];
|
|
@ -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,
|
||||||
|
};
|
|
@ -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',
|
||||||
|
};
|
|
@ -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)),
|
||||||
|
);
|
|
@ -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;
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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' }]);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -2,14 +2,19 @@ import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { Button } from 'react-bootstrap';
|
import { Button } from 'react-bootstrap';
|
||||||
import Select from 'react-select';
|
import Select from 'react-select';
|
||||||
|
import Loading from '../../components/Loading';
|
||||||
import QueryTable from './QueryTable';
|
import QueryTable from './QueryTable';
|
||||||
import { now, epochTimeXHoursAgo,
|
import {
|
||||||
epochTimeXDaysAgo, epochTimeXYearsAgo } from '../../modules/dates';
|
now,
|
||||||
|
epochTimeXHoursAgo,
|
||||||
|
epochTimeXDaysAgo,
|
||||||
|
epochTimeXYearsAgo,
|
||||||
|
} from '../../modules/dates';
|
||||||
import { STATUS_OPTIONS, TIME_OPTIONS } from '../constants';
|
import { STATUS_OPTIONS, TIME_OPTIONS } from '../constants';
|
||||||
import AsyncSelect from '../../components/AsyncSelect';
|
import AsyncSelect from '../../components/AsyncSelect';
|
||||||
import { t } from '../../locales';
|
import { t } from '../../locales';
|
||||||
|
|
||||||
const $ = window.$ = require('jquery');
|
const $ = (window.$ = require('jquery'));
|
||||||
|
|
||||||
const propTypes = {
|
const propTypes = {
|
||||||
actions: PropTypes.object.isRequired,
|
actions: PropTypes.object.isRequired,
|
||||||
|
@ -47,13 +52,17 @@ class QuerySearch extends React.PureComponent {
|
||||||
this.refreshQueries();
|
this.refreshQueries();
|
||||||
}
|
}
|
||||||
onUserClicked(userId) {
|
onUserClicked(userId) {
|
||||||
this.setState({ userId }, () => { this.refreshQueries(); });
|
this.setState({ userId }, () => {
|
||||||
|
this.refreshQueries();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
onDbClicked(dbId) {
|
onDbClicked(dbId) {
|
||||||
this.setState({ databaseId: dbId }, () => { this.refreshQueries(); });
|
this.setState({ databaseId: dbId }, () => {
|
||||||
|
this.refreshQueries();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
onChange(db) {
|
onChange(db) {
|
||||||
const val = (db) ? db.value : null;
|
const val = db ? db.value : null;
|
||||||
this.setState({ databaseId: val });
|
this.setState({ databaseId: val });
|
||||||
}
|
}
|
||||||
getTimeFromSelection(selection) {
|
getTimeFromSelection(selection) {
|
||||||
|
@ -77,25 +86,25 @@ class QuerySearch extends React.PureComponent {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
changeFrom(user) {
|
changeFrom(user) {
|
||||||
const val = (user) ? user.value : null;
|
const val = user ? user.value : null;
|
||||||
this.setState({ from: val });
|
this.setState({ from: val });
|
||||||
}
|
}
|
||||||
changeTo(status) {
|
changeTo(status) {
|
||||||
const val = (status) ? status.value : null;
|
const val = status ? status.value : null;
|
||||||
this.setState({ to: val });
|
this.setState({ to: val });
|
||||||
}
|
}
|
||||||
changeUser(user) {
|
changeUser(user) {
|
||||||
const val = (user) ? user.value : null;
|
const val = user ? user.value : null;
|
||||||
this.setState({ userId: val });
|
this.setState({ userId: val });
|
||||||
}
|
}
|
||||||
insertParams(baseUrl, params) {
|
insertParams(baseUrl, params) {
|
||||||
const validParams = params.filter(
|
const validParams = params.filter(function (p) {
|
||||||
function (p) { return p !== ''; },
|
return p !== '';
|
||||||
);
|
});
|
||||||
return baseUrl + '?' + validParams.join('&');
|
return baseUrl + '?' + validParams.join('&');
|
||||||
}
|
}
|
||||||
changeStatus(status) {
|
changeStatus(status) {
|
||||||
const val = (status) ? status.value : null;
|
const val = status ? status.value : null;
|
||||||
this.setState({ status: val });
|
this.setState({ status: val });
|
||||||
}
|
}
|
||||||
changeSearch(event) {
|
changeSearch(event) {
|
||||||
|
@ -120,7 +129,7 @@ class QuerySearch extends React.PureComponent {
|
||||||
if (data.result.length === 0) {
|
if (data.result.length === 0) {
|
||||||
this.props.actions.addAlert({
|
this.props.actions.addAlert({
|
||||||
bsStyle: 'danger',
|
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;
|
return options;
|
||||||
|
@ -175,8 +184,10 @@ class QuerySearch extends React.PureComponent {
|
||||||
<Select
|
<Select
|
||||||
name="select-from"
|
name="select-from"
|
||||||
placeholder={t('[From]-')}
|
placeholder={t('[From]-')}
|
||||||
options={TIME_OPTIONS
|
options={TIME_OPTIONS.slice(1, TIME_OPTIONS.length).map(xt => ({
|
||||||
.slice(1, TIME_OPTIONS.length).map(xt => ({ value: xt, label: xt }))}
|
value: xt,
|
||||||
|
label: xt,
|
||||||
|
}))}
|
||||||
value={this.state.from}
|
value={this.state.from}
|
||||||
autosize={false}
|
autosize={false}
|
||||||
onChange={this.changeFrom}
|
onChange={this.changeFrom}
|
||||||
|
@ -206,20 +217,13 @@ class QuerySearch extends React.PureComponent {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{this.state.queriesLoading ?
|
{this.state.queriesLoading ? (
|
||||||
(<img className="loading" alt="Loading..." src="/static/assets/images/loading.gif" />)
|
<Loading />
|
||||||
:
|
) : (
|
||||||
(
|
|
||||||
<div className="scrollbar-container">
|
<div className="scrollbar-container">
|
||||||
<div
|
<div className="scrollbar-content" style={{ height: this.props.height }}>
|
||||||
className="scrollbar-content"
|
|
||||||
style={{ height: this.props.height }}
|
|
||||||
>
|
|
||||||
<QueryTable
|
<QueryTable
|
||||||
columns={[
|
columns={['state', 'db', 'user', 'time', 'progress', 'rows', 'sql', 'querylink']}
|
||||||
'state', 'db', 'user', 'time',
|
|
||||||
'progress', 'rows', 'sql', 'querylink',
|
|
||||||
]}
|
|
||||||
onUserClicked={this.onUserClicked}
|
onUserClicked={this.onUserClicked}
|
||||||
onDbClicked={this.onDbClicked}
|
onDbClicked={this.onDbClicked}
|
||||||
queries={this.state.queriesArray}
|
queries={this.state.queriesArray}
|
||||||
|
@ -227,8 +231,7 @@ class QuerySearch extends React.PureComponent {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)}
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
|
||||||
import { Alert, Button, ButtonGroup, ProgressBar } from 'react-bootstrap';
|
import { Alert, Button, ButtonGroup, ProgressBar } from 'react-bootstrap';
|
||||||
import shortid from 'shortid';
|
import shortid from 'shortid';
|
||||||
|
|
||||||
|
import Loading from '../../components/Loading';
|
||||||
import VisualizeModal from './VisualizeModal';
|
import VisualizeModal from './VisualizeModal';
|
||||||
import HighlightedSql from './HighlightedSql';
|
import HighlightedSql from './HighlightedSql';
|
||||||
import FilterableTable from '../../components/FilterableTable/FilterableTable';
|
import FilterableTable from '../../components/FilterableTable/FilterableTable';
|
||||||
|
@ -238,7 +239,7 @@ export default class ResultSet extends React.PureComponent {
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<img className="loading" alt={t('Loading...')} src="/static/assets/images/loading.gif" />
|
<Loading />
|
||||||
<QueryStateLabel query={query} />
|
<QueryStateLabel query={query} />
|
||||||
{progressBar}
|
{progressBar}
|
||||||
<div>
|
<div>
|
||||||
|
|
|
@ -7,7 +7,7 @@ import { Tooltip } from 'react-bootstrap';
|
||||||
import { d3format } from '../modules/utils';
|
import { d3format } from '../modules/utils';
|
||||||
import ChartBody from './ChartBody';
|
import ChartBody from './ChartBody';
|
||||||
import Loading from '../components/Loading';
|
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 StackTraceMessage from '../components/StackTraceMessage';
|
||||||
import RefreshChartOverlay from '../components/RefreshChartOverlay';
|
import RefreshChartOverlay from '../components/RefreshChartOverlay';
|
||||||
import visMap from '../visualizations';
|
import visMap from '../visualizations';
|
||||||
|
@ -17,7 +17,7 @@ import './chart.css';
|
||||||
const propTypes = {
|
const propTypes = {
|
||||||
annotationData: PropTypes.object,
|
annotationData: PropTypes.object,
|
||||||
actions: PropTypes.object,
|
actions: PropTypes.object,
|
||||||
chartKey: PropTypes.string.isRequired,
|
chartId: PropTypes.number.isRequired,
|
||||||
containerId: PropTypes.string.isRequired,
|
containerId: PropTypes.string.isRequired,
|
||||||
datasource: PropTypes.object.isRequired,
|
datasource: PropTypes.object.isRequired,
|
||||||
formData: PropTypes.object.isRequired,
|
formData: PropTypes.object.isRequired,
|
||||||
|
@ -42,8 +42,6 @@ const propTypes = {
|
||||||
// dashboard callbacks
|
// dashboard callbacks
|
||||||
addFilter: PropTypes.func,
|
addFilter: PropTypes.func,
|
||||||
getFilters: PropTypes.func,
|
getFilters: PropTypes.func,
|
||||||
clearFilter: PropTypes.func,
|
|
||||||
removeFilter: PropTypes.func,
|
|
||||||
onQuery: PropTypes.func,
|
onQuery: PropTypes.func,
|
||||||
onDismissRefreshOverlay: PropTypes.func,
|
onDismissRefreshOverlay: PropTypes.func,
|
||||||
};
|
};
|
||||||
|
@ -51,8 +49,6 @@ const propTypes = {
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
addFilter: () => ({}),
|
addFilter: () => ({}),
|
||||||
getFilters: () => ({}),
|
getFilters: () => ({}),
|
||||||
clearFilter: () => ({}),
|
|
||||||
removeFilter: () => ({}),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
class Chart extends React.PureComponent {
|
class Chart extends React.PureComponent {
|
||||||
|
@ -67,8 +63,6 @@ class Chart extends React.PureComponent {
|
||||||
this.datasource = props.datasource;
|
this.datasource = props.datasource;
|
||||||
this.addFilter = this.addFilter.bind(this);
|
this.addFilter = this.addFilter.bind(this);
|
||||||
this.getFilters = this.getFilters.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.headerHeight = this.headerHeight.bind(this);
|
||||||
this.height = this.height.bind(this);
|
this.height = this.height.bind(this);
|
||||||
this.width = this.width.bind(this);
|
this.width = this.width.bind(this);
|
||||||
|
@ -76,10 +70,11 @@ class Chart extends React.PureComponent {
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
if (this.props.triggerQuery) {
|
if (this.props.triggerQuery) {
|
||||||
this.props.actions.runQuery(this.props.formData, false,
|
const { formData } = this.props;
|
||||||
this.props.timeout,
|
this.props.actions.runQuery(formData, false, this.props.timeout, this.props.chartId);
|
||||||
this.props.chartKey,
|
} else {
|
||||||
);
|
// when drag/dropping in a dashboard, a chart may be unmounted/remounted but still have data
|
||||||
|
this.renderViz();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -95,8 +90,8 @@ class Chart extends React.PureComponent {
|
||||||
if (
|
if (
|
||||||
this.props.queryResponse &&
|
this.props.queryResponse &&
|
||||||
['success', 'rendered'].indexOf(this.props.chartStatus) > -1 &&
|
['success', 'rendered'].indexOf(this.props.chartStatus) > -1 &&
|
||||||
!this.props.queryResponse.error && (
|
!this.props.queryResponse.error &&
|
||||||
prevProps.annotationData !== this.props.annotationData ||
|
(prevProps.annotationData !== this.props.annotationData ||
|
||||||
prevProps.queryResponse !== this.props.queryResponse ||
|
prevProps.queryResponse !== this.props.queryResponse ||
|
||||||
prevProps.height !== this.props.height ||
|
prevProps.height !== this.props.height ||
|
||||||
prevProps.width !== this.props.width ||
|
prevProps.width !== this.props.width ||
|
||||||
|
@ -118,20 +113,14 @@ class Chart extends React.PureComponent {
|
||||||
this.props.addFilter(col, vals, merge, refresh);
|
this.props.addFilter(col, vals, merge, refresh);
|
||||||
}
|
}
|
||||||
|
|
||||||
clearFilter() {
|
|
||||||
this.props.clearFilter();
|
|
||||||
}
|
|
||||||
|
|
||||||
removeFilter(col, vals, refresh = true) {
|
|
||||||
this.props.removeFilter(col, vals, refresh);
|
|
||||||
}
|
|
||||||
|
|
||||||
clearError() {
|
clearError() {
|
||||||
this.setState({ errorMsg: null });
|
this.setState({ errorMsg: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
width() {
|
width() {
|
||||||
return this.props.width || this.container.el.offsetWidth;
|
return (
|
||||||
|
this.props.width || (this.container && this.container.el && this.container.el.offsetWidth)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
headerHeight() {
|
headerHeight() {
|
||||||
|
@ -139,7 +128,9 @@ class Chart extends React.PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
height() {
|
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) {
|
d3format(col, number) {
|
||||||
|
@ -150,7 +141,7 @@ class Chart extends React.PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
error(e) {
|
error(e) {
|
||||||
this.props.actions.chartRenderingFailed(e, this.props.chartKey);
|
this.props.actions.chartRenderingFailed(e, this.props.chartId);
|
||||||
}
|
}
|
||||||
|
|
||||||
verboseMetricName(metric) {
|
verboseMetricName(metric) {
|
||||||
|
@ -167,7 +158,6 @@ class Chart extends React.PureComponent {
|
||||||
|
|
||||||
renderTooltip() {
|
renderTooltip() {
|
||||||
if (this.state.tooltip) {
|
if (this.state.tooltip) {
|
||||||
/* eslint-disable react/no-danger */
|
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
className="chart-tooltip"
|
className="chart-tooltip"
|
||||||
|
@ -177,66 +167,72 @@ class Chart extends React.PureComponent {
|
||||||
positionLeft={this.state.tooltip.x + 30}
|
positionLeft={this.state.tooltip.x + 30}
|
||||||
arrowOffsetTop={10}
|
arrowOffsetTop={10}
|
||||||
>
|
>
|
||||||
<div dangerouslySetInnerHTML={{ __html: this.state.tooltip.content }} />
|
<div // eslint-disable-next-line react/no-danger
|
||||||
|
dangerouslySetInnerHTML={{ __html: this.state.tooltip.content }}
|
||||||
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
/* eslint-enable react/no-danger */
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderViz() {
|
renderViz() {
|
||||||
const viz = visMap[this.props.vizType];
|
const { vizType, formData, queryResponse, setControlValue, chartId, chartStatus } = this.props;
|
||||||
const fd = this.props.formData;
|
const visRenderer = visMap[vizType];
|
||||||
const qr = this.props.queryResponse;
|
|
||||||
const renderStart = Logger.getTimestamp();
|
const renderStart = Logger.getTimestamp();
|
||||||
try {
|
try {
|
||||||
// Executing user-defined data mutator function
|
// Executing user-defined data mutator function
|
||||||
if (fd.js_data) {
|
if (formData.js_data) {
|
||||||
qr.data = sandboxedEval(fd.js_data)(qr.data);
|
queryResponse.data = sandboxedEval(formData.js_data)(queryResponse.data);
|
||||||
}
|
}
|
||||||
// [re]rendering the visualization
|
visRenderer(this, queryResponse, setControlValue);
|
||||||
viz(this, qr, this.props.setControlValue);
|
if (chartStatus !== 'rendered') {
|
||||||
Logger.append(LOG_ACTIONS_RENDER_EVENT, {
|
this.props.actions.chartRenderingSucceeded(chartId);
|
||||||
label: this.props.chartKey,
|
}
|
||||||
vis_type: this.props.vizType,
|
Logger.append(LOG_ACTIONS_RENDER_CHART, {
|
||||||
|
slice_id: 'slice_' + chartId,
|
||||||
|
viz_type: vizType,
|
||||||
start_offset: renderStart,
|
start_offset: renderStart,
|
||||||
duration: Logger.getTimestamp() - renderStart,
|
duration: Logger.getTimestamp() - renderStart,
|
||||||
});
|
});
|
||||||
this.props.actions.chartRenderingSucceeded(this.props.chartKey);
|
this.props.actions.chartRenderingSucceeded(chartId);
|
||||||
} catch (e) {
|
} 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() {
|
render() {
|
||||||
const isLoading = this.props.chartStatus === 'loading';
|
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 (
|
return (
|
||||||
<div className={`token col-md-12 ${isLoading ? 'is-loading' : ''}`}>
|
<div className={`chart-container ${isLoading ? 'is-loading' : ''}`} style={containerStyles}>
|
||||||
{this.renderTooltip()}
|
{this.renderTooltip()}
|
||||||
{isLoading &&
|
{isLoading && <Loading size={75} />}
|
||||||
<Loading size={25} />
|
{this.props.chartAlert && (
|
||||||
}
|
|
||||||
{this.props.chartAlert &&
|
|
||||||
<StackTraceMessage
|
<StackTraceMessage
|
||||||
message={this.props.chartAlert}
|
message={this.props.chartAlert}
|
||||||
queryResponse={this.props.queryResponse}
|
queryResponse={this.props.queryResponse}
|
||||||
/>
|
/>
|
||||||
}
|
)}
|
||||||
|
|
||||||
{!isLoading &&
|
{!isLoading &&
|
||||||
!this.props.chartAlert &&
|
!this.props.chartAlert &&
|
||||||
this.props.refreshOverlayVisible &&
|
this.props.refreshOverlayVisible &&
|
||||||
!this.props.errorMessage &&
|
!this.props.errorMessage &&
|
||||||
this.container &&
|
this.container && (
|
||||||
<RefreshChartOverlay
|
<RefreshChartOverlay
|
||||||
height={this.height()}
|
height={this.height()}
|
||||||
width={this.width()}
|
width={this.width()}
|
||||||
onQuery={this.props.onQuery}
|
onQuery={this.props.onQuery}
|
||||||
onDismiss={this.props.onDismissRefreshOverlay}
|
onDismiss={this.props.onDismissRefreshOverlay}
|
||||||
/>
|
/>
|
||||||
}
|
)}
|
||||||
{!isLoading && !this.props.chartAlert &&
|
|
||||||
|
{!isLoading &&
|
||||||
|
!this.props.chartAlert && (
|
||||||
<ChartBody
|
<ChartBody
|
||||||
containerId={this.containerId}
|
containerId={this.containerId}
|
||||||
vizType={this.props.vizType}
|
vizType={this.props.vizType}
|
||||||
|
@ -247,7 +243,7 @@ class Chart extends React.PureComponent {
|
||||||
this.container = inner;
|
this.container = inner;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,29 +1,13 @@
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { bindActionCreators } from 'redux';
|
import { bindActionCreators } from 'redux';
|
||||||
|
|
||||||
import * as Actions from './chartAction';
|
import * as actions from './chartAction';
|
||||||
import Chart from './Chart';
|
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) {
|
function mapDispatchToProps(dispatch) {
|
||||||
return {
|
return {
|
||||||
actions: bindActionCreators(Actions, dispatch),
|
actions: bindActionCreators(actions, dispatch),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(Chart);
|
export default connect(null, mapDispatchToProps)(Chart);
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import { getExploreUrlAndPayload, getAnnotationJsonUrl } from '../explore/exploreUtils';
|
import { getExploreUrlAndPayload, getAnnotationJsonUrl } from '../explore/exploreUtils';
|
||||||
import { requiresQuery, ANNOTATION_SOURCE_TYPES } from '../modules/AnnotationTypes';
|
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 { COMMON_ERR_MESSAGES } from '../common';
|
||||||
import { t } from '../locales';
|
import { t } from '../locales';
|
||||||
|
|
||||||
const $ = window.$ = require('jquery');
|
const $ = (window.$ = require('jquery'));
|
||||||
|
|
||||||
export const CHART_UPDATE_STARTED = 'CHART_UPDATE_STARTED';
|
export const CHART_UPDATE_STARTED = 'CHART_UPDATE_STARTED';
|
||||||
export function chartUpdateStarted(queryRequest, latestQueryFormData, key) {
|
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.time_grain_sqla = granularity;
|
||||||
fd.granularity = granularity;
|
fd.granularity = granularity;
|
||||||
|
|
||||||
const sliceFormData = Object.keys(annotation.overrides)
|
const sliceFormData = Object.keys(annotation.overrides).reduce(
|
||||||
.reduce((d, k) => ({
|
(d, k) => ({
|
||||||
...d,
|
...d,
|
||||||
[k]: annotation.overrides[k] || fd[k],
|
[k]: annotation.overrides[k] || fd[k],
|
||||||
}), {});
|
}),
|
||||||
|
{},
|
||||||
|
);
|
||||||
const isNative = annotation.sourceType === ANNOTATION_SOURCE_TYPES.NATIVE;
|
const isNative = annotation.sourceType === ANNOTATION_SOURCE_TYPES.NATIVE;
|
||||||
const url = getAnnotationJsonUrl(annotation.value, sliceFormData, isNative);
|
const url = getAnnotationJsonUrl(annotation.value, sliceFormData, isNative);
|
||||||
const queryRequest = $.ajax({
|
const queryRequest = $.ajax({
|
||||||
|
@ -117,6 +119,11 @@ export function updateQueryFormData(value, key) {
|
||||||
return { type: UPDATE_QUERY_FORM_DATA, 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 const RUN_QUERY = 'RUN_QUERY';
|
||||||
export function runQuery(formData, force = false, timeout = 60, key) {
|
export function runQuery(formData, force = false, timeout = 60, key) {
|
||||||
return (dispatch) => {
|
return (dispatch) => {
|
||||||
|
@ -138,19 +145,22 @@ export function runQuery(formData, force = false, timeout = 60, key) {
|
||||||
const queryPromise = Promise.resolve(dispatch(chartUpdateStarted(queryRequest, payload, key)))
|
const queryPromise = Promise.resolve(dispatch(chartUpdateStarted(queryRequest, payload, key)))
|
||||||
.then(() => queryRequest)
|
.then(() => queryRequest)
|
||||||
.then((queryResponse) => {
|
.then((queryResponse) => {
|
||||||
Logger.append(LOG_ACTIONS_LOAD_EVENT, {
|
Logger.append(LOG_ACTIONS_LOAD_CHART, {
|
||||||
label: key,
|
slice_id: 'slice_' + key,
|
||||||
is_cached: queryResponse.is_cached,
|
is_cached: queryResponse.is_cached,
|
||||||
|
force_refresh: force,
|
||||||
row_count: queryResponse.rowcount,
|
row_count: queryResponse.rowcount,
|
||||||
datasource: formData.datasource,
|
datasource: formData.datasource,
|
||||||
start_offset: logStart,
|
start_offset: logStart,
|
||||||
duration: Logger.getTimestamp() - 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));
|
return dispatch(chartUpdateSucceeded(queryResponse, key));
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
Logger.append(LOG_ACTIONS_LOAD_EVENT, {
|
Logger.append(LOG_ACTIONS_LOAD_CHART, {
|
||||||
label: key,
|
slice_id: 'slice_' + key,
|
||||||
has_err: true,
|
has_err: true,
|
||||||
datasource: formData.datasource,
|
datasource: formData.datasource,
|
||||||
start_offset: logStart,
|
start_offset: logStart,
|
||||||
|
@ -190,3 +200,7 @@ export function runQuery(formData, force = false, timeout = 60, key) {
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function refreshChart(chart, force, timeout) {
|
||||||
|
return dispatch => dispatch(runQuery(chart.latestQueryFormData, force, timeout, chart.id));
|
||||||
|
}
|
||||||
|
|
|
@ -1,25 +1,10 @@
|
||||||
/* eslint camelcase: 0 */
|
/* eslint camelcase: 0 */
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
import { now } from '../modules/dates';
|
import { now } from '../modules/dates';
|
||||||
import * as actions from './chartAction';
|
import * as actions from './chartAction';
|
||||||
import { t } from '../locales';
|
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 = {
|
export const chart = {
|
||||||
chartKey: '',
|
id: 0,
|
||||||
chartAlert: null,
|
chartAlert: null,
|
||||||
chartStatus: 'loading',
|
chartStatus: 'loading',
|
||||||
chartUpdateEndTime: null,
|
chartUpdateEndTime: null,
|
||||||
|
@ -33,6 +18,12 @@ export const chart = {
|
||||||
|
|
||||||
export default function chartReducer(charts = {}, action) {
|
export default function chartReducer(charts = {}, action) {
|
||||||
const actionHandlers = {
|
const actionHandlers = {
|
||||||
|
[actions.ADD_CHART]() {
|
||||||
|
return {
|
||||||
|
...chart,
|
||||||
|
...action.chart,
|
||||||
|
};
|
||||||
|
},
|
||||||
[actions.CHART_UPDATE_SUCCEEDED](state) {
|
[actions.CHART_UPDATE_SUCCEEDED](state) {
|
||||||
return { ...state,
|
return { ...state,
|
||||||
chartStatus: 'success',
|
chartStatus: 'success',
|
||||||
|
@ -151,7 +142,10 @@ export default function chartReducer(charts = {}, action) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action.type in actionHandlers) {
|
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;
|
return charts;
|
||||||
|
|
|
@ -0,0 +1,65 @@
|
||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { MenuItem } from 'react-bootstrap';
|
||||||
|
|
||||||
|
import InfoTooltipWithTrigger from './InfoTooltipWithTrigger';
|
||||||
|
|
||||||
|
export function MenuItemContent({ faIcon, text, tooltip, children }) {
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
{faIcon && <i className={`fa fa-${faIcon}`}> </i>}
|
||||||
|
{text} {''}
|
||||||
|
<InfoTooltipWithTrigger
|
||||||
|
tooltip={tooltip}
|
||||||
|
label={faIcon ? `dash-${faIcon}` : ''}
|
||||||
|
placement="top"
|
||||||
|
/>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
MenuItemContent.propTypes = {
|
||||||
|
faIcon: PropTypes.string,
|
||||||
|
text: PropTypes.string,
|
||||||
|
tooltip: PropTypes.string,
|
||||||
|
children: PropTypes.node,
|
||||||
|
};
|
||||||
|
|
||||||
|
MenuItemContent.defaultProps = {
|
||||||
|
faIcon: '',
|
||||||
|
text: '',
|
||||||
|
tooltip: null,
|
||||||
|
children: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ActionMenuItem({
|
||||||
|
onClick,
|
||||||
|
href,
|
||||||
|
target,
|
||||||
|
text,
|
||||||
|
tooltip,
|
||||||
|
children,
|
||||||
|
faIcon,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuItem onClick={onClick} href={href} target={target}>
|
||||||
|
<MenuItemContent faIcon={faIcon} text={text} tooltip={tooltip}>
|
||||||
|
{children}
|
||||||
|
</MenuItemContent>
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ActionMenuItem.propTypes = {
|
||||||
|
onClick: PropTypes.func,
|
||||||
|
href: PropTypes.string,
|
||||||
|
target: PropTypes.string,
|
||||||
|
...MenuItemContent.propTypes,
|
||||||
|
};
|
||||||
|
|
||||||
|
ActionMenuItem.defaultProps = {
|
||||||
|
onClick() {},
|
||||||
|
href: null,
|
||||||
|
target: null,
|
||||||
|
};
|
|
@ -1,5 +1,6 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
import cx from 'classnames';
|
||||||
import TooltipWrapper from './TooltipWrapper';
|
import TooltipWrapper from './TooltipWrapper';
|
||||||
import { t } from '../locales';
|
import { t } from '../locales';
|
||||||
|
|
||||||
|
@ -27,8 +28,10 @@ class EditableTitle extends React.PureComponent {
|
||||||
this.handleClick = this.handleClick.bind(this);
|
this.handleClick = this.handleClick.bind(this);
|
||||||
this.handleBlur = this.handleBlur.bind(this);
|
this.handleBlur = this.handleBlur.bind(this);
|
||||||
this.handleChange = this.handleChange.bind(this);
|
this.handleChange = this.handleChange.bind(this);
|
||||||
|
this.handleKeyUp = this.handleKeyUp.bind(this);
|
||||||
this.handleKeyPress = this.handleKeyPress.bind(this);
|
this.handleKeyPress = this.handleKeyPress.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillReceiveProps(nextProps) {
|
componentWillReceiveProps(nextProps) {
|
||||||
if (nextProps.title !== this.state.title) {
|
if (nextProps.title !== this.state.title) {
|
||||||
this.setState({
|
this.setState({
|
||||||
|
@ -37,8 +40,9 @@ class EditableTitle extends React.PureComponent {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleClick() {
|
handleClick() {
|
||||||
if (!this.props.canEdit) {
|
if (!this.props.canEdit || this.state.isEditing) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -46,6 +50,7 @@ class EditableTitle extends React.PureComponent {
|
||||||
isEditing: true,
|
isEditing: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
handleBlur() {
|
handleBlur() {
|
||||||
if (!this.props.canEdit) {
|
if (!this.props.canEdit) {
|
||||||
return;
|
return;
|
||||||
|
@ -67,9 +72,31 @@ class EditableTitle extends React.PureComponent {
|
||||||
this.setState({
|
this.setState({
|
||||||
lastTitle: this.state.title,
|
lastTitle: this.state.title,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.props.title !== this.state.title) {
|
||||||
this.props.onSaveTitle(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) {
|
handleChange(ev) {
|
||||||
if (!this.props.canEdit) {
|
if (!this.props.canEdit) {
|
||||||
return;
|
return;
|
||||||
|
@ -79,6 +106,7 @@ class EditableTitle extends React.PureComponent {
|
||||||
title: ev.target.value,
|
title: ev.target.value,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
handleKeyPress(ev) {
|
handleKeyPress(ev) {
|
||||||
if (ev.key === 'Enter') {
|
if (ev.key === 'Enter') {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
|
@ -86,12 +114,14 @@ class EditableTitle extends React.PureComponent {
|
||||||
this.handleBlur();
|
this.handleBlur();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
let input = (
|
let content = (
|
||||||
<input
|
<input
|
||||||
required
|
required
|
||||||
type={this.state.isEditing ? 'text' : 'button'}
|
type={this.state.isEditing ? 'text' : 'button'}
|
||||||
value={this.state.title}
|
value={this.state.title}
|
||||||
|
onKeyUp={this.handleKeyUp}
|
||||||
onChange={this.handleChange}
|
onChange={this.handleChange}
|
||||||
onBlur={this.handleBlur}
|
onBlur={this.handleBlur}
|
||||||
onClick={this.handleClick}
|
onClick={this.handleClick}
|
||||||
|
@ -99,18 +129,26 @@ class EditableTitle extends React.PureComponent {
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
if (this.props.showTooltip) {
|
if (this.props.showTooltip) {
|
||||||
input = (
|
content = (
|
||||||
<TooltipWrapper
|
<TooltipWrapper
|
||||||
label="title"
|
label="title"
|
||||||
tooltip={this.props.canEdit ? t('click to edit title') :
|
tooltip={this.props.canEdit ? t('click to edit title') :
|
||||||
this.props.noPermitTooltip || t('You don\'t have the rights to alter this title.')}
|
this.props.noPermitTooltip || t('You don\'t have the rights to alter this title.')}
|
||||||
>
|
>
|
||||||
{input}
|
{content}
|
||||||
</TooltipWrapper>
|
</TooltipWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,7 +5,7 @@ const propTypes = {
|
||||||
size: PropTypes.number,
|
size: PropTypes.number,
|
||||||
};
|
};
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
size: 25,
|
size: 50,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Loading(props) {
|
export default function Loading(props) {
|
||||||
|
@ -15,14 +15,18 @@ export default function Loading(props) {
|
||||||
alt="Loading..."
|
alt="Loading..."
|
||||||
src="/static/assets/images/loading.gif"
|
src="/static/assets/images/loading.gif"
|
||||||
style={{
|
style={{
|
||||||
width: props.size,
|
width: Math.min(props.size, 50),
|
||||||
height: props.size,
|
// height is auto
|
||||||
padding: 0,
|
padding: 0,
|
||||||
margin: 0,
|
margin: 0,
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
left: '50%',
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Loading.propTypes = propTypes;
|
Loading.propTypes = propTypes;
|
||||||
Loading.defaultProps = defaultProps;
|
Loading.defaultProps = defaultProps;
|
||||||
|
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
|
@ -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,
|
||||||
|
);
|
|
@ -0,0 +1,254 @@
|
||||||
|
/* 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];
|
||||||
|
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.`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
|
@ -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)),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
|
@ -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 }));
|
||||||
|
}
|
|
@ -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));
|
||||||
|
};
|
||||||
|
}
|
|
@ -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;
|
|
@ -0,0 +1,129 @@
|
||||||
|
/* 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;
|
|
@ -12,13 +12,16 @@ const propTypes = {
|
||||||
|
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
codeCallback: () => {},
|
codeCallback: () => {},
|
||||||
|
code: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default class CodeModal extends React.PureComponent {
|
export default class CodeModal extends React.PureComponent {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = { code: props.code };
|
this.state = { code: props.code };
|
||||||
|
this.beforeOpen = this.beforeOpen.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeOpen() {
|
beforeOpen() {
|
||||||
let code = this.props.code;
|
let code = this.props.code;
|
||||||
if (!code && this.props.codeCallback) {
|
if (!code && this.props.codeCallback) {
|
||||||
|
@ -26,18 +29,17 @@ export default class CodeModal extends React.PureComponent {
|
||||||
}
|
}
|
||||||
this.setState({ code });
|
this.setState({ code });
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<ModalTrigger
|
<ModalTrigger
|
||||||
triggerNode={this.props.triggerNode}
|
triggerNode={this.props.triggerNode}
|
||||||
isButton
|
isButton
|
||||||
beforeOpen={this.beforeOpen.bind(this)}
|
beforeOpen={this.beforeOpen}
|
||||||
modalTitle={t('Active Dashboard Filters')}
|
modalTitle={t('Active Dashboard Filters')}
|
||||||
modalBody={
|
modalBody={
|
||||||
<div className="CodeModal">
|
<div className="CodeModal">
|
||||||
<pre>
|
<pre>{this.state.code}</pre>
|
||||||
{this.state.code}
|
|
||||||
</pre>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -29,15 +29,20 @@ class CssEditor extends React.PureComponent {
|
||||||
css: props.initialCss,
|
css: props.initialCss,
|
||||||
cssTemplateOptions: [],
|
cssTemplateOptions: [],
|
||||||
};
|
};
|
||||||
|
this.changeCss = this.changeCss.bind(this);
|
||||||
|
this.changeCssTemplate = this.changeCssTemplate.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
changeCss(css) {
|
changeCss(css) {
|
||||||
this.setState({ css }, () => {
|
this.setState({ css }, () => {
|
||||||
this.props.onChange(css);
|
this.props.onChange(css);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
changeCssTemplate(opt) {
|
changeCssTemplate(opt) {
|
||||||
this.changeCss(opt.css);
|
this.changeCss(opt.css);
|
||||||
}
|
}
|
||||||
|
|
||||||
renderTemplateSelector() {
|
renderTemplateSelector() {
|
||||||
if (this.props.templates) {
|
if (this.props.templates) {
|
||||||
return (
|
return (
|
||||||
|
@ -46,13 +51,14 @@ class CssEditor extends React.PureComponent {
|
||||||
<Select
|
<Select
|
||||||
options={this.props.templates}
|
options={this.props.templates}
|
||||||
placeholder={t('Load a CSS template')}
|
placeholder={t('Load a CSS template')}
|
||||||
onChange={this.changeCssTemplate.bind(this)}
|
onChange={this.changeCssTemplate}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<ModalTrigger
|
<ModalTrigger
|
||||||
|
@ -70,7 +76,7 @@ class CssEditor extends React.PureComponent {
|
||||||
theme="github"
|
theme="github"
|
||||||
minLines={8}
|
minLines={8}
|
||||||
maxLines={30}
|
maxLines={30}
|
||||||
onChange={this.changeCss.bind(this)}
|
onChange={this.changeCss}
|
||||||
height="200px"
|
height="200px"
|
||||||
width="100%"
|
width="100%"
|
||||||
editorProps={{ $blockScrolling: true }}
|
editorProps={{ $blockScrolling: true }}
|
||||||
|
@ -85,6 +91,7 @@ class CssEditor extends React.PureComponent {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
CssEditor.propTypes = propTypes;
|
CssEditor.propTypes = propTypes;
|
||||||
CssEditor.defaultProps = defaultProps;
|
CssEditor.defaultProps = defaultProps;
|
||||||
|
|
||||||
|
|
|
@ -1,348 +1,229 @@
|
||||||
|
/* global window */
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import AlertsWrapper from '../../components/AlertsWrapper';
|
import AlertsWrapper from '../../components/AlertsWrapper';
|
||||||
import GridLayout from './GridLayout';
|
import getChartIdsFromLayout from '../util/getChartIdsFromLayout';
|
||||||
import Header from './Header';
|
import DashboardBuilder from '../containers/DashboardBuilder';
|
||||||
import { exportChart } from '../../explore/exploreUtils';
|
import {
|
||||||
|
chartPropShape,
|
||||||
|
slicePropShape,
|
||||||
|
dashboardInfoPropShape,
|
||||||
|
dashboardStatePropShape,
|
||||||
|
loadStatsPropShape,
|
||||||
|
} from '../util/propShapes';
|
||||||
import { areObjectsEqual } from '../../reduxUtils';
|
import { areObjectsEqual } from '../../reduxUtils';
|
||||||
import { Logger, ActionLog, LOG_ACTIONS_PAGE_LOAD,
|
import getFormDataWithExtraFilters from '../util/charts/getFormDataWithExtraFilters';
|
||||||
LOG_ACTIONS_LOAD_EVENT, LOG_ACTIONS_RENDER_EVENT } from '../../logger';
|
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 { t } from '../../locales';
|
||||||
|
|
||||||
import '../../../stylesheets/dashboard.css';
|
import '../stylesheets/index.less';
|
||||||
|
|
||||||
const propTypes = {
|
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,
|
initMessages: PropTypes.array,
|
||||||
dashboard: PropTypes.object.isRequired,
|
|
||||||
slices: PropTypes.object,
|
|
||||||
datasources: PropTypes.object,
|
|
||||||
filters: PropTypes.object,
|
|
||||||
refresh: PropTypes.bool,
|
|
||||||
timeout: PropTypes.number,
|
timeout: PropTypes.number,
|
||||||
userId: PropTypes.string,
|
userId: PropTypes.string,
|
||||||
isStarred: PropTypes.bool,
|
|
||||||
editMode: PropTypes.bool,
|
|
||||||
impressionId: PropTypes.string,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
initMessages: [],
|
initMessages: [],
|
||||||
dashboard: {},
|
|
||||||
slices: {},
|
|
||||||
datasources: {},
|
|
||||||
filters: {},
|
|
||||||
refresh: false,
|
|
||||||
timeout: 60,
|
timeout: 60,
|
||||||
userId: '',
|
userId: '',
|
||||||
isStarred: false,
|
|
||||||
editMode: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
class Dashboard extends React.PureComponent {
|
class Dashboard extends React.PureComponent {
|
||||||
constructor(props) {
|
// eslint-disable-next-line react/sort-comp
|
||||||
super(props);
|
static onBeforeUnload(hasChanged) {
|
||||||
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) {
|
|
||||||
if (hasChanged) {
|
if (hasChanged) {
|
||||||
window.addEventListener('beforeunload', this.unload);
|
window.addEventListener('beforeunload', Dashboard.unload);
|
||||||
} else {
|
} else {
|
||||||
window.removeEventListener('beforeunload', this.unload);
|
window.removeEventListener('beforeunload', Dashboard.unload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onChange() {
|
static unload() {
|
||||||
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() {
|
|
||||||
const message = t('You have unsaved changes.');
|
const message = t('You have unsaved changes.');
|
||||||
window.event.returnValue = message; // Gecko + IE
|
window.event.returnValue = message; // Gecko + IE
|
||||||
return message; // Gecko + Webkit, Safari, Chrome etc.
|
return message; // Gecko + Webkit, Safari, Chrome etc.
|
||||||
}
|
}
|
||||||
|
|
||||||
effectiveExtraFilters(sliceId) {
|
constructor(props) {
|
||||||
const metadata = this.props.dashboard.metadata;
|
super(props);
|
||||||
const filters = this.props.filters;
|
this.isFirstLoad = true;
|
||||||
const f = [];
|
this.actionLog = new ActionLog({
|
||||||
const immuneSlices = metadata.filter_immune_slices || [];
|
impressionId: props.impressionId,
|
||||||
if (sliceId && immuneSlices.includes(sliceId)) {
|
source: 'dashboard',
|
||||||
// The slice is immune to dashboard filters
|
sourceId: props.dashboardInfo.id,
|
||||||
return f;
|
eventNames: DASHBOARD_EVENT_NAMES,
|
||||||
|
});
|
||||||
|
Logger.start(this.actionLog);
|
||||||
|
this.initTs = new Date().getTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Building a list of fields the slice is immune to filters on
|
componentDidMount() {
|
||||||
let immuneToFields = [];
|
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 (
|
if (
|
||||||
sliceId &&
|
didLoad &&
|
||||||
metadata.filter_immune_slice_fields &&
|
this.props.loadStats[paneId] &&
|
||||||
metadata.filter_immune_slice_fields[sliceId]) {
|
!this.props.loadStats[paneId].didLoad
|
||||||
immuneToFields = metadata.filter_immune_slice_fields[sliceId];
|
) {
|
||||||
}
|
Logger.append(LOG_ACTIONS_LOAD_DASHBOARD_PANE, {
|
||||||
for (const filteringSliceId in filters) {
|
...restStats,
|
||||||
if (filteringSliceId === sliceId.toString()) {
|
duration: new Date().getTime() - paneMinQueryStart,
|
||||||
// Filters applied by the slice don't apply to itself
|
version,
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for (const field in filters[filteringSliceId]) {
|
|
||||||
if (!immuneToFields.includes(field)) {
|
|
||||||
f.push({
|
|
||||||
col: field,
|
|
||||||
op: 'in',
|
|
||||||
val: filters[filteringSliceId][field],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 f;
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentChartIds = getChartIdsFromLayout(this.props.layout);
|
||||||
|
const nextChartIds = getChartIdsFromLayout(nextProps.layout);
|
||||||
|
|
||||||
|
if (currentChartIds.length < nextChartIds.length) {
|
||||||
|
const newChartIds = nextChartIds.filter(
|
||||||
|
key => currentChartIds.indexOf(key) === -1,
|
||||||
|
);
|
||||||
|
newChartIds.forEach(newChartId =>
|
||||||
|
this.props.actions.addSliceToDashboard(newChartId),
|
||||||
|
);
|
||||||
|
} else if (currentChartIds.length > nextChartIds.length) {
|
||||||
|
// remove chart
|
||||||
|
const removedChartIds = currentChartIds.filter(
|
||||||
|
key => nextChartIds.indexOf(key) === -1,
|
||||||
|
);
|
||||||
|
removedChartIds.forEach(removedChartId =>
|
||||||
|
this.props.actions.removeSliceFromDashboard(removedChartId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasUnsavedChanges) {
|
||||||
|
Dashboard.onBeforeUnload(true);
|
||||||
|
} else {
|
||||||
|
Dashboard.onBeforeUnload(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// return charts in array
|
||||||
|
getAllCharts() {
|
||||||
|
return Object.values(this.props.charts);
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshExcept(filterKey) {
|
refreshExcept(filterKey) {
|
||||||
const immune = this.props.dashboard.metadata.filter_immune_slices || [];
|
const immune = this.props.dashboardInfo.metadata.filter_immune_slices || [];
|
||||||
let slices = this.getAllSlices();
|
|
||||||
if (filterKey) {
|
|
||||||
slices = slices.filter(slice => (
|
|
||||||
String(slice.slice_id) !== filterKey &&
|
|
||||||
immune.indexOf(slice.slice_id) === -1
|
|
||||||
));
|
|
||||||
}
|
|
||||||
this.fetchSlices(slices);
|
|
||||||
}
|
|
||||||
|
|
||||||
stopPeriodicRender() {
|
this.getAllCharts().forEach(chart => {
|
||||||
if (this.refreshTimer) {
|
// filterKey is a string, immune array contains numbers
|
||||||
clearTimeout(this.refreshTimer);
|
if (String(chart.id) !== filterKey && immune.indexOf(chart.id) === -1) {
|
||||||
this.refreshTimer = null;
|
const updatedFormData = getFormDataWithExtraFilters({
|
||||||
}
|
chart,
|
||||||
}
|
dashboardMetadata: this.props.dashboardInfo.metadata,
|
||||||
|
filters: this.props.dashboardState.filters,
|
||||||
|
sliceId: chart.id,
|
||||||
|
});
|
||||||
|
|
||||||
startPeriodicRender(interval) {
|
this.props.actions.runQuery(
|
||||||
this.stopPeriodicRender();
|
updatedFormData,
|
||||||
const immune = this.props.dashboard.metadata.timed_refresh_immune_slices || [];
|
false,
|
||||||
const refreshAll = () => {
|
this.props.timeout,
|
||||||
const affectedSlices = this.getAllSlices()
|
chart.id,
|
||||||
.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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<div id="dashboard-container">
|
<div>
|
||||||
<div id="dashboard-header">
|
|
||||||
<AlertsWrapper initMessages={this.props.initMessages} />
|
<AlertsWrapper initMessages={this.props.initMessages} />
|
||||||
<Header
|
<DashboardBuilder />
|
||||||
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -1,123 +1,333 @@
|
||||||
|
/* eslint-env browser */
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import Controls from './Controls';
|
import HeaderActionsDropdown from './HeaderActionsDropdown';
|
||||||
import EditableTitle from '../../components/EditableTitle';
|
import EditableTitle from '../../components/EditableTitle';
|
||||||
import Button from '../../components/Button';
|
import Button from '../../components/Button';
|
||||||
import FaveStar from '../../components/FaveStar';
|
import FaveStar from '../../components/FaveStar';
|
||||||
import URLShortLinkButton from '../../components/URLShortLinkButton';
|
import UndoRedoKeylisteners from './UndoRedoKeylisteners';
|
||||||
import InfoTooltipWithTrigger from '../../components/InfoTooltipWithTrigger';
|
import V2PreviewModal from '../deprecated/V2PreviewModal';
|
||||||
|
|
||||||
|
import { chartPropShape } from '../util/propShapes';
|
||||||
import { t } from '../../locales';
|
import { t } from '../../locales';
|
||||||
|
import { UNDO_LIMIT, SAVE_TYPE_OVERWRITE } from '../util/constants';
|
||||||
|
|
||||||
const propTypes = {
|
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,
|
filters: PropTypes.object.isRequired,
|
||||||
userId: PropTypes.string.isRequired,
|
expandedSlices: PropTypes.object.isRequired,
|
||||||
isStarred: PropTypes.bool,
|
css: PropTypes.string.isRequired,
|
||||||
addSlicesToDashboard: PropTypes.func,
|
isStarred: PropTypes.bool.isRequired,
|
||||||
onSave: PropTypes.func,
|
onSave: PropTypes.func.isRequired,
|
||||||
onChange: PropTypes.func,
|
onChange: PropTypes.func.isRequired,
|
||||||
fetchFaveStar: PropTypes.func,
|
fetchFaveStar: PropTypes.func.isRequired,
|
||||||
renderSlices: PropTypes.func,
|
fetchCharts: PropTypes.func.isRequired,
|
||||||
saveFaveStar: PropTypes.func,
|
saveFaveStar: PropTypes.func.isRequired,
|
||||||
serialize: PropTypes.func,
|
startPeriodicRender: PropTypes.func.isRequired,
|
||||||
startPeriodicRender: PropTypes.func,
|
updateDashboardTitle: PropTypes.func.isRequired,
|
||||||
updateDashboardTitle: PropTypes.func,
|
|
||||||
editMode: PropTypes.bool.isRequired,
|
editMode: PropTypes.bool.isRequired,
|
||||||
|
isV2Preview: PropTypes.bool.isRequired,
|
||||||
setEditMode: PropTypes.func.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 {
|
class Header extends React.PureComponent {
|
||||||
|
static discardChanges() {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(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.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() {
|
toggleEditMode() {
|
||||||
this.props.setEditMode(!this.props.editMode);
|
this.props.setEditMode(!this.props.editMode);
|
||||||
}
|
}
|
||||||
renderUnsaved() {
|
|
||||||
if (!this.props.unsavedChanges) {
|
toggleShowV2PreviewModal() {
|
||||||
return null;
|
this.setState({ showV2PreviewModal: !this.state.showV2PreviewModal });
|
||||||
}
|
}
|
||||||
return (
|
|
||||||
<InfoTooltipWithTrigger
|
overwriteDashboard() {
|
||||||
label="unsaved"
|
const {
|
||||||
tooltip={t('Unsaved changes')}
|
dashboardTitle,
|
||||||
icon="exclamation-triangle"
|
layout: positions,
|
||||||
className="text-danger m-r-5"
|
expandedSlices,
|
||||||
placement="top"
|
css,
|
||||||
/>
|
filters,
|
||||||
);
|
dashboardInfo,
|
||||||
}
|
} = this.props;
|
||||||
renderEditButton() {
|
|
||||||
if (!this.props.dashboard.dash_save_perm) {
|
const data = {
|
||||||
return null;
|
positions,
|
||||||
}
|
expanded_slices: expandedSlices,
|
||||||
const btnText = this.props.editMode ? 'Switch to View Mode' : 'Edit Dashboard';
|
css,
|
||||||
return (
|
dashboard_title: dashboardTitle,
|
||||||
<Button
|
default_filters: JSON.stringify(filters),
|
||||||
bsStyle="default"
|
};
|
||||||
className="m-r-5"
|
|
||||||
style={{ width: '150px' }}
|
this.props.onSave(data, dashboardInfo.id, SAVE_TYPE_OVERWRITE);
|
||||||
onClick={this.toggleEditMode}
|
|
||||||
>
|
|
||||||
{btnText}
|
|
||||||
</Button>);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
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 (
|
return (
|
||||||
<div className="title">
|
<div className="dashboard-header">
|
||||||
<div className="pull-left">
|
<div className="dashboard-component-header header-large">
|
||||||
<h1 className="outer-container pull-left">
|
|
||||||
<EditableTitle
|
<EditableTitle
|
||||||
title={dashboard.dashboard_title}
|
title={dashboardTitle}
|
||||||
canEdit={dashboard.dash_save_perm && this.props.editMode}
|
canEdit={userCanEdit && editMode}
|
||||||
onSaveTitle={this.handleSaveTitle}
|
onSaveTitle={this.handleChangeText}
|
||||||
showTooltip={this.props.editMode}
|
showTooltip={false}
|
||||||
/>
|
/>
|
||||||
<span className="favstar m-r-5">
|
<span className="favstar">
|
||||||
<FaveStar
|
<FaveStar
|
||||||
itemId={dashboard.id}
|
itemId={dashboardInfo.id}
|
||||||
fetchFaveStar={this.props.fetchFaveStar}
|
fetchFaveStar={this.props.fetchFaveStar}
|
||||||
saveFaveStar={this.props.saveFaveStar}
|
saveFaveStar={this.props.saveFaveStar}
|
||||||
isStarred={this.props.isStarred}
|
isStarred={this.props.isStarred}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
{this.renderUnsaved()}
|
{isV2Preview && (
|
||||||
</h1>
|
<div
|
||||||
|
role="none"
|
||||||
|
className="v2-preview-badge"
|
||||||
|
onClick={this.toggleShowV2PreviewModal}
|
||||||
|
>
|
||||||
|
{t('v2 Preview')}
|
||||||
|
<span className="fa fa-info-circle m-l-5" />
|
||||||
</div>
|
</div>
|
||||||
<div className="pull-right" style={{ marginTop: '35px' }}>
|
)}
|
||||||
<span className="m-r-5">
|
{isV2Preview &&
|
||||||
<URLShortLinkButton
|
this.state.showV2PreviewModal && (
|
||||||
emailSubject="Superset Dashboard"
|
<V2PreviewModal onClose={this.toggleShowV2PreviewModal} />
|
||||||
emailContent="Check out this dashboard: "
|
)}
|
||||||
/>
|
</div>
|
||||||
</span>
|
|
||||||
{this.renderEditButton()}
|
{userCanSaveAs && (
|
||||||
<Controls
|
<div className="button-container">
|
||||||
dashboard={dashboard}
|
{editMode && (
|
||||||
filters={this.props.filters}
|
<Button
|
||||||
userId={this.props.userId}
|
bsSize="small"
|
||||||
addSlicesToDashboard={this.props.addSlicesToDashboard}
|
onClick={onUndo}
|
||||||
onSave={this.props.onSave}
|
disabled={undoLength < 1}
|
||||||
onChange={this.props.onChange}
|
bsStyle={this.state.emphasizeUndo ? 'primary' : undefined}
|
||||||
renderSlices={this.props.renderSlices}
|
>
|
||||||
serialize={this.props.serialize}
|
<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}
|
startPeriodicRender={this.props.startPeriodicRender}
|
||||||
editMode={this.props.editMode}
|
updateCss={updateCss}
|
||||||
|
editMode={editMode}
|
||||||
|
hasUnsavedChanges={hasUnsavedChanges}
|
||||||
|
userCanEdit={userCanEdit}
|
||||||
|
isV2Preview={isV2Preview}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{editMode && (
|
||||||
|
<UndoRedoKeylisteners
|
||||||
|
onUndo={this.handleCtrlZ}
|
||||||
|
onRedo={this.handleCtrlY}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="clearfix" />
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Header.propTypes = propTypes;
|
Header.propTypes = propTypes;
|
||||||
|
|
||||||
export default Header;
|
export default Header;
|
||||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -16,7 +16,7 @@ const defaultProps = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const options = [
|
const options = [
|
||||||
[0, t('Don\'t refresh')],
|
[0, t("Don't refresh")],
|
||||||
[10, t('10 seconds')],
|
[10, t('10 seconds')],
|
||||||
[30, t('30 seconds')],
|
[30, t('30 seconds')],
|
||||||
[60, t('1 minute')],
|
[60, t('1 minute')],
|
||||||
|
@ -47,9 +47,12 @@ class RefreshIntervalModal extends React.PureComponent {
|
||||||
<Select
|
<Select
|
||||||
options={options}
|
options={options}
|
||||||
value={this.state.refreshFrequency}
|
value={this.state.refreshFrequency}
|
||||||
onChange={(opt) => {
|
onChange={opt => {
|
||||||
this.setState({ refreshFrequency: opt.value });
|
const value = opt ? opt.value : options[0].value;
|
||||||
this.props.onChange(opt.value);
|
this.setState({
|
||||||
|
refreshFrequency: value,
|
||||||
|
});
|
||||||
|
this.props.onChange(value);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,128 +1,134 @@
|
||||||
/* global notify */
|
/* eslint-env browser */
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import { Button, FormControl, FormGroup, Radio } from 'react-bootstrap';
|
import { Button, FormControl, FormGroup, Radio } from 'react-bootstrap';
|
||||||
import { getAjaxErrorMsg } from '../../modules/utils';
|
|
||||||
import ModalTrigger from '../../components/ModalTrigger';
|
import ModalTrigger from '../../components/ModalTrigger';
|
||||||
import { t } from '../../locales';
|
import { t } from '../../locales';
|
||||||
import Checkbox from '../../components/Checkbox';
|
import Checkbox from '../../components/Checkbox';
|
||||||
|
import { SAVE_TYPE_OVERWRITE, SAVE_TYPE_NEWDASHBOARD } from '../util/constants';
|
||||||
const $ = window.$ = require('jquery');
|
|
||||||
|
|
||||||
const propTypes = {
|
const propTypes = {
|
||||||
css: PropTypes.string,
|
addSuccessToast: PropTypes.func.isRequired,
|
||||||
dashboard: PropTypes.object.isRequired,
|
addDangerToast: PropTypes.func.isRequired,
|
||||||
|
dashboardId: PropTypes.number.isRequired,
|
||||||
|
dashboardTitle: PropTypes.string.isRequired,
|
||||||
|
expandedSlices: PropTypes.object.isRequired,
|
||||||
|
layout: PropTypes.object.isRequired,
|
||||||
|
saveType: PropTypes.oneOf([SAVE_TYPE_OVERWRITE, SAVE_TYPE_NEWDASHBOARD]),
|
||||||
triggerNode: PropTypes.node.isRequired,
|
triggerNode: PropTypes.node.isRequired,
|
||||||
filters: PropTypes.object.isRequired,
|
filters: PropTypes.object.isRequired,
|
||||||
serialize: PropTypes.func,
|
css: PropTypes.string.isRequired,
|
||||||
onSave: PropTypes.func,
|
onSave: PropTypes.func.isRequired,
|
||||||
|
isMenuItem: PropTypes.bool,
|
||||||
|
canOverwrite: PropTypes.bool.isRequired,
|
||||||
|
isV2Preview: PropTypes.bool.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
isMenuItem: false,
|
||||||
|
saveType: SAVE_TYPE_OVERWRITE,
|
||||||
};
|
};
|
||||||
|
|
||||||
class SaveModal extends React.PureComponent {
|
class SaveModal extends React.PureComponent {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {
|
this.state = {
|
||||||
dashboard: props.dashboard,
|
saveType: props.saveType,
|
||||||
css: props.css,
|
newDashName: `${props.dashboardTitle} [copy]`,
|
||||||
saveType: 'overwrite',
|
|
||||||
newDashName: props.dashboard.dashboard_title + ' [copy]',
|
|
||||||
duplicateSlices: false,
|
duplicateSlices: false,
|
||||||
};
|
};
|
||||||
this.modal = null;
|
this.modal = null;
|
||||||
this.handleSaveTypeChange = this.handleSaveTypeChange.bind(this);
|
this.handleSaveTypeChange = this.handleSaveTypeChange.bind(this);
|
||||||
this.handleNameChange = this.handleNameChange.bind(this);
|
this.handleNameChange = this.handleNameChange.bind(this);
|
||||||
this.saveDashboard = this.saveDashboard.bind(this);
|
this.saveDashboard = this.saveDashboard.bind(this);
|
||||||
|
this.setModalRef = this.setModalRef.bind(this);
|
||||||
|
this.toggleDuplicateSlices = this.toggleDuplicateSlices.bind(this);
|
||||||
|
this.onSave = this.props.onSave.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setModalRef(ref) {
|
||||||
|
this.modal = ref;
|
||||||
|
}
|
||||||
|
|
||||||
toggleDuplicateSlices() {
|
toggleDuplicateSlices() {
|
||||||
this.setState({ duplicateSlices: !this.state.duplicateSlices });
|
this.setState({ duplicateSlices: !this.state.duplicateSlices });
|
||||||
}
|
}
|
||||||
|
|
||||||
handleSaveTypeChange(event) {
|
handleSaveTypeChange(event) {
|
||||||
this.setState({
|
this.setState({
|
||||||
saveType: event.target.value,
|
saveType: event.target.value,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
handleNameChange(event) {
|
handleNameChange(event) {
|
||||||
this.setState({
|
this.setState({
|
||||||
newDashName: event.target.value,
|
newDashName: event.target.value,
|
||||||
saveType: 'newDashboard',
|
saveType: SAVE_TYPE_NEWDASHBOARD,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
saveDashboardRequest(data, url, saveType) {
|
|
||||||
const saveModal = this.modal;
|
saveDashboard() {
|
||||||
const onSaveDashboard = this.props.onSave;
|
const { saveType, newDashName } = this.state;
|
||||||
Object.assign(data, { css: this.props.css });
|
const {
|
||||||
$.ajax({
|
dashboardTitle,
|
||||||
type: 'POST',
|
layout: positions,
|
||||||
url,
|
css,
|
||||||
data: {
|
expandedSlices,
|
||||||
data: JSON.stringify(data),
|
filters,
|
||||||
},
|
dashboardId,
|
||||||
success(resp) {
|
} = this.props;
|
||||||
saveModal.close();
|
|
||||||
onSaveDashboard();
|
|
||||||
if (saveType === 'newDashboard') {
|
|
||||||
window.location = `/superset/dashboard/${resp.id}/`;
|
|
||||||
} else {
|
|
||||||
notify.success(t('This dashboard was saved successfully.'));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
error(error) {
|
|
||||||
saveModal.close();
|
|
||||||
const errorMsg = getAjaxErrorMsg(error);
|
|
||||||
notify.error(t('Sorry, there was an error saving this dashboard: ') + errorMsg);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
saveDashboard(saveType, newDashboardTitle) {
|
|
||||||
const dashboard = this.props.dashboard;
|
|
||||||
const positions = this.props.serialize();
|
|
||||||
const data = {
|
const data = {
|
||||||
positions,
|
positions,
|
||||||
css: this.state.css,
|
css,
|
||||||
expanded_slices: dashboard.metadata.expanded_slices || {},
|
expanded_slices: expandedSlices,
|
||||||
dashboard_title: dashboard.dashboard_title,
|
dashboard_title:
|
||||||
default_filters: JSON.stringify(this.props.filters),
|
saveType === SAVE_TYPE_NEWDASHBOARD ? newDashName : dashboardTitle,
|
||||||
|
default_filters: JSON.stringify(filters),
|
||||||
duplicate_slices: this.state.duplicateSlices,
|
duplicate_slices: this.state.duplicateSlices,
|
||||||
};
|
};
|
||||||
let url = null;
|
|
||||||
if (saveType === 'overwrite') {
|
if (saveType === SAVE_TYPE_NEWDASHBOARD && !newDashName) {
|
||||||
url = `/superset/save_dash/${dashboard.id}/`;
|
this.props.addDangerToast(
|
||||||
this.saveDashboardRequest(data, url, saveType);
|
t('You must pick a name for the new dashboard'),
|
||||||
} else if (saveType === 'newDashboard') {
|
);
|
||||||
if (!newDashboardTitle) {
|
|
||||||
this.modal.close();
|
|
||||||
showModal({
|
|
||||||
title: t('Error'),
|
|
||||||
body: t('You must pick a name for the new dashboard'),
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
data.dashboard_title = newDashboardTitle;
|
this.onSave(data, dashboardId, saveType).done(resp => {
|
||||||
url = `/superset/copy_dash/${dashboard.id}/`;
|
if (saveType === SAVE_TYPE_NEWDASHBOARD) {
|
||||||
this.saveDashboardRequest(data, url, saveType);
|
window.location = `/superset/dashboard/${resp.id}/`;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
this.modal.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
const { isV2Preview } = this.props;
|
||||||
return (
|
return (
|
||||||
<ModalTrigger
|
<ModalTrigger
|
||||||
ref={(modal) => { this.modal = modal; }}
|
ref={this.setModalRef}
|
||||||
isMenuItem
|
isMenuItem={this.props.isMenuItem}
|
||||||
triggerNode={this.props.triggerNode}
|
triggerNode={this.props.triggerNode}
|
||||||
modalTitle={t('Save Dashboard')}
|
modalTitle={t(
|
||||||
|
'Save Dashboard%s',
|
||||||
|
isV2Preview ? ' (⚠️ all saved dashboards will be V2)' : '',
|
||||||
|
)}
|
||||||
modalBody={
|
modalBody={
|
||||||
<FormGroup>
|
<FormGroup>
|
||||||
<Radio
|
<Radio
|
||||||
value="overwrite"
|
value={SAVE_TYPE_OVERWRITE}
|
||||||
onChange={this.handleSaveTypeChange}
|
onChange={this.handleSaveTypeChange}
|
||||||
checked={this.state.saveType === 'overwrite'}
|
checked={this.state.saveType === SAVE_TYPE_OVERWRITE}
|
||||||
|
disabled={!this.props.canOverwrite}
|
||||||
>
|
>
|
||||||
{t('Overwrite Dashboard [%s]', this.props.dashboard.dashboard_title)}
|
{t('Overwrite Dashboard [%s]', this.props.dashboardTitle)}
|
||||||
</Radio>
|
</Radio>
|
||||||
<hr />
|
<hr />
|
||||||
<Radio
|
<Radio
|
||||||
value="newDashboard"
|
value={SAVE_TYPE_NEWDASHBOARD}
|
||||||
onChange={this.handleSaveTypeChange}
|
onChange={this.handleSaveTypeChange}
|
||||||
checked={this.state.saveType === 'newDashboard'}
|
checked={this.state.saveType === SAVE_TYPE_NEWDASHBOARD}
|
||||||
>
|
>
|
||||||
{t('Save as:')}
|
{t('Save as:')}
|
||||||
</Radio>
|
</Radio>
|
||||||
|
@ -136,7 +142,7 @@ class SaveModal extends React.PureComponent {
|
||||||
<div className="m-l-25 m-t-5">
|
<div className="m-l-25 m-t-5">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={this.state.duplicateSlices}
|
checked={this.state.duplicateSlices}
|
||||||
onChange={this.toggleDuplicateSlices.bind(this)}
|
onChange={this.toggleDuplicateSlices}
|
||||||
/>
|
/>
|
||||||
<span className="m-l-5">also copy (duplicate) charts</span>
|
<span className="m-l-5">also copy (duplicate) charts</span>
|
||||||
</div>
|
</div>
|
||||||
|
@ -144,10 +150,7 @@ class SaveModal extends React.PureComponent {
|
||||||
}
|
}
|
||||||
modalFooter={
|
modalFooter={
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button bsStyle="primary" onClick={this.saveDashboard}>
|
||||||
bsStyle="primary"
|
|
||||||
onClick={() => { this.saveDashboard(this.state.saveType, this.state.newDashName); }}
|
|
||||||
>
|
|
||||||
{t('Save')}
|
{t('Save')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -156,6 +159,8 @@ class SaveModal extends React.PureComponent {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SaveModal.propTypes = propTypes;
|
SaveModal.propTypes = propTypes;
|
||||||
|
SaveModal.defaultProps = defaultProps;
|
||||||
|
|
||||||
export default SaveModal;
|
export default SaveModal;
|
||||||
|
|
|
@ -1,219 +1,255 @@
|
||||||
|
/* eslint-env browser */
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import $ from 'jquery';
|
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { BootstrapTable, TableHeaderColumn } from 'react-bootstrap-table';
|
import { DropdownButton, MenuItem } from 'react-bootstrap';
|
||||||
|
import { CellMeasurer, CellMeasurerCache, List } from 'react-virtualized';
|
||||||
|
import SearchInput, { createFilter } from 'react-search-input';
|
||||||
|
|
||||||
import ModalTrigger from '../../components/ModalTrigger';
|
import AddSliceCard from './AddSliceCard';
|
||||||
import { t } from '../../locales';
|
import AddSliceDragPreview from './dnd/AddSliceDragPreview';
|
||||||
|
import DragDroppable from './dnd/DragDroppable';
|
||||||
require('react-bootstrap-table/css/react-bootstrap-table.css');
|
import Loading from '../../components/Loading';
|
||||||
|
import { CHART_TYPE, NEW_COMPONENT_SOURCE_TYPE } from '../util/componentTypes';
|
||||||
|
import { NEW_CHART_ID, NEW_COMPONENTS_SOURCE_ID } from '../util/constants';
|
||||||
|
import { slicePropShape } from '../util/propShapes';
|
||||||
|
|
||||||
const propTypes = {
|
const propTypes = {
|
||||||
dashboard: PropTypes.object.isRequired,
|
fetchAllSlices: PropTypes.func.isRequired,
|
||||||
triggerNode: PropTypes.node.isRequired,
|
isLoading: PropTypes.bool.isRequired,
|
||||||
|
slices: PropTypes.objectOf(slicePropShape).isRequired,
|
||||||
|
lastUpdated: PropTypes.number.isRequired,
|
||||||
|
errorMessage: PropTypes.string,
|
||||||
userId: PropTypes.string.isRequired,
|
userId: PropTypes.string.isRequired,
|
||||||
addSlicesToDashboard: PropTypes.func,
|
selectedSliceIds: PropTypes.arrayOf(PropTypes.number).isRequired,
|
||||||
|
editMode: PropTypes.bool,
|
||||||
|
height: PropTypes.number,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
selectedSliceIds: [],
|
||||||
|
editMode: false,
|
||||||
|
errorMessage: '',
|
||||||
|
height: window.innerHeight,
|
||||||
|
};
|
||||||
|
|
||||||
|
const KEYS_TO_FILTERS = ['slice_name', 'viz_type', 'datasource_name'];
|
||||||
|
const KEYS_TO_SORT = [
|
||||||
|
{ key: 'slice_name', label: 'Name' },
|
||||||
|
{ key: 'viz_type', label: 'Vis type' },
|
||||||
|
{ key: 'datasource_name', label: 'Datasource' },
|
||||||
|
{ key: 'changed_on', label: 'Recent' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MARGIN_BOTTOM = 16;
|
||||||
|
const SIDEPANE_HEADER_HEIGHT = 55;
|
||||||
|
const SLICE_ADDER_CONTROL_HEIGHT = 64;
|
||||||
|
const DEFAULT_CELL_HEIGHT = 136;
|
||||||
|
|
||||||
|
const cache = new CellMeasurerCache({
|
||||||
|
defaultHeight: DEFAULT_CELL_HEIGHT,
|
||||||
|
fixedWidth: true,
|
||||||
|
});
|
||||||
|
|
||||||
class SliceAdder extends React.Component {
|
class SliceAdder extends React.Component {
|
||||||
constructor(props) {
|
static sortByComparator(attr) {
|
||||||
super(props);
|
const desc = attr === 'changed_on' ? -1 : 1;
|
||||||
this.state = {
|
|
||||||
slices: [],
|
|
||||||
slicesLoaded: false,
|
|
||||||
selectionMap: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
this.options = {
|
return (a, b) => {
|
||||||
defaultSortOrder: 'desc',
|
if (a[attr] < b[attr]) {
|
||||||
defaultSortName: 'modified',
|
return -1 * desc;
|
||||||
sizePerPage: 10,
|
} else if (a[attr] > b[attr]) {
|
||||||
};
|
return 1 * desc;
|
||||||
|
}
|
||||||
this.addSlices = this.addSlices.bind(this);
|
return 0;
|
||||||
this.toggleSlice = this.toggleSlice.bind(this);
|
|
||||||
|
|
||||||
this.selectRowProp = {
|
|
||||||
mode: 'checkbox',
|
|
||||||
clickToSelect: true,
|
|
||||||
onSelect: this.toggleSlice,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
filteredSlices: [],
|
||||||
|
searchTerm: '',
|
||||||
|
sortBy: KEYS_TO_SORT.findIndex(item => item.key === 'changed_on'),
|
||||||
|
selectedSliceIdsSet: new Set(props.selectedSliceIds),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.rowRenderer = this.rowRenderer.bind(this);
|
||||||
|
this.searchUpdated = this.searchUpdated.bind(this);
|
||||||
|
this.handleKeyPress = this.handleKeyPress.bind(this);
|
||||||
|
this.handleSelect = this.handleSelect.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.slicesRequest = this.props.fetchAllSlices(this.props.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillReceiveProps(nextProps) {
|
||||||
|
const nextState = {};
|
||||||
|
if (nextProps.lastUpdated !== this.props.lastUpdated) {
|
||||||
|
nextState.filteredSlices = Object.values(nextProps.slices)
|
||||||
|
.filter(createFilter(this.state.searchTerm, KEYS_TO_FILTERS))
|
||||||
|
.sort(SliceAdder.sortByComparator(KEYS_TO_SORT[this.state.sortBy].key));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextProps.selectedSliceIds !== this.props.selectedSliceIds) {
|
||||||
|
nextState.selectedSliceIdsSet = new Set(nextProps.selectedSliceIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(nextState).length) {
|
||||||
|
this.setState(nextState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
if (this.slicesRequest) {
|
if (this.slicesRequest && this.slicesRequest.abort) {
|
||||||
this.slicesRequest.abort();
|
this.slicesRequest.abort();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onEnterModal() {
|
getFilteredSortedSlices(searchTerm, sortBy) {
|
||||||
const uri = `/sliceaddview/api/read?_flt_0_created_by=${this.props.userId}`;
|
return Object.values(this.props.slices)
|
||||||
this.slicesRequest = $.ajax({
|
.filter(createFilter(searchTerm, KEYS_TO_FILTERS))
|
||||||
url: uri,
|
.sort(SliceAdder.sortByComparator(KEYS_TO_SORT[sortBy].key));
|
||||||
type: 'GET',
|
}
|
||||||
success: (response) => {
|
|
||||||
// Prepare slice data for table
|
|
||||||
const slices = response.result.map(slice => ({
|
|
||||||
id: slice.id,
|
|
||||||
sliceName: slice.slice_name,
|
|
||||||
vizType: slice.viz_type,
|
|
||||||
datasourceLink: slice.datasource_link,
|
|
||||||
modified: slice.modified,
|
|
||||||
}));
|
|
||||||
|
|
||||||
|
handleKeyPress(ev) {
|
||||||
|
if (ev.key === 'Enter') {
|
||||||
|
ev.preventDefault();
|
||||||
|
|
||||||
|
this.searchUpdated(ev.target.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
searchUpdated(searchTerm) {
|
||||||
this.setState({
|
this.setState({
|
||||||
slices,
|
searchTerm,
|
||||||
selectionMap: {},
|
filteredSlices: this.getFilteredSortedSlices(
|
||||||
slicesLoaded: true,
|
searchTerm,
|
||||||
|
this.state.sortBy,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
},
|
}
|
||||||
error: (error) => {
|
|
||||||
this.errored = true;
|
handleSelect(sortBy) {
|
||||||
this.setState({
|
this.setState({
|
||||||
errorMsg: t('Sorry, there was an error fetching charts to this dashboard: ') +
|
sortBy,
|
||||||
this.getAjaxErrorMsg(error),
|
filteredSlices: this.getFilteredSortedSlices(
|
||||||
});
|
this.state.searchTerm,
|
||||||
},
|
sortBy,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getAjaxErrorMsg(error) {
|
rowRenderer({ key, index, style, parent }) {
|
||||||
const respJSON = error.responseJSON;
|
const { filteredSlices, selectedSliceIdsSet } = this.state;
|
||||||
return (respJSON && respJSON.message) ? respJSON.message :
|
const cellData = filteredSlices[index];
|
||||||
error.responseText;
|
const isSelected = selectedSliceIdsSet.has(cellData.slice_id);
|
||||||
}
|
const type = CHART_TYPE;
|
||||||
|
const id = NEW_CHART_ID;
|
||||||
|
|
||||||
addSlices() {
|
const meta = {
|
||||||
const adder = this;
|
chartId: cellData.slice_id,
|
||||||
this.props.addSlicesToDashboard(Object.keys(this.state.selectionMap))
|
sliceName: cellData.slice_name,
|
||||||
// if successful, page will be reloaded.
|
};
|
||||||
.fail((error) => {
|
|
||||||
adder.errored = true;
|
|
||||||
adder.setState({
|
|
||||||
errorMsg: t('Sorry, there was an error adding charts to this dashboard: ') +
|
|
||||||
this.getAjaxErrorMsg(error),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleSlice(slice) {
|
return (
|
||||||
const selectionMap = Object.assign({}, this.state.selectionMap);
|
<DragDroppable
|
||||||
selectionMap[slice.id] = !selectionMap[slice.id];
|
key={key}
|
||||||
this.setState({ selectionMap });
|
component={{ type, id, meta }}
|
||||||
}
|
parentComponent={{
|
||||||
|
id: NEW_COMPONENTS_SOURCE_ID,
|
||||||
modifiedDateComparator(a, b, order) {
|
type: NEW_COMPONENT_SOURCE_TYPE,
|
||||||
if (order === 'desc') {
|
}}
|
||||||
if (a.changed_on > b.changed_on) {
|
index={index}
|
||||||
return -1;
|
depth={0}
|
||||||
} else if (a.changed_on < b.changed_on) {
|
disableDragDrop={isSelected}
|
||||||
return 1;
|
editMode={this.props.editMode}
|
||||||
}
|
// we must use a custom drag preview within the List because
|
||||||
return 0;
|
// it does not seem to work within a fixed-position container
|
||||||
}
|
useEmptyDragPreview
|
||||||
|
// List library expect style props here
|
||||||
if (a.changed_on < b.changed_on) {
|
// actual style should be applied to nested AddSliceCard component
|
||||||
return -1;
|
style={{}}
|
||||||
} else if (a.changed_on > b.changed_on) {
|
>
|
||||||
return 1;
|
{({ dragSourceRef }) => (
|
||||||
}
|
<CellMeasurer
|
||||||
return 0;
|
cache={cache}
|
||||||
|
columnIndex={0}
|
||||||
|
key={key}
|
||||||
|
parent={parent}
|
||||||
|
rowIndex={index}
|
||||||
|
>
|
||||||
|
<AddSliceCard
|
||||||
|
innerRef={dragSourceRef}
|
||||||
|
style={style}
|
||||||
|
sliceName={cellData.slice_name}
|
||||||
|
lastModified={cellData.modified}
|
||||||
|
visType={cellData.viz_type}
|
||||||
|
datasourceLink={cellData.datasource_link}
|
||||||
|
isSelected={isSelected}
|
||||||
|
/>
|
||||||
|
</CellMeasurer>
|
||||||
|
)}
|
||||||
|
</DragDroppable>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const hideLoad = this.state.slicesLoaded || this.errored;
|
const slicesListHeight =
|
||||||
let enableAddSlice = this.state.selectionMap && Object.keys(this.state.selectionMap);
|
this.props.height -
|
||||||
if (enableAddSlice) {
|
SIDEPANE_HEADER_HEIGHT -
|
||||||
enableAddSlice = enableAddSlice.some(function (key) {
|
SLICE_ADDER_CONTROL_HEIGHT -
|
||||||
return this.state.selectionMap[key];
|
MARGIN_BOTTOM;
|
||||||
}, this);
|
|
||||||
}
|
|
||||||
const modalContent = (
|
|
||||||
<div>
|
|
||||||
<img
|
|
||||||
src="/static/assets/images/loading.gif"
|
|
||||||
className={'loading ' + (hideLoad ? 'hidden' : '')}
|
|
||||||
alt={hideLoad ? '' : 'loading'}
|
|
||||||
/>
|
|
||||||
<div className={this.errored ? '' : 'hidden'}>
|
|
||||||
{this.state.errorMsg}
|
|
||||||
</div>
|
|
||||||
<div className={this.state.slicesLoaded ? '' : 'hidden'}>
|
|
||||||
<BootstrapTable
|
|
||||||
ref="table"
|
|
||||||
data={this.state.slices}
|
|
||||||
selectRow={this.selectRowProp}
|
|
||||||
options={this.options}
|
|
||||||
hover
|
|
||||||
search
|
|
||||||
pagination
|
|
||||||
condensed
|
|
||||||
height="auto"
|
|
||||||
>
|
|
||||||
<TableHeaderColumn
|
|
||||||
dataField="id"
|
|
||||||
isKey
|
|
||||||
dataSort
|
|
||||||
hidden
|
|
||||||
/>
|
|
||||||
<TableHeaderColumn
|
|
||||||
dataField="sliceName"
|
|
||||||
dataSort
|
|
||||||
>
|
|
||||||
{t('Name')}
|
|
||||||
</TableHeaderColumn>
|
|
||||||
<TableHeaderColumn
|
|
||||||
dataField="vizType"
|
|
||||||
dataSort
|
|
||||||
>
|
|
||||||
{t('Viz')}
|
|
||||||
</TableHeaderColumn>
|
|
||||||
<TableHeaderColumn
|
|
||||||
dataField="datasourceLink"
|
|
||||||
dataSort
|
|
||||||
// Will cause react-bootstrap-table to interpret the HTML returned
|
|
||||||
dataFormat={datasourceLink => datasourceLink}
|
|
||||||
>
|
|
||||||
{t('Datasource')}
|
|
||||||
</TableHeaderColumn>
|
|
||||||
<TableHeaderColumn
|
|
||||||
dataField="modified"
|
|
||||||
dataSort
|
|
||||||
sortFunc={this.modifiedDateComparator}
|
|
||||||
// Will cause react-bootstrap-table to interpret the HTML returned
|
|
||||||
dataFormat={modified => modified}
|
|
||||||
>
|
|
||||||
{t('Modified')}
|
|
||||||
</TableHeaderColumn>
|
|
||||||
</BootstrapTable>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-default"
|
|
||||||
data-dismiss="modal"
|
|
||||||
onClick={this.addSlices}
|
|
||||||
disabled={!enableAddSlice}
|
|
||||||
>
|
|
||||||
{t('Add Charts')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalTrigger
|
<div className="slice-adder-container">
|
||||||
triggerNode={this.props.triggerNode}
|
<div className="controls">
|
||||||
tooltip={t('Add a new chart to the dashboard')}
|
<SearchInput
|
||||||
beforeOpen={this.onEnterModal.bind(this)}
|
placeholder="Filter your charts"
|
||||||
isMenuItem
|
className="search-input"
|
||||||
modalBody={modalContent}
|
onChange={this.searchUpdated}
|
||||||
bsSize="large"
|
onKeyPress={this.handleKeyPress}
|
||||||
setModalAsTriggerChildren
|
|
||||||
modalTitle={t('Add Charts to Dashboard')}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<DropdownButton
|
||||||
|
title={`Sort by ${KEYS_TO_SORT[this.state.sortBy].label}`}
|
||||||
|
onSelect={this.handleSelect}
|
||||||
|
id="slice-adder-sortby"
|
||||||
|
>
|
||||||
|
{KEYS_TO_SORT.map((item, index) => (
|
||||||
|
<MenuItem key={item.key} eventKey={index}>
|
||||||
|
Sort by {item.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{this.props.isLoading && <Loading />}
|
||||||
|
|
||||||
|
{this.props.errorMessage && <div>{this.props.errorMessage}</div>}
|
||||||
|
|
||||||
|
{!this.props.isLoading &&
|
||||||
|
this.state.filteredSlices.length > 0 && (
|
||||||
|
<List
|
||||||
|
width={376}
|
||||||
|
height={slicesListHeight}
|
||||||
|
rowCount={this.state.filteredSlices.length}
|
||||||
|
deferredMeasurementCache={cache}
|
||||||
|
rowHeight={cache.rowHeight}
|
||||||
|
rowRenderer={this.rowRenderer}
|
||||||
|
searchTerm={this.state.searchTerm}
|
||||||
|
sortBy={this.state.sortBy}
|
||||||
|
selectedSliceIds={this.props.selectedSliceIds}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Drag preview is just a single fixed-position element */}
|
||||||
|
<AddSliceDragPreview slices={this.state.filteredSlices} />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SliceAdder.propTypes = propTypes;
|
SliceAdder.propTypes = propTypes;
|
||||||
|
SliceAdder.defaultProps = defaultProps;
|
||||||
|
|
||||||
export default SliceAdder;
|
export default SliceAdder;
|
||||||
|
|
|
@ -1,20 +1,17 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import moment from 'moment';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import { t } from '../../locales';
|
import { t } from '../../locales';
|
||||||
import EditableTitle from '../../components/EditableTitle';
|
import EditableTitle from '../../components/EditableTitle';
|
||||||
import TooltipWrapper from '../../components/TooltipWrapper';
|
import TooltipWrapper from '../../components/TooltipWrapper';
|
||||||
|
import SliceHeaderControls from './SliceHeaderControls';
|
||||||
|
|
||||||
const propTypes = {
|
const propTypes = {
|
||||||
|
innerRef: PropTypes.func,
|
||||||
slice: PropTypes.object.isRequired,
|
slice: PropTypes.object.isRequired,
|
||||||
supersetCanExplore: PropTypes.bool,
|
|
||||||
sliceCanEdit: PropTypes.bool,
|
|
||||||
isExpanded: PropTypes.bool,
|
isExpanded: PropTypes.bool,
|
||||||
isCached: PropTypes.bool,
|
isCached: PropTypes.bool,
|
||||||
cachedDttm: PropTypes.string,
|
cachedDttm: PropTypes.string,
|
||||||
removeSlice: PropTypes.func,
|
|
||||||
updateSliceName: PropTypes.func,
|
updateSliceName: PropTypes.func,
|
||||||
toggleExpandSlice: PropTypes.func,
|
toggleExpandSlice: PropTypes.func,
|
||||||
forceRefresh: PropTypes.func,
|
forceRefresh: PropTypes.func,
|
||||||
|
@ -23,9 +20,13 @@ const propTypes = {
|
||||||
editMode: PropTypes.bool,
|
editMode: PropTypes.bool,
|
||||||
annotationQuery: PropTypes.object,
|
annotationQuery: PropTypes.object,
|
||||||
annotationError: PropTypes.object,
|
annotationError: PropTypes.object,
|
||||||
|
sliceName: PropTypes.string,
|
||||||
|
supersetCanExplore: PropTypes.bool,
|
||||||
|
sliceCanEdit: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
|
innerRef: null,
|
||||||
forceRefresh: () => ({}),
|
forceRefresh: () => ({}),
|
||||||
removeSlice: () => ({}),
|
removeSlice: () => ({}),
|
||||||
updateSliceName: () => ({}),
|
updateSliceName: () => ({}),
|
||||||
|
@ -33,52 +34,51 @@ const defaultProps = {
|
||||||
exploreChart: () => ({}),
|
exploreChart: () => ({}),
|
||||||
exportCSV: () => ({}),
|
exportCSV: () => ({}),
|
||||||
editMode: false,
|
editMode: false,
|
||||||
|
annotationQuery: {},
|
||||||
|
annotationError: {},
|
||||||
|
cachedDttm: null,
|
||||||
|
isCached: false,
|
||||||
|
isExpanded: false,
|
||||||
|
sliceName: '',
|
||||||
|
supersetCanExplore: false,
|
||||||
|
sliceCanEdit: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
class SliceHeader extends React.PureComponent {
|
class SliceHeader extends React.PureComponent {
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
|
|
||||||
this.onSaveTitle = this.onSaveTitle.bind(this);
|
|
||||||
this.onToggleExpandSlice = this.onToggleExpandSlice.bind(this);
|
|
||||||
this.exportCSV = this.props.exportCSV.bind(this, this.props.slice);
|
|
||||||
this.exploreChart = this.props.exploreChart.bind(this, this.props.slice);
|
|
||||||
this.forceRefresh = this.props.forceRefresh.bind(this, this.props.slice.slice_id);
|
|
||||||
this.removeSlice = this.props.removeSlice.bind(this, this.props.slice);
|
|
||||||
}
|
|
||||||
|
|
||||||
onSaveTitle(newTitle) {
|
|
||||||
if (this.props.updateSliceName) {
|
|
||||||
this.props.updateSliceName(this.props.slice.slice_id, newTitle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onToggleExpandSlice() {
|
|
||||||
this.props.toggleExpandSlice(this.props.slice, !this.props.isExpanded);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const slice = this.props.slice;
|
const {
|
||||||
const isCached = this.props.isCached;
|
slice,
|
||||||
const cachedWhen = moment.utc(this.props.cachedDttm).fromNow();
|
isExpanded,
|
||||||
const refreshTooltip = isCached ?
|
isCached,
|
||||||
t('Served from data cached %s . Click to force refresh.', cachedWhen) :
|
cachedDttm,
|
||||||
t('Force refresh data');
|
toggleExpandSlice,
|
||||||
|
forceRefresh,
|
||||||
|
exploreChart,
|
||||||
|
exportCSV,
|
||||||
|
innerRef,
|
||||||
|
sliceName,
|
||||||
|
supersetCanExplore,
|
||||||
|
sliceCanEdit,
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
const annoationsLoading = t('Annotation layers are still loading.');
|
const annoationsLoading = t('Annotation layers are still loading.');
|
||||||
const annoationsError = t('One ore more annotation layers failed loading.');
|
const annoationsError = t('One ore more annotation layers failed loading.');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="row chart-header">
|
<div className="chart-header" ref={innerRef}>
|
||||||
<div className="col-md-12">
|
|
||||||
<div className="header">
|
<div className="header">
|
||||||
<EditableTitle
|
<EditableTitle
|
||||||
title={slice.slice_name}
|
title={
|
||||||
canEdit={!!this.props.updateSliceName && this.props.editMode}
|
sliceName ||
|
||||||
onSaveTitle={this.onSaveTitle}
|
(this.props.editMode
|
||||||
showTooltip={this.props.editMode}
|
? '---' // this makes an empty title clickable
|
||||||
noPermitTooltip={'You don\'t have the rights to alter this dashboard.'}
|
: '')
|
||||||
|
}
|
||||||
|
canEdit={this.props.editMode}
|
||||||
|
onSaveTitle={this.props.updateSliceName}
|
||||||
|
showTooltip={false}
|
||||||
/>
|
/>
|
||||||
{!!Object.values(this.props.annotationQuery || {}).length &&
|
{!!Object.values(this.props.annotationQuery).length && (
|
||||||
<TooltipWrapper
|
<TooltipWrapper
|
||||||
label="annotations-loading"
|
label="annotations-loading"
|
||||||
placement="top"
|
placement="top"
|
||||||
|
@ -86,8 +86,8 @@ class SliceHeader extends React.PureComponent {
|
||||||
>
|
>
|
||||||
<i className="fa fa-refresh warning" />
|
<i className="fa fa-refresh warning" />
|
||||||
</TooltipWrapper>
|
</TooltipWrapper>
|
||||||
}
|
)}
|
||||||
{!!Object.values(this.props.annotationError || {}).length &&
|
{!!Object.values(this.props.annotationError).length && (
|
||||||
<TooltipWrapper
|
<TooltipWrapper
|
||||||
label="annoation-errors"
|
label="annoation-errors"
|
||||||
placement="top"
|
placement="top"
|
||||||
|
@ -95,85 +95,21 @@ class SliceHeader extends React.PureComponent {
|
||||||
>
|
>
|
||||||
<i className="fa fa-exclamation-circle danger" />
|
<i className="fa fa-exclamation-circle danger" />
|
||||||
</TooltipWrapper>
|
</TooltipWrapper>
|
||||||
}
|
)}
|
||||||
</div>
|
{!this.props.editMode && (
|
||||||
<div className="chart-controls">
|
<SliceHeaderControls
|
||||||
<div id={'controls_' + slice.slice_id} className="pull-right">
|
slice={slice}
|
||||||
{this.props.editMode &&
|
isCached={isCached}
|
||||||
<a>
|
isExpanded={isExpanded}
|
||||||
<TooltipWrapper
|
cachedDttm={cachedDttm}
|
||||||
placement="top"
|
toggleExpandSlice={toggleExpandSlice}
|
||||||
label="move"
|
forceRefresh={forceRefresh}
|
||||||
tooltip={t('Move chart')}
|
exploreChart={exploreChart}
|
||||||
>
|
exportCSV={exportCSV}
|
||||||
<i className="fa fa-arrows drag" />
|
supersetCanExplore={supersetCanExplore}
|
||||||
</TooltipWrapper>
|
sliceCanEdit={sliceCanEdit}
|
||||||
</a>
|
/>
|
||||||
}
|
)}
|
||||||
<a className={`refresh ${isCached ? 'danger' : ''}`} onClick={this.forceRefresh}>
|
|
||||||
<TooltipWrapper
|
|
||||||
placement="top"
|
|
||||||
label="refresh"
|
|
||||||
tooltip={refreshTooltip}
|
|
||||||
>
|
|
||||||
<i className="fa fa-repeat" />
|
|
||||||
</TooltipWrapper>
|
|
||||||
</a>
|
|
||||||
{slice.description &&
|
|
||||||
<a onClick={this.onToggleExpandSlice}>
|
|
||||||
<TooltipWrapper
|
|
||||||
placement="top"
|
|
||||||
label="description"
|
|
||||||
tooltip={t('Toggle chart description')}
|
|
||||||
>
|
|
||||||
<i className="fa fa-info-circle slice_info" />
|
|
||||||
</TooltipWrapper>
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
{this.props.sliceCanEdit &&
|
|
||||||
<a href={slice.edit_url} target="_blank">
|
|
||||||
<TooltipWrapper
|
|
||||||
placement="top"
|
|
||||||
label="edit"
|
|
||||||
tooltip={t('Edit chart')}
|
|
||||||
>
|
|
||||||
<i className="fa fa-pencil" />
|
|
||||||
</TooltipWrapper>
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
<a className="exportCSV" onClick={this.exportCSV}>
|
|
||||||
<TooltipWrapper
|
|
||||||
placement="top"
|
|
||||||
label="exportCSV"
|
|
||||||
tooltip={t('Export CSV')}
|
|
||||||
>
|
|
||||||
<i className="fa fa-table" />
|
|
||||||
</TooltipWrapper>
|
|
||||||
</a>
|
|
||||||
{this.props.supersetCanExplore &&
|
|
||||||
<a className="exploreChart" onClick={this.exploreChart}>
|
|
||||||
<TooltipWrapper
|
|
||||||
placement="top"
|
|
||||||
label="exploreChart"
|
|
||||||
tooltip={t('Explore chart')}
|
|
||||||
>
|
|
||||||
<i className="fa fa-share" />
|
|
||||||
</TooltipWrapper>
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
{this.props.editMode &&
|
|
||||||
<a className="remove-chart" onClick={this.removeSlice}>
|
|
||||||
<TooltipWrapper
|
|
||||||
placement="top"
|
|
||||||
label="close"
|
|
||||||
tooltip={t('Remove chart from dashboard')}
|
|
||||||
>
|
|
||||||
<i className="fa fa-close" />
|
|
||||||
</TooltipWrapper>
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -183,12 +119,4 @@ class SliceHeader extends React.PureComponent {
|
||||||
SliceHeader.propTypes = propTypes;
|
SliceHeader.propTypes = propTypes;
|
||||||
SliceHeader.defaultProps = defaultProps;
|
SliceHeader.defaultProps = defaultProps;
|
||||||
|
|
||||||
function mapStateToProps({ dashboard }) {
|
export default SliceHeader;
|
||||||
return {
|
|
||||||
supersetCanExplore: dashboard.dashboard.superset_can_explore,
|
|
||||||
sliceCanEdit: dashboard.dashboard.slice_can_edit,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export { SliceHeader };
|
|
||||||
export default connect(mapStateToProps, () => ({}))(SliceHeader);
|
|
||||||
|
|
|
@ -0,0 +1,161 @@
|
||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import cx from 'classnames';
|
||||||
|
import moment from 'moment';
|
||||||
|
import { Dropdown, MenuItem } from 'react-bootstrap';
|
||||||
|
import {
|
||||||
|
Logger,
|
||||||
|
LOG_ACTIONS_EXPLORE_DASHBOARD_CHART,
|
||||||
|
LOG_ACTIONS_EXPORT_CSV_DASHBOARD_CHART,
|
||||||
|
LOG_ACTIONS_REFRESH_CHART,
|
||||||
|
} from '../../logger';
|
||||||
|
|
||||||
|
import { t } from '../../locales';
|
||||||
|
|
||||||
|
const propTypes = {
|
||||||
|
slice: PropTypes.object.isRequired,
|
||||||
|
isCached: PropTypes.bool,
|
||||||
|
isExpanded: PropTypes.bool,
|
||||||
|
cachedDttm: PropTypes.string,
|
||||||
|
supersetCanExplore: PropTypes.bool,
|
||||||
|
sliceCanEdit: PropTypes.bool,
|
||||||
|
toggleExpandSlice: PropTypes.func,
|
||||||
|
forceRefresh: PropTypes.func,
|
||||||
|
exploreChart: PropTypes.func,
|
||||||
|
exportCSV: PropTypes.func,
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
forceRefresh: () => ({}),
|
||||||
|
toggleExpandSlice: () => ({}),
|
||||||
|
exploreChart: () => ({}),
|
||||||
|
exportCSV: () => ({}),
|
||||||
|
cachedDttm: null,
|
||||||
|
isCached: false,
|
||||||
|
isExpanded: false,
|
||||||
|
supersetCanExplore: false,
|
||||||
|
sliceCanEdit: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const VerticalDotsTrigger = () => (
|
||||||
|
<div className="vertical-dots-container">
|
||||||
|
<span className="dot" />
|
||||||
|
<span className="dot" />
|
||||||
|
<span className="dot" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
class SliceHeaderControls extends React.PureComponent {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.exportCSV = this.exportCSV.bind(this);
|
||||||
|
this.exploreChart = this.exploreChart.bind(this);
|
||||||
|
this.toggleControls = this.toggleControls.bind(this);
|
||||||
|
this.refreshChart = this.refreshChart.bind(this);
|
||||||
|
this.toggleExpandSlice = this.props.toggleExpandSlice.bind(
|
||||||
|
this,
|
||||||
|
this.props.slice.slice_id,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
showControls: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
exportCSV() {
|
||||||
|
this.props.exportCSV(this.props.slice.slice_id);
|
||||||
|
Logger.append(
|
||||||
|
LOG_ACTIONS_EXPORT_CSV_DASHBOARD_CHART,
|
||||||
|
{
|
||||||
|
slice_id: this.props.slice.slice_id,
|
||||||
|
is_cached: this.props.isCached,
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
exploreChart() {
|
||||||
|
this.props.exploreChart(this.props.slice.slice_id);
|
||||||
|
Logger.append(
|
||||||
|
LOG_ACTIONS_EXPLORE_DASHBOARD_CHART,
|
||||||
|
{
|
||||||
|
slice_id: this.props.slice.slice_id,
|
||||||
|
is_cached: this.props.isCached,
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshChart() {
|
||||||
|
this.props.forceRefresh(this.props.slice.slice_id);
|
||||||
|
Logger.append(LOG_ACTIONS_REFRESH_CHART, {
|
||||||
|
slice_id: this.props.slice.slice_id,
|
||||||
|
is_cached: this.props.isCached,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleControls() {
|
||||||
|
this.setState({
|
||||||
|
showControls: !this.state.showControls,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const slice = this.props.slice;
|
||||||
|
const isCached = this.props.isCached;
|
||||||
|
const cachedWhen = moment.utc(this.props.cachedDttm).fromNow();
|
||||||
|
const refreshTooltip = isCached ? t('Cached %s', cachedWhen) : '';
|
||||||
|
|
||||||
|
// @TODO account for
|
||||||
|
// dashboard.dashboard.superset_can_explore
|
||||||
|
// dashboard.dashboard.slice_can_edit
|
||||||
|
return (
|
||||||
|
<Dropdown
|
||||||
|
id={`slice_${slice.slice_id}-controls`}
|
||||||
|
className={cx(isCached && 'is-cached')}
|
||||||
|
pullRight
|
||||||
|
>
|
||||||
|
<Dropdown.Toggle className="slice-header-controls-trigger" noCaret>
|
||||||
|
<VerticalDotsTrigger />
|
||||||
|
</Dropdown.Toggle>
|
||||||
|
|
||||||
|
<Dropdown.Menu>
|
||||||
|
<MenuItem onClick={this.refreshChart}>
|
||||||
|
{isCached && <span className="dot" />}
|
||||||
|
{t('Force refresh')}
|
||||||
|
{isCached && (
|
||||||
|
<div className="refresh-tooltip">{refreshTooltip}</div>
|
||||||
|
)}
|
||||||
|
</MenuItem>
|
||||||
|
|
||||||
|
<MenuItem divider />
|
||||||
|
|
||||||
|
{slice.description && (
|
||||||
|
<MenuItem onClick={this.toggleExpandSlice}>
|
||||||
|
{t('Toggle chart description')}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{this.props.sliceCanEdit && (
|
||||||
|
<MenuItem href={slice.edit_url} target="_blank">
|
||||||
|
{t('Edit chart metadata')}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MenuItem onClick={this.exportCSV}>{t('Export CSV')}</MenuItem>
|
||||||
|
|
||||||
|
{this.props.supersetCanExplore && (
|
||||||
|
<MenuItem onClick={this.exploreChart}>
|
||||||
|
{t('Explore chart')}
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SliceHeaderControls.propTypes = propTypes;
|
||||||
|
SliceHeaderControls.defaultProps = defaultProps;
|
||||||
|
|
||||||
|
export default SliceHeaderControls;
|
|
@ -0,0 +1,89 @@
|
||||||
|
import { Alert } from 'react-bootstrap';
|
||||||
|
import cx from 'classnames';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { toastShape } from '../util/propShapes';
|
||||||
|
import {
|
||||||
|
INFO_TOAST,
|
||||||
|
SUCCESS_TOAST,
|
||||||
|
WARNING_TOAST,
|
||||||
|
DANGER_TOAST,
|
||||||
|
} from '../util/constants';
|
||||||
|
|
||||||
|
const propTypes = {
|
||||||
|
toast: toastShape.isRequired,
|
||||||
|
onCloseToast: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultProps = {};
|
||||||
|
|
||||||
|
class Toast extends React.Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
visible: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.showToast = this.showToast.bind(this);
|
||||||
|
this.handleClosePress = this.handleClosePress.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
const { toast } = this.props;
|
||||||
|
|
||||||
|
setTimeout(this.showToast);
|
||||||
|
|
||||||
|
if (toast.duration > 0) {
|
||||||
|
this.hideTimer = setTimeout(this.handleClosePress, toast.duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
clearTimeout(this.hideTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast() {
|
||||||
|
this.setState({ visible: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
handleClosePress() {
|
||||||
|
clearTimeout(this.hideTimer);
|
||||||
|
|
||||||
|
this.setState({ visible: false }, () => {
|
||||||
|
// Wait for the transition
|
||||||
|
setTimeout(() => {
|
||||||
|
this.props.onCloseToast(this.props.toast.id);
|
||||||
|
}, 150);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { visible } = this.state;
|
||||||
|
const {
|
||||||
|
toast: { toastType, text },
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
onDismiss={this.handleClosePress}
|
||||||
|
bsClass={cx(
|
||||||
|
'alert',
|
||||||
|
'toast',
|
||||||
|
visible && 'toast--visible',
|
||||||
|
toastType === INFO_TOAST && 'toast--info',
|
||||||
|
toastType === SUCCESS_TOAST && 'toast--success',
|
||||||
|
toastType === WARNING_TOAST && 'toast--warning',
|
||||||
|
toastType === DANGER_TOAST && 'toast--danger',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Toast.propTypes = propTypes;
|
||||||
|
Toast.defaultProps = defaultProps;
|
||||||
|
|
||||||
|
export default Toast;
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue