From 40ead8050aa09333b3a4054c81db351d51c75bbc Mon Sep 17 00:00:00 2001 From: Sameer Al-Sakran <sameer@expa.com> Date: Wed, 5 Jul 2017 18:25:14 -0700 Subject: [PATCH] basic positive path tests --- .../metabase/__support__/integrated_tests.js | 274 +++++++++--- .../__support__/integrated_tests_mocks.js | 36 ++ .../__support__/sample_dataset_fixture.js | 1 - frontend/src/metabase/components/Tooltip.jsx | 68 +++ frontend/src/metabase/lib/api.js | 12 +- frontend/src/metabase/questions/questions.js | 2 +- frontend/src/metabase/redux/metadata.js | 20 +- frontend/src/metabase/reference/reference.js | 2 +- .../src/metabase/reference/reference.spec.js | 333 ++++++++++---- frontend/src/metabase/store.js | 29 +- .../integration/reference/reference.spec.js | 411 +++++++++++++++++- frontend/test/unit/reference/utils.spec.js | 194 +-------- package.json | 11 +- 13 files changed, 1020 insertions(+), 373 deletions(-) create mode 100644 frontend/src/metabase/__support__/integrated_tests_mocks.js diff --git a/frontend/src/metabase/__support__/integrated_tests.js b/frontend/src/metabase/__support__/integrated_tests.js index 1202f477c4c..a8ff718b40c 100644 --- a/frontend/src/metabase/__support__/integrated_tests.js +++ b/frontend/src/metabase/__support__/integrated_tests.js @@ -1,31 +1,72 @@ -import { BackendResource } from "../../../test/e2e/support/backend.js"; +/* global process, jasmine */ + +/** + * Import this file before other imports in integrated tests + */ + +// Mocks in a separate file as they would clutter this file +// This must be before all other imports +import "./integrated_tests_mocks"; + +import { format as urlFormat } from "url"; import api from "metabase/lib/api"; -import { SessionApi } from "metabase/services"; +import { CardApi, SessionApi } from "metabase/services"; import { METABASE_SESSION_COOKIE } from "metabase/lib/cookies"; +import reducers from 'metabase/reducers-main'; import React from 'react' -import { Provider } from 'react-redux' -import reducers from 'metabase/reducers-main'; +import { Provider } from 'react-redux'; -import MetabaseSettings from "metabase/lib/settings"; +import { createMemoryHistory } from 'history' +import { getStore } from "metabase/store"; +import { createRoutes, Router, useRouterHistory } from "react-router"; +import _ from 'underscore'; -import { getStore } from 'metabase/store' +// Importing isomorphic-fetch sets the global `fetch` and `Headers` objects that are used here +import fetch from 'isomorphic-fetch'; import { refreshSiteSettings } from "metabase/redux/settings"; +import { getRoutes } from "metabase/routes"; -// Stores the current login session -var loginSession = null; +let hasCreatedStore = false; +let loginSession = null; // Stores the current login session +let simulateOfflineMode = false; /** * Login to the Metabase test instance with default credentials */ export async function login() { + if (hasCreatedStore) { + console.warn( + "Warning: You have created a test store before calling login() which means that up-to-date site settings " + + "won't be in the store unless you call `refreshSiteSettings` action manually. Please prefer " + + "logging in before all tests and creating the store inside an individual test or describe block." + ) + } + loginSession = await SessionApi.create({ email: "bob@metabase.com", password: "12341234"}); } +/** + * Calls the provided function while simulating that the browser is offline. + */ +export async function whenOffline(callWhenOffline) { + simulateOfflineMode = true; + return callWhenOffline() + .then((result) => { + simulateOfflineMode = false; + return result; + }) + .catch((e) => { + simulateOfflineMode = false; + throw e; + }); +} + + // Patches the metabase/lib/api module so that all API queries contain the login credential cookie. // Needed because we are not in a real web browser environment. -api._makeRequest = async (method, url, headers, body, data, options) => { +api._makeRequest = async (method, url, headers, requestBody, data, options) => { const headersWithSessionCookie = { ...headers, ...(loginSession ? {"Cookie": `${METABASE_SESSION_COOKIE}=${loginSession.id}`} : {}) @@ -35,64 +76,191 @@ api._makeRequest = async (method, url, headers, body, data, options) => { credentials: "include", method, headers: new Headers(headersWithSessionCookie), - ...(body ? {body} : {}) + ...(requestBody ? { body: requestBody } : {}) }; - const result = await fetch(api.basename + url, fetchOptions); + let isCancelled = false + if (options.cancelled) { + options.cancelled.then(() => { + isCancelled = true; + }); + } + const result = simulateOfflineMode + ? { status: 0, responseText: '' } + : (await fetch(api.basename + url, fetchOptions)); + + if (isCancelled) { + throw { status: 0, data: '', isCancelled: true} + } + + let resultBody = null + try { + resultBody = await result.text(); + // Even if the result conversion to JSON fails, we still return the original text + // This is 1-to-1 with the real _makeRequest implementation + resultBody = JSON.parse(resultBody); + } catch (e) {} + + if (result.status >= 200 && result.status <= 299) { - return result.json(); + return resultBody } else { - throw { status: result.status, data: result.json() } + const error = { status: result.status, data: resultBody, isCancelled: false } + if (!simulateOfflineMode) { + console.log('A request made in a test failed with the following error:'); + console.dir(error, { depth: null }); + console.log(`The original request: ${method} ${url}`); + if (requestBody) console.log(`Original payload: ${requestBody}`); + } + throw error } } -// disable GA for integrated tests (needed for UI tests) -MetabaseSettings.on("anon_tracking_enabled", () => { - window['ga-disable-' + MetabaseSettings.get('ga_code')] = true; -}); - -// Reference to the reusable/shared backend server resource -const server = BackendResource.get({}); // Set the correct base url to metabase/lib/api module -api.basename = server.host; +if (process.env.E2E_HOST) { + api.basename = process.env.E2E_HOST; +} else { + console.log( + 'Please use `yarn run test-integrated` or `yarn run test-integrated-watch` for running integration tests.' + ) + process.quit(0) +} /** - * Starts the backend process. Promise resolves when the backend has properly been initialized. - * If the backend is already running, this resolves immediately - * TODO: Should happen automatically before any tests have been run + * Creates an augmented Redux store for testing the whole app including browser history manipulation. Includes: + * - A simulated browser history that is used by react-router + * - Methods for + * * manipulating the browser history + * * waiting until specific Redux actions have been dispatched + * * getting a React container subtree for the current route */ -export const startServer = async () => { - await BackendResource.start(server); + +export const createTestStore = () => { + hasCreatedStore = true; + + const history = useRouterHistory(createMemoryHistory)(); + const store = getStore(reducers, history, undefined, (createStore) => testStoreEnhancer(createStore, history)); + store.setFinalStoreInstance(store); + + store.dispatch(refreshSiteSettings()); + return store; } -/** - * Stops the current backend process - * TODO: This should happen automatically after tests have been run - */ -export const stopServer = async () => await BackendResource.stop(server); +const testStoreEnhancer = (createStore, history) => { + return (...args) => { + const store = createStore(...args); -/** - * A Redux store that is shared between subsequent tests, - * intended to reduce the need for reloading metadata between every test - * - * The Redux store matches the production app's store 1-to-1 with an exception that - * it doesn't contain redux-router navigation history state - */ -export const globalReduxStore = getStore(reducers); -// needed in some tests after server startup: -// globalReduxStore.dispatch(refreshSiteSettings()); + const testStoreExtensions = { + _originalDispatch: store.dispatch, + _onActionDispatched: null, + _dispatchedActions: [], + _finalStoreInstance: null, -/** - * Returns the given React container with an access to a global Redux store - */ -export function linkContainerToGlobalReduxStore(component) { - return ( - <Provider store={globalReduxStore}> - {component} - </Provider> - ); + setFinalStoreInstance: (finalStore) => { + store._finalStoreInstance = finalStore; + }, + + dispatch: (action) => { + const result = store._originalDispatch(action); + store._dispatchedActions = store._dispatchedActions.concat([action]); + if (store._onActionDispatched) store._onActionDispatched(); + return result; + }, + + resetDispatchedActions: () => { + store._dispatchedActions = []; + }, + + /** + * Waits until all actions with given type identifiers have been called or fails if the maximum waiting + * time defined in `timeout` is exceeded. + * + * Convenient in tests for waiting specific actions to be executed after mounting a React container. + */ + waitForActions: (actionTypes, {timeout = 2000} = {}) => { + actionTypes = Array.isArray(actionTypes) ? actionTypes : [actionTypes] + + const allActionsAreTriggered = () => _.every(actionTypes, actionType => + store._dispatchedActions.filter((action) => action.type === actionType).length > 0 + ); + + if (allActionsAreTriggered()) { + // Short-circuit if all action types are already in the history of dispatched actions + return; + } else { + return new Promise((resolve, reject) => { + store._onActionDispatched = () => { + if (allActionsAreTriggered()) resolve() + }; + setTimeout(() => { + store._onActionDispatched = null; + + if (allActionsAreTriggered()) { + // TODO: Figure out why we sometimes end up here instead of _onActionDispatched hook + resolve() + } else { + return reject( + new Error( + `Actions ${actionTypes.join(", ")} were not dispatched within ${timeout}ms. ` + + `Dispatched actions so far: ${store._dispatchedActions.map((a) => a.type).join(", ")}` + ) + ) + } + + }, timeout) + }); + } + }, + + getDispatchedActions: () => { + return store._dispatchedActions; + }, + + pushPath: (path) => history.push(path), + getPath: () => urlFormat(history.getCurrentLocation()), + + connectContainer: (reactContainer) => { + // exploratory approach, not sure if this can ever work: + // return store._connectWithStore(reactContainer) + const routes = createRoutes(getRoutes(store._finalStoreInstance)) + return store._connectWithStore( + <Router + routes={routes} + history={history} + render={(props) => React.cloneElement(reactContainer, props)} + /> + ); + }, + + getAppContainer: () => { + return store._connectWithStore( + <Router history={history}> + {getRoutes(store._finalStoreInstance)} + </Router> + ) + }, + + _connectWithStore: (reactContainer) => + <Provider store={store._finalStoreInstance}> + {reactContainer} + </Provider> + + } + + return Object.assign(store, testStoreExtensions); + } +} + +export const clickRouterLink = (linkEnzymeWrapper) => + linkEnzymeWrapper.simulate('click', { button: 0 }); + +// Commonly used question helpers that are temporarily here +// TODO Atte Keinänen 6/27/17: Put all metabase-lib -related test helpers to one file +export const createSavedQuestion = async (unsavedQuestion) => { + const savedCard = await CardApi.create(unsavedQuestion.card()) + const savedQuestion = unsavedQuestion.setCard(savedCard); + savedQuestion._card = { ...savedQuestion._card, original_card_id: savedQuestion.id() } + return savedQuestion } -// TODO: How to have the high timeout interval only for integration tests? -// or even better, just for the setup/teardown of server process? -jasmine.DEFAULT_TIMEOUT_INTERVAL = 120000; +jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000; diff --git a/frontend/src/metabase/__support__/integrated_tests_mocks.js b/frontend/src/metabase/__support__/integrated_tests_mocks.js new file mode 100644 index 00000000000..f66d6cfe0be --- /dev/null +++ b/frontend/src/metabase/__support__/integrated_tests_mocks.js @@ -0,0 +1,36 @@ +import React from 'react' + +global.ga = () => {} +global.ace.define = () => {} +global.ace.require = () => {} + +global.window.matchMedia = () => ({ addListener: () => {}, removeListener: () => {} }) + +jest.mock('metabase/lib/analytics'); + +jest.mock("ace/ace", () => {}, {virtual: true}); +jest.mock("ace/mode-plain_text", () => {}, {virtual: true}); +jest.mock("ace/mode-javascript", () => {}, {virtual: true}); +jest.mock("ace/mode-json", () => {}, {virtual: true}); +jest.mock("ace/mode-clojure", () => {}, {virtual: true}); +jest.mock("ace/mode-ruby", () => {}, {virtual: true}); +jest.mock("ace/mode-html", () => {}, {virtual: true}); +jest.mock("ace/mode-jsx", () => {}, {virtual: true}); +jest.mock("ace/mode-sql", () => {}, {virtual: true}); +jest.mock("ace/mode-mysql", () => {}, {virtual: true}); +jest.mock("ace/mode-pgsql", () => {}, {virtual: true}); +jest.mock("ace/mode-sqlserver", () => {}, {virtual: true}); +jest.mock("ace/snippets/sql", () => {}, {virtual: true}); +jest.mock("ace/snippets/mysql", () => {}, {virtual: true}); +jest.mock("ace/snippets/pgsql", () => {}, {virtual: true}); +jest.mock("ace/snippets/sqlserver", () => {}, {virtual: true}); +jest.mock("ace/snippets/json", () => {}, {virtual: true}); +jest.mock("ace/snippets/json", () => {}, {virtual: true}); +jest.mock("ace/ext-language_tools", () => {}, {virtual: true}); + +import * as modal from "metabase/components/Modal"; +const MockedModal = () => <div className="mocked-modal" />; +modal.default = MockedModal; + +import * as tooltip from "metabase/components/Tooltip"; +tooltip.default = tooltip.TestTooltip \ No newline at end of file diff --git a/frontend/src/metabase/__support__/sample_dataset_fixture.js b/frontend/src/metabase/__support__/sample_dataset_fixture.js index f043fa256dd..801c717fe04 100644 --- a/frontend/src/metabase/__support__/sample_dataset_fixture.js +++ b/frontend/src/metabase/__support__/sample_dataset_fixture.js @@ -1,5 +1,4 @@ import { getMetadata } from "metabase/selectors/metadata"; -import { assocIn } from "icepick"; export const DATABASE_ID = 1; export const ANOTHER_DATABASE_ID = 2; diff --git a/frontend/src/metabase/components/Tooltip.jsx b/frontend/src/metabase/components/Tooltip.jsx index 7b8d6f5fbb4..e1fd7961179 100644 --- a/frontend/src/metabase/components/Tooltip.jsx +++ b/frontend/src/metabase/components/Tooltip.jsx @@ -90,3 +90,71 @@ export default class Tooltip extends Component { return React.Children.only(this.props.children); } } + +/** + * Modified version of Tooltip for Jest/Enzyme tests. Instead of manipulating the document root it + * renders the tooltip content (in TestTooltipContent) next to "children" / hover area (TestTooltipHoverArea). + * + * The test tooltip can only be toggled with `jestWrapper.simulate("mouseenter")` and `jestWrapper.simulate("mouseleave")`. + */ +export class TestTooltip extends Component { + constructor(props, context) { + super(props, context); + + this.state = { + isOpen: false, + isHovered: false + }; + } + + static propTypes = { + tooltip: PropTypes.node, + children: PropTypes.element.isRequired, + isEnabled: PropTypes.bool, + verticalAttachments: PropTypes.array, + isOpen: PropTypes.bool + }; + + static defaultProps = { + isEnabled: true, + verticalAttachments: ["top", "bottom"] + }; + + _onMouseEnter = (e) => { + this.setState({ isOpen: true, isHovered: true }); + } + + _onMouseLeave = (e) => { + this.setState({ isOpen: false, isHovered: false }); + } + + render() { + const { isEnabled, tooltip } = this.props; + const isOpen = this.props.isOpen != null ? this.props.isOpen : this.state.isOpen; + + return ( + <div> + <TestTooltipTarget + onMouseEnter={this._onMouseEnter} + onMouseLeave={this._onMouseLeave} + > + {this.props.children} + </TestTooltipTarget> + + { tooltip && isEnabled && isOpen && + <TestTooltipContent> + <TooltipPopover isOpen={true} target={this} {...this.props} children={this.props.tooltip} /> + {this.props.tooltip} + </TestTooltipContent> + } + </div> + ) + } +} + +export const TestTooltipTarget = ({ children, onMouseEnter, onMouseLeave }) => + <div className="test-tooltip-hover-area" onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}> + {children} + </div> + +export const TestTooltipContent = ({ children }) => <div className="test-tooltip-content">{children}</div> \ No newline at end of file diff --git a/frontend/src/metabase/lib/api.js b/frontend/src/metabase/lib/api.js index 6062ea64975..ea661c351c4 100644 --- a/frontend/src/metabase/lib/api.js +++ b/frontend/src/metabase/lib/api.js @@ -1,4 +1,4 @@ -/* @flow */ +/* @flow weak */ import querystring from "querystring"; @@ -82,8 +82,10 @@ class Api extends EventEmitter { } } + // TODO Atte Keinänen 6/26/17: Replacing this with isomorphic-fetch could simplify the implementation _makeRequest(method, url, headers, body, data, options) { return new Promise((resolve, reject) => { + let isCancelled = false; let xhr = new XMLHttpRequest(); xhr.open(method, this.basename + url); for (let headerName in headers) { @@ -102,7 +104,8 @@ class Api extends EventEmitter { } else { reject({ status: xhr.status, - data: body + data: body, + isCancelled: isCancelled }); } if (!options.noEvent) { @@ -113,7 +116,10 @@ class Api extends EventEmitter { xhr.send(body); if (options.cancelled) { - options.cancelled.then(() => xhr.abort()); + options.cancelled.then(() => { + isCancelled = true; + xhr.abort() + }); } }); } diff --git a/frontend/src/metabase/questions/questions.js b/frontend/src/metabase/questions/questions.js index 02d4924030d..b09640bddec 100644 --- a/frontend/src/metabase/questions/questions.js +++ b/frontend/src/metabase/questions/questions.js @@ -26,7 +26,7 @@ const card = new schema.Entity('cards', { import { CardApi, CollectionsApi } from "metabase/services"; -const LOAD_ENTITIES = 'metabase/questions/LOAD_ENTITIES'; +export const LOAD_ENTITIES = 'metabase/questions/LOAD_ENTITIES'; const SET_SEARCH_TEXT = 'metabase/questions/SET_SEARCH_TEXT'; const SET_ITEM_SELECTED = 'metabase/questions/SET_ITEM_SELECTED'; const SET_ALL_SELECTED = 'metabase/questions/SET_ALL_SELECTED'; diff --git a/frontend/src/metabase/redux/metadata.js b/frontend/src/metabase/redux/metadata.js index 0e7de82de78..24654bbf3da 100644 --- a/frontend/src/metabase/redux/metadata.js +++ b/frontend/src/metabase/redux/metadata.js @@ -17,7 +17,7 @@ import _ from "underscore"; import { MetabaseApi, MetricApi, SegmentApi, RevisionsApi } from "metabase/services"; -const FETCH_METRICS = "metabase/metadata/FETCH_METRICS"; +export const FETCH_METRICS = "metabase/metadata/FETCH_METRICS"; export const fetchMetrics = createThunkAction(FETCH_METRICS, (reload = false) => { return async (dispatch, getState) => { const requestStatePath = ["metadata", "metrics"]; @@ -82,7 +82,7 @@ export const updateMetricImportantFields = createThunkAction(UPDATE_METRIC_IMPOR }); -const FETCH_SEGMENTS = "metabase/metadata/FETCH_SEGMENTS"; +export const FETCH_SEGMENTS = "metabase/metadata/FETCH_SEGMENTS"; export const fetchSegments = createThunkAction(FETCH_SEGMENTS, (reload = false) => { return async (dispatch, getState) => { const requestStatePath = ["metadata", "segments"]; @@ -125,7 +125,7 @@ export const updateSegment = createThunkAction(UPDATE_SEGMENT, function(segment) }; }); -const FETCH_DATABASES = "metabase/metadata/FETCH_DATABASES"; +export const FETCH_DATABASES = "metabase/metadata/FETCH_DATABASES"; export const fetchDatabases = createThunkAction(FETCH_DATABASES, (reload = false) => { return async (dispatch, getState) => { const requestStatePath = ["metadata", "databases"]; @@ -146,7 +146,7 @@ export const fetchDatabases = createThunkAction(FETCH_DATABASES, (reload = false }; }); -const FETCH_DATABASE_METADATA = "metabase/metadata/FETCH_DATABASE_METADATA"; +export const FETCH_DATABASE_METADATA = "metabase/metadata/FETCH_DATABASE_METADATA"; export const fetchDatabaseMetadata = createThunkAction(FETCH_DATABASE_METADATA, function(dbId, reload = false) { return async function(dispatch, getState) { const requestStatePath = ["metadata", "databases", dbId]; @@ -302,7 +302,7 @@ export const updateField = createThunkAction(UPDATE_FIELD, function(field) { }; }); -const FETCH_REVISIONS = "metabase/metadata/FETCH_REVISIONS"; +export const FETCH_REVISIONS = "metabase/metadata/FETCH_REVISIONS"; export const fetchRevisions = createThunkAction(FETCH_REVISIONS, (type, id, reload = false) => { return async (dispatch, getState) => { const requestStatePath = ["metadata", "revisions", type, id]; @@ -327,7 +327,7 @@ export const fetchRevisions = createThunkAction(FETCH_REVISIONS, (type, id, relo }); // for fetches with data dependencies in /reference -const FETCH_METRIC_TABLE = "metabase/metadata/FETCH_METRIC_TABLE"; +export const FETCH_METRIC_TABLE = "metabase/metadata/FETCH_METRIC_TABLE"; export const fetchMetricTable = createThunkAction(FETCH_METRIC_TABLE, (metricId, reload = false) => { return async (dispatch, getState) => { await dispatch(fetchMetrics()); // FIXME: fetchMetric? @@ -337,7 +337,7 @@ export const fetchMetricTable = createThunkAction(FETCH_METRIC_TABLE, (metricId, }; }); -const FETCH_METRIC_REVISIONS = "metabase/metadata/FETCH_METRIC_REVISIONS"; +export const FETCH_METRIC_REVISIONS = "metabase/metadata/FETCH_METRIC_REVISIONS"; export const fetchMetricRevisions = createThunkAction(FETCH_METRIC_REVISIONS, (metricId, reload = false) => { return async (dispatch, getState) => { await Promise.all([ @@ -350,7 +350,7 @@ export const fetchMetricRevisions = createThunkAction(FETCH_METRIC_REVISIONS, (m }; }); -const FETCH_SEGMENT_FIELDS = "metabase/metadata/FETCH_SEGMENT_FIELDS"; +export const FETCH_SEGMENT_FIELDS = "metabase/metadata/FETCH_SEGMENT_FIELDS"; export const fetchSegmentFields = createThunkAction(FETCH_SEGMENT_FIELDS, (segmentId, reload = false) => { return async (dispatch, getState) => { await dispatch(fetchSegments()); // FIXME: fetchSegment? @@ -363,7 +363,7 @@ export const fetchSegmentFields = createThunkAction(FETCH_SEGMENT_FIELDS, (segme }; }); -const FETCH_SEGMENT_TABLE = "metabase/metadata/FETCH_SEGMENT_TABLE"; +export const FETCH_SEGMENT_TABLE = "metabase/metadata/FETCH_SEGMENT_TABLE"; export const fetchSegmentTable = createThunkAction(FETCH_SEGMENT_TABLE, (segmentId, reload = false) => { return async (dispatch, getState) => { await dispatch(fetchSegments()); // FIXME: fetchSegment? @@ -373,7 +373,7 @@ export const fetchSegmentTable = createThunkAction(FETCH_SEGMENT_TABLE, (segment }; }); -const FETCH_SEGMENT_REVISIONS = "metabase/metadata/FETCH_SEGMENT_REVISIONS"; +export const FETCH_SEGMENT_REVISIONS = "metabase/metadata/FETCH_SEGMENT_REVISIONS"; export const fetchSegmentRevisions = createThunkAction(FETCH_SEGMENT_REVISIONS, (segmentId, reload = false) => { return async (dispatch, getState) => { await Promise.all([ diff --git a/frontend/src/metabase/reference/reference.js b/frontend/src/metabase/reference/reference.js index 4934bed947d..94d42e0cbff 100644 --- a/frontend/src/metabase/reference/reference.js +++ b/frontend/src/metabase/reference/reference.js @@ -17,7 +17,7 @@ import { isEmptyObject } from "./utils.js" -const FETCH_GUIDE = "metabase/reference/FETCH_GUIDE"; +export const FETCH_GUIDE = "metabase/reference/FETCH_GUIDE"; const SET_ERROR = "metabase/reference/SET_ERROR"; const CLEAR_ERROR = "metabase/reference/CLEAR_ERROR"; const START_LOADING = "metabase/reference/START_LOADING"; diff --git a/frontend/src/metabase/reference/reference.spec.js b/frontend/src/metabase/reference/reference.spec.js index 430cc4b26eb..66f20d21627 100644 --- a/frontend/src/metabase/reference/reference.spec.js +++ b/frontend/src/metabase/reference/reference.spec.js @@ -1,34 +1,51 @@ /* @flow weak */ -// import { DATABASE_ID, ORDERS_TABLE_ID, metadata } from "metabase/__support__/sample_dataset_fixture"; import { - linkContainerToGlobalReduxStore, - globalReduxStore as store, login, - startServer, - stopServer + createTestStore } from "metabase/__support__/integrated_tests"; import React from 'react'; import { shallow, mount, render } from 'enzyme'; import { CardApi, SegmentApi, MetricApi } from 'metabase/services' -import ReferenceEntity from "metabase/reference/containers/ReferenceEntity"; import { fetchMetrics } from "metabase/redux/metadata"; - -/** - * Returns a reference section container linked to the global test Redux store - * - * @param Container: the container element like ReferenceEntity, ReferenceEntityList or ReferenceFieldsList - * @param sectionId: either "metrics", "segments" or "databases" - * @param params: a key-value list of section-specific route parameters, e.g. { segmentId: "1" } - */ -const getReferenceContainer = (Container, sectionId, params = {}) => { - return linkContainerToGlobalReduxStore(<Container - location={{ pathname: {sectionId} }} - params={params} - />) -} +import { + FETCH_DATABASE_METADATA, + FETCH_DATABASES, + FETCH_METRICS, + FETCH_SEGMENTS, + FETCH_SEGMENT_TABLE, + FETCH_SEGMENT_FIELDS, + FETCH_METRIC_TABLE, + FETCH_SEGMENT_REVISIONS, + FETCH_METRIC_REVISIONS +} from "metabase/redux/metadata"; + +import { FETCH_GUIDE } from "metabase/reference/reference" +import { LOAD_ENTITIES } from "metabase/questions/questions" + +import DatabaseListContainer from "metabase/reference/databases/DatabaseListContainer"; +import DatabaseDetailContainer from "metabase/reference/databases/DatabaseDetailContainer"; +import TableListContainer from "metabase/reference/databases/TableListContainer"; +import TableDetailContainer from "metabase/reference/databases/TableDetailContainer"; +import TableQuestionsContainer from "metabase/reference/databases/TableQuestionsContainer"; +import FieldListContainer from "metabase/reference/databases/FieldListContainer"; +import FieldDetailContainer from "metabase/reference/databases/FieldDetailContainer"; + +import GettingStartedGuideContainer from "metabase/reference/guide/GettingStartedGuideContainer"; + +import SegmentListContainer from "metabase/reference/segments/SegmentListContainer"; +import SegmentDetailContainer from "metabase/reference/segments/SegmentDetailContainer"; +import SegmentQuestionsContainer from "metabase/reference/segments/SegmentQuestionsContainer"; +import SegmentRevisionsContainer from "metabase/reference/segments/SegmentRevisionsContainer"; +import SegmentFieldListContainer from "metabase/reference/segments/SegmentFieldListContainer"; +import SegmentFieldDetailContainer from "metabase/reference/segments/SegmentFieldDetailContainer"; + +import MetricListContainer from "metabase/reference/metrics/MetricListContainer"; +import MetricDetailContainer from "metabase/reference/metrics/MetricDetailContainer"; +import MetricQuestionsContainer from "metabase/reference/metrics/MetricQuestionsContainer"; +import MetricRevisionsContainer from "metabase/reference/metrics/MetricRevisionsContainer"; describe("The Reference Section", () => { @@ -57,40 +74,33 @@ describe("The Reference Section", () => { // Scaffolding beforeAll(async () => { - await startServer(); await login(); }) - afterAll(async () => { - await stopServer(); - }) - - it("do stuff", async () => { - - // browserHistory.replace("/"); - - expect(true).toBe(true); - }) - - - - describe("The Getting Started Guide", ()=>{ - - it("Should show an empty guide for non-admin users", async () => {expect(true).toBe(true)}) + describe("The Getting Started Guide", async ()=>{ + - it("Should show an empty guide with a creation CTA for admin users", async () => {expect(true).toBe(true)}) + it("Should show an empty guide for non-admin users", async () => { + const store = await createTestStore() + store.pushPath("/reference/"); + const container = mount(store.connectContainer(<GettingStartedGuideContainer />)); + await store.waitForActions([FETCH_DATABASE_METADATA, FETCH_SEGMENTS, FETCH_METRICS]) + }) + + it("Should show an empty guide with a creation CTA for admin users", async () => { + // TODO + expect(true).toBe(true) + }) - it("A non-admin attempting to edit the guide should get an error", async () => {expect(true).toBe(true)}) + it("A non-admin attempting to edit the guide should get an error", async () => { + // TODO + expect(true).toBe(true) + }) it("Adding metrics should to the guide should make them appear", async () => { - // load needed metadata already (as you can check from the `referenceSections` object in reference/selectors.js) - // normally ReferenceApp container does the loading by calling `tryFetchData` - await store.dispatch(fetchMetrics()) - const mountedContainer = mount(getReferenceContainer(ReferenceEntity, "metrics")); - console.log('the dom tree that enzyme has rendered', mountedContainer.debug()) - + expect(0).toBe(0) var metric = await MetricApi.create(metricDef); expect(1).toBe(1) @@ -118,42 +128,78 @@ describe("The Reference Section", () => { describe("The Metrics section of the Data Reference", async ()=>{ describe("Empty State", async () => { - // metrics list - // metric detail - // metrics questions - // metrics revisions + + it("Should show no metrics in the list", async () => { + const store = await createTestStore() + store.pushPath("/reference/metrics"); + const container = mount(store.connectContainer(<MetricListContainer />)); + await store.waitForActions([FETCH_METRICS]) + }) }); describe("With Metrics State", async () => { - var metricIds = [] - var segmentIds = [] + var metricIds = [] + var segmentIds = [] - beforeAll(async () => { - // Create some metrics to have something to look at - var metric = await MetricApi.create(metricDef); - var metric2 = await MetricApi.create(anotherMetricDef); - - metricIds.push(metric.id) - metricIds.push(metric2.id) + beforeAll(async () => { + // Create some metrics to have something to look at + var metric = await MetricApi.create(metricDef); + var metric2 = await MetricApi.create(anotherMetricDef); + + metricIds.push(metric.id) + metricIds.push(metric2.id) + console.log(metricIds) + }) + afterAll(async () => { + // Delete the guide we created + // remove the metrics we created + // This is a bit messy as technically these are just archived + for (const id of metricIds){ + await MetricApi.delete({metricId: id, revision_message: "Please"}) + } + }) + // metrics list + it("Should show no metrics in the list", async () => { + const store = await createTestStore() + store.pushPath("/reference/metrics"); + const container = mount(store.connectContainer(<MetricListContainer />)); + await store.waitForActions([FETCH_METRICS]) + }) + // metric detail + it("Should show the metric detail view for a specific id", async () => { + const store = await createTestStore() + store.pushPath("/reference/metrics/"+metricIds[0]); + const container = mount(store.connectContainer(<MetricDetailContainer />)); + await store.waitForActions([FETCH_METRIC_TABLE, FETCH_GUIDE]) + }) + // metrics questions + it("Should show no questions based on a new metric", async () => { + const store = await createTestStore() + store.pushPath("/reference/metrics/"+metricIds[0]+'/questions'); + const container = mount(store.connectContainer(<MetricQuestionsContainer />)); + await store.waitForActions([FETCH_METRICS, FETCH_METRIC_TABLE]) + }) + // metrics revisions + it("Should show revisions", async () => { + const store = await createTestStore() + store.pushPath("/reference/metrics/"+metricIds[0]+'/revisions'); + const container = mount(store.connectContainer(<MetricRevisionsContainer />)); + await store.waitForActions([FETCH_METRICS, FETCH_METRIC_REVISIONS]) }) - afterAll(async () => { - // Delete the guide we created - // remove the metrics we created - // This is a bit messy as technically these are just archived - for (const id of metricIds){ - await MetricApi.delete({metricId: id, revision_message: "Please"}) - } - }) - - it("Should see a newly asked question in its questions list", async () => { - var card = await CardApi.create(metricCardDef) - - expect(card.name).toBe(metricCardDef.name); - - await CardApi.delete({cardId: card.id}) + it("Should see a newly asked question in its questions list", async () => { + var card = await CardApi.create(metricCardDef) + + expect(card.name).toBe(metricCardDef.name); + // see that there is a new question on the metric's questions page + const store = await createTestStore() + store.pushPath("/reference/metrics/"+metricIds[0]+'/questions'); + const container = mount(store.connectContainer(<MetricQuestionsContainer />)); + await store.waitForActions([FETCH_METRICS, FETCH_METRIC_TABLE]) + + await CardApi.delete({cardId: card.id}) }) @@ -163,15 +209,16 @@ describe("The Reference Section", () => { describe("The Segments section of the Data Reference", async ()=>{ describe("Empty State", async () => { - + it("Should show no segments in the list", async () => { + const store = await createTestStore() + store.pushPath("/reference/segments"); + const container = mount(store.connectContainer(<SegmentListContainer />)); + await store.waitForActions([FETCH_SEGMENTS]) + }) + }); - // segments list - // segments detail - // segments field list - // segments field detail - // segments questions - // segments revisions - describe("With Segments State", async () => { + + fdescribe("With Segments State", async () => { var segmentIds = [] beforeAll(async () => { @@ -192,12 +239,65 @@ describe("The Reference Section", () => { } }) + + // segments list + it("Should show the segments in the list", async () => { + const store = await createTestStore() + store.pushPath("/reference/segments"); + const container = mount(store.connectContainer(<SegmentListContainer />)); + await store.waitForActions([FETCH_SEGMENTS]) + }) + // segment detail + it("Should show the segment detail view for a specific id", async () => { + const store = await createTestStore() + store.pushPath("/reference/segments/"+segmentIds[0]); + const container = mount(store.connectContainer(<SegmentDetailContainer />)); + await store.waitForActions([FETCH_SEGMENT_TABLE]) + }) + + // segments field list + it("Should show the segment fields list", async () => { + const store = await createTestStore() + store.pushPath("/reference/segments/"+segmentIds[0]+"/fields"); + const container = mount(store.connectContainer(<SegmentFieldListContainer />)); + await store.waitForActions([FETCH_SEGMENT_TABLE, FETCH_SEGMENT_FIELDS]) + }) + // segment detail + it("Should show the segment field detail view for a specific id", async () => { + const store = await createTestStore() + store.pushPath("/reference/segments/"+segmentIds[0]+"/fields/" + 1); + const container = mount(store.connectContainer(<SegmentFieldDetailContainer />)); + await store.waitForActions([FETCH_SEGMENT_TABLE, FETCH_SEGMENT_FIELDS]) + }) + + // segment questions + it("Should show no questions based on a new segment", async () => { + const store = await createTestStore() + store.pushPath("/reference/segments/"+segmentIds[0]+'/questions'); + const container = mount(store.connectContainer(<SegmentQuestionsContainer />)); + await store.waitForActions([FETCH_SEGMENT_TABLE, LOAD_ENTITIES]) + }) + // segment revisions + it("Should show revisions", async () => { + const store = await createTestStore() + store.pushPath("/reference/segments/"+segmentIds[0]+'/revisions'); + const container = mount(store.connectContainer(<SegmentRevisionsContainer />)); + await store.waitForActions([FETCH_SEGMENT_TABLE, FETCH_SEGMENT_REVISIONS]) + }) + + + it("Should see a newly asked question in its questions list", async () => { var card = await CardApi.create(segmentCardDef) expect(card.name).toBe(segmentCardDef.name); await CardApi.delete({cardId: card.id}) + + const store = await createTestStore() + store.pushPath("/reference/segments/"+segmentIds[0]+'/questions'); + const container = mount(store.connectContainer(<SegmentQuestionsContainer />)); + await store.waitForActions([FETCH_SEGMENT_TABLE, LOAD_ENTITIES]) }) }); @@ -206,36 +306,81 @@ describe("The Reference Section", () => { describe("The Data Reference for the Sample Database", async () => { // database list - it("should see a single database", async ()=>{}) + it("should see a single database", async ()=>{ + const store = await createTestStore() + store.pushPath("/reference/databases/"); + const container = mount(store.connectContainer(<DatabaseListContainer />)); + await store.waitForActions(["metabase/metadata/FETCH_DATABASES"]) + }) // database detail - it("should see a the detail view for the sample database", async ()=>{}) + it("should see a the detail view for the sample database", async ()=>{ + const store = await createTestStore() + store.pushPath("/reference/databases/1"); + const container = mount(store.connectContainer(<DatabaseDetailContainer />)); + await store.waitForActions(["metabase/metadata/FETCH_DATABASE_METADATA"]) + + }) // table list it("should see the 4 tables in the sample database",async () => { + const store = await createTestStore() + store.pushPath("/reference/databases/1/tables"); + const container = mount(store.connectContainer(<TableListContainer />)); + await store.waitForActions(["metabase/metadata/FETCH_DATABASE_METADATA"]) + expect(4).toBe(4); }) // table detail it("should see the Orders table", async () => { + const store = await createTestStore() + store.pushPath("/reference/databases/1/tables/1"); + const container = mount(store.connectContainer(<TableDetailContainer />)); + await store.waitForActions(["metabase/metadata/FETCH_DATABASE_METADATA"]) expect(true).toBe(true); }) - it("should see the People table", async () => { + it("should see the Reviews table", async () => { + const store = await createTestStore() + store.pushPath("/reference/databases/1/tables/2"); + const container = mount(store.connectContainer(<TableDetailContainer />)); + await store.waitForActions(["metabase/metadata/FETCH_DATABASE_METADATA"]) expect(true).toBe(true); }) it("should see the Products table", async () => { + const store = await createTestStore() + store.pushPath("/reference/databases/1/tables/3"); + const container = mount(store.connectContainer(<TableDetailContainer />)); + await store.waitForActions(["metabase/metadata/FETCH_DATABASE_METADATA"]) expect(true).toBe(true); }) - it("should see the Reviews table", async () => { + it("should see the People table", async () => { + const store = await createTestStore() + store.pushPath("/reference/databases/1/tables/4"); + const container = mount(store.connectContainer(<TableDetailContainer />)); + await store.waitForActions(["metabase/metadata/FETCH_DATABASE_METADATA"]) expect(true).toBe(true); }) // field list it("should see the fields for the orders table", async () => { + const store = await createTestStore() + store.pushPath("/reference/databases/1/tables/1/fields"); + const container = mount(store.connectContainer(<FieldListContainer />)); + await store.waitForActions(["metabase/metadata/FETCH_DATABASE_METADATA"]) + expect(true).toBe(true); + expect(true).toBe(true); }) it("should see the questions for the orders tables", async () => { + const store = await createTestStore() + store.pushPath("/reference/databases/1/tables/1/questions"); + const container = mount(store.connectContainer(<TableQuestionsContainer />)); + await store.waitForActions(["metabase/metadata/FETCH_DATABASE_METADATA"]) + expect(true).toBe(true); + + expect(true).toBe(true); var card = await CardApi.create(cardDef) @@ -248,19 +393,19 @@ describe("The Reference Section", () => { // field detail it("should see the orders created_at timestamp field", async () => { - expect(true).toBe(true); + const store = await createTestStore() + store.pushPath("/reference/databases/1/tables/1/fields/1"); + const container = mount(store.connectContainer(<FieldDetailContainer />)); + await store.waitForActions(["metabase/metadata/FETCH_DATABASE_METADATA"]) }) it("should see the orders id field", async () => { - expect(true).toBe(true); + const store = await createTestStore() + store.pushPath("/reference/databases/1/tables/1/fields/25"); + const container = mount(store.connectContainer(<FieldDetailContainer />)); + await store.waitForActions(["metabase/metadata/FETCH_DATABASE_METADATA"]) }) }); -}); - - - - - - +}); \ No newline at end of file diff --git a/frontend/src/metabase/store.js b/frontend/src/metabase/store.js index d80f41cc22f..92185f7bb6d 100644 --- a/frontend/src/metabase/store.js +++ b/frontend/src/metabase/store.js @@ -4,20 +4,36 @@ import { combineReducers, applyMiddleware, createStore, compose } from 'redux' import { reducer as form } from "redux-form"; import { routerReducer as routing, routerMiddleware } from 'react-router-redux' -import thunk from "redux-thunk"; import promise from "redux-promise"; import logger from "redux-logger"; import { DEBUG } from "metabase/lib/debug"; -const devToolsExtension = window.devToolsExtension ? window.devToolsExtension() : (f => f); +/** + * Provides the same functionality as redux-thunk and augments the dispatch method with + * `dispatch.action(type, payload)` which creates an action that adheres to Flux Standard Action format. + */ +const thunkWithDispatchAction = ({ dispatch, getState }) => next => action => { + if (typeof action === 'function') { + const dispatchAugmented = Object.assign(dispatch, { + action: (type, payload) => dispatch({ type, payload }) + }); + + return action(dispatchAugmented, getState); + } -let middleware = [thunk, promise]; + return next(action); +}; + +let middleware = [thunkWithDispatchAction, promise]; if (DEBUG) { middleware.push(logger); } -export function getStore(reducers, history, intialState) { +const devToolsExtension = window.devToolsExtension ? window.devToolsExtension() : (f => f); + +export function getStore(reducers, history, intialState, enhancer = (a) => a) { + const reducer = combineReducers({ ...reducers, form, @@ -28,6 +44,7 @@ export function getStore(reducers, history, intialState) { return createStore(reducer, intialState, compose( applyMiddleware(...middleware), - devToolsExtension + devToolsExtension, + enhancer, )); -} +} \ No newline at end of file diff --git a/frontend/test/integration/reference/reference.spec.js b/frontend/test/integration/reference/reference.spec.js index 05c30df63d9..cf5eaedcaa8 100644 --- a/frontend/test/integration/reference/reference.spec.js +++ b/frontend/test/integration/reference/reference.spec.js @@ -1,19 +1,412 @@ /* @flow weak */ -import { DATABASE_ID, ORDERS_TABLE_ID, metadata } from "metabase/__support__/sample_dataset_fixture"; -import { login, startServer, stopServer } from "metabase/__support__/integrated_tests"; +// import { DATABASE_ID, ORDERS_TABLE_ID, metadata } from "metabase/__support__/sample_dataset_fixture"; +import { + login, + createTestStore +} from "metabase/__support__/integrated_tests"; -describe("Testing testing", () => { +import React from 'react'; +import { shallow, mount, render } from 'enzyme'; + +import { CardApi, SegmentApi, MetricApi } from 'metabase/services' +import { fetchMetrics } from "metabase/redux/metadata"; +import { + FETCH_DATABASE_METADATA, + FETCH_DATABASES, + FETCH_METRICS, + FETCH_SEGMENTS, + FETCH_SEGMENT_TABLE, + FETCH_SEGMENT_FIELDS, + FETCH_METRIC_TABLE, + FETCH_SEGMENT_REVISIONS, + FETCH_METRIC_REVISIONS +} from "metabase/redux/metadata"; + +import { FETCH_GUIDE } from "metabase/reference/reference" +import { LOAD_ENTITIES } from "metabase/questions/questions" + +import DatabaseListContainer from "metabase/reference/databases/DatabaseListContainer"; +import DatabaseDetailContainer from "metabase/reference/databases/DatabaseDetailContainer"; +import TableListContainer from "metabase/reference/databases/TableListContainer"; +import TableDetailContainer from "metabase/reference/databases/TableDetailContainer"; +import TableQuestionsContainer from "metabase/reference/databases/TableQuestionsContainer"; +import FieldListContainer from "metabase/reference/databases/FieldListContainer"; +import FieldDetailContainer from "metabase/reference/databases/FieldDetailContainer"; + +import GettingStartedGuideContainer from "metabase/reference/guide/GettingStartedGuideContainer"; + +import SegmentListContainer from "metabase/reference/segments/SegmentListContainer"; +import SegmentDetailContainer from "metabase/reference/segments/SegmentDetailContainer"; +import SegmentQuestionsContainer from "metabase/reference/segments/SegmentQuestionsContainer"; +import SegmentRevisionsContainer from "metabase/reference/segments/SegmentRevisionsContainer"; +import SegmentFieldListContainer from "metabase/reference/segments/SegmentFieldListContainer"; +import SegmentFieldDetailContainer from "metabase/reference/segments/SegmentFieldDetailContainer"; + +import MetricListContainer from "metabase/reference/metrics/MetricListContainer"; +import MetricDetailContainer from "metabase/reference/metrics/MetricDetailContainer"; +import MetricQuestionsContainer from "metabase/reference/metrics/MetricQuestionsContainer"; +import MetricRevisionsContainer from "metabase/reference/metrics/MetricRevisionsContainer"; + + +describe("The Reference Section", () => { + // Test data + const segmentDef = {name: "A Segment", description: "I did it!", table_id: 1, show_in_getting_started: true, + definition: {database: 1, query: {filter: ["abc"]}}} + + const anotherSegmentDef = {name: "Another Segment", description: "I did it again!", table_id: 1, show_in_getting_started: true, + definition:{database: 1, query: {filter: ["def"]}}} + const metricDef = {name: "A Metric", description: "I did it!", table_id: 1,show_in_getting_started: true, + definition: {database: 1, query: {aggregation: ["count"]}}} + + const anotherMetricDef = {name: "Another Metric", description: "I did it again!", table_id: 1,show_in_getting_started: true, + definition: {database: 1, query: {aggregation: ["count"]}}} + + const cardDef = { name :"A card", display: "scalar", + dataset_query: {database: 1, table_id: 1, type: "query", query: {source_table: 1, "aggregation": ["count"]}}, + visualization_settings: {}} + + const metricCardDef = { name :"A card", display: "scalar", + dataset_query: {database: 1, table_id: 1, type: "query", query: {source_table: 1, "aggregation": ["metric", 1]}}, + visualization_settings: {}} + const segmentCardDef = { name :"A card", display: "scalar", + dataset_query: {database: 1, table_id: 1, type: "query", query: {source_table: 1, "aggregation": ["count"], "filter": ["segment", 1]}}, + visualization_settings: {}} + + // Scaffolding beforeAll(async () => { - await startServer(); await login(); - }) - it("do stuff", async () => { - expect(true).toBe(true); }) - afterAll(async () => { - await stopServer(); + + describe("The Getting Started Guide", async ()=>{ + + + it("Should show an empty guide for non-admin users", async () => { + const store = await createTestStore() + store.pushPath("/reference/"); + const container = mount(store.connectContainer(<GettingStartedGuideContainer />)); + await store.waitForActions([FETCH_DATABASE_METADATA, FETCH_SEGMENTS, FETCH_METRICS]) + }) + + it("Should show an empty guide with a creation CTA for admin users", async () => { + // TODO + expect(true).toBe(true) + }) + + it("A non-admin attempting to edit the guide should get an error", async () => { + // TODO + expect(true).toBe(true) + }) + + it("Adding metrics should to the guide should make them appear", async () => { + + expect(0).toBe(0) + var metric = await MetricApi.create(metricDef); + expect(1).toBe(1) + var metric2 = await MetricApi.create(anotherMetricDef); + expect(2).toBe(2) + await MetricApi.delete({metricId: metric.id, revision_message: "Please"}) + expect(1).toBe(1) + await MetricApi.delete({metricId: metric2.id, revision_message: "Please"}) + expect(0).toBe(0) + }) + + it("Adding segments should to the guide should make them appear", async () => { + expect(0).toBe(0) + var segment = await SegmentApi.create(segmentDef); + expect(1).toBe(1) + var anotherSegment = await SegmentApi.create(anotherSegmentDef); + expect(2).toBe(2) + await SegmentApi.delete({segmentId: segment.id, revision_message: "Please"}) + expect(1).toBe(1) + await SegmentApi.delete({segmentId: anotherSegment.id, revision_message: "Please"}) + expect(0).toBe(0) + }) + }) + + describe("The Metrics section of the Data Reference", async ()=>{ + describe("Empty State", async () => { + + it("Should show no metrics in the list", async () => { + const store = await createTestStore() + store.pushPath("/reference/metrics"); + const container = mount(store.connectContainer(<MetricListContainer />)); + await store.waitForActions([FETCH_METRICS]) + }) + + }); + + describe("With Metrics State", async () => { + var metricIds = [] + var segmentIds = [] + + beforeAll(async () => { + // Create some metrics to have something to look at + var metric = await MetricApi.create(metricDef); + var metric2 = await MetricApi.create(anotherMetricDef); + + metricIds.push(metric.id) + metricIds.push(metric2.id) + console.log(metricIds) + }) + + afterAll(async () => { + // Delete the guide we created + // remove the metrics we created + // This is a bit messy as technically these are just archived + for (const id of metricIds){ + await MetricApi.delete({metricId: id, revision_message: "Please"}) + } + }) + // metrics list + it("Should show no metrics in the list", async () => { + const store = await createTestStore() + store.pushPath("/reference/metrics"); + const container = mount(store.connectContainer(<MetricListContainer />)); + await store.waitForActions([FETCH_METRICS]) + }) + // metric detail + it("Should show the metric detail view for a specific id", async () => { + const store = await createTestStore() + store.pushPath("/reference/metrics/"+metricIds[0]); + const container = mount(store.connectContainer(<MetricDetailContainer />)); + await store.waitForActions([FETCH_METRIC_TABLE, FETCH_GUIDE]) + }) + // metrics questions + it("Should show no questions based on a new metric", async () => { + const store = await createTestStore() + store.pushPath("/reference/metrics/"+metricIds[0]+'/questions'); + const container = mount(store.connectContainer(<MetricQuestionsContainer />)); + await store.waitForActions([FETCH_METRICS, FETCH_METRIC_TABLE]) + }) + // metrics revisions + it("Should show revisions", async () => { + const store = await createTestStore() + store.pushPath("/reference/metrics/"+metricIds[0]+'/revisions'); + const container = mount(store.connectContainer(<MetricRevisionsContainer />)); + await store.waitForActions([FETCH_METRICS, FETCH_METRIC_REVISIONS]) + }) + + it("Should see a newly asked question in its questions list", async () => { + var card = await CardApi.create(metricCardDef) + + expect(card.name).toBe(metricCardDef.name); + // see that there is a new question on the metric's questions page + const store = await createTestStore() + store.pushPath("/reference/metrics/"+metricIds[0]+'/questions'); + const container = mount(store.connectContainer(<MetricQuestionsContainer />)); + await store.waitForActions([FETCH_METRICS, FETCH_METRIC_TABLE]) + + await CardApi.delete({cardId: card.id}) + }) + + + }); + }); + + describe("The Segments section of the Data Reference", async ()=>{ + + describe("Empty State", async () => { + it("Should show no segments in the list", async () => { + const store = await createTestStore() + store.pushPath("/reference/segments"); + const container = mount(store.connectContainer(<SegmentListContainer />)); + await store.waitForActions([FETCH_SEGMENTS]) + }) + + }); + + fdescribe("With Segments State", async () => { + var segmentIds = [] + + beforeAll(async () => { + // Create some segments to have something to look at + var segment = await SegmentApi.create(segmentDef); + var anotherSegment = await SegmentApi.create(anotherSegmentDef); + segmentIds.push(segment.id) + segmentIds.push(anotherSegment.id) + + }) + + afterAll(async () => { + // Delete the guide we created + // remove the metrics we created + // This is a bit messy as technically these are just archived + for (const id of segmentIds){ + await SegmentApi.delete({segmentId: id, revision_message: "Please"}) + } + }) + + + // segments list + it("Should show the segments in the list", async () => { + const store = await createTestStore() + store.pushPath("/reference/segments"); + const container = mount(store.connectContainer(<SegmentListContainer />)); + await store.waitForActions([FETCH_SEGMENTS]) + }) + // segment detail + it("Should show the segment detail view for a specific id", async () => { + const store = await createTestStore() + store.pushPath("/reference/segments/"+segmentIds[0]); + const container = mount(store.connectContainer(<SegmentDetailContainer />)); + await store.waitForActions([FETCH_SEGMENT_TABLE]) + }) + + // segments field list + it("Should show the segment fields list", async () => { + const store = await createTestStore() + store.pushPath("/reference/segments/"+segmentIds[0]+"/fields"); + const container = mount(store.connectContainer(<SegmentFieldListContainer />)); + await store.waitForActions([FETCH_SEGMENT_TABLE, FETCH_SEGMENT_FIELDS]) + }) + // segment detail + it("Should show the segment field detail view for a specific id", async () => { + const store = await createTestStore() + store.pushPath("/reference/segments/"+segmentIds[0]+"/fields/" + 1); + const container = mount(store.connectContainer(<SegmentFieldDetailContainer />)); + await store.waitForActions([FETCH_SEGMENT_TABLE, FETCH_SEGMENT_FIELDS]) + }) + + // segment questions + it("Should show no questions based on a new segment", async () => { + const store = await createTestStore() + store.pushPath("/reference/segments/"+segmentIds[0]+'/questions'); + const container = mount(store.connectContainer(<SegmentQuestionsContainer />)); + await store.waitForActions([FETCH_SEGMENT_TABLE, LOAD_ENTITIES]) + }) + // segment revisions + it("Should show revisions", async () => { + const store = await createTestStore() + store.pushPath("/reference/segments/"+segmentIds[0]+'/revisions'); + const container = mount(store.connectContainer(<SegmentRevisionsContainer />)); + await store.waitForActions([FETCH_SEGMENT_TABLE, FETCH_SEGMENT_REVISIONS]) + }) + + + + it("Should see a newly asked question in its questions list", async () => { + var card = await CardApi.create(segmentCardDef) + + expect(card.name).toBe(segmentCardDef.name); + + await CardApi.delete({cardId: card.id}) + + const store = await createTestStore() + store.pushPath("/reference/segments/"+segmentIds[0]+'/questions'); + const container = mount(store.connectContainer(<SegmentQuestionsContainer />)); + await store.waitForActions([FETCH_SEGMENT_TABLE, LOAD_ENTITIES]) + }) + + }); + }); + + describe("The Data Reference for the Sample Database", async () => { + + // database list + it("should see a single database", async ()=>{ + const store = await createTestStore() + store.pushPath("/reference/databases/"); + const container = mount(store.connectContainer(<DatabaseListContainer />)); + await store.waitForActions(["metabase/metadata/FETCH_DATABASES"]) + }) + + // database detail + it("should see a the detail view for the sample database", async ()=>{ + const store = await createTestStore() + store.pushPath("/reference/databases/1"); + const container = mount(store.connectContainer(<DatabaseDetailContainer />)); + await store.waitForActions(["metabase/metadata/FETCH_DATABASE_METADATA"]) + + }) + + // table list + it("should see the 4 tables in the sample database",async () => { + const store = await createTestStore() + store.pushPath("/reference/databases/1/tables"); + const container = mount(store.connectContainer(<TableListContainer />)); + await store.waitForActions(["metabase/metadata/FETCH_DATABASE_METADATA"]) + + expect(4).toBe(4); + }) + // table detail + + it("should see the Orders table", async () => { + const store = await createTestStore() + store.pushPath("/reference/databases/1/tables/1"); + const container = mount(store.connectContainer(<TableDetailContainer />)); + await store.waitForActions(["metabase/metadata/FETCH_DATABASE_METADATA"]) + expect(true).toBe(true); + }) + + it("should see the Reviews table", async () => { + const store = await createTestStore() + store.pushPath("/reference/databases/1/tables/2"); + const container = mount(store.connectContainer(<TableDetailContainer />)); + await store.waitForActions(["metabase/metadata/FETCH_DATABASE_METADATA"]) + expect(true).toBe(true); + }) + it("should see the Products table", async () => { + const store = await createTestStore() + store.pushPath("/reference/databases/1/tables/3"); + const container = mount(store.connectContainer(<TableDetailContainer />)); + await store.waitForActions(["metabase/metadata/FETCH_DATABASE_METADATA"]) + expect(true).toBe(true); + }) + it("should see the People table", async () => { + const store = await createTestStore() + store.pushPath("/reference/databases/1/tables/4"); + const container = mount(store.connectContainer(<TableDetailContainer />)); + await store.waitForActions(["metabase/metadata/FETCH_DATABASE_METADATA"]) + expect(true).toBe(true); + }) + // field list + it("should see the fields for the orders table", async () => { + const store = await createTestStore() + store.pushPath("/reference/databases/1/tables/1/fields"); + const container = mount(store.connectContainer(<FieldListContainer />)); + await store.waitForActions(["metabase/metadata/FETCH_DATABASE_METADATA"]) + expect(true).toBe(true); + + expect(true).toBe(true); + }) + it("should see the questions for the orders tables", async () => { + + const store = await createTestStore() + store.pushPath("/reference/databases/1/tables/1/questions"); + const container = mount(store.connectContainer(<TableQuestionsContainer />)); + await store.waitForActions(["metabase/metadata/FETCH_DATABASE_METADATA"]) + expect(true).toBe(true); + + + expect(true).toBe(true); + + var card = await CardApi.create(cardDef) + + expect(card.name).toBe(cardDef.name); + + await CardApi.delete({cardId: card.id}) + }) + + // field detail + + it("should see the orders created_at timestamp field", async () => { + const store = await createTestStore() + store.pushPath("/reference/databases/1/tables/1/fields/1"); + const container = mount(store.connectContainer(<FieldDetailContainer />)); + await store.waitForActions(["metabase/metadata/FETCH_DATABASE_METADATA"]) + }) + + it("should see the orders id field", async () => { + const store = await createTestStore() + store.pushPath("/reference/databases/1/tables/1/fields/25"); + const container = mount(store.connectContainer(<FieldDetailContainer />)); + await store.waitForActions(["metabase/metadata/FETCH_DATABASE_METADATA"]) + }) + }); + + }); \ No newline at end of file diff --git a/frontend/test/unit/reference/utils.spec.js b/frontend/test/unit/reference/utils.spec.js index ed576007bcc..4da172ad2a7 100644 --- a/frontend/test/unit/reference/utils.spec.js +++ b/frontend/test/unit/reference/utils.spec.js @@ -1,148 +1,13 @@ import { - tryFetchData, - tryUpdateData, - tryUpdateFields, - buildBreadcrumbs, databaseToForeignKeys, - separateTablesBySchema, getQuestion } from 'metabase/reference/utils'; +import { separateTablesBySchema } from "metabase/reference/databases/TableList" import { TYPE } from "metabase/lib/types"; describe("Reference utils.js", () => { - const getProps = ({ - section = { - fetch: {test1: [], test2: [2], test3: [3,4]} - }, - entity = { foo: 'foo', bar: 'bar' }, - entities = { foo: {foo: 'foo', bar: 'bar'}, bar: {foo: 'bar', bar: 'foo'} }, - test1 = jasmine.createSpy('test1'), - test2 = jasmine.createSpy('test2'), - test3 = jasmine.createSpy('test3'), - updateField = jasmine.createSpy('updateField'), - clearError = jasmine.createSpy('clearError'), - resetForm = jasmine.createSpy('resetForm'), - endEditing = jasmine.createSpy('endEditing'), - startLoading = jasmine.createSpy('startLoading'), - setError = jasmine.createSpy('setError'), - endLoading = jasmine.createSpy('endLoading') - } = {}) => ({ - section, - entity, - entities, - test1, - test2, - test3, - updateField, - clearError, - resetForm, - endEditing, - startLoading, - setError, - endLoading - }); - - describe("tryFetchData()", () => { - it("should call all fetch functions in section with correct arguments", async (done) => { - const props = getProps(); - await tryFetchData(props); - - expect(props.test1).toHaveBeenCalledWith(); - expect(props.test2).toHaveBeenCalledWith(2); - expect(props.test3).toHaveBeenCalledWith(3, 4); - expect(props.clearError.calls.count()).toEqual(1); - expect(props.startLoading.calls.count()).toEqual(1); - expect(props.setError.calls.count()).toEqual(0); - expect(props.endLoading.calls.count()).toEqual(1); - done(); - }); - - xit("should set error when error occurs", async () => { - const props = getProps(() => Promise.reject('test')); - tryFetchData(props).catch(error => console.error(error)) - - expect(props.test1).toHaveBeenCalledWith(); - expect(props.test2).toHaveBeenCalledWith(2); - expect(props.test3).toHaveBeenCalledWith(3, 4); - expect(props.clearError.calls.count()).toEqual(1); - expect(props.startLoading.calls.count()).toEqual(1); - expect(props.setError.calls.count()).toEqual(0); - expect(props.endLoading.calls.count()).toEqual(1); - }); - }); - - describe("tryUpdateData()", () => { - it("should call update function with merged entity", async (done) => { - const props = getProps({ - section: { - update: 'test1' - }, - entity: { foo: 'foo', bar: 'bar' } - }); - const fields = {bar: 'bar2'}; - - await tryUpdateData(fields, props); - - expect(props.test1.calls.argsFor(0)[0]).toEqual({foo: 'foo', bar: 'bar2'}); - expect(props.endEditing.calls.count()).toEqual(1); - expect(props.resetForm.calls.count()).toEqual(1); - expect(props.startLoading.calls.count()).toEqual(1); - expect(props.setError.calls.count()).toEqual(0); - expect(props.endLoading.calls.count()).toEqual(1); - done(); - }); - it("should ignore untouched fields when merging changed fields", async (done) => { - const props = getProps({ - section: { - update: 'test1' - }, - entity: { foo: 'foo', bar: 'bar' } - }); - const fields = {foo: '', bar: undefined, boo: 'boo'}; - - await tryUpdateData(fields, props); - - expect(props.test1.calls.argsFor(0)[0]).toEqual({foo: '', bar: 'bar', boo: 'boo'}); - expect(props.endEditing.calls.count()).toEqual(1); - expect(props.resetForm.calls.count()).toEqual(1); - expect(props.startLoading.calls.count()).toEqual(1); - expect(props.setError.calls.count()).toEqual(0); - expect(props.endLoading.calls.count()).toEqual(1); - done(); - }); - }); - - describe("tryUpdateFields()", () => { - it("should call update function with all updated fields", async (done) => { - const props = getProps(); - const formFields = { - foo: {foo: undefined, bar: 'bar2'}, - bar: {foo: '', bar: 'bar2'} - }; - - await tryUpdateFields(formFields, props); - - expect(props.updateField.calls.argsFor(0)[0]).toEqual({foo: 'foo', bar: 'bar2'}); - expect(props.updateField.calls.argsFor(1)[0]).toEqual({foo: '', bar: 'bar2'}); - done(); - }); - - it("should not call update function for items where all fields are untouched", async (done) => { - const props = getProps(); - const formFields = { - foo: {foo: undefined, bar: undefined}, - bar: {foo: undefined, bar: ''} - }; - - await tryUpdateFields(formFields, props); - - expect(props.updateField.calls.argsFor(0)[0]).toEqual({foo: 'bar', bar: ''}); - expect(props.updateField.calls.count()).toEqual(1); - done(); - }); - }); describe("databaseToForeignKeys()", () => { it("should build foreignKey viewmodels from database", () => { @@ -198,62 +63,7 @@ describe("Reference utils.js", () => { }); }); - describe("buildBreadcrumbs()", () => { - const section1 = { - id: 1, - breadcrumb: 'section1' - }; - - const section2 = { - id: 2, - breadcrumb: 'section2', - parent: section1 - }; - - const section3 = { - id: 3, - breadcrumb: 'section3', - parent: section2 - }; - - const section4 = { - id: 4, - breadcrumb: 'section4', - parent: section3 - }; - - const section5 = { - id: 5, - breadcrumb: 'section5', - parent: section4 - }; - - it("should build correct breadcrumbs from parent section", () => { - const breadcrumbs = buildBreadcrumbs(section1); - expect(breadcrumbs).toEqual([ - [ 'section1' ] - ]); - }); - - it("should build correct breadcrumbs from child section", () => { - const breadcrumbs = buildBreadcrumbs(section3); - expect(breadcrumbs).toEqual([ - [ 'section1', 1 ], - [ 'section2', 2 ], - [ 'section3' ] - ]); - }); - - it("should keep at most 3 highest level breadcrumbs", () => { - const breadcrumbs = buildBreadcrumbs(section5); - expect(breadcrumbs).toEqual([ - [ 'section3', 3 ], - [ 'section4', 4 ], - [ 'section5' ] - ]); - }); - }); - + describe("tablesToSchemaSeparatedTables()", () => { it("should add schema separator to appropriate locations", () => { const tables = { diff --git a/package.json b/package.json index 698562cf7d7..7d6388ff1cb 100644 --- a/package.json +++ b/package.json @@ -152,7 +152,7 @@ "test": "yarn run test-jest && yarn run test-karma", "test-karma": "karma start frontend/test/karma.conf.js --single-run", "test-karma-watch": "karma start frontend/test/karma.conf.js --auto-watch --reporters nyan", - "test-jest": "jest", + "test-jest": "jest --maxWorkers=10", "test-jest-watch": "jest --watch", "test-e2e": "JASMINE_CONFIG_PATH=./frontend/test/e2e/support/jasmine.json jasmine", "test-e2e-dev": "./frontend/test/e2e-with-persistent-browser.js", @@ -185,6 +185,11 @@ ], "setupFiles": [ "<rootDir>/frontend/test/metabase-bootstrap.js" - ] + ], + "globals": { + "ace": {}, + "ga": {}, + "document": {} + } } -} +} \ No newline at end of file -- GitLab