diff --git a/frontend/src/metabase/App.jsx b/frontend/src/metabase/App.jsx index 89922c39f14769a232e61c36a5a64bdbe913df61..389ac21c4902d205209073709fe9d4ed561e6a13 100644 --- a/frontend/src/metabase/App.jsx +++ b/frontend/src/metabase/App.jsx @@ -1,7 +1,7 @@ /* @flow weak */ -import React, { Component } from "react"; -import { connect } from "react-redux"; +import React, {Component} from "react"; +import {connect} from "react-redux"; import Navbar from "metabase/nav/containers/Navbar.jsx"; @@ -9,26 +9,33 @@ import UndoListing from "metabase/containers/UndoListing"; import NotFound from "metabase/components/NotFound.jsx"; import Unauthorized from "metabase/components/Unauthorized.jsx"; +import Archived from "metabase/components/Archived.jsx"; const mapStateToProps = (state, props) => ({ errorPage: state.app.errorPage -}) +}); + +const getErrorComponent = ({status, data, context}) => { + if (status === 403) { + return <Unauthorized /> + } else if (data && data.error_code === "archived" && context === "dashboard") { + return <Archived entityName="dashboard" linkTo="/dashboards/archive" /> + } else if (data && data.error_code === "archived" && context === "query-builder") { + return <Archived entityName="question" linkTo="/questions/archive" /> + } else { + return <NotFound /> + } +} @connect(mapStateToProps) export default class App extends Component { render() { const { children, location, errorPage } = this.props; + return ( <div className="spread flex flex-column"> - <Navbar location={location} className="flex-no-shrink" /> - { errorPage && errorPage.status === 403 ? - <Unauthorized /> - : errorPage ? - // TODO: different error page for non-404 errors - <NotFound /> - : - children - } + <Navbar location={location} className="flex-no-shrink"/> + { errorPage ? getErrorComponent(errorPage) : children } <UndoListing /> </div> ) diff --git a/frontend/src/metabase/admin/permissions/selectors.js b/frontend/src/metabase/admin/permissions/selectors.js index 2b212a7b67ade21268d5c74219d1d0d4ccc7528c..7ec22a97101b9cc2fd5aad360b0ddd9c9baa1b25 100644 --- a/frontend/src/metabase/admin/permissions/selectors.js +++ b/frontend/src/metabase/admin/permissions/selectors.js @@ -146,7 +146,7 @@ function getRevokingAccessToAllTablesWarningModal(database, permissions, groupId // allTableEntityIds contains tables from all schemas const allTableEntityIds = database.tables().map((table) => ({ databaseId: table.db_id, - schemaName: table.schema, + schemaName: table.schema || "", tableId: table.id })); @@ -456,7 +456,7 @@ export const getDatabasesPermissionsGrid = createSelector( const getCollections = (state) => state.admin.permissions.collections; const getCollectionPermission = (permissions, groupId, { collectionId }) => - getIn(permissions, [groupId, collectionId]) + getIn(permissions, [groupId, collectionId]); export const getCollectionsPermissionsGrid = createSelector( getCollections, getGroups, getPermissions, @@ -504,7 +504,6 @@ export const getCollectionsPermissionsGrid = createSelector( } ); - export const getDiff = createSelector( getMeta, getGroups, getPermissions, getOriginalPermissions, (metadata: Metadata, groups: Array<Group>, permissions: GroupsPermissions, originalPermissions: GroupsPermissions) => diff --git a/frontend/src/metabase/admin/permissions/selectors.spec.fixtures.js b/frontend/src/metabase/admin/permissions/selectors.spec.fixtures.js new file mode 100644 index 0000000000000000000000000000000000000000..e6e4109acd2a636a20a1ebb4a95a82d3544c0b2e --- /dev/null +++ b/frontend/src/metabase/admin/permissions/selectors.spec.fixtures.js @@ -0,0 +1,93 @@ +// Database 2 contains an imaginary multi-schema database (like Redshift for instance) +// Database 3 contains an imaginary database which doesn't have any schemas (like MySQL) +// (A single-schema database was originally Database 1 but it got removed as testing against it felt redundant) + +export const normalizedMetadata = { + "metrics": {}, + "segments": {}, + "databases": { + "2": { + "name": "Imaginary Multi-Schema Dataset", + "tables": [ + // In schema_1 + 5, + 6, + // In schema_2 + 7, + 8, + 9 + ], + "id": 2 + }, + "3": { + "name": "Imaginary Schemaless Dataset", + "tables": [ + 10, + 11, + 12, + 13 + ], + "id": 3 + } + }, + "tables": { + "5": { + "schema": "schema_1", + "name": "Avian Singles Messages", + "id": 5, + "db_id": 2 + }, + "6": { + "schema": "schema_1", + "name": "Avian Singles Users", + "id": 6, + "db_id": 2 + }, + "7": { + "schema": "schema_2", + "name": "Tupac Sightings Sightings", + "id": 7, + "db_id": 2 + }, + "8": { + "schema": "schema_2", + "name": "Tupac Sightings Categories", + "id": 8, + "db_id": 2 + }, + "9": { + "schema": "schema_2", + "name": "Tupac Sightings Cities", + "id": 9, + "db_id": 2 + }, + "10": { + "schema": null, + "name": "Badminton Men's Double Results", + "id": 10, + "db_id": 3 + }, + "11": { + "schema": null, + "name": "Badminton Mixed Double Results", + "id": 11, + "db_id": 3 + }, + "12": { + "schema": null, + "name": "Badminton Women's Singles Results", + "id": 12, + "db_id": 3 + }, + "13": { + "schema": null, + "name": "Badminton Mixed Singles Results", + "id": 13, + "db_id": 3 + }, + }, + "fields": {/* stripped out */}, + "revisions": {}, + "databasesList": [2, 3] +}; + diff --git a/frontend/src/metabase/admin/permissions/selectors.spec.js b/frontend/src/metabase/admin/permissions/selectors.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..1b2b32e50005d88263c1c06b23630417013ef887 --- /dev/null +++ b/frontend/src/metabase/admin/permissions/selectors.spec.js @@ -0,0 +1,468 @@ +/** + * Tests granting and revoking permissions against three kinds of datasets: + * - dataset with tables in a single PUBLIC schema (for instance H2 or PostgreSQL if no custom schemas created) + * - dataset with no schemas (for instance MySQL) + * - dataset with multiple schemas (for instance Redshift) + */ + +import { setIn } from "icepick"; + +jest.mock('metabase/lib/analytics'); + +import {GroupsPermissions} from "metabase/meta/types/Permissions"; +import { normalizedMetadata } from "./selectors.spec.fixtures"; +import { getTablesPermissionsGrid, getSchemasPermissionsGrid, getDatabasesPermissionsGrid } from "./selectors"; + +/******** INITIAL TEST STATE ********/ + +const groups = [{ + id: 1, + name: "Group starting with full access", +}, { + id: 2, + name: "Group starting with no access at all", +}]; + +const initialPermissions: GroupsPermissions = { + 1: { + // Sample dataset + 1: { + "native": "write", + "schemas": "all" + }, + // Imaginary multi-schema + 2: { + "native": "write", + "schemas": "all" + }, + // Imaginary schemaless + 3: { + "native": "write", + "schemas": "all" + } + }, + 2: { + // Sample dataset + 1: { + "native": "none", + "schemas": "none" + }, + // Imaginary multi-schema + 2: { + "native": "none", + "schemas": "none" + }, + // Imaginary schemaless + 3: { + "native": "none", + "schemas": "none" + } + } +}; + + +/******** MANAGING THE CURRENT (SIMULATED) STATE TREE ********/ + +const initialState = { + admin: { + permissions: { + permissions: initialPermissions, + originalPermissions: initialPermissions, + groups + } + }, + metadata: normalizedMetadata +}; + +var state = initialState; +const resetState = () => { state = initialState }; +const getPermissionsTree = () => state.admin.permissions.permissions; +const getPermissionsForDb = ({ entityId, groupId }) => getPermissionsTree()[groupId][entityId.databaseId]; + +const updatePermissionsInState = (permissions) => { + state = setIn(state, ["admin", "permissions", "permissions"], permissions); +}; + +const getProps = ({ databaseId, schemaName }) => ({ + params: { + databaseId, + schemaName + } +}); + +/******** HIGH-LEVEL METHODS FOR UPDATING PERMISSIONS ********/ + +const changePermissionsForEntityInGrid = ({ grid, category, entityId, groupId, permission }) => { + const newPermissions = grid.permissions[category].updater(groupId, entityId, permission); + updatePermissionsInState(newPermissions); + return newPermissions; +}; + +const changeDbNativePermissionsForEntity = ({ entityId, groupId, permission }) => { + const grid = getDatabasesPermissionsGrid(state, getProps(entityId)); + return changePermissionsForEntityInGrid({ grid, category: "native", entityId, groupId, permission }); +}; + +const changeDbDataPermissionsForEntity = ({ entityId, groupId, permission }) => { + const grid = getDatabasesPermissionsGrid(state, getProps(entityId)); + return changePermissionsForEntityInGrid({ grid, category: "schemas", entityId, groupId, permission }); +}; + +const changeSchemaPermissionsForEntity = ({ entityId, groupId, permission }) => { + const grid = getSchemasPermissionsGrid(state, getProps(entityId)); + return changePermissionsForEntityInGrid({ grid, category: "tables", entityId, groupId, permission }); +}; + +const changeTablePermissionsForEntity = ({ entityId, groupId, permission }) => { + const grid = getTablesPermissionsGrid(state, getProps(entityId)); + return changePermissionsForEntityInGrid({ grid, category: "fields", entityId, groupId, permission }); +}; + +const getMethodsForDbAndSchema = (entityId) => ({ + changeDbNativePermissions: ({ groupId, permission }) => + changeDbNativePermissionsForEntity({ entityId, groupId, permission }), + changeDbDataPermissions: ({ groupId, permission }) => + changeDbDataPermissionsForEntity({ entityId, groupId, permission }), + changeSchemaPermissions: ({ groupId, permission }) => + changeSchemaPermissionsForEntity({ entityId, groupId, permission }), + changeTablePermissions: ({ tableId, groupId, permission }) => + changeTablePermissionsForEntity({ entityId: {...entityId, tableId}, groupId, permission }), + getPermissions: ({ groupId }) => + getPermissionsForDb({ entityId, groupId }) +}); + +/******** ACTUAL TESTS ********/ + +describe("permissions selectors", () => { + beforeEach(resetState); + + describe("for a schemaless dataset", () => { + // Schema "name" (better description would be a "permission path identifier") is simply an empty string + // for databases where the metadata value for table schema is `null` + const schemalessDataset = getMethodsForDbAndSchema({ databaseId: 3, schemaName: "" }); + + it("should restrict access correctly on table level", () => { + // Revoking access to one table should downgrade the native permissions to "read" + schemalessDataset.changeTablePermissions({ tableId: 10, groupId: 1, permission: "none" }); + expect(schemalessDataset.getPermissions({ groupId: 1})).toMatchObject({ + "native": "read", + "schemas": { + "": { + "10": "none", + "11": "all", + "12": "all", + "13": "all" + } + } + }); + + // Revoking access to the rest of tables one-by-one... + schemalessDataset.changeTablePermissions({ tableId: 11, groupId: 1, permission: "none" }); + schemalessDataset.changeTablePermissions({ tableId: 12, groupId: 1, permission: "none" }); + schemalessDataset.changeTablePermissions({ tableId: 13, groupId: 1, permission: "none" }); + + expect(schemalessDataset.getPermissions({groupId: 1})).toMatchObject({ + // ...should revoke all permissions for that database + "native": "none", + "schemas": "none" + }); + + }); + + it("should restrict access correctly on db level", () => { + // Should let change the native permission to "read" + schemalessDataset.changeDbNativePermissions({ groupId: 1, permission: "read" }); + expect(schemalessDataset.getPermissions({groupId: 1})).toMatchObject({ + "native": "read", + "schemas": "all" + }); + + // Should not let change the native permission to none + schemalessDataset.changeDbNativePermissions({ groupId: 1, permission: "none" }); + expect(schemalessDataset.getPermissions({groupId: 1})).toMatchObject({ + "native": "none", + "schemas": "all" + }); + + resetState(); // ad-hoc state reset for the next test + // Revoking the data access to the database at once should revoke all permissions for that database + schemalessDataset.changeDbDataPermissions({ groupId: 1, permission: "none" }); + expect(schemalessDataset.getPermissions({groupId: 1})).toMatchObject({ + "native": "none", + "schemas": "none" + }); + }); + + it("should grant more access correctly on table level", () => { + // Simply grant an access to a single table + schemalessDataset.changeTablePermissions({ tableId: 12, groupId: 2, permission: "all" }); + expect(schemalessDataset.getPermissions({groupId: 2})).toMatchObject({ + "native": "none", + "schemas": { + "": { + "10": "none", + "11": "none", + "12": "all", + "13": "none" + } + } + }); + + // Grant the access to rest of tables + schemalessDataset.changeTablePermissions({ tableId: 10, groupId: 2, permission: "all" }); + schemalessDataset.changeTablePermissions({ tableId: 11, groupId: 2, permission: "all" }); + schemalessDataset.changeTablePermissions({ tableId: 13, groupId: 2, permission: "all" }); + expect(schemalessDataset.getPermissions({groupId: 2})).toMatchObject({ + "native": "none", + "schemas": "all" + }); + + + // Should pass changes to native permissions through + schemalessDataset.changeDbNativePermissions({ groupId: 2, permission: "read" }); + expect(schemalessDataset.getPermissions({ groupId: 2 })).toMatchObject({ + "native": "read", + "schemas": "all" + }); + }); + + it("should grant more access correctly on db level", () => { + // Setting limited access should produce a permission tree where each schema has "none" access + // (this is a strange, rather no-op edge case but the UI currently enables this) + schemalessDataset.changeDbDataPermissions({ groupId: 2, permission: "controlled" }); + expect(schemalessDataset.getPermissions({ groupId: 2 })).toMatchObject({ + "native": "none", + "schemas": { + "": "none" + } + }); + + // Granting native access should also grant a full write access + schemalessDataset.changeDbNativePermissions({ groupId: 2, permission: "write" }); + expect(schemalessDataset.getPermissions({ groupId: 2 })).toMatchObject({ + "native": "write", + "schemas": "all" + }); + + resetState(); // ad-hoc reset (normally run before tests) + // test that setting full access works too + schemalessDataset.changeDbDataPermissions({ groupId: 2, permission: "all" }); + expect(schemalessDataset.getPermissions({ groupId: 2 })).toMatchObject({ + "native": "none", + "schemas": "all" + }); + }) + }); + + describe("for a dataset with multiple schemas", () => { + const schema1 = getMethodsForDbAndSchema({ databaseId: 2, schemaName: "schema_1" }); + const schema2 = getMethodsForDbAndSchema({ databaseId: 2, schemaName: "schema_2" }); + + it("should restrict access correctly on table level", () => { + // Revoking access to one table should downgrade the native permissions to "read" + schema1.changeTablePermissions({ tableId: 5, groupId: 1, permission: "none" }); + expect(schema1.getPermissions({ groupId: 1})).toMatchObject({ + "native": "read", + "schemas": { + "schema_1": { + "5": "none", + "6": "all" + }, + "schema_2": "all" + } + }); + + // State where both schemas have mixed permissions + schema2.changeTablePermissions({ tableId: 8, groupId: 1, permission: "none" }); + schema2.changeTablePermissions({ tableId: 9, groupId: 1, permission: "none" }); + expect(schema2.getPermissions({groupId: 1})).toMatchObject({ + "native": "read", + "schemas": { + "schema_1": { + "5": "none", + "6": "all" + }, + "schema_2": { + "7": "all", + "8": "none", + "9": "none" + } + } + }); + + // Completely revoke access to the first schema with table-level changes + schema1.changeTablePermissions({ tableId: 6, groupId: 1, permission: "none" }); + + expect(schema1.getPermissions({groupId: 1})).toMatchObject({ + "native": "read", + "schemas": { + "schema_1": "none", + "schema_2": { + "7": "all", + "8": "none", + "9": "none" + } + } + }); + + // Revoking all permissions of the other schema should revoke all db permissions too + schema2.changeTablePermissions({ tableId: 7, groupId: 1, permission: "none" }); + expect(schema2.getPermissions({groupId: 1})).toMatchObject({ + "native": "none", + "schemas": "none" + }); + }); + + it("should restrict access correctly on schema level", () => { + // Revoking access to one schema + schema2.changeSchemaPermissions({ groupId: 1, permission: "none" }); + expect(schema2.getPermissions({groupId: 1})).toMatchObject({ + "native": "read", + "schemas": { + "schema_1": "all", + "schema_2": "none" + } + }); + + // Revoking access to other too + schema1.changeSchemaPermissions({ groupId: 1, permission: "none" }); + expect(schema1.getPermissions({groupId: 1})).toMatchObject({ + "native": "none", + "schemas": "none" + }); + }); + + it("should restrict access correctly on db level", () => { + // Should let change the native permission to "read" + schema1.changeDbNativePermissions({ groupId: 1, permission: "read" }); + expect(schema1.getPermissions({groupId: 1})).toMatchObject({ + "native": "read", + "schemas": "all" + }); + + // Should let change the native permission to none + schema1.changeDbNativePermissions({ groupId: 1, permission: "none" }); + expect(schema1.getPermissions({groupId: 1})).toMatchObject({ + "native": "none", + "schemas": "all" + }); + + resetState(); // ad-hoc state reset for the next test + // Revoking the data access to the database at once should revoke all permissions for that database + schema1.changeDbDataPermissions({ groupId: 1, permission: "none" }); + expect(schema1.getPermissions({groupId: 1})).toMatchObject({ + "native": "none", + "schemas": "none" + }); + }); + + it("should grant more access correctly on table level", () => { + // Simply grant an access to a single table + schema2.changeTablePermissions({ tableId: 7, groupId: 2, permission: "all" }); + expect(schema2.getPermissions({groupId: 2})).toMatchObject({ + "native": "none", + "schemas": { + "schema_1": "none", + "schema_2": { + "7": "all", + "8": "none", + "9": "none" + } + } + }); + + // State where both schemas have mixed permissions + schema1.changeTablePermissions({ tableId: 5, groupId: 2, permission: "all" }); + expect(schema1.getPermissions({groupId: 2})).toMatchObject({ + "native": "none", + "schemas": { + "schema_1": { + "5": "all", + "6": "none" + }, + "schema_2": { + "7": "all", + "8": "none", + "9": "none" + } + } + }); + + // Grant full access to the second schema + schema2.changeTablePermissions({ tableId: 8, groupId: 2, permission: "all" }); + schema2.changeTablePermissions({ tableId: 9, groupId: 2, permission: "all" }); + expect(schema2.getPermissions({groupId: 2})).toMatchObject({ + "native": "none", + "schemas": { + "schema_1": { + "5": "all", + "6": "none" + }, + "schema_2": "all" + } + }); + + // Grant the access to whole db (no native yet) + schema1.changeTablePermissions({ tableId: 5, groupId: 2, permission: "all" }); + schema1.changeTablePermissions({ tableId: 6, groupId: 2, permission: "all" }); + expect(schema1.getPermissions({groupId: 2})).toMatchObject({ + "native": "none", + "schemas": "all" + }); + + // Should pass changes to native permissions through + schema1.changeDbNativePermissions({ groupId: 2, permission: "read" }); + expect(schema1.getPermissions({ groupId: 2 })).toMatchObject({ + "native": "read", + "schemas": "all" + }); + }); + + it("should grant more access correctly on schema level", () => { + // Granting full access to one schema + schema1.changeSchemaPermissions({ groupId: 2, permission: "all" }); + expect(schema1.getPermissions({groupId: 2})).toMatchObject({ + "native": "none", + "schemas": { + "schema_1": "all", + "schema_2": "none" + } + }); + + // Granting access to the other as well + schema2.changeSchemaPermissions({ groupId: 2, permission: "all" }); + expect(schema2.getPermissions({groupId: 2})).toMatchObject({ + "native": "none", + "schemas": "all" + }); + }); + + it("should grant more access correctly on db level", () => { + // Setting limited access should produce a permission tree where each schema has "none" access + // (this is a strange, rather no-op edge case but the UI currently enables this) + schema1.changeDbDataPermissions({ groupId: 2, permission: "controlled" }); + expect(schema1.getPermissions({ groupId: 2 })).toMatchObject({ + "native": "none", + "schemas": { + "schema_1": "none", + "schema_2": "none" + } + }); + + // Granting native access should also grant a full write access + schema1.changeDbNativePermissions({ groupId: 2, permission: "write" }); + expect(schema1.getPermissions({ groupId: 2 })).toMatchObject({ + "native": "write", + "schemas": "all" + }); + + resetState(); // ad-hoc reset (normally run before tests) + // test that setting full access works too + schema1.changeDbDataPermissions({ groupId: 2, permission: "all" }); + expect(schema1.getPermissions({ groupId: 2 })).toMatchObject({ + "native": "none", + "schemas": "all" + }); + }) + }); +}); diff --git a/frontend/src/metabase/components/Archived.jsx b/frontend/src/metabase/components/Archived.jsx new file mode 100644 index 0000000000000000000000000000000000000000..64d514cb43c5d2784174a61fb6d342799b38c17a --- /dev/null +++ b/frontend/src/metabase/components/Archived.jsx @@ -0,0 +1,16 @@ +import React from 'react'; +import EmptyState from "metabase/components/EmptyState"; +import Link from "metabase/components/Link"; + +const Archived = ({ entityName, linkTo }) => + <div className="full-height flex justify-center align-center"> + <EmptyState + message={<div> + <div>This {entityName} has been archived</div> + <Link to={linkTo} className="my2 link" style={{fontSize: "14px"}}>View the archive</Link> + </div>} + icon="viewArchive" + /> + </div>; + +export default Archived; \ No newline at end of file diff --git a/frontend/src/metabase/components/LoadingAndErrorWrapper.jsx b/frontend/src/metabase/components/LoadingAndErrorWrapper.jsx index c5fd86d101bbcf8736044228f4230f1de6ce0212..f37e11c77ab98338e86add30b197ca05e06c6617 100644 --- a/frontend/src/metabase/components/LoadingAndErrorWrapper.jsx +++ b/frontend/src/metabase/components/LoadingAndErrorWrapper.jsx @@ -28,7 +28,8 @@ export default class LoadingAndErrorWrapper extends Component { getErrorMessage() { const { error } = this.props; return ( - error.data || + // NOTE Atte Keinänen 5/10/17 Dashboard API endpoint returns the error as JSON with `message` field + error.data && (error.data.message ? error.data.message : error.data) || error.statusText || error.message || "An error occured" diff --git a/frontend/src/metabase/dashboard/components/DashCard.jsx b/frontend/src/metabase/dashboard/components/DashCard.jsx index b6b0fc202a3908d23b6c564f91e9eb3883a1fc1c..6474eca68af3b13631a7b8811e5b11bedddd284c 100644 --- a/frontend/src/metabase/dashboard/components/DashCard.jsx +++ b/frontend/src/metabase/dashboard/components/DashCard.jsx @@ -34,6 +34,7 @@ export default class DashCard extends Component { parameterValues: PropTypes.object.isRequired, markNewCardSeen: PropTypes.func.isRequired, fetchCardData: PropTypes.func.isRequired, + navigateToNewCardFromDashboard: PropTypes.func.isRequired }; async componentDidMount() { @@ -60,7 +61,7 @@ export default class DashCard extends Component { isEditingParameter, onAddSeries, onRemove, - navigateToNewCard, + navigateToNewCardFromDashboard, metadata } = this.props; @@ -134,8 +135,9 @@ export default class DashCard extends Component { onUpdateVisualizationSettings={this.props.onUpdateVisualizationSettings} replacementContent={isEditingParameter && <DashCardParameterMapper dashcard={dashcard} />} metadata={metadata} - onChangeCardAndRun={ navigateToNewCard ? (card: UnsavedCard) => { - navigateToNewCard(card, dashcard) + onChangeCardAndRun={ navigateToNewCardFromDashboard ? ({ nextCard, previousCard }) => { + // navigateToNewCardFromDashboard needs `dashcard` for applying active filters to the query + navigateToNewCardFromDashboard({ nextCard, previousCard, dashcard }) } : null} /> </div> diff --git a/frontend/src/metabase/dashboard/components/Dashboard.jsx b/frontend/src/metabase/dashboard/components/Dashboard.jsx index e9c8458e90b33a59bc1f01f56b1090cd843db6f6..ef76f09aad0989851e9965cacb57614e5a982ff7 100644 --- a/frontend/src/metabase/dashboard/components/Dashboard.jsx +++ b/frontend/src/metabase/dashboard/components/Dashboard.jsx @@ -147,7 +147,7 @@ export default class Dashboard extends Component { } } catch (error) { if (error.status === 404) { - setErrorPage(error); + setErrorPage({ ...error, context: "dashboard" }); } else { console.error(error); this.setState({ error }); @@ -198,40 +198,40 @@ export default class Dashboard extends Component { return ( <LoadingAndErrorWrapper className={cx("Dashboard flex-full pb4", { "Dashboard--fullscreen": isFullscreen, "Dashboard--night": isNightMode})} loading={!dashboard} error={error}> - {() => - <div className="full" style={{ overflowX: "hidden" }}> - <header className="DashboardHeader relative z2"> - <DashboardHeader - {...this.props} - onEditingChange={this.setEditing} - setDashboardAttribute={this.setDashboardAttribute} - addParameter={this.props.addParameter} - parametersWidget={parametersWidget} - /> - </header> - {!isFullscreen && parametersWidget && - <div className="wrapper flex flex-column align-start mt2 relative z2"> - {parametersWidget} - </div> - } - <div className="wrapper"> - - { dashboard.ordered_cards.length === 0 ? - <div className="absolute z1 top bottom left right flex flex-column layout-centered"> - <span className="QuestionCircle">?</span> - <div className="text-normal mt3 mb1">This dashboard is looking empty.</div> - <div className="text-normal text-grey-2">Add a question to start making it useful!</div> - </div> - : - <DashboardGrid + {() => + <div className="full" style={{ overflowX: "hidden" }}> + <header className="DashboardHeader relative z2"> + <DashboardHeader {...this.props} onEditingChange={this.setEditing} + setDashboardAttribute={this.setDashboardAttribute} + addParameter={this.props.addParameter} + parametersWidget={parametersWidget} /> + </header> + {!isFullscreen && parametersWidget && + <div className="wrapper flex flex-column align-start mt2 relative z2"> + {parametersWidget} + </div> } + <div className="wrapper"> + + { dashboard.ordered_cards.length === 0 ? + <div className="absolute z1 top bottom left right flex flex-column layout-centered"> + <span className="QuestionCircle">?</span> + <div className="text-normal mt3 mb1">This dashboard is looking empty.</div> + <div className="text-normal text-grey-2">Add a question to start making it useful!</div> + </div> + : + <DashboardGrid + {...this.props} + onEditingChange={this.setEditing} + /> + } + </div> </div> - </div> - } + } </LoadingAndErrorWrapper> ); } -} +} \ No newline at end of file diff --git a/frontend/src/metabase/dashboard/components/DashboardGrid.jsx b/frontend/src/metabase/dashboard/components/DashboardGrid.jsx index b1ab2839a3bd0217bec0f776911e6a6e99de07be..ce36abfd94c59470b0421a9600e69f82b3313f37 100644 --- a/frontend/src/metabase/dashboard/components/DashboardGrid.jsx +++ b/frontend/src/metabase/dashboard/components/DashboardGrid.jsx @@ -193,7 +193,7 @@ export default class DashboardGrid extends Component { onAddSeries={this.onDashCardAddSeries.bind(this, dc)} onUpdateVisualizationSettings={this.props.onUpdateDashCardVisualizationSettings.bind(this, dc.id)} onReplaceAllVisualizationSettings={this.props.onReplaceAllDashCardVisualizationSettings.bind(this, dc.id)} - navigateToNewCard={this.props.navigateToNewCard} + navigateToNewCardFromDashboard={this.props.navigateToNewCardFromDashboard} metadata={this.props.metadata} /> ) diff --git a/frontend/src/metabase/dashboard/dashboard.js b/frontend/src/metabase/dashboard/dashboard.js index 2bff4047b2ccefd3e7a7b1d656efa228f65bacc1..24aebceb82f09b61cc71f0925de46df7259400ad 100644 --- a/frontend/src/metabase/dashboard/dashboard.js +++ b/frontend/src/metabase/dashboard/dashboard.js @@ -14,7 +14,7 @@ import { applyParameters, questionUrlWithParameters } from "metabase/meta/Card"; import { getParametersBySlug } from "metabase/meta/Parameter"; import type { DashboardWithCards, DashCard, DashCardId } from "metabase/meta/types/Dashboard"; -import type { UnsavedCard, Card, CardId } from "metabase/meta/types/Card"; +import type { Card, CardId } from "metabase/meta/types/Card"; import Utils from "metabase/lib/utils"; import { getPositionForNewDashCard } from "metabase/lib/dashboard_grid"; @@ -25,6 +25,7 @@ import { push } from "react-router-redux"; import { DashboardApi, CardApi, RevisionApi, PublicApi, EmbedApi } from "metabase/services"; import { getDashboard, getDashboardComplete } from "./selectors"; +import {getCardAfterVisualizationClick} from "metabase/visualizations/lib/utils"; const DATASET_SLOW_TIMEOUT = 15 * 1000; @@ -491,24 +492,43 @@ export const deletePublicLink = createAction(DELETE_PUBLIC_LINK, async ({ id }) return { id }; }); -/** All navigation actions from dashboards to cards (e.x. clicking a title, drill through) - * should go through this action, which merges any currently applied dashboard filters - * into the new card / URL parameters. +/** + * All navigation actions from dashboards to cards (e.x. clicking a title, drill through) + * should go through this action, which merges any currently applied dashboard filters + * into the new card / URL parameters. + * + * User-triggered events that are handled here: + * - clicking a dashcard legend: + * * question title legend (only for single-question cards) + * * series legend (multi-aggregation, multi-breakout, multiple questions) + * - clicking the visualization inside dashcard + * * drill-through (single series, multi-aggregation, multi-breakout, multiple questions) + * * (not in 0.24.2 yet: drag on line/area/bar visualization) + * - those all can be applied without or with a dashboard filter */ -// TODO Atte Keinänen 5/2/17: This could be combined with `setCardAndRun` of query_builder/actions.js -// Having two separate actions for very similar behavior was a source of initial confusion for me const NAVIGATE_TO_NEW_CARD = "metabase/dashboard/NAVIGATE_TO_NEW_CARD"; -export const navigateToNewCard = createThunkAction(NAVIGATE_TO_NEW_CARD, (card: UnsavedCard, dashcard: DashCard) => - (dispatch, getState) => { - const { metadata } = getState(); - const { dashboardId, dashboards, parameterValues } = getState().dashboard; - const dashboard = dashboards[dashboardId]; +export const navigateToNewCardFromDashboard = createThunkAction( + NAVIGATE_TO_NEW_CARD, + ({ nextCard, previousCard, dashcard }) => + (dispatch, getState) => { + const {metadata} = getState(); + const {dashboardId, dashboards, parameterValues} = getState().dashboard; + const dashboard = dashboards[dashboardId]; + const cardIsDirty = !_.isEqual(previousCard.dataset_query, nextCard.dataset_query); + + const url = questionUrlWithParameters( + getCardAfterVisualizationClick(nextCard, previousCard), + metadata, + dashboard.parameters, + parameterValues, + dashcard && dashcard.parameter_mappings, + cardIsDirty + ); - // $FlowFixMe - const url = questionUrlWithParameters(card, metadata, dashboard.parameters, parameterValues, dashcard && dashcard.parameter_mappings); - dispatch(push(url)); - }); + dispatch(push(url)); + } +); // reducers diff --git a/frontend/src/metabase/dashboards/containers/Dashboards.jsx b/frontend/src/metabase/dashboards/containers/Dashboards.jsx index b989c2685fdbda5712c697c2cc73c34119ee119c..3b6d558a99c13c1d2aaf253de8afbf982733941f 100644 --- a/frontend/src/metabase/dashboards/containers/Dashboards.jsx +++ b/frontend/src/metabase/dashboards/containers/Dashboards.jsx @@ -144,9 +144,8 @@ export class Dashboards extends Component { return ( <LoadingAndErrorWrapper - style={{ backgroundColor: "#f9fbfc" }} loading={isLoading} - className={cx("relative px4 full-height", {"flex flex-full flex-column": noDashboardsCreated})} + className={cx("relative px4 full-height bg-slate-extra-light", {"flex flex-full flex-column": noDashboardsCreated})} noBackground > { modalOpen ? this.renderCreateDashboardModal() : null } diff --git a/frontend/src/metabase/lib/dom.js b/frontend/src/metabase/lib/dom.js index eb402e5403b9d17d28572e893cc2b57457b959ed..af5a76cbd5017d7a543328d49755f8bbe1e490a4 100644 --- a/frontend/src/metabase/lib/dom.js +++ b/frontend/src/metabase/lib/dom.js @@ -209,6 +209,14 @@ export function constrainToScreen(element, direction, padding) { return false; } +// Used for tackling Safari rendering issues +// http://stackoverflow.com/a/3485654 +export function forceRedraw(domNode) { + domNode.style.display='none'; + domNode.offsetHeight; + domNode.style.display=''; +} + export function moveToBack(element) { if (element && element.parentNode) { element.parentNode.insertBefore( diff --git a/frontend/src/metabase/lib/permissions.js b/frontend/src/metabase/lib/permissions.js index cf2e925ee2debcebad380dc7173cca56ced9d61e..afc79b85473a3bee618190ca8d6cb6100a02180d 100644 --- a/frontend/src/metabase/lib/permissions.js +++ b/frontend/src/metabase/lib/permissions.js @@ -109,13 +109,15 @@ export function downgradeNativePermissionsIfNeeded(permissions: GroupsPermission } } -// $FlowFixMe -const metadataTableToTableEntityId = (table: Table): TableEntityId => ({ databaseId: table.db_id, schemaName: table.schema, tableId: table.id }); +const metadataTableToTableEntityId = (table: Table): TableEntityId => ({ databaseId: table.db_id, schemaName: table.schema || "", tableId: table.id }); + +// TODO Atte Keinänen 6/24/17 See if this method could be simplified const entityIdToMetadataTableFields = (entityId: EntityId) => ({ ...(entityId.databaseId ? {db_id: entityId.databaseId} : {}), - ...(entityId.schemaName ? {schema: entityId.schemaName} : {}), - ...(entityId.tableId ? {tableId: entityId.tableId} : {}) -}) + // $FlowFixMe Because schema name can be an empty string, which means an empty schema, this check becomes a little nasty + ...(entityId.schemaName !== undefined ? {schema: entityId.schemaName !== "" ? entityId.schemaName : null} : {}), + ...(entityId.tableId ? {id: entityId.tableId} : {}) +}); function inferEntityPermissionValueFromChildTables(permissions: GroupsPermissions, groupId: GroupId, entityId: DatabaseEntityId|SchemaEntityId, metadata: Metadata) { const { databaseId } = entityId; @@ -177,7 +179,7 @@ export function updateFieldsPermission(permissions: GroupsPermissions, groupId: export function updateTablesPermission(permissions: GroupsPermissions, groupId: GroupId, { databaseId, schemaName }: SchemaEntityId, value: string, metadata: Metadata): GroupsPermissions { const database = metadata && metadata.database(databaseId); - const tableIds: ?number[] = database && database.tables().filter(t => t.schema === schemaName).map(t => t.id); + const tableIds: ?number[] = database && database.tables().filter(t => (t.schema || "") === schemaName).map(t => t.id); permissions = updateSchemasPermission(permissions, groupId, { databaseId }, "controlled", metadata); permissions = updatePermission(permissions, groupId, [databaseId, "schemas", schemaName], value, tableIds); @@ -186,11 +188,12 @@ export function updateTablesPermission(permissions: GroupsPermissions, groupId: } export function updateSchemasPermission(permissions: GroupsPermissions, groupId: GroupId, { databaseId }: DatabaseEntityId, value: string, metadata: Metadata): GroupsPermissions { - let database = metadata.database(databaseId); - let schemaNames = database && database.schemaNames(); + const database = metadata.database(databaseId); + const schemaNames = database && database.schemaNames(); + const schemaNamesOrNoSchema = (schemaNames && schemaNames.length > 0) ? schemaNames : [""]; permissions = downgradeNativePermissionsIfNeeded(permissions, groupId, { databaseId }, value, metadata); - return updatePermission(permissions, groupId, [databaseId, "schemas"], value, schemaNames); + return updatePermission(permissions, groupId, [databaseId, "schemas"], value, schemaNamesOrNoSchema); } export function updateNativePermission(permissions: GroupsPermissions, groupId: GroupId, { databaseId }: DatabaseEntityId, value: string, metadata: Metadata): GroupsPermissions { diff --git a/frontend/src/metabase/lib/redux.js b/frontend/src/metabase/lib/redux.js index c1259080e302c6e871fc9b1eedef5f3d89e4b62f..21d8e93d7e385fe9645d9d381fd616937b2959b0 100644 --- a/frontend/src/metabase/lib/redux.js +++ b/frontend/src/metabase/lib/redux.js @@ -110,6 +110,36 @@ export const updateData = async ({ } } +// helper for working with normalizr +// merge each entity from newEntities with existing entity, if any +// this ensures partial entities don't overwrite existing entities with more properties +export function mergeEntities(entities, newEntities) { + entities = { ...entities }; + for (const id in newEntities) { + if (id in entities) { + entities[id] = { ...entities[id], ...newEntities[id] }; + } else { + entities[id] = newEntities[id]; + } + } + return entities; +} + +// helper for working with normalizr +// reducer that merges payload.entities +export function handleEntities(actionPattern, entityType, reducer) { + return (state, action) => { + if (state === undefined) { + state = {}; + } + let entities = getIn(action, ["payload", "entities", entityType]); + if (actionPattern.test(action.type) && entities) { + state = mergeEntities(state, entities); + } + return reducer(state, action); + } +} + // for filtering non-DOM props from redux-form field objects // https://github.com/erikras/redux-form/issues/1441 export const formDomOnlyProps = ({ diff --git a/frontend/src/metabase/meta/Card.js b/frontend/src/metabase/meta/Card.js index ca2bc5d82cb5a536716ab632b543452689bbdb03..6db71543c4a7ca406d39036225f722aa2c660dde 100644 --- a/frontend/src/metabase/meta/Card.js +++ b/frontend/src/metabase/meta/Card.js @@ -131,7 +131,11 @@ export function applyParameters( continue; } - const mapping = _.findWhere(parameterMappings, { card_id: card.id, parameter_id: parameter.id }); + const mapping = _.findWhere(parameterMappings, { + // $FlowFixMe original_card_id not included in the flow type of card + card_id: card.id || card.original_card_id, + parameter_id: parameter.id + }); if (mapping) { // mapped target, e.x. on a dashboard datasetQuery.parameters.push({ @@ -158,7 +162,8 @@ export function questionUrlWithParameters( metadata: Metadata, parameters: Parameter[], parameterValues: ParameterValues = {}, - parameterMappings: ParameterMapping[] = [] + parameterMappings: ParameterMapping[] = [], + cardIsDirty: boolean = true ): DatasetQuery { if (!card.dataset_query) { return Urls.question(card.id); @@ -174,6 +179,11 @@ export function questionUrlWithParameters( parameterMappings ); + // If we have a clean question without parameters applied, don't add the dataset query hash + if (!cardIsDirty && datasetQuery.parameters && datasetQuery.parameters.length === 0) { + return Urls.question(card.id); + } + const query = {}; for (const datasetParameter of datasetQuery.parameters || []) { const cardParameter = _.find(cardParameters, p => diff --git a/frontend/src/metabase/meta/Card.spec.js b/frontend/src/metabase/meta/Card.spec.js index 8830d733ab259bb4bdc9441ec8ee4124670040ed..f94eef948e3822f26ec664669b05071d275f8e4f 100644 --- a/frontend/src/metabase/meta/Card.spec.js +++ b/frontend/src/metabase/meta/Card.spec.js @@ -135,6 +135,26 @@ describe("metabase/meta/Card", () => { ) }); }); + it("should return question URL even if only original_card_id is present", () => { + const cardWithOnlyOriginalCardId = { ...card, id: undefined, original_card_id: card.id }; + + const url = Card.questionUrlWithParameters( + cardWithOnlyOriginalCardId, + metadata, + parameters, + { "1": "bar" }, + parameterMappings + ); + expect(parseUrl(url)).toEqual({ + pathname: "/question", + query: {}, + card: assocIn( + cardWithOnlyOriginalCardId, + ["dataset_query", "query", "filter"], + ["AND", ["=", ["field-id", 1], "bar"]] + ) + }); + }); it("should return question URL with number MBQL filter added", () => { const url = Card.questionUrlWithParameters( card, diff --git a/frontend/src/metabase/meta/types/Visualization.js b/frontend/src/metabase/meta/types/Visualization.js index 75b6f11e09ca8b7fd40c614047f02da0108b8585..2486a723541fd9063b3446fee0a099bcb7b8c70e 100644 --- a/frontend/src/metabase/meta/types/Visualization.js +++ b/frontend/src/metabase/meta/types/Visualization.js @@ -53,7 +53,7 @@ export type ClickActionProps = { } export type ClickActionPopoverProps = { - onChangeCardAndRun: (card: ?Card) => void, + onChangeCardAndRun: (Object) => void, onClose: () => void, } @@ -81,7 +81,7 @@ export type VisualizationProps = { onHoverChange: (?HoverObject) => void, onVisualizationClick: (?ClickObject) => void, visualizationIsClickable: (?ClickObject) => boolean, - onChangeCardAndRun: (card: Card) => void, + onChangeCardAndRun: (Object) => void, onUpdateVisualizationSettings: ({ [key: string]: any }) => void } diff --git a/frontend/src/metabase/qb/components/actions/PivotByAction.jsx b/frontend/src/metabase/qb/components/actions/PivotByAction.jsx index a26f75a88c74bcb73115d693338025dbe63f935f..c52cddaddc5e101b9b02e9c1859d496d4812c5e7 100644 --- a/frontend/src/metabase/qb/components/actions/PivotByAction.jsx +++ b/frontend/src/metabase/qb/components/actions/PivotByAction.jsx @@ -88,9 +88,14 @@ export default (name: string, icon: string, fieldFilter: FieldFilter) => fieldOptions={fieldOptions} customFieldOptions={customFieldOptions} onCommitBreakout={breakout => { - onChangeCardAndRun( - pivot(card, breakout, tableMetadata, dimensions) - ); + onChangeCardAndRun({ + nextCard: pivot( + card, + breakout, + tableMetadata, + dimensions + ) + }); }} onClose={onClose} /> diff --git a/frontend/src/metabase/qb/components/actions/SummarizeBySegmentMetricAction.jsx b/frontend/src/metabase/qb/components/actions/SummarizeBySegmentMetricAction.jsx index 851400885e1aa61b750b3a204911d6c1c8afa231..94242389f2afeee09121e3b649f4823841bf8c33 100644 --- a/frontend/src/metabase/qb/components/actions/SummarizeBySegmentMetricAction.jsx +++ b/frontend/src/metabase/qb/components/actions/SummarizeBySegmentMetricAction.jsx @@ -34,9 +34,13 @@ export default ({ card, tableMetadata }: ClickActionProps): ClickAction[] => { customFields={Query.getExpressions(query)} availableAggregations={tableMetadata.aggregation_options} onCommitAggregation={aggregation => { - onChangeCardAndRun( - summarize(card, aggregation, tableMetadata) - ); + onChangeCardAndRun({ + nextCard: summarize( + card, + aggregation, + tableMetadata + ) + }); onClose && onClose(); }} /> diff --git a/frontend/src/metabase/qb/lib/actions.js b/frontend/src/metabase/qb/lib/actions.js index 7ea9a5ca859ca05dc568eac3bf34cd1d01ae5c85..2eea6601f716b8dba58a420fd84e9e0efc0d7323 100644 --- a/frontend/src/metabase/qb/lib/actions.js +++ b/frontend/src/metabase/qb/lib/actions.js @@ -22,6 +22,7 @@ export const toUnderlyingData = (card: CardObject): ?CardObject => { const newCard = startNewCard("query"); newCard.dataset_query = card.dataset_query; newCard.display = "table"; + newCard.original_card_id = card.id; return newCard; }; diff --git a/frontend/src/metabase/query_builder/actions.js b/frontend/src/metabase/query_builder/actions.js index b131d3cee9958201dab34ca3abb3ea5b262fd7c1..b02e64cbb780a4dca19a4294a40c84ebe63f35d2 100644 --- a/frontend/src/metabase/query_builder/actions.js +++ b/frontend/src/metabase/query_builder/actions.js @@ -29,6 +29,7 @@ import { MetabaseApi, CardApi, UserApi } from "metabase/services"; import { parse as urlParse } from "url"; import querystring from "querystring"; +import {getCardAfterVisualizationClick} from "metabase/visualizations/lib/utils"; export const SET_CURRENT_STATE = "metabase/qb/SET_CURRENT_STATE"; const setCurrentState = createAction(SET_CURRENT_STATE); @@ -183,7 +184,7 @@ export const initializeQB = createThunkAction(INITIALIZE_QB, (location, params) MetabaseAnalytics.trackEvent("QueryBuilder", "Query Loaded", card.dataset_query.type); // if we have deserialized card from the url AND loaded a card by id then the user should be dropped into edit mode - uiControls.isEditing = !!options.edit + uiControls.isEditing = !!options.edit; // if this is the users first time loading a saved card on the QB then show them the newb modal if (params.cardId && currentUser.is_qbnewb) { @@ -191,9 +192,20 @@ export const initializeQB = createThunkAction(INITIALIZE_QB, (location, params) MetabaseAnalytics.trackEvent("QueryBuilder", "Show Newb Modal"); } + if (card.archived) { + // use the error handler in App.jsx for showing "This question has been archived" message + dispatch(setErrorPage({ + data: { + error_code: "archived" + }, + context: "query-builder" + })); + card = null; + } + preserveParameters = true; } catch(error) { - console.warn(error) + console.warn(error); card = null; dispatch(setErrorPage(error)); } @@ -480,9 +492,12 @@ export const reloadCard = createThunkAction(RELOAD_CARD, () => { }; }); -// setCardAndRun -// Used when navigating browser history, when drilling through in visualizations / action widget, -// and when having the entity details view open and clicking its cells +/** + * `setCardAndRun` is used when: + * - navigating browser history + * - clicking in the entity details view + * - `navigateToNewCardInsideQB` is being called (see below) + */ export const SET_CARD_AND_RUN = "metabase/qb/SET_CARD_AND_RUN"; export const setCardAndRun = createThunkAction(SET_CARD_AND_RUN, (nextCard, shouldUpdateUrl = true) => { return async (dispatch, getState) => { @@ -507,8 +522,31 @@ export const setCardAndRun = createThunkAction(SET_CARD_AND_RUN, (nextCard, shou }; }); +/** + * User-triggered events that are handled with this action: + * - clicking a legend: + * * series legend (multi-aggregation, multi-breakout, multiple questions) + * - clicking the visualization itself + * * drill-through (single series, multi-aggregation, multi-breakout, multiple questions) + * * (not in 0.24.2 yet: drag on line/area/bar visualization) + * - clicking an action widget action + * + * All these events can be applied either for an unsaved question or a saved question. + */ +export const NAVIGATE_TO_NEW_CARD = "metabase/qb/NAVIGATE_TO_NEW_CARD"; +export const navigateToNewCardInsideQB = createThunkAction(NAVIGATE_TO_NEW_CARD, ({ nextCard, previousCard }) => { + return async (dispatch, getState) => { + const nextCardIsClean = _.isEqual(previousCard.dataset_query, nextCard.dataset_query) && previousCard.display === nextCard.display; + + if (nextCardIsClean) { + // This is mainly a fallback for scenarios where a visualization legend is clicked inside QB + dispatch(setCardAndRun(await loadCard(nextCard.id))); + } else { + dispatch(setCardAndRun(getCardAfterVisualizationClick(nextCard, previousCard))); + } + } +}); -// setDatasetQuery export const SET_DATASET_QUERY = "metabase/qb/SET_DATASET_QUERY"; export const setDatasetQuery = createThunkAction(SET_DATASET_QUERY, (dataset_query, run = false) => { return (dispatch, getState) => { diff --git a/frontend/src/metabase/query_builder/components/ActionsWidget.jsx b/frontend/src/metabase/query_builder/components/ActionsWidget.jsx index fba9d3ffc8d7440a54c32047303bcacab1fbeaa0..6e57f4eda6a21852562218ec6616b79ceb6f6a0b 100644 --- a/frontend/src/metabase/query_builder/components/ActionsWidget.jsx +++ b/frontend/src/metabase/query_builder/components/ActionsWidget.jsx @@ -12,7 +12,7 @@ import MetabaseAnalytics from "metabase/lib/analytics"; import cx from "classnames"; import _ from "underscore"; -import type { Card, UnsavedCard } from "metabase/meta/types/Card"; +import type { Card, UnsavedCard} from "metabase/meta/types/Card"; import type { QueryMode, ClickAction } from "metabase/meta/types/Visualization"; import type { TableMetadata } from "metabase/meta/types/Metadata"; @@ -21,7 +21,7 @@ type Props = { mode: QueryMode, card: Card, tableMetadata: TableMetadata, - setCardAndRun: (card: Card) => void + navigateToNewCardInsideQB: any => void }; type State = { @@ -78,18 +78,9 @@ export default class ActionsWidget extends Component { }); }; - handleOnChangeCardAndRun(nextCard: UnsavedCard|Card) { - const { card } = this.props; - - // Include the original card id if present for showing the lineage next to title - const nextCardWithOriginalId = { - ...nextCard, - // $FlowFixMe - original_card_id: card.id || card.original_card_id - }; - if (nextCardWithOriginalId) { - this.props.setCardAndRun(nextCardWithOriginalId); - } + handleOnChangeCardAndRun = ({ nextCard }: { nextCard: Card|UnsavedCard}) => { + const { card: previousCard } = this.props; + this.props.navigateToNewCardInsideQB({ nextCard, previousCard }); } handleActionClick = (index: number) => { @@ -101,7 +92,7 @@ export default class ActionsWidget extends Component { const nextCard = action.card(); if (nextCard) { MetabaseAnalytics.trackEvent("Actions", "Executed Action", `${action.section||""}:${action.name||""}`); - this.handleOnChangeCardAndRun(nextCard); + this.handleOnChangeCardAndRun({ nextCard }); } this.close(); } @@ -177,12 +168,12 @@ export default class ActionsWidget extends Component { </div> </div> <PopoverComponent - onChangeCardAndRun={(card) => { - if (card) { + onChangeCardAndRun={({ nextCard }) => { + if (nextCard) { if (selectedAction) { MetabaseAnalytics.trackEvent("Actions", "Executed Action", `${selectedAction.section||""}:${selectedAction.name||""}`); } - this.handleOnChangeCardAndRun(card) + this.handleOnChangeCardAndRun({ nextCard }) } }} onClose={this.close} diff --git a/frontend/src/metabase/query_builder/components/VisualizationResult.jsx b/frontend/src/metabase/query_builder/components/VisualizationResult.jsx index d959a936de3aa3bd747248fb0e3213abc5c18bb3..def5c8c63df9aadc09776c1812e99a8390de311c 100644 --- a/frontend/src/metabase/query_builder/components/VisualizationResult.jsx +++ b/frontend/src/metabase/query_builder/components/VisualizationResult.jsx @@ -7,7 +7,7 @@ import VisualizationErrorMessage from './VisualizationErrorMessage'; import Visualization from "metabase/visualizations/components/Visualization.jsx"; import { datasetContainsNoResults } from "metabase/lib/dataset"; -const VisualizationResult = ({card, isObjectDetail, lastRunDatasetQuery, result, ...props}) => { +const VisualizationResult = ({card, isObjectDetail, lastRunDatasetQuery, navigateToNewCardInsideQB, result, ...props}) => { const noResults = datasetContainsNoResults(result.data); if (isObjectDetail) { @@ -34,7 +34,7 @@ const VisualizationResult = ({card, isObjectDetail, lastRunDatasetQuery, result, }; return <Visualization series={[{ card: vizCard, data: result.data }]} - onChangeCardAndRun={props.setCardAndRun} + onChangeCardAndRun={navigateToNewCardInsideQB} isEditing={true} // Table: {...props} @@ -43,11 +43,11 @@ const VisualizationResult = ({card, isObjectDetail, lastRunDatasetQuery, result, } VisualizationResult.propTypes = { - card: PropTypes.object.isRequired, - isObjectDetail: PropTypes.bool.isRequired, - lastRunDatasetQuery: PropTypes.object.isRequired, - result: PropTypes.object.isRequired, - setCardAndRun: PropTypes.func, + card: PropTypes.object.isRequired, + isObjectDetail: PropTypes.bool.isRequired, + lastRunDatasetQuery: PropTypes.object.isRequired, + result: PropTypes.object.isRequired, + navigateToNewCardInsideQB: PropTypes.func, } export default VisualizationResult; diff --git a/frontend/src/metabase/query_builder/components/filters/OperatorSelector.jsx b/frontend/src/metabase/query_builder/components/filters/OperatorSelector.jsx index 8c08585fde9c5a40c56a6ed37c8ae8dac1b1c942..de0242e100d42785acc3bcdd6190ef520769556c 100644 --- a/frontend/src/metabase/query_builder/components/filters/OperatorSelector.jsx +++ b/frontend/src/metabase/query_builder/components/filters/OperatorSelector.jsx @@ -1,13 +1,15 @@ /* @flow */ import React, { Component } from "react"; +import ReactDOM from "react-dom"; import PropTypes from "prop-types"; - -import Icon from "metabase/components/Icon.jsx"; - import cx from "classnames"; import _ from "underscore"; +import {forceRedraw} from "metabase/lib/dom"; + +import Icon from "metabase/components/Icon.jsx"; + import type { Operator, OperatorName } from "metabase/meta/types/Metadata" type Props = { @@ -39,6 +41,13 @@ export default class OperatorSelector extends Component { onOperatorChange: PropTypes.func.isRequired }; + expandOperators = () => { + this.setState({ expanded: true }, () => { + // HACK: Address Safari rendering bug which causes https://github.com/metabase/metabase/issues/5075 + forceRedraw(ReactDOM.findDOMNode(this)); + }); + }; + render() { let { operator, operators } = this.props; let { expanded } = this.state; @@ -65,7 +74,7 @@ export default class OperatorSelector extends Component { </button> )} { !expanded && expandedOperators.length > 0 ? - <div className="text-grey-3 cursor-pointer" onClick={() => this.setState({ expanded: true })}> + <div className="text-grey-3 cursor-pointer" onClick={this.expandOperators}> <Icon className="px1" name="chevrondown" size={14} /> More Options </div> diff --git a/frontend/src/metabase/query_builder/selectors.js b/frontend/src/metabase/query_builder/selectors.js index fe202d7e3485d164e40b944d5904cca9355004c0..af49d2d8b0db302be3df69799a3018aaef8d2fb2 100644 --- a/frontend/src/metabase/query_builder/selectors.js +++ b/frontend/src/metabase/query_builder/selectors.js @@ -156,13 +156,15 @@ export const getIsRunnable = createSelector( const getLastRunDatasetQuery = createSelector([getLastRunCard], (card) => card && card.dataset_query); const getNextRunDatasetQuery = createSelector([getCard], (card) => card && card.dataset_query); -const getLastRunParameters = createSelector([getQueryResult], (queryResult) => queryResult && queryResult.json_query.parameters || []) -const getLastRunParameterValues = createSelector([getLastRunParameters], (parameters) => parameters.map(parameter => parameter.value)) -const getNextRunParameterValues = createSelector([getParameters], (parameters) => parameters.map(parameter => parameter.value)) +const getLastRunParameters = createSelector([getQueryResult], (queryResult) => queryResult && queryResult.json_query && queryResult.json_query.parameters || []); +const getLastRunParameterValues = createSelector([getLastRunParameters], (parameters) => parameters.map(parameter => parameter.value)); +const getNextRunParameterValues = createSelector([getParameters], (parameters) => + parameters.map(parameter => parameter.value).filter(p => p !== undefined) +); export const getIsResultDirty = createSelector( [getLastRunDatasetQuery, getNextRunDatasetQuery, getLastRunParameterValues, getNextRunParameterValues], (lastDatasetQuery, nextDatasetQuery, lastParameters, nextParameters) => { return !Utils.equals(lastDatasetQuery, nextDatasetQuery) || !Utils.equals(lastParameters, nextParameters); } -) +); diff --git a/frontend/src/metabase/questions/components/CollectionActions.jsx b/frontend/src/metabase/questions/components/CollectionActions.jsx index bbecfd2290d86eaa8822f00606d50474f39649b5..27ff146af85c162cd3507931510ff938b7872f27 100644 --- a/frontend/src/metabase/questions/components/CollectionActions.jsx +++ b/frontend/src/metabase/questions/components/CollectionActions.jsx @@ -1,11 +1,11 @@ import React from "react"; const CollectionActions = ({ children }) => - <div onClick={(e) => { e.stopPropagation(); e.preventDefault() }}> + <div className="flex align-center" onClick={(e) => { e.stopPropagation(); e.preventDefault() }}> {React.Children.map(children, (child, index) => - <span key={index} className="cursor-pointer text-brand-hover mx1"> + <div key={index} className="cursor-pointer text-brand-hover mx1"> {child} - </span> + </div> )} </div> diff --git a/frontend/src/metabase/questions/containers/ArchiveCollectionWidget.jsx b/frontend/src/metabase/questions/containers/ArchiveCollectionWidget.jsx index 3fc8b913ec6a795af113601f8e55110d7fb76786..c988729ce759c81ccecbdc23e97fc7ee32af44ca 100644 --- a/frontend/src/metabase/questions/containers/ArchiveCollectionWidget.jsx +++ b/frontend/src/metabase/questions/containers/ArchiveCollectionWidget.jsx @@ -43,7 +43,7 @@ export default class ArchiveCollectionWidget extends Component { ref="modal" triggerElement={ <Tooltip tooltip="Archive collection"> - <Icon name="archive" /> + <Icon size={18} name="archive" /> </Tooltip> } title="Archive this collection?" diff --git a/frontend/src/metabase/questions/containers/CollectionPage.jsx b/frontend/src/metabase/questions/containers/CollectionPage.jsx index 49822508801a6e045b1436d90cb7b8b7aa3623fd..a90a2e56b37da7546510f01931d46f172c5e020d 100644 --- a/frontend/src/metabase/questions/containers/CollectionPage.jsx +++ b/frontend/src/metabase/questions/containers/CollectionPage.jsx @@ -50,8 +50,8 @@ export default class CollectionPage extends Component { <div className="ml-auto"> <CollectionActions> { canEdit && <ArchiveCollectionWidget collectionId={this.props.collection.id} onArchived={this.props.goToQuestions}/> } - { canEdit && <Icon name="pencil" tooltip="Edit collection" onClick={() => this.props.editCollection(this.props.collection.id)} /> } - { canEdit && <Icon name="lock" tooltip="Set permissions" onClick={() => this.props.editPermissions(this.props.collection.id)} /> } + { canEdit && <Icon size={18} name="pencil" tooltip="Edit collection" onClick={() => this.props.editCollection(this.props.collection.id)} /> } + { canEdit && <Icon size={18} name="lock" tooltip="Set permissions" onClick={() => this.props.editPermissions(this.props.collection.id)} /> } </CollectionActions> </div> </div> diff --git a/frontend/src/metabase/questions/containers/EntityList.jsx b/frontend/src/metabase/questions/containers/EntityList.jsx index 3e42b9c9db4ae2d586fbbdab95df3461f41b7717..3ee2b5a9bfc341f48f341ce0943285d530894b83 100644 --- a/frontend/src/metabase/questions/containers/EntityList.jsx +++ b/frontend/src/metabase/questions/containers/EntityList.jsx @@ -175,11 +175,13 @@ export default class EntityList extends Component { const section = this.getSection(); + const hasEntitiesInPlainState = entityIds.length > 0 || section.section !== "all"; const showActionHeader = (editable && selectedCount > 0); const showSearchHeader = (hasEntitiesInPlainState && showSearchWidget); const showEntityFilterWidget = onChangeSection; + return ( <div style={style}> { (showActionHeader || showSearchHeader || showEntityFilterWidget) && diff --git a/frontend/src/metabase/questions/containers/QuestionIndex.jsx b/frontend/src/metabase/questions/containers/QuestionIndex.jsx index e0b95acbc50cd9ce174d5086c5c9b789af5a5d2f..4c07ec137db1a3d36187cbfe96177704b923fd8f 100644 --- a/frontend/src/metabase/questions/containers/QuestionIndex.jsx +++ b/frontend/src/metabase/questions/containers/QuestionIndex.jsx @@ -1,12 +1,12 @@ import React, { Component } from "react"; import { connect } from "react-redux"; import { Link } from "react-router"; -import Collapse from "react-collapse"; +import cx from "classnames"; import Icon from "metabase/components/Icon"; import Button from "metabase/components/Button"; -import DisclosureTriangle from "metabase/components/DisclosureTriangle"; import TitleAndDescription from "metabase/components/TitleAndDescription"; + import ExpandingSearchField from "../components/ExpandingSearchField"; import CollectionActions from "../components/CollectionActions"; @@ -16,114 +16,140 @@ import EntityList from "./EntityList"; import { search } from "../questions"; import { loadCollections } from "../collections"; -import { getAllCollections, getAllEntities } from "../selectors"; +import { getLoadingInitialEntities, getAllCollections, getAllEntities } from "../selectors"; import { getUserIsAdmin } from "metabase/selectors/user"; import { replace, push } from "react-router-redux"; +import EmptyState from "metabase/components/EmptyState"; +import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; + +export const CollectionEmptyState = () => + <div className="flex align-center p2 mt4 bordered border-med border-brand rounded bg-grey-0 text-brand"> + <Icon name="collection" size={32} className="mr2"/> + <div className="flex-full"> + <h3>Create collections for your saved questions</h3> + <div className="mt1"> + Collections help you organize your questions and allow you to decide who gets to see what. + {" "} + <a href="http://www.metabase.com/docs/latest/administration-guide/06-collections.html" target="_blank"> + Learn more + </a> + </div> + </div> + <Link to="/collections/create"> + <Button primary>Create a collection</Button> + </Link> + </div>; + +export const NoSavedQuestionsState = () => + <div className="flex-full flex align-center justify-center mb4"> + <EmptyState + message={<span>Explore your data, create charts or maps, and save what you find.</span>} + image="/app/img/questions_illustration" + action="Ask a question" + link="/question" + /> + </div>; + +export const QuestionIndexHeader = ({questions, collections, isAdmin, onSearch}) => { + // Some replication of logic for making writing tests easier + const hasCollections = collections && collections.length > 0; + const hasQuestionsWithoutCollection = questions && questions.length > 0; + + const showSearch = hasCollections || hasQuestionsWithoutCollection; + const showSetPermissionsLink = isAdmin && hasCollections; + + return (<div className="flex align-center pt4 pb2"> + <TitleAndDescription title={ hasCollections ? "Collections of Questions" : "Saved Questions" }/> + + <div className="flex align-center ml-auto"> + { showSearch && + <ExpandingSearchField className="mr2" onSearch={onSearch}/> + } + + <CollectionActions> + { showSetPermissionsLink && + <Link to="/collections/permissions"> + <Icon size={18} name="lock" tooltip="Set permissions for collections"/> + </Link> + } + <Link to="/questions/archive"> + <Icon size={20} name="viewArchive" tooltip="View the archive"/> + </Link> + </CollectionActions> + </div> + </div>); +}; const mapStateToProps = (state, props) => ({ + loading: getLoadingInitialEntities(state, props), questions: getAllEntities(state, props), collections: getAllCollections(state, props), - isAdmin: getUserIsAdmin(state, props), -}) + isAdmin: getUserIsAdmin(state, props) +}); const mapDispatchToProps = ({ search, loadCollections, replace, push, -}) - -@connect(mapStateToProps, mapDispatchToProps) -export default class QuestionIndex extends Component { - constructor (props) { - super(props); - this.state = { - questionsExpanded: true - } - } - componentWillMount () { +}); + +/* connect() is in the end of this file because of the plain QuestionIndex component is used in Jest tests */ +export class QuestionIndex extends Component { + componentWillMount() { this.props.loadCollections(); } render () { - const { questions, collections, replace, push, location, isAdmin } = this.props; - const { questionsExpanded } = this.state; + const { loading, questions, collections, replace, push, location, isAdmin } = this.props; + const hasCollections = collections && collections.length > 0; - const hasQuestions = questions && questions.length > 0; - const showCollections = isAdmin || hasCollections; - const showQuestions = hasQuestions || !showCollections || location.query.f != null; + const hasQuestionsWithoutCollection = questions && questions.length > 0; + + const showNoCollectionsState = !loading && isAdmin && !hasCollections; + const showNoSavedQuestionsState = !loading && !hasCollections && !hasQuestionsWithoutCollection; + + const hasEntityListSectionQuery = !!(location.query && location.query.f); + const showEntityList = hasQuestionsWithoutCollection || hasEntityListSectionQuery; + const showEverythingElseTitle = showEntityList && hasCollections; + return ( - <div className="relative mx4"> - <div className="flex align-center pt4 pb2"> - <TitleAndDescription title={ showCollections ? "Collections of Questions" : "Saved Questions" } /> - <div className="flex align-center ml-auto"> - <ExpandingSearchField className="mr2" onSearch={this.props.search} /> - - <CollectionActions> - { isAdmin && hasCollections && - <Link to="/collections/permissions"> - <Icon name="lock" tooltip="Set permissions for collections" /> - </Link> - } - <Link to="/questions/archive"> - <Icon name="viewArchive" tooltip="View the archive" /> - </Link> - </CollectionActions> - </div> - </div> - { showCollections && - <div> - { collections.length > 0 ? - <CollectionButtons collections={collections} isAdmin={isAdmin} push={push} /> - : - <CollectionEmptyState /> - } - </div> - } - {/* only show title if we're showing the questions AND collections, otherwise title goes in the main header */} - { showQuestions && showCollections && - <div - className="inline-block mt2 mb2 cursor-pointer text-brand-hover" - onClick={() => this.setState({ questionsExpanded: !questionsExpanded })} - > - <div className="flex align-center"> - <DisclosureTriangle open={questionsExpanded} /> - <h2>Everything Else</h2> - </div> - </div> - } - <Collapse isOpened={showQuestions && (questionsExpanded || !showCollections)} keepCollapsedContent={true}> + <div className={cx("relative px4", {"full-height flex flex-column bg-slate-extra-light": showNoSavedQuestionsState})}> + {/* Use loading wrapper only for displaying the loading indicator as EntityList component should always be in DOM */} + { loading && <LoadingAndErrorWrapper loading={true} noBackground /> } + + { showNoCollectionsState && <CollectionEmptyState /> } + + { !loading && <QuestionIndexHeader + questions={questions} + collections={collections} + isAdmin={isAdmin} + onSearch={this.props.search} + /> } + + { hasCollections && <CollectionButtons collections={collections} isAdmin={isAdmin} push={push} /> } + + { showNoSavedQuestionsState && <NoSavedQuestionsState /> } + + { showEverythingElseTitle && <h2 className="mt2 mb2">Everything Else</h2> } + + <div className={cx({ "hide": !showEntityList })}> + {/* EntityList loads `questions` according to the query specified in the url query string */} <EntityList entityType="cards" - entityQuery={{ f: "all", collection: "", ...location.query }} + entityQuery={{f: "all", collection: "", ...location.query}} // use replace when changing sections so back button still takes you back to collections page onChangeSection={(section) => replace({ ...location, - query: { ...location.query, f: section } + query: {...location.query, f: section} })} - defaultEmptyState="Questions that aren’t in a collection will be shown here" /> - </Collapse> + </div> </div> ) } } -const CollectionEmptyState = () => - <div className="flex align-center p2 bordered border-med border-brand rounded bg-grey-0 text-brand"> - <Icon name="collection" size={32} className="mr2"/> - <div className="flex-full"> - <h3>Create collections for your saved questions</h3> - <div className="mt1"> - Collections help you organize your questions and allow you to decide who gets to see what. - {" "} - <a href="http://www.metabase.com/docs/latest/administration-guide/06-collections.html" target="_blank"> - Learn more - </a> - </div> - </div> - <Link to="/collections/create"> - <Button primary>Create a collection</Button> - </Link> - </div> +export default connect(mapStateToProps, mapDispatchToProps)(QuestionIndex); + diff --git a/frontend/src/metabase/questions/containers/QuestionIndex.spec.js b/frontend/src/metabase/questions/containers/QuestionIndex.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..179d516e11d9cd9b813dc9ca5000998c056c1ffb --- /dev/null +++ b/frontend/src/metabase/questions/containers/QuestionIndex.spec.js @@ -0,0 +1,140 @@ +import React from 'react' +import {shallow, mount} from 'enzyme' + +import { + QuestionIndex, + CollectionEmptyState, + NoSavedQuestionsState, + QuestionIndexHeader +} from './QuestionIndex'; + +const someQuestions = [{}, {}, {}]; +const someCollections = [{}, {}]; + +const dummyFunction = () => {}; + +const getQuestionIndex = ({ questions, collections, isAdmin }) => + <QuestionIndex + questions={questions} + collections={collections} + isAdmin={isAdmin} + replace={dummyFunction} + push={dummyFunction} + location={dummyFunction} + search={dummyFunction} + loadCollections={dummyFunction} + />; + +describe('QuestionIndex', () => { + describe('info box about collections', () => { + it("should be shown to admins if no collections", () => { + const component = shallow(getQuestionIndex({ + questions: null, + collections: null, + isAdmin: true + })); + + expect(component.find(CollectionEmptyState).length).toEqual(1); + + const component2 = shallow(getQuestionIndex({ + questions: someQuestions, + collections: null, + isAdmin: true + })); + + expect(component2.find(CollectionEmptyState).length).toEqual(1); + }); + + it("should not be shown to admins if there are collections", () => { + const component = shallow(getQuestionIndex({ + questions: null, + collections: someCollections, + isAdmin: false + })); + + expect(component.find(CollectionEmptyState).length).toEqual(0); + }); + + it("should not be shown to non-admins", () => { + const component = shallow(getQuestionIndex({ + questions: null, + collections: null, + isAdmin: false + })); + + expect(component.find(CollectionEmptyState).length).toEqual(0); + }) + }); + + describe('no saved questions state', () => { + const eitherAdminOrNot = [true, false]; + + it("should be shown if no collections or questions", () => { + eitherAdminOrNot.forEach(isAdmin => { + const component = shallow(getQuestionIndex({ + questions: null, + collections: null, + isAdmin + })); + + expect(component.find(NoSavedQuestionsState).length).toEqual(1); + }) + }); + + it("should not be shown otherwise", () => { + eitherAdminOrNot.forEach(isAdmin => { + const component = shallow(getQuestionIndex({ + questions: someQuestions, + collections: null, + isAdmin + })); + + expect(component.find(NoSavedQuestionsState).length).toEqual(0); + + const component2 = shallow(getQuestionIndex({ + questions: null, + collections: someCollections, + isAdmin + })); + + expect(component2.find(NoSavedQuestionsState).length).toEqual(0); + }); + }) + }) + + describe('collection actions', () => { + it("should let admins change permissions if collections exist", () => { + const component = mount( + <QuestionIndexHeader + collections={someCollections} + isAdmin={true} + /> + ); + + // Why `find` does not work for matching React props: https://github.com/airbnb/enzyme/issues/582 + expect(component.findWhere((node) => node.prop('to') === "/collections/permissions" ).length).toEqual(1); + }); + + it("should not let admins change permissions if no collections", () => { + const component = mount( + <QuestionIndexHeader + collections={null} + isAdmin={true} + /> + ); + + expect(component.findWhere((node) => node.prop('to') === "/collections/permissions" ).length).toEqual(0); + }); + + it("should not let non-admins change permissions", () => { + const component = mount( + <QuestionIndexHeader + collections={someCollections} + isAdmin={false} + /> + ); + + expect(component.findWhere((node) => node.prop('to') === "/collections/permissions" ).length).toEqual(0); + }); + }) +}); \ No newline at end of file diff --git a/frontend/src/metabase/questions/labels.js b/frontend/src/metabase/questions/labels.js index 36a1aaa0f50a9cffde368d4768fe5239445707fb..776451a0018c00b39f6ac7b1ee3f3d0272692ed1 100644 --- a/frontend/src/metabase/questions/labels.js +++ b/frontend/src/metabase/questions/labels.js @@ -1,5 +1,5 @@ -import { createAction, createThunkAction } from "metabase/lib/redux"; +import {createAction, createThunkAction, mergeEntities} from "metabase/lib/redux"; import { reset } from 'redux-form'; import { normalize, schema } from "normalizr"; @@ -8,7 +8,6 @@ import MetabaseAnalytics from "metabase/lib/analytics"; const label = new schema.Entity('labels'); import { LabelApi } from "metabase/services"; -import { assoc, merge } from "icepick"; import _ from "underscore"; const LOAD_LABELS = 'metabase/labels/LOAD_LABELS'; @@ -76,19 +75,17 @@ const initialState = { }; export default function(state = initialState, { type, payload, error }) { - if (payload && payload.entities) { - state = assoc(state, "entities", merge(state.entities, payload.entities)); - } - if (payload && payload.message) { - state = assoc(state, "message", payload.message); - } - switch (type) { case LOAD_LABELS: if (error) { return { ...state, error: payload }; } else { - return { ...state, labelIds: payload.result, error: null }; + return { + ...state, + entities: mergeEntities(state.entities, payload.entities), + labelIds: payload.result, + error: null + }; } case SAVE_LABEL: if (error || payload == null) { diff --git a/frontend/src/metabase/questions/questions.js b/frontend/src/metabase/questions/questions.js index 661fa2b8196d1f6a8df41814c78bc2cac2b0dd14..02d4924030df1b86d66269c3a158cad335a3a3b5 100644 --- a/frontend/src/metabase/questions/questions.js +++ b/frontend/src/metabase/questions/questions.js @@ -1,8 +1,8 @@ -import { createAction, createThunkAction, momentifyArraysTimestamps } from "metabase/lib/redux"; +import {createAction, createThunkAction, mergeEntities, momentifyArraysTimestamps} from "metabase/lib/redux"; import { normalize, schema } from "normalizr"; -import { getIn, assoc, assocIn, updateIn, merge, chain } from "icepick"; +import { getIn, assocIn, updateIn, chain } from "icepick"; import _ from "underscore"; import { inflect } from "metabase/lib/formatting"; @@ -217,6 +217,7 @@ const initialState = { lastEntityType: null, lastEntityQuery: null, entities: {}, + loadingInitialEntities: true, itemsBySection: {}, searchText: "", selectedIds: {}, @@ -224,10 +225,6 @@ const initialState = { }; export default function(state = initialState, { type, payload, error }) { - if (payload && payload.entities) { - state = assoc(state, "entities", merge(state.entities, payload.entities)); - } - switch (type) { case SET_SEARCH_TEXT: return { ...state, searchText: payload }; @@ -240,6 +237,8 @@ export default function(state = initialState, { type, payload, error }) { return assocIn(state, ["itemsBySection", payload.entityType, payload.entityQuery, "error"], payload.error); } else { return (chain(state) + .assoc("loadingInitialEntities", false) + .assoc("entities", mergeEntities(state.entities, payload.entities)) .assoc("lastEntityType", payload.entityType) .assoc("lastEntityQuery", payload.entityQuery) .assoc("selectedIds", {}) diff --git a/frontend/src/metabase/questions/selectors.js b/frontend/src/metabase/questions/selectors.js index ced278541ee04724e49ee195254a10379d068584..3c9aae81994841fce3c999aac3cfd150c6f4c23a 100644 --- a/frontend/src/metabase/questions/selectors.js +++ b/frontend/src/metabase/questions/selectors.js @@ -7,17 +7,18 @@ import _ from "underscore"; import visualizations from "metabase/visualizations"; import {caseInsensitiveSearch} from "metabase/lib/string" -export const getEntityType = (state, props) => props && props.entityType ? props.entityType : state.questions.lastEntityType; -export const getEntityQuery = (state, props) => props && props.entityQuery ? JSON.stringify(props.entityQuery) : state.questions.lastEntityQuery; +export const getEntityType = (state, props) => props && props.entityType ? props.entityType : state.questions.lastEntityType; +export const getEntityQuery = (state, props) => props && props.entityQuery ? JSON.stringify(props.entityQuery) : state.questions.lastEntityQuery; -export const getSection = (state, props) => props.entityQuery && JSON.stringify(props.entityQuery); -export const getEntities = (state, props) => state.questions.entities -export const getItemsBySection = (state, props) => state.questions.itemsBySection +export const getSection = (state, props) => props.entityQuery && JSON.stringify(props.entityQuery); +export const getLoadingInitialEntities = (state, props) => state.questions.loadingInitialEntities +export const getEntities = (state, props) => state.questions.entities +export const getItemsBySection = (state, props) => state.questions.itemsBySection -export const getSearchText = (state, props) => state.questions.searchText; -export const getSelectedIds = (state, props) => state.questions.selectedIds; +export const getSearchText = (state, props) => state.questions.searchText; +export const getSelectedIds = (state, props) => state.questions.selectedIds; -export const getAllCollections = (state, props) => state.collections.collections; +export const getAllCollections = (state, props) => state.collections.collections; export const getWritableCollections = createSelector( [getAllCollections], diff --git a/frontend/src/metabase/redux/metadata.js b/frontend/src/metabase/redux/metadata.js index 6530f3d7e43c379d0545461b1e631363fc52d42d..0e7de82de78ac3e077062285fda5af476d02e08a 100644 --- a/frontend/src/metabase/redux/metadata.js +++ b/frontend/src/metabase/redux/metadata.js @@ -6,6 +6,7 @@ import { resourceListToMap, fetchData, updateData, + handleEntities } from "metabase/lib/redux"; import { normalize } from "normalizr"; @@ -427,34 +428,6 @@ const revisions = handleActions({ [FETCH_REVISIONS]: { next: (state, { payload }) => payload } }, {}); -// merge each entity from newEntities with existing entity, if any -// this ensures partial entities don't overwrite existing entities with more properties -function mergeEntities(entities, newEntities) { - entities = { ...entities }; - for (const id in newEntities) { - if (id in entities) { - entities[id] = { ...entities[id], ...newEntities[id] }; - } else { - entities[id] = newEntities[id]; - } - } - return entities; -} - -// reducer that merges payload.entities -function handleEntities(actionPattern, entityType, reducer) { - return (state, action) => { - if (state === undefined) { - state = {}; - } - let entities = getIn(action, ["payload", "entities", entityType]) - if (actionPattern.test(action.type) && entities) { - state = mergeEntities(state, entities); - } - return reducer(state, action); - } -} - export default combineReducers({ metrics: handleEntities(/^metabase\/metadata\//, "metrics", metrics), segments: handleEntities(/^metabase\/metadata\//, "segments", segments), diff --git a/frontend/src/metabase/visualizations/components/ChartClickActions.jsx b/frontend/src/metabase/visualizations/components/ChartClickActions.jsx index 73b71b471e66cbe37ea7b2319249b5c2835ea75d..d5d5ed9808703cf1ebead874abb0c88fb129466b 100644 --- a/frontend/src/metabase/visualizations/components/ChartClickActions.jsx +++ b/frontend/src/metabase/visualizations/components/ChartClickActions.jsx @@ -9,7 +9,6 @@ import Popover from "metabase/components/Popover"; import MetabaseAnalytics from "metabase/lib/analytics"; import type { ClickObject, ClickAction } from "metabase/meta/types/Visualization"; -import type { Card } from "metabase/meta/types/Card"; import _ from "underscore"; @@ -54,7 +53,7 @@ Object.values(SECTIONS).map((section, index) => { type Props = { clicked: ?ClickObject, clickActions: ?ClickAction[], - onChangeCardAndRun: (card: ?Card) => void, + onChangeCardAndRun: (Object) => void, onClose: () => void }; @@ -80,12 +79,12 @@ export default class ChartClickActions extends Component { if (action.popover) { this.setState({ popoverAction: action }); } else if (action.card) { - const card = action.card(); + const nextCard = action.card(); MetabaseAnalytics.trackEvent("Actions", "Executed Click Action", `${action.section||""}:${action.name||""}`); - onChangeCardAndRun(card); + onChangeCardAndRun({ nextCard }); this.close(); } - } + }; render() { const { clicked, clickActions, onChangeCardAndRun } = this.props; @@ -100,11 +99,11 @@ export default class ChartClickActions extends Component { const PopoverContent = popoverAction.popover; popover = ( <PopoverContent - onChangeCardAndRun={(card) => { + onChangeCardAndRun={({ nextCard }) => { if (popoverAction) { MetabaseAnalytics.trackEvent("Action", "Executed Click Action", `${popoverAction.section||""}:${popoverAction.name||""}`); } - onChangeCardAndRun(card); + onChangeCardAndRun({ nextCard }); }} onClose={() => { MetabaseAnalytics.trackEvent("Action", "Dismissed Click Action Menu"); diff --git a/frontend/src/metabase/visualizations/components/LegendHeader.jsx b/frontend/src/metabase/visualizations/components/LegendHeader.jsx index bec9352dd3b06ea6fec5dbb6a499bbfbc9e63cc2..bd7aa4221770d9a310cb5375dfb44daec8fbdaff 100644 --- a/frontend/src/metabase/visualizations/components/LegendHeader.jsx +++ b/frontend/src/metabase/visualizations/components/LegendHeader.jsx @@ -71,7 +71,7 @@ export default class LegendHeader extends Component { onClick={s.clicked && visualizationIsClickable(s.clicked) ? ((e) => onVisualizationClick({ ...s.clicked, element: e.currentTarget })) : onChangeCardAndRun ? - ((e) => onChangeCardAndRun(s.card)) + (() => onChangeCardAndRun({ nextCard: s.card, seriesIndex: index })) : null } />, onRemoveSeries && index > 0 && diff --git a/frontend/src/metabase/visualizations/components/LineAreaBarChart.jsx b/frontend/src/metabase/visualizations/components/LineAreaBarChart.jsx index 759c375e64b3773fc659569ce4bbd3bba45646ea..a669b82448b11c1890a1961734a54d2f52dedf8d 100644 --- a/frontend/src/metabase/visualizations/components/LineAreaBarChart.jsx +++ b/frontend/src/metabase/visualizations/components/LineAreaBarChart.jsx @@ -191,11 +191,15 @@ export default class LineAreaBarChart extends Component { // $FlowFixMe let originalSeries = series._raw || series; let cardIds = _.uniq(originalSeries.map(s => s.card.id)) + const isComposedOfMultipleQuestions = cardIds.length > 1; if (showTitle && settings["card.title"]) { titleHeaderSeries = [{ card: { name: settings["card.title"], - id: cardIds.length === 1 ? cardIds[0] : null + ...(isComposedOfMultipleQuestions ? {} : { + id: cardIds[0], + dataset_query: originalSeries[0].card.dataset_query + }), }}]; } @@ -211,7 +215,9 @@ export default class LineAreaBarChart extends Component { series={titleHeaderSeries} description={settings["card.description"]} actionButtons={actionButtons} - onChangeCardAndRun={onChangeCardAndRun} + // If a dashboard card is composed of multiple questions, its custom card title + // shouldn't act as a link as it's ambiguous that which question it should open + onChangeCardAndRun={ isComposedOfMultipleQuestions ? null : onChangeCardAndRun } /> : null } { multiseriesHeaderSeries || (!titleHeaderSeries && actionButtons) ? // always show action buttons if we have them diff --git a/frontend/src/metabase/visualizations/components/Visualization.jsx b/frontend/src/metabase/visualizations/components/Visualization.jsx index d476580e73e54b212b40dffc89878b989dc7f095..141f8c8a2480954829a6dedb9252be1a450f301f 100644 --- a/frontend/src/metabase/visualizations/components/Visualization.jsx +++ b/frontend/src/metabase/visualizations/components/Visualization.jsx @@ -31,7 +31,7 @@ import cx from "classnames"; export const ERROR_MESSAGE_GENERIC = "There was a problem displaying this chart."; export const ERROR_MESSAGE_PERMISSION = "Sorry, you don't have permission to see this card." -import type { UnsavedCard, VisualizationSettings} from "metabase/meta/types/Card"; +import type { VisualizationSettings} from "metabase/meta/types/Card"; import type { HoverObject, ClickObject, Series } from "metabase/meta/types/Visualization"; import type { Metadata } from "metabase/meta/types/Metadata"; @@ -63,7 +63,7 @@ type Props = { // for click actions metadata: Metadata, - onChangeCardAndRun: (card: UnsavedCard) => void, + onChangeCardAndRun: any => void, // used for showing content in place of visualization, e.x. dashcard filter mapping replacementContent: Element<any>, @@ -221,27 +221,16 @@ export default class Visualization extends Component { setTimeout(() => { this.setState({ clicked }); }, 100); - } + }; - handleOnChangeCardAndRun = (card: UnsavedCard) => { + // Add the underlying card of current series to onChangeCardAndRun if available + handleOnChangeCardAndRun = ({ nextCard, seriesIndex }) => { const { series, clicked } = this.state; - const index = (clicked && clicked.seriesIndex) || 0; - const originalCard = series && series[index] && series[index].card; + const index = seriesIndex || (clicked && clicked.seriesIndex) || 0; + const previousCard = series && series[index] && series[index].card; - let cardId = card.id || card.original_card_id; - // if the supplied card doesn't have an id, get it from the original card - if (cardId == null && originalCard) { - // $FlowFixMe - cardId = originalCard.id || originalCard.original_card_id; - } - - this.props.onChangeCardAndRun({ - ...card, - id: cardId, - // $FlowFixMe - original_card_id: cardId - }); + this.props.onChangeCardAndRun({ nextCard, previousCard }); } onRender = ({ yAxisSplit, warnings = [] } = {}) => { diff --git a/frontend/src/metabase/visualizations/lib/utils.js b/frontend/src/metabase/visualizations/lib/utils.js index c776525135fcb9e7ac46f0d0841c7020bb82785c..5aa2aa826e3e930abb3b51c26fa759567b2889f3 100644 --- a/frontend/src/metabase/visualizations/lib/utils.js +++ b/frontend/src/metabase/visualizations/lib/utils.js @@ -249,3 +249,32 @@ function wrapMethod(object, name, method) { } } } +// TODO Atte Keinänen 5/30/17 Extract to metabase-lib card/question logic +export const cardHasBecomeDirty = (nextCard, previousCard) => + !_.isEqual(previousCard.dataset_query, nextCard.dataset_query) || previousCard.display !== nextCard.display; + +export function getCardAfterVisualizationClick(nextCard, previousCard) { + if (cardHasBecomeDirty(nextCard, previousCard)) { + const isMultiseriesQuestion = !nextCard.id; + const alreadyHadLineage = !!previousCard.original_card_id; + + return { + ...nextCard, + // Original card id is needed for showing the "started from" lineage in dirty cards. + original_card_id: alreadyHadLineage + // Just recycle the original card id of previous card if there was one + ? previousCard.original_card_id + // A multi-aggregation or multi-breakout series legend / drill-through action + // should always use the id of underlying/previous card + : (isMultiseriesQuestion ? previousCard.id : nextCard.id) + } + } else { + // Even though the card is currently clean, we might still apply dashboard parameters to it, + // so add the original_card_id to ensure a correct behavior in that context + return { + ...nextCard, + original_card_id: nextCard.id + }; + } +} + diff --git a/frontend/src/metabase/visualizations/lib/utils.spec.js b/frontend/src/metabase/visualizations/lib/utils.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..4123abdd97ab53dc8c74e3356cea72ec1c1d4959 --- /dev/null +++ b/frontend/src/metabase/visualizations/lib/utils.spec.js @@ -0,0 +1,171 @@ +import {cardHasBecomeDirty, getCardAfterVisualizationClick} from "./utils"; +import _ from "underscore"; + +// TODO Atte Keinänen 5/31/17 Rewrite tests using metabase-lib methods instead of a raw format + +const baseQuery = { + "database": 1, + "type": "query", + "query": { + "source_table": 2, + "aggregation": [ + [ + "count" + ] + ], + "breakout": [ + [ + "field-id", + 2 + ] + ] + } +}; +const derivedQuery = { + ...baseQuery, + "query": { + ...baseQuery.query, + "filter": [ + "time-interval", + [ + "field-id", + 1 + ], + -7, + "day" + ] + } +}; + +const breakoutMultiseriesQuery = { + ...baseQuery, + "query": { + ...baseQuery.query, + "breakout": [ + ...baseQuery.query.breakout, + [ + "fk->", + 1, + 10 + ] + ] + } +}; +const derivedBreakoutMultiseriesQuery = { + ...breakoutMultiseriesQuery, + "query": { + ...breakoutMultiseriesQuery.query, + "filter": [ + "time-interval", + [ + "field-id", + 1 + ], + -7, + "day" + ] + } +}; + +const savedCard = { + id: 3, + dataset_query: baseQuery, + display: "line" +}; +const clonedSavedCard = { + id: 3, + dataset_query: _.clone(baseQuery), + display: "line" +}; +const dirtyCardOnlyOriginalId = { + original_card_id: 7, + dataset_query: baseQuery, + display: "line" +}; + +const derivedCard = { + ...dirtyCardOnlyOriginalId, + dataset_query: derivedQuery +}; +const derivedCardModifiedId = { + ...savedCard, + dataset_query: derivedQuery +}; +const derivedDirtyCard = { + ...dirtyCardOnlyOriginalId, + dataset_query: derivedQuery +}; + +const derivedCardWithDifferentDisplay = { + ...savedCard, + display: "table" +}; + +const savedMultiseriesCard = { + ...savedCard, + dataset_query: breakoutMultiseriesQuery +}; +const derivedMultiseriesCard = { + // id is not present when drilling through series / multiseries + dataset_query: derivedBreakoutMultiseriesQuery, + display: savedCard.display +}; +const newCard = { + dataset_query: baseQuery, + display: "line" +}; +const modifiedNewCard = { + dataset_query: derivedQuery, + display: "line" +}; + +describe("metabase/visualization/lib/utils", () => { + describe("cardHasBecomeDirty", () => { + it("should consider cards with different display types dirty", () => { + // mostly for action widget actions that only change the display type + expect(cardHasBecomeDirty(derivedCardWithDifferentDisplay, savedCard)).toEqual(true); + }); + + it("should consider cards with different data data dirty", () => { + expect(cardHasBecomeDirty(derivedCard, savedCard)).toEqual(true); + }); + + it("should consider cards with same display type and data clean", () => { + // i.e. the card is practically the same as original card + expect(cardHasBecomeDirty(clonedSavedCard, savedCard)).toEqual(false); + }); + }); + + describe("getCardAfterVisualizationClick", () => { + it("should use the id of a previous card in case of a multi-breakout visualization", () => { + expect(getCardAfterVisualizationClick(derivedMultiseriesCard, savedMultiseriesCard)) + .toMatchObject({original_card_id: savedMultiseriesCard.id}) + }); + + // TODO: Atte Keinänen 5/31/17 This scenario is a little fuzzy at the moment as there have been + // some specific corner cases where the id in previousCard is wrong/missing + // We should validate that previousCard always has an id as it should + it("if the new card contains the id it's more reliable to use it for initializing lineage", () => { + expect(getCardAfterVisualizationClick(derivedCardModifiedId, savedCard)) + .toMatchObject({original_card_id: derivedCardModifiedId.id}) + }); + + it("should be able to continue the lineage even if the previous question was dirty already", () => { + expect(getCardAfterVisualizationClick(derivedDirtyCard, dirtyCardOnlyOriginalId)) + .toMatchObject({original_card_id: dirtyCardOnlyOriginalId.original_card_id}) + }); + + it("should just pass the new question if the previous question was new", () => { + expect(getCardAfterVisualizationClick(modifiedNewCard, newCard)) + .toMatchObject(modifiedNewCard) + }); + + it("should populate original_card_id even if the question isn't modified", () => { + // This is the hack to interoperate with questionUrlWithParameters when + // dashboard parameters are applied to a dashcards + expect(getCardAfterVisualizationClick(clonedSavedCard, savedCard)) + .toMatchObject({original_card_id: savedCard.id}) + }); + }) +}); + diff --git a/frontend/src/metabase/visualizations/visualizations/Scalar.jsx b/frontend/src/metabase/visualizations/visualizations/Scalar.jsx index bc0a4c79f2208ffc94905e6e44477cc3688c93e4..9608011fb23640ceefe4dbb0494bb6eaeaab9448 100644 --- a/frontend/src/metabase/visualizations/visualizations/Scalar.jsx +++ b/frontend/src/metabase/visualizations/visualizations/Scalar.jsx @@ -196,7 +196,7 @@ export default class Scalar extends Component { <div className={styles.Title + " flex align-center relative"}> <Ellipsified tooltip={card.name}> <span - onClick={onChangeCardAndRun && (() => onChangeCardAndRun(card))} + onClick={onChangeCardAndRun && (() => onChangeCardAndRun({ nextCard: card }))} className={cx("fullscreen-normal-text fullscreen-night-text", { "cursor-pointer": !!onChangeCardAndRun })} diff --git a/resources/frontend_client/app/img/questions_illustration.png b/resources/frontend_client/app/img/questions_illustration.png new file mode 100644 index 0000000000000000000000000000000000000000..22f75051960c64b575aa00cd794e199988914740 Binary files /dev/null and b/resources/frontend_client/app/img/questions_illustration.png differ diff --git a/resources/frontend_client/app/img/questions_illustration@2x.png b/resources/frontend_client/app/img/questions_illustration@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..22f75051960c64b575aa00cd794e199988914740 Binary files /dev/null and b/resources/frontend_client/app/img/questions_illustration@2x.png differ diff --git a/src/metabase/api/common.clj b/src/metabase/api/common.clj index 4b8514c881880868c17a05d7c98940a6083f1f18..098ba60567ddd645eebc063b5c9f4d3c97695e04 100644 --- a/src/metabase/api/common.clj +++ b/src/metabase/api/common.clj @@ -40,6 +40,8 @@ "Assertion mechanism for use inside API functions. Checks that TEST is true, or throws an `ExceptionInfo` with STATUS-CODE and MESSAGE. + MESSAGE can be either a plain string error message, or a map including the key `:message` and any additional details, such as an `:error_code`. + This exception is automatically caught in the body of `defendpoint` functions, and the appropriate HTTP response is generated. `check` can be called with the form @@ -59,7 +61,9 @@ [code-or-code-message-pair rest-args] [[code-or-code-message-pair (first rest-args)] (rest rest-args)])] (when-not tst - (throw (ex-info message {:status-code code}))) + (throw (if (map? message) + (ex-info (:message message) (assoc message :status-code code)) + (ex-info message {:status-code code})))) (if (empty? rest-args) tst (recur (first rest-args) (second rest-args) (drop 2 rest-args)))))) @@ -313,8 +317,9 @@ [400 "Embedding is not enabled."])) (defn check-not-archived - "Check that the OBJECT is not `:archived`, or throw a `404`. Returns OBJECT as-is if check passes." + "Check that the OBJECT exists and is not `:archived`, or throw a `404`. Returns OBJECT as-is if check passes." [object] (u/prog1 object + (check-404 object) (check (not (:archived object)) - [404 "The object has been archived."]))) + [404 {:message "The object has been archived.", :error_code "archived"}]))) diff --git a/test/metabase/api/card_test.clj b/test/metabase/api/card_test.clj index ce3aea62759dca69bcff184f877ce5c2723470ac..a902540a5dac7515be02d5ea236d7503cab49dd8 100644 --- a/test/metabase/api/card_test.clj +++ b/test/metabase/api/card_test.clj @@ -680,7 +680,7 @@ ;; Test that we *cannot* share a Card if the Card has been archived (expect - "The object has been archived." + {:message "The object has been archived.", :error_code "archived"} (tu/with-temporary-setting-values [enable-public-sharing true] (tt/with-temp Card [card {:archived true}] ((user->client :crowberto) :post 404 (format "card/%d/public_link" (u/get-id card))))))