From 426da4a3dc5afc27ff7607423aae4a54b03097c2 Mon Sep 17 00:00:00 2001 From: Alexander Polyankin <alexander.polyankin@metabase.com> Date: Mon, 20 Jun 2022 21:32:01 +0300 Subject: [PATCH] Update collection empty state (#23406) --- frontend/src/metabase-lib/lib/Question.ts | 2 + frontend/src/metabase-types/api/database.ts | 4 + .../src/metabase-types/api/mocks/database.ts | 2 + .../CollectionEmptyState.styled.tsx | 35 +++ .../CollectionEmptyState.tsx | 45 +++ .../components/CollectionEmptyState/index.ts | 1 + .../CollectionHeader/CollectionHeader.jsx | 13 +- .../CollectionHeader.unit.spec.js | 38 ++- .../CollectionLanding/CollectionLanding.tsx | 29 ++ .../components/CollectionLanding/index.ts | 1 + .../collections/components/Layout.jsx | 5 - .../components/NewCollectionItemMenu.jsx | 43 --- .../containers/CollectionContent.jsx | 2 +- .../containers/CollectionContent.styled.tsx | 5 +- .../components/CollectionEmptyState.jsx | 273 ------------------ .../CollectionLanding/CollectionLanding.jsx | 27 -- .../CollectionLanding.styled.jsx | 11 - .../components/NewItemMenu/NewItemMenu.tsx | 138 +++++++++ .../metabase/components/NewItemMenu/index.ts | 1 + .../containers/NewItemMenu/NewItemMenu.tsx | 46 +++ .../metabase/containers/NewItemMenu/index.ts | 1 + .../NewItemButton/NewItemButton.styled.tsx} | 16 +- .../NewItemButton/NewItemButton.tsx | 23 ++ .../nav/components/NewItemButton/index.ts | 1 + .../src/metabase/nav/containers/AppBar.tsx | 4 +- .../nav/containers/NewButton/NewButton.tsx | 162 ----------- .../nav/containers/NewButton/index.ts | 1 - frontend/src/metabase/nav/selectors.ts | 25 ++ frontend/src/metabase/routes.jsx | 2 +- 29 files changed, 400 insertions(+), 556 deletions(-) create mode 100644 frontend/src/metabase/collections/components/CollectionEmptyState/CollectionEmptyState.styled.tsx create mode 100644 frontend/src/metabase/collections/components/CollectionEmptyState/CollectionEmptyState.tsx create mode 100644 frontend/src/metabase/collections/components/CollectionEmptyState/index.ts create mode 100644 frontend/src/metabase/collections/components/CollectionLanding/CollectionLanding.tsx create mode 100644 frontend/src/metabase/collections/components/CollectionLanding/index.ts delete mode 100644 frontend/src/metabase/collections/components/Layout.jsx delete mode 100644 frontend/src/metabase/collections/components/NewCollectionItemMenu.jsx delete mode 100644 frontend/src/metabase/components/CollectionEmptyState.jsx delete mode 100644 frontend/src/metabase/components/CollectionLanding/CollectionLanding.jsx delete mode 100644 frontend/src/metabase/components/CollectionLanding/CollectionLanding.styled.jsx create mode 100644 frontend/src/metabase/components/NewItemMenu/NewItemMenu.tsx create mode 100644 frontend/src/metabase/components/NewItemMenu/index.ts create mode 100644 frontend/src/metabase/containers/NewItemMenu/NewItemMenu.tsx create mode 100644 frontend/src/metabase/containers/NewItemMenu/index.ts rename frontend/src/metabase/nav/{containers/NewButton/NewButton.styled.tsx => components/NewItemButton/NewItemButton.styled.tsx} (62%) create mode 100644 frontend/src/metabase/nav/components/NewItemButton/NewItemButton.tsx create mode 100644 frontend/src/metabase/nav/components/NewItemButton/index.ts delete mode 100644 frontend/src/metabase/nav/containers/NewButton/NewButton.tsx delete mode 100644 frontend/src/metabase/nav/containers/NewButton/index.ts create mode 100644 frontend/src/metabase/nav/selectors.ts diff --git a/frontend/src/metabase-lib/lib/Question.ts b/frontend/src/metabase-lib/lib/Question.ts index a32d4ff42d0..32de0f573f9 100644 --- a/frontend/src/metabase-lib/lib/Question.ts +++ b/frontend/src/metabase-lib/lib/Question.ts @@ -74,12 +74,14 @@ import { ALERT_TYPE_TIMESERIES_GOAL, } from "metabase-lib/lib/Alert"; import { utf8_to_b64url } from "metabase/lib/encoding"; +import { CollectionId } from "metabase-types/api"; type QuestionUpdateFn = (q: Question) => Promise<void> | null | undefined; export type QuestionCreatorOpts = { databaseId?: DatabaseId; tableId?: TableId; + collectionId?: CollectionId; metadata?: Metadata; parameterValues?: ParameterValues; type?: "query" | "native"; diff --git a/frontend/src/metabase-types/api/database.ts b/frontend/src/metabase-types/api/database.ts index 950dc47a743..0d81213986d 100644 --- a/frontend/src/metabase-types/api/database.ts +++ b/frontend/src/metabase-types/api/database.ts @@ -1,3 +1,5 @@ +import { NativePermissions } from "./permissions"; + export type DatabaseId = number; export type InitialSyncStatus = "incomplete" | "complete" | "aborted"; @@ -7,8 +9,10 @@ export interface Database { name: string; engine: string; is_sample: boolean; + is_saved_questions: boolean; creator_id?: number; created_at: string; timezone?: string; + native_permissions: NativePermissions; initial_sync_status: InitialSyncStatus; } diff --git a/frontend/src/metabase-types/api/mocks/database.ts b/frontend/src/metabase-types/api/mocks/database.ts index 118a498a200..2c05c6097e4 100644 --- a/frontend/src/metabase-types/api/mocks/database.ts +++ b/frontend/src/metabase-types/api/mocks/database.ts @@ -5,9 +5,11 @@ export const createMockDatabase = (opts?: Partial<Database>): Database => ({ name: "Database", engine: "H2", is_sample: false, + is_saved_questions: false, creator_id: undefined, created_at: "2015-01-01T20:10:30.200", timezone: "UTC", + native_permissions: "write", initial_sync_status: "complete", ...opts, }); diff --git a/frontend/src/metabase/collections/components/CollectionEmptyState/CollectionEmptyState.styled.tsx b/frontend/src/metabase/collections/components/CollectionEmptyState/CollectionEmptyState.styled.tsx new file mode 100644 index 00000000000..6e171634ea5 --- /dev/null +++ b/frontend/src/metabase/collections/components/CollectionEmptyState/CollectionEmptyState.styled.tsx @@ -0,0 +1,35 @@ +import styled from "@emotion/styled"; +import { color } from "metabase/lib/colors"; + +export const EmptyStateRoot = styled.div` + display: flex; + flex-direction: column; + align-items: center; +`; + +export const EmptyStateTitle = styled.div` + color: ${color("text-dark")}; + font-size: 1.5rem; + font-weight: bold; + line-height: 2rem; + margin-top: 2.5rem; + margin-bottom: 0.75rem; +`; + +export const EmptyStateDescription = styled.div` + color: ${color("text-medium")}; + font-size: 1rem; + line-height: 1.5rem; + margin-bottom: 1.5rem; + text-align: center; +`; + +export const EmptyStateIconForeground = styled.path` + fill: ${color("bg-light")}; + stroke: ${color("brand")}; +`; + +export const EmptyStateIconBackground = styled.path` + fill: ${color("brand-light")}; + stroke: ${color("brand")}; +`; diff --git a/frontend/src/metabase/collections/components/CollectionEmptyState/CollectionEmptyState.tsx b/frontend/src/metabase/collections/components/CollectionEmptyState/CollectionEmptyState.tsx new file mode 100644 index 00000000000..22f45376e4a --- /dev/null +++ b/frontend/src/metabase/collections/components/CollectionEmptyState/CollectionEmptyState.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import { t } from "ttag"; +import Button from "metabase/core/components/Button"; +import NewItemMenu from "metabase/containers/NewItemMenu"; +import { ANALYTICS_CONTEXT } from "metabase/collections/constants"; +import { + EmptyStateDescription, + EmptyStateIconBackground, + EmptyStateIconForeground, + EmptyStateRoot, + EmptyStateTitle, +} from "./CollectionEmptyState.styled"; + +const CollectionEmptyState = (): JSX.Element => { + return ( + <EmptyStateRoot data-testid="collection-empty-state"> + <CollectionEmptyIcon /> + <EmptyStateTitle>{t`This collection is empty`}</EmptyStateTitle> + <EmptyStateDescription>{t`Use collections to organize and group dashboards and questions for your team or yourself`}</EmptyStateDescription> + <NewItemMenu + trigger={<Button icon="add">{t`Create a new…`}</Button>} + analyticsContext={ANALYTICS_CONTEXT} + /> + </EmptyStateRoot> + ); +}; + +const CollectionEmptyIcon = (): JSX.Element => { + return ( + <svg width="117" height="94" fill="none" xmlns="http://www.w3.org/2000/svg"> + <EmptyStateIconForeground + fillRule="evenodd" + clipRule="evenodd" + d="M12.5 1C6.148 1 .995 6.151 1 12.505l.023 69C1.029 87.854 6.175 93 12.523 93H104.5C110.853 93 116 87.851 116 81.5V22.196c0-6.352-5.147-11.5-11.501-11.5H65.357a5.752 5.752 0 0 1-5.307-3.533l-1.099-2.63A5.752 5.752 0 0 0 53.644 1H12.5Z" + strokeWidth="2" + /> + <EmptyStateIconBackground + d="M1 13C1 6.373 6.373 1 13 1h39.76a8 8 0 0 1 7.017 4.157l.446.815a8 8 0 0 0 7.017 4.158H107a9 9 0 0 1 9 9V26l-2.714-3.137a16.003 16.003 0 0 0-12.099-5.53H15.383a16 16 0 0 0-13.155 6.893L1 26V13Z" + strokeWidth="2" + /> + </svg> + ); +}; + +export default CollectionEmptyState; diff --git a/frontend/src/metabase/collections/components/CollectionEmptyState/index.ts b/frontend/src/metabase/collections/components/CollectionEmptyState/index.ts new file mode 100644 index 00000000000..ef732eeef5f --- /dev/null +++ b/frontend/src/metabase/collections/components/CollectionEmptyState/index.ts @@ -0,0 +1 @@ +export { default } from "./CollectionEmptyState"; diff --git a/frontend/src/metabase/collections/components/CollectionHeader/CollectionHeader.jsx b/frontend/src/metabase/collections/components/CollectionHeader/CollectionHeader.jsx index 1fc1b53324a..10710fe8c4c 100644 --- a/frontend/src/metabase/collections/components/CollectionHeader/CollectionHeader.jsx +++ b/frontend/src/metabase/collections/components/CollectionHeader/CollectionHeader.jsx @@ -10,10 +10,11 @@ import PageHeading from "metabase/components/type/PageHeading"; import Tooltip from "metabase/components/Tooltip"; import CollectionEditMenu from "metabase/collections/components/CollectionEditMenu"; -import NewCollectionItemMenu from "metabase/collections/components/NewCollectionItemMenu"; +import NewItemMenu from "metabase/containers/NewItemMenu"; import { color } from "metabase/lib/colors"; import { PLUGIN_COLLECTION_COMPONENTS } from "metabase/plugins"; +import { ANALYTICS_CONTEXT } from "metabase/collections/constants"; import { BookmarkIcon, @@ -140,7 +141,15 @@ function Menu(props) { return ( <MenuContainer data-testid="collection-menu"> - {hasWritePermission && <NewCollectionItemMenu {...props} />} + {hasWritePermission && ( + <NewItemMenu + {...props} + collectionId={collectionId} + triggerIcon="add" + triggerTooltip={t`New…`} + analyticsContext={ANALYTICS_CONTEXT} + /> + )} <EditMenu {...props} /> <PermissionsLink {...props} /> <TimelinesLink {...props} /> diff --git a/frontend/src/metabase/collections/components/CollectionHeader/CollectionHeader.unit.spec.js b/frontend/src/metabase/collections/components/CollectionHeader/CollectionHeader.unit.spec.js index 792a0266b24..80ea2e0da46 100644 --- a/frontend/src/metabase/collections/components/CollectionHeader/CollectionHeader.unit.spec.js +++ b/frontend/src/metabase/collections/components/CollectionHeader/CollectionHeader.unit.spec.js @@ -1,6 +1,6 @@ import React from "react"; -import { render, screen } from "@testing-library/react"; - +import { screen } from "@testing-library/react"; +import { renderWithProviders } from "__support__/ui"; import Header from "./CollectionHeader"; const collection = { @@ -8,7 +8,7 @@ const collection = { }; it("should display collection name", () => { - render(<Header collection={collection} />); + renderWithProviders(<Header collection={collection} />); screen.getByText(collection.name); }); @@ -16,7 +16,9 @@ it("should display collection name", () => { describe("description tooltip", () => { describe("should not be displayed", () => { it("if description is not received", () => { - const { container } = render(<Header collection={collection} />); + const { container } = renderWithProviders( + <Header collection={collection} />, + ); expect(container.textContent).toEqual("Name"); }); }); @@ -25,7 +27,9 @@ describe("description tooltip", () => { it("if description is received", () => { const description = "description"; - render(<Header collection={{ ...collection, description }} />); + renderWithProviders( + <Header collection={{ ...collection, description }} />, + ); screen.getByText(description); }); @@ -37,13 +41,13 @@ describe("permissions link", () => { describe("should not be displayed", () => { it("if user is not admin", () => { - render(<Header collection={collection} />); + renderWithProviders(<Header collection={collection} />); expect(screen.queryByLabelText(ariaLabel)).not.toBeInTheDocument(); }); it("for personal collections", () => { - render( + renderWithProviders( <Header isAdmin={true} collection={{ ...collection, personal_owner_id: 1 }} @@ -54,7 +58,7 @@ describe("permissions link", () => { }); it("if a collection is a personal collection child", () => { - render( + renderWithProviders( <Header isAdmin={true} collection={collection} @@ -68,7 +72,7 @@ describe("permissions link", () => { describe("should be displayed", () => { it("if user is admin", () => { - render(<Header collection={collection} isAdmin={true} />); + renderWithProviders(<Header collection={collection} isAdmin={true} />); screen.getByLabelText(ariaLabel); }); @@ -80,13 +84,15 @@ describe("link to add new collection items", () => { describe("should not be displayed", () => { it("when no detail is passed in the collection to determine if user can change collection", () => { - render(<Header collection={collection} />); + renderWithProviders(<Header collection={collection} />); expect(screen.queryByLabelText(ariaLabel)).not.toBeInTheDocument(); }); it("if user is not allowed to change collection", () => { - render(<Header collection={{ ...collection, can_write: false }} />); + renderWithProviders( + <Header collection={{ ...collection, can_write: false }} />, + ); expect(screen.queryByLabelText(ariaLabel)).not.toBeInTheDocument(); }); @@ -94,7 +100,9 @@ describe("link to add new collection items", () => { describe("should be displayed", () => { it("if user is allowed to change collection", () => { - render(<Header collection={{ ...collection, can_write: true }} />); + renderWithProviders( + <Header collection={{ ...collection, can_write: true }} />, + ); screen.getByLabelText(ariaLabel); }); @@ -106,7 +114,7 @@ describe("link to add new collection items", () => { describe("should not be displayed", () => { it("if user is not allowed to change collection", () => { - render(<Header collection={collection} />); + renderWithProviders(<Header collection={collection} />); expect(screen.queryByLabelText(ariaLabel)).not.toBeInTheDocument(); }); @@ -114,7 +122,9 @@ describe("link to add new collection items", () => { describe("should be displayed", () => { it("if user is allowed to change collection", () => { - render(<Header collection={{ ...collection, can_write: true }} />); + renderWithProviders( + <Header collection={{ ...collection, can_write: true }} />, + ); screen.getByLabelText(ariaLabel); }); diff --git a/frontend/src/metabase/collections/components/CollectionLanding/CollectionLanding.tsx b/frontend/src/metabase/collections/components/CollectionLanding/CollectionLanding.tsx new file mode 100644 index 00000000000..fcf3dc82abe --- /dev/null +++ b/frontend/src/metabase/collections/components/CollectionLanding/CollectionLanding.tsx @@ -0,0 +1,29 @@ +import React, { ReactNode } from "react"; +import { extractCollectionId } from "metabase/lib/urls"; +import CollectionContent from "../../containers/CollectionContent"; + +export interface CollectionLandingProps { + params: CollectionLandingParams; + children?: ReactNode; +} + +export interface CollectionLandingParams { + slug: string; +} + +const CollectionLanding = ({ + params: { slug }, + children, +}: CollectionLandingProps) => { + const collectionId = extractCollectionId(slug); + const isRoot = collectionId === "root"; + + return ( + <> + <CollectionContent isRoot={isRoot} collectionId={collectionId} /> + {children} + </> + ); +}; + +export default CollectionLanding; diff --git a/frontend/src/metabase/collections/components/CollectionLanding/index.ts b/frontend/src/metabase/collections/components/CollectionLanding/index.ts new file mode 100644 index 00000000000..bd66ac36b61 --- /dev/null +++ b/frontend/src/metabase/collections/components/CollectionLanding/index.ts @@ -0,0 +1 @@ +export { default } from "./CollectionLanding"; diff --git a/frontend/src/metabase/collections/components/Layout.jsx b/frontend/src/metabase/collections/components/Layout.jsx deleted file mode 100644 index 816a1b2b0b2..00000000000 --- a/frontend/src/metabase/collections/components/Layout.jsx +++ /dev/null @@ -1,5 +0,0 @@ -import styled from "@emotion/styled"; - -export const PageWrapper = styled.div` - overflow: hidden; -`; diff --git a/frontend/src/metabase/collections/components/NewCollectionItemMenu.jsx b/frontend/src/metabase/collections/components/NewCollectionItemMenu.jsx deleted file mode 100644 index 4e4af5cb587..00000000000 --- a/frontend/src/metabase/collections/components/NewCollectionItemMenu.jsx +++ /dev/null @@ -1,43 +0,0 @@ -import React from "react"; -import PropTypes from "prop-types"; -import { t } from "ttag"; - -import { newQuestion, newDashboard, newCollection } from "metabase/lib/urls"; - -import EntityMenu from "metabase/components/EntityMenu"; -import { ANALYTICS_CONTEXT } from "metabase/collections/constants"; - -const propTypes = { - collection: PropTypes.object, - list: PropTypes.arrayOf(PropTypes.object), - canCreateQuestions: PropTypes.bool, -}; - -function NewCollectionItemMenu({ collection, canCreateQuestions }) { - const items = [ - canCreateQuestions && { - icon: "insight", - title: t`Question`, - link: newQuestion({ mode: "notebook", collectionId: collection.id }), - event: `${ANALYTICS_CONTEXT};New Item Menu;Question Click`, - }, - { - icon: "dashboard", - title: t`Dashboard`, - link: newDashboard(collection.id), - event: `${ANALYTICS_CONTEXT};New Item Menu;Dashboard Click`, - }, - { - icon: "folder", - title: t`Collection`, - link: newCollection(collection.id), - event: `${ANALYTICS_CONTEXT};New Item Menu;Collection Click`, - }, - ].filter(Boolean); - - return <EntityMenu items={items} triggerIcon="add" tooltip={t`New…`} />; -} - -NewCollectionItemMenu.propTypes = propTypes; - -export default NewCollectionItemMenu; diff --git a/frontend/src/metabase/collections/containers/CollectionContent.jsx b/frontend/src/metabase/collections/containers/CollectionContent.jsx index 8a52ea0825b..e8bdef14651 100644 --- a/frontend/src/metabase/collections/containers/CollectionContent.jsx +++ b/frontend/src/metabase/collections/containers/CollectionContent.jsx @@ -13,7 +13,7 @@ import { getIsBookmarked } from "metabase/collections/selectors"; import { getIsNavbarOpen, openNavbar } from "metabase/redux/app"; import BulkActions from "metabase/collections/components/BulkActions"; -import CollectionEmptyState from "metabase/components/CollectionEmptyState"; +import CollectionEmptyState from "metabase/collections/components/CollectionEmptyState"; import Header from "metabase/collections/containers/CollectionHeader"; import ItemsTable from "metabase/collections/components/ItemsTable"; import PinnedItemOverview from "metabase/collections/components/PinnedItemOverview"; diff --git a/frontend/src/metabase/collections/containers/CollectionContent.styled.tsx b/frontend/src/metabase/collections/containers/CollectionContent.styled.tsx index 2800f76ce9a..b68f5a3512f 100644 --- a/frontend/src/metabase/collections/containers/CollectionContent.styled.tsx +++ b/frontend/src/metabase/collections/containers/CollectionContent.styled.tsx @@ -18,8 +18,5 @@ export const CollectionTable = styled.div<CollectionTableProps>` `; export const CollectionEmptyContent = styled.div` - display: flex; - justify-content: center; - align-items: start; - margin-top: 3rem; + margin-top: calc(20vh - 3.5rem); `; diff --git a/frontend/src/metabase/components/CollectionEmptyState.jsx b/frontend/src/metabase/components/CollectionEmptyState.jsx deleted file mode 100644 index 1bba88a3367..00000000000 --- a/frontend/src/metabase/components/CollectionEmptyState.jsx +++ /dev/null @@ -1,273 +0,0 @@ -import React from "react"; - -function CollectionEmptyState() { - return ( - <svg - style={{ maxWidth: 1120 }} - data-testid="collection-empty-state" - viewBox="0 0 944 672" - fill="none" - xmlns="http://www.w3.org/2000/svg" - > - <rect - opacity="0.7" - x="0.5" - y="0.5" - width="463" - height="215" - rx="7.5" - fill="white" - stroke="#F0F0F0" - /> - <rect - width="42" - height="200" - transform="matrix(-1 0 0 1 428 16)" - fill="#EDF2F5" - fillOpacity="0.5" - /> - <rect - width="42" - height="177" - transform="matrix(-1 0 0 1 378 39)" - fill="#EDF2F5" - fillOpacity="0.5" - /> - <rect - width="42" - height="127.941" - transform="matrix(-1 0 0 1 328 88.0588)" - fill="#EDF2F5" - fillOpacity="0.5" - /> - <rect - width="42" - height="83.8235" - transform="matrix(-1 0 0 1 278 132.176)" - fill="#EDF2F5" - fillOpacity="0.5" - /> - <rect - width="42" - height="41.1765" - transform="matrix(-1 0 0 1 228 174.824)" - fill="#EDF2F5" - fillOpacity="0.5" - /> - <rect - width="42" - height="147.059" - transform="matrix(-1 0 0 1 178 68.9412)" - fill="#EDF2F5" - fillOpacity="0.5" - /> - <rect - width="42" - height="110" - transform="matrix(-1 0 0 1 128 106)" - fill="#EDF2F5" - fillOpacity="0.5" - /> - <rect - width="42" - height="84" - transform="matrix(-1 0 0 1 78 132)" - fill="#EDF2F5" - fillOpacity="0.5" - /> - <rect - opacity="0.7" - x="483.049" - y="0.5" - width="460.451" - height="215" - rx="7.5" - fill="white" - stroke="#F0F0F0" - /> - <path - opacity="0.5" - d="M550.992 198.14L515.496 184.744L480 192.48V208C480 212.418 483.582 216 488 216H933.451C937.869 216 941.451 212.418 941.451 208V24L905.954 35.1628L870.458 59.7209L834.962 44.093L799.466 77.5814L763.97 59.7209L728.473 86.5116L692.977 104.372L657.481 77.5814L621.985 176.93L586.489 150.14L550.992 198.14Z" - fill="#EDF2F5" - /> - <path - d="M481.275 193.44L515.496 184.744L550.992 198.14L586.488 150.14L621.985 176.93L657.481 77.5814L692.977 104.372L728.473 86.5116L763.969 59.7209L799.466 77.5814L834.962 44.093L870.458 59.7209L905.954 35.1628L941.45 24" - stroke="#EDF2F5" - strokeWidth="2" - /> - <rect - opacity="0.7" - x="0.5" - y="232.5" - width="463" - height="127" - rx="7.5" - fill="white" - stroke="#F0F0F0" - /> - <path - fillRule="evenodd" - clipRule="evenodd" - d="M216 281.714C216 279.505 217.791 277.714 220 277.714H244H245C246.657 277.714 248 279.057 248 280.714V281.714V285.714V305.714C248 307.923 246.209 309.714 244 309.714H220C218.136 309.714 216.57 308.44 216.126 306.714H216V305.714V285.714V281.714ZM244 285.714V305.714H220V285.714H244ZM233.455 296.623H223.273V300.987H233.455V296.623ZM223.273 289.805H240.727V294.169H223.273V289.805ZM240.727 296.623H236.364V300.987H240.727V296.623Z" - fill="#EDF2F5" - /> - <rect - opacity="0.7" - x="480.5" - y="232.5" - width="463" - height="127" - rx="7.5" - fill="white" - stroke="#F0F0F0" - /> - <path - fillRule="evenodd" - clipRule="evenodd" - d="M696 281.714C696 279.505 697.791 277.714 700 277.714H724H725C726.657 277.714 728 279.057 728 280.714V281.714V285.714V305.714C728 307.923 726.209 309.714 724 309.714H700C698.136 309.714 696.57 308.44 696.126 306.714H696V305.714V285.714V281.714ZM724 285.714V305.714H700V285.714H724ZM713.455 296.623H703.273V300.987H713.455V296.623ZM703.273 289.805H720.727V294.169H703.273V289.805ZM720.727 296.623H716.364V300.987H720.727V296.623Z" - fill="#EDF2F5" - /> - <rect y="440" width="258.683" height="8" rx="4" fill="#EDF2F5" /> - <rect - x="630.708" - y="440" - width="113.898" - height="8" - rx="4" - fill="#EDF2F5" - /> - <rect - x="830.102" - y="440" - width="103.28" - height="8" - rx="4" - fill="#EDF2F5" - /> - <rect y="472" width="402.503" height="8" rx="4" fill="#EDF2F5" /> - <rect - x="630.708" - y="472" - width="99.4192" - height="8" - rx="4" - fill="#EDF2F5" - /> - <rect - x="830.102" - y="472" - width="113.898" - height="8" - rx="4" - fill="#EDF2F5" - /> - <rect y="504" width="207.526" height="8" rx="4" fill="#EDF2F5" /> - <rect - x="630.708" - y="504" - width="130.307" - height="8" - rx="4" - fill="#EDF2F5" - /> - <rect - x="830.102" - y="504" - width="89.7669" - height="8" - rx="4" - fill="#EDF2F5" - /> - <rect y="536" width="354.241" height="8" rx="4" fill="#EDF2F5" /> - <rect - x="630.708" - y="536" - width="113.898" - height="8" - rx="4" - fill="#EDF2F5" - /> - <rect - x="830.102" - y="536" - width="103.28" - height="8" - rx="4" - fill="#EDF2F5" - /> - <rect y="568" width="370.65" height="8" rx="4" fill="#EDF2F5" /> - <rect - x="630.708" - y="568" - width="106.176" - height="8" - rx="4" - fill="#EDF2F5" - /> - <rect - x="830.102" - y="568" - width="89.7669" - height="8" - rx="4" - fill="#EDF2F5" - /> - <rect y="600" width="233.587" height="8" rx="4" fill="#EDF2F5" /> - <rect - x="630.708" - y="600" - width="138.029" - height="8" - rx="4" - fill="#EDF2F5" - /> - <rect - x="830.102" - y="600" - width="103.28" - height="8" - rx="4" - fill="#EDF2F5" - /> - <rect y="632" width="284.744" height="8" rx="4" fill="#EDF2F5" /> - <rect - x="630.708" - y="632" - width="106.176" - height="8" - rx="4" - fill="#EDF2F5" - /> - <rect - x="830.102" - y="632" - width="103.28" - height="8" - rx="4" - fill="#EDF2F5" - /> - <rect y="664" width="258.683" height="8" rx="4" fill="#EDF2F5" /> - <rect - x="630.708" - y="664" - width="113.898" - height="8" - rx="4" - fill="#EDF2F5" - /> - <rect - x="830.102" - y="664" - width="113.898" - height="8" - rx="4" - fill="#EDF2F5" - /> - <rect y="408" width="50.1239" height="8" rx="4" fill="#EDF2F5" /> - <rect x="631" y="408" width="73.515" height="8" rx="4" fill="#EDF2F5" /> - <rect x="830" y="408" width="60.9841" height="8" rx="4" fill="#EDF2F5" /> - </svg> - ); -} - -export default CollectionEmptyState; diff --git a/frontend/src/metabase/components/CollectionLanding/CollectionLanding.jsx b/frontend/src/metabase/components/CollectionLanding/CollectionLanding.jsx deleted file mode 100644 index 621747ec416..00000000000 --- a/frontend/src/metabase/components/CollectionLanding/CollectionLanding.jsx +++ /dev/null @@ -1,27 +0,0 @@ -/* eslint-disable react/prop-types */ -import React from "react"; - -import { extractCollectionId } from "metabase/lib/urls"; - -import { PageWrapper } from "metabase/collections/components/Layout"; -import CollectionContent from "metabase/collections/containers/CollectionContent"; -import { ContentBox } from "./CollectionLanding.styled"; - -const CollectionLanding = ({ params: { slug }, children }) => { - const collectionId = extractCollectionId(slug); - const isRoot = collectionId === "root"; - - return ( - <PageWrapper> - <ContentBox> - <CollectionContent isRoot={isRoot} collectionId={collectionId} /> - </ContentBox> - { - // Need to have this here so the child modals will show up - children - } - </PageWrapper> - ); -}; - -export default CollectionLanding; diff --git a/frontend/src/metabase/components/CollectionLanding/CollectionLanding.styled.jsx b/frontend/src/metabase/components/CollectionLanding/CollectionLanding.styled.jsx deleted file mode 100644 index d2f2c7e837f..00000000000 --- a/frontend/src/metabase/components/CollectionLanding/CollectionLanding.styled.jsx +++ /dev/null @@ -1,11 +0,0 @@ -import styled from "@emotion/styled"; - -import { breakpointMinSmall } from "metabase/styled-components/theme/media-queries"; - -export const ContentBox = styled.div` - overflow-y: auto; - - ${breakpointMinSmall} { - display: block; - } -`; diff --git a/frontend/src/metabase/components/NewItemMenu/NewItemMenu.tsx b/frontend/src/metabase/components/NewItemMenu/NewItemMenu.tsx new file mode 100644 index 00000000000..dd1efe2d1b1 --- /dev/null +++ b/frontend/src/metabase/components/NewItemMenu/NewItemMenu.tsx @@ -0,0 +1,138 @@ +import React, { ReactNode, useCallback, useMemo, useState } from "react"; +import { t } from "ttag"; +import * as Urls from "metabase/lib/urls"; +import Modal from "metabase/components/Modal"; +import EntityMenu from "metabase/components/EntityMenu"; +import CreateDashboardModal from "metabase/components/CreateDashboardModal"; +import CollectionCreate from "metabase/collections/containers/CollectionCreate"; +import { Collection, CollectionId } from "metabase-types/api"; + +type ModalType = "new-dashboard" | "new-collection"; + +export interface NewItemMenuProps { + className?: string; + collectionId?: CollectionId; + trigger?: ReactNode; + triggerIcon?: string; + triggerTooltip?: string; + analyticsContext?: string; + hasDataAccess: boolean; + hasNativeWrite: boolean; + hasDatabaseWithJsonEngine: boolean; + onChangeLocation: (location: string) => void; + onCloseNavbar: () => void; +} + +const NewItemMenu = ({ + className, + collectionId, + trigger, + triggerIcon, + triggerTooltip, + analyticsContext, + hasDataAccess, + hasNativeWrite, + hasDatabaseWithJsonEngine, + onChangeLocation, + onCloseNavbar, +}: NewItemMenuProps) => { + const [modal, setModal] = useState<ModalType>(); + + const handleModalClose = useCallback(() => { + setModal(undefined); + }, []); + + const handleCollectionSave = useCallback( + (collection: Collection) => { + handleModalClose(); + onChangeLocation(Urls.collection(collection)); + }, + [handleModalClose, onChangeLocation], + ); + + const menuItems = useMemo(() => { + const items = []; + + if (hasDataAccess) { + items.push({ + title: t`Question`, + icon: "insight", + link: Urls.newQuestion({ + mode: "notebook", + creationType: "custom_question", + collectionId, + }), + event: `${analyticsContext};New Question Click;`, + onClose: onCloseNavbar, + }); + } + + if (hasNativeWrite) { + items.push({ + title: hasDatabaseWithJsonEngine ? t`Native query` : t`SQL query`, + icon: "sql", + link: Urls.newQuestion({ + type: "native", + creationType: "native_question", + collectionId, + }), + event: `${analyticsContext};New SQL Query Click;`, + onClose: onCloseNavbar, + }); + } + + items.push( + { + title: t`Dashboard`, + icon: "dashboard", + action: () => setModal("new-dashboard"), + event: `${analyticsContext};New Dashboard Click;`, + }, + { + title: t`Collection`, + icon: "folder", + action: () => setModal("new-collection"), + event: `${analyticsContext};New Collection Click;`, + }, + ); + + return items; + }, [ + collectionId, + hasDataAccess, + hasNativeWrite, + hasDatabaseWithJsonEngine, + analyticsContext, + onCloseNavbar, + ]); + + return ( + <> + <EntityMenu + className={className} + items={menuItems} + trigger={trigger} + triggerIcon={triggerIcon} + tooltip={triggerTooltip} + /> + {modal && ( + <Modal onClose={handleModalClose}> + {modal === "new-collection" ? ( + <CollectionCreate + collectionId={collectionId} + onClose={handleModalClose} + onSaved={handleCollectionSave} + /> + ) : modal === "new-dashboard" ? ( + <CreateDashboardModal + collectionId={collectionId} + onClose={handleModalClose} + /> + ) : null} + </Modal> + )} + </> + ); +}; + +export default NewItemMenu; diff --git a/frontend/src/metabase/components/NewItemMenu/index.ts b/frontend/src/metabase/components/NewItemMenu/index.ts new file mode 100644 index 00000000000..734e3d64075 --- /dev/null +++ b/frontend/src/metabase/components/NewItemMenu/index.ts @@ -0,0 +1 @@ +export { default } from "./NewItemMenu"; diff --git a/frontend/src/metabase/containers/NewItemMenu/NewItemMenu.tsx b/frontend/src/metabase/containers/NewItemMenu/NewItemMenu.tsx new file mode 100644 index 00000000000..62cabbcf75c --- /dev/null +++ b/frontend/src/metabase/containers/NewItemMenu/NewItemMenu.tsx @@ -0,0 +1,46 @@ +import { ReactNode } from "react"; +import { connect } from "react-redux"; +import { push } from "react-router-redux"; +import { closeNavbar } from "metabase/redux/app"; +import NewItemMenu from "metabase/components/NewItemMenu"; +import { + getHasDataAccess, + getHasDatabaseWithJsonEngine, + getHasNativeWrite, +} from "metabase/nav/selectors"; +import { State } from "metabase-types/store"; + +interface MenuOwnProps { + className?: string; + trigger?: ReactNode; + triggerIcon?: string; + triggerTooltip?: string; + analyticsContext?: string; +} + +interface MenuStateProps { + hasDataAccess: boolean; + hasNativeWrite: boolean; + hasDatabaseWithJsonEngine: boolean; +} + +interface MenuDispatchProps { + onChangeLocation: (location: string) => void; + onCloseNavbar: () => void; +} + +const mapStateToProps = (state: State): MenuStateProps => ({ + hasDataAccess: getHasDataAccess(state), + hasNativeWrite: getHasNativeWrite(state), + hasDatabaseWithJsonEngine: getHasDatabaseWithJsonEngine(state), +}); + +const mapDispatchToProps = { + onChangeLocation: push, + onCloseNavbar: closeNavbar, +}; + +export default connect<MenuStateProps, MenuDispatchProps, MenuOwnProps, State>( + mapStateToProps, + mapDispatchToProps, +)(NewItemMenu); diff --git a/frontend/src/metabase/containers/NewItemMenu/index.ts b/frontend/src/metabase/containers/NewItemMenu/index.ts new file mode 100644 index 00000000000..734e3d64075 --- /dev/null +++ b/frontend/src/metabase/containers/NewItemMenu/index.ts @@ -0,0 +1 @@ +export { default } from "./NewItemMenu"; diff --git a/frontend/src/metabase/nav/containers/NewButton/NewButton.styled.tsx b/frontend/src/metabase/nav/components/NewItemButton/NewItemButton.styled.tsx similarity index 62% rename from frontend/src/metabase/nav/containers/NewButton/NewButton.styled.tsx rename to frontend/src/metabase/nav/components/NewItemButton/NewItemButton.styled.tsx index ac9d0c58953..d83e2d8efbd 100644 --- a/frontend/src/metabase/nav/containers/NewButton/NewButton.styled.tsx +++ b/frontend/src/metabase/nav/components/NewItemButton/NewItemButton.styled.tsx @@ -1,11 +1,9 @@ import styled from "@emotion/styled"; - -import EntityMenu from "metabase/components/EntityMenu"; -import Button from "metabase/core/components/Button"; - +import Button from "metabase/core/components/Button/Button"; +import NewItemMenu from "metabase/containers/NewItemMenu"; import { breakpointMaxSmall } from "metabase/styled-components/theme"; -export const Menu = styled(EntityMenu)` +export const NewMenu = styled(NewItemMenu)` margin-right: 0.5rem; ${breakpointMaxSmall} { @@ -13,12 +11,12 @@ export const Menu = styled(EntityMenu)` } `; -export const StyledButton = styled(Button)` +export const NewButton = styled(Button)` display: flex; align-items: center; + height: 2.25rem; margin-right: 0.5rem; padding: 0.5rem; - height: 36px; ${Button.TextContainer} { margin-left: 0; @@ -29,10 +27,8 @@ export const StyledButton = styled(Button)` } `; -export const Title = styled.h4` +export const NewButtonText = styled.h4` display: inline; - margin-left: 0.5rem; - white-space: nowrap; `; diff --git a/frontend/src/metabase/nav/components/NewItemButton/NewItemButton.tsx b/frontend/src/metabase/nav/components/NewItemButton/NewItemButton.tsx new file mode 100644 index 00000000000..b9b24342b06 --- /dev/null +++ b/frontend/src/metabase/nav/components/NewItemButton/NewItemButton.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import { t } from "ttag"; +import { NewButton, NewButtonText, NewMenu } from "./NewItemButton.styled"; + +const NewItemButton = () => { + return ( + <NewMenu + trigger={ + <NewButton + primary + icon="add" + iconSize={14} + data-metabase-event="NavBar;Create Menu Click" + > + <NewButtonText>{t`New`}</NewButtonText> + </NewButton> + } + analyticsContext={"NavBar"} + /> + ); +}; + +export default NewItemButton; diff --git a/frontend/src/metabase/nav/components/NewItemButton/index.ts b/frontend/src/metabase/nav/components/NewItemButton/index.ts new file mode 100644 index 00000000000..d4ff42c65c6 --- /dev/null +++ b/frontend/src/metabase/nav/components/NewItemButton/index.ts @@ -0,0 +1 @@ +export { default } from "./NewItemButton"; diff --git a/frontend/src/metabase/nav/containers/AppBar.tsx b/frontend/src/metabase/nav/containers/AppBar.tsx index 4597f673a58..252c3f77097 100644 --- a/frontend/src/metabase/nav/containers/AppBar.tsx +++ b/frontend/src/metabase/nav/containers/AppBar.tsx @@ -9,7 +9,7 @@ import LogoIcon from "metabase/components/LogoIcon"; import SearchBar from "metabase/nav/components/SearchBar"; import SidebarButton from "metabase/nav/components/SidebarButton"; -import NewButton from "metabase/nav/containers/NewButton"; +import NewItemButton from "metabase/nav/components/NewItemButton"; import PathBreadcrumbs from "../components/PathBreadcrumbs/PathBreadcrumbs"; import { State } from "metabase-types/store"; @@ -150,7 +150,7 @@ function AppBar({ </SearchBarContent> </SearchBarContainer> )} - {isNewButtonVisible && <NewButton />} + {isNewButtonVisible && <NewItemButton />} </RightContainer> )} </AppBarRoot> diff --git a/frontend/src/metabase/nav/containers/NewButton/NewButton.tsx b/frontend/src/metabase/nav/containers/NewButton/NewButton.tsx deleted file mode 100644 index 4df5e973e40..00000000000 --- a/frontend/src/metabase/nav/containers/NewButton/NewButton.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import React, { useCallback, useMemo, useState } from "react"; -import { t } from "ttag"; -import _ from "underscore"; -import { connect } from "react-redux"; -import { push } from "react-router-redux"; -import { LocationDescriptor } from "history"; - -import Modal from "metabase/components/Modal"; -import CreateDashboardModal from "metabase/components/CreateDashboardModal"; - -import { Collection } from "metabase-types/api"; -import { State } from "metabase-types/store"; - -import CollectionCreate from "metabase/collections/containers/CollectionCreate"; - -import { closeNavbar } from "metabase/redux/app"; -import * as Urls from "metabase/lib/urls"; -import { - getHasDataAccess, - getHasNativeWrite, - getHasDbWithJsonEngine, -} from "metabase/new_query/selectors"; - -import { Menu, StyledButton, Title } from "./NewButton.styled"; - -type NewButtonModal = "MODAL_NEW_DASHBOARD" | "MODAL_NEW_COLLECTION" | null; - -const MODAL_NEW_DASHBOARD: NewButtonModal = "MODAL_NEW_DASHBOARD"; -const MODAL_NEW_COLLECTION: NewButtonModal = "MODAL_NEW_COLLECTION"; - -interface NewButtonStateProps { - hasDataAccess: boolean; - hasNativeWrite: boolean; - hasDbWithJsonEngine: boolean; -} - -interface NewButtonDispatchProps { - onChangeLocation: (nextLocation: LocationDescriptor) => void; - closeNavbar: () => void; -} - -interface NewButtonProps extends NewButtonStateProps, NewButtonDispatchProps {} - -const mapStateToProps: (state: State) => NewButtonStateProps = state => ({ - hasDataAccess: getHasDataAccess(state), - hasNativeWrite: getHasNativeWrite(state), - hasDbWithJsonEngine: getHasDbWithJsonEngine(state), -}); - -const mapDispatchToProps = { - onChangeLocation: push, - closeNavbar, -}; - -function NewButton({ - hasDataAccess, - hasNativeWrite, - hasDbWithJsonEngine, - onChangeLocation, - closeNavbar, -}: NewButtonProps) { - const [modal, setModal] = useState<NewButtonModal>(null); - - const closeModal = useCallback(() => setModal(null), []); - - const renderModalContent = useCallback(() => { - if (modal === MODAL_NEW_COLLECTION) { - return ( - <CollectionCreate - onClose={closeModal} - onSaved={(collection: Collection) => { - closeModal(); - onChangeLocation(Urls.collection(collection)); - }} - /> - ); - } - if (modal === MODAL_NEW_DASHBOARD) { - return <CreateDashboardModal onClose={closeModal} />; - } - return null; - }, [modal, closeModal, onChangeLocation]); - - const menuItems = useMemo(() => { - const items = []; - - if (hasDataAccess) { - items.push({ - title: t`Question`, - icon: "insight", - link: Urls.newQuestion({ - mode: "notebook", - creationType: "custom_question", - }), - event: "NavBar;New Question Click;", - onClose: closeNavbar, - }); - } - - if (hasNativeWrite) { - items.push({ - title: hasDbWithJsonEngine ? t`Native query` : t`SQL query`, - icon: "sql", - link: Urls.newQuestion({ - type: "native", - creationType: "native_question", - }), - event: "NavBar;New SQL Query Click;", - onClose: closeNavbar, - }); - } - - items.push( - { - title: t`Dashboard`, - icon: "dashboard", - action: () => setModal(MODAL_NEW_DASHBOARD), - event: "NavBar;New Dashboard Click;", - }, - { - title: t`Collection`, - icon: "folder", - action: () => setModal(MODAL_NEW_COLLECTION), - event: "NavBar;New Collection Click;", - }, - ); - - return items; - }, [ - hasDataAccess, - hasNativeWrite, - hasDbWithJsonEngine, - closeNavbar, - setModal, - ]); - - return ( - <> - <Menu - trigger={ - <StyledButton - primary - icon="add" - iconSize={14} - data-metabase-event="NavBar;Create Menu Click" - > - <Title>{t`New`}</Title> - </StyledButton> - } - items={menuItems} - /> - {modal && <Modal onClose={closeModal}>{renderModalContent()}</Modal>} - </> - ); -} - -export default _.compose( - connect<NewButtonStateProps, NewButtonDispatchProps, unknown, State>( - mapStateToProps, - mapDispatchToProps, - ), -)(NewButton); diff --git a/frontend/src/metabase/nav/containers/NewButton/index.ts b/frontend/src/metabase/nav/containers/NewButton/index.ts deleted file mode 100644 index 8206a487828..00000000000 --- a/frontend/src/metabase/nav/containers/NewButton/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./NewButton"; diff --git a/frontend/src/metabase/nav/selectors.ts b/frontend/src/metabase/nav/selectors.ts new file mode 100644 index 00000000000..6948ea6cc3e --- /dev/null +++ b/frontend/src/metabase/nav/selectors.ts @@ -0,0 +1,25 @@ +import { createSelector } from "reselect"; +import { getEngineNativeType } from "metabase/lib/engine"; +import { State } from "metabase-types/store"; + +const getDatabaseList = createSelector( + (state: State) => state.entities.databases, + databases => (databases ? Object.values(databases) : []), +); + +export const getHasDataAccess = createSelector([getDatabaseList], databases => + databases.some(d => !d.is_saved_questions), +); + +export const getHasOwnDatabase = createSelector([getDatabaseList], databases => + databases.some(d => !d.is_sample && !d.is_saved_questions), +); + +export const getHasNativeWrite = createSelector([getDatabaseList], databases => + databases.some(d => d.native_permissions === "write"), +); + +export const getHasDatabaseWithJsonEngine = createSelector( + [getDatabaseList], + databases => databases.some(d => getEngineNativeType(d.engine) === "json"), +); diff --git a/frontend/src/metabase/routes.jsx b/frontend/src/metabase/routes.jsx index 212c8f4a9b7..8c69e758bbe 100644 --- a/frontend/src/metabase/routes.jsx +++ b/frontend/src/metabase/routes.jsx @@ -84,7 +84,7 @@ import DashboardDetailsModal from "metabase/dashboard/components/DashboardDetail import { ModalRoute } from "metabase/hoc/ModalRoute"; import HomePage from "metabase/home/homepage/containers/HomePage"; -import CollectionLanding from "metabase/components/CollectionLanding/CollectionLanding"; +import CollectionLanding from "metabase/collections/components/CollectionLanding"; import ArchiveApp from "metabase/home/containers/ArchiveApp"; import SearchApp from "metabase/home/containers/SearchApp"; -- GitLab