From 0eb5045fe507c6ad35ad0b873b1a509823bc8a24 Mon Sep 17 00:00:00 2001 From: Tom Robinson <tlrobinson@gmail.com> Date: Mon, 1 Aug 2016 02:03:18 -0700 Subject: [PATCH] =?UTF-8?q?Replace=20Angular=20routing=20with=20react-rout?= =?UTF-8?q?er,=20react-router-redux,=20redux-auth-wrapper.=20=F0=9F=8E=89?= =?UTF-8?q?=F0=9F=8E=89=F0=9F=8E=89=20FINALLY=20=F0=9F=8E=89=F0=9F=8E=89?= =?UTF-8?q?=F0=9F=8E=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/metabase/Routes.jsx | 257 +++++++++------ .../databases/containers/DatabaseEditApp.jsx | 2 +- .../databases/containers/DatabaseListApp.jsx | 8 +- .../components/ObjectActionSelect.jsx | 9 +- .../components/database/MetadataTableList.jsx | 2 +- .../components/database/MetricsList.jsx | 3 +- .../components/database/SegmentsList.jsx | 3 +- .../containers/MetadataEditorApp.jsx | 12 +- .../admin/datamodel/containers/MetricForm.jsx | 3 +- .../containers/RevisionHistoryApp.jsx | 6 +- .../datamodel/containers/SegmentForm.jsx | 3 +- .../src/metabase/admin/datamodel/selectors.js | 23 +- .../admin/people/components/AdminPeople.jsx | 3 +- .../settings/containers/SettingsEditorApp.jsx | 16 +- .../src/metabase/admin/settings/selectors.js | 10 +- .../src/metabase/admin/settings/settings.js | 33 +- frontend/src/metabase/app.js | 312 ++++-------------- frontend/src/metabase/auth/auth.js | 21 +- .../metabase/auth/components/BackToLogin.jsx | 5 +- .../src/metabase/auth/containers/LoginApp.jsx | 22 +- .../auth/containers/PasswordResetApp.jsx | 16 +- .../src/metabase/components/Breadcrumbs.jsx | 3 +- .../src/metabase/components/EmptyState.jsx | 3 +- frontend/src/metabase/components/NotFound.jsx | 8 +- frontend/src/metabase/controllers.js | 33 -- .../dashboard/components/Dashboard.jsx | 7 +- .../dashboard/components/DashboardHeader.jsx | 4 +- .../DashCardCardParameterMapper.jsx | 4 +- .../dashboard/containers/DashboardApp.jsx | 26 +- .../dashboard/containers/ParameterWidget.jsx | 2 +- frontend/src/metabase/dashboard/dashboard.js | 32 +- frontend/src/metabase/dashboard/selectors.js | 2 +- .../src/metabase/home/components/Activity.jsx | 23 +- .../home/components/ActivityStory.jsx | 4 +- .../components/NewUserOnboardingModal.jsx | 3 +- .../src/metabase/home/components/NextStep.jsx | 7 +- .../metabase/home/components/RecentViews.jsx | 3 +- .../metabase/home/containers/HomepageApp.jsx | 9 +- frontend/src/metabase/home/selectors.js | 1 - frontend/src/metabase/lib/card.js | 2 + frontend/src/metabase/lib/cookies.js | 61 ++-- .../metabase/nav/components/ProfileLink.jsx | 21 +- .../nav/containers/DashboardsDropdown.jsx | 5 +- .../src/metabase/nav/containers/Navbar.jsx | 45 +-- frontend/src/metabase/nav/selectors.js | 2 +- .../metabase/pulse/components/PulseEdit.jsx | 3 +- .../pulse/components/PulseListItem.jsx | 7 +- .../pulse/components/SetupMessage.jsx | 3 +- .../pulse/containers/PulseEditApp.jsx | 2 +- .../pulse/containers/PulseListApp.jsx | 2 +- frontend/src/metabase/pulse/selectors.js | 14 +- .../query_builder/QueryVisualization.jsx | 3 +- .../src/metabase/query_builder/actions.js | 95 ++++-- .../query_builder/containers/QueryBuilder.jsx | 10 +- .../src/metabase/query_builder/reducers.js | 5 - frontend/src/metabase/reducers.js | 65 ++++ .../reference/containers/ReferenceApp.jsx | 12 +- .../reference/containers/ReferenceEntity.jsx | 20 +- .../containers/ReferenceEntityList.jsx | 12 +- .../containers/ReferenceFieldsList.jsx | 14 +- .../containers/ReferenceRevisionsList.jsx | 16 +- frontend/src/metabase/reference/selectors.js | 44 +-- frontend/src/metabase/services.js | 211 +----------- .../src/metabase/setup/components/Setup.jsx | 3 +- frontend/src/metabase/store.js | 59 ++++ frontend/src/metabase/user.js | 14 +- frontend/src/metabase/user/actions.js | 6 +- .../src/metabase/visualizations/Scalar.jsx | 3 +- package.json | 13 +- resources/frontend_client/index_template.html | 6 +- src/metabase/api/setup.clj | 4 +- 71 files changed, 786 insertions(+), 939 deletions(-) create mode 100644 frontend/src/metabase/reducers.js create mode 100644 frontend/src/metabase/store.js diff --git a/frontend/src/metabase/Routes.jsx b/frontend/src/metabase/Routes.jsx index 24f7ad54e1c..5b55c9e6492 100644 --- a/frontend/src/metabase/Routes.jsx +++ b/frontend/src/metabase/Routes.jsx @@ -1,7 +1,6 @@ import React, { Component, PropTypes } from "react"; -import { Route } from 'react-router'; -import { ReduxRouter } from 'redux-router'; +import { Route, IndexRoute, IndexRedirect, Redirect } from 'react-router'; // auth containers import ForgotPasswordApp from "metabase/auth/containers/ForgotPasswordApp.jsx"; @@ -45,100 +44,170 @@ import ReferenceGettingStartedGuide from "metabase/reference/containers/Referenc import Navbar from "metabase/nav/containers/Navbar.jsx"; -export default class Routes extends Component { - // this lets us forward props we've injected from the Angular controller - _forwardProps(ComposedComponent, propNames) { - let forwarededProps = {}; - for (const propName of propNames) { - forwarededProps[propName] = this.props[propName]; - } - return (props) => <ComposedComponent {...props} {...forwarededProps} />; - } +import { UserAuthWrapper } from 'redux-auth-wrapper'; + +// START react-router-redux +import { routerActions } from 'react-router-redux'; +const redirectAction = routerActions.replace; +// END react-router-redux + +// START redux-router +// import { push } from 'redux-router'; +// const redirectAction = ({ pathname, query }) => { +// console.log("REDIRECT", pathname, query); +// if (query.redirect) { +// return push(`${pathname}?next=${query.redirect}`) +// } else { +// return push(pathname) +// } +// }; +// END redux-router + +// Create the wrapper that checks if user is authenticated. +const UserIsAuthenticated = UserAuthWrapper({ + // Select the field of the state with auth data + authSelector: state => state.currentUser, + redirectAction: redirectAction, + // Choose the url to redirect not authenticated users. + failureRedirectPath: '/auth/login', + wrapperDisplayName: 'UserIsAuthenticated' +}) + +// Do the same to create the wrapper that checks if user is NOT authenticated. +const UserIsNotAuthenticated = UserAuthWrapper({ + authSelector: state => state.currentUser, + redirectAction: redirectAction, + failureRedirectPath: '/', + // Choose what exactly you need to check in the selected field of the state + // (in the previous wrapper it checks by default). + predicate: currentUser => !currentUser, + wrapperDisplayName: 'UserIsNotAuthenticated' +}) + +import { connect } from "react-redux"; +import { push } from "react-router-redux"; + +function FIXME_forwardOnChangeLocation(Component) { + return connect(null, { onChangeLocation: push })(Component) +} + +const NotAuthenticated = UserIsNotAuthenticated(({ children }) => children); +const Authenticated = UserIsAuthenticated(({ children }) => children); +class App extends Component { + componentWillMount() { + console.log('will mount app') + } render() { + const { children, location } = this.props; return ( - <ReduxRouter> - <Route component={({ children }) => - <div className="spread flex flex-column"> - <Navbar className="flex-no-shrink" onChangeLocation={this.props.onChangeLocation} /> - {children} - </div> - }> - <Route path="/" component={this._forwardProps(HomepageApp, ["onChangeLocation"])} /> - - <Route path="/admin"> - <Route path="databases" component={DatabaseListApp} /> - <Route path="databases/create" component={this._forwardProps(DatabaseEditApp, ["onChangeLocation"])} /> - <Route path="databases/:databaseId" component={this._forwardProps(DatabaseEditApp, ["onChangeLocation"])} /> - - <Route path="datamodel"> - <Route path="database" component={this._forwardProps(MetadataEditorApp, ["onChangeLocation"])} /> - <Route path="database/:databaseId" component={this._forwardProps(MetadataEditorApp, ["onChangeLocation"])} /> - <Route path="database/:databaseId/:mode" component={this._forwardProps(MetadataEditorApp, ["onChangeLocation"])} /> - <Route path="database/:databaseId/:mode/:tableId" component={this._forwardProps(MetadataEditorApp, ["onChangeLocation"])} /> - <Route path="metric/create" component={this._forwardProps(MetricApp, ["onChangeLocation"])} /> - <Route path="metric/:id" component={this._forwardProps(MetricApp, ["onChangeLocation"])} /> - <Route path="segment/create" component={this._forwardProps(SegmentApp, ["onChangeLocation"])} /> - <Route path="segment/:id" component={this._forwardProps(SegmentApp, ["onChangeLocation"])} /> - <Route path=":entity/:id/revisions" component={RevisionHistoryApp} /> - </Route> - - <Route path="people" component={this._forwardProps(AdminPeopleApp, ["onChangeLocation"])} /> - <Route path="settings" component={this._forwardProps(SettingsEditorApp, ["refreshSiteSettings"])} /> - </Route> - - <Route path="/reference" component={ReferenceApp}> - <Route path="guide" component={ReferenceGettingStartedGuide} /> - <Route path="metrics" component={ReferenceEntityList} /> - <Route path="metrics/:metricId" component={ReferenceEntity} /> - <Route path="metrics/:metricId/questions" component={ReferenceEntityList} /> - <Route path="metrics/:metricId/revisions" component={ReferenceRevisionsList} /> - <Route path="segments" component={ReferenceEntityList} /> - <Route path="segments/:segmentId" component={ReferenceEntity} /> - <Route path="segments/:segmentId/fields" component={ReferenceFieldsList} /> - <Route path="segments/:segmentId/fields/:fieldId" component={ReferenceEntity} /> - <Route path="segments/:segmentId/questions" component={ReferenceEntityList} /> - <Route path="segments/:segmentId/revisions" component={ReferenceRevisionsList} /> - <Route path="databases" component={ReferenceEntityList} /> - <Route path="databases/:databaseId" component={ReferenceEntity} /> - <Route path="databases/:databaseId/tables" component={ReferenceEntityList} /> - <Route path="databases/:databaseId/tables/:tableId" component={ReferenceEntity} /> - <Route path="databases/:databaseId/tables/:tableId/fields" component={ReferenceFieldsList} /> - <Route path="databases/:databaseId/tables/:tableId/fields/:fieldId" component={ReferenceEntity} /> - <Route path="databases/:databaseId/tables/:tableId/questions" component={ReferenceEntityList} /> - </Route> - - <Route path="/auth"> - <Route path="forgot_password" component={ForgotPasswordApp} /> - <Route path="login" component={this._forwardProps(LoginApp, ["onChangeLocation"])} /> - <Route path="logout" component={this._forwardProps(LogoutApp, ["onChangeLocation"])} /> - <Route path="reset_password/:token" component={this._forwardProps(PasswordResetApp, ["onChangeLocation"])} /> - <Route path="google_no_mb_account" component={GoogleNoAccount} /> - </Route> - - <Route path="/dash/:dashboardId" component={this._forwardProps(DashboardApp, ["onChangeLocation"])} /> - - <Route path="/pulse" component={this._forwardProps(PulseListApp, ["onChangeLocation"])} /> - <Route path="/pulse/create" component={this._forwardProps(PulseEditApp, ["onChangeLocation"])} /> - <Route path="/pulse/:pulseId" component={this._forwardProps(PulseEditApp, ["onChangeLocation"])} /> - - <Route path="/card/:cardId" component={this._forwardProps(QueryBuilder, ["onChangeLocation", "updateUrl"])} /> - <Route path="/q" component={this._forwardProps(QueryBuilder, ["onChangeLocation", "updateUrl"])} /> - - <Route path="/questions" component={EntityBrowser}> - <Route path="edit/labels" component={EditLabels} /> - <Route path=":section" component={EntityList} /> - <Route path=":section/:slug" component={EntityList} /> - </Route> - - <Route path="/setup" component={SetupApp} /> - - <Route path="/user/edit_current" component={UserSettingsApp} /> - - <Route path="/unauthorized" component={Unauthorized} /> - <Route path="/*" component={NotFound} /> - </Route> - </ReduxRouter> - ); + <div className="spread flex flex-column"> + <Navbar location={location} className="flex-no-shrink" /> + {children} + </div> + ) } } + +const Routes = + <Route component={App}> + {/* AUTH */} + <Route path="/auth"> + <IndexRedirect to="/auth/login" /> + <Route path="forgot_password" component={ForgotPasswordApp} /> + <Route path="login" component={LoginApp} /> + <Route path="logout" component={LogoutApp} /> + <Route path="reset_password/:token" component={PasswordResetApp} /> + <Route path="google_no_mb_account" component={GoogleNoAccount} /> + </Route> + + {/* SETUP */} + <Route path="/setup" component={SetupApp} /> + + {/* MAIN */} + <Route component={Authenticated}> + {/* HOME */} + <Route path="/" component={HomepageApp} /> + + {/* DASHBOARD */} + <Route path="/dash/:dashboardId" component={FIXME_forwardOnChangeLocation(DashboardApp)} /> + + {/* QUERY BUILDER */} + <Route path="/card/:cardId" component={FIXME_forwardOnChangeLocation(QueryBuilder, ["onChangeLocation", "updateUrl"])} /> + <Route path="/q" component={FIXME_forwardOnChangeLocation(QueryBuilder, ["onChangeLocation", "updateUrl"])} /> + + {/* QUESTIONS */} + <Route path="/questions" component={EntityBrowser}> + <Route path="edit/labels" component={EditLabels} /> + <Route path=":section" component={EntityList} /> + <Route path=":section/:slug" component={EntityList} /> + </Route> + + {/* REFERENCE */} + <Route path="/reference" component={ReferenceApp}> + <IndexRedirect to="/reference/guide" /> + <Route path="guide" component={ReferenceGettingStartedGuide} /> + <Route path="metrics" component={ReferenceEntityList} /> + <Route path="metrics/:metricId" component={ReferenceEntity} /> + <Route path="metrics/:metricId/questions" component={ReferenceEntityList} /> + <Route path="metrics/:metricId/revisions" component={ReferenceRevisionsList} /> + <Route path="segments" component={ReferenceEntityList} /> + <Route path="segments/:segmentId" component={ReferenceEntity} /> + <Route path="segments/:segmentId/fields" component={ReferenceFieldsList} /> + <Route path="segments/:segmentId/fields/:fieldId" component={ReferenceEntity} /> + <Route path="segments/:segmentId/questions" component={ReferenceEntityList} /> + <Route path="segments/:segmentId/revisions" component={ReferenceRevisionsList} /> + <Route path="databases" component={ReferenceEntityList} /> + <Route path="databases/:databaseId" component={ReferenceEntity} /> + <Route path="databases/:databaseId/tables" component={ReferenceEntityList} /> + <Route path="databases/:databaseId/tables/:tableId" component={ReferenceEntity} /> + <Route path="databases/:databaseId/tables/:tableId/fields" component={ReferenceFieldsList} /> + <Route path="databases/:databaseId/tables/:tableId/fields/:fieldId" component={ReferenceEntity} /> + <Route path="databases/:databaseId/tables/:tableId/questions" component={ReferenceEntityList} /> + </Route> + + {/* PULSE */} + <Route path="/pulse" component={FIXME_forwardOnChangeLocation(PulseListApp)} /> + <Route path="/pulse/create" component={FIXME_forwardOnChangeLocation(PulseEditApp)} /> + <Route path="/pulse/:pulseId" component={FIXME_forwardOnChangeLocation(PulseEditApp)} /> + + {/* USER */} + <Route path="/user/edit_current" component={UserSettingsApp} /> + </Route> + + {/* ADMIN */} + <Route path="/admin" component={Authenticated}> + <IndexRedirect to="/admin/settings" /> + <Route path="databases" component={DatabaseListApp} /> + <Route path="databases/create" component={FIXME_forwardOnChangeLocation(DatabaseEditApp)} /> + <Route path="databases/:databaseId" component={FIXME_forwardOnChangeLocation(DatabaseEditApp)} /> + + <Route path="datamodel"> + <Route path="database" component={FIXME_forwardOnChangeLocation(MetadataEditorApp)} /> + <Route path="database/:databaseId" component={FIXME_forwardOnChangeLocation(MetadataEditorApp)} /> + <Route path="database/:databaseId/:mode" component={FIXME_forwardOnChangeLocation(MetadataEditorApp)} /> + <Route path="database/:databaseId/:mode/:tableId" component={FIXME_forwardOnChangeLocation(MetadataEditorApp)} /> + <Route path="metric/create" component={FIXME_forwardOnChangeLocation(MetricApp)} /> + <Route path="metric/:id" component={FIXME_forwardOnChangeLocation(MetricApp)} /> + <Route path="segment/create" component={FIXME_forwardOnChangeLocation(SegmentApp)} /> + <Route path="segment/:id" component={FIXME_forwardOnChangeLocation(SegmentApp)} /> + <Route path=":entity/:id/revisions" component={RevisionHistoryApp} /> + </Route> + + <Route path="people" component={FIXME_forwardOnChangeLocation(AdminPeopleApp)} /> + + <Route path="settings" component={SettingsEditorApp} /> + <Route path="settings/:section" component={SettingsEditorApp} /> + </Route> + + {/* MISC */} + <Route path="/unauthorized" component={Unauthorized} /> + <Route path="/*" component={NotFound} /> + + {/* LEGACY */} + <Redirect from="/card" to="/questions" /> + <Redirect from="/card/:cardId/:serializedCard" to="/questions/:cardId#:serializedCard" /> + <Redirect from="/q/:serializedCard" to="/q#:serializedCard" /> + </Route> + +export default Routes; diff --git a/frontend/src/metabase/admin/databases/containers/DatabaseEditApp.jsx b/frontend/src/metabase/admin/databases/containers/DatabaseEditApp.jsx index 946268410de..3982bac77fb 100644 --- a/frontend/src/metabase/admin/databases/containers/DatabaseEditApp.jsx +++ b/frontend/src/metabase/admin/databases/containers/DatabaseEditApp.jsx @@ -17,7 +17,7 @@ import * as databaseActions from "../database"; const mapStateToProps = (state, props) => { return { - databaseId: state.router && state.router.params && state.router.params.databaseId, + databaseId: props.params.databaseId, database: getEditingDatabase(state), formState: getFormState(state), onChangeLocation: props.onChangeLocation diff --git a/frontend/src/metabase/admin/databases/containers/DatabaseListApp.jsx b/frontend/src/metabase/admin/databases/containers/DatabaseListApp.jsx index d171b25d173..c8ba614bf04 100644 --- a/frontend/src/metabase/admin/databases/containers/DatabaseListApp.jsx +++ b/frontend/src/metabase/admin/databases/containers/DatabaseListApp.jsx @@ -1,5 +1,7 @@ import React, { Component, PropTypes } from "react"; import { connect } from "react-redux"; +import { Link } from "react-router"; + import cx from "classnames"; import MetabaseSettings from "metabase/lib/settings"; import ModalWithTrigger from "metabase/components/ModalWithTrigger.jsx"; @@ -17,7 +19,7 @@ import * as databaseActions from "../database"; const mapStateToProps = (state, props) => { return { - created: state.router && state.router.params && state.router.params.created, + created: props.params.created, databases: getDatabasesSorted(state), hasSampleDataset: hasSampleDataset(state), engines: MetabaseSettings.get('engines') @@ -46,7 +48,7 @@ export default class DatabaseList extends Component { return ( <div className="wrapper"> <section className="PageHeader px2 clearfix"> - <a className="Button Button--primary float-right" href="/admin/databases/create">Add database</a> + <Link to="/admin/databases/create" className="Button Button--primary float-right">Add database</Link> <h2 className="PageTitle">Databases</h2> </section> <section> @@ -63,7 +65,7 @@ export default class DatabaseList extends Component { databases.map(database => <tr key={database.id}> <td> - <a className="text-bold link" href={"/admin/databases/"+database.id}>{database.name}</a> + <Link to={"/admin/databases/"+database.id} className="text-bold link">{database.name}</Link> </td> <td> {engines && engines[database.engine] ? engines[database.engine]['driver-name'] : database.engine} diff --git a/frontend/src/metabase/admin/datamodel/components/ObjectActionSelect.jsx b/frontend/src/metabase/admin/datamodel/components/ObjectActionSelect.jsx index 945e45c5f5a..9910e1a11a7 100644 --- a/frontend/src/metabase/admin/datamodel/components/ObjectActionSelect.jsx +++ b/frontend/src/metabase/admin/datamodel/components/ObjectActionSelect.jsx @@ -1,4 +1,5 @@ import React, { Component, PropTypes } from "react"; +import { Link } from "react-router"; import Icon from "metabase/components/Icon.jsx"; import PopoverWithTrigger from "metabase/components/PopoverWithTrigger.jsx"; @@ -30,14 +31,14 @@ export default class ObjectActionsSelect extends Component { > <ul className="UserActionsSelect"> <li> - <a data-metabase-event={"Data Model;"+objectType+" Edit Page"}href={"/admin/datamodel/" + objectType + "/" + object.id} className="py1 px2 block bg-brand-hover text-white-hover no-decoration cursor-pointer"> + <Link to={"/admin/datamodel/" + objectType + "/" + object.id} data-metabase-event={"Data Model;"+objectType+" Edit Page"} className="py1 px2 block bg-brand-hover text-white-hover no-decoration cursor-pointer"> Edit {capitalize(objectType)} - </a> + </Link> </li> <li> - <a data-metabase-event={"Data Model;"+objectType+" History"} href={"/admin/datamodel/" + objectType + "/" + object.id + "/revisions"} className="py1 px2 block bg-brand-hover text-white-hover no-decoration cursor-pointer"> + <Link to={"/admin/datamodel/" + objectType + "/" + object.id + "/revisions"} data-metabase-event={"Data Model;"+objectType+" History"} className="py1 px2 block bg-brand-hover text-white-hover no-decoration cursor-pointer"> Revision History - </a> + </Link> </li> <li className="mt1 border-top"> <ModalWithTrigger diff --git a/frontend/src/metabase/admin/datamodel/components/database/MetadataTableList.jsx b/frontend/src/metabase/admin/datamodel/components/database/MetadataTableList.jsx index f7932ad14f9..c33d7bfe0eb 100644 --- a/frontend/src/metabase/admin/datamodel/components/database/MetadataTableList.jsx +++ b/frontend/src/metabase/admin/datamodel/components/database/MetadataTableList.jsx @@ -43,7 +43,7 @@ export default class MetadataTableList extends Component { _.each(tables, (table) => { var row = ( <li key={table.id}> - <a href="#" className={cx("AdminList-item flex align-center no-decoration", { selected: this.props.tableId === table.id })} onClick={this.props.selectTable.bind(null, table)}> + <a className={cx("AdminList-item flex align-center no-decoration", { selected: this.props.tableId === table.id })} onClick={this.props.selectTable.bind(null, table)}> {table.display_name} <ProgressBar className="ProgressBar ProgressBar--mini flex-align-right" percentage={table.metadataStrength} /> </a> diff --git a/frontend/src/metabase/admin/datamodel/components/database/MetricsList.jsx b/frontend/src/metabase/admin/datamodel/components/database/MetricsList.jsx index 06612a95f6c..a8e5b6d2097 100644 --- a/frontend/src/metabase/admin/datamodel/components/database/MetricsList.jsx +++ b/frontend/src/metabase/admin/datamodel/components/database/MetricsList.jsx @@ -1,4 +1,5 @@ import React, { Component, PropTypes } from "react"; +import { Link } from "react-router"; import MetricItem from "./MetricItem.jsx"; @@ -18,7 +19,7 @@ export default class MetricsList extends Component { <div className="my3"> <div className="flex mb1"> <h2 className="px1 text-green">Metrics</h2> - <a data-metabase-event="Data Model;Add Metric Page" className="flex-align-right float-right text-bold text-brand no-decoration" href={"/admin/datamodel/metric/create?table="+tableMetadata.id}>+ Add a Metric</a> + <Link to={"/admin/datamodel/metric/create?table="+tableMetadata.id} data-metabase-event="Data Model;Add Metric Page" className="flex-align-right float-right text-bold text-brand no-decoration">+ Add a Metric</Link> </div> <table className="AdminTable"> <thead> diff --git a/frontend/src/metabase/admin/datamodel/components/database/SegmentsList.jsx b/frontend/src/metabase/admin/datamodel/components/database/SegmentsList.jsx index 9d977347755..c53d38e100c 100644 --- a/frontend/src/metabase/admin/datamodel/components/database/SegmentsList.jsx +++ b/frontend/src/metabase/admin/datamodel/components/database/SegmentsList.jsx @@ -1,4 +1,5 @@ import React, { Component, PropTypes } from "react"; +import { Link } from "react-router"; import SegmentItem from "./SegmentItem.jsx"; @@ -18,7 +19,7 @@ export default class SegmentsList extends Component { <div className="my3"> <div className="flex mb1"> <h2 className="px1 text-purple">Segments</h2> - <a data-metabase-event="Data Model;Add Segment Page" className="flex-align-right float-right text-bold text-brand no-decoration" href={"/admin/datamodel/segment/create?table="+tableMetadata.id}>+ Add a Segment</a> + <Link to={"/admin/datamodel/segment/create?table="+tableMetadata.id} data-metabase-event="Data Model;Add Segment Page" className="flex-align-right float-right text-bold text-brand no-decoration">+ Add a Segment</Link> </div> <table className="AdminTable"> <thead> diff --git a/frontend/src/metabase/admin/datamodel/containers/MetadataEditorApp.jsx b/frontend/src/metabase/admin/datamodel/containers/MetadataEditorApp.jsx index 2961405399f..bee7bd4b21f 100644 --- a/frontend/src/metabase/admin/datamodel/containers/MetadataEditorApp.jsx +++ b/frontend/src/metabase/admin/datamodel/containers/MetadataEditorApp.jsx @@ -21,13 +21,13 @@ import * as metadataActions from "../metadata"; const mapStateToProps = (state, props) => { return { - databaseId: state.router && state.router.params && parseInt(state.router.params.databaseId), - tableId: state.router && state.router.params && parseInt(state.router.params.tableId), + databaseId: parseInt(props.params.databaseId), + tableId: parseInt(props.params.tableId), onChangeLocation: props.onChangeLocation, - databases: getDatabases(state), - idfields: getDatabaseIdfields(state), - databaseMetadata: getEditingDatabaseWithTableMetadataStrengths(state), - editingTable: getEditingTable(state) + databases: getDatabases(state, props), + idfields: getDatabaseIdfields(state, props), + databaseMetadata: getEditingDatabaseWithTableMetadataStrengths(state, props), + editingTable: getEditingTable(state, props) } } diff --git a/frontend/src/metabase/admin/datamodel/containers/MetricForm.jsx b/frontend/src/metabase/admin/datamodel/containers/MetricForm.jsx index 185af29564d..2913ed122cd 100644 --- a/frontend/src/metabase/admin/datamodel/containers/MetricForm.jsx +++ b/frontend/src/metabase/admin/datamodel/containers/MetricForm.jsx @@ -1,4 +1,5 @@ import React, { Component, PropTypes } from "react"; +import { Link } from "react-router"; import FormLabel from "../components/FormLabel.jsx"; import FormInput from "../components/FormInput.jsx"; @@ -53,7 +54,7 @@ export default class MetricForm extends Component { return ( <div> <button className={cx("Button", { "Button--primary": !invalid, "disabled": invalid })} onClick={handleSubmit}>Save changes</button> - <a className="Button Button--borderless mx1" href={"/admin/datamodel/database/" + tableMetadata.db_id + "/table/" + tableMetadata.id}>Cancel</a> + <Link to={"/admin/datamodel/database/" + tableMetadata.db_id + "/table/" + tableMetadata.id} className="Button Button--borderless mx1">Cancel</Link> </div> ) } diff --git a/frontend/src/metabase/admin/datamodel/containers/RevisionHistoryApp.jsx b/frontend/src/metabase/admin/datamodel/containers/RevisionHistoryApp.jsx index 76e9f944f95..80dab1ccb99 100644 --- a/frontend/src/metabase/admin/datamodel/containers/RevisionHistoryApp.jsx +++ b/frontend/src/metabase/admin/datamodel/containers/RevisionHistoryApp.jsx @@ -9,9 +9,9 @@ import { connect } from "react-redux"; const mapStateToProps = (state, props) => { return { - ...revisionHistorySelectors(state), - entity: state.router && state.router.params && state.router.params.entity, - id: state.router && state.router.params && state.router.params.id + ...revisionHistorySelectors(state, props), + entity: props.params.entity, + id: props.params.id } } diff --git a/frontend/src/metabase/admin/datamodel/containers/SegmentForm.jsx b/frontend/src/metabase/admin/datamodel/containers/SegmentForm.jsx index a0e41a11ffa..09f324407fa 100644 --- a/frontend/src/metabase/admin/datamodel/containers/SegmentForm.jsx +++ b/frontend/src/metabase/admin/datamodel/containers/SegmentForm.jsx @@ -1,4 +1,5 @@ import React, { Component, PropTypes } from "react"; +import { Link } from "react-router"; import FormLabel from "../components/FormLabel.jsx"; import FormInput from "../components/FormInput.jsx"; @@ -54,7 +55,7 @@ export default class SegmentForm extends Component { return ( <div> <button className={cx("Button", { "Button--primary": !invalid, "disabled": invalid })} onClick={handleSubmit}>Save changes</button> - <a className="Button Button--borderless mx1" href={"/admin/datamodel/database/" + tableMetadata.db_id + "/table/" + tableMetadata.id}>Cancel</a> + <Link to={"/admin/datamodel/database/" + tableMetadata.db_id + "/table/" + tableMetadata.id} className="Button Button--borderless mx1">Cancel</Link> </div> ) } diff --git a/frontend/src/metabase/admin/datamodel/selectors.js b/frontend/src/metabase/admin/datamodel/selectors.js index 189c0caab3b..8feb9612b04 100644 --- a/frontend/src/metabase/admin/datamodel/selectors.js +++ b/frontend/src/metabase/admin/datamodel/selectors.js @@ -3,17 +3,17 @@ import { createSelector } from 'reselect'; import { computeMetadataStrength } from "metabase/lib/schema_metadata"; -const segmentsSelector = state => state.datamodel.segments; -const metricsSelector = state => state.datamodel.metrics; +const segmentsSelector = (state, props) => state.datamodel.segments; +const metricsSelector = (state, props) => state.datamodel.metrics; -const tableMetadataSelector = state => state.datamodel.tableMetadata; -const previewSummarySelector = state => state.datamodel.previewSummary; -const revisionObjectSelector = state => state.datamodel.revisionObject; +const tableMetadataSelector = (state, props) => state.datamodel.tableMetadata; +const previewSummarySelector = (state, props) => state.datamodel.previewSummary; +const revisionObjectSelector = (state, props) => state.datamodel.revisionObject; -const idSelector = state => state.router.params.id == null ? null : parseInt(state.router.params.id); -const tableIdSelector = state => state.router.location.query.table == null ? null : parseInt(state.router.location.query.table); +const idSelector = (state, props) => props.params.id == null ? null : parseInt(props.params.id); +const tableIdSelector = (state, props) => props.location.query.table == null ? null : parseInt(props.location.query.table); -const userSelector = state => state.currentUser; +const userSelector = (state, props) => state.currentUser; export const segmentEditSelectors = createSelector( segmentsSelector, @@ -73,9 +73,9 @@ export const revisionHistorySelectors = createSelector( ); -export const getDatabases = state => state.datamodel.databases; -export const getDatabaseIdfields = state => state.datamodel.idfields; -export const getEditingTable = state => state.datamodel.editingTable; +export const getDatabases = (state, props) => state.datamodel.databases; +export const getDatabaseIdfields = (state, props) => state.datamodel.idfields; +export const getEditingTable = (state, props) => state.datamodel.editingTable; export const getEditingDatabaseWithTableMetadataStrengths = createSelector( @@ -93,4 +93,3 @@ export const getEditingDatabaseWithTableMetadataStrengths = createSelector( return database; } ); - diff --git a/frontend/src/metabase/admin/people/components/AdminPeople.jsx b/frontend/src/metabase/admin/people/components/AdminPeople.jsx index 237533ea7f0..3d7089be92a 100644 --- a/frontend/src/metabase/admin/people/components/AdminPeople.jsx +++ b/frontend/src/metabase/admin/people/components/AdminPeople.jsx @@ -1,4 +1,5 @@ import React, { Component, PropTypes } from "react"; +import { Link } from "react-router"; import _ from "underscore"; import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper.jsx"; @@ -183,7 +184,7 @@ export default class AdminPeople extends Component { <PasswordReveal password={user.password} /> - <div style={{paddingLeft: "5em", paddingRight: "5em"}} className="pt4 text-centered">If you want to be able to send email invites, just go to the <a className="link text-bold" href="/admin/settings/?section=Email">Email Settings</a> page.</div> + <div style={{paddingLeft: "5em", paddingRight: "5em"}} className="pt4 text-centered">If you want to be able to send email invites, just go to the <Link to="/admin/settings/email" className="link text-bold">Email Settings</Link> page.</div> </div> <div className="Form-actions"> diff --git a/frontend/src/metabase/admin/settings/containers/SettingsEditorApp.jsx b/frontend/src/metabase/admin/settings/containers/SettingsEditorApp.jsx index 393b26ff60c..d9610291ad7 100644 --- a/frontend/src/metabase/admin/settings/containers/SettingsEditorApp.jsx +++ b/frontend/src/metabase/admin/settings/containers/SettingsEditorApp.jsx @@ -1,4 +1,5 @@ import React, { Component, PropTypes } from "react"; +import { Link } from "react-router"; import { connect } from "react-redux"; import MetabaseAnalytics from "metabase/lib/analytics"; @@ -24,11 +25,10 @@ import * as settingsActions from "../settings"; const mapStateToProps = (state, props) => { return { - refreshSiteSettings: props.refreshSiteSettings, - settings: getSettings(state), - sections: getSections(state), - activeSection: getActiveSection(state), - newVersionAvailable: getNewVersionAvailable(state) + settings: getSettings(state, props), + sections: getSections(state, props), + activeSection: getActiveSection(state, props), + newVersionAvailable: getNewVersionAvailable(state, props) } } @@ -54,7 +54,7 @@ export default class SettingsEditorApp extends Component { }; componentWillMount() { - this.props.initializeSettings(this.props.refreshSiteSettings); + this.props.initializeSettings(); } updateSetting(setting, value) { @@ -160,10 +160,10 @@ export default class SettingsEditorApp extends Component { return ( <li key={section.name}> - <a href={"/admin/settings/?section=" + section.name} className={classes}> + <Link to={"/admin/settings/" + section.slug} className={classes}> <span>{section.name}</span> {newVersionIndicator} - </a> + </Link> </li> ); }); diff --git a/frontend/src/metabase/admin/settings/selectors.js b/frontend/src/metabase/admin/settings/selectors.js index b064ccb015a..293f97b075a 100644 --- a/frontend/src/metabase/admin/settings/selectors.js +++ b/frontend/src/metabase/admin/settings/selectors.js @@ -2,6 +2,7 @@ import _ from "underscore"; import { createSelector } from "reselect"; import MetabaseSettings from "metabase/lib/settings"; +import { slugify } from "metabase/lib/formatting"; const SECTIONS = [ { @@ -149,6 +150,9 @@ const SECTIONS = [ ] } ]; +for (const section of SECTIONS) { + section.slug = slugify(section.name); +} export const getSettings = state => state.settings.settings; @@ -183,14 +187,14 @@ export const getSections = createSelector( } ); -export const getActiveSectionName = (state) => state.router && state.router.location && state.router.location.query.section +export const getActiveSectionName = (state, props) => props.params.section export const getActiveSection = createSelector( getActiveSectionName, getSections, - (section = "Setup", sections) => { + (section = "setup", sections) => { if (sections) { - return _.findWhere(sections, {name: section}); + return _.findWhere(sections, { slug: section }); } else { return null; } diff --git a/frontend/src/metabase/admin/settings/settings.js b/frontend/src/metabase/admin/settings/settings.js index 48987fce6e7..49ad04b4714 100644 --- a/frontend/src/metabase/admin/settings/settings.js +++ b/frontend/src/metabase/admin/settings/settings.js @@ -1,12 +1,20 @@ import { handleActions, combineReducers, AngularResourceProxy, createThunkAction } from "metabase/lib/redux"; +import MetabaseSettings from 'metabase/lib/settings'; + +import _ from "underscore"; // resource wrappers const SettingsApi = new AngularResourceProxy("Settings", ["list", "put"]); const EmailApi = new AngularResourceProxy("Email", ["updateSettings", "sendTest"]); const SlackApi = new AngularResourceProxy("Slack", ["updateSettings"]); +const SessionApi = new AngularResourceProxy("Session", ["properties"]); +async function refreshSiteSettings() { + const settings = await SessionApi.properties(); + MetabaseSettings.setAll(_.omit(settings, (value, key) => key.indexOf('$') === 0)); +} async function loadSettings() { try { @@ -22,14 +30,10 @@ async function loadSettings() { } // initializeSettings -export const initializeSettings = createThunkAction("INITIALIZE_SETTINGS", function(refreshSiteSettings) { +export const initializeSettings = createThunkAction("INITIALIZE_SETTINGS", function() { return async function(dispatch, getState) { try { - let settings = await loadSettings(); - return { - settings, - refreshSiteSettings - } + return await loadSettings(); } catch(error) { console.log("error fetching settings", error); throw error; @@ -40,8 +44,6 @@ export const initializeSettings = createThunkAction("INITIALIZE_SETTINGS", funct // updateSetting export const updateSetting = createThunkAction("UPDATE_SETTING", function(setting) { return async function(dispatch, getState) { - const { settings: { refreshSiteSettings } } = getState(); - try { await SettingsApi.put({ key: setting.key }, setting); refreshSiteSettings(); @@ -56,8 +58,6 @@ export const updateSetting = createThunkAction("UPDATE_SETTING", function(settin // updateEmailSettings export const updateEmailSettings = createThunkAction("UPDATE_EMAIL_SETTINGS", function(settings) { return async function(dispatch, getState) { - const { settings: { refreshSiteSettings } } = getState(); - try { await EmailApi.updateSettings(settings); refreshSiteSettings(); @@ -84,8 +84,6 @@ export const sendTestEmail = createThunkAction("SEND_TEST_EMAIL", function() { // updateSlackSettings export const updateSlackSettings = createThunkAction("UPDATE_SLACK_SETTINGS", function(settings) { return async function(dispatch, getState) { - const { settings: { refreshSiteSettings } } = getState(); - try { await SlackApi.updateSettings(settings); refreshSiteSettings(); @@ -97,22 +95,15 @@ export const updateSlackSettings = createThunkAction("UPDATE_SLACK_SETTINGS", fu }; }); - // reducers -// this is a backwards compatibility thing with angular to allow programmatic route changes. remove/change this when going to ReduxRouter -const refreshSiteSettings = handleActions({ - ["INITIALIZE_SETTINGS"]: { next: (state, { payload }) => payload ? payload.refreshSiteSettings : state } -}, () => null); - const settings = handleActions({ - ["INITIALIZE_SETTINGS"]: { next: (state, { payload }) => payload ? payload.settings : state }, + ["INITIALIZE_SETTINGS"]: { next: (state, { payload }) => payload }, ["UPDATE_SETTING"]: { next: (state, { payload }) => payload }, ["UPDATE_EMAIL_SETTINGS"]: { next: (state, { payload }) => payload }, ["UPDATE_SLACK_SETTINGS"]: { next: (state, { payload }) => payload } }, []); export default combineReducers({ - settings, - refreshSiteSettings + settings }); diff --git a/frontend/src/metabase/app.js b/frontend/src/metabase/app.js index b7ef264975b..bb9e414c02e 100644 --- a/frontend/src/metabase/app.js +++ b/frontend/src/metabase/app.js @@ -2,263 +2,77 @@ import 'babel-polyfill'; +import { registerAnalyticsClickListener } from "metabase/lib/analytics"; + // angular: import 'angular'; -import 'angular-cookies'; import 'angular-resource'; -import 'angular-route'; - -// angular 3rd-party: import 'angular-cookie'; -import 'angular-http-auth'; - -import "./controllers"; import "./services"; -import React from "react"; -import ReactDOM from "react-dom"; - -import { Provider } from 'react-redux'; - -import { combineReducers } from "redux"; -import { reducer as form } from "redux-form"; -import { reduxReactRouter, routerStateReducer } from "redux-router"; - -import Routes from "./Routes.jsx"; - -import auth from "metabase/auth/auth"; - -/* ducks */ -import metadata from "metabase/redux/metadata"; -import requests from "metabase/redux/requests"; - -/* admin */ -import settings from "metabase/admin/settings/settings"; -import * as people from "metabase/admin/people/reducers"; -import databases from "metabase/admin/databases/database"; -import datamodel from "metabase/admin/datamodel/metadata"; - -/* dashboards */ -import dashboard from "metabase/dashboard/dashboard"; -import * as home from "metabase/home/reducers"; - -/* questions / query builder */ -import questions from "metabase/questions/questions"; -import labels from "metabase/questions/labels"; -import undo from "metabase/questions/undo"; -import * as qb from "metabase/query_builder/reducers"; - -/* data reference */ -import reference from "metabase/reference/reference"; - -/* pulses */ -import * as pulse from "metabase/pulse/reducers"; - -/* setup */ -import * as setup from "metabase/setup/reducers"; - -/* user */ -import * as user from "metabase/user/reducers"; -import { currentUser, setUser } from "metabase/user"; - -import { registerAnalyticsClickListener } from "metabase/lib/analytics"; -import { serializeCardForUrl, cleanCopyCard, urlForCardState } from "metabase/lib/card"; -import { createStoreWithAngularScope } from "metabase/lib/redux"; - -const reducers = combineReducers({ - form, - router: routerStateReducer, - - // global reducers - auth, - currentUser, - metadata, - requests, - - // main app reducers - dashboard, - home: combineReducers(home), - labels, - pulse: combineReducers(pulse), - qb: combineReducers(qb), - questions, - reference, - setup: combineReducers(setup), - undo, - user: combineReducers(user), - - // admin reducers - databases, - datamodel: datamodel, - people: combineReducers(people), - settings -}); - -// Declare app level module which depends on filters, and services -angular.module('metabase', [ - 'ngRoute', - 'ngCookies', - 'metabase.controllers', -]) -.run(["AppState", function(AppState) { - // initialize app state - AppState.init(); - - // start our analytics click listener +angular +.module('metabase', ['ngCookies', 'metabase.controllers']) +.run([function() { registerAnalyticsClickListener(); }]) -.config(['$routeProvider', '$locationProvider', function($routeProvider, $locationProvider) { - $locationProvider.html5Mode({ - enabled: true, - requireBase: false - }); - - const route = { - template: '<div id="main" />', - controller: 'AppController', - resolve: { - appState: ["AppState", function(AppState) { - return AppState.init(); - }] - } - }; - - $routeProvider.when('/admin/', { redirectTo: () => ('/admin/settings') }); - $routeProvider.when('/auth/', { redirectTo: () => ('/auth/login') }); - $routeProvider.when('/card/', { redirectTo: () => ("/questions/all") }); - $routeProvider.when('/card/:cardId/:serializedCard', { redirectTo: (routeParams) => ("/card/"+routeParams.cardId+"#"+routeParams.serializedCard) }); - $routeProvider.when('/q/:serializedCard', { redirectTo: (routeParams) => ("/q#"+routeParams.serializedCard) }); - - $routeProvider.otherwise(route); -}]) -.controller('AppController', ['$scope', '$location', '$route', '$rootScope', '$timeout', 'ipCookie', 'AppState', - function($scope, $location, $route, $rootScope, $timeout, ipCookie, AppState) { - const props = { - onChangeLocation(url) { - $scope.$apply(() => $location.url(url)); - }, - refreshSiteSettings() { - $scope.$apply(() => AppState.refreshSiteSettings()); - }, - updateUrl: (card, isDirty=false, replaceState=false) => { - if (!card) { - return; - } - var copy = cleanCopyCard(card); - var newState = { - card: copy, - cardId: copy.id, - serializedCard: serializeCardForUrl(copy) - }; - - if (angular.equals(window.history.state, newState)) { - return; - } - - var url = urlForCardState(newState, isDirty); - - // if the serialized card is identical replace the previous state instead of adding a new one - // e.x. when saving a new card we want to replace the state and URL with one with the new card ID - replaceState = replaceState || (window.history.state && window.history.state.serializedCard === newState.serializedCard); - - // ensure the digest cycle is run, otherwise pending location changes will prevent navigation away from query builder on the first click - $scope.$apply(() => { - // prevents infinite digest loop - // https://stackoverflow.com/questions/22914228/successfully-call-history-pushstate-from-angular-without-inifinite-digest - $location.url(url); - $location.replace(); - if (replaceState) { - window.history.replaceState(newState, null, $location.absUrl()); - } else { - window.history.pushState(newState, null, $location.absUrl()); - } - }); - } - }; - - const store = createStoreWithAngularScope($scope, $location, reducers, {currentUser: AppState.model.currentUser}); - - const element = document.getElementById("main"); - - ReactDOM.render( - <Provider store={store}> - <Routes {...props} /> - </Provider>, - element - ); - $scope.$on("$destroy", function() { - ReactDOM.unmountComponentAtNode(element); - }); +angular +.module('metabase.controllers', ['metabase.services']) +.controller('Metabase', [function() { +}]); - // ANGULAR_HACKâ„¢: this seems like the easiest way to keep the redux store up to date with the currentUser :-/ - let userSyncTimer = setInterval(() => { - if (store.getState().currentUser !== AppState.model.currentUser) { - store.dispatch(setUser(AppState.model.currentUser)); - } - }, 250); - $scope.$on("$destroy", () => clearInterval(userSyncTimer)); - - // HACK: prevent reloading controllers as the URL changes - let route = $route.current; - $scope.$on('$locationChangeSuccess', function (event) { - $route.current = route; - }); - } -]) - - -// async function refreshCurrentUser() { -// let response = await fetch("/api/user/current", { credentials: 'same-origin' }); -// if (response.status === 200) { -// return await response.json(); -// } -// } - - -// This is the entry point for our Redux application which is fired once on page load. -// We attempt to: -// 1. Identify the currently authenticated user, if possible -// 2. Create the application Redux store -// 3. Render our React/Redux application using a single Redux `Provider` -// window.onload = async function() { -// // refresh site settings - -// // fetch current user -// let user = await refreshCurrentUser(); - -// // initialize redux store -// // NOTE: we want to initialize the store with the active user because it makes lots of other initialization steps simpler -// let store = createMetabaseStore(reducers, {currentUser: user}); - -// // route change listener -// // set app context (for navbar) -// // guard admin urls and redirect to auth pages - -// // events fired -// // appstate:user - currentUser changed -// // appstate:site-settings - changes made to current app settings -// // appstate:context-changed - app section changed (only used by navbar?) - -// // listeners -// // $locationChangeSuccess - analytics route tracking -// // $routeChangeSuccess - route protection logic (updates context and redirects urls based on user perms) -// // appstate:login - refresh the currentUser -// // appstate:logout - null the currentUser and make sure cookie is cleared and session deleted -// // appstate:site-settings - if GA setting changed then update analytics appropriately -// // event:auth-loginRequired - (fired by angular service middleware) lets us know an api call returned a 401 -// // event:auth-forbidden - (fired by angular service middleware) lets us know an api call returned a 403 - -// // start analytics +import Routes from "./Routes.jsx"; -// let reduxApp = document.getElementById("redux-app"); -// render( -// <Provider store={store}> -// <div className="full full-height"> -// <div className="Nav"><Navbar /></div> -// <main className="relative full-height z1"><Routes /></main> -// </div> -// </Provider>, -// reduxApp -// ); -// } +import React from 'react' +import ReactDOM from 'react-dom' +import { Provider } from 'react-redux' +import { getStore } from './store' + +// START react-router-redux +import { Router, browserHistory } from "react-router"; +import { syncHistoryWithStore } from 'react-router-redux' +// END react-router-redux + +// START redux-router +// import { ReduxRouter } from "redux-router"; +// END redux-router + +import { refreshCurrentUser } from "./user"; + +async function init() { + // const user = await getCurrentUser(); + + // START react-router-redux + const store = getStore(browserHistory); + + await store.dispatch(refreshCurrentUser()); + + const history = syncHistoryWithStore(browserHistory, store); + ReactDOM.render( + <Provider store={store}> + <Router history={history}> + {Routes} + </Router> + </Provider>, + document.getElementById('root') + ) + // END react-router-redux + + // START redux-router + // const store = getStore(Routes); + // ReactDOM.render( + // <Provider store={store}> + // <ReduxRouter> + // {Routes} + // </ReduxRouter> + // </Provider>, + // document.getElementById('root') + // ) + // END redux-router +} + +if (document.readyState != 'loading') { + init(); +} else { + document.addEventListener('DOMContentLoaded', init); +} diff --git a/frontend/src/metabase/auth/auth.js b/frontend/src/metabase/auth/auth.js index 3af4c586eda..bcf5b8114d7 100644 --- a/frontend/src/metabase/auth/auth.js +++ b/frontend/src/metabase/auth/auth.js @@ -1,19 +1,22 @@ import { handleActions, combineReducers, AngularResourceProxy, createThunkAction } from "metabase/lib/redux"; +import { push } from "react-router-redux"; + import MetabaseCookies from "metabase/lib/cookies"; import MetabaseUtils from "metabase/lib/utils"; import MetabaseAnalytics from "metabase/lib/analytics"; import { clearGoogleAuthCredentials } from "metabase/lib/auth"; +import { refreshCurrentUser } from "metabase/user"; // resource wrappers const SessionApi = new AngularResourceProxy("Session", ["create", "createWithGoogleAuth", "delete", "reset_password"]); // login -export const login = createThunkAction("AUTH_LOGIN", function(credentials, onChangeLocation) { +export const login = createThunkAction("AUTH_LOGIN", function(credentials) { return async function(dispatch, getState) { if (!MetabaseUtils.validEmail(credentials.email)) { @@ -29,7 +32,9 @@ export const login = createThunkAction("AUTH_LOGIN", function(credentials, onCha MetabaseAnalytics.trackEvent('Auth', 'Login'); // TODO: redirect after login (carry user to intended destination) // this is ridiculously stupid. we have to wait (300ms) for the cookie to actually be set in the browser :( - setTimeout(() => onChangeLocation("/"), 300); + // setTimeout(() => dispatch(push("/")), 300); + await dispatch(refreshCurrentUser()); + dispatch(push("/")); } catch (error) { return error; @@ -39,7 +44,7 @@ export const login = createThunkAction("AUTH_LOGIN", function(credentials, onCha // login Google -export const loginGoogle = createThunkAction("AUTH_LOGIN_GOOGLE", function(googleUser, onChangeLocation) { +export const loginGoogle = createThunkAction("AUTH_LOGIN_GOOGLE", function(googleUser) { return async function(dispatch, getState) { try { let newSession = await SessionApi.createWithGoogleAuth({ @@ -53,13 +58,13 @@ export const loginGoogle = createThunkAction("AUTH_LOGIN_GOOGLE", function(googl // TODO: redirect after login (carry user to intended destination) // this is ridiculously stupid. we have to wait (300ms) for the cookie to actually be set in the browser :( - setTimeout(() => onChangeLocation("/"), 300); + setTimeout(() => dispatch(push("/")), 300); } catch (error) { clearGoogleAuthCredentials(); // If we see a 428 ("Precondition Required") that means we need to show the "No Metabase account exists for this Google Account" page if (error.status === 428) { - onChangeLocation('/auth/google_no_mb_account'); + dispatch(push("/auth/google_no_mb_account")); } else { return error; } @@ -68,7 +73,7 @@ export const loginGoogle = createThunkAction("AUTH_LOGIN_GOOGLE", function(googl }); // logout -export const logout = createThunkAction("AUTH_LOGOUT", function(onChangeLocation) { +export const logout = createThunkAction("AUTH_LOGOUT", function() { return async function(dispatch, getState) { // TODO: as part of a logout we want to clear out any saved state that we have about anything @@ -79,12 +84,12 @@ export const logout = createThunkAction("AUTH_LOGOUT", function(onChangeLocation } MetabaseAnalytics.trackEvent('Auth', 'Logout'); - setTimeout(() => onChangeLocation("/auth/login"), 300); + setTimeout(() => dispatch(push("/auth/login")), 300); }; }); // passwordReset -export const passwordReset = createThunkAction("AUTH_PASSWORD_RESET", function(token, credentials, onChangeLocation) { +export const passwordReset = createThunkAction("AUTH_PASSWORD_RESET", function(token, credentials) { return async function(dispatch, getState) { if (credentials.password !== credentials.password2) { diff --git a/frontend/src/metabase/auth/components/BackToLogin.jsx b/frontend/src/metabase/auth/components/BackToLogin.jsx index 4d602083b2b..4ded6c192a1 100644 --- a/frontend/src/metabase/auth/components/BackToLogin.jsx +++ b/frontend/src/metabase/auth/components/BackToLogin.jsx @@ -1,6 +1,7 @@ -import React from 'react' +import React from 'react'; +import { Link } from "react-router"; const BackToLogin = () => - <a className="link block" href="/auth/login">Back to login</a> + <Link to="/auth/login" className="link block">Back to login</Link> export default BackToLogin; diff --git a/frontend/src/metabase/auth/containers/LoginApp.jsx b/frontend/src/metabase/auth/containers/LoginApp.jsx index abc33827c51..e2970bebcf9 100644 --- a/frontend/src/metabase/auth/containers/LoginApp.jsx +++ b/frontend/src/metabase/auth/containers/LoginApp.jsx @@ -1,5 +1,6 @@ import React, { Component, PropTypes } from "react"; import { findDOMNode } from "react-dom"; +import { Link } from "react-router"; import { connect } from "react-redux"; import cx from "classnames"; @@ -19,8 +20,7 @@ import * as authActions from "../auth"; const mapStateToProps = (state, props) => { return { loginError: state.auth && state.auth.loginError, - user: state.currentUser, - onChangeLocation: props.onChangeLocation + user: state.currentUser } } @@ -57,7 +57,7 @@ export default class LoginApp extends Component { this.validateForm(); - const { loginGoogle, onChangeLocation } = this.props; + const { loginGoogle } = this.props; let ssoLoginButton = findDOMNode(this.refs.ssoLoginButton); @@ -74,7 +74,7 @@ export default class LoginApp extends Component { cookiepolicy: 'single_host_origin', }); auth2.attachClickHandler(ssoLoginButton, {}, - (googleUser) => loginGoogle(googleUser, onChangeLocation), + (googleUser) => loginGoogle(googleUser), (error) => console.error('There was an error logging in', error) ); }) @@ -89,14 +89,6 @@ export default class LoginApp extends Component { this.validateForm(); } - componentWillReceiveProps(newProps) { - const { user, onChangeLocation } = newProps; - // if we already have a user then we shouldn't be logging in - if (user) { - onChangeLocation("/"); - } - } - onChange(fieldName, fieldValue) { this.setState({ credentials: { ...this.state.credentials, [fieldName]: fieldValue }}); } @@ -104,10 +96,10 @@ export default class LoginApp extends Component { formSubmitted(e) { e.preventDefault(); - let { login, onChangeLocation } = this.props; + let { login } = this.props; let { credentials } = this.state; - login(credentials, onChangeLocation); + login(credentials); } render() { @@ -158,7 +150,7 @@ export default class LoginApp extends Component { <button className={cx("Button Grid-cell", {'Button--primary': this.state.valid})} disabled={!this.state.valid}> Sign in </button> - <a className="Grid-cell py2 sm-py0 text-grey-3 md-text-right text-centered flex-full link" href="/auth/forgot_password" onClick={(e) => { window.OSX ? window.OSX.resetPassword() : null }}>I seem to have forgotten my password</a> + <Link to="/auth/forgot_password" className="Grid-cell py2 sm-py0 text-grey-3 md-text-right text-centered flex-full link" onClick={(e) => { window.OSX ? window.OSX.resetPassword() : null }}>I seem to have forgotten my password</Link> </div> </form> </div> diff --git a/frontend/src/metabase/auth/containers/PasswordResetApp.jsx b/frontend/src/metabase/auth/containers/PasswordResetApp.jsx index bc573a7668f..4a11c424e0d 100644 --- a/frontend/src/metabase/auth/containers/PasswordResetApp.jsx +++ b/frontend/src/metabase/auth/containers/PasswordResetApp.jsx @@ -1,5 +1,6 @@ import React, { Component, PropTypes } from "react"; import { connect } from "react-redux"; +import { Link } from "react-router"; import { AngularResourceProxy } from "metabase/lib/redux"; import cx from "classnames"; @@ -19,11 +20,10 @@ const SessionApi = new AngularResourceProxy("Session", ["password_reset_token_va const mapStateToProps = (state, props) => { return { - token: state.router && state.router.params && state.router.params.token, + token: props.params.token, resetError: state.auth && state.auth.resetError, resetSuccess: state.auth && state.auth.resetSuccess, - newUserJoining: state.router && state.router.location && state.router.location.hash === "#new", - onChangeLocation: props.onChangeLocation + newUserJoining: props.location.hash === "#new" } } @@ -83,10 +83,10 @@ export default class PasswordResetApp extends Component { formSubmitted(e) { e.preventDefault(); - let { token, passwordReset, onChangeLocation } = this.props; + let { token, passwordReset } = this.props; let { credentials } = this.state; - passwordReset(token, credentials, onChangeLocation); + passwordReset(token, credentials); } render() { @@ -106,7 +106,7 @@ export default class PasswordResetApp extends Component { <h3 className="Login-header Form-offset mt4">Whoops, that's an expired link</h3> <p className="Form-offset mb4 mr4"> For security reasons, password reset links expire after a little while. If you still need - to reset your password, you can <a href="/auth/forgot_password" className="link">request a new reset email</a>. + to reset your password, you can <Link to="/auth/forgot_password" className="link">request a new reset email</Link>. </p> </div> </div> @@ -160,9 +160,9 @@ export default class PasswordResetApp extends Component { <p>Your password has been reset.</p> <p> { newUserJoining ? - <a href="/?new" className="Button Button--primary">Sign in with your new password</a> + <Link to="/?new" className="Button Button--primary">Sign in with your new password</Link> : - <a href="/" className="Button Button--primary">Sign in with your new password</a> + <Link to="/" className="Button Button--primary">Sign in with your new password</Link> } </p> </div> diff --git a/frontend/src/metabase/components/Breadcrumbs.jsx b/frontend/src/metabase/components/Breadcrumbs.jsx index af080db1ba4..c32baf6d26a 100644 --- a/frontend/src/metabase/components/Breadcrumbs.jsx +++ b/frontend/src/metabase/components/Breadcrumbs.jsx @@ -1,4 +1,5 @@ import React, { Component, PropTypes } from "react"; +import { Link } from "react-router"; import S from "./Breadcrumbs.css"; @@ -51,7 +52,7 @@ export default class Breadcrumbs extends Component { )} > { breadcrumb.length > 1 ? - <a href={breadcrumb[1]}>{breadcrumb[0]}</a> : + <Link to={breadcrumb[1]}>{breadcrumb[0]}</Link> : <span>{breadcrumb[0]}</span> } </Ellipsified> diff --git a/frontend/src/metabase/components/EmptyState.jsx b/frontend/src/metabase/components/EmptyState.jsx index 0e66f2d34a1..2a4e5d56c68 100644 --- a/frontend/src/metabase/components/EmptyState.jsx +++ b/frontend/src/metabase/components/EmptyState.jsx @@ -1,4 +1,5 @@ import React, { PropTypes } from "react"; +import { Link } from "react-router"; import Icon from "metabase/components/Icon.jsx"; @@ -17,7 +18,7 @@ const EmptyState = ({ title, message, icon, image, action, link }) => <h3 className="text-grey-2 mt4" style={{maxWidth: "350px"}}>{message}</h3> </div> { action && - <a className="Button Button--primary mt3" href={link} target={link.startsWith('http') ? "_blank" : ""}>{action}</a> + <Link to={link} className="Button Button--primary mt3" target={link.startsWith('http') ? "_blank" : ""}>{action}</Link> } </div> diff --git a/frontend/src/metabase/components/NotFound.jsx b/frontend/src/metabase/components/NotFound.jsx index 08338513869..0eb16a19945 100644 --- a/frontend/src/metabase/components/NotFound.jsx +++ b/frontend/src/metabase/components/NotFound.jsx @@ -1,5 +1,5 @@ import React, { Component, PropTypes } from "react"; - +import { Link } from "react-router"; export default class NotFound extends Component { render() { @@ -11,9 +11,9 @@ export default class NotFound extends Component { <p className="h4">You might've been tricked by a ninja, but in all likelihood, you were just given a bad link.</p> <p className="h4 my4">You can always:</p> <div className="flex align-center"> - <a className="Button Button--primary" href="/q"> + <Link to="/q" className="Button Button--primary"> <div className="p1">Ask a new question.</div> - </a> + </Link> <span className="mx2">or</span> <a className="Button Button--withIcon" target="_blank" href="http://tv.giphy.com/kitten"> <div className="p1 flex align-center relative"> @@ -26,4 +26,4 @@ export default class NotFound extends Component { </div> ); } -} \ No newline at end of file +} diff --git a/frontend/src/metabase/controllers.js b/frontend/src/metabase/controllers.js index 8da3c4ed76b..8b137891791 100644 --- a/frontend/src/metabase/controllers.js +++ b/frontend/src/metabase/controllers.js @@ -1,34 +1 @@ -angular -.module('metabase.controllers', ['metabase.services']) -.controller('Metabase', ['$scope', '$location', 'AppState', function($scope, $location, AppState) { - - var clearState = function() { - $scope.siteName = undefined; - $scope.user = undefined; - $scope.userIsSuperuser = false; - }; - - // current User - $scope.user = undefined; - $scope.userIsSuperuser = false; - - $scope.$on("appstate:site-settings", function(event, settings) { - // change in global settings - $scope.siteName = settings.site_name; - }); - - $scope.$on("appstate:user", function(event, user) { - // change in current user - $scope.user = user; - $scope.userIsSuperuser = user.is_superuser; - }); - - $scope.$on("appstate:logout", function(event, user) { - clearState(); - }); - - $scope.refreshCurrentUser = function() { - AppState.refreshCurrentUser(); - }; -}]); diff --git a/frontend/src/metabase/dashboard/components/Dashboard.jsx b/frontend/src/metabase/dashboard/components/Dashboard.jsx index c26deda7e32..6736de15b3a 100644 --- a/frontend/src/metabase/dashboard/components/Dashboard.jsx +++ b/frontend/src/metabase/dashboard/components/Dashboard.jsx @@ -60,7 +60,6 @@ export default class Dashboard extends Component { setDashCardVisualizationSetting: PropTypes.func.isRequired, onChangeLocation: PropTypes.func.isRequired, - onDashboardDeleted: PropTypes.func.isRequired, }; async componentDidMount() { @@ -101,10 +100,10 @@ export default class Dashboard extends Component { async loadDashboard(dashboardId) { this.loadParams(); - const { addCardOnLoad, fetchDashboard, fetchCards, addCardToDashboard, onChangeLocation } = this.props; + const { addCardOnLoad, fetchDashboard, fetchCards, addCardToDashboard, onChangeLocation, location } = this.props; try { - await fetchDashboard(dashboardId); + await fetchDashboard(dashboardId, location.query); if (addCardOnLoad != null) { // we have to load our cards before we can add one await fetchCards(); @@ -285,7 +284,7 @@ export default class Dashboard extends Component { if (refreshElapsed >= this.state.refreshPeriod) { refreshElapsed = 0; - await this.props.fetchDashboard(this.props.selectedDashboard); + await this.props.fetchDashboard(this.props.selectedDashboard, this.props.location.query); this.fetchDashboardCardData(this.props); } this.setState({ refreshElapsed }); diff --git a/frontend/src/metabase/dashboard/components/DashboardHeader.jsx b/frontend/src/metabase/dashboard/components/DashboardHeader.jsx index 958c1186874..d6d32275a34 100644 --- a/frontend/src/metabase/dashboard/components/DashboardHeader.jsx +++ b/frontend/src/metabase/dashboard/components/DashboardHeader.jsx @@ -61,7 +61,7 @@ export default class DashboardHeader extends Component { } onRevert() { - this.props.fetchDashboard(this.props.dashboard.id); + this.props.fetchDashboard(this.props.dashboard.id, this.props.location.query); } async onSave() { @@ -92,7 +92,7 @@ export default class DashboardHeader extends Component { // 3. finished reverting to a revision onRevertedRevision() { this.refs.dashboardHistory.toggle(); - this.props.fetchDashboard(this.props.dashboard.id); + this.props.fetchDashboard(this.props.dashboard.id, this.props.location.query); } getEditingButtons() { diff --git a/frontend/src/metabase/dashboard/containers/DashCardCardParameterMapper.jsx b/frontend/src/metabase/dashboard/containers/DashCardCardParameterMapper.jsx index ba513b33524..c7524d1ad3b 100644 --- a/frontend/src/metabase/dashboard/containers/DashCardCardParameterMapper.jsx +++ b/frontend/src/metabase/dashboard/containers/DashCardCardParameterMapper.jsx @@ -24,11 +24,11 @@ import type { DatabaseId } from "metabase/meta/types/base"; const makeMapStateToProps = () => { const getParameterMappingOptions = makeGetParameterMappingOptions() const mapStateToProps = (state, props) => ({ - parameter: getEditingParameter(state), + parameter: getEditingParameter(state, props), mappingOptions: getParameterMappingOptions(state, props), mappingOptionSections: _.groupBy(getParameterMappingOptions(state, props), "sectionName"), target: getParameterTarget(state, props), - mappingsByParameter: getMappingsByParameter(state) + mappingsByParameter: getMappingsByParameter(state, props) }); return mapStateToProps; } diff --git a/frontend/src/metabase/dashboard/containers/DashboardApp.jsx b/frontend/src/metabase/dashboard/containers/DashboardApp.jsx index 6586225730e..7dbe55ad17f 100644 --- a/frontend/src/metabase/dashboard/containers/DashboardApp.jsx +++ b/frontend/src/metabase/dashboard/containers/DashboardApp.jsx @@ -12,19 +12,19 @@ import * as dashboardActions from "../dashboard"; const mapStateToProps = (state, props) => { return { - isEditing: getIsEditing(state), - isEditingParameter: getIsEditingParameter(state), - isDirty: getIsDirty(state), - selectedDashboard: getSelectedDashboard(state), - dashboard: getDashboardComplete(state), - cards: getCardList(state), - revisions: getRevisions(state), - dashcardData: getCardData(state), - cardDurations: getCardDurations(state), - databases: getDatabases(state), - editingParameter: getEditingParameter(state), - parameterValues: getParameterValues(state), - addCardOnLoad: parseInt(state.router.location.query.add) || null + isEditing: getIsEditing(state, props), + isEditingParameter: getIsEditingParameter(state, props), + isDirty: getIsDirty(state, props), + selectedDashboard: getSelectedDashboard(state, props), + dashboard: getDashboardComplete(state, props), + cards: getCardList(state, props), + revisions: getRevisions(state, props), + dashcardData: getCardData(state, props), + cardDurations: getCardDurations(state, props), + databases: getDatabases(state, props), + editingParameter: getEditingParameter(state, props), + parameterValues: getParameterValues(state, props), + addCardOnLoad: props.location.query.add ? parseInt(props.location.query.add) : null } } diff --git a/frontend/src/metabase/dashboard/containers/ParameterWidget.jsx b/frontend/src/metabase/dashboard/containers/ParameterWidget.jsx index 4a7c5cb02bc..cfbc1e66d2b 100644 --- a/frontend/src/metabase/dashboard/containers/ParameterWidget.jsx +++ b/frontend/src/metabase/dashboard/containers/ParameterWidget.jsx @@ -12,7 +12,7 @@ import { getMappingsByParameter } from "../selectors"; const makeMapStateToProps = () => { const mapStateToProps = (state, props) => ({ - mappingsByParameter: getMappingsByParameter(state) + mappingsByParameter: getMappingsByParameter(state, props) }); return mapStateToProps; } diff --git a/frontend/src/metabase/dashboard/dashboard.js b/frontend/src/metabase/dashboard/dashboard.js index 84671ea3e3d..a313a7e1388 100644 --- a/frontend/src/metabase/dashboard/dashboard.js +++ b/frontend/src/metabase/dashboard/dashboard.js @@ -138,7 +138,7 @@ export const fetchCardData = createThunkAction(FETCH_CARD_DATA, function(card, d let result = null; // if we have a parameter, apply it to the card query before we execute - let { dashboardId } = getState().router.params; + let { dashboardId } = getState().dashboard; let { dashboards, parameterValues } = getState().dashboard; let dashboard = dashboards[dashboardId]; @@ -188,19 +188,26 @@ export const fetchCardDuration = createThunkAction(FETCH_CARD_DURATION, function }; }); -export const fetchDashboard = createThunkAction(FETCH_DASHBOARD, function(id, enableQueryParameters = true, enableDefaultParameters = true) { +const SET_DASHBOARD_ID = "metabase/dashboard/SET_DASHBOARD_ID"; +export const setDashboardId = createAction(SET_DASHBOARD_ID); + +export const fetchDashboard = createThunkAction(FETCH_DASHBOARD, function(dashId, queryParams, enableDefaultParameters = true) { return async function(dispatch, getState) { - let result = await DashboardApi.get({ dashId: id }); + let result = await DashboardApi.get({ dashId: dashId }); + + dispatch(setDashboardId(dashId)); + if (result.parameters) { - const { query } = getState().router.location; for (const parameter of result.parameters) { - if (enableQueryParameters && query[parameter.slug] != null) { - dispatch(setParameterValue(parameter.id, query[parameter.slug])); + if (queryParams && queryParams[parameter.slug] != null) { + dispatch(setParameterValue(parameter.id, queryParams[parameter.slug])); } else if (enableDefaultParameters && parameter.default != null) { dispatch(setParameterValue(parameter.id, parameter.default)); } } } + + // fetch database metadata for every card _.chain(result.ordered_cards) .map((dc) => [dc.card].concat(dc.series)) .flatten() @@ -214,10 +221,10 @@ export const fetchDashboard = createThunkAction(FETCH_DASHBOARD, function(id, en export const saveDashboard = createThunkAction(SAVE_DASHBOARD, function(dashId) { return async function(dispatch, getState) { - let { dashboards, dashcards } = getState().dashboard; + let { dashboards, dashcards, dashboardId } = getState().dashboard; let dashboard = { - ...dashboards[dashId], - ordered_cards: dashboards[dashId].ordered_cards.map(dashcardId => dashcards[dashcardId]) + ...dashboards[dashboardId], + ordered_cards: dashboards[dashboardId].ordered_cards.map(dashcardId => dashcards[dashcardId]) }; // remove isRemoved dashboards @@ -270,7 +277,7 @@ export const saveDashboard = createThunkAction(SAVE_DASHBOARD, function(dashId) } // make sure that we've fully cleared out any dirty state from editing (this is overkill, but simple) - dispatch(fetchDashboard(dashId, false, true)); // disable using query parameters when saving + dispatch(fetchDashboard(dashId, null, true)); // disable using query parameters when saving MetabaseAnalytics.trackEvent("Dashboard", "Update"); @@ -328,6 +335,10 @@ export const setParameterValue = createThunkAction(SET_PARAMETER_VALUE, (paramet // reducers +const dashboardId = handleActions({ + [SET_DASHBOARD_ID]: { next: (state, { payload }) => payload } +}, null); + const isEditing = handleActions({ [SET_EDITING_DASHBOARD]: { next: (state, { payload }) => payload } }, false); @@ -422,6 +433,7 @@ const dashboardListing = handleActions({ }, []); export default combineReducers({ + dashboardId, isEditing, cards, cardList, diff --git a/frontend/src/metabase/dashboard/selectors.js b/frontend/src/metabase/dashboard/selectors.js index 68392ddf9da..73da45afaf6 100644 --- a/frontend/src/metabase/dashboard/selectors.js +++ b/frontend/src/metabase/dashboard/selectors.js @@ -13,7 +13,7 @@ import Query from "metabase/lib/query"; import type { CardObject } from "metabase/meta/types/Card"; import type { ParameterMappingOption, ParameterObject } from "metabase/meta/types/Dashboard"; -export const getSelectedDashboard = state => state.router.params.dashboardId; +export const getSelectedDashboard = (state, props) => props.params.dashboardId; export const getIsEditing = state => state.dashboard.isEditing; export const getCards = state => state.dashboard.cards; export const getDashboards = state => state.dashboard.dashboards; diff --git a/frontend/src/metabase/home/components/Activity.jsx b/frontend/src/metabase/home/components/Activity.jsx index a8067cb5c7c..d2eecea0d25 100644 --- a/frontend/src/metabase/home/components/Activity.jsx +++ b/frontend/src/metabase/home/components/Activity.jsx @@ -1,4 +1,5 @@ import React, { Component, PropTypes } from 'react'; +import { Link } from "react-router"; import _ from 'underscore'; import LoadingAndErrorWrapper from 'metabase/components/LoadingAndErrorWrapper.jsx'; @@ -88,7 +89,7 @@ export default class Activity extends Component { case "card-create": case "card-update": if(item.table) { - description.summary = (<span>saved a question about <a data-metabase-event={"Activity Feed;Header Clicked;Database -> "+item.topic} className="link text-dark" href={Urls.tableRowsQuery(item.database_id, item.table_id)}>{item.table.display_name}</a></span>); + description.summary = (<span>saved a question about <Link to={Urls.tableRowsQuery(item.database_id, item.table_id)} data-metabase-event={"Activity Feed;Header Clicked;Database -> "+item.topic} className="link text-dark">{item.table.display_name}</Link></span>); } else { description.summary = "saved a question"; } @@ -104,14 +105,14 @@ export default class Activity extends Component { break; case "dashboard-add-cards": if(item.model_exists) { - description.summary = (<span>added a question to the dashboard - <a data-metabase-event={"Activity Feed;Header Clicked;Dashboard -> "+item.topic} className="link text-dark" href={Urls.dashboard(item.model_id)}>{item.details.name}</a></span>); + description.summary = (<span>added a question to the dashboard - <Link to={Urls.dashboard(item.model_id)} data-metabase-event={"Activity Feed;Header Clicked;Dashboard -> "+item.topic} className="link text-dark">{item.details.name}</Link></span>); } else { description.summary = (<span>added a question to the dashboard - <span className="text-dark">{item.details.name}</span></span>); } break; case "dashboard-remove-cards": if(item.model_exists) { - description.summary = (<span>removed a question from the dashboard - <a data-metabase-event={"Activity Feed;Header Clicked;Dashboard -> "+item.topic} className="link text-dark" href={Urls.dashboard(item.model_id)}>{item.details.name}</a></span>); + description.summary = (<span>removed a question from the dashboard - <Link to={Urls.dashboard(item.model_id)} data-metabase-event={"Activity Feed;Header Clicked;Dashboard -> "+item.topic} className="link text-dark">{item.details.name}</Link></span>); } else { description.summary = (<span>removed a question from the dashboard - <span className="text-dark">{item.details.name}</span></span>); } @@ -133,14 +134,14 @@ export default class Activity extends Component { break; case "metric-create": if(item.model_exists) { - description.summary = (<span>added the metric <a data-metabase-event={"Activity Feed;Header Clicked;Metric -> "+item.topic} className="link text-dark" href={Urls.tableRowsQuery(item.database_id, item.table_id, item.model_id)}>{item.details.name}</a> to the <a data-metabase-event={"Activity Feed;Header Clicked;Table -> "+item.topic} className="link text-dark" href={Urls.tableRowsQuery(item.database_id, item.table_id)}>{item.table.display_name}</a> table</span>); + description.summary = (<span>added the metric <Link to={Urls.tableRowsQuery(item.database_id, item.table_id, item.model_id)} data-metabase-event={"Activity Feed;Header Clicked;Metric -> "+item.topic} className="link text-dark">{item.details.name}</Link> to the <Link to={Urls.tableRowsQuery(item.database_id, item.table_id)} data-metabase-event={"Activity Feed;Header Clicked;Table -> "+item.topic} className="link text-dark">{item.table.display_name}</Link> table</span>); } else { description.summary = (<span>added the metric <span className="text-dark">{item.details.name}</span></span>); } break; case "metric-update": if(item.model_exists) { - description.summary = (<span>made changes to the metric <a data-metabase-event={"Activity Feed;Header Clicked;Metric -> "+item.topic} className="link text-dark" href={Urls.tableRowsQuery(item.database_id, item.table_id, item.model_id)}>{item.details.name}</a> in the <a data-metabase-event={"Activity Feed;Header Clicked;Table -> "+item.topic} className="link text-dark" href={Urls.tableRowsQuery(item.database_id, item.table_id)}>{item.table.display_name}</a> table</span>); + description.summary = (<span>made changes to the metric <Link to={Urls.tableRowsQuery(item.database_id, item.table_id, item.model_id)} data-metabase-event={"Activity Feed;Header Clicked;Metric -> "+item.topic} className="link text-dark">{item.details.name}</Link> in the <Link to={Urls.tableRowsQuery(item.database_id, item.table_id)} data-metabase-event={"Activity Feed;Header Clicked;Table -> "+item.topic} className="link text-dark">{item.table.display_name}</Link> table</span>); } else { description.summary = (<span>made changes to the metric <span className="text-dark">{item.details.name}</span></span>); } @@ -156,14 +157,22 @@ export default class Activity extends Component { break; case "segment-create": if(item.model_exists) { - description.summary = (<span>added the filter <a data-metabase-event={"Activity Feed;Header Clicked;Segment -> "+item.topic} className="link text-dark" href={Urls.tableRowsQuery(item.database_id, item.table_id, null, item.model_id)}>{item.details.name}</a> to the <a data-metabase-event={"Activity Feed;Header Clicked;Table -> "+item.topic} className="link text-dark" href={Urls.tableRowsQuery(item.database_id, item.table_id)}>{item.table.display_name}</a> table</span>); + description.summary = ( + <span> + added the filter + <Link to={Urls.tableRowsQuery(item.database_id, item.table_id, null, item.model_id)} data-metabase-event={"Activity Feed;Header Clicked;Segment -> "+item.topic} className="link text-dark">{item.details.name}</Link> + to the + <Link to={Urls.tableRowsQuery(item.database_id, item.table_id)} data-metabase-event={"Activity Feed;Header Clicked;Table -> "+item.topic} className="link text-dark">{item.table.display_name}</Link> + table + </span> + ); } else { description.summary = (<span>added the filter <span className="text-dark">{item.details.name}</span></span>); } break; case "segment-update": if(item.model_exists) { - description.summary = (<span>made changes to the filter <a data-metabase-event={"Activity Feed;Header Clicked;Segment -> "+item.topic} className="link text-dark" href={Urls.tableRowsQuery(item.database_id, item.table_id, null, item.model_id)}>{item.details.name}</a> in the <a data-metabase-event={"Activity Feed;Header Clicked;Table -> "+item.topic} className="link text-dark" href={Urls.tableRowsQuery(item.database_id, item.table_id)}>{item.table.display_name}</a> table</span>); + description.summary = (<span>made changes to the filter <Link to={Urls.tableRowsQuery(item.database_id, item.table_id, null, item.model_id)} data-metabase-event={"Activity Feed;Header Clicked;Segment -> "+item.topic} className="link text-dark">{item.details.name}</Link> in the <Link to={Urls.tableRowsQuery(item.database_id, item.table_id)} data-metabase-event={"Activity Feed;Header Clicked;Table -> "+item.topic} className="link text-dark">{item.table.display_name}</Link> table</span>); } else { description.summary = (<span>made changes to the filter <span className="text-dark">{item.details.name}</span></span>); } diff --git a/frontend/src/metabase/home/components/ActivityStory.jsx b/frontend/src/metabase/home/components/ActivityStory.jsx index 8fd50b1c0d4..93446a019a5 100644 --- a/frontend/src/metabase/home/components/ActivityStory.jsx +++ b/frontend/src/metabase/home/components/ActivityStory.jsx @@ -1,5 +1,5 @@ import React, { Component, PropTypes } from 'react'; - +import { Link } from "react-router"; export default class ActivityStory extends Component { constructor(props, context) { @@ -26,7 +26,7 @@ export default class ActivityStory extends Component { <div className="mt1 border-left flex mr2" style={{borderWidth: '3px', marginLeft: '22px', borderColor: '#F2F5F6'}}> <div className="flex full ml4 bordered rounded p2" style={this.styles}> { story.bodyLink ? - <a data-metabase-event={"Activity Feed;Story Clicked;"+story.topic} className="link" href={story.bodyLink}>{story.body}</a> + <Link to={story.bodyLink} data-metabase-event={"Activity Feed;Story Clicked;"+story.topic} className="link">{story.body}</Link> : <span>{story.body}</span> } diff --git a/frontend/src/metabase/home/components/NewUserOnboardingModal.jsx b/frontend/src/metabase/home/components/NewUserOnboardingModal.jsx index e47ecb2cdef..5722a7e1b4a 100644 --- a/frontend/src/metabase/home/components/NewUserOnboardingModal.jsx +++ b/frontend/src/metabase/home/components/NewUserOnboardingModal.jsx @@ -1,4 +1,5 @@ import React, { Component, PropTypes } from "react"; +import { Link } from "react-router"; import MetabaseSettings from "metabase/lib/settings"; @@ -83,7 +84,7 @@ export default class NewUserOnboardingModal extends Component { {this.renderStep()} <span className="flex-align-right"> <a className="text-underline-hover cursor-pointer mr3" onClick={() => (this.closeModal())}>skip for now</a> - <a className="Button Button--primary" href="/q?tutorial">Let's do it!</a> + <Link to="/q?tutorial" className="Button Button--primary">Let's do it!</Link> </span> </div> </div> diff --git a/frontend/src/metabase/home/components/NextStep.jsx b/frontend/src/metabase/home/components/NextStep.jsx index 806223f9dcb..a3d1ef4a52f 100644 --- a/frontend/src/metabase/home/components/NextStep.jsx +++ b/frontend/src/metabase/home/components/NextStep.jsx @@ -1,4 +1,5 @@ import React, { Component, PropTypes } from "react"; +import { Link } from "react-router"; import fetch from 'isomorphic-fetch'; import SidebarSection from "./SidebarSection.jsx"; @@ -30,11 +31,11 @@ export default class NextStep extends Component { const { next } = this.state; if (next) { return ( - <SidebarSection title="Setup Tip" icon="info" extra={<a className="text-brand no-decoration" href="/admin/settings">View all</a>}> - <a className="block p3 no-decoration" href={next.link}> + <SidebarSection title="Setup Tip" icon="info" extra={<Link to="/admin/settings" className="text-brand no-decoration">View all</Link>}> + <Link to={next.link} className="block p3 no-decoration"> <h4 className="text-brand text-bold">{next.title}</h4> <p className="m0 mt1">{next.description}</p> - </a> + </Link> </SidebarSection> ) } else { diff --git a/frontend/src/metabase/home/components/RecentViews.jsx b/frontend/src/metabase/home/components/RecentViews.jsx index 4fef7d5b860..f21955be677 100644 --- a/frontend/src/metabase/home/components/RecentViews.jsx +++ b/frontend/src/metabase/home/components/RecentViews.jsx @@ -1,4 +1,5 @@ import React, { Component, PropTypes } from "react"; +import { Link } from "react-router"; import Icon from "metabase/components/Icon.jsx"; import SidebarSection from "./SidebarSection.jsx"; @@ -54,7 +55,7 @@ export default class RecentViews extends Component { {recentViews.map((item, index) => <li key={index} className="py1 ml1 flex align-center clearfix"> {this.renderIllustration(item)} - <a data-metabase-event={"Recent Views;"+item.model+";"+item.cnt} className="ml1 flex-full link" href={Urls.modelToUrl(item.model, item.model_id)}>{item.model_object.name}</a> + <Link to={Urls.modelToUrl(item.model, item.model_id)} data-metabase-event={"Recent Views;"+item.model+";"+item.cnt} className="ml1 flex-full link">{item.model_object.name}</Link> </li> )} </ul> diff --git a/frontend/src/metabase/home/containers/HomepageApp.jsx b/frontend/src/metabase/home/containers/HomepageApp.jsx index 53ebb22d079..b5514b92512 100644 --- a/frontend/src/metabase/home/containers/HomepageApp.jsx +++ b/frontend/src/metabase/home/containers/HomepageApp.jsx @@ -12,19 +12,22 @@ import NewUserOnboardingModal from '../components/NewUserOnboardingModal.jsx'; import NextStep from "../components/NextStep.jsx"; import * as homepageActions from "../actions"; -import { getActivity, getRecentViews, getUser, getShowOnboarding } from "../selectors"; +import { getActivity, getRecentViews, getUser } from "../selectors"; const mapStateToProps = (state, props) => { return { activity: getActivity(state), recentViews: getRecentViews(state), user: getUser(state), - showOnboarding: getShowOnboarding(state) + showOnboarding: "new" in props.location.query } } +import { push } from "react-router-redux"; + const mapDispatchToProps = { - ...homepageActions + ...homepageActions, + onChangeLocation: push } @connect(mapStateToProps, mapDispatchToProps) diff --git a/frontend/src/metabase/home/selectors.js b/frontend/src/metabase/home/selectors.js index 75a8b5430ef..45f2d4420af 100644 --- a/frontend/src/metabase/home/selectors.js +++ b/frontend/src/metabase/home/selectors.js @@ -1,4 +1,3 @@ export const getActivity = (state) => state.home && state.home.activity export const getRecentViews = (state) => state.home && state.home.recentViews export const getUser = (state) => state.currentUser -export const getShowOnboarding = (state) => state.router && state.router.location && "new" in state.router.location.query diff --git a/frontend/src/metabase/lib/card.js b/frontend/src/metabase/lib/card.js index 23bf207f838..fa82c25c3ad 100644 --- a/frontend/src/metabase/lib/card.js +++ b/frontend/src/metabase/lib/card.js @@ -82,6 +82,8 @@ export function serializeCardForUrl(card) { } export function deserializeCardFromUrl(serialized) { + serialized = serialized.replace(/^#/, ""); + console.log("deserializing", serialized) return JSON.parse(b64url_to_utf8(serialized)); } diff --git a/frontend/src/metabase/lib/cookies.js b/frontend/src/metabase/lib/cookies.js index fdfd9eca242..4d832e850c8 100644 --- a/frontend/src/metabase/lib/cookies.js +++ b/frontend/src/metabase/lib/cookies.js @@ -1,45 +1,40 @@ import { clearGoogleAuthCredentials } from "metabase/lib/auth"; -export const METABASE_SESSION_COOKIE = 'metabase.SESSION_ID'; - -var mb_cookies = {}; +// import Cookies from "js-cookie"; +export const METABASE_SESSION_COOKIE = 'metabase.SESSION_ID'; // Handles management of Metabase cookie work var MetabaseCookies = { - // a little weird, but needed to keep us hooked in with Angular - bootstrap: function($rootScope, $location, ipCookie) { - mb_cookies.scope = $rootScope; - mb_cookies.location = $location; - mb_cookies.ipCookie = ipCookie; - }, - // set the session cookie. if sessionId is null, clears the cookie setSessionCookie: function(sessionId) { - if (sessionId) { - // set a session cookie - var isSecure = (mb_cookies.location.protocol() === "https") ? true : false; - mb_cookies.ipCookie(METABASE_SESSION_COOKIE, sessionId, { - path: '/', - expires: 14, - secure: isSecure - }); - - // send a login notification event - mb_cookies.scope.$broadcast('appstate:login', sessionId); - - } else { - sessionId = mb_cookies.ipCookie(METABASE_SESSION_COOKIE); - - // delete the current session cookie and Google Auth creds - mb_cookies.ipCookie.remove(METABASE_SESSION_COOKIE); - clearGoogleAuthCredentials(); - - // send a logout notification event - mb_cookies.scope.$broadcast('appstate:logout', sessionId); - - return sessionId; + let ipCookie = angular.element(document.querySelector("body")).injector().get("ipCookie"); + + const options = { + path: '/', + expires: 14, + secure: window.location.protocol === "https:" + }; + + try { + if (sessionId) { + // set a session cookie + // Cookies.set(METABASE_SESSION_COOKIE, sessionId); + ipCookie(METABASE_SESSION_COOKIE, sessionId, options); + } else { + // sessionId = Cookies.get(METABASE_SESSION_COOKIE); + sessionId = ipCookie(METABASE_SESSION_COOKIE); + + // delete the current session cookie and Google Auth creds + // Cookies.remove(METABASE_SESSION_COOKIE); + ipCookie.remove(METABASE_SESSION_COOKIE); + clearGoogleAuthCredentials(); + + return sessionId; + } + } catch (e) { + console.error("setSessionCookie:", e); } } } diff --git a/frontend/src/metabase/nav/components/ProfileLink.jsx b/frontend/src/metabase/nav/components/ProfileLink.jsx index d180adca1e5..0dc608f2764 100644 --- a/frontend/src/metabase/nav/components/ProfileLink.jsx +++ b/frontend/src/metabase/nav/components/ProfileLink.jsx @@ -1,4 +1,5 @@ import React, { Component, PropTypes } from 'react'; +import { Link } from "react-router"; import OnClickOut from 'react-onclickout'; import cx from 'classnames'; import _ from "underscore"; @@ -74,24 +75,24 @@ export default class ProfileLink extends Component { <div className="NavDropdown-content right"> <ul className="NavDropdown-content-layer"> <li> - <a data-metabase-event={"Navbar;Profile Dropdown;Edit Profile"} onClick={this.closeDropdown} className="Dropdown-item block text-white no-decoration" href="/user/edit_current"> + <Link to="/user/edit_current" data-metabase-event={"Navbar;Profile Dropdown;Edit Profile"} onClick={this.closeDropdown} className="Dropdown-item block text-white no-decoration"> Account Settings - </a> + </Link> </li> { user.is_superuser && context !== 'admin' ? <li> - <a data-metabase-event={"Navbar;Profile Dropdown;Enter Admin"} onClick={this.closeDropdown} className="Dropdown-item block text-white no-decoration" href="/admin/"> + <Link to="/admin" data-metabase-event={"Navbar;Profile Dropdown;Enter Admin"} onClick={this.closeDropdown} className="Dropdown-item block text-white no-decoration"> Admin Panel - </a> + </Link> </li> : null } { user.is_superuser && context === 'admin' ? <li> - <a data-metabase-event={"Navbar;Profile Dropdown;Exit Admin"} onClick={this.closeDropdown} className="Dropdown-item block text-white no-decoration" href="/"> + <Link to="/" data-metabase-event={"Navbar;Profile Dropdown;Exit Admin"} onClick={this.closeDropdown} className="Dropdown-item block text-white no-decoration"> Exit Admin - </a> + </Link> </li> : null } @@ -116,7 +117,13 @@ export default class ProfileLink extends Component { </li> <li className="border-top border-light"> - <a data-metabase-event={"Navbar;Profile Dropdown;Logout"} className="Dropdown-item block text-white no-decoration" href="/auth/logout">Logout</a> + <Link + to="/auth/logout" + data-metabase-event={"Navbar;Profile Dropdown;Logout"} + className="Dropdown-item block text-white no-decoration" + > + Logout + </Link> </li> </ul> </div> diff --git a/frontend/src/metabase/nav/containers/DashboardsDropdown.jsx b/frontend/src/metabase/nav/containers/DashboardsDropdown.jsx index c0e499263f0..6ef3182e0db 100644 --- a/frontend/src/metabase/nav/containers/DashboardsDropdown.jsx +++ b/frontend/src/metabase/nav/containers/DashboardsDropdown.jsx @@ -1,5 +1,6 @@ import React, { Component, PropTypes } from 'react'; import { connect } from "react-redux"; +import { Link } from "react-router"; import OnClickOut from 'react-onclickout'; @@ -135,7 +136,7 @@ export default class DashboardsDropdown extends Component { <ul className="NavDropdown-content-layer"> { dashboards.map(dash => <li key={dash.id} className="block"> - <a data-metabase-event={"Navbar;Dashboard Dropdown;Open Dashboard;"+dash.id} className="Dropdown-item block text-white no-decoration" href={"/dash/"+dash.id} onClick={this.closeDropdown}> + <Link to={"/dash/"+dash.id} data-metabase-event={"Navbar;Dashboard Dropdown;Open Dashboard;"+dash.id} className="Dropdown-item block text-white no-decoration" onClick={this.closeDropdown}> <div className="flex text-bold"> {dash.name} </div> @@ -144,7 +145,7 @@ export default class DashboardsDropdown extends Component { {dash.description} </div> : null } - </a> + </Link> </li> )} <li className="block border-top border-light"> diff --git a/frontend/src/metabase/nav/containers/Navbar.jsx b/frontend/src/metabase/nav/containers/Navbar.jsx index 848444e8d78..45f04d3cca0 100644 --- a/frontend/src/metabase/nav/containers/Navbar.jsx +++ b/frontend/src/metabase/nav/containers/Navbar.jsx @@ -2,6 +2,8 @@ import React, { Component, PropTypes } from 'react'; import cx from "classnames"; import { connect } from "react-redux"; +import { push } from "react-router-redux"; +import { Link } from "react-router"; import Icon from "metabase/components/Icon.jsx"; import LogoIcon from "metabase/components/LogoIcon.jsx"; @@ -12,12 +14,13 @@ import ProfileLink from "metabase/nav/components/ProfileLink.jsx"; import { getPath, getContext, getUser } from "../selectors"; const mapStateToProps = (state, props) => ({ - path: getPath(state), - context: getContext(state), + path: getPath(state, props), + context: getContext(state, props), user: getUser(state) }); const mapDispatchToProps = { + onChangeLocation: push }; @connect(mapStateToProps, mapDispatchToProps) @@ -54,7 +57,9 @@ export default class Navbar extends Component { } renderAdminNav() { - const classes = "NavItem py1 px2 no-decoration"; + const getClasses = (path) => cx("NavItem py1 px2 no-decoration", { + "is--selected": this.isActive(path) + }); return ( <nav className={cx("Nav AdminNav", this.props.className)}> @@ -66,24 +71,24 @@ export default class Navbar extends Component { <ul className="sm-ml4 flex flex-full"> <li> - <a data-metabase-event={"Navbar;Settings"} className={cx(classes, {"is--selected": this.isActive("/admin/settings")})} href="/admin/settings/"> + <Link to="/admin/settings" data-metabase-event={"Navbar;Settings"} className={getClasses("/admin/settings")} > Settings - </a> + </Link> </li> <li> - <a data-metabase-event={"Navbar;People"} className={cx(classes, {"is--selected": this.isActive("/admin/people")})} href="/admin/people/"> + <Link to="/admin/people" data-metabase-event={"Navbar;People"} className={getClasses("/admin/people")} > People - </a> + </Link> </li> <li> - <a data-metabase-event={"Navbar;Data Model"} className={cx(classes, {"is--selected": this.isActive("/admin/datamodel")})} href="/admin/datamodel/database"> + <Link to="/admin/datamodel/database" data-metabase-event={"Navbar;Data Model"} className={getClasses("/admin/datamodel")} > Data Model - </a> + </Link> </li> <li> - <a data-metabase-event={"Navbar;Databases"} className={cx(classes, {"is--selected": this.isActive("/admin/databases")})} href="/admin/databases/"> + <Link to="/admin/databases" data-metabase-event={"Navbar;Databases"} className={getClasses("/admin/databases")}> Databases - </a> + </Link> </li> </ul> @@ -98,9 +103,9 @@ export default class Navbar extends Component { <nav className={cx("Nav py2 sm-py1 xl-py3 relative", this.props.className)}> <ul className="wrapper flex align-center"> <li> - <a data-metabase-event={"Navbar;Logo"} className="NavItem cursor-pointer flex align-center" href="/"> + <Link to="/" data-metabase-event={"Navbar;Logo"} className="NavItem cursor-pointer flex align-center"> <LogoIcon className="text-brand my2"></LogoIcon> - </a> + </Link> </li> </ul> </nav> @@ -112,9 +117,9 @@ export default class Navbar extends Component { <nav className={cx("Nav CheckBg CheckBg-offset relative bg-brand sm-py2 sm-py1 xl-py3", this.props.className)}> <ul className="pl4 pr1 flex align-center"> <li> - <a data-metabase-event={"Navbar;Logo"} className="NavItem cursor-pointer text-white flex align-center my1 transition-background" href="/"> - <span><LogoIcon className="text-white m1"></LogoIcon></span> - </a> + <Link to="/" data-metabase-event={"Navbar;Logo"} className="NavItem cursor-pointer text-white flex align-center my1 transition-background"> + <LogoIcon className="text-white m1"></LogoIcon> + </Link> </li> <li className="pl3"> <DashboardsDropdown {...this.props}> @@ -127,16 +132,16 @@ export default class Navbar extends Component { </DashboardsDropdown> </li> <li className="pl1"> - <a data-metabase-event={"Navbar;Questions"} style={this.styles.navButton} className={cx("NavItem cursor-pointer text-white text-bold no-decoration flex align-center px2 transition-background", {"NavItem--selected": this.isActive("/questions") })} href="/questions/all">Questions</a> + <Link to="/questions/all" data-metabase-event={"Navbar;Questions"} style={this.styles.navButton} className={cx("NavItem cursor-pointer text-white text-bold no-decoration flex align-center px2 transition-background")} activeClassName="NavItem--selected">Questions</Link> </li> <li className="pl1"> - <a data-metabase-event={"Navbar;Pulses"} style={this.styles.navButton} className={cx("NavItem cursor-pointer text-white text-bold no-decoration flex align-center px2 transition-background", {"NavItem--selected": this.isActive("/pulse") })} href="/pulse/">Pulses</a> + <Link to="/pulse" data-metabase-event={"Navbar;Pulses"} style={this.styles.navButton} className={cx("NavItem cursor-pointer text-white text-bold no-decoration flex align-center px2 transition-background")} activeClassName="NavItem--selected">Pulses</Link> </li> <li className="pl1"> - <a data-metabase-event={"Navbar;DataReference"} style={this.styles.navButton} className={cx("NavItem cursor-pointer text-white text-bold no-decoration flex align-center px2 transition-background", {"NavItem--selected": this.isActive("/reference") })} href="/reference/guide">Data Reference</a> + <Link to="/reference/guide" data-metabase-event={"Navbar;DataReference"} style={this.styles.navButton} className={cx("NavItem cursor-pointer text-white text-bold no-decoration flex align-center px2 transition-background")} activeClassName="NavItem--selected">Data Reference</Link> </li> <li className="pl3"> - <a data-metabase-event={"Navbar;New Question"} style={this.styles.newQuestion} className="NavNewQuestion rounded inline-block bg-white text-brand text-bold cursor-pointer px2 no-decoration transition-all" href="/q">New <span className="hide sm-show">Question</span></a> + <Link to="/q" data-metabase-event={"Navbar;New Question"} style={this.styles.newQuestion} className="NavNewQuestion rounded inline-block bg-white text-brand text-bold cursor-pointer px2 no-decoration transition-all">New <span className="hide sm-show">Question</span></Link> </li> <li className="flex-align-right transition-background"> <div className="inline-block text-white"><ProfileLink {...this.props}></ProfileLink></div> diff --git a/frontend/src/metabase/nav/selectors.js b/frontend/src/metabase/nav/selectors.js index 05d832607fb..612ad051c26 100644 --- a/frontend/src/metabase/nav/selectors.js +++ b/frontend/src/metabase/nav/selectors.js @@ -1,7 +1,7 @@ import { createSelector } from 'reselect'; -export const getPath = (state) => state.router.location.pathname; +export const getPath = (state, props) => props.location.pathname; export const getUser = (state) => state.currentUser; diff --git a/frontend/src/metabase/pulse/components/PulseEdit.jsx b/frontend/src/metabase/pulse/components/PulseEdit.jsx index deed84d1d12..9a5d7e5e0e3 100644 --- a/frontend/src/metabase/pulse/components/PulseEdit.jsx +++ b/frontend/src/metabase/pulse/components/PulseEdit.jsx @@ -1,4 +1,5 @@ import React, { Component, PropTypes } from "react"; +import { Link } from "react-router"; import PulseEditName from "./PulseEditName.jsx"; import PulseEditCards from "./PulseEditCards.jsx"; @@ -148,7 +149,7 @@ export default class PulseEdit extends Component { failedText="Save failed" successText="Saved" /> - <a className="text-bold flex-align-right no-decoration text-brand-hover" href="/pulse">Cancel</a> + <Link to="/pulse" className="text-bold flex-align-right no-decoration text-brand-hover">Cancel</Link> </div> </div> ); diff --git a/frontend/src/metabase/pulse/components/PulseListItem.jsx b/frontend/src/metabase/pulse/components/PulseListItem.jsx index 1515eb8cc8d..5d01ad3289f 100644 --- a/frontend/src/metabase/pulse/components/PulseListItem.jsx +++ b/frontend/src/metabase/pulse/components/PulseListItem.jsx @@ -1,5 +1,6 @@ import React, { Component, PropTypes } from "react"; import ReactDOM from "react-dom"; +import { Link } from "react-router"; import cx from "classnames"; @@ -31,15 +32,15 @@ export default class PulseListItem extends Component { <span>Pulse by <span className="text-bold">{pulse.creator && pulse.creator.common_name}</span></span> </div> <div className="flex-align-right"> - <a className="PulseEditButton PulseButton Button no-decoration text-bold" href={"/pulse/" + pulse.id}>Edit</a> + <Link to={"/pulse/" + pulse.id} className="PulseEditButton PulseButton Button no-decoration text-bold">Edit</Link> </div> </div> <ol className="mb2 px4 flex flex-wrap"> { pulse.cards.map((card, index) => <li key={index} className="mr1 mb1"> - <a className="Button" href={Urls.card(card.id)}> + <Link to={Urls.card(card.id)} className="Button"> {card.name} - </a> + </Link> </li> )} </ol> diff --git a/frontend/src/metabase/pulse/components/SetupMessage.jsx b/frontend/src/metabase/pulse/components/SetupMessage.jsx index 36fde9b7cc8..d953094b269 100644 --- a/frontend/src/metabase/pulse/components/SetupMessage.jsx +++ b/frontend/src/metabase/pulse/components/SetupMessage.jsx @@ -1,4 +1,5 @@ import React, { Component, PropTypes } from "react"; +import { Link } from "react-router"; import Icon from "metabase/components/Icon.jsx"; @@ -30,7 +31,7 @@ export default class SetupMessage extends Component { </div> <div className="mt2"> {channels.map(c => - <a className="Button Button--primary mr1" href={"/admin/settings?section="+c} target={window.OSX ? null : "_blank"}>Configure {c}</a> + <Link to={"/admin/settings/"+c.toLowerCase()} className="Button Button--primary mr1" target={window.OSX ? null : "_blank"}>Configure {c}</Link> )} </div> </div> diff --git a/frontend/src/metabase/pulse/containers/PulseEditApp.jsx b/frontend/src/metabase/pulse/containers/PulseEditApp.jsx index 86f56f94d6e..be8ef590beb 100644 --- a/frontend/src/metabase/pulse/containers/PulseEditApp.jsx +++ b/frontend/src/metabase/pulse/containers/PulseEditApp.jsx @@ -6,7 +6,7 @@ import { editPulseSelectors } from "../selectors"; const mapStateToProps = (state, props) => { return { - ...editPulseSelectors(state), + ...editPulseSelectors(state, props), user: state.currentUser // onChangeLocation: onChangeLocation } diff --git a/frontend/src/metabase/pulse/containers/PulseListApp.jsx b/frontend/src/metabase/pulse/containers/PulseListApp.jsx index 593702023f8..6567524c0a1 100644 --- a/frontend/src/metabase/pulse/containers/PulseListApp.jsx +++ b/frontend/src/metabase/pulse/containers/PulseListApp.jsx @@ -7,7 +7,7 @@ import { listPulseSelectors } from "../selectors"; const mapStateToProps = (state, props) => { return { - ...listPulseSelectors(state), + ...listPulseSelectors(state, props), user: state.currentUser, // onChangeLocation: onChangeLocation } diff --git a/frontend/src/metabase/pulse/selectors.js b/frontend/src/metabase/pulse/selectors.js index a1a0bfa44ab..7386a312f2a 100644 --- a/frontend/src/metabase/pulse/selectors.js +++ b/frontend/src/metabase/pulse/selectors.js @@ -30,19 +30,7 @@ const userListSelector = createSelector( (users) => Object.values(users) ); -const getPulseId = createSelector( - state => state.router, - (router) => { - if (router && router.params && router.params.pulseId) { - return parseInt(router.params.pulseId); - } else if (router && router.location && router.location.hash) { - return parseInt(router.location.hash.substr(1)); - } else { - return null; - } - } -); - +const getPulseId = (state, props) => props.params.pulseId ? parseInt(props.params.pulseId) : null; // LIST export const listPulseSelectors = createSelector( diff --git a/frontend/src/metabase/query_builder/QueryVisualization.jsx b/frontend/src/metabase/query_builder/QueryVisualization.jsx index c1b9226320f..ad137db4543 100644 --- a/frontend/src/metabase/query_builder/QueryVisualization.jsx +++ b/frontend/src/metabase/query_builder/QueryVisualization.jsx @@ -1,5 +1,6 @@ import React, { Component, PropTypes } from "react"; import ReactDOM from "react-dom"; +import { Link } from "react-router"; import Icon from "metabase/components/Icon.jsx"; import LoadingSpinner from 'metabase/components/LoadingSpinner.jsx'; @@ -259,5 +260,5 @@ export default class QueryVisualization extends Component { const VisualizationEmptyState = ({showTutorialLink}) => <div className="flex full layout-centered text-grey-1 flex-column"> <h1>If you give me some data I can show you something cool. Run a Query!</h1> - { showTutorialLink && <a className="link cursor-pointer my2" href="/q?tutorial">How do I use this thing?</a> } + { showTutorialLink && <Link to="/q?tutorial" className="link cursor-pointer my2">How do I use this thing?</Link> } </div> diff --git a/frontend/src/metabase/query_builder/actions.js b/frontend/src/metabase/query_builder/actions.js index 5a1e5c8fa25..e2dabba0267 100644 --- a/frontend/src/metabase/query_builder/actions.js +++ b/frontend/src/metabase/query_builder/actions.js @@ -6,9 +6,10 @@ import i from "icepick"; import moment from "moment"; import { AngularResourceProxy, angularPromise, createThunkAction } from "metabase/lib/redux"; +import { push, replace } from "react-router-redux"; import MetabaseAnalytics from "metabase/lib/analytics"; -import { loadCard, isCardDirty, startNewCard, deserializeCardFromUrl } from "metabase/lib/card"; +import { loadCard, isCardDirty, startNewCard, deserializeCardFromUrl, serializeCardForUrl, cleanCopyCard, urlForCardState } from "metabase/lib/card"; import { formatSQL, humanize } from "metabase/lib/formatting"; import Query from "metabase/lib/query"; import { createQuery } from "metabase/lib/query"; @@ -20,11 +21,50 @@ import { getParameters } from "./selectors"; const Metabase = new AngularResourceProxy("Metabase", ["db_list_with_tables", "db_fields", "dataset", "table_query_metadata"]); const User = new AngularResourceProxy("User", ["update_qbnewb"]); +import { parse as urlParse } from "url"; + +function updateUrl(card, isDirty=false, replaceState=false) { + if (!card) { + return; + } + var copy = cleanCopyCard(card); + var newState = { + card: copy, + cardId: copy.id, + serializedCard: serializeCardForUrl(copy) + }; + + // FIXME: history.state is not what we expect when using react-router + if (angular.equals(window.history.state, newState)) { + return; + } + + var url = urlForCardState(newState, isDirty); + + // if the serialized card is identical replace the previous state instead of adding a new one + // e.x. when saving a new card we want to replace the state and URL with one with the new card ID + // FIXME: history.state is not what we expect when using react-router + replaceState = replaceState || (window.history.state && window.history.state.serializedCard === newState.serializedCard); + + const urlParsed = urlParse(url); + const locationDescriptor = { + pathname: urlParsed.pathname, + search: urlParsed.search, + hash: urlParsed.hash, + state: newState + }; + + if (replaceState) { + return replace(locationDescriptor); + } else { + return push(locationDescriptor); + } +} export const INITIALIZE_QB = "INITIALIZE_QB"; -export const initializeQB = createThunkAction(INITIALIZE_QB, (updateUrl) => { +export const initializeQB = createThunkAction(INITIALIZE_QB, (location, params) => { return async (dispatch, getState) => { - const { router: { location, params: { cardId } }, currentUser } = getState(); + const { currentUser } = getState(); let card, databases, originalCard, uiControls = {}; @@ -45,11 +85,11 @@ export const initializeQB = createThunkAction(INITIALIZE_QB, (updateUrl) => { // load up or initialize the card we'll be working on const serializedCard = location.hash || null; const sampleDataset = _.findWhere(databases, { is_sample: true }); - if (cardId || serializedCard) { + if (params.cardId || serializedCard) { // existing card being loaded try { - if (cardId) { - card = await loadCard(cardId); + if (params.cardId) { + card = await loadCard(params.cardId); // when we are loading from a card id we want an explict clone of the card we loaded which is unmodified originalCard = JSON.parse(JSON.stringify(card)); @@ -64,10 +104,10 @@ export const initializeQB = createThunkAction(INITIALIZE_QB, (updateUrl) => { MetabaseAnalytics.trackEvent("QueryBuilder", "Query Loaded", card.dataset_query.type); // if we have deserialized card from the url AND loaded a card by id then the user should be dropped into edit mode - uiControls.isEditing = (location.query.edit || (cardId && serializedCard)) ? true : false; + uiControls.isEditing = (location.query.edit || (params.cardId && serializedCard)) ? true : false; // if this is the users first time loading a saved card on the QB then show them the newb modal - if (cardId && currentUser.is_qbnewb) { + if (params.cardId && currentUser.is_qbnewb) { uiControls.isShowingNewbModal = true; MetabaseAnalytics.trackEvent("QueryBuilder", "Show Newb Modal"); } @@ -120,14 +160,13 @@ export const initializeQB = createThunkAction(INITIALIZE_QB, (updateUrl) => { } // clean up the url and make sure it reflects our card state - updateUrl(card, isCardDirty(card, originalCard)); + dispatch(updateUrl(card, isCardDirty(card, originalCard))); return { card, originalCard, databases, - uiControls, - updateUrl + uiControls }; }; }); @@ -167,7 +206,7 @@ export const beginEditing = createAction(BEGIN_EDITING, () => { export const CANCEL_EDITING = "CANCEL_EDITING"; export const cancelEditing = createThunkAction(CANCEL_EDITING, () => { return (dispatch, getState) => { - const { qb: { originalCard, updateUrl } } = getState(); + const { qb: { originalCard } } = getState(); // clone let card = JSON.parse(JSON.stringify(originalCard)); @@ -176,7 +215,7 @@ export const cancelEditing = createThunkAction(CANCEL_EDITING, () => { // we do this to force the indication of the fact that the card should not be considered dirty when the url is updated dispatch(runQuery(card, false)); - updateUrl(card, false); + dispatch(updateUrl(card, false)); MetabaseAnalytics.trackEvent("QueryBuilder", "Edit Cancel"); return card; @@ -264,9 +303,9 @@ export const setCardAttribute = createAction(SET_CARD_ATTRIBUTE, (attr, value) = export const SET_CARD_VISUALIZATION = "SET_CARD_VISUALIZATION"; export const setCardVisualization = createThunkAction(SET_CARD_VISUALIZATION, (display) => { return (dispatch, getState) => { - const { qb: { card, uiControls, updateUrl } } = getState(); + const { qb: { card, uiControls } } = getState(); let updatedCard = updateVisualizationSettings(card, uiControls.isEditing, display, card.visualization_settings); - updateUrl(updatedCard, true); + dispatch(updateUrl(updatedCard, true)); return updatedCard; } }); @@ -274,9 +313,9 @@ export const setCardVisualization = createThunkAction(SET_CARD_VISUALIZATION, (d export const SET_CARD_VISUALIZATION_SETTING = "SET_CARD_VISUALIZATION_SETTING"; export const setCardVisualizationSetting = createThunkAction(SET_CARD_VISUALIZATION_SETTING, (path, value) => { return (dispatch, getState) => { - const { qb: { card, uiControls, updateUrl } } = getState(); + const { qb: { card, uiControls } } = getState(); let updatedCard = updateVisualizationSettings(card, uiControls.isEditing, card.display, i.assocIn(card.visualization_settings, path, value)); - updateUrl(updatedCard, true); + dispatch(updateUrl(updatedCard, true)); return updatedCard; }; }); @@ -284,9 +323,9 @@ export const setCardVisualizationSetting = createThunkAction(SET_CARD_VISUALIZAT export const SET_CARD_VISUALIZATION_SETTINGS = "SET_CARD_VISUALIZATION_SETTINGS"; export const setCardVisualizationSettings = createThunkAction(SET_CARD_VISUALIZATION_SETTINGS, (settings) => { return (dispatch, getState) => { - const { qb: { card, uiControls, updateUrl } } = getState(); + const { qb: { card, uiControls } } = getState(); let updatedCard = updateVisualizationSettings(card, uiControls.isEditing, card.display, settings); - updateUrl(updatedCard, true); + dispatch(updateUrl(updatedCard, true)); return updatedCard; }; }); @@ -326,13 +365,11 @@ export const setParameterValue = createThunkAction(SET_PARAMETER_VALUE, (paramet export const NOTIFY_CARD_CREATED = "NOTIFY_CARD_CREATED"; export const notifyCardCreatedFn = createThunkAction(NOTIFY_CARD_CREATED, (card) => { return (dispatch, getState) => { - const { qb: { updateUrl } } = getState(); - dispatch(loadMetadataForCard(card)); // we do this to force the indication of the fact that the card should not be considered dirty when the url is updated dispatch(runQuery(card, false)); - updateUrl(card, false); + dispatch(updateUrl(card, false)); MetabaseAnalytics.trackEvent("QueryBuilder", "Create Card", card.dataset_query.type); @@ -343,13 +380,11 @@ export const notifyCardCreatedFn = createThunkAction(NOTIFY_CARD_CREATED, (card) export const NOTIFY_CARD_UPDATED = "NOTIFY_CARD_UPDATED"; export const notifyCardUpdatedFn = createThunkAction("NOTIFY_CARD_UPDATED", (card) => { return (dispatch, getState) => { - const { qb: { updateUrl } } = getState(); - dispatch(loadMetadataForCard(card)); // we do this to force the indication of the fact that the card should not be considered dirty when the url is updated dispatch(runQuery(card, false)); - updateUrl(card, false); + dispatch(updateUrl(card, false)); MetabaseAnalytics.trackEvent("QueryBuilder", "Update Card", card.dataset_query.type); @@ -361,7 +396,7 @@ export const notifyCardUpdatedFn = createThunkAction("NOTIFY_CARD_UPDATED", (car export const RELOAD_CARD = "RELOAD_CARD"; export const reloadCard = createThunkAction(RELOAD_CARD, () => { return async (dispatch, getState) => { - const { qb: { originalCard, updateUrl } } = getState(); + const { qb: { originalCard } } = getState(); // clone let card = JSON.parse(JSON.stringify(originalCard)); @@ -370,7 +405,7 @@ export const reloadCard = createThunkAction(RELOAD_CARD, () => { // we do this to force the indication of the fact that the card should not be considered dirty when the url is updated dispatch(runQuery(card, false)); - updateUrl(card, false); + dispatch(updateUrl(card, false)); return card; }; @@ -687,7 +722,7 @@ export const setQuerySort = createThunkAction(SET_QUERY_SORT, (column) => { // runQuery export const RUN_QUERY = "RUN_QUERY"; -export const runQuery = createThunkAction(RUN_QUERY, (card, updateUrl=true, paramValues) => { +export const runQuery = createThunkAction(RUN_QUERY, (card, shouldUpdateUrl=true, paramValues) => { return async (dispatch, getState) => { const state = getState(); const parameters = getParameters(state); @@ -720,8 +755,8 @@ export const runQuery = createThunkAction(RUN_QUERY, (card, updateUrl=true, para }).filter(p => p); } - if (updateUrl) { - state.qb.updateUrl(card, cardIsDirty); + if (shouldUpdateUrl) { + dispatch(updateUrl(card, cardIsDirty)); } let cancelQueryDeferred = angularPromise(); diff --git a/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx b/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx index 1064dbdc7b6..435e5b72791 100644 --- a/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx +++ b/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx @@ -70,10 +70,9 @@ function autocompleteResults(card, prefix) { const mapStateToProps = (state, props) => { return { - updateUrl: props.updateUrl, user: state.currentUser, - fromUrl: state.router && state.router.location && state.router.location.query.from, - location: state.router && state.router.location, + fromUrl: props.location.query.from, + location: props.location, card: card(state), originalCard: originalCard(state), @@ -124,7 +123,7 @@ export default class QueryBuilder extends Component { } componentWillMount() { - this.props.initializeQB(this.props.updateUrl); + this.props.initializeQB(this.props.location, this.props.params); } componentDidMount() { @@ -141,7 +140,7 @@ export default class QueryBuilder extends Component { } // HACK: if we switch to the tutorial from within the QB we need to manually re-initialize if (!this.props.location.query.tutorial && nextProps.location.query.tutorial) { - this.props.initializeQB(nextProps.updateUrl); + this.props.initializeQB(this.props.location, this.props.params); } } @@ -157,6 +156,7 @@ export default class QueryBuilder extends Component { } popStateListener(e) { + // FIXME: e.state is not what we expect when using react-router if (e.state && e.state.card) { e.preventDefault(); this.props.setCardAndRun(e.state.card); diff --git a/frontend/src/metabase/query_builder/reducers.js b/frontend/src/metabase/query_builder/reducers.js index 2092bbfc532..877fdde76cb 100644 --- a/frontend/src/metabase/query_builder/reducers.js +++ b/frontend/src/metabase/query_builder/reducers.js @@ -36,11 +36,6 @@ import { } from "./actions"; -// TODO: these are here as work arounds until we are transitioned over to ReduxRouter and using their history approach -export const updateUrl = handleActions({ - [INITIALIZE_QB]: { next: (state, { payload }) => payload ? payload.updateUrl : state } -}, () => console.log("default")); - // TODO: once we are using the global redux store we can get this from there export const user = handleActions({ [CLOSE_QB_NEWB_MODAL]: { next: (state, { payload }) => ({...state, is_qbnewb: false}) } diff --git a/frontend/src/metabase/reducers.js b/frontend/src/metabase/reducers.js new file mode 100644 index 00000000000..5759d1bec38 --- /dev/null +++ b/frontend/src/metabase/reducers.js @@ -0,0 +1,65 @@ + +import { combineReducers } from 'redux'; + +import auth from "metabase/auth/auth"; + +/* ducks */ +import metadata from "metabase/redux/metadata"; +import requests from "metabase/redux/requests"; + +/* admin */ +import settings from "metabase/admin/settings/settings"; +import * as people from "metabase/admin/people/reducers"; +import databases from "metabase/admin/databases/database"; +import datamodel from "metabase/admin/datamodel/metadata"; + +/* dashboards */ +import dashboard from "metabase/dashboard/dashboard"; +import * as home from "metabase/home/reducers"; + +/* questions / query builder */ +import questions from "metabase/questions/questions"; +import labels from "metabase/questions/labels"; +import undo from "metabase/questions/undo"; +import * as qb from "metabase/query_builder/reducers"; + +/* data reference */ +import reference from "metabase/reference/reference"; + +/* pulses */ +import * as pulse from "metabase/pulse/reducers"; + +/* setup */ +import * as setup from "metabase/setup/reducers"; + +/* user */ +import * as user from "metabase/user/reducers"; +import { currentUser } from "metabase/user"; + +const reducers = { + // global reducers + auth, + currentUser, + metadata, + requests, + + // main app reducers + dashboard, + home: combineReducers(home), + labels, + pulse: combineReducers(pulse), + qb: combineReducers(qb), + questions, + reference, + setup: combineReducers(setup), + undo, + user: combineReducers(user), + + // admin reducers + databases, + datamodel: datamodel, + people: combineReducers(people), + settings +}; + +export default reducers; diff --git a/frontend/src/metabase/reference/containers/ReferenceApp.jsx b/frontend/src/metabase/reference/containers/ReferenceApp.jsx index dd47ced0486..e2c86b284ae 100644 --- a/frontend/src/metabase/reference/containers/ReferenceApp.jsx +++ b/frontend/src/metabase/reference/containers/ReferenceApp.jsx @@ -27,12 +27,12 @@ import { } from 'metabase/questions/questions'; const mapStateToProps = (state, props) => ({ - sectionId: getSectionId(state), - databaseId: getDatabaseId(state), - sections: getSections(state), - section: getSection(state), - breadcrumbs: getBreadcrumbs(state), - isEditing: getIsEditing(state) + sectionId: getSectionId(state, props), + databaseId: getDatabaseId(state, props), + sections: getSections(state, props), + section: getSection(state, props), + breadcrumbs: getBreadcrumbs(state, props), + isEditing: getIsEditing(state, props) }); const mapDispatchToProps = { diff --git a/frontend/src/metabase/reference/containers/ReferenceEntity.jsx b/frontend/src/metabase/reference/containers/ReferenceEntity.jsx index 867e5b981b1..3140debceb8 100644 --- a/frontend/src/metabase/reference/containers/ReferenceEntity.jsx +++ b/frontend/src/metabase/reference/containers/ReferenceEntity.jsx @@ -46,17 +46,17 @@ import * as metadataActions from 'metabase/redux/metadata'; import * as actions from 'metabase/reference/reference'; const mapStateToProps = (state, props) => ({ - section: getSection(state), - entity: getData(state) || {}, - loading: getLoading(state), + section: getSection(state, props), + entity: getData(state, props) || {}, + loading: getLoading(state, props), // naming this 'error' will conflict with redux form - loadingError: getError(state), - user: getUser(state), - foreignKeys: getForeignKeys(state), - isEditing: getIsEditing(state), - hasSingleSchema: getHasSingleSchema(state), - hasDisplayName: getHasDisplayName(state), - hasRevisionHistory: getHasRevisionHistory(state) + loadingError: getError(state, props), + user: getUser(state, props), + foreignKeys: getForeignKeys(state, props), + isEditing: getIsEditing(state, props), + hasSingleSchema: getHasSingleSchema(state, props), + hasDisplayName: getHasDisplayName(state, props), + hasRevisionHistory: getHasRevisionHistory(state, props) }); const mapDispatchToProps = { diff --git a/frontend/src/metabase/reference/containers/ReferenceEntityList.jsx b/frontend/src/metabase/reference/containers/ReferenceEntityList.jsx index 0eae0f94624..0d45f4e7255 100644 --- a/frontend/src/metabase/reference/containers/ReferenceEntityList.jsx +++ b/frontend/src/metabase/reference/containers/ReferenceEntityList.jsx @@ -33,12 +33,12 @@ import { import * as metadataActions from "metabase/redux/metadata"; const mapStateToProps = (state, props) => ({ - section: getSection(state), - entities: getData(state), - user: getUser(state), - hasSingleSchema: getHasSingleSchema(state), - loading: getLoading(state), - loadingError: getError(state) + section: getSection(state, props), + entities: getData(state, props), + user: getUser(state, props), + hasSingleSchema: getHasSingleSchema(state, props), + loading: getLoading(state, props), + loadingError: getError(state, props) }); const mapDispatchToProps = { diff --git a/frontend/src/metabase/reference/containers/ReferenceFieldsList.jsx b/frontend/src/metabase/reference/containers/ReferenceFieldsList.jsx index 79af13f641b..13d5442917a 100644 --- a/frontend/src/metabase/reference/containers/ReferenceFieldsList.jsx +++ b/frontend/src/metabase/reference/containers/ReferenceFieldsList.jsx @@ -37,15 +37,15 @@ import * as metadataActions from "metabase/redux/metadata"; import * as actions from 'metabase/reference/reference'; const mapStateToProps = (state, props) => { - const data = getData(state); + const data = getData(state, props); return { - section: getSection(state), + section: getSection(state, props), entities: data, - foreignKeys: getForeignKeys(state), - loading: getLoading(state), - loadingError: getError(state), - user: getUser(state), - isEditing: getIsEditing(state), + foreignKeys: getForeignKeys(state, props), + loading: getLoading(state, props), + loadingError: getError(state, props), + user: getUser(state, props), + isEditing: getIsEditing(state, props), fields: fieldsToFormFields(data) }; } diff --git a/frontend/src/metabase/reference/containers/ReferenceRevisionsList.jsx b/frontend/src/metabase/reference/containers/ReferenceRevisionsList.jsx index c37ebd395d3..0f592bb09b5 100644 --- a/frontend/src/metabase/reference/containers/ReferenceRevisionsList.jsx +++ b/frontend/src/metabase/reference/containers/ReferenceRevisionsList.jsx @@ -27,14 +27,14 @@ import ReferenceHeader from "../components/ReferenceHeader.jsx"; const mapStateToProps = (state, props) => { return { - section: getSection(state), - revisions: getData(state), - metric: getMetric(state), - segment: getSegment(state), - tables: getTables(state), - user: getUser(state), - loading: getLoading(state), - loadingError: getError(state) + section: getSection(state, props), + revisions: getData(state, props), + metric: getMetric(state, props), + segment: getSegment(state, props), + tables: getTables(state, props), + user: getUser(state, props), + loading: getLoading(state, props), + loadingError: getError(state, props) } } diff --git a/frontend/src/metabase/reference/selectors.js b/frontend/src/metabase/reference/selectors.js index 946ce2c76aa..472c09f75c5 100644 --- a/frontend/src/metabase/reference/selectors.js +++ b/frontend/src/metabase/reference/selectors.js @@ -67,7 +67,7 @@ const referenceSections = { } }; -const getReferenceSections = (state) => referenceSections; +const getReferenceSections = (state, props) => referenceSections; const getMetricSections = (metric, table, user) => metric ? { [`/reference/metrics/${metric.id}`]: { @@ -293,34 +293,34 @@ const idsToObjectMap = (ids, objects) => ids // hangs browser for large databases // .reduce((map, object) => i.assoc(map, object.id, object), {}); -export const getUser = (state) => state.currentUser; +export const getUser = (state, props) => state.currentUser; -export const getSectionId = (state) => state.router.location.pathname; +export const getSectionId = (state, props) => props.location.pathname; -export const getMetricId = (state) => Number.parseInt(state.router.params.metricId); -const getMetrics = (state) => state.metadata.metrics; +export const getMetricId = (state, props) => Number.parseInt(props.params.metricId); +const getMetrics = (state, props) => state.metadata.metrics; export const getMetric = createSelector( [getMetricId, getMetrics], (metricId, metrics) => metrics[metricId] || { id: metricId } ); -export const getSegmentId = (state) => Number.parseInt(state.router.params.segmentId); -const getSegments = (state) => state.metadata.segments; +export const getSegmentId = (state, props) => Number.parseInt(props.params.segmentId); +const getSegments = (state, props) => state.metadata.segments; export const getSegment = createSelector( [getSegmentId, getSegments], (segmentId, segments) => segments[segmentId] || { id: segmentId } ); -export const getDatabaseId = (state) => Number.parseInt(state.router.params.databaseId); -const getDatabases = (state) => state.metadata.databases; +export const getDatabaseId = (state, props) => Number.parseInt(props.params.databaseId); +const getDatabases = (state, props) => state.metadata.databases; const getDatabase = createSelector( [getDatabaseId, getDatabases], (databaseId, databases) => databases[databaseId] || { id: databaseId } ); -export const getTableId = (state) => Number.parseInt(state.router.params.tableId); -// export const getTableId = (state) => Number.parseInt(state.router.params.tableId); -export const getTables = (state) => state.metadata.tables; +export const getTableId = (state, props) => Number.parseInt(props.params.tableId); +// export const getTableId = (state, props) => Number.parseInt(props.params.tableId); +export const getTables = (state, props) => state.metadata.tables; const getTablesByDatabase = createSelector( [getTables, getDatabase], (tables, database) => tables && database && database.tables ? @@ -339,8 +339,8 @@ const getTableByMetric = createSelector( (metric, tables) => metric ? tables[metric.table_id] : {} ); -export const getFieldId = (state) => Number.parseInt(state.router.params.fieldId); -const getFields = (state) => state.metadata.fields; +export const getFieldId = (state, props) => Number.parseInt(props.params.fieldId); +const getFields = (state, props) => state.metadata.fields; const getFieldsByTable = createSelector( [getTable, getFields], (table, fields) => table && table.fields ? idsToObjectMap(table.fields, fields) : {} @@ -358,7 +358,7 @@ const getFieldBySegment = createSelector( (fieldId, fields) => fields[fieldId] || { id: fieldId } ); -const getQuestions = (state) => i.getIn(state, ['questions', 'entities', 'cards']) || {}; +const getQuestions = (state, props) => i.getIn(state, ['questions', 'entities', 'cards']) || {}; const getMetricQuestions = createSelector( [getMetricId, getQuestions], @@ -370,7 +370,7 @@ const getMetricQuestions = createSelector( .reduce((map, question) => i.assoc(map, question.id, question), {}) ); -const getRevisions = (state) => state.metadata.revisions; +const getRevisions = (state, props) => state.metadata.revisions; const getMetricRevisions = createSelector( [getMetricId, getRevisions], @@ -512,8 +512,8 @@ const dataSelectors = { getFieldsBySegment }; -export const getData = (state) => { - const section = getSection(state); +export const getData = (state, props) => { + const section = getSection(state, props); if (!section) { return {}; } @@ -522,12 +522,12 @@ export const getData = (state) => { return {}; } - return selector(state); + return selector(state, props); }; -export const getLoading = (state) => state.reference.isLoading; +export const getLoading = (state, props) => state.reference.isLoading; -export const getError = (state) => state.reference.error; +export const getError = (state, props) => state.reference.error; const getBreadcrumb = (section, index, sections) => index !== sections.length - 1 ? [section.breadcrumb, section.id] : [section.breadcrumb]; @@ -573,4 +573,4 @@ export const getHasRevisionHistory = createSelector( section.type === 'segment' ) -export const getIsEditing = (state) => state.reference.isEditing; +export const getIsEditing = (state, props) => state.reference.isEditing; diff --git a/frontend/src/metabase/services.js b/frontend/src/metabase/services.js index 5bf983386c6..16ff80d6e05 100644 --- a/frontend/src/metabase/services.js +++ b/frontend/src/metabase/services.js @@ -1,213 +1,4 @@ -import _ from "underscore"; - -import MetabaseAnalytics from 'metabase/lib/analytics'; -import MetabaseCookies from 'metabase/lib/cookies'; -import MetabaseSettings from 'metabase/lib/settings'; - - -var MetabaseServices = angular.module('metabase.services', ['http-auth-interceptor', 'ipCookie', 'metabase.core.services']); - -MetabaseServices.factory('AppState', ['$rootScope', '$q', '$location', '$interval', '$timeout', 'ipCookie', 'Session', 'User', 'Settings', - function($rootScope, $q, $location, $interval, $timeout, ipCookie, Session, User, Settings) { - // this is meant to be a global service used for keeping track of our overall app state - // we fire 2 events as things change in the app - // 1. appstate:user - - var initPromise; - var currentUserPromise; - - var service = { - - model: { - currentUser: null - }, - - init: function() { - - if (!initPromise) { - // hackery to allow MetabaseCookies to tie into Angular - MetabaseCookies.bootstrap($rootScope, $location, ipCookie); - - var deferred = $q.defer(); - initPromise = deferred.promise; - - // grab our global settings - service.refreshSiteSettings(); - - // just make sure we grab the current user - service.refreshCurrentUser().then(function(user) { - deferred.resolve(); - }, function(error) { - deferred.resolve(); - }); - } - - return initPromise; - }, - - clearState: function() { - currentUserPromise = null; - service.model.currentUser = null; - - // clear any existing session cookies if they exist - ipCookie.remove('metabase.SESSION_ID'); - }, - - refreshCurrentUser: function() { - - // this is meant to be called once on app startup - var userRefresh = User.current(function(result) { - service.model.currentUser = result; - - $rootScope.$broadcast('appstate:user', result); - - }, function(error) { - console.log('unable to get current user', error); - }); - - // NOTE: every time we refresh the user we update our current promise to ensure that - // we can guarantee we've resolved the current user - currentUserPromise = userRefresh.$promise; - - return currentUserPromise; - }, - - refreshSiteSettings: function() { - - var settingsRefresh = Session.properties(function(settings) { - - MetabaseSettings.setAll(_.omit(settings, function(value, key, object) { - return (key.indexOf('$') === 0); - })); - - $rootScope.$broadcast('appstate:site-settings', settings); - - }, function(error) { - console.log('unable to get site settings', error); - }); - - return settingsRefresh.$promise; - }, - - // This function performs whatever state cleanup and next steps are required when a user tries to access - // something they are not allowed to. - invalidAccess: function(user, url, message) { - $location.path('/unauthorized/'); - }, - - locationChanged: function(event) { - // this code is here to ensure that we have resolved our currentUser BEFORE we execute any other - // code meant to establish app context based on the current route - if (currentUserPromise) { - currentUserPromise.then(function(user) { - service.locationChangedImpl(event); - }, function(error) { - service.locationChangedImpl(event); - }); - } else { - service.locationChangedImpl(event); - } - }, - - locationChangedImpl: function(event) { - // whenever we have a route change (including initial page load) we need to establish some context - - // handle routing protections for /setup/ - if ($location.path().indexOf('/setup') === 0 && !MetabaseSettings.hasSetupToken()) { - // someone trying to access setup process without having a setup token, so block that. - $location.path('/'); - return; - } else if ($location.path().indexOf('/setup') !== 0 && MetabaseSettings.hasSetupToken()) { - // someone who has a setup token but isn't routing to setup yet, so send them there! - $location.path('/setup/'); - return; - } - - // if we don't have a current user then the only sensible destination is the login page - if (!service.model.currentUser) { - // make sure we clear out any current state just to be safe - service.clearState(); - - if ($location.path().indexOf('/auth/') !== 0 && $location.path().indexOf('/setup/') !== 0) { - // if the user is asking for a url outside of /auth/* then record the url then send them - // to login page, otherwise we will let the user continue on to their requested page - $location.path('/auth/login'); - } - - return; - } - - if ($location.path().indexOf('/admin/') === 0) { - // the user is trying to change to a superuser page - if (!service.model.currentUser.is_superuser) { - service.invalidAccess(service.model.currentUser, $location.url(), "user is not a superuser!!!"); - return; - } - - } - }, - }; - - // listen for location changes and use that as a trigger for page view tracking - $rootScope.$on('$locationChangeSuccess', function(event) { - service.locationChanged(event); - // NOTE: we are only taking the path right now to avoid accidentally grabbing sensitive data like table/field ids - MetabaseAnalytics.trackPageView($location.path()); - }); - - // login just took place, so lets force a refresh of the current user - $rootScope.$on("appstate:login", function(event, session_id) { - service.refreshCurrentUser(); - }); - - // logout just took place, do some cleanup - $rootScope.$on("appstate:logout", function(event, session_id) { - - // clear out any current state - service.clearState(); - - // NOTE that we don't really care about callbacks in this case - Session.delete({ - 'session_id': session_id - }); - }); - - // enable / disable GA based on opt-out of anonymous tracking - $rootScope.$on("appstate:site-settings", function(event, settings) { - const ga_code = MetabaseSettings.get('ga_code'); - if (MetabaseSettings.isTrackingEnabled()) { - // we are doing tracking - window['ga-disable-'+ga_code] = null; - } else { - // tracking is disabled - window['ga-disable-'+ga_code] = true; - } - }); - - // NOTE: the below events are generated from the http-auth-interceptor which listens on our $http calls - // and intercepts calls that result in a 401 or 403 so that we can handle them here. You must be - // careful to consider the implications of this because any endpoint that returns a 401/403 can - // have its call stack interrupted now and handled here instead of its normal callback sequence. - - // $http interceptor received a 401 response - $rootScope.$on("event:auth-loginRequired", function() { - // this is effectively just like a logout, we want to reset everything to a base state, then force login - service.clearState(); - - // this is ridiculously stupid. we have to wait (300ms) for the cookie to actually be set in the browser :( - $timeout(function() { - $location.path('/auth/login'); - }, 300); - }); - - // $http interceptor received a 403 response - $rootScope.$on("event:auth-forbidden", function() { - $location.path("/unauthorized"); - }); - - return service; - } -]); +angular.module('metabase.services', ['metabase.core.services']); // API Services var CoreServices = angular.module('metabase.core.services', ['ngResource', 'ngCookies']); diff --git a/frontend/src/metabase/setup/components/Setup.jsx b/frontend/src/metabase/setup/components/Setup.jsx index e7aab9dcdc2..40d223d79e5 100644 --- a/frontend/src/metabase/setup/components/Setup.jsx +++ b/frontend/src/metabase/setup/components/Setup.jsx @@ -1,4 +1,5 @@ import React, { Component, PropTypes } from "react"; +import { Link } from "react-router"; import LogoIcon from 'metabase/components/LogoIcon.jsx'; import NewsletterForm from 'metabase/components/NewsletterForm.jsx'; @@ -82,7 +83,7 @@ export default class Setup extends Component { <NewsletterForm initialEmail={userDetails.email} /> </div> <div className="pt4 pb2"> - <a className="Button Button--primary" href="/?new" onClick={this.completeSetup.bind(this)}>Take me to Metabase</a> + <Link to="/?new" className="Button Button--primary" onClick={this.completeSetup.bind(this)}>Take me to Metabase</Link> </div> </section> : null } diff --git a/frontend/src/metabase/store.js b/frontend/src/metabase/store.js new file mode 100644 index 00000000000..23c65f9b1b3 --- /dev/null +++ b/frontend/src/metabase/store.js @@ -0,0 +1,59 @@ +import { combineReducers, applyMiddleware, createStore, compose } from 'redux' +import { reducer as form } from "redux-form"; + +import { DEBUG } from "metabase/lib/debug"; + +import thunk from "redux-thunk"; +import promise from "redux-promise"; +import logger from "redux-logger"; + +const devToolsExtension = window.devToolsExtension ? window.devToolsExtension() : (f => f); + +let middleware = [thunk, promise]; +if (DEBUG) { + middleware.push(logger()); +} + +// START react-router-redux +import { routerReducer, routerMiddleware } from 'react-router-redux' +// END react-router-redux + +// START redux-router +// import { reduxReactRouter, routerStateReducer} from 'redux-router'; +// import { createHistory } from 'history'; +// END redux-router + +import appReducers from './reducers'; + +// Combine your base app reducer with the router reducer, +// now the application data will be in the "app" prop +// and the routing data will be in the "routing" prop of the State. +export function getStore(history, intialState) { + const reducer = combineReducers({ + ...appReducers, + form, + // START react-router-redux + routing: routerReducer, + // END react-router-redux + + // START redux-router + // router: routerStateReducer, + // end redux-router + }) + + // START react-router-redux + middleware.push(routerMiddleware(history)); + // END react-router-redux + + // Apply this middleware to the Store. + return createStore(reducer, intialState, compose( + applyMiddleware(...middleware), + + // START redux-router + // applyMiddleware(middleware), + // reduxReactRouter({ routes, createHistory }), + // END redux-router + + devToolsExtension + )); +} diff --git a/frontend/src/metabase/user.js b/frontend/src/metabase/user.js index 4aa015311cb..7dd8c53fd3a 100644 --- a/frontend/src/metabase/user.js +++ b/frontend/src/metabase/user.js @@ -4,7 +4,19 @@ import { handleActions } from 'redux-actions'; export const setUser = createAction("SET_USER"); +export const refreshCurrentUser = createAction("REFRESH_CURRENT_USER", async function getCurrentUser() { + try { + let response = await fetch("/api/user/current", { credentials: 'same-origin' }); + if (response.status === 200) { + return await response.json(); + } + } catch (e) { + console.warn("couldn't get user", e) + } + return null; +}) export const currentUser = handleActions({ - ["SET_USER"]: { next: (state, { payload }) => payload } + ["SET_USER"]: { next: (state, { payload }) => payload }, + ["REFRESH_CURRENT_USER"]: { next: (state, { payload }) => payload } }, null); diff --git a/frontend/src/metabase/user/actions.js b/frontend/src/metabase/user/actions.js index d4c77a43491..0f6bc7f6230 100644 --- a/frontend/src/metabase/user/actions.js +++ b/frontend/src/metabase/user/actions.js @@ -3,9 +3,10 @@ import { createAction } from "redux-actions"; import { AngularResourceProxy, createThunkAction } from "metabase/lib/redux"; // resource wrappers -const AppState = new AngularResourceProxy("AppState", ["refreshCurrentUser"]); +// const AppState = new AngularResourceProxy("AppState", ["refreshCurrentUser"]); const UserApi = new AngularResourceProxy("User", ["update", "update_password"]); +import { refreshCurrentUser } from "metabase/user"; // action constants export const CHANGE_TAB = 'CHANGE_TAB'; @@ -44,7 +45,8 @@ export const updateUser = createThunkAction(UPDATE_USER, function(user) { try { await UserApi.update(user); - AppState.refreshCurrentUser(); + // AppState.refreshCurrentUser(); + dispatch(refreshCurrentUser()); return { success: true, diff --git a/frontend/src/metabase/visualizations/Scalar.jsx b/frontend/src/metabase/visualizations/Scalar.jsx index 14b89d1d6bb..87e8e0e799d 100644 --- a/frontend/src/metabase/visualizations/Scalar.jsx +++ b/frontend/src/metabase/visualizations/Scalar.jsx @@ -1,4 +1,5 @@ import React, { Component, PropTypes } from "react"; +import { Link } from "react-router"; import styles from "./Scalar.css"; import Ellipsified from "metabase/components/Ellipsified.jsx"; @@ -166,7 +167,7 @@ export default class Scalar extends Component { {compactScalarValue} </Ellipsified> <Ellipsified className={styles.Title} tooltip={card.name}> - <a className="no-decoration fullscreen-normal-text fullscreen-night-text" href={Urls.card(card.id)}>{card.name}</a> + <Link to={Urls.card(card.id)} className="no-decoration fullscreen-normal-text fullscreen-night-text">{card.name}</Link> </Ellipsified> </div> ); diff --git a/package.json b/package.json index 9efe1f4b210..98850bface9 100644 --- a/package.json +++ b/package.json @@ -25,11 +25,12 @@ "dc": "^2.0.0-beta.25", "diff": "^2.2.1", "fixed-data-table": "^0.6.0", - "history": "1.12.0", + "history": "^3.0.0", "humanize-plus": "^1.8.1", "icepick": "^1.1.0", "inflection": "^1.7.1", "isomorphic-fetch": "^2.2.1", + "js-cookie": "^2.1.2", "moment": "^2.12.0", "node-libs-browser": "^0.5.3", "normalizr": "^2.0.0", @@ -43,19 +44,21 @@ "react-dom": "^15.2.1", "react-draggable": "^1.1.3", "react-onclickout": "^2.0.4", - "react-redux": "^4.4.0", + "react-redux": "^4.4.5", "react-resizable": "^1.0.1", "react-retina-image": "^2.0.0", - "react-router": "1.0.0", + "react-router": "^2.6.0", + "react-router-redux": "^4.0.5", "react-sortable": "^1.0.1", "react-virtualized": "^6.1.2", "recompose": "^0.20.2", - "redux": "^3.0.4", + "redux": "^3.5.2", "redux-actions": "^0.9.1", + "redux-auth-wrapper": "^0.6.0", "redux-form": "^4.2.0", "redux-logger": "^2.6.1", "redux-promise": "^0.5.0", - "redux-router": "^1.0.0-beta4", + "redux-router": "^2.1.2", "redux-thunk": "^2.0.1", "reselect": "^2.0.1", "screenfull": "^3.0.0", diff --git a/resources/frontend_client/index_template.html b/resources/frontend_client/index_template.html index ebcd9aa00bf..fb9832117d3 100644 --- a/resources/frontend_client/index_template.html +++ b/resources/frontend_client/index_template.html @@ -11,10 +11,14 @@ <script type="text/javascript"> window.MetabaseBootstrap = {{{bootstrap_json}}}; + console.log("DEBUG_LOAD_COUNT", localStorage.DEBUG_LOAD_COUNT = parseInt(localStorage.DEBUG_LOAD_COUNT || 0) + 1); </script> </head> - <body ng-controller="Metabase" ng-view></body> + <body> + <div id="root" /> + <div style="display: none;" ng-controller="Metabase" ng-view /> + </body> <script type="text/javascript"> // Load scripts asyncronously after the page has finished loading diff --git a/src/metabase/api/setup.clj b/src/metabase/api/setup.clj index 35df0c67e05..23e1551bda3 100644 --- a/src/metabase/api/setup.clj +++ b/src/metabase/api/setup.clj @@ -111,13 +111,13 @@ {:title "Set up email" :group "Get connected" :description "Add email credentials so you can more easily invite team members and get updates via Pulses." - :link "/admin/settings/?section=Email" + :link "/admin/settings/email" :completed (email/email-configured?) :triggered :always} {:title "Set Slack credentials" :group "Get connected" :description "Does your team use Slack? If so, you can send automated updates via pulses and ask questions with Metabot." - :link "/admin/settings/?section=Slack" + :link "/admin/settings/slack" :completed (slack/slack-configured?) :triggered :always} {:title "Invite team members" -- GitLab