From 964c8059e9d030ddb1c1fd2253700986e502229b Mon Sep 17 00:00:00 2001 From: Kyle Doherty <kdoh@users.noreply.github.com> Date: Thu, 22 Mar 2018 14:44:17 -0700 Subject: [PATCH] new components for easier question and result loading (#7134) * new components for easier question and result loading * code review updates * prettier * Cleanup loader component tests, props, and flow types * Cleanup question/result loaders, add rawQuery, add _internal Question demo app * lint fix --- .../containers/AdHocQuestionLoader.jsx | 166 +++++++++++++++++ .../containers/QuestionAndResultLoader.jsx | 59 +++++++ .../metabase/containers/QuestionLoader.jsx | 69 ++++++++ .../containers/QuestionResultLoader.jsx | 151 ++++++++++++++++ .../containers/SavedQuestionLoader.jsx | 167 ++++++++++++++++++ .../internal/components/ColorsApp.jsx | 2 + .../internal/components/ComponentsApp.jsx | 14 +- .../metabase/internal/components/IconsApp.jsx | 17 +- .../internal/components/QuestionApp.jsx | 51 ++++++ frontend/src/metabase/internal/routes.js | 36 ++-- .../src/metabase/meta/types/Visualization.js | 5 +- .../components/Visualization.jsx | 3 +- frontend/src/metabase/visualizations/index.js | 1 - .../visualizations/Progress.jsx | 3 +- .../AdHocQuestionLoader.unit.spec.js | 76 ++++++++ .../QuestionAndResultLoader.unit.spec.js | 10 ++ .../containers/QuestionLoader.unit.spec.js | 46 +++++ .../QuestionResultLoader.unit.spec.js | 22 +++ .../SavedQuestionLoader.unit.spec.js | 76 ++++++++ 19 files changed, 947 insertions(+), 27 deletions(-) create mode 100644 frontend/src/metabase/containers/AdHocQuestionLoader.jsx create mode 100644 frontend/src/metabase/containers/QuestionAndResultLoader.jsx create mode 100644 frontend/src/metabase/containers/QuestionLoader.jsx create mode 100644 frontend/src/metabase/containers/QuestionResultLoader.jsx create mode 100644 frontend/src/metabase/containers/SavedQuestionLoader.jsx create mode 100644 frontend/src/metabase/internal/components/QuestionApp.jsx create mode 100644 frontend/test/containers/AdHocQuestionLoader.unit.spec.js create mode 100644 frontend/test/containers/QuestionAndResultLoader.unit.spec.js create mode 100644 frontend/test/containers/QuestionLoader.unit.spec.js create mode 100644 frontend/test/containers/QuestionResultLoader.unit.spec.js create mode 100644 frontend/test/containers/SavedQuestionLoader.unit.spec.js diff --git a/frontend/src/metabase/containers/AdHocQuestionLoader.jsx b/frontend/src/metabase/containers/AdHocQuestionLoader.jsx new file mode 100644 index 00000000000..b891b2eebf8 --- /dev/null +++ b/frontend/src/metabase/containers/AdHocQuestionLoader.jsx @@ -0,0 +1,166 @@ +/* @flow */ + +import React from "react"; +import { connect } from "react-redux"; + +// things that will eventually load the quetsion +import { deserializeCardFromUrl } from "metabase/lib/card"; +import { loadMetadataForCard } from "metabase/query_builder/actions"; +import { getMetadata } from "metabase/selectors/metadata"; + +import Question from "metabase-lib/lib/Question"; + +// type annotations +import type Metadata from "metabase-lib/lib/metadata/Metadata"; +import type { Card } from "metabase/meta/types/Card"; + +type ChildProps = { + loading: boolean, + error: ?any, + question: ?Question, +}; + +type Props = { + questionHash?: string, + children?: (props: ChildProps) => React$Element<any>, + // provided by redux + loadMetadataForCard: (card: Card) => Promise<void>, + metadata: Metadata, +}; + +type State = { + // the question should be of type Question if it is set + question: ?Question, + card: ?Card, + loading: boolean, + error: ?any, +}; + +/* + * AdHocQuestionLoader + * + * Load a transient quetsion via its encoded URL and return it to the calling + * component + * + * @example + * + * Render prop style + * import AdHocQuestionLoader from 'metabase/containers/AdHocQuestionLoader' + * + * // assuming + * class ExampleAdHocQuestionFeature extends React.Component { + * render () { + * return ( + * <AdHocQuestionLoader questionId={this.props.params.questionId}> + * { ({ question, loading, error }) => { + * + * }} + * </SavedQuestion> + * ) + * } + * } + * + * @example + * + * The raw un-connected component is also exported so we can unit test it + * without the redux store. + */ +export class AdHocQuestionLoader extends React.Component { + props: Props; + + state: State = { + // this will store the loaded question + question: null, + // keep a reference to the card as well to help with re-creating question + // objects if the underlying metadata changes + card: null, + loading: false, + error: null, + }; + + componentWillMount() { + // load the specified question when the component mounts + this._loadQuestion(this.props.questionHash); + } + + componentWillReceiveProps(nextProps: Props) { + // if the questionHash changes (this will most likely be the result of a + // url change) then we need to load this new question + if (nextProps.questionHash !== this.props.questionHash) { + this._loadQuestion(nextProps.questionHash); + } + + // if the metadata changes for some reason we need to make sure we + // update the question with that metadata + if (nextProps.metadata !== this.props.metadata && this.state.card) { + this.setState({ + question: new Question(nextProps.metadata, this.state.card), + }); + } + } + + /* + * Load an AdHoc question and any required metadata + * + * 1. Decode the question via the URL + * 2. Load any required metadata into the redux store + * 3. Create a new Question object to return to metabase-lib methods can + * be used + * 4. Set the component state to the new Question + */ + async _loadQuestion(questionHash: ?string) { + if (!questionHash) { + this.setState({ + loading: false, + error: null, + question: null, + card: null, + }); + return; + } + try { + this.setState({ loading: true, error: null }); + // get the card definition from the URL, the "card" + const card = deserializeCardFromUrl(questionHash); + // pass the decoded card to load any necessary metadata + // (tables, source db, segments, etc) into + // the redux store, the resulting metadata will be avaliable as metadata on the + // component props once it's avaliable + await this.props.loadMetadataForCard(card); + + // instantiate a new question object using the metadata and saved question + // so we can use metabase-lib methods to retrieve information and modify + // the question + const question = new Question(this.props.metadata, card); + + // finally, set state to store the Question object so it can be passed + // to the component using the loader, keep a reference to the card + // as well + this.setState({ loading: false, question, card }); + } catch (error) { + this.setState({ loading: false, error }); + } + } + + render() { + const { children } = this.props; + const { question, loading, error } = this.state; + // call the child function with our loaded question + return children && children({ question, loading, error }); + } +} + +// redux stuff +function mapStateToProps(state) { + return { + metadata: getMetadata(state), + }; +} + +const mapDispatchToProps = { + loadMetadataForCard, +}; + +export default connect(mapStateToProps, mapDispatchToProps)( + AdHocQuestionLoader, +); diff --git a/frontend/src/metabase/containers/QuestionAndResultLoader.jsx b/frontend/src/metabase/containers/QuestionAndResultLoader.jsx new file mode 100644 index 00000000000..aac64d22466 --- /dev/null +++ b/frontend/src/metabase/containers/QuestionAndResultLoader.jsx @@ -0,0 +1,59 @@ +/* @flow */ + +import React from "react"; + +import QuestionLoader from "metabase/containers/QuestionLoader"; +import QuestionResultLoader from "metabase/containers/QuestionResultLoader"; + +import type { ChildProps as QuestionLoaderChildProps } from "./QuestionLoader"; +import type { ChildProps as QuestionResultLoaderChildProps } from "./QuestionResultLoader"; + +type ChildProps = QuestionLoaderChildProps & QuestionResultLoaderChildProps; + +type Props = { + questionId?: ?number, + questionHash?: ?string, + children?: (props: ChildProps) => React$Element<any>, +}; + +/* + * QuestionAndResultLoader + * + * Load a question and also run the query to get the result. Useful when you want + * to load both a question and its visualization at the same time. + * + * @example + * + * import QuestionAndResultLoader from 'metabase/containers/QuestionAndResultLoader' + * + * const MyNewFeature = ({ params, location }) => + * <QuestionAndResultLoader question={question}> + * { ({ question, result, cancel, reload }) => + * <div> + * </div> + * </QuestionAndResultLoader> + * + */ +const QuestionAndResultLoader = ({ + questionId, + questionHash, + children, +}: Props) => ( + <QuestionLoader questionId={questionId} questionHash={questionHash}> + {({ loading: questionLoading, error: questionError, ...questionProps }) => ( + <QuestionResultLoader question={questionProps.question}> + {({ loading: resultLoading, error: resultError, ...resultProps }) => + children && + children({ + ...questionProps, + ...resultProps, + loading: resultLoading || questionLoading, + error: resultError || questionError, + }) + } + </QuestionResultLoader> + )} + </QuestionLoader> +); + +export default QuestionAndResultLoader; diff --git a/frontend/src/metabase/containers/QuestionLoader.jsx b/frontend/src/metabase/containers/QuestionLoader.jsx new file mode 100644 index 00000000000..50ec004f47f --- /dev/null +++ b/frontend/src/metabase/containers/QuestionLoader.jsx @@ -0,0 +1,69 @@ +/* @flow */ + +import React from "react"; + +import AdHocQuestionLoader from "metabase/containers/AdHocQuestionLoader"; +import SavedQuestionLoader from "metabase/containers/SavedQuestionLoader"; + +import Question from "metabase-lib/lib/Question"; + +export type ChildProps = { + loading: boolean, + error: ?any, + question: ?Question, +}; + +type Props = { + questionId?: ?number, + questionHash?: ?string, + children?: (props: ChildProps) => React$Element<any>, +}; + +/* + * QuestionLoader + * + * Load either a saved or ad-hoc question depending on which is needed. Use + * this component if you need to moved between saved and ad-hoc questions + * as part of the same experience in the same part of the app. + * + * @example + * import QuestionLoader from 'metabase/containers/QuestionLoader + * + * const MyQuestionExplorer = ({ params, location }) => + * <QuestionLoader questionId={params.questionId} questionHash={ + * { ({ question, loading, error }) => + * <div> + * { // display info about the loaded question } + * <h1>{ question.displayName() }</h1> + * + * { // link to a new question created by adding a filter } + * <Link + * to={ + * question.query() + * .addFilter([ + * "SEGMENT", + * question.query().filterSegmentOptions()[0] + * ]) + * .question() + * .getUrl() + * } + * > + * View this ad-hoc exploration + * </Link> + * </div> + * } + * </QuestionLoader> + * + */ + +const QuestionLoader = ({ questionId, questionHash, children }: Props) => + // if there's a questionHash it means we're in ad-hoc land + questionHash ? ( + <AdHocQuestionLoader questionHash={questionHash} children={children} /> + ) : // otherwise if there's a non-null questionId it means we're in saved land + questionId != null ? ( + <SavedQuestionLoader questionId={questionId} children={children} /> + ) : // finally, if neither is present, just don't do anything + null; + +export default QuestionLoader; diff --git a/frontend/src/metabase/containers/QuestionResultLoader.jsx b/frontend/src/metabase/containers/QuestionResultLoader.jsx new file mode 100644 index 00000000000..fb24e9f7ccf --- /dev/null +++ b/frontend/src/metabase/containers/QuestionResultLoader.jsx @@ -0,0 +1,151 @@ +/* @flow */ + +import React from "react"; +import { defer } from "metabase/lib/promise"; + +import type { Dataset } from "metabase/meta/types/Dataset"; +import type { RawSeries } from "metabase/meta/types/Visualization"; + +import Question from "metabase-lib/lib/Question"; + +export type ChildProps = { + loading: boolean, + error: ?any, + results: ?(Dataset[]), + result: ?Dataset, + rawSeries: ?RawSeries, + cancel: () => void, + reload: () => void, +}; + +type Props = { + question: ?Question, + children?: (props: ChildProps) => React$Element<any>, +}; + +type State = { + results: ?(Dataset[]), + loading: boolean, + error: ?any, +}; + +/* + * Question result loader + * + * Handle runninng, canceling, and reloading Question results + * + * @example + * <QuestionResultLoader question={question}> + * { ({ result, cancel, reload }) => + * <div> + * { result && (<Visualization ... />) } + * + * <a onClick={() => reload()}>Reload this please</a> + * <a onClick={() => cancel()}>Changed my mind</a> + * </div> + * } + * </QuestionResultLoader> + * + */ +export class QuestionResultLoader extends React.Component { + props: Props; + state: State = { + results: null, + loading: false, + error: null, + }; + + _cancelDeferred: ?() => void; + + componentWillMount() { + this._loadResult(this.props.question); + } + + componentWillReceiveProps(nextProps: Props) { + // if the question is different, we need to do a fresh load, check the + // difference by comparing the URL we'd generate for the question + if ( + (nextProps.question && nextProps.question.getUrl()) !== + (this.props.question && this.props.question.getUrl()) + ) { + this._loadResult(nextProps.question); + } + } + + /* + * load the result by calling question.apiGetResults + */ + async _loadResult(question: ?Question) { + // we need to have a question for anything to happen + if (question) { + try { + // set up a defer for cancelation + this._cancelDeferred = defer(); + + // begin the request, set cancel in state so the query can be canceled + this.setState({ loading: true, results: null, error: null }); + + // call apiGetResults and pass our cancel to allow for cancelation + const results: Dataset[] = await question.apiGetResults({ + cancelDeferred: this._cancelDeferred, + }); + + // setState with our result, remove our cancel since we've finished + this.setState({ loading: false, results }); + } catch (error) { + this.setState({ loading: false, error }); + } + } else { + // if there's not a question we can't do anything so go back to our initial + // state + this.setState({ loading: false, results: null, error: null }); + } + } + + /* + * a function to pass to the child to allow the component to call + * load again + */ + _reload = () => { + this._loadResult(this.props.question); + }; + + /* + * a function to pass to the child to allow the component to interrupt + * the query + */ + _cancel = () => { + // we only want to do things if cancel has been set + if (this.state.loading) { + // set loading false + this.setState({ loading: false }); + // call our _cancelDeferred to cancel the query + if (this._cancelDeferred) { + this._cancelDeferred(); + } + } + }; + + render() { + const { question, children } = this.props; + const { results, loading, error } = this.state; + return ( + children && + children({ + results, + result: results && results[0], + // convienence for <Visualization /> component. Only support single series for now + rawSeries: + question && results + ? [{ card: question.card(), data: results[0].data }] + : null, + loading, + error, + cancel: this._cancel, + reload: this._reload, + }) + ); + } +} + +export default QuestionResultLoader; diff --git a/frontend/src/metabase/containers/SavedQuestionLoader.jsx b/frontend/src/metabase/containers/SavedQuestionLoader.jsx new file mode 100644 index 00000000000..76c543dd45e --- /dev/null +++ b/frontend/src/metabase/containers/SavedQuestionLoader.jsx @@ -0,0 +1,167 @@ +/* @flow */ + +import React from "react"; +import { connect } from "react-redux"; + +// things that will eventually load the quetsion +import { CardApi } from "metabase/services"; +import { loadMetadataForCard } from "metabase/query_builder/actions"; +import { getMetadata } from "metabase/selectors/metadata"; + +import Question from "metabase-lib/lib/Question"; + +// type annotations +import type Metadata from "metabase-lib/lib/metadata/Metadata"; +import type { Card } from "metabase/meta/types/Card"; + +type ChildProps = { + loading: boolean, + error: ?any, + question: ?Question, +}; + +type Props = { + questionId: ?number, + children?: (props: ChildProps) => React$Element<any>, + // provided by redux + loadMetadataForCard: (card: Card) => Promise<void>, + metadata: Metadata, +}; + +type State = { + // the question should be of type Question if it is set + question: ?Question, + // keep a reference to the card as well to help with re-creating question + // objects if the underlying metadata changes + card: ?Card, + loading: boolean, + error: ?any, +}; + +/* + * SavedQuestionLaoder + * + * Load a saved quetsion and return it to the calling component + * + * @example + * + * Render prop style + * import SavedQuestionLoader from 'metabase/containers/SavedQuestionLoader' + * + * // assuming + * class ExampleSavedQuestionFeature extends React.Component { + * render () { + * return ( + * <SavedQuestionLoader questionId={this.props.params.questionId}> + * { ({ question, loading, error }) => { + * + * }} + * </SavedQuestion> + * ) + * } + * } + * + * @example + * + * The raw un-connected component is also exported so we can unit test it + * without the redux store. + */ +export class SavedQuestionLoader extends React.Component { + props: Props; + + state: State = { + // this will store the loaded question + question: null, + card: null, + loading: false, + error: null, + }; + + componentWillMount() { + // load the specified question when the component mounts + this._loadQuestion(this.props.questionId); + } + + componentWillReceiveProps(nextProps: Props) { + // if the questionId changes (this will most likely be the result of a + // url change) then we need to load this new question + if (nextProps.questionId !== this.props.questionId) { + this._loadQuestion(nextProps.questionId); + } + + // if the metadata changes for some reason we need to make sure we + // update the question with that metadata + if (nextProps.metadata !== this.props.metadata && this.state.card) { + this.setState({ + question: new Question(nextProps.metadata, this.state.card), + }); + } + } + + /* + * Load a saved question and any required metadata + * + * 1. Get the card from the api + * 2. Load any required metadata into the redux store + * 3. Create a new Question object to return to metabase-lib methods can + * be used + * 4. Set the component state to the new Question + */ + async _loadQuestion(questionId: ?number) { + if (questionId == null) { + this.setState({ + loading: false, + error: null, + question: null, + card: null, + }); + return; + } + try { + this.setState({ loading: true, error: null }); + // get the saved question via the card API + const card = await CardApi.get({ cardId: questionId }); + + // pass the retrieved card to load any necessary metadata + // (tables, source db, segments, etc) into + // the redux store, the resulting metadata will be avaliable as metadata on the + // component props once it's avaliable + await this.props.loadMetadataForCard(card); + + // instantiate a new question object using the metadata and saved question + // so we can use metabase-lib methods to retrieve information and modify + // the question + // + const question = new Question(this.props.metadata, card); + + // finally, set state to store the Question object so it can be passed + // to the component using the loader, keep a reference to the card + // as well + this.setState({ loading: false, question, card }); + } catch (error) { + this.setState({ loading: false, error }); + } + } + + render() { + const { children } = this.props; + const { question, loading, error } = this.state; + // call the child function with our loaded question + return children && children({ question, loading, error }); + } +} + +// redux stuff +function mapStateToProps(state) { + return { + metadata: getMetadata(state), + }; +} + +const mapDispatchToProps = { + loadMetadataForCard, +}; + +export default connect(mapStateToProps, mapDispatchToProps)( + SavedQuestionLoader, +); diff --git a/frontend/src/metabase/internal/components/ColorsApp.jsx b/frontend/src/metabase/internal/components/ColorsApp.jsx index aa1e941aa12..2c82dccc803 100644 --- a/frontend/src/metabase/internal/components/ColorsApp.jsx +++ b/frontend/src/metabase/internal/components/ColorsApp.jsx @@ -1,3 +1,5 @@ +/* @flow */ + import React from "react"; import cx from "classnames"; diff --git a/frontend/src/metabase/internal/components/ComponentsApp.jsx b/frontend/src/metabase/internal/components/ComponentsApp.jsx index 045c613dd27..6111b2d3275 100644 --- a/frontend/src/metabase/internal/components/ComponentsApp.jsx +++ b/frontend/src/metabase/internal/components/ComponentsApp.jsx @@ -1,5 +1,7 @@ +/* @flow */ + import React, { Component } from "react"; -import { Link } from "react-router"; +import { Link, Route } from "react-router"; import { slugify } from "metabase/lib/formatting"; import reactElementToJSXString from "react-element-to-jsx-string"; @@ -13,6 +15,7 @@ const Section = ({ title, children }) => ( ); export default class ComponentsApp extends Component { + static routes: ?[React$Element<Route>]; render() { const componentName = slugify(this.props.params.componentName); const exampleName = slugify(this.props.params.exampleName); @@ -116,3 +119,12 @@ export default class ComponentsApp extends Component { ); } } + +ComponentsApp.routes = [ + <Route path="components" component={ComponentsApp} />, + <Route path="components/:componentName" component={ComponentsApp} />, + <Route + path="components/:componentName/:exampleName" + component={ComponentsApp} + />, +]; diff --git a/frontend/src/metabase/internal/components/IconsApp.jsx b/frontend/src/metabase/internal/components/IconsApp.jsx index 1e688eccfc1..9634fdb832f 100644 --- a/frontend/src/metabase/internal/components/IconsApp.jsx +++ b/frontend/src/metabase/internal/components/IconsApp.jsx @@ -1,16 +1,21 @@ +/* @flow */ + import React, { Component } from "react"; import Icon from "metabase/components/Icon.jsx"; const SIZES = [12, 16]; +type Props = {}; +type State = { + size: number, +}; + export default class IconsApp extends Component { - constructor(props) { - super(props); - this.state = { - size: 32, - }; - } + props: Props; + state: State = { + size: 32, + }; render() { let sizes = SIZES.concat(this.state.size); return ( diff --git a/frontend/src/metabase/internal/components/QuestionApp.jsx b/frontend/src/metabase/internal/components/QuestionApp.jsx new file mode 100644 index 00000000000..d27666c80c6 --- /dev/null +++ b/frontend/src/metabase/internal/components/QuestionApp.jsx @@ -0,0 +1,51 @@ +/* @flow */ + +import React from "react"; +import { Route } from "react-router"; + +import QuestionAndResultLoader from "metabase/containers/QuestionAndResultLoader"; +import Visualization from "metabase/visualizations/components/Visualization"; + +type Props = { + location: { + hash: ?string, + }, + params: { + questionId?: string, + }, +}; + +export default class QuestionApp extends React.Component { + props: Props; + + static routes: ?[React$Element<Route>]; + + render() { + const { location, params } = this.props; + if (!location.hash && !params.questionId) { + return ( + <div className="p4 text-centered flex-full"> + Visit <strong>/_internal/question/:id</strong> or{" "} + <strong>/_internal/question#:hash</strong>. + </div> + ); + } + return ( + <QuestionAndResultLoader + questionHash={location.hash} + questionId={params.questionId ? parseInt(params.questionId) : null} + > + {({ rawSeries }) => + rawSeries && ( + <Visualization className="flex-full" rawSeries={rawSeries} /> + ) + } + </QuestionAndResultLoader> + ); + } +} + +QuestionApp.routes = [ + <Route path="question" component={QuestionApp} />, + <Route path="question/:questionId" component={QuestionApp} />, +]; diff --git a/frontend/src/metabase/internal/routes.js b/frontend/src/metabase/internal/routes.js index cefece31d1c..eb2d40d213c 100644 --- a/frontend/src/metabase/internal/routes.js +++ b/frontend/src/metabase/internal/routes.js @@ -1,15 +1,20 @@ +/* @flow */ + import React from "react"; import { Route, IndexRoute } from "react-router"; -import IconsApp from "metabase/internal/components/IconsApp"; -import ColorsApp from "metabase/internal/components/ColorsApp"; -import ComponentsApp from "metabase/internal/components/ComponentsApp"; +// $FlowFixMe: doesn't know about require.context +const req = require.context( + "metabase/internal/components", + true, + /(\w+)App.jsx$/, +); -const PAGES = { - Icons: IconsApp, - Colors: ColorsApp, - Components: ComponentsApp, -}; +const PAGES = {}; +for (const key of req.keys()) { + const name = key.match(/(\w+)App.jsx$/)[1]; + PAGES[name] = req(key).default; +} const WelcomeApp = () => { return ( @@ -49,13 +54,12 @@ const InternalLayout = ({ children }) => { export default ( <Route component={InternalLayout}> <IndexRoute component={WelcomeApp} /> - {Object.entries(PAGES).map(([name, Component]) => ( - <Route path={name.toLowerCase()} component={Component} /> - ))} - <Route path="components/:componentName" component={ComponentsApp} /> - <Route - path="components/:componentName/:exampleName" - component={ComponentsApp} - /> + {Object.entries(PAGES).map( + ([name, Component]) => + Component && + (Component.routes || ( + <Route path={name.toLowerCase()} component={Component} /> + )), + )} </Route> ); diff --git a/frontend/src/metabase/meta/types/Visualization.js b/frontend/src/metabase/meta/types/Visualization.js index e59453f964c..860bc38b6df 100644 --- a/frontend/src/metabase/meta/types/Visualization.js +++ b/frontend/src/metabase/meta/types/Visualization.js @@ -68,8 +68,11 @@ export type ClickActionPopoverProps = { }; export type SingleSeries = { card: Card, data: DatasetData }; -export type Series = SingleSeries[] & { _raw: Series }; +export type RawSeries = SingleSeries[]; +export type TransformedSeries = RawSeries & { _raw: Series }; +export type Series = RawSeries | TransformedSeries; +// These are the props provided to the visualization implementations BY the Visualization component export type VisualizationProps = { series: Series, card: Card, diff --git a/frontend/src/metabase/visualizations/components/Visualization.jsx b/frontend/src/metabase/visualizations/components/Visualization.jsx index 682d2a1b360..f97f8e77419 100644 --- a/frontend/src/metabase/visualizations/components/Visualization.jsx +++ b/frontend/src/metabase/visualizations/components/Visualization.jsx @@ -46,12 +46,13 @@ import type { HoverObject, ClickObject, Series, + RawSeries, OnChangeCardAndRun, } from "metabase/meta/types/Visualization"; import Metadata from "metabase-lib/lib/metadata/Metadata"; type Props = { - rawSeries: Series, + rawSeries: RawSeries, className: string, diff --git a/frontend/src/metabase/visualizations/index.js b/frontend/src/metabase/visualizations/index.js index 492ae0a8966..7a62db9110d 100644 --- a/frontend/src/metabase/visualizations/index.js +++ b/frontend/src/metabase/visualizations/index.js @@ -70,7 +70,6 @@ export function getVisualizationTransformed(series: Series) { series = CardVisualization.transformSeries(series); } if (series !== lastSeries) { - // $FlowFixMe series = [...series]; // $FlowFixMe series._raw = lastSeries; diff --git a/frontend/src/metabase/visualizations/visualizations/Progress.jsx b/frontend/src/metabase/visualizations/visualizations/Progress.jsx index ee312a55d51..ee0a439cd41 100644 --- a/frontend/src/metabase/visualizations/visualizations/Progress.jsx +++ b/frontend/src/metabase/visualizations/visualizations/Progress.jsx @@ -110,7 +110,8 @@ export default class Progress extends Component { onVisualizationClick, visualizationIsClickable, } = this.props; - const value: number = rows[0][0]; + const value: number = + rows[0] && typeof rows[0][0] === "number" ? rows[0][0] : 0; const goal = settings["progress.goal"] || 0; const mainColor = settings["progress.color"]; diff --git a/frontend/test/containers/AdHocQuestionLoader.unit.spec.js b/frontend/test/containers/AdHocQuestionLoader.unit.spec.js new file mode 100644 index 00000000000..f9cc2d0e5c7 --- /dev/null +++ b/frontend/test/containers/AdHocQuestionLoader.unit.spec.js @@ -0,0 +1,76 @@ +import React from "react"; +import { shallow, mount } from "enzyme"; + +import Question from "metabase-lib/lib/Question"; +import { delay } from "metabase/lib/promise"; + +// import the un-connected component so we can test its internal logic sans +// redux +import { AdHocQuestionLoader } from "metabase/containers/AdHocQuestionLoader"; + +describe("AdHocQuestionLoader", () => { + let loadQuestionSpy, loadMetadataSpy, mockChild; + beforeEach(() => { + // reset mocks between tests so we have fresh spies, etc + jest.resetAllMocks(); + mockChild = jest.fn().mockReturnValue(<div />); + loadMetadataSpy = jest.fn(); + loadQuestionSpy = jest.spyOn( + AdHocQuestionLoader.prototype, + "_loadQuestion", + ); + }); + + it("should load a question given a questionHash", async () => { + const q = new Question.create({ databaseId: 1, tableId: 2 }); + const questionHash = q.getUrl().match(/(#.*)/)[1]; + + const wrapper = mount( + <AdHocQuestionLoader + questionHash={questionHash} + loadMetadataForCard={loadMetadataSpy} + children={mockChild} + />, + ); + expect(mockChild.mock.calls[0][0].loading).toEqual(true); + expect(mockChild.mock.calls[0][0].error).toEqual(null); + + // stuff happens asynchronously + wrapper.update(); + await delay(0); + + expect(loadMetadataSpy.mock.calls[0][0]).toEqual(q.card()); + + const calls = mockChild.mock.calls; + const { question, loading, error } = calls[calls.length - 1][0]; + expect(question.card()).toEqual(q.card()); + expect(loading).toEqual(false); + expect(error).toEqual(null); + }); + + it("should load a new question if the question hash changes", () => { + // create some junk strigs, real question hashes are more ludicrous but this + // is easier to verify + const originalQuestionHash = "#abc123"; + const newQuestionHash = "#def456"; + + const wrapper = shallow( + <AdHocQuestionLoader + questionHash={originalQuestionHash} + loadMetadataForCard={loadMetadataSpy} + children={mockChild} + />, + ); + + expect(loadQuestionSpy).toHaveBeenCalledWith(originalQuestionHash); + + // update the question hash, a new location.hash in the url would most + // likely do this + wrapper.setProps({ questionHash: newQuestionHash }); + + // question loading should begin with the new ID + expect(loadQuestionSpy).toHaveBeenCalledWith(newQuestionHash); + + expect(mockChild).toHaveBeenCalled(); + }); +}); diff --git a/frontend/test/containers/QuestionAndResultLoader.unit.spec.js b/frontend/test/containers/QuestionAndResultLoader.unit.spec.js new file mode 100644 index 00000000000..421df4519b5 --- /dev/null +++ b/frontend/test/containers/QuestionAndResultLoader.unit.spec.js @@ -0,0 +1,10 @@ +import React from "react"; +import { shallow } from "enzyme"; + +import QuestionAndResultLoader from "metabase/containers/QuestionAndResultLoader"; + +describe("QuestionAndResultLoader", () => { + it("should load a question and a result", () => { + shallow(<QuestionAndResultLoader>{() => <div />}</QuestionAndResultLoader>); + }); +}); diff --git a/frontend/test/containers/QuestionLoader.unit.spec.js b/frontend/test/containers/QuestionLoader.unit.spec.js new file mode 100644 index 00000000000..0a548971c63 --- /dev/null +++ b/frontend/test/containers/QuestionLoader.unit.spec.js @@ -0,0 +1,46 @@ +import React from "react"; +import { shallow } from "enzyme"; + +import QuestionLoader from "metabase/containers/QuestionLoader"; + +import AdHocQuestionLoader from "metabase/containers/AdHocQuestionLoader"; +import SavedQuestionLoader from "metabase/containers/SavedQuestionLoader"; + +describe("QuestionLoader", () => { + describe("initial load", () => { + it("should use SavedQuestionLoader if there is a saved question", () => { + const wrapper = shallow( + <QuestionLoader questionId={1}>{() => <div />}</QuestionLoader>, + ); + + expect(wrapper.find(SavedQuestionLoader).length).toBe(1); + }); + + it("should use AdHocQuestionLoader if there is an ad-hoc question", () => { + const wrapper = shallow( + <QuestionLoader questionHash={"#abc123"}> + {() => <div />} + </QuestionLoader>, + ); + + expect(wrapper.find(AdHocQuestionLoader).length).toBe(1); + }); + }); + describe("subsequent movement", () => { + it("should transition between loaders when props change", () => { + // start with a quesitonId + const wrapper = shallow( + <QuestionLoader questionId={4}>{() => <div />}</QuestionLoader>, + ); + + expect(wrapper.find(SavedQuestionLoader).length).toBe(1); + + wrapper.setProps({ + questionId: undefined, + questionHash: "#abc123", + }); + + expect(wrapper.find(AdHocQuestionLoader).length).toBe(1); + }); + }); +}); diff --git a/frontend/test/containers/QuestionResultLoader.unit.spec.js b/frontend/test/containers/QuestionResultLoader.unit.spec.js new file mode 100644 index 00000000000..020d93dbe86 --- /dev/null +++ b/frontend/test/containers/QuestionResultLoader.unit.spec.js @@ -0,0 +1,22 @@ +import React from "react"; +import { shallow } from "enzyme"; + +import { QuestionResultLoader } from "metabase/containers/QuestionResultLoader"; + +describe("QuestionResultLoader", () => { + it("should load a result given a question", () => { + const question = { + id: 1, + }; + + const loadSpy = jest.spyOn(QuestionResultLoader.prototype, "_loadResult"); + + shallow( + <QuestionResultLoader question={question}> + {() => <div />} + </QuestionResultLoader>, + ); + + expect(loadSpy).toHaveBeenCalledWith(question); + }); +}); diff --git a/frontend/test/containers/SavedQuestionLoader.unit.spec.js b/frontend/test/containers/SavedQuestionLoader.unit.spec.js new file mode 100644 index 00000000000..d41f8d0d9c1 --- /dev/null +++ b/frontend/test/containers/SavedQuestionLoader.unit.spec.js @@ -0,0 +1,76 @@ +import React from "react"; +import { shallow, mount } from "enzyme"; + +import Question from "metabase-lib/lib/Question"; +import { delay } from "metabase/lib/promise"; +import { CardApi } from "metabase/services"; + +// import the un-connected component so we can test its internal logic sans +// redux +import { SavedQuestionLoader } from "metabase/containers/SavedQuestionLoader"; + +// we need to mock the things that try and actually load the question +jest.mock("metabase/services"); + +describe("SavedQuestionLoader", () => { + let loadQuestionSpy, loadMetadataSpy, mockChild; + beforeEach(() => { + // reset mocks between tests so we have fresh spies, etc + jest.resetAllMocks(); + mockChild = jest.fn().mockReturnValue(<div />); + loadMetadataSpy = jest.fn(); + loadQuestionSpy = jest.spyOn( + SavedQuestionLoader.prototype, + "_loadQuestion", + ); + }); + + it("should load a question given a questionId", async () => { + const questionId = 1; + const q = new Question.create({ databaseId: 1, tableId: 2 }); + jest.spyOn(CardApi, "get").mockReturnValue(q.card()); + + const wrapper = mount( + <SavedQuestionLoader + questionId={questionId} + loadMetadataForCard={loadMetadataSpy} + children={mockChild} + />, + ); + expect(mockChild.mock.calls[0][0].loading).toEqual(true); + expect(mockChild.mock.calls[0][0].error).toEqual(null); + + // stuff happens asynchronously + wrapper.update(); + await delay(0); + + expect(loadQuestionSpy).toHaveBeenCalledWith(questionId); + + const calls = mockChild.mock.calls; + const { question, loading, error } = calls[calls.length - 1][0]; + expect(question.card()).toEqual(q.card()); + expect(loading).toEqual(false); + expect(error).toEqual(null); + }); + + it("should load a new question if the question ID changes", () => { + const originalQuestionId = 1; + const newQuestionId = 2; + + const wrapper = shallow( + <SavedQuestionLoader + questionId={originalQuestionId} + loadMetadataForCard={loadMetadataSpy} + children={mockChild} + />, + ); + + expect(loadQuestionSpy).toHaveBeenCalledWith(originalQuestionId); + + // update the question ID, a new question id param in the url would do this + wrapper.setProps({ questionId: newQuestionId }); + + // question loading should begin with the new ID + expect(loadQuestionSpy).toHaveBeenCalledWith(newQuestionId); + }); +}); -- GitLab