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}]({`${imageFolder}/${selectedType.type}.png`})
+
{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}
-
+
{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}]({item.imgSrc})
)}
+ 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 (
-
);
}
-});
-
-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() && (
+
+ )}
+
+ ) : ();
+ }
+
+ 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}]({`${IMG_ROOT}/${type.type}.png`})
+
{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() && (
+
+ )}
+
+ ) : ();
+ }
+
+ 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}]({`${IMG_ROOT}/${type.type}.png`})
+
{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');
});
});