diff --git a/frontend/src/metabase/dashboard/containers/DashboardApp.jsx b/frontend/src/metabase/dashboard/containers/DashboardApp.jsx index 452a52be5150ef491ce6e5aed9964797f8fc5c3b..37fbead141f83fd1e094576135a0b19f4e5639b5 100644 --- a/frontend/src/metabase/dashboard/containers/DashboardApp.jsx +++ b/frontend/src/metabase/dashboard/containers/DashboardApp.jsx @@ -4,6 +4,7 @@ import React, { Component } from "react"; import { connect } from "react-redux"; import { push } from "react-router-redux"; import title from "metabase/hoc/Title"; +import titleWithLoadingTime from "metabase/hoc/TitleWithLoadingTime"; import Dashboard from "metabase/dashboard/components/Dashboard"; @@ -21,6 +22,7 @@ import { getEditingParameter, getParameters, getParameterValues, + getLoadingStartTime, } from "../selectors"; import { getDatabases, getMetadata } from "metabase/selectors/metadata"; import { getUserIsAdmin } from "metabase/selectors/user"; @@ -47,6 +49,7 @@ const mapStateToProps = (state, props) => { parameters: getParameters(state, props), parameterValues: getParameterValues(state, props), metadata: getMetadata(state), + loadingStartTime: getLoadingStartTime(state), }; }; @@ -67,6 +70,7 @@ type DashboardAppState = { mapDispatchToProps, ) @title(({ dashboard }) => dashboard && dashboard.name) +@titleWithLoadingTime("loadingStartTime") // NOTE: should use DashboardControls and DashboardData HoCs here? export default class DashboardApp extends Component { state: DashboardAppState = { diff --git a/frontend/src/metabase/dashboard/dashboard.js b/frontend/src/metabase/dashboard/dashboard.js index ead731e6cd8fe61468a24309253a0067e203919f..f9e5b953e6f28d084e1e1196c41179e57503d336 100644 --- a/frontend/src/metabase/dashboard/dashboard.js +++ b/frontend/src/metabase/dashboard/dashboard.js @@ -1168,6 +1168,52 @@ const parameterValues = handleActions( {}, ); +const loadingDashCards = handleActions( + { + [FETCH_DASHBOARD]: { + next: (state, { payload }) => ({ + ...state, + dashcardIds: Object.values(payload.entities.dashcard || {}).map( + dc => dc.id, + ), + }), + }, + [FETCH_DASHBOARD_CARD_DATA]: { + next: state => ({ + ...state, + loadingIds: state.dashcardIds, + startTime: + state.dashcardIds.length > 0 && + // check that performance is defined just in case + typeof performance === "object" + ? performance.now() + : null, + }), + }, + [FETCH_CARD_DATA]: { + next: (state, { payload: { dashcard_id } }) => { + const loadingIds = state.loadingIds.filter(id => id !== dashcard_id); + return { + ...state, + loadingIds, + ...(loadingIds.length === 0 ? { startTime: null } : {}), + }; + }, + }, + [CANCEL_FETCH_CARD_DATA]: { + next: (state, { payload: { dashcard_id } }) => { + const loadingIds = state.loadingIds.filter(id => id !== dashcard_id); + return { + ...state, + loadingIds, + ...(loadingIds.length === 0 ? { startTime: null } : {}), + }; + }, + }, + }, + { dashcardIds: [], loadingIds: [], startTime: null }, +); + export default combineReducers({ dashboardId, isEditing, @@ -1179,4 +1225,5 @@ export default combineReducers({ dashcardData, slowCards, parameterValues, + loadingDashCards, }); diff --git a/frontend/src/metabase/dashboard/selectors.js b/frontend/src/metabase/dashboard/selectors.js index f9b33c19daec8e0647f6c6e667bff679a8d834ec..edcb7cec0bcf08728bbe72cb93ebf53e42a21175 100644 --- a/frontend/src/metabase/dashboard/selectors.js +++ b/frontend/src/metabase/dashboard/selectors.js @@ -44,6 +44,8 @@ export const getCardData = state => state.dashboard.dashcardData; export const getSlowCards = state => state.dashboard.slowCards; export const getCardIdList = state => state.dashboard.cardList; export const getParameterValues = state => state.dashboard.parameterValues; +export const getLoadingStartTime = state => + state.dashboard.loadingDashCards.startTime; export const getDashboard = createSelector( [getDashboardId, getDashboards], diff --git a/frontend/src/metabase/hoc/Title.jsx b/frontend/src/metabase/hoc/Title.jsx index 63f7d6d3e5eb8b253e04f7b63b180199e423fc15..8addf691a2c47c747ca1db27614085d3e66c2e24 100644 --- a/frontend/src/metabase/hoc/Title.jsx +++ b/frontend/src/metabase/hoc/Title.jsx @@ -4,36 +4,14 @@ 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 SEPARATOR = " · "; 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; - } - } - } + document.title = componentStack + .map(component => component._documentTitle) + .filter(title => title) + .reverse() + .join(SEPARATOR); }); const title = documentTitleOrGetter => ComposedComponent => @@ -64,7 +42,19 @@ const title = documentTitleOrGetter => ComposedComponent => if (typeof documentTitleOrGetter === "string") { this._documentTitle = documentTitleOrGetter; } else if (typeof documentTitleOrGetter === "function") { - this._documentTitle = documentTitleOrGetter(this.props); + const result = documentTitleOrGetter(this.props); + if (result == null) { + // title functions might return null before data is loaded + this._documentTitle = ""; + } else if (typeof result === "string") { + this._documentTitle = result; + } else if (typeof result === "object") { + // The getter can return an object with a `refresh` promise along with + // the title. When that promise resolves, we call + // `documentTitleOrGetter` again. + this._documentTitle = result.title; + result.refresh.then(() => this._updateDocumentTitle()); + } } updateDocumentTitle(); } diff --git a/frontend/src/metabase/hoc/TitleWithLoadingTime.jsx b/frontend/src/metabase/hoc/TitleWithLoadingTime.jsx new file mode 100644 index 0000000000000000000000000000000000000000..cb277a065a9695b6a591df227c45c0f58c5b2525 --- /dev/null +++ b/frontend/src/metabase/hoc/TitleWithLoadingTime.jsx @@ -0,0 +1,27 @@ +import React from "react"; + +import { delay } from "metabase/lib/promise"; +import title from "metabase/hoc/Title"; + +const SECONDS_UNTIL_DISPLAY = 10; + +export default startTimePropName => ComposedComponent => + title(({ [startTimePropName]: startTime }) => { + if (startTime == null) { + return ""; + } + const totalSeconds = (performance.now() - startTime) / 1000; + const title = + totalSeconds < SECONDS_UNTIL_DISPLAY + ? "" // don't display the title until SECONDS_UNTIL_DISPLAY have elapsed + : [totalSeconds / 60, totalSeconds % 60] // minutes, seconds + .map(Math.floor) // round both down + .map(x => (x < 10 ? `0${x}` : `${x}`)) // pad with "0" to two digits + .join(":"); // separate with ":" + return { title, refresh: delay(100) }; + })( + // remove the start time prop to prevent affecting child components + ({ [startTimePropName]: _removed, ...props }) => ( + <ComposedComponent {...props} /> + ), + ); diff --git a/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx b/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx index b153d54998adff6bf7504b98590b2885a7fd710b..54f3412ff1c2587bb4435b18d8a291377def7fd0 100644 --- a/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx +++ b/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx @@ -14,6 +14,7 @@ import View from "../components/view/View"; // import Notebook from "../components/notebook/Notebook"; import title from "metabase/hoc/Title"; +import titleWithLoadingTime from "metabase/hoc/TitleWithLoadingTime"; import { getCard, @@ -42,6 +43,7 @@ import { getQuestion, getOriginalQuestion, getSettings, + getQueryStartTime, getRawSeries, getQuestionAlerts, getVisualizationSettings, @@ -140,6 +142,7 @@ const mapStateToProps = (state, props) => { state, props, ), + queryStartTime: getQueryStartTime(state), }; }; @@ -153,6 +156,7 @@ const mapDispatchToProps = { mapDispatchToProps, ) @title(({ card }) => (card && card.name) || t`Question`) +@titleWithLoadingTime("queryStartTime") @fitViewport export default class QueryBuilder extends Component { timeout: any; diff --git a/frontend/src/metabase/query_builder/reducers.js b/frontend/src/metabase/query_builder/reducers.js index 884e4872ee86394093343dd6053768cd7d760e35..3e9d03f019f7597d3c0b2e58b83c1b1f29d262d5 100644 --- a/frontend/src/metabase/query_builder/reducers.js +++ b/frontend/src/metabase/query_builder/reducers.js @@ -303,6 +303,16 @@ export const cancelQueryDeferred = handleActions( null, ); +export const queryStartTime = handleActions( + { + [RUN_QUERY]: { next: (state, { payload }) => performance.now() }, + [CANCEL_QUERY]: { next: (state, { payload }) => null }, + [QUERY_COMPLETED]: { next: (state, { payload }) => null }, + [QUERY_ERRORED]: { next: (state, { payload }) => null }, + }, + null, +); + export const parameterValues = handleActions( { [SET_PARAMETER_VALUE]: { diff --git a/frontend/src/metabase/query_builder/selectors.js b/frontend/src/metabase/query_builder/selectors.js index 945e19a89e598be6de0774f3e8a8dc9ddb9b4e23..6844b6754a43aa94be8a521c1944ddb81299eceb 100644 --- a/frontend/src/metabase/query_builder/selectors.js +++ b/frontend/src/metabase/query_builder/selectors.js @@ -50,6 +50,8 @@ export const getSettings = state => state.settings.values; export const getIsNew = state => state.qb.card && !state.qb.card.id; +export const getQueryStartTime = state => state.qb.queryStartTime; + export const getDatabaseId = createSelector( [getCard], card => card && card.dataset_query && card.dataset_query.database, diff --git a/frontend/test/metabase/scenarios/pulse.cy.spec.js b/frontend/test/metabase/scenarios/pulse.cy.spec.js index ecc6015f43c00b6cbaf37a98b5b211db588baaf9..65183dc2860d5ec01b7c9faa0e8312b175cab295 100644 --- a/frontend/test/metabase/scenarios/pulse.cy.spec.js +++ b/frontend/test/metabase/scenarios/pulse.cy.spec.js @@ -32,7 +32,7 @@ describe("pulse", () => { cy.visit("/pulse/create"); cy.get('[placeholder="Important metrics"]') - .wait(10) + .wait(100) .type("pulse title"); cy.contains("Select a question").click();