diff --git a/client/app/assets/less/ant.less b/client/app/assets/less/ant.less index 3d67ce8ffc..4d27fb4d99 100644 --- a/client/app/assets/less/ant.less +++ b/client/app/assets/less/ant.less @@ -22,6 +22,8 @@ @import '~antd/lib/switch/style/index'; @import '~antd/lib/empty/style/index'; @import '~antd/lib/drawer/style/index'; +@import '~antd/lib/card/style/index'; +@import '~antd/lib/steps/style/index'; @import '~antd/lib/divider/style/index'; @import '~antd/lib/dropdown/style/index'; @import '~antd/lib/menu/style/index'; diff --git a/client/app/assets/less/redash/redash-newstyle.less b/client/app/assets/less/redash/redash-newstyle.less index 97973d3bc0..d5cb7d8c4c 100644 --- a/client/app/assets/less/redash/redash-newstyle.less +++ b/client/app/assets/less/redash/redash-newstyle.less @@ -242,110 +242,6 @@ body { float: right; } -.database-source { - display: inline-flex; - flex-wrap: wrap; - justify-content: center; -} - -.visual-card { - background: #FFFFFF; - border: 1px solid fade(@redash-gray, 15%); - border-radius: 3px; - margin: 5px; - width: 212px; - padding: 15px 5px; - cursor: pointer; - box-shadow: none; - transition: transform 0.12s ease-out; - transition-duration: 0.3s; - transition-property: box-shadow; - - display: flex; - //flex-direction: row; - align-items: center; - - &:hover { - box-shadow: rgba(102, 136, 153, 0.15) 0px 4px 9px -3px; - } - - img { - width: 64px !important; - height: 64px !important; - margin-right: 5px; - } - - h3 { - font-size: 13px; - color: #323232; - margin: 0 !important; - text-overflow: ellipsis; - overflow: hidden; - } -} - -.visual-card--selected { - background: fade(@redash-gray, 3%); - border: 1px solid fade(@redash-gray, 15%); - border-radius: 3px; - padding: 0 15px; - box-shadow: none; - - display: flex; - flex-direction: row; - align-items: center; - - justify-content: space-around; - margin-bottom: 15px; - width: 100%; - - img { - width: 64px; - height: 64px; - } - - a { - cursor: pointer; - } -} - -@media (max-width: 1200px) { - .visual-card { - width: 217px; - } -} - -@media (max-width: 755px) { - .visual-card { - width: 47%; - } -} - -@media (max-width: 515px) { - .visual-card { - width: 47%; - - img { - width: 48px; - height: 48px; - } - } -} - -@media (max-width: 408px) { - .visual-card { - width: 100%; - padding: 5px; - margin: 5px 0; - - img { - width: 48px; - height: 48px; - } - } -} - - .t-header:not(.th-alt) { padding: 15px; diff --git a/client/app/components/CreateSourceDialog.jsx b/client/app/components/CreateSourceDialog.jsx new file mode 100644 index 0000000000..039fbedec3 --- /dev/null +++ b/client/app/components/CreateSourceDialog.jsx @@ -0,0 +1,191 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { isEmpty, toUpper, includes } from 'lodash'; +import Button from 'antd/lib/button'; +import List from 'antd/lib/list'; +import Modal from 'antd/lib/modal'; +import Input from 'antd/lib/input'; +import Steps from 'antd/lib/steps'; +import { wrap as wrapDialog, DialogPropType } from '@/components/DialogWrapper'; +import { PreviewCard } from '@/components/PreviewCard'; +import EmptyState from '@/components/items-list/components/EmptyState'; +import DynamicForm from '@/components/dynamic-form/DynamicForm'; +import helper from '@/components/dynamic-form/dynamicFormHelper'; +import { HelpTrigger, TYPES as HELP_TRIGGER_TYPES } from '@/components/HelpTrigger'; + +const { Step } = Steps; +const { Search } = Input; + +const StepEnum = { + SELECT_TYPE: 0, + CONFIGURE_IT: 1, + DONE: 2, +}; + +class CreateSourceDialog extends React.Component { + static propTypes = { + dialog: DialogPropType.isRequired, + types: PropTypes.arrayOf(PropTypes.object), + sourceType: PropTypes.string.isRequired, + imageFolder: PropTypes.string.isRequired, + helpTriggerPrefix: PropTypes.string, + onCreate: PropTypes.func.isRequired, + }; + + static defaultProps = { + types: [], + helpTriggerPrefix: null, + }; + + state = { + searchText: '', + selectedType: null, + savingSource: false, + currentStep: StepEnum.SELECT_TYPE, + }; + + selectType = (selectedType) => { + this.setState({ selectedType, currentStep: StepEnum.CONFIGURE_IT }); + }; + + resetType = () => { + if (this.state.currentStep === StepEnum.CONFIGURE_IT) { + this.setState({ searchText: '', selectedType: null, currentStep: StepEnum.SELECT_TYPE }); + } + }; + + createSource = (values, successCallback, errorCallback) => { + const { selectedType, savingSource } = this.state; + if (!savingSource) { + this.setState({ savingSource: true, currentStep: StepEnum.DONE }); + this.props.onCreate(selectedType, values).then((data) => { + successCallback('Saved.'); + this.props.dialog.close({ success: true, data }); + }).catch((error) => { + this.setState({ savingSource: false, currentStep: StepEnum.CONFIGURE_IT }); + errorCallback(error.message); + }); + } + }; + + renderTypeSelector() { + const { types } = this.props; + const { searchText } = this.state; + const filteredTypes = types.filter(type => isEmpty(searchText) || + includes(type.name.toLowerCase(), searchText.toLowerCase())); + return ( +
+ this.setState({ searchText: e.target.value })} + autoFocus + data-test="SearchSource" + /> +
+ {isEmpty(filteredTypes) ? () : ( + this.renderItem(item)} + /> + )} +
+
+ ); + } + + renderForm() { + const { imageFolder, helpTriggerPrefix } = this.props; + const { selectedType } = this.state; + const fields = helper.getFields(selectedType); + const helpTriggerType = `${helpTriggerPrefix}${toUpper(selectedType.type)}`; + return ( +
+
+ {selectedType.name} +

{selectedType.name}

+
+
+ {HELP_TRIGGER_TYPES[helpTriggerType] && ( + + Setup Instructions + + )} +
+ +
+ ); + } + + renderItem(item) { + const { imageFolder } = this.props; + return ( + this.selectType(item)} + > + + + + + ); + } + + render() { + const { currentStep, savingSource } = this.state; + const { dialog, sourceType } = this.props; + return ( + dialog.dismiss()}>Cancel), + (), + ] : [ + (), + ( + + ), + ]} + > +
+ + {currentStep === StepEnum.CONFIGURE_IT ? ( + Type Selection} + className="clickable" + onClick={this.resetType} + /> + ) : ()} + + + + {currentStep === StepEnum.SELECT_TYPE && this.renderTypeSelector()} + {currentStep !== StepEnum.SELECT_TYPE && this.renderForm()} +
+
+ ); + } +} + +export default wrapDialog(CreateSourceDialog); diff --git a/client/app/components/HelpTrigger.jsx b/client/app/components/HelpTrigger.jsx index 2de707fb3a..9d9e464bca 100644 --- a/client/app/components/HelpTrigger.jsx +++ b/client/app/components/HelpTrigger.jsx @@ -12,7 +12,8 @@ import './HelpTrigger.less'; const DOMAIN = 'https://redash.io'; const HELP_PATH = '/help'; const IFRAME_TIMEOUT = 20000; -const TYPES = { + +export const TYPES = { HOME: [ '', 'Help', @@ -25,16 +26,50 @@ const TYPES = { '/user-guide/dashboards/sharing-dashboards', 'Guide: Sharing and Embedding Dashboards', ], + DS_ATHENA: [ + '/data-sources/amazon-athena-setup', + 'Guide: Help Setting up Amazon Athena', + ], + DS_BIGQUERY: [ + '/data-sources/bigquery-setup', + 'Guide: Help Setting up BigQuery', + ], + DS_URL: [ + '/data-sources/querying-urls', + 'Guide: Help Setting up URL', + ], + DS_MONGODB: [ + '/data-sources/mongodb-setup', + 'Guide: Help Setting up MongoDB', + ], + DS_GOOGLE_SPREADSHEETS: [ + '/data-sources/querying-a-google-spreadsheet', + 'Guide: Help Setting up Google Spreadsheets', + ], + DS_GOOGLE_ANALYTICS: [ + '/data-sources/google-analytics-setup', + 'Guide: Help Setting up Google Analytics', + ], + DS_AXIBASETSD: [ + '/data-sources/axibase-time-series-database', + 'Guide: Help Setting up Axibase Time Series', + ], + DS_RESULTS: [ + '/user-guide/querying/query-results-data-source', + 'Guide: Help Setting up Query Results', + ], }; export class HelpTrigger extends React.Component { static propTypes = { type: PropTypes.oneOf(Object.keys(TYPES)).isRequired, className: PropTypes.string, + children: PropTypes.node, } static defaultProps = { className: null, + children: , }; iframeRef = null @@ -92,7 +127,7 @@ export class HelpTrigger extends React.Component { - + {this.props.children} - Logo/Avatar + Logo/Avatar
{title}
{body &&
{body}
} @@ -20,12 +27,14 @@ PreviewCard.propTypes = { imageUrl: PropTypes.string.isRequired, title: PropTypes.node.isRequired, body: PropTypes.node, + roundedImage: PropTypes.bool, className: PropTypes.string, children: PropTypes.node, }; PreviewCard.defaultProps = { body: null, + roundedImage: true, className: '', children: null, }; diff --git a/client/app/components/cards-list/CardsList.jsx b/client/app/components/cards-list/CardsList.jsx new file mode 100644 index 0000000000..df4eb94ee2 --- /dev/null +++ b/client/app/components/cards-list/CardsList.jsx @@ -0,0 +1,86 @@ +import Card from 'antd/lib/card'; +import Input from 'antd/lib/input'; +import List from 'antd/lib/list'; +import { includes, isEmpty } from 'lodash'; +import PropTypes from 'prop-types'; +import React from 'react'; +import EmptyState from '@/components/items-list/components/EmptyState'; + +import './CardsList.less'; + +const { Search } = Input; +const { Meta } = Card; + +export default class CardsList extends React.Component { + static propTypes = { + items: PropTypes.arrayOf( + PropTypes.shape({ + title: PropTypes.string.isRequired, + imgSrc: PropTypes.string.isRequired, + onClick: PropTypes.func, + href: PropTypes.string, + }), + ), + showSearch: PropTypes.bool, + }; + + static defaultProps = { + items: [], + showSearch: false, + }; + + state = { + searchText: '', + }; + + // eslint-disable-next-line class-methods-use-this + renderListItem(item) { + const card = ( + {item.title}
)} + onClick={item.onClick} + hoverable + > + {item.title})} /> + + ); + return ( + + {item.href ? ({card}) : card} + + ); + } + + render() { + const { items, showSearch } = this.props; + const { searchText } = this.state; + + const filteredItems = items.filter(item => isEmpty(searchText) || + includes(item.title.toLowerCase(), searchText.toLowerCase())); + + return ( +
+ {showSearch && ( +
+
+ this.setState({ searchText: e.target.value })} + autoFocus + /> +
+
+ )} + {isEmpty(filteredItems) ? () : ( + this.renderListItem(item)} + /> + )} +
+ ); + } +} diff --git a/client/app/components/cards-list/CardsList.less b/client/app/components/cards-list/CardsList.less new file mode 100644 index 0000000000..e0a34544b5 --- /dev/null +++ b/client/app/components/cards-list/CardsList.less @@ -0,0 +1,22 @@ +.cards-list { + .cards-list-item { + text-align: center; + } + + .cards-list-item img { + margin-top: 10px; + width: 64px; + height: 64px; + } + + .cards-list-item h3 { + font-size: 13px; + height: 44px; + white-space: normal; + overflow: hidden; + /* autoprefixer: off */ + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + } +} diff --git a/client/app/components/dynamic-form/DynamicForm.jsx b/client/app/components/dynamic-form/DynamicForm.jsx index 8bfe38bdad..79d1fab5db 100644 --- a/client/app/components/dynamic-form/DynamicForm.jsx +++ b/client/app/components/dynamic-form/DynamicForm.jsx @@ -7,10 +7,9 @@ import Checkbox from 'antd/lib/checkbox'; import Button from 'antd/lib/button'; import Upload from 'antd/lib/upload'; import Icon from 'antd/lib/icon'; +import { includes, isFunction } from 'lodash'; import Select from 'antd/lib/select'; import notification from '@/services/notification'; -import { includes } from 'lodash'; -import { react2angular } from 'react2angular'; import { Field, Action, AntdForm } from '../proptypes'; import helper from './dynamicFormHelper'; @@ -26,8 +25,9 @@ const fieldRules = ({ type, required, minLength }) => { ].filter(rule => rule); }; -export const DynamicForm = Form.create()(class DynamicForm extends React.Component { +class DynamicForm extends React.Component { static propTypes = { + id: PropTypes.string, fields: PropTypes.arrayOf(Field), actions: PropTypes.arrayOf(Action), feedbackIcons: PropTypes.bool, @@ -38,6 +38,7 @@ export const DynamicForm = Form.create()(class DynamicForm extends React.Compone }; static defaultProps = { + id: null, fields: [], actions: [], feedbackIcons: false, @@ -179,14 +180,12 @@ export const DynamicForm = Form.create()(class DynamicForm extends React.Compone renderFields() { return this.props.fields.map((field) => { - const [firstField] = this.props.fields; const FormItem = Form.Item; - const { name, title, type, readOnly } = field; + const { name, title, type, readOnly, autoFocus, contentAfter } = field; const fieldLabel = title || helper.toHuman(name); - const { feedbackIcons } = this.props; + const { feedbackIcons, form } = this.props; const formItemProps = { - key: name, className: 'm-b-10', hasFeedback: type !== 'checkbox' && type !== 'file' && feedbackIcons, label: type === 'checkbox' ? '' : fieldLabel, @@ -194,16 +193,21 @@ export const DynamicForm = Form.create()(class DynamicForm extends React.Compone const fieldProps = { ...field.props, - autoFocus: (firstField === field), className: 'w-100', name, type, readOnly, + autoFocus, placeholder: field.placeholder, 'data-test': fieldLabel, }; - return ({this.renderField(field, fieldProps)}); + return ( + + {this.renderField(field, fieldProps)} + {isFunction(contentAfter) ? contentAfter(form.getFieldValue(name)) : contentAfter} + + ); }); } @@ -234,47 +238,17 @@ export const DynamicForm = Form.create()(class DynamicForm extends React.Compone disabled: this.state.isSubmitting, loading: this.state.isSubmitting, }; - const { hideSubmitButton, saveText } = this.props; + const { id, hideSubmitButton, saveText } = this.props; const saveButton = !hideSubmitButton; return ( -
+ {this.renderFields()} {saveButton && } {this.renderActions()}
); } -}); - -export default function init(ngModule) { - ngModule.component('dynamicForm', react2angular((props) => { - const fields = helper.getFields(props.type.configuration_schema, props.target); - - const onSubmit = (values, onSuccess, onError) => { - helper.updateTargetWithValues(props.target, values); - props.target.$save( - () => { - onSuccess('Saved.'); - }, - (error) => { - if (error.status === 400 && 'message' in error.data) { - onError(error.data.message); - } else { - onError('Failed saving.'); - } - }, - ); - }; - - const updatedProps = { - fields, - actions: props.target.id ? props.actions : [], - feedbackIcons: true, - onSubmit, - }; - return (); - }, ['target', 'type', 'actions'])); } -init.init = true; +export default Form.create()(DynamicForm); diff --git a/client/app/components/dynamic-form/dynamicFormHelper.js b/client/app/components/dynamic-form/dynamicFormHelper.js index 3eb10af6bb..c1d17997d3 100644 --- a/client/app/components/dynamic-form/dynamicFormHelper.js +++ b/client/app/components/dynamic-form/dynamicFormHelper.js @@ -1,3 +1,4 @@ +import React from 'react'; import { each, includes, isUndefined } from 'lodash'; function orderedInputs(properties, order, targetOptions) { @@ -57,10 +58,12 @@ function setDefaultValueForCheckboxes(configurationSchema, options = {}) { } } -function getFields(configurationSchema, target = {}) { +function getFields(type = {}, target = { options: {} }) { + const configurationSchema = type.configuration_schema; normalizeSchema(configurationSchema); setDefaultValueForCheckboxes(configurationSchema, target.options); + const isNewTarget = !target.id; const inputs = [ { name: 'name', @@ -68,6 +71,9 @@ function getFields(configurationSchema, target = {}) { type: 'text', required: true, initialValue: target.name, + contentAfter: React.createElement('hr'), + placeholder: `My ${type.name}`, + autoFocus: isNewTarget, }, ...orderedInputs(configurationSchema.properties, configurationSchema.order, target.options), ]; diff --git a/client/app/components/proptypes.js b/client/app/components/proptypes.js index 43f368cadf..05b585904d 100644 --- a/client/app/components/proptypes.js +++ b/client/app/components/proptypes.js @@ -55,8 +55,10 @@ export const Field = PropTypes.shape({ mode: PropTypes.string, required: PropTypes.bool, readOnly: PropTypes.bool, + autoFocus: PropTypes.bool, minLength: PropTypes.number, placeholder: PropTypes.string, + contentAfter: PropTypes.oneOfType([PropTypes.node, PropTypes.func]), loading: PropTypes.bool, props: PropTypes.object, // eslint-disable-line react/forbid-prop-types }); diff --git a/client/app/components/type-picker.html b/client/app/components/type-picker.html deleted file mode 100644 index 4ffe2c1259..0000000000 --- a/client/app/components/type-picker.html +++ /dev/null @@ -1,20 +0,0 @@ -
-

{{$ctrl.title}}

- -
-
-
- -
-
-
- -
-
-
- {{type.name}} -

{{type.name}}

-
-
-
-
diff --git a/client/app/components/type-picker.js b/client/app/components/type-picker.js deleted file mode 100644 index 8c2d57b6d9..0000000000 --- a/client/app/components/type-picker.js +++ /dev/null @@ -1,18 +0,0 @@ -import template from './type-picker.html'; - -export default function init(ngModule) { - ngModule.component('typePicker', { - template, - bindings: { - types: '<', - title: '@', - imgRoot: '@', - onTypeSelect: '=', - }, - controller() { - this.filter = {}; - }, - }); -} - -init.init = true; diff --git a/client/app/components/users/CreateUserDialog.jsx b/client/app/components/users/CreateUserDialog.jsx index ef799a92f2..dc630b8edc 100644 --- a/client/app/components/users/CreateUserDialog.jsx +++ b/client/app/components/users/CreateUserDialog.jsx @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import Modal from 'antd/lib/modal'; import Alert from 'antd/lib/alert'; -import { DynamicForm } from '@/components/dynamic-form/DynamicForm'; +import DynamicForm from '@/components/dynamic-form/DynamicForm'; import { wrap as wrapDialog, DialogPropType } from '@/components/DialogWrapper'; import recordEvent from '@/services/recordEvent'; @@ -38,7 +38,7 @@ class CreateUserDialog extends React.Component { render() { const { savingUser, errorMessage } = this.state; const formFields = [ - { name: 'name', title: 'Name', type: 'text' }, + { name: 'name', title: 'Name', type: 'text', autoFocus: true }, { name: 'email', title: 'Email', type: 'email' }, ].map(field => ({ required: true, props: { onPressEnter: this.createUser }, ...field })); diff --git a/client/app/components/users/UserEdit.jsx b/client/app/components/users/UserEdit.jsx index 4b3bcd50c9..63489a6aec 100644 --- a/client/app/components/users/UserEdit.jsx +++ b/client/app/components/users/UserEdit.jsx @@ -10,7 +10,7 @@ import { Group } from '@/services/group'; import { currentUser } from '@/services/auth'; import { absoluteUrl } from '@/services/utils'; import { UserProfile } from '../proptypes'; -import { DynamicForm } from '../dynamic-form/DynamicForm'; +import DynamicForm from '../dynamic-form/DynamicForm'; import ChangePasswordDialog from './ChangePasswordDialog'; import InputWithCopy from '../InputWithCopy'; diff --git a/client/app/pages/data-sources/DataSourcesList.jsx b/client/app/pages/data-sources/DataSourcesList.jsx new file mode 100644 index 0000000000..29d51c2263 --- /dev/null +++ b/client/app/pages/data-sources/DataSourcesList.jsx @@ -0,0 +1,145 @@ +import React from 'react'; +import Button from 'antd/lib/button'; +import { react2angular } from 'react2angular'; +import { isEmpty, get } from 'lodash'; +import settingsMenu from '@/services/settingsMenu'; +import { DataSource, IMG_ROOT } from '@/services/data-source'; +import { policy } from '@/services/policy'; +import navigateTo from '@/services/navigateTo'; +import { $route } from '@/services/ng'; +import { routesToAngularRoutes } from '@/lib/utils'; +import CardsList from '@/components/cards-list/CardsList'; +import LoadingState from '@/components/items-list/components/LoadingState'; +import CreateSourceDialog from '@/components/CreateSourceDialog'; +import helper from '@/components/dynamic-form/dynamicFormHelper'; + +class DataSourcesList extends React.Component { + state = { + dataSourceTypes: [], + dataSources: [], + loading: true, + }; + + componentDidMount() { + Promise.all([ + DataSource.query().$promise, + DataSource.types().$promise, + ]).then(values => this.setState({ + dataSources: values[0], + dataSourceTypes: values[1], + loading: false, + }, () => { // all resources are loaded in state + if ($route.current.locals.isNewDataSourcePage) { + if (policy.canCreateDataSource()) { + this.showCreateSourceDialog(); + } else { + navigateTo('/data_sources'); + } + } + })); + } + + createDataSource = (selectedType, values) => { + const target = { options: {}, type: selectedType.type }; + helper.updateTargetWithValues(target, values); + + return DataSource.save(target).$promise.then((dataSource) => { + this.setState({ loading: true }); + DataSource.query(dataSources => this.setState({ dataSources, loading: false })); + return dataSource; + }).catch((error) => { + if (!(error instanceof Error)) { + error = new Error(get(error, 'data.message', 'Failed saving.')); + } + return Promise.reject(error); + }); + }; + + showCreateSourceDialog = () => { + CreateSourceDialog.showModal({ + types: this.state.dataSourceTypes, + sourceType: 'Data Source', + imageFolder: IMG_ROOT, + helpTriggerPrefix: 'DS_', + onCreate: this.createDataSource, + }).result.then((result = {}) => { + if (result.success) { + navigateTo(`data_sources/${result.data.id}`); + } + }); + }; + + renderDataSources() { + const { dataSources } = this.state; + const items = dataSources.map(dataSource => ({ + title: dataSource.name, + imgSrc: `${IMG_ROOT}/${dataSource.type}.png`, + href: `data_sources/${dataSource.id}`, + })); + + return isEmpty(dataSources) ? ( +
+ There are no data sources yet. + {policy.isCreateDataSourceEnabled() && ( +
+ Click here to add one. +
+ )} +
+ ) : (); + } + + render() { + const newDataSourceProps = { + type: 'primary', + onClick: policy.isCreateDataSourceEnabled() ? this.showCreateSourceDialog : null, + disabled: !policy.isCreateDataSourceEnabled(), + }; + + return ( +
+
+ +
+ {this.state.loading ? : this.renderDataSources()} +
+ ); + } +} + +export default function init(ngModule) { + settingsMenu.add({ + permission: 'admin', + title: 'Data Sources', + path: 'data_sources', + order: 1, + }); + + ngModule.component('pageDataSourcesList', react2angular(DataSourcesList)); + + return routesToAngularRoutes([ + { + path: '/data_sources', + title: 'Data Sources', + key: 'data_sources', + }, + { + path: '/data_sources/new', + title: 'Data Sources', + key: 'data_sources', + isNewDataSourcePage: true, + }, + ], { + template: '', + controller($scope, $exceptionHandler) { + 'ngInject'; + + $scope.handleError = $exceptionHandler; + }, + }); +} + +init.init = true; diff --git a/client/app/pages/data-sources/EditDataSource.jsx b/client/app/pages/data-sources/EditDataSource.jsx new file mode 100644 index 0000000000..75bf14b75b --- /dev/null +++ b/client/app/pages/data-sources/EditDataSource.jsx @@ -0,0 +1,152 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { get, find, toUpper } from 'lodash'; +import { react2angular } from 'react2angular'; +import Modal from 'antd/lib/modal'; +import { DataSource, IMG_ROOT } from '@/services/data-source'; +import navigateTo from '@/services/navigateTo'; +import { $route } from '@/services/ng'; +import notification from '@/services/notification'; +import PromiseRejectionError from '@/lib/promise-rejection-error'; +import LoadingState from '@/components/items-list/components/LoadingState'; +import DynamicForm from '@/components/dynamic-form/DynamicForm'; +import helper from '@/components/dynamic-form/dynamicFormHelper'; +import { HelpTrigger, TYPES as HELP_TRIGGER_TYPES } from '@/components/HelpTrigger'; + +class EditDataSource extends React.Component { + static propTypes = { + onError: PropTypes.func, + }; + + static defaultProps = { + onError: () => {}, + }; + + state = { + dataSource: null, + type: null, + loading: true, + }; + + componentDidMount() { + DataSource.get({ id: $route.current.params.dataSourceId }).$promise.then((dataSource) => { + const { type } = dataSource; + this.setState({ dataSource }); + DataSource.types(types => this.setState({ type: find(types, { type }), loading: false })); + }).catch((error) => { + // ANGULAR_REMOVE_ME This code is related to Angular's HTTP services + if (error.status && error.data) { + error = new PromiseRejectionError(error); + } + this.props.onError(error); + }); + } + + saveDataSource = (values, successCallback, errorCallback) => { + const { dataSource } = this.state; + helper.updateTargetWithValues(dataSource, values); + dataSource.$save( + () => successCallback('Saved.'), + (error) => { + const message = get(error, 'data.message', 'Failed saving.'); + errorCallback(message); + }, + ); + } + + deleteDataSource = (callback) => { + const { dataSource } = this.state; + + const doDelete = () => { + dataSource.$delete(() => { + notification.success('Data source deleted successfully.'); + navigateTo('/data_sources', true); + }, () => { + callback(); + }); + }; + + Modal.confirm({ + title: 'Delete Data Source', + content: 'Are you sure you want to delete this data source?', + okText: 'Delete', + okType: 'danger', + onOk: doDelete, + onCancel: callback, + maskClosable: true, + autoFocusButton: null, + }); + }; + + testConnection = (callback) => { + const { dataSource } = this.state; + DataSource.test({ id: dataSource.id }, (httpResponse) => { + if (httpResponse.ok) { + notification.success('Success'); + } else { + notification.error('Connection Test Failed:', httpResponse.message, { duration: 10 }); + } + callback(); + }, () => { + notification.error('Connection Test Failed:', 'Unknown error occurred while performing connection test. Please try again later.', { duration: 10 }); + callback(); + }); + }; + + renderForm() { + const { dataSource, type } = this.state; + const fields = helper.getFields(type, dataSource); + const helpTriggerType = `DS_${toUpper(type.type)}`; + const formProps = { + fields, + type, + actions: [ + { name: 'Delete', type: 'danger', callback: this.deleteDataSource }, + { name: 'Test Connection', pullRight: true, callback: this.testConnection, disableWhenDirty: true }, + ], + onSubmit: this.saveDataSource, + feedbackIcons: true, + }; + + return ( +
+
+ {HELP_TRIGGER_TYPES[helpTriggerType] && ( + + Setup Instructions + + )} +
+
+ {type.name} +

{type.name}

+
+
+ +
+
+ ); + } + + render() { + return this.state.loading ? : this.renderForm(); + } +} + +export default function init(ngModule) { + ngModule.component('pageEditDataSource', react2angular(EditDataSource)); + + return { + '/data_sources/:dataSourceId': { + template: '', + title: 'Data Sources', + controller($scope, $exceptionHandler) { + 'ngInject'; + + $scope.handleError = $exceptionHandler; + }, + }, + }; +} + +init.init = true; diff --git a/client/app/pages/data-sources/list.html b/client/app/pages/data-sources/list.html deleted file mode 100644 index 56af90e071..0000000000 --- a/client/app/pages/data-sources/list.html +++ /dev/null @@ -1,17 +0,0 @@ - - - \ No newline at end of file diff --git a/client/app/pages/data-sources/list.js b/client/app/pages/data-sources/list.js deleted file mode 100644 index ac8ff4487b..0000000000 --- a/client/app/pages/data-sources/list.js +++ /dev/null @@ -1,31 +0,0 @@ -import settingsMenu from '@/services/settingsMenu'; -import { policy } from '@/services/policy'; -import template from './list.html'; - -function DataSourcesCtrl(DataSource) { - this.policy = policy; - this.dataSources = DataSource.query(); -} - -export default function init(ngModule) { - settingsMenu.add({ - permission: 'admin', - title: 'Data Sources', - path: 'data_sources', - order: 1, - }); - - ngModule.component('dsListPage', { - controller: DataSourcesCtrl, - template, - }); - - return { - '/data_sources': { - template: '', - title: 'Data Sources', - }, - }; -} - -init.init = true; diff --git a/client/app/pages/data-sources/show.html b/client/app/pages/data-sources/show.html deleted file mode 100644 index 5883654482..0000000000 --- a/client/app/pages/data-sources/show.html +++ /dev/null @@ -1,37 +0,0 @@ - -
- - -
-
- - -
- {{type.name}} -

{{type.name}}

-
- - -
- -
- -
- - -
-
-
-
diff --git a/client/app/pages/data-sources/show.js b/client/app/pages/data-sources/show.js deleted file mode 100644 index efd96c2510..0000000000 --- a/client/app/pages/data-sources/show.js +++ /dev/null @@ -1,133 +0,0 @@ -import { find } from 'lodash'; -import debug from 'debug'; -import template from './show.html'; -import notification from '@/services/notification'; - -const logger = debug('redash:http'); -export const deleteConfirm = { class: 'btn-warning', title: 'Delete' }; -export function logAndNotifyError(deleteObject, httpResponse) { - logger('Failed to delete ' + deleteObject + ': ', httpResponse.status, httpResponse.statusText, httpResponse.data); - notification.error('Failed to delete ' + deleteObject + '.'); -} -export function notifySuccessAndPath(deleteObject, deletePath, $location) { - notification.success(deleteObject + ' deleted successfully.'); - $location.path('/' + deletePath + '/'); -} - -function DataSourceCtrl( - $scope, $route, $routeParams, $http, $location, - currentUser, AlertDialog, DataSource, -) { - $scope.dataSource = $route.current.locals.dataSource; - $scope.dataSourceId = $routeParams.dataSourceId; - $scope.types = $route.current.locals.types; - $scope.type = find($scope.types, { type: $scope.dataSource.type }); - $scope.canChangeType = $scope.dataSource.id === undefined; - - $scope.helpLinks = { - athena: 'https://redash.io/help/data-sources/amazon-athena-setup', - bigquery: 'https://redash.io/help/data-sources/bigquery-setup', - url: 'https://redash.io/help/data-sources/querying-urls', - mongodb: 'https://redash.io/help/data-sources/mongodb-setup', - google_spreadsheets: 'https://redash.io/help/data-sources/querying-a-google-spreadsheet', - google_analytics: 'https://redash.io/help/data-sources/google-analytics-setup', - axibasetsd: 'https://redash.io/help/data-sources/axibase-time-series-database', - results: 'https://redash.io/help/user-guide/querying/query-results-data-source', - }; - - $scope.$watch('dataSource.id', (id) => { - if (id !== $scope.dataSourceId && id !== undefined) { - $location.path(`/data_sources/${id}`).replace(); - } - }); - - $scope.setType = (type) => { - $scope.type = type; - $scope.dataSource.type = type.type; - }; - - $scope.resetType = () => { - $scope.type = undefined; - $scope.dataSource = new DataSource({ options: {} }); - }; - - function deleteDataSource(callback) { - const doDelete = () => { - $scope.dataSource.$delete(() => { - notifySuccessAndPath('Data source', 'data_sources', $location); - }, (httpResponse) => { - logAndNotifyError('data source', httpResponse); - }); - }; - - const deleteTitle = 'Delete Data source'; - const deleteMessage = `Are you sure you want to delete the "${$scope.dataSource.name}" data source?`; - - AlertDialog.open(deleteTitle, deleteMessage, deleteConfirm).then(doDelete, callback); - } - - function testConnection(callback) { - DataSource.test({ id: $scope.dataSource.id }, (httpResponse) => { - if (httpResponse.ok) { - notification.success('Success'); - } else { - notification.error('Connection Test Failed:', httpResponse.message, { duration: 10 }); - } - callback(); - }, (httpResponse) => { - logger('Failed to test data source: ', httpResponse.status, httpResponse.statusText, httpResponse); - notification.error('Connection Test Failed:', 'Unknown error occurred while performing connection test. Please try again later.', { duration: 10 }); - callback(); - }); - } - - $scope.actions = [ - { name: 'Delete', type: 'danger', callback: deleteDataSource }, - { - name: 'Test Connection', pullRight: true, callback: testConnection, disableWhenDirty: true, - }, - ]; -} - -export default function init(ngModule) { - ngModule.controller('DataSourceCtrl', DataSourceCtrl); - - return { - '/data_sources/new': { - template, - controller: 'DataSourceCtrl', - title: 'Datasources', - resolve: { - dataSource: (DataSource) => { - 'ngInject'; - - return new DataSource({ options: {} }); - }, - types: ($http) => { - 'ngInject'; - - return $http.get('api/data_sources/types').then(response => response.data); - }, - }, - }, - '/data_sources/:dataSourceId': { - template, - controller: 'DataSourceCtrl', - title: 'Datasources', - resolve: { - dataSource: (DataSource, $route) => { - 'ngInject'; - - return DataSource.get({ id: $route.current.params.dataSourceId }).$promise; - }, - types: ($http) => { - 'ngInject'; - - return $http.get('api/data_sources/types').then(response => response.data); - }, - }, - }, - }; -} - -init.init = true; diff --git a/client/app/pages/destinations/DestinationsList.jsx b/client/app/pages/destinations/DestinationsList.jsx new file mode 100644 index 0000000000..2f68efddfb --- /dev/null +++ b/client/app/pages/destinations/DestinationsList.jsx @@ -0,0 +1,144 @@ +import React from 'react'; +import Button from 'antd/lib/button'; +import { react2angular } from 'react2angular'; +import { isEmpty, get } from 'lodash'; +import settingsMenu from '@/services/settingsMenu'; +import { Destination, IMG_ROOT } from '@/services/destination'; +import { policy } from '@/services/policy'; +import navigateTo from '@/services/navigateTo'; +import { $route } from '@/services/ng'; +import { routesToAngularRoutes } from '@/lib/utils'; +import CardsList from '@/components/cards-list/CardsList'; +import LoadingState from '@/components/items-list/components/LoadingState'; +import CreateSourceDialog from '@/components/CreateSourceDialog'; +import helper from '@/components/dynamic-form/dynamicFormHelper'; + +class DestinationsList extends React.Component { + state = { + destinationTypes: [], + destinations: [], + loading: true, + }; + + componentDidMount() { + Promise.all([ + Destination.query().$promise, + Destination.types().$promise, + ]).then(values => this.setState({ + destinations: values[0], + destinationTypes: values[1], + loading: false, + }, () => { // all resources are loaded in state + if ($route.current.locals.isNewDestinationPage) { + if (policy.canCreateDestination()) { + this.showCreateSourceDialog(); + } else { + navigateTo('/destinations'); + } + } + })); + } + + createDestination = (selectedType, values) => { + const target = { options: {}, type: selectedType.type }; + helper.updateTargetWithValues(target, values); + + return Destination.save(target).$promise.then((destination) => { + this.setState({ loading: true }); + Destination.query(destinations => this.setState({ destinations, loading: false })); + return destination; + }).catch((error) => { + if (!(error instanceof Error)) { + error = new Error(get(error, 'data.message', 'Failed saving.')); + } + return Promise.reject(error); + }); + }; + + showCreateSourceDialog = () => { + CreateSourceDialog.showModal({ + types: this.state.destinationTypes, + sourceType: 'Alert Destination', + imageFolder: IMG_ROOT, + onCreate: this.createDestination, + }).result.then((result = {}) => { + if (result.success) { + navigateTo(`destinations/${result.data.id}`); + } + }); + }; + + renderDestinations() { + const { destinations } = this.state; + const items = destinations.map(destination => ({ + title: destination.name, + imgSrc: `${IMG_ROOT}/${destination.type}.png`, + href: `destinations/${destination.id}`, + })); + + return isEmpty(destinations) ? ( +
+ There are no alert destinations yet. + {policy.isCreateDestinationEnabled() && ( +
+ Click here to add one. +
+ )} +
+ ) : (); + } + + render() { + const newDestinationProps = { + type: 'primary', + onClick: policy.isCreateDestinationEnabled() ? this.showCreateSourceDialog : null, + disabled: !policy.isCreateDestinationEnabled(), + }; + + return ( +
+
+ +
+ {this.state.loading ? : this.renderDestinations()} +
+ ); + } +} + +export default function init(ngModule) { + settingsMenu.add({ + permission: 'admin', + title: 'Alert Destinations', + path: 'destinations', + order: 4, + }); + + ngModule.component('pageDestinationsList', react2angular(DestinationsList)); + + return routesToAngularRoutes([ + { + path: '/destinations', + title: 'Alert Destinations', + key: 'destinations', + }, + { + path: '/destinations/new', + title: 'Alert Destinations', + key: 'destinations', + isNewDestinationPage: true, + }, + ], { + template: '', + controller($scope, $exceptionHandler) { + 'ngInject'; + + $scope.handleError = $exceptionHandler; + }, + }); +} + +init.init = true; diff --git a/client/app/pages/destinations/EditDestination.jsx b/client/app/pages/destinations/EditDestination.jsx new file mode 100644 index 0000000000..86677d3c12 --- /dev/null +++ b/client/app/pages/destinations/EditDestination.jsx @@ -0,0 +1,127 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { get, find } from 'lodash'; +import { react2angular } from 'react2angular'; +import Modal from 'antd/lib/modal'; +import { Destination, IMG_ROOT } from '@/services/destination'; +import navigateTo from '@/services/navigateTo'; +import { $route } from '@/services/ng'; +import notification from '@/services/notification'; +import PromiseRejectionError from '@/lib/promise-rejection-error'; +import LoadingState from '@/components/items-list/components/LoadingState'; +import DynamicForm from '@/components/dynamic-form/DynamicForm'; +import helper from '@/components/dynamic-form/dynamicFormHelper'; + +class EditDestination extends React.Component { + static propTypes = { + onError: PropTypes.func, + }; + + static defaultProps = { + onError: () => {}, + }; + + state = { + destination: null, + type: null, + loading: true, + }; + + componentDidMount() { + Destination.get({ id: $route.current.params.destinationId }).$promise.then((destination) => { + const { type } = destination; + this.setState({ destination }); + Destination.types(types => this.setState({ type: find(types, { type }), loading: false })); + }).catch((error) => { + // ANGULAR_REMOVE_ME This code is related to Angular's HTTP services + if (error.status && error.data) { + error = new PromiseRejectionError(error); + } + this.props.onError(error); + }); + } + + saveDestination = (values, successCallback, errorCallback) => { + const { destination } = this.state; + helper.updateTargetWithValues(destination, values); + destination.$save( + () => successCallback('Saved.'), + (error) => { + const message = get(error, 'data.message', 'Failed saving.'); + errorCallback(message); + }, + ); + } + + deleteDestination = (callback) => { + const { destination } = this.state; + + const doDelete = () => { + destination.$delete(() => { + notification.success('Alert destination deleted successfully.'); + navigateTo('/destinations', true); + }, () => { + callback(); + }); + }; + + Modal.confirm({ + title: 'Delete Alert Destination', + content: 'Are you sure you want to delete this alert destination?', + okText: 'Delete', + okType: 'danger', + onOk: doDelete, + onCancel: callback, + maskClosable: true, + autoFocusButton: null, + }); + }; + + renderForm() { + const { destination, type } = this.state; + const fields = helper.getFields(type, destination); + const formProps = { + fields, + type, + actions: [ + { name: 'Delete', type: 'danger', callback: this.deleteDestination }, + ], + onSubmit: this.saveDestination, + feedbackIcons: true, + }; + + return ( +
+
+ {type.name} +

{type.name}

+
+
+ +
+
+ ); + } + + render() { + return this.state.loading ? : this.renderForm(); + } +} + +export default function init(ngModule) { + ngModule.component('pageEditDestination', react2angular(EditDestination)); + + return { + '/destinations/:destinationId': { + template: '', + title: 'Alert Destinations', + controller($scope, $exceptionHandler) { + 'ngInject'; + + $scope.handleError = $exceptionHandler; + }, + }, + }; +} + +init.init = true; diff --git a/client/app/pages/destinations/list.html b/client/app/pages/destinations/list.html deleted file mode 100644 index a3dcb2650f..0000000000 --- a/client/app/pages/destinations/list.html +++ /dev/null @@ -1,15 +0,0 @@ - - - diff --git a/client/app/pages/destinations/list.js b/client/app/pages/destinations/list.js deleted file mode 100644 index bcb6ec2b5c..0000000000 --- a/client/app/pages/destinations/list.js +++ /dev/null @@ -1,27 +0,0 @@ -import settingsMenu from '@/services/settingsMenu'; -import template from './list.html'; - -function DestinationsCtrl($scope, $location, currentUser, Destination) { - $scope.destinations = Destination.query(); -} - -export default function init(ngModule) { - settingsMenu.add({ - permission: 'admin', - title: 'Alert Destinations', - path: 'destinations', - order: 4, - }); - - ngModule.controller('DestinationsCtrl', DestinationsCtrl); - - return { - '/destinations': { - template, - controller: 'DestinationsCtrl', - title: 'Destinations', - }, - }; -} - -init.init = true; diff --git a/client/app/pages/destinations/show.html b/client/app/pages/destinations/show.html deleted file mode 100644 index 51eaddbdfe..0000000000 --- a/client/app/pages/destinations/show.html +++ /dev/null @@ -1,29 +0,0 @@ - -
- - -
-
- - -
- {{type.name}} -

{{type.name}}

-
-
- -
- -
- - -
-
-
-
diff --git a/client/app/pages/destinations/show.js b/client/app/pages/destinations/show.js deleted file mode 100644 index 699052b4a6..0000000000 --- a/client/app/pages/destinations/show.js +++ /dev/null @@ -1,92 +0,0 @@ -import { find } from 'lodash'; -import template from './show.html'; -import { deleteConfirm, logAndNotifyError, notifySuccessAndPath } from '../data-sources/show'; - -function DestinationCtrl( - $scope, $route, $routeParams, $http, $location, - currentUser, AlertDialog, Destination, -) { - $scope.destination = $route.current.locals.destination; - $scope.destinationId = $routeParams.destinationId; - $scope.types = $route.current.locals.types; - $scope.type = find($scope.types, { type: $scope.destination.type }); - $scope.canChangeType = $scope.destination.id === undefined; - - $scope.$watch('destination.id', (id) => { - if (id !== $scope.destinationId && id !== undefined) { - $location.path(`/destinations/${id}`).replace(); - } - }); - - $scope.setType = (type) => { - $scope.type = type; - $scope.destination.type = type.type; - }; - - $scope.resetType = () => { - $scope.type = undefined; - $scope.destination = new Destination({ options: {} }); - }; - - function deleteDestination(callback) { - const doDelete = () => { - $scope.destination.$delete(() => { - notifySuccessAndPath('Destination', 'destinations', $location); - }, (httpResponse) => { - logAndNotifyError('destination', httpResponse); - }); - }; - - const title = 'Delete Destination'; - const message = `Are you sure you want to delete the "${$scope.destination.name}" destination?`; - - AlertDialog.open(title, message, deleteConfirm).then(doDelete, callback); - } - - $scope.actions = [ - { name: 'Delete', type: 'danger', callback: deleteDestination }, - ]; -} - -export default function init(ngModule) { - ngModule.controller('DestinationCtrl', DestinationCtrl); - - return { - '/destinations/new': { - template, - controller: 'DestinationCtrl', - title: 'Destinations', - resolve: { - destination: (Destination) => { - 'ngInject'; - - return new Destination({ options: {} }); - }, - types: ($http) => { - 'ngInject'; - - return $http.get('api/destinations/types').then(response => response.data); - }, - }, - }, - '/destinations/:destinationId': { - template, - controller: 'DestinationCtrl', - title: 'Destinations', - resolve: { - destination: (Destination, $route) => { - 'ngInject'; - - return Destination.get({ id: $route.current.params.destinationId }).$promise; - }, - types: ($http) => { - 'ngInject'; - - return $http.get('api/destinations/types').then(response => response.data); - }, - }, - }, - }; -} - -init.init = true; diff --git a/client/app/services/data-source.js b/client/app/services/data-source.js index f1d69d5cbc..efd4422a0d 100644 --- a/client/app/services/data-source.js +++ b/client/app/services/data-source.js @@ -1,8 +1,10 @@ export const SCHEMA_NOT_SUPPORTED = 1; export const SCHEMA_LOAD_ERROR = 2; +export const IMG_ROOT = '/static/images/db-logos'; export let DataSource = null; // eslint-disable-line import/no-mutable-exports + function DataSourceService($q, $resource, $http) { function fetchSchema(dataSourceId, refresh = false) { const params = {}; @@ -17,6 +19,13 @@ function DataSourceService($q, $resource, $http) { const actions = { get: { method: 'GET', cache: false, isArray: false }, query: { method: 'GET', cache: false, isArray: true }, + save: { method: 'POST' }, + types: { + method: 'GET', + cache: false, + isArray: true, + url: 'api/data_sources/types', + }, test: { method: 'POST', cache: false, diff --git a/client/app/services/destination.js b/client/app/services/destination.js index 9c3c6d2f61..6452ceb0ef 100644 --- a/client/app/services/destination.js +++ b/client/app/services/destination.js @@ -1,10 +1,19 @@ +export const IMG_ROOT = '/static/images/destinations'; + export let Destination = null; // eslint-disable-line import/no-mutable-exports function DestinationService($resource) { const actions = { get: { method: 'GET', cache: false, isArray: false }, + types: { + method: 'GET', + cache: false, + isArray: true, + url: 'api/destinations/types', + }, query: { method: 'GET', cache: false, isArray: true }, }; + return $resource('api/destinations/:id', { id: '@id' }, actions); } diff --git a/client/app/services/policy/DefaultPolicy.js b/client/app/services/policy/DefaultPolicy.js index b1101addbe..9aa1ed3046 100644 --- a/client/app/services/policy/DefaultPolicy.js +++ b/client/app/services/policy/DefaultPolicy.js @@ -17,6 +17,14 @@ export default class DefaultPolicy { return currentUser.isAdmin; } + canCreateDestination() { + return currentUser.isAdmin; + } + + isCreateDestinationEnabled() { + return currentUser.isAdmin; + } + canCreateDashboard() { return currentUser.hasPermission('create_dashboard'); } diff --git a/client/cypress/integration/data-source/create_data_source_spec.js b/client/cypress/integration/data-source/create_data_source_spec.js index fe0c3370e6..315935d591 100644 --- a/client/cypress/integration/data-source/create_data_source_spec.js +++ b/client/cypress/integration/data-source/create_data_source_spec.js @@ -5,18 +5,21 @@ describe('Create Data Source', () => { }); it('renders the page and takes a screenshot', () => { - cy.getByTestId('TypePicker').should('contain', 'PostgreSQL'); + cy.getByTestId('CreateSourceDialog').should('contain', 'PostgreSQL'); + cy.wait(1000); // eslint-disable-line cypress/no-unnecessary-waiting cy.percySnapshot('Create Data Source - Types'); }); it('creates a new PostgreSQL data source', () => { - cy.getByTestId('TypePicker').contains('PostgreSQL').click(); + cy.getByTestId('SearchSource').type('PostgreSQL'); + cy.getByTestId('CreateSourceDialog').contains('PostgreSQL').click(); cy.getByTestId('Name').type('Redash'); - cy.getByTestId('Host').type('{selectall}postgres'); + cy.getByTestId('Host').type('postgres'); cy.getByTestId('User').type('postgres'); cy.getByTestId('Password').type('postgres'); cy.getByTestId('Database Name').type('postgres{enter}'); + cy.getByTestId('CreateSourceButton').click(); cy.contains('Saved.'); }); diff --git a/client/cypress/integration/destination/create_destination_spec.js b/client/cypress/integration/destination/create_destination_spec.js index 3c82a316dd..7b6bf3c09e 100644 --- a/client/cypress/integration/destination/create_destination_spec.js +++ b/client/cypress/integration/destination/create_destination_spec.js @@ -5,7 +5,8 @@ describe('Create Destination', () => { }); it('renders the page and takes a screenshot', () => { - cy.getByTestId('TypePicker').should('contain', 'Email'); + cy.getByTestId('CreateSourceDialog').should('contain', 'Email'); + cy.wait(1000); // eslint-disable-line cypress/no-unnecessary-waiting cy.percySnapshot('Create Destination - Types'); }); });