diff --git a/frontend/src/metabase-lib/lib/Question.js b/frontend/src/metabase-lib/lib/Question.js index 14bf1ae6848574352f228445e5fab9fbaae80295..1f4b0934b312b88681a6192b75e31cafa4d8b18e 100644 --- a/frontend/src/metabase-lib/lib/Question.js +++ b/frontend/src/metabase-lib/lib/Question.js @@ -289,6 +289,14 @@ export default class Question { return this.setCard(assoc(this.card(), "display", display)); } + isDataset() { + return this._card && this._card.dataset; + } + + setDataset(dataset) { + return this.setCard(assoc(this.card(), "dataset", dataset)); + } + // locking the display prevents auto-selection lockDisplay(): Question { return this.setDisplayIsLocked(true); diff --git a/frontend/src/metabase/collections/containers/CollectionContent.jsx b/frontend/src/metabase/collections/containers/CollectionContent.jsx index 287974d97d45a5642c6bcbd813597647f6ce0556..99f20df734130ec913297671177b938eb60ced6a 100644 --- a/frontend/src/metabase/collections/containers/CollectionContent.jsx +++ b/frontend/src/metabase/collections/containers/CollectionContent.jsx @@ -24,7 +24,7 @@ import { useListSelect } from "metabase/hooks/use-list-select"; const PAGE_SIZE = 25; -const ALL_MODELS = ["dashboard", "card", "snippet", "pulse"]; +const ALL_MODELS = ["dashboard", "dataset", "card", "snippet", "pulse"]; const itemKeyFn = item => `${item.id}:${item.model}`; diff --git a/frontend/src/metabase/components/EntityItem.styled.jsx b/frontend/src/metabase/components/EntityItem.styled.jsx index 2af57409556b7e3c1f71a8b89f8bed5734b72433..b3949b9b1acc469fb29974fbaeb7dd8a43c1820f 100644 --- a/frontend/src/metabase/components/EntityItem.styled.jsx +++ b/frontend/src/metabase/components/EntityItem.styled.jsx @@ -1,7 +1,7 @@ import styled from "styled-components"; import { Flex } from "grid-styled"; -import { color, lighten } from "metabase/lib/colors"; +import { alpha, color, lighten } from "metabase/lib/colors"; import IconButtonWrapper from "metabase/components/IconButtonWrapper"; @@ -16,10 +16,16 @@ function getPinnedForeground(model) { } function getBackground(model) { + if (model === "dataset") { + return alpha(color("accent2"), 0.08); + } return model === "dashboard" ? color("brand") : color("brand-light"); } function getForeground(model) { + if (model === "dataset") { + return color("accent2"); + } return model === "dashboard" ? color("white") : color("brand"); } diff --git a/frontend/src/metabase/entities/questions.js b/frontend/src/metabase/entities/questions.js index 843b7eed9d75d29883bcfaace33f3f87d70f0214..3ac9f36ee838fac348cd99bfafaec34531ec0721 100644 --- a/frontend/src/metabase/entities/questions.js +++ b/frontend/src/metabase/entities/questions.js @@ -69,11 +69,7 @@ const Questions = createEntity({ getColor: () => color("text-medium"), getCollection: question => question && normalizedCollection(question.collection), - getIcon: question => ({ - name: - (require("metabase/visualizations").default.get(question.display) || {}) - .iconName || "beaker", - }), + getIcon, }, reducer: (state = {}, { type, payload, error }) => { @@ -89,6 +85,7 @@ const Questions = createEntity({ writableProperties: [ "name", "cache_ttl", + "dataset", "dataset_query", "display", "description", @@ -110,4 +107,16 @@ const Questions = createEntity({ forms, }); +function getIcon(question) { + if (question.dataset || question.model === "dataset") { + return { name: "dataset" }; + } + const visualization = require("metabase/visualizations").default.get( + question.display, + ); + return { + name: visualization?.iconName ?? "beaker", + }; +} + export default Questions; diff --git a/frontend/src/metabase/icon_paths.js b/frontend/src/metabase/icon_paths.js index 6d8e5285eec70b6c0b086c7cd0f60cdafb000168..c004862523e4ef41c4e192806a387ec9f988d513 100644 --- a/frontend/src/metabase/icon_paths.js +++ b/frontend/src/metabase/icon_paths.js @@ -137,6 +137,11 @@ export const ICON_PATHS = { "M28 10.105v18.728A3.166 3.166 0 0 1 24.834 32H6.166A3.163 3.163 0 0 1 3 28.844V3.156A3.163 3.163 0 0 1 6.16 0h13.553V10.105H28zm-.215-1.684h-6.4V.311l6.4 8.11zM17 13v2h2v-2h-2zm0 4v2h2v-2h-2zm4-4v2h2v-2h-2zM7 13v2h7v-2H7zm14 4v2h2v-2h-2zM7 17v2h7v-2H7zm10 4v2h2v-2h-2zm4 0v2h2v-2h-2zM7 21v2h7v-2H7z", database: "M0 9.32V4.054S1.584 0 15.657 0C29.731 0 31.89 3.669 31.89 4.054v5.24s-1.445 4.125-15.424 4.125S0 10.138 0 9.32zm.305 12.93s2.044 3.692 15.727 3.692 15.63-3.72 15.63-3.72.338.099.338.632v5S30.463 32 15.964 32C1.465 32 .041 27.817.041 27.817V22.9c0-.582.264-.65.264-.65zm0-9.368s2.044 3.692 15.727 3.692 15.63-3.72 15.63-3.72.338.099.338.632v5.001s-1.537 4.145-16.036 4.145C1.465 22.632.041 18.45.041 18.45v-4.918c0-.583.264-.65.264-.65z", + dataset: { + path: + "M17.0086 0.596191H4.38525C2.17612 0.596191 0.385254 2.38705 0.385254 4.59619V17.739C0.385254 19.9482 2.17612 21.739 4.38525 21.739H17.0086C19.2178 21.739 21.0086 19.9482 21.0086 17.739V4.59619C21.0086 2.38705 19.2178 0.596191 17.0086 0.596191ZM2.38525 4.59619C2.38525 3.49162 3.28068 2.59619 4.38525 2.59619H17.0086C18.1132 2.59619 19.0086 3.49162 19.0086 4.59619V17.739C19.0086 18.8436 18.1132 19.739 17.0086 19.739H4.38525C3.28069 19.739 2.38525 18.8436 2.38525 17.739V4.59619ZM7.92633 4.73904H4.46313V8.31047H7.92633V4.73904ZM12.4284 4.73904H8.96524V8.31047H12.4284V4.73904ZM13.4675 4.73904H16.9307V8.31047H13.4675V4.73904ZM7.92633 9.38189H4.46313V12.9533H7.92633V9.38189ZM8.96524 9.38189H12.4284V12.9533H8.96524V9.38189ZM16.9307 9.38189H13.4675V12.9533H16.9307V9.38189ZM4.46313 14.0247H7.92633V17.5962H4.46313V14.0247ZM12.4284 14.0247H8.96524V17.5962H12.4284V14.0247ZM13.4675 14.0247H16.9307V17.5962H13.4675V14.0247Z", + attrs: { viewBox: "0.385254 0.596191 20.623346 21.142809" }, + }, dash: "M0 13h32v6.61H0z", dashboard: "M32 28a4 4 0 0 1-4 4H4a4.002 4.002 0 0 1-3.874-3H0V4a4 4 0 0 1 4-4h25a3 3 0 0 1 3 3v25zm-4 0V8H4v20h24zM7.273 18.91h10.182v4.363H7.273v-4.364zm0-6.82h17.454v4.365H7.273V12.09zm13.09 6.82h4.364v4.363h-4.363v-4.364z", diff --git a/frontend/src/metabase/query_builder/actions.js b/frontend/src/metabase/query_builder/actions.js index 32c2474e798e1c01cb146ed398d79543b1334965..194b0469588baa2ef41c0c007df829456c8ef9ed 100644 --- a/frontend/src/metabase/query_builder/actions.js +++ b/frontend/src/metabase/query_builder/actions.js @@ -1406,3 +1406,17 @@ export const revertToRevision = createThunkAction( }; }, ); + +export const turnQuestionIntoDataset = () => async (dispatch, getState) => { + const question = getQuestion(getState()); + const dataset = question.setDataset(true); + await dispatch(apiUpdateQuestion(dataset)); + + // When a question is turned into a dataset, + // its visualization is changed to a table + // In order for that transition not to look like something just broke, + // we rerun the query + if (question.display() !== "table") { + dispatch(runQuestionQuery()); + } +}; diff --git a/frontend/src/metabase/query_builder/components/NewDatasetModal/NewDatasetModal.jsx b/frontend/src/metabase/query_builder/components/NewDatasetModal/NewDatasetModal.jsx new file mode 100644 index 0000000000000000000000000000000000000000..732e88eb8e33a47c97e113660032ac0bac999134 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/NewDatasetModal/NewDatasetModal.jsx @@ -0,0 +1,69 @@ +import React from "react"; +import { t, jt } from "ttag"; +import PropTypes from "prop-types"; +import { connect } from "react-redux"; + +import { turnQuestionIntoDataset } from "metabase/query_builder/actions"; + +import Button from "metabase/components/Button"; +import ModalContent from "metabase/components/ModalContent"; + +import { + DatasetFeatureOverview, + DatasetFeaturesContainer, +} from "./NewDatasetModal.styled"; + +const propTypes = { + turnQuestionIntoDataset: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, +}; + +const mapDispatchToProps = { + turnQuestionIntoDataset, +}; + +function NewDatasetModal({ turnQuestionIntoDataset, onClose }) { + const onConfirm = () => { + turnQuestionIntoDataset(); + onClose(); + }; + + return ( + <ModalContent + title={t`Create datasets to make it easier for everyone to explore.`} + footer={[ + <Button key="cancel" onClick={onClose}>{t`Cancel`}</Button>, + <Button + key="action" + primary + onClick={onConfirm} + >{t`Turn this into a dataset`}</Button>, + ]} + > + <DatasetFeaturesContainer> + <DatasetFeatureOverview icon="dataset"> + {jt`You’ll see them in the ${( + <strong>{t`Datasets section`}</strong> + )} when creating a new question.`} + </DatasetFeatureOverview> + <DatasetFeatureOverview icon="folder"> + {jt`Easily ${( + <strong>{t`open a dataset from its collection`}</strong> + )} or via Search to start a new question.`} + </DatasetFeatureOverview> + <DatasetFeatureOverview icon="label"> + {jt`You can ${( + <strong>{t`customize a dataset’s metadata`}</strong> + )} to make it even easier to explore the data.`} + </DatasetFeatureOverview> + </DatasetFeaturesContainer> + </ModalContent> + ); +} + +NewDatasetModal.propTypes = propTypes; + +export default connect( + null, + mapDispatchToProps, +)(NewDatasetModal); diff --git a/frontend/src/metabase/query_builder/components/NewDatasetModal/NewDatasetModal.styled.jsx b/frontend/src/metabase/query_builder/components/NewDatasetModal/NewDatasetModal.styled.jsx new file mode 100644 index 0000000000000000000000000000000000000000..1c71972d4942869959260055c75e61bcf862cee1 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/NewDatasetModal/NewDatasetModal.styled.jsx @@ -0,0 +1,45 @@ +import React from "react"; +import { PropTypes } from "prop-types"; +import styled from "styled-components"; +import { color, lighten } from "metabase/lib/colors"; +import { space } from "metabase/styled-components/theme"; +import Icon from "metabase/components/Icon"; + +const FeatureOverviewContainer = styled.div` + padding-right: ${space(1)}; +`; + +const FeatureIconContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + border-radius: 8px; + background-color: ${lighten(color("brand", 0.5))}; + width: 100px; + height: 100px; +`; + +const FeatureDescription = styled.p` + color: ${color("text-dark")}; +`; + +DatasetFeatureOverview.propTypes = { + icon: PropTypes.string.isRequired, + children: PropTypes.node.isRequired, +}; + +export function DatasetFeatureOverview({ icon, children }) { + return ( + <FeatureOverviewContainer> + <FeatureIconContainer> + <Icon name={icon} color={color("brand")} size={40} /> + </FeatureIconContainer> + <FeatureDescription>{children}</FeatureDescription> + </FeatureOverviewContainer> + ); +} + +export const DatasetFeaturesContainer = styled.div` + display: flex; + justify-content: space-between; +`; diff --git a/frontend/src/metabase/query_builder/components/NewDatasetModal/index.js b/frontend/src/metabase/query_builder/components/NewDatasetModal/index.js new file mode 100644 index 0000000000000000000000000000000000000000..cb7ab7fe640e46472ea25387afbea13e8e471440 --- /dev/null +++ b/frontend/src/metabase/query_builder/components/NewDatasetModal/index.js @@ -0,0 +1 @@ +export { default } from "./NewDatasetModal"; diff --git a/frontend/src/metabase/query_builder/components/QueryModals.jsx b/frontend/src/metabase/query_builder/components/QueryModals.jsx index c78583863e05624abc3a488851e27f9649243192..488aedf4bc08c402e2384ba90653f2d9a4bed266 100644 --- a/frontend/src/metabase/query_builder/components/QueryModals.jsx +++ b/frontend/src/metabase/query_builder/components/QueryModals.jsx @@ -19,6 +19,7 @@ import QuestionEmbedWidget from "metabase/query_builder/containers/QuestionEmbed import QuestionHistoryModal from "metabase/query_builder/containers/QuestionHistoryModal"; import { CreateAlertModalContent } from "metabase/query_builder/components/AlertModals"; +import NewDatasetModal from "metabase/query_builder/components/NewDatasetModal"; import EntityCopyModal from "metabase/entities/containers/EntityCopyModal"; export default class QueryModals extends React.Component { @@ -202,6 +203,10 @@ export default class QueryModals extends React.Component { onSaved={() => onOpenModal(MODAL_TYPES.SAVED)} /> </Modal> + ) : modal === MODAL_TYPES.TURN_INTO_DATASET ? ( + <Modal onClose={onCloseModal}> + <NewDatasetModal onClose={onCloseModal} /> + </Modal> ) : null; } } diff --git a/frontend/src/metabase/query_builder/components/QuestionActionButtons.jsx b/frontend/src/metabase/query_builder/components/QuestionActionButtons.jsx index 33d7651419b10c5d620d6847e76231b081131754..f296bfb0f05c007ca851ceddf1d87888bb5da1e2 100644 --- a/frontend/src/metabase/query_builder/components/QuestionActionButtons.jsx +++ b/frontend/src/metabase/query_builder/components/QuestionActionButtons.jsx @@ -11,6 +11,7 @@ import { Container } from "./QuestionActionButtons.styled"; export const EDIT_TESTID = "edit-details-button"; export const ADD_TO_DASH_TESTID = "add-to-dashboard-button"; export const MOVE_TESTID = "move-button"; +export const TURN_INTO_DATASET_TESTID = "turn-into-dataset"; export const CLONE_TESTID = "clone-button"; export const ARCHIVE_TESTID = "archive-button"; @@ -18,12 +19,13 @@ const ICON_SIZE = 18; QuestionActionButtons.propTypes = { canWrite: PropTypes.bool.isRequired, + isDataset: PropTypes.bool.isRequired, onOpenModal: PropTypes.func.isRequired, }; export default QuestionActionButtons; -function QuestionActionButtons({ canWrite, onOpenModal }) { +function QuestionActionButtons({ canWrite, isDataset, onOpenModal }) { return ( <Container> {canWrite && ( @@ -57,6 +59,17 @@ function QuestionActionButtons({ canWrite, onOpenModal }) { /> </Tooltip> )} + {canWrite && !isDataset && ( + <Tooltip tooltip={t`Turn this into a dataset`}> + <Button + onlyIcon + icon="dataset" + iconSize={ICON_SIZE} + onClick={() => onOpenModal(MODAL_TYPES.TURN_INTO_DATASET)} + data-testid={TURN_INTO_DATASET_TESTID} + /> + </Tooltip> + )} {canWrite && ( <Tooltip tooltip={t`Duplicate this question`}> <Button diff --git a/frontend/src/metabase/query_builder/components/view/sidebars/QuestionDetailsSidebarPanel.jsx b/frontend/src/metabase/query_builder/components/view/sidebars/QuestionDetailsSidebarPanel.jsx index a6c6022a67aa3e7feea46949ab899b6822ddf831..3157fa6ffb8f2d49a3bd3920c53d769f50fbc758 100644 --- a/frontend/src/metabase/query_builder/components/view/sidebars/QuestionDetailsSidebarPanel.jsx +++ b/frontend/src/metabase/query_builder/components/view/sidebars/QuestionDetailsSidebarPanel.jsx @@ -36,7 +36,11 @@ function QuestionDetailsSidebarPanel({ return ( <Container> <SidebarPaddedContent> - <QuestionActionButtons canWrite={canWrite} onOpenModal={onOpenModal} /> + <QuestionActionButtons + canWrite={canWrite} + isDataset={question.isDataset()} + onOpenModal={onOpenModal} + /> <ClampedDescription className="pl1 pb2" visibleLines={8} diff --git a/frontend/src/metabase/query_builder/constants.js b/frontend/src/metabase/query_builder/constants.js index a96420d5a2bc33b8ccfb28ee2301b90bf251e752..95609a78674e28e6804f09c123003c9f935ac750 100644 --- a/frontend/src/metabase/query_builder/constants.js +++ b/frontend/src/metabase/query_builder/constants.js @@ -12,4 +12,5 @@ export const MODAL_TYPES = { SAVE_QUESTION_BEFORE_EMBED: "save-question-before-embed", HISTORY: "history", EMBED: "embed", + TURN_INTO_DATASET: "turn-into-dataset", }; diff --git a/frontend/src/metabase/schema.js b/frontend/src/metabase/schema.js index c6da434e8705a33a0f946933f0cedf80f9a99a7d..565a286f3a398e8fa6a0502880871e24a77c844a 100644 --- a/frontend/src/metabase/schema.js +++ b/frontend/src/metabase/schema.js @@ -82,7 +82,7 @@ MetricSchema.define({ // backend returns model = "card" instead of "question" export const entityTypeForModel = model => - model === "card" ? "questions" : `${model}s`; + model === "card" || model === "dataset" ? "questions" : `${model}s`; export const entityTypeForObject = object => object && entityTypeForModel(object.model); diff --git a/frontend/test/metabase/query_builder/components/QuestionActionButtons.unit.spec.js b/frontend/test/metabase/query_builder/components/QuestionActionButtons.unit.spec.js index ffbe71cfc7bbd6662da190edd7040a73c807b5b0..73fc39a6d77d22b0bad905f912c51b61db41b168 100644 --- a/frontend/test/metabase/query_builder/components/QuestionActionButtons.unit.spec.js +++ b/frontend/test/metabase/query_builder/components/QuestionActionButtons.unit.spec.js @@ -57,7 +57,7 @@ describe("QuestionActionButtons", () => { it("should show all buttons", () => { const buttons = screen.getAllByRole("button"); - expect(buttons.length).toBe(5); + expect(buttons.length).toBe(6); }); it("should pass the correct action to the `onOpenModal`", () => { diff --git a/frontend/test/metabase/scenarios/question/datasets.cy.spec.js b/frontend/test/metabase/scenarios/question/datasets.cy.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..b7600345e73961d1cabac53219ef8179d6c748e3 --- /dev/null +++ b/frontend/test/metabase/scenarios/question/datasets.cy.spec.js @@ -0,0 +1,41 @@ +import { restore, modal } from "__support__/e2e/cypress"; + +describe("scenarios > datasets", () => { + beforeEach(() => { + restore(); + cy.signInAsAdmin(); + }); + + it("allows to turn a question into a dataset", () => { + cy.visit("/question/1"); + turnIntoDataset(); + cy.findByText("Our analytics").click(); + getCollectionItemRow("Orders").within(() => { + cy.icon("dataset"); + }); + }); + + it("changes dataset's display to table", () => { + cy.visit("/question/3"); + + cy.get(".LineAreaBarChart"); + cy.get(".TableInteractive").should("not.exist"); + + turnIntoDataset(); + + cy.get(".TableInteractive"); + cy.get(".LineAreaBarChart").should("not.exist"); + }); +}); + +function turnIntoDataset() { + cy.findByTestId("saved-question-header-button").click(); + cy.icon("dataset").click(); + modal().within(() => { + cy.button("Turn this into a dataset").click(); + }); +} + +function getCollectionItemRow(itemName) { + return cy.findByText(itemName).closest("tr"); +}