diff --git a/frontend/src/metabase/admin/databases/containers/DatabaseEditApp.jsx b/frontend/src/metabase/admin/databases/containers/DatabaseEditApp.jsx index b3e122cbc78e59a1dedd976dc1c45c2e8ab5cbe2..942463586a2f08ce7f67c9fccadee684975191d9 100644 --- a/frontend/src/metabase/admin/databases/containers/DatabaseEditApp.jsx +++ b/frontend/src/metabase/admin/databases/containers/DatabaseEditApp.jsx @@ -2,6 +2,7 @@ import React, { Component } from "react"; import PropTypes from "prop-types"; import { connect } from "react-redux"; +import title from "metabase/hoc/Title"; import MetabaseSettings from "metabase/lib/settings"; import DeleteDatabaseModal from "../components/DeleteDatabaseModal.jsx"; @@ -41,6 +42,7 @@ const mapDispatchToProps = { }; @connect(mapStateToProps, mapDispatchToProps) +@title(({ database }) => database && database.name) export default class DatabaseEditApp extends Component { static propTypes = { database: PropTypes.object, diff --git a/frontend/src/metabase/admin/permissions/routes.jsx b/frontend/src/metabase/admin/permissions/routes.jsx index 47308bb5699934df97207a1de4e079202407ddd8..23c3df9119eedcda2a0710f7a4c4a5b29718097b 100644 --- a/frontend/src/metabase/admin/permissions/routes.jsx +++ b/frontend/src/metabase/admin/permissions/routes.jsx @@ -1,5 +1,7 @@ + import React from "react"; -import { Route, IndexRedirect } from 'react-router'; +import { Route } from "metabase/hoc/Title"; +import { IndexRedirect } from 'react-router'; import DataPermissionsApp from "./containers/DataPermissionsApp.jsx"; import DatabasesPermissionsApp from "./containers/DatabasesPermissionsApp.jsx"; @@ -7,7 +9,7 @@ import SchemasPermissionsApp from "./containers/SchemasPermissionsApp.jsx"; import TablesPermissionsApp from "./containers/TablesPermissionsApp.jsx"; const getRoutes = (store) => - <Route path="permissions" component={DataPermissionsApp}> + <Route title="Permissions" path="permissions" component={DataPermissionsApp}> <IndexRedirect to="databases" /> <Route path="databases" component={DatabasesPermissionsApp} /> <Route path="databases/:databaseId/schemas" component={SchemasPermissionsApp} /> diff --git a/frontend/src/metabase/admin/settings/containers/SettingsEditorApp.jsx b/frontend/src/metabase/admin/settings/containers/SettingsEditorApp.jsx index 0928a1420d60e83ec6c58c92eec2ed837016f83b..83e121977c4634a7a59b361963bfa73e21636f57 100644 --- a/frontend/src/metabase/admin/settings/containers/SettingsEditorApp.jsx +++ b/frontend/src/metabase/admin/settings/containers/SettingsEditorApp.jsx @@ -2,6 +2,8 @@ import React, { Component } from "react"; import PropTypes from "prop-types"; import { Link } from "react-router"; import { connect } from "react-redux"; + +import title from "metabase/hoc/Title"; import MetabaseAnalytics from "metabase/lib/analytics"; import AdminLayout from "metabase/components/AdminLayout.jsx"; @@ -41,6 +43,7 @@ const mapDispatchToProps = { } @connect(mapStateToProps, mapDispatchToProps) +@title(({ activeSection }) => activeSection && activeSection.name) export default class SettingsEditorApp extends Component { static propTypes = { sections: PropTypes.array.isRequired, diff --git a/frontend/src/metabase/dashboard/containers/DashboardApp.jsx b/frontend/src/metabase/dashboard/containers/DashboardApp.jsx index 8e704cde2cdc3225d7c6a29d626cc344f587e87d..568e01fefc0fa1fc671e1ce89d0ce0a76104fa86 100644 --- a/frontend/src/metabase/dashboard/containers/DashboardApp.jsx +++ b/frontend/src/metabase/dashboard/containers/DashboardApp.jsx @@ -3,6 +3,7 @@ import React, { Component } from "react"; import { connect } from "react-redux"; import { push } from "react-router-redux"; +import title from "metabase/hoc/Title"; import Dashboard from "../components/Dashboard.jsx"; @@ -40,6 +41,7 @@ const mapDispatchToProps = { } @connect(mapStateToProps, mapDispatchToProps) +@title(({ dashboard }) => dashboard && dashboard.name) export default class DashboardApp extends Component { render() { return <Dashboard {...this.props} />; diff --git a/frontend/src/metabase/hoc/Title.jsx b/frontend/src/metabase/hoc/Title.jsx new file mode 100644 index 0000000000000000000000000000000000000000..d150131d270ef4d6c2fca11c2f1721000be623ea --- /dev/null +++ b/frontend/src/metabase/hoc/Title.jsx @@ -0,0 +1,88 @@ +import React from "react"; + +import _ from "underscore"; + +const componentStack = []; + +let SEPARATOR = " · "; +let HIERARCHICAL = true; +let BASE_NAME = null; + +export const setSeparator = (separator) => SEPARATOR = separator; +export const setHierarchical = (hierarchical) => HIERARCHICAL = hierarchical; +export const setBaseName = (baseName) => BASE_NAME = baseName; + +const updateDocumentTitle = _.debounce(() => { + if (HIERARCHICAL) { + document.title = componentStack + .map(component => component._documentTitle) + .filter(title => title) + .reverse() + .join(SEPARATOR); + } else { + // update with the top-most title + for (let i = componentStack.length - 1; i >= 0; i--) { + let title = componentStack[i]._documentTitle; + if (title) { + if (BASE_NAME) { + title += SEPARATOR + BASE_NAME; + } + if (document.title !== title) { + document.title = title; + } + break; + } + } + } +}) + +const title = (documentTitleOrGetter) => (ComposedComponent) => + class extends React.Component { + static displayName = "Title["+(ComposedComponent.displayName || ComposedComponent.name)+"]"; + + componentWillMount() { + componentStack.push(this); + this._updateDocumentTitle(); + } + componentDidUpdate() { + this._updateDocumentTitle(); + } + componentWillUnmount() { + for (let i = 0; i < componentStack.length; i++) { + if (componentStack[i] === this) { + componentStack.splice(i, 1); + break; + } + } + this._updateDocumentTitle(); + } + + _updateDocumentTitle() { + if (typeof documentTitleOrGetter === "string") { + this._documentTitle = documentTitleOrGetter; + } else if (typeof documentTitleOrGetter === "function") { + this._documentTitle = documentTitleOrGetter(this.props); + } + updateDocumentTitle(); + } + + render() { + return <ComposedComponent {...this.props} />; + } + } + +export default title; + +import { Route as _Route } from "react-router"; + +// react-router Route wrapper that adds a `title` property +export class Route extends _Route { + static createRouteFromReactElement(element) { + if (element.props.title) { + element = React.cloneElement(element, { + component: title(element.props.title)(element.props.component || (({ children }) => children)) + }); + } + return _Route.createRouteFromReactElement(element); + } +} diff --git a/frontend/src/metabase/pulse/containers/PulseEditApp.jsx b/frontend/src/metabase/pulse/containers/PulseEditApp.jsx index 60c68b4aa41af0ee53acb4adb1de150d9ef6a8b8..cc54cf9df4699f98a7e7586a0b3c3623b63fc692 100644 --- a/frontend/src/metabase/pulse/containers/PulseEditApp.jsx +++ b/frontend/src/metabase/pulse/containers/PulseEditApp.jsx @@ -3,6 +3,8 @@ import React, { Component } from "react"; import { connect } from "react-redux"; import { push } from "react-router-redux"; +import title from "metabase/hoc/Title"; + import PulseEdit from "../components/PulseEdit.jsx"; import { editPulseSelectors } from "../selectors"; @@ -39,6 +41,7 @@ const mapDispatchToProps = { }; @connect(mapStateToProps, mapDispatchToProps) +@title(({ pulse }) => pulse && pulse.name) export default class PulseEditApp extends Component { render() { return ( diff --git a/frontend/src/metabase/qb/components/TimeseriesFilterWidget.jsx b/frontend/src/metabase/qb/components/TimeseriesFilterWidget.jsx index 285e528400e100284e6c370c8d317912019f1290..bacd9397af7e9c28ef6b18e944c21831d1a453d1 100644 --- a/frontend/src/metabase/qb/components/TimeseriesFilterWidget.jsx +++ b/frontend/src/metabase/qb/components/TimeseriesFilterWidget.jsx @@ -154,7 +154,9 @@ export default class TimeseriesFilterWidget extends Component<*, Props, State> { this._popover.close(); } }} - >Apply</Button> + > + Apply + </Button> </div> </PopoverWithTrigger> ); diff --git a/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx b/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx index d934891be8cb6a19b532b1d3611cda766ee24d69..261438e2947f45d2a1050a7e96705a415562cfcd 100644 --- a/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx +++ b/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx @@ -1,6 +1,7 @@ import React, { Component } from "react"; import ReactDOM from "react-dom"; import { connect } from "react-redux"; + import cx from "classnames"; import _ from "underscore"; @@ -18,6 +19,8 @@ import TagEditorSidebar from "../components/template_tags/TagEditorSidebar.jsx"; import SavedQuestionIntroModal from "../components/SavedQuestionIntroModal.jsx"; import ActionsWidget from "../components/ActionsWidget.jsx"; +import title from "metabase/hoc/Title"; + import { getCard, getOriginalCard, @@ -123,6 +126,7 @@ const mapDispatchToProps = { }; @connect(mapStateToProps, mapDispatchToProps) +@title(({ card }) => (card && card.name) || "Question") export default class QueryBuilder extends Component { constructor(props, context) { diff --git a/frontend/src/metabase/questions/containers/CollectionPage.jsx b/frontend/src/metabase/questions/containers/CollectionPage.jsx index c69b15be2244a3fd6a3394eeaf68a405e4f03789..49822508801a6e045b1436d90cb7b8b7aa3623fd 100644 --- a/frontend/src/metabase/questions/containers/CollectionPage.jsx +++ b/frontend/src/metabase/questions/containers/CollectionPage.jsx @@ -1,6 +1,7 @@ import React, { Component } from "react"; import { connect } from "react-redux"; import { push, replace, goBack } from "react-router-redux"; +import title from "metabase/hoc/Title"; import Icon from "metabase/components/Icon"; import HeaderWithBack from "metabase/components/HeaderWithBack"; @@ -27,6 +28,7 @@ const mapDispatchToProps = ({ }) @connect(mapStateToProps, mapDispatchToProps) +@title(({ collection }) => collection && collection.name) export default class CollectionPage extends Component { componentWillMount () { this.props.loadCollections(); diff --git a/frontend/src/metabase/routes.jsx b/frontend/src/metabase/routes.jsx index f52854442201ee62eae1d9693c4d623e074c5099..d09921e45620562426d7f06c7353ed7b1f9f5cb0 100644 --- a/frontend/src/metabase/routes.jsx +++ b/frontend/src/metabase/routes.jsx @@ -2,7 +2,8 @@ import React from "react"; -import { Route, Redirect, IndexRedirect, IndexRoute } from 'react-router'; +import { Route } from "metabase/hoc/Title"; +import { Redirect, IndexRedirect, IndexRoute } from 'react-router'; import { routerActions } from 'react-router-redux'; import { UserAuthWrapper } from 'redux-auth-wrapper'; @@ -114,7 +115,7 @@ const IsAdmin = MetabaseIsSetup(UserIsAuthenticated(UserIsAdmin(({ children }) = const IsNotAuthenticated = MetabaseIsSetup(UserIsNotAuthenticated(({ children }) => children)); export const getRoutes = (store) => - <Route component={App}> + <Route title="Metabase" component={App}> {/* SETUP */} <Route path="/setup" component={SetupApp} onEnter={(nextState, replace) => { if (!MetabaseSettings.hasSetupToken()) { @@ -137,7 +138,7 @@ export const getRoutes = (store) => <Route path="/auth"> <IndexRedirect to="/auth/login" /> <Route component={IsNotAuthenticated}> - <Route path="login" component={LoginApp} /> + <Route path="login" title="Login" component={LoginApp} /> </Route> <Route path="logout" component={LogoutApp} /> <Route path="forgot_password" component={ForgotPasswordApp} /> @@ -151,17 +152,17 @@ export const getRoutes = (store) => <Route path="/" component={HomepageApp} /> {/* DASHBOARD */} - <Route path="/dashboard/:dashboardId" component={DashboardApp} /> + <Route path="/dashboard/:dashboardId" title="Dashboard" component={DashboardApp} /> {/* QUERY BUILDER */} <Route path="/question" component={QueryBuilder} /> <Route path="/question/:cardId" component={QueryBuilder} /> {/* QUESTIONS */} - <Route path="/questions"> + <Route path="/questions" title="Questions"> <IndexRoute component={QuestionIndex} /> - <Route path="search" component={SearchResults} /> - <Route path="archive" component={Archive} /> + <Route path="search" title={({ location: { query: { q } }}) => "Search: " + q} component={SearchResults} /> + <Route path="archive" title="Archive" component={Archive} /> <Route path="collections/:collectionSlug" component={CollectionPage} /> </Route> @@ -182,9 +183,9 @@ export const getRoutes = (store) => </Route> {/* REFERENCE */} - <Route path="/reference" component={ReferenceApp}> + <Route path="/reference" title="Data Reference" component={ReferenceApp}> <IndexRedirect to="/reference/guide" /> - <Route path="guide" component={ReferenceGettingStartedGuide} /> + <Route path="guide" title="Getting Started" component={ReferenceGettingStartedGuide} /> <Route path="metrics" component={ReferenceEntityList} /> <Route path="metrics/:metricId" component={ReferenceEntity} /> <Route path="metrics/:metricId/questions" component={ReferenceEntityList} /> @@ -205,23 +206,27 @@ export const getRoutes = (store) => </Route> {/* PULSE */} - <Route path="/pulse" component={PulseListApp} /> - <Route path="/pulse/create" component={PulseEditApp} /> - <Route path="/pulse/:pulseId" component={PulseEditApp} /> + <Route path="/pulse" title="Pulses"> + <IndexRoute component={PulseListApp} /> + <Route path="create" component={PulseEditApp} /> + <Route path=":pulseId" component={PulseEditApp} /> + </Route> {/* USER */} <Route path="/user/edit_current" component={UserSettingsApp} /> </Route> {/* ADMIN */} - <Route path="/admin" component={IsAdmin}> + <Route path="/admin" title="Admin" component={IsAdmin}> <IndexRedirect to="/admin/settings" /> - <Route path="databases" component={DatabaseListApp} /> - <Route path="databases/create" component={DatabaseEditApp} /> - <Route path="databases/:databaseId" component={DatabaseEditApp} /> + <Route path="databases" title="Databases"> + <IndexRoute component={DatabaseListApp} /> + <Route path="create" component={DatabaseEditApp} /> + <Route path=":databaseId" component={DatabaseEditApp} /> + </Route> - <Route path="datamodel"> + <Route path="datamodel" title="Data Model"> <IndexRedirect to="database" /> <Route path="database" component={MetadataEditorApp} /> <Route path="database/:databaseId" component={MetadataEditorApp} /> @@ -235,16 +240,20 @@ export const getRoutes = (store) => </Route> {/* PEOPLE */} - <Route path="people" component={AdminPeopleApp}> + <Route path="people" title="People" component={AdminPeopleApp}> <IndexRoute component={PeopleListingApp} /> - <Route path="groups"> + <Route path="groups" title="Groups"> <IndexRoute component={GroupsListingApp} /> <Route path=":groupId" component={GroupDetailApp} /> </Route> </Route> - <Route path="settings" component={SettingsEditorApp} /> - <Route path="settings/:section" component={SettingsEditorApp} /> + {/* SETTINGS */} + <Route path="settings" title="Settings"> + <IndexRedirect to="/admin/settings/setup" /> + {/* <IndexRoute component={SettingsEditorApp} /> */} + <Route path=":section" component={SettingsEditorApp} /> + </Route> {getAdminPermissionsRoutes(store)} </Route>