Skip to content
Snippets Groups Projects
Commit 38397824 authored by Tom Robinson's avatar Tom Robinson
Browse files

Lots more metadata editing

parent eb1009a5
No related merge requests found
Showing
with 609 additions and 50 deletions
"use strict";
export default React.createClass({
getInitialState: function() {
return { value: this.props.value };
},
componentWillReceiveProps: function(newProps) {
this.setState({ value: newProps.value });
},
onChange: function(event) {
this.setState({ value: event.target.value });
if (this.props.onChange) {
this.props.onChange(event);
}
},
onBlur: function(event) {
if (this.props.onBlurChange && (this.props.value || "") !== event.target.value) {
this.props.onBlurChange(event);
}
},
render: function() {
return <input {...this.props} value={this.state.value} onBlur={this.onBlur} onChange={this.onChange} />
}
});
'use strict';
/*global _*/
import MetadataHeader from './MetadataHeader.react';
import MetadataTableList from './MetadataTableList.react';
import MetadataTableEditor from './MetadataTableEditor.react';
import MetadataTable from './MetadataTable.react';
import MetadataSchema from './MetadataSchema.react';
export default React.createClass({
displayName: "MetadataEditor",
propTypes: {
databaseId: React.PropTypes.number,
databases: React.PropTypes.array.isRequired,
metabaseApi: React.PropTypes.func.isRequired,
selectDatabaseFn: React.PropTypes.func.isRequired,
selectDatabase: React.PropTypes.func.isRequired,
tableId: React.PropTypes.number,
tables: React.PropTypes.array.isRequired,
selectTable: React.PropTypes.func.isRequired,
updateTable: React.PropTypes.func.isRequired,
updateField: React.PropTypes.func.isRequired
},
getInitialState: function() {
return {
saving: false,
error: null,
isShowingSchema: false
}
},
toggleShowSchema: function() {
this.setState({ isShowingSchema: !this.state.isShowingSchema });
},
updateTable: function(table) {
this.setState({ saving: true });
this.props.updateTable(table).then(() => {
this.setState({ saving: false });
}, (error) => {
this.setState({ saving: false, error: error });
});
},
updateField: function(field) {
this.setState({ saving: true });
this.props.updateField(field).then(() => {
this.setState({ saving: false });
}, (error) => {
this.setState({ saving: false, error: error });
});
},
render: function() {
console.log("render")
var table = _.find(this.props.tables, (t) => t.id === this.props.tableId);
var content;
if (this.state.isShowingSchema) {
content = (
<MetadataSchema
table={table}
metadata={table && this.props.tablesMetadata[table.id]}
updateTable={this.updateTable}
updateField={this.updateField}
/>
);
} else {
content = (
<MetadataTable
table={table}
metadata={table && this.props.tablesMetadata[table.id]}
updateTable={this.updateTable}
updateField={this.updateField}
/>
);
}
return (
<div className="MetadataEditor p3">
<div className="MetadataEditor flex flex-column flex-full p3">
<MetadataHeader
database={this.props.database}
databaseId={this.props.databaseId}
databases={this.props.databases}
selectDatabaseFn={this.props.selectDatabaseFn}
selectDatabase={this.props.selectDatabase}
saving={this.state.saving}
isShowingSchema={this.state.isShowingSchema}
toggleShowSchema={this.toggleShowSchema}
/>
<div className="MetadataEditor-main flex">
<MetadataTableList />
<MetadataTableEditor />
<div className="MetadataEditor-main flex flex-row flex-full mt2">
<MetadataTableList
tableId={this.props.tableId}
tables={this.props.tables}
selectTable={this.props.selectTable}
/>
{content}
</div>
</div>
);
......
"use strict";
import Input from "./Input.react";
export default React.createClass({
displayName: "MetadataField",
propTypes: {
field: React.PropTypes.object,
updateField: React.PropTypes.func.isRequired
},
updateProperty: function(name, value) {
this.props.field[name] = value;
this.props.updateField(this.props.field);
},
onNameChange: function(event) {
this.updateProperty("display_name", event.target.value);
},
onDescriptionChange: function(event) {
this.updateProperty("description", event.target.value);
},
render: function() {
return (
<li className="my1 flex">
<div className="MetadataTable-title flex flex-column flex-full bordered rounded mr1">
<Input className="AdminInput TableEditor-field-name text-bold border-bottom rounded-top" type="text" value={this.props.field.display_name} onBlurChange={this.onNameChange}/>
<Input className="AdminInput TableEditor-field-description rounded-bottom" type="text" value={this.props.field.description} onBlurChange={this.onDescriptionChange} placeholder="No table description yet" />
</div>
<div className="flex-half">
<label className="Select Select--small Select--blue">
<select></select>
</label>
</div>
<div className="flex-half">Details</div>
</li>
)
}
})
......@@ -7,25 +7,24 @@ import Icon from '../../../query_builder/icon.react';
export default React.createClass({
displayName: "MetadataHeader",
propTypes: {
database: React.PropTypes.object.isRequired,
databaseId: React.PropTypes.number,
databases: React.PropTypes.array.isRequired,
selectDatabase: React.PropTypes.func.isRequired,
// metabaseApi: React.PropTypes.func.isRequired,
// isShowingSchema: React.PropTypes.bool.isRequired,
selectDatabaseFn: React.PropTypes.func.isRequired,
// toggleShowSchemaFn: React.PropTypes.func.isRequired,
toggleShowSchema: React.PropTypes.func.isRequired,
},
renderDbSelector: function() {
if (this.props.databases.length > 1) {
var database = this.props.databases.filter((db) => db.id === this.props.database.id)[0];
var database = this.props.databases.filter((db) => db.id === this.props.databaseId)[0];
if (database) {
var columns = [{
selectedItem: database,
items: this.props.databases,
itemTitleFn: (db) => db.name,
itemSelectFn: (db) => {
this.props.selectDatabaseFn(db)
this.props.selectDatabase(db)
this.refs.databasePopover.toggleModal();
console.log("toggle")
}
}];
var tetherOptions = {
......@@ -35,7 +34,7 @@ export default React.createClass({
};
var triggerElement = (
<span className="text-bold cursor-pointer text-default">
{this.props.database.name}
{database.name}
<Icon className="ml1" name="chevrondown" width="8px" height="8px"/>
</span>
);
......@@ -53,13 +52,19 @@ export default React.createClass({
},
render: function() {
var spinner;
if (this.props.saving || true) {
spinner = (<div className="mx2 px2 border-right"><div className="spinner"></div></div>);
}
return (
<div className="MetadataEditor-header flex align-center">
<div className="MetadataEditor-header-section h2">
Edit Metadata for {this.renderDbSelector()}
<span className="text-grey-4">Edit Metadata for</span> {this.renderDbSelector()}
</div>
<div className="MetadataEditor-header-section flex-align-right">
Show original schema {this.props.isShowingSchema}
<div className="MetadataEditor-header-section flex-align-right flex align-center">
{spinner}
<span>Show original schema</span>
<span className={"Toggle " + (this.props.isShowingSchema ? "selected" : "")} onClick={this.props.toggleShowSchema}></span>
</div>
</div>
);
......
'use strict';
/*global _*/
import Input from "./Input.react";
import MetadataField from "./MetadataField.react";
import cx from "classnames";
export default React.createClass({
displayName: "MetadataSchema",
propTypes: {
table: React.PropTypes.object,
metadata: React.PropTypes.object
},
render: function() {
var table = this.props.table;
if (!table) {
return false;
}
var fields;
if (this.props.metadata) {
fields = this.props.metadata.fields.map((field) => {
return (
<li key={field.id} className="px1 py2 flex border-bottom">
<div className="TableEditor-column-column flex flex-column mr1">
<span className="TableEditor-field-name text-bold">{field.name}</span>
</div>
<div className="TableEditor-column-type">
<span className="text-bold">{field.base_type}</span>
</div>
<div className="TableEditor-column-details">
</div>
</li>
);
});
}
return (
<div className="MetadataTable px2 flex-full">
<div className="flex flex-column px1">
<div className="TableEditor-table-name text-bold">{this.props.table.name}</div>
</div>
<div className="mt2 ">
<div className="text-uppercase text-grey-3 py1 flex">
<div className="TableEditor-column-column px1">Column</div>
<div className="TableEditor-column-type px1">Data Type</div>
<div className="TableEditor-column-details px1">Additional Info</div>
</div>
<ol className="border-top border-bottom scroll-y">
{fields}
</ol>
</div>
</div>
);
}
});
'use strict';
/*global _*/
import Input from "./Input.react";
import MetadataField from "./MetadataField.react";
import cx from "classnames";
export default React.createClass({
displayName: "MetadataTable",
propTypes: {
table: React.PropTypes.object,
metadata: React.PropTypes.object,
updateTable: React.PropTypes.func.isRequired,
updateField: React.PropTypes.func.isRequired
},
isHidden: function() {
return !!this.props.table.visibility_type;
},
updateProperty: function(name, value) {
this.props.table[name] = value;
this.setState({ saving: true });
this.props.updateTable(this.props.table);
},
onNameChange: function(event) {
this.updateProperty("display_name", event.target.value);
},
onDescriptionChange: function(event) {
this.updateProperty("description", event.target.value);
},
renderVisibilityType: function(text, type, any) {
var classes = cx("mx1", "text-bold", "text-brand-hover", "cursor-pointer", "text-default", {
"text-brand": this.props.table.visibility_type === type || (any && this.props.table.visibility_type)
});
return <span className={classes} onClick={this.updateProperty.bind(null, "visibility_type", type)}>{text}</span>;
},
renderVisibilityWidget: function() {
var subTypes;
if (this.props.table.visibility_type) {
subTypes = (
<span className="border-left mx2">
<span className="mx2 text-uppercase text-grey-3">Why Hide?</span>
{this.renderVisibilityType("Technical Data", "technical")}
{this.renderVisibilityType("Irrellevant/Cruft", "cruft")}
</span>
);
}
return (
<span>
{this.renderVisibilityType("Queryable", null)}
{this.renderVisibilityType("Hidden", "hidden", true)}
{subTypes}
</span>
);
},
render: function() {
var table = this.props.table;
if (!table) {
return false;
}
var fields;
if (this.props.metadata) {
fields = this.props.metadata.fields.map((field) => {
return <MetadataField key={field.id} field={field} updateField={this.props.updateField} />
});
}
return (
<div className="MetadataTable px2 flex-full">
<div className="MetadataTable-title flex flex-column bordered rounded">
<Input className="AdminInput TableEditor-table-name text-bold border-bottom rounded-top" type="text" value={this.props.table.display_name} onBlurChange={this.onNameChange}/>
<Input className="AdminInput TableEditor-table-description rounded-bottom" type="text" value={this.props.table.description} onBlurChange={this.onDescriptionChange} placeholder="No table description yet" />
</div>
<div className="MetadataTable-header flex align-center py2 text-grey-3">
<span className="mx1 text-uppercase">Visibility</span>
{this.renderVisibilityWidget()}
<span className="text-uppercase flex-align-right">Metadata Strength</span>
</div>
<div className={"mt2 " + (this.isHidden() ? "disabled" : "")}>
<div className="text-uppercase text-grey-3 py1 flex">
<div className="flex-full px1">Column</div>
<div className="flex-half px1">Type</div>
<div className="flex-half px1">Details</div>
</div>
<ol className="border-top border-bottom scroll-y">
{fields}
</ol>
</div>
</div>
);
}
});
'use strict';
export default React.createClass({
displayName: "MetadataTableEditor",
render: function() {
return (
<div className="MetadataEditor-table-editor">
editor
</div>
);
}
});
'use strict';
import cx from 'classnames';
import Humanize from 'humanize';
export default React.createClass({
displayName: "MetadataTableList",
propTypes: {
tableId: React.PropTypes.number,
tables: React.PropTypes.array.isRequired,
selectTable: React.PropTypes.func.isRequired
},
getInitialState: function() {
return {
searchText: null,
searchRegex: null
};
},
updateSearchText: function(event) {
this.setState({
searchText: event.target.value,
searchRegex: event.target.value ? new RegExp(RegExp.escape(event.target.value), "i") : null
});
},
render: function() {
var queryableTablesHeader, hiddenTablesHeader;
var queryableTables = [];
var hiddenTables = [];
if (this.props.tables) {
this.props.tables.forEach((table, index) => {
var classes = cx("AdminList-item", {
"selected": this.props.tableId === table.id
});
var row = (
<li key={table.id} className={classes} onClick={this.props.selectTable.bind(null, table)}>
{table.display_name}
</li>
)
var regex = this.state.searchRegex;
if (!regex || regex.test(table.display_name) || regex.test(table.name)) {
if (table.visibility_type) {
hiddenTables.push(row);
} else {
queryableTables.push(row);
}
}
});
}
if (queryableTables.length > 0) {
queryableTablesHeader = <li className="AdminList-section">{queryableTables.length} Queryable {Humanize.pluralize(queryableTables.length, "Table")}</li>;
}
if (hiddenTables.length > 0) {
hiddenTablesHeader = <li className="AdminList-section">{hiddenTables.length} Hidden {Humanize.pluralize(hiddenTables.length, "Table")}</li>;
}
if (queryableTables.length === 0 && hiddenTables.length === 0) {
queryableTablesHeader = <li className="AdminList-section">0 Tables</li>;
}
return (
<div className="MetadataEditor-table-list">
list
<div className="MetadataEditor-table-list AdminList">
<input
className="AdminList-search AdminInput"
type="text"
placeholder="Find a table"
value={this.state.searchText}
onChange={this.updateSearchText}
/>
<ul className="AdminList-items">
{queryableTablesHeader}
{queryableTables}
{hiddenTablesHeader}
{hiddenTables}
</ul>
</div>
);
}
......
......@@ -9,16 +9,76 @@ angular
'corvus.directives',
'metabase.forms'
])
.controller('MetadataEditor', ['$scope', 'databases', 'Metabase', function($scope, databases, Metabase) {
.controller('MetadataEditor', ['$scope', '$route', '$routeParams', '$location', '$q', '$timeout', 'databases', 'Metabase',
function($scope, $route, $routeParams, $location, $q, $timeout, databases, Metabase) {
// inject the React component to be rendered
$scope.MetadataEditor = MetadataEditor;
$scope.database = databases[0];
$scope.databases = databases;
$scope.metabaseApi = Metabase;
$scope.selectDatabaseFn = function(db) {
$scope.database = db;
$scope.databaseId = null;
$scope.databases = databases;
$scope.tableId = null;
$scope.tables = [];
$scope.tablesMetadata = {};
// mildly hacky way to prevent reloading controllers as the URL changes
var lastRoute = $route.current;
$scope.$on('$locationChangeSuccess', function (event) {
if ($route.current.$$route.controller === 'MetadataEditor') {
var params = $route.current.params;
$route.current = lastRoute;
angular.forEach(params, function(value, key) {
$route.current.params[key] = value;
$routeParams[key] = value;
});
}
});
$scope.routeParams = $routeParams;
$scope.$watch('routeParams', function() {
$scope.databaseId = $routeParams.databaseId ? parseInt($routeParams.databaseId) : null
$scope.tableId = $routeParams.tableId ? parseInt($routeParams.tableId) : null
// default to the first database
if ($scope.databaseId == null && $scope.databases.length > 0) {
$scope.selectDatabase($scope.databases[0]);
}
}, true);
$scope.$watch('databaseId', function() {
Metabase.db_tables({ 'dbId': $scope.databaseId }).$promise
.then(function(tables) {
$scope.tables = tables;
return $q.all($scope.tables.map((table) => {
return Metabase.table_query_metadata({
'tableId': table.id,
'include_sensitive_fields': true
}).$promise.then(function(result) {
$scope.tablesMetadata[table.id] = result;
});
})).then(function() {
$timeout(() => $scope.$digest());
});
}, function(err) {
console.warn("error loading tables", err)
});
}, true);
$scope.selectDatabase = function(db) {
$location.path('/admin/metadata/'+db.id);
};
$scope.selectTable = function(table) {
$location.path('/admin/metadata/'+table.db_id+'/table/'+table.id);
};
$scope.updateTable = function(table) {
return Metabase.table_update(table).$promise;
};
$scope.updateField = function(field) {
return Metabase.field_update(field).$promise;
};
}]);
......@@ -6,13 +6,18 @@ angular
'metabase.admin.metadata.controllers'
])
.config(['$routeProvider', function ($routeProvider) {
$routeProvider.when('/admin/metadata/', {
template: '<div mb-react-component="MetadataEditor"></div>',
var metadataRoute = {
template: '<div class="flex flex-column flex-full" mb-react-component="MetadataEditor"></div>',
controller: 'MetadataEditor',
resolve: {
databases: ['Metabase', function(Metabase) {
return Metabase.db_list().$promise
}]
}
});
};
$routeProvider.when('/admin/metadata', metadataRoute);
$routeProvider.when('/admin/metadata/:databaseId', metadataRoute);
$routeProvider.when('/admin/metadata/:databaseId/:mode', metadataRoute);
$routeProvider.when('/admin/metadata/:databaseId/:mode/:tableId', metadataRoute);
}]);
......@@ -210,5 +210,141 @@
.ScrollShadow {
border-top: 1px solid rgba(0, 0, 0, .05);
box-shadow: 0 -1px 0 rgba(0, 0, 0, .12);
}
.AdminList {
background-color: #F9FBFC;
border: var(--border-size) var(--border-style) var(--border-color);
border-radius: var(--default-border-radius);
width: 266px;
box-shadow: inset -1px -1px 3px rgba(0,0,0,0.05);
padding-bottom: 0.75em;
}
.AdminList-items {
}
.AdminList-item {
padding: 0.75em 1em 0.75em 1em;
border: var(--border-size) var(--border-style) transparent;
border-radius: var(--default-border-radius);
margin-bottom: 0.25em;
}
.AdminList-item.selected {
color: var(--brand-color);
}
.AdminList-item.selected,
.AdminList-item:hover {
background-color: white;
border-color: var(--border-color);
margin-left: -10px;
margin-right: -10px;
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
}
.AdminList-section {
margin-top: 1em;
padding: 0.5em 1em 0.5em 1em;
text-transform: uppercase;
color: color(var(--base-grey) shade(20%));
font-weight: 700;
font-size: smaller;
}
.AdminList-search {
padding: 0.5em;
font-size: 18px;
width: 100%;
border-top-left-radius: var(--default-border-radius);
border-top-right-radius: var(--default-border-radius);
border-bottom-color: var(--border-color);
}
.AdminInput {
color: var(--default-font-color);
padding: 0.5em;
background-color: transparent;
border: 1px solid transparent;
}
.AdminInput:focus {
border-color: var(--brand-color);
box-shadow: none;
outline: 0;
}
.MetadataTable-title {
background-color: #FCFCFC;
}
.TableEditor-table-name {
font-size: 24px;
}
.TableEditor-field-name {
font-size: 16px;
}
.TableEditor-table-description,
.TableEditor-field-description {
font-size: 14px;
}
.spinner {
width: 18px;
height: 18px;
border-radius: 99px;
border: 3px solid rgba(111,122,139,0.25);
border-left: 3px solid rgba(111,122,139,1.0);
animation-name: spin;
animation-duration: 1s;
animation-iteration-count: infinite;
animation-timing-function: linear;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.Toggle {
margin: 0.5em;
width: 48px;
height: 24px;
border-radius: 99px;
border: 1px solid #EAEAEA;
background-color: #F7F7F7;
position: relative;
transition: all 0.3s;
}
.Toggle.selected {
background-color: #33A2FF;
}
.Toggle:after {
content: "";
width: 20px;
height: 20px;
border-radius: 99px;
position: absolute;
top: 1px;
left: 1px;
background-color: #D9D9D9;
transition: all 0.3s;
}
.Toggle.selected:after {
content: "";
width: 20px;
height: 20px;
border-radius: 99px;
position: absolute;
top: 1px;
left: 25px;
background-color: white;
}
......@@ -8,6 +8,10 @@
flex: 1;
}
.flex-half {
flex: 0.5;
}
.align-center {
align-items: center;
}
......
......@@ -133,7 +133,7 @@ CorvusDirectives.directive('mbActionButton', ['$timeout', '$compile', function (
};
}]);
CorvusDirectives.directive('mbReactComponent', [function (){
CorvusDirectives.directive('mbReactComponent', ['$timeout', function ($timeout) {
return {
restrict: 'A',
link: function (scope, element, attr) {
......@@ -145,10 +145,11 @@ CorvusDirectives.directive('mbReactComponent', [function (){
angular.forEach(scope, function(value, key) {
if (typeof value === "function") {
props[key] = function() {
var that = this, args = arguments;
scope.$apply(function() {
return value.apply(that, args);
});
try {
return value.apply(this, arguments);
} finally {
$timeout(() => scope.$digest());
}
}
} else {
props[key] = value;
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment