diff --git a/.circleci/docker-compose.cypress.yml b/.circleci/docker-compose.cypress.yml
index 5305f41d4e..2483582ce7 100644
--- a/.circleci/docker-compose.cypress.yml
+++ b/.circleci/docker-compose.cypress.yml
@@ -23,7 +23,7 @@ services:
REDASH_LOG_LEVEL: "INFO"
REDASH_REDIS_URL: "redis://redis:6379/0"
REDASH_DATABASE_URL: "postgresql://postgres@postgres/postgres"
- QUEUES: "queries,scheduled_queries,celery"
+ QUEUES: "queries,scheduled_queries,celery,schemas"
WORKERS_COUNT: 2
cypress:
build:
diff --git a/bin/docker-entrypoint b/bin/docker-entrypoint
index 8cc09a4949..2ecd723d66 100755
--- a/bin/docker-entrypoint
+++ b/bin/docker-entrypoint
@@ -3,7 +3,7 @@ set -e
worker() {
WORKERS_COUNT=${WORKERS_COUNT:-2}
- QUEUES=${QUEUES:-queries,scheduled_queries,celery}
+ QUEUES=${QUEUES:-queries,scheduled_queries,celery,schemas}
echo "Starting $WORKERS_COUNT workers for queues: $QUEUES..."
exec /usr/local/bin/celery worker --app=redash.worker -c$WORKERS_COUNT -Q$QUEUES -linfo --maxtasksperchild=10 -Ofair
diff --git a/client/app/assets/less/ant.less b/client/app/assets/less/ant.less
index d2fd4a9581..0037893255 100644
--- a/client/app/assets/less/ant.less
+++ b/client/app/assets/less/ant.less
@@ -13,6 +13,7 @@
@import '~antd/lib/radio/style/index';
@import '~antd/lib/time-picker/style/index';
@import '~antd/lib/pagination/style/index';
+@import '~antd/lib/drawer/style/index';
@import '~antd/lib/table/style/index';
@import '~antd/lib/popover/style/index';
@import '~antd/lib/icon/style/index';
diff --git a/client/app/assets/less/inc/schema-browser.less b/client/app/assets/less/inc/schema-browser.less
index 0034391086..d547a78790 100644
--- a/client/app/assets/less/inc/schema-browser.less
+++ b/client/app/assets/less/inc/schema-browser.less
@@ -7,14 +7,14 @@ div.table-name {
border-radius: @redash-radius;
position: relative;
- .copy-to-editor {
+ .copy-to-editor, .info {
display: none;
}
&:hover {
background: fade(@redash-gray, 10%);
- .copy-to-editor {
+ .copy-to-editor, .info {
display: flex;
}
}
@@ -36,7 +36,7 @@ div.table-name {
background: transparent;
}
- .copy-to-editor {
+ .copy-to-editor, .info {
color: fade(@redash-gray, 90%);
cursor: pointer;
position: absolute;
@@ -49,6 +49,10 @@ div.table-name {
justify-content: center;
}
+ .info {
+ right: 20px
+ }
+
.table-open {
padding: 0 22px 0 26px;
overflow: hidden;
@@ -56,14 +60,14 @@ div.table-name {
white-space: nowrap;
position: relative;
- .copy-to-editor {
+ .copy-to-editor, .info {
display: none;
}
&:hover {
background: fade(@redash-gray, 10%);
- .copy-to-editor {
+ .copy-to-editor, .info {
display: flex;
}
}
diff --git a/client/app/assets/less/redash/redash-newstyle.less b/client/app/assets/less/redash/redash-newstyle.less
index b2e6ebf018..b6f14e392a 100644
--- a/client/app/assets/less/redash/redash-newstyle.less
+++ b/client/app/assets/less/redash/redash-newstyle.less
@@ -101,6 +101,10 @@ body {
}
}
+.admin-schema-editor {
+ padding: 50px 0;
+}
+
.creation-container {
h5 {
color: #a7a7a7;
diff --git a/client/app/components/proptypes.js b/client/app/components/proptypes.js
index f35e89cc0e..36f7824547 100644
--- a/client/app/components/proptypes.js
+++ b/client/app/components/proptypes.js
@@ -11,8 +11,16 @@ export const DataSource = PropTypes.shape({
type_name: PropTypes.string,
});
+export const DataSourceMetadata = PropTypes.shape({
+ key: PropTypes.number,
+ name: PropTypes.string,
+ type: PropTypes.string,
+ example: PropTypes.string,
+ column_description: PropTypes.string,
+});
+
export const Table = PropTypes.shape({
- columns: PropTypes.arrayOf(PropTypes.string).isRequired,
+ columns: PropTypes.arrayOf(PropTypes.object).isRequired,
});
export const Schema = PropTypes.arrayOf(Table);
@@ -31,6 +39,13 @@ export const RefreshScheduleDefault = {
until: null,
};
+export const TableMetadata = PropTypes.shape({
+ key: PropTypes.number.isRequired,
+ name: PropTypes.string.isRequired,
+ table_description: PropTypes.string.isRequired,
+ table_visible: PropTypes.bool.isRequired,
+});
+
export const Field = PropTypes.shape({
name: PropTypes.string.isRequired,
title: PropTypes.string,
diff --git a/client/app/components/queries/SchemaData.jsx b/client/app/components/queries/SchemaData.jsx
new file mode 100644
index 0000000000..400626aafb
--- /dev/null
+++ b/client/app/components/queries/SchemaData.jsx
@@ -0,0 +1,75 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { react2angular } from 'react2angular';
+import Drawer from 'antd/lib/drawer';
+import Table from 'antd/lib/table';
+
+import { DataSourceMetadata } from '@/components/proptypes';
+
+class SchemaData extends React.PureComponent {
+ static propTypes = {
+ show: PropTypes.bool.isRequired,
+ onClose: PropTypes.func.isRequired,
+ tableName: PropTypes.string,
+ tableDescription: PropTypes.string,
+ tableMetadata: PropTypes.arrayOf(DataSourceMetadata),
+ };
+
+ static defaultProps = {
+ tableName: '',
+ tableDescription: '',
+ tableMetadata: [],
+ };
+
+ render() {
+ const columns = [{
+ title: 'Column Name',
+ dataIndex: 'name',
+ width: 400,
+ key: 'name',
+ }, {
+ title: 'Column Type',
+ dataIndex: 'type',
+ width: 400,
+ key: 'type',
+ }, {
+ title: 'Example',
+ dataIndex: 'example',
+ width: 400,
+ key: 'example',
+ }, {
+ title: 'Description',
+ dataIndex: 'column_description',
+ width: 400,
+ key: 'column_description',
+ }];
+
+ return (
+
+
+ {this.props.tableDescription}
+
+
+
+ );
+ }
+}
+
+export default function init(ngModule) {
+ ngModule.component('schemaData', react2angular(SchemaData, null, []));
+}
+
+init.init = true;
diff --git a/client/app/components/queries/schema-browser.html b/client/app/components/queries/schema-browser.html
index 6e3f518059..7623aeef59 100644
--- a/client/app/components/queries/schema-browser.html
+++ b/client/app/components/queries/schema-browser.html
@@ -9,22 +9,31 @@
-
+
{{table.name}}
({{table.size}})
+
-
{{column}}
+
{{column.name}}
+ ng-click="$ctrl.itemSelected($event, [column.name])">
+
diff --git a/client/app/components/queries/schema-browser.js b/client/app/components/queries/schema-browser.js
index 34615aa590..13057f3dae 100644
--- a/client/app/components/queries/schema-browser.js
+++ b/client/app/components/queries/schema-browser.js
@@ -8,6 +8,18 @@ function SchemaBrowserCtrl($rootScope, $scope) {
$scope.$broadcast('vsRepeatTrigger');
};
+ $scope.showSchemaInfo = false;
+ $scope.openSchemaInfo = ($event, table) => {
+ $scope.tableName = table.name;
+ $scope.tableDescription = table.table_description;
+ $scope.tableMetadata = table.columns;
+ $scope.showSchemaInfo = true;
+ $event.stopPropagation();
+ };
+ $scope.closeSchemaInfo = () => {
+ $scope.$apply(() => { $scope.showSchemaInfo = false; });
+ };
+
this.getSize = (table) => {
let size = 22;
@@ -22,6 +34,13 @@ function SchemaBrowserCtrl($rootScope, $scope) {
return this.schema === undefined || this.schema.length === 0;
};
+ this.itemExists = (item) => {
+ if ('visible' in item) {
+ return item.exists && item.visible;
+ }
+ return item.exists;
+ };
+
this.itemSelected = ($event, hierarchy) => {
$rootScope.$broadcast('query-editor.command', 'paste', hierarchy.join('.'));
$event.preventDefault();
diff --git a/client/app/pages/data-sources/schema-table-components/EditableTable.jsx b/client/app/pages/data-sources/schema-table-components/EditableTable.jsx
new file mode 100644
index 0000000000..8f60ef4fc0
--- /dev/null
+++ b/client/app/pages/data-sources/schema-table-components/EditableTable.jsx
@@ -0,0 +1,86 @@
+import React from 'react';
+import Form from 'antd/lib/form';
+import Input from 'antd/lib/input';
+import PropTypes from 'prop-types';
+import { TableMetadata } from '@/components/proptypes';
+import TableVisibilityCheckbox from './TableVisibilityCheckbox';
+import './schema-table.css';
+
+const FormItem = Form.Item;
+const { TextArea } = Input;
+export const EditableContext = React.createContext();
+
+// eslint-disable-next-line react/prop-types
+const EditableRow = ({ form, index, ...props }) => (
+
+
+
+);
+
+export const EditableFormRow = Form.create()(EditableRow);
+
+export class EditableCell extends React.Component {
+ static propTypes = {
+ dataIndex: PropTypes.string,
+ input_type: PropTypes.string,
+ editing: PropTypes.bool,
+ record: TableMetadata,
+ };
+
+ static defaultProps = {
+ dataIndex: undefined,
+ input_type: undefined,
+ editing: false,
+ record: {},
+ };
+
+ constructor(props) {
+ super(props);
+ this.state = {
+ visible: this.props.record ? this.props.record.table_visible : false,
+ };
+ }
+
+ onChange = () => {
+ this.setState({ visible: !this.state.visible });
+ }
+
+ getInput = () => {
+ if (this.props.input_type === 'table_visible') {
+ return (
+
);
+ }
+ return
;
+ };
+
+ render() {
+ const {
+ editing,
+ dataIndex,
+ record,
+ ...restProps
+ } = this.props;
+
+ return (
+
+ {(form) => {
+ const { getFieldDecorator } = form;
+ return (
+
+ {editing ? (
+
+ {getFieldDecorator(dataIndex, {
+ initialValue: record[dataIndex],
+ })(this.getInput()) }
+
+ ) : restProps.children}
+ |
+ );
+ }}
+
+ );
+ }
+}
diff --git a/client/app/pages/data-sources/schema-table-components/SchemaTable.jsx b/client/app/pages/data-sources/schema-table-components/SchemaTable.jsx
new file mode 100644
index 0000000000..f87d74fa68
--- /dev/null
+++ b/client/app/pages/data-sources/schema-table-components/SchemaTable.jsx
@@ -0,0 +1,270 @@
+import React from 'react';
+import { react2angular } from 'react2angular';
+import PropTypes from 'prop-types';
+import Table from 'antd/lib/table';
+import Popconfirm from 'antd/lib/popconfirm';
+import { Schema } from '@/components/proptypes';
+import { EditableCell, EditableFormRow, EditableContext } from './EditableTable';
+import TableVisibilityCheckbox from './TableVisibilityCheckbox';
+
+import './schema-table.css';
+
+function fetchTableData(schema) {
+ return schema.map(tableData => ({
+ key: tableData.id,
+ name: tableData.name,
+ table_description: tableData.table_description || '',
+ table_visible: tableData.visible,
+ columns: tableData.columns,
+ }));
+}
+
+const components = {
+ body: {
+ row: EditableFormRow,
+ cell: EditableCell,
+ },
+};
+
+class SchemaTable extends React.Component {
+ static propTypes = {
+ schema: Schema, // eslint-disable-line react/no-unused-prop-types
+ updateSchema: PropTypes.func.isRequired,
+ };
+
+ static defaultProps = {
+ schema: null,
+ };
+
+ constructor(props) {
+ super(props);
+ this.state = { data: [], editingKey: '' };
+ this.columns = [{
+ title: 'Table Name',
+ dataIndex: 'name',
+ width: '20%',
+ key: 'name',
+ }, {
+ title: 'Table Description',
+ dataIndex: 'table_description',
+ width: '52%',
+ key: 'table_description',
+ editable: true,
+ render: this.truncateDescriptionText,
+ }, {
+ title: 'Visibility',
+ dataIndex: 'table_visible',
+ width: '13%',
+ key: 'table_visible',
+ editable: true,
+ render: (text, record) => (
+
+ ),
+ }, {
+ title: '',
+ width: '15%',
+ dataIndex: 'edit',
+ key: 'edit',
+ // Purposely calling fieldEditor() instead of setting render() to it
+ // because render() will pass a different third argument than what
+ // fieldEditory() takes
+ render: (text, record) => this.fieldEditor(text, record),
+ }];
+ }
+
+ static getDerivedStateFromProps(nextProps, prevState) {
+ if (nextProps.schema && prevState.data.length === 0) {
+ return {
+ data: fetchTableData(nextProps.schema),
+ editingKey: prevState.editingKey,
+ };
+ }
+ return prevState;
+ }
+
+ truncateDescriptionText = (text) => {
+ if (!text) {
+ return;
+ }
+ const MAX_CHARACTER_COUNT = 305;
+ const addEllipses = text.length > MAX_CHARACTER_COUNT;
+
+ return (
+
+ {`${text.replace(/\n/g, ' ').substring(0, MAX_CHARACTER_COUNT)}${addEllipses ? '...' : ''}`}
+
+ );
+ }
+
+ fieldEditor(text, record, tableData) {
+ const editable = this.isEditing(record);
+ const tableKey = tableData ? tableData.key : record.key;
+ const columnKey = tableData ? record.key : undefined;
+ return (
+
+ {editable ? (
+
+
+ {form => (
+
+ )}
+
+ this.cancel(record.key)}
+ >
+
+
+
+ ) : (
+
+ )}
+
+ );
+ }
+
+ expandedRowRender = (tableData) => {
+ const columns = [
+ {
+ title: 'Column Name',
+ dataIndex: 'name',
+ key: 'name',
+ width: '15%',
+ }, {
+ title: 'Column Type',
+ dataIndex: 'type',
+ key: 'type',
+ width: '15%',
+ }, {
+ title: 'Column Example',
+ dataIndex: 'example',
+ key: 'example',
+ width: '20%',
+ }, {
+ title: 'Column Description',
+ dataIndex: 'column_description',
+ key: 'column_description',
+ width: '35%',
+ editable: true,
+ render: this.truncateDescriptionText,
+ onCell: record => ({
+ record,
+ input_type: 'text',
+ dataIndex: 'column_description',
+ title: 'Column Description',
+ editing: this.isEditing(record),
+ }),
+ },
+ {
+ title: '',
+ width: '15%',
+ dataIndex: 'edit',
+ key: 'edit',
+ render: (text, record) => this.fieldEditor(text, record, tableData),
+ },
+ ];
+
+ return (
+
+ );
+ }
+
+ cancel() {
+ this.setState({ editingKey: '' });
+ }
+
+ edit(key) {
+ this.setState({ editingKey: key });
+ }
+
+ isEditing(record) {
+ return record.key === this.state.editingKey;
+ }
+
+ save(form, tableKey, columnKey) {
+ form.validateFields((error, editedFields) => {
+ if (error) {
+ return;
+ }
+ const newData = [...this.state.data];
+ let spliceIndex = newData.findIndex(item => tableKey === item.key);
+
+ if (spliceIndex < 0) {
+ return;
+ }
+
+ const tableRow = newData[spliceIndex];
+ let dataToUpdate = newData;
+ let rowToUpdate = tableRow;
+
+ const columnIndex = tableRow.columns.findIndex(item => columnKey === item.key);
+ const columnRow = tableRow.columns[columnIndex];
+ if (columnKey) {
+ dataToUpdate = tableRow.columns;
+ spliceIndex = columnIndex;
+ rowToUpdate = columnRow;
+ }
+
+ dataToUpdate.splice(spliceIndex, 1, {
+ ...rowToUpdate,
+ ...editedFields,
+ });
+ this.props.updateSchema(editedFields, tableRow.key, columnRow ? columnRow.key : undefined);
+ this.setState({ data: newData, editingKey: '' });
+ });
+ }
+
+ render() {
+ const columns = this.columns.map(col => ({
+ ...col,
+ onCell: record => ({
+ record,
+ input_type: col.dataIndex,
+ dataIndex: col.dataIndex,
+ title: col.title,
+ editing: col.editable ? this.isEditing(record) : false,
+ }),
+ }));
+
+ return (
+
+ );
+ }
+}
+
+export default function init(ngModule) {
+ ngModule.component('schemaTable', react2angular(SchemaTable, null, []));
+}
+
+init.init = true;
diff --git a/client/app/pages/data-sources/schema-table-components/TableVisibilityCheckbox.jsx b/client/app/pages/data-sources/schema-table-components/TableVisibilityCheckbox.jsx
new file mode 100644
index 0000000000..17a7ac7f6b
--- /dev/null
+++ b/client/app/pages/data-sources/schema-table-components/TableVisibilityCheckbox.jsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Checkbox from 'antd/lib/checkbox';
+
+
+export default class TableVisibilityCheckbox extends React.PureComponent {
+ static propTypes = {
+ visible: PropTypes.bool.isRequired,
+ onChange: PropTypes.func,
+ disabled: PropTypes.bool,
+ };
+
+ static defaultProps = {
+ disabled: false,
+ onChange: undefined,
+ };
+
+ render() {
+ return (
+
+ {this.props.visible ? 'Visible' : 'Hidden'}
+
+ );
+ }
+}
diff --git a/client/app/pages/data-sources/schema-table-components/schema-table.css b/client/app/pages/data-sources/schema-table-components/schema-table.css
new file mode 100644
index 0000000000..790177326b
--- /dev/null
+++ b/client/app/pages/data-sources/schema-table-components/schema-table.css
@@ -0,0 +1,4 @@
+.editable-row, .table-description {
+ word-break: break-all;
+ white-space: pre-line;
+}
diff --git a/client/app/pages/data-sources/show.html b/client/app/pages/data-sources/show.html
index c513d1f196..4c64fb09d3 100644
--- a/client/app/pages/data-sources/show.html
+++ b/client/app/pages/data-sources/show.html
@@ -32,6 +32,12 @@
{{type.name}}
+