Compare commits

...

6 Commits

Author SHA1 Message Date
Denis Berezin
a8acad0e43 Temp 2024-12-18 20:23:06 +03:00
Denis Berezin
b707c21282 Temp 2024-12-18 20:23:06 +03:00
Denis Berezin
04517f982f Fix lint 2024-12-18 20:23:04 +03:00
Denis Berezin
c1bcf0b1eb Finish embedding actions migration to rest menu for q and d 2024-12-18 20:22:34 +03:00
Denis Berezin
c9390e6d22 WIP: Moving embedding menu items to extra actions menu 2024-12-18 20:22:34 +03:00
Denis Berezin
ee635244e7 Remove weird illustration, add accordion structure for channels 2024-12-18 20:22:32 +03:00
73 changed files with 800 additions and 678 deletions

View File

@@ -1,5 +1,6 @@
export const sharingMenuButton = () => cy.findByTestId("sharing-menu-button");
export const sharingMenu = () => cy.findByTestId("sharing-menu");
export const sharingMenuButton = () =>
cy.findByTestId("notifications-menu-button");
export const sharingMenu = () => cy.findByTestId("notifications-menu");
export const openSharingMenu = (menuItemText?: string) => {
sharingMenuButton().click();

View File

@@ -461,7 +461,7 @@ describe(
it("should allow you to enable a webhook alert", () => {
H.visitQuestion(ORDERS_COUNT_QUESTION_ID);
cy.findByTestId("sharing-menu-button").click();
cy.findByTestId("notifications-menu-button").click();
H.popover().findByText("Create alert").click();
H.modal().button("Set up an alert").click();
H.modal().within(() => {
@@ -469,7 +469,7 @@ describe(
H.toggleAlertChannel(secondWebhookName);
cy.button("Done").click();
});
cy.findByTestId("sharing-menu-button").click();
cy.findByTestId("notifications-menu-button").click();
H.popover().findByText("Edit alerts").click();
H.popover().within(() => {
cy.findByText("You set up an alert").should("exist");
@@ -486,7 +486,7 @@ describe(
it("should allow you to test a webhook", () => {
H.visitQuestion(ORDERS_COUNT_QUESTION_ID);
cy.findByTestId("sharing-menu-button").click();
cy.findByTestId("notifications-menu-button").click();
H.popover().findByText("Create alert").click();
H.modal().button("Set up an alert").click();
H.modal().within(() => {

View File

@@ -283,7 +283,9 @@ describe("DashboardHeaderButtonRow", () => {
it("should show sharing button", () => {
setup({ isEditing: false });
expect(screen.getByTestId("sharing-menu-button")).toBeInTheDocument();
expect(
screen.getByTestId("notifications-menu-button"),
).toBeInTheDocument();
});
it("should not show editing-related buttons", () => {

View File

@@ -1,4 +1,4 @@
import { DashboardSharingMenu } from "metabase/sharing/components/SharingMenu";
import { DashboardNotificationsMenu } from "metabase/notifications/NotificationsActionsMenu";
import { Center, Divider } from "metabase/ui";
import { DashboardBookmark } from "../../DashboardBookmark";
@@ -87,7 +87,7 @@ export const dashboardActionButtons: Record<
!isFullscreen && !isEditing && canEdit,
},
[DASHBOARD_ACTION.DASHBOARD_SHARING]: {
component: DashboardSharingMenu,
component: DashboardNotificationsMenu,
enabled: ({ isEditing }) => !isEditing,
},
[DASHBOARD_ACTION.REFRESH_WIDGET]: {

View File

@@ -0,0 +1,50 @@
import { type Ref, forwardRef } from "react";
import { DashboardSharingEmbeddingModal } from "metabase/dashboard/containers/DashboardSharingEmbeddingModal";
import type { SharingModalType } from "metabase/notifications/NotificationsActionsMenu/types";
import { DashboardPublicLinkPopover } from "metabase/sharing/components/PublicLinkPopover";
import { Box } from "metabase/ui";
import type { Dashboard } from "metabase-types/api";
const MenuTarget = forwardRef(function _MenuTarget(
_props,
ref: Ref<HTMLDivElement>,
) {
return <Box h="2rem" ref={ref} />;
});
type SharingModalProps = {
modalType: SharingModalType | null;
dashboard: Dashboard;
onClose: () => void;
};
export const EmbeddingModals = ({
modalType,
onClose,
dashboard,
}: SharingModalProps) => {
if (modalType === "dashboard-public-link") {
return (
<DashboardPublicLinkPopover
dashboard={dashboard}
target={<MenuTarget />}
onClose={onClose}
isOpen
/>
);
}
if (modalType === "dashboard-embed" && dashboard) {
return (
<DashboardSharingEmbeddingModal
key="dashboard-embed"
dashboard={dashboard}
onClose={onClose}
isOpen
/>
);
}
return null;
};

View File

@@ -3,11 +3,17 @@ import { withRouter } from "react-router";
import type { WithRouterProps } from "react-router/lib/withRouter";
import { c, t } from "ttag";
import { isInstanceAnalyticsCollection } from "metabase/collections/utils";
import Button from "metabase/core/components/Button";
import Tooltip from "metabase/core/components/Tooltip";
import type { HeaderButtonProps } from "metabase/dashboard/components/DashboardHeader/DashboardHeaderButtonRow/types";
import { EmbeddingModals } from "metabase/dashboard/components/DashboardHeader/EmbeddingModals";
import { useRefreshDashboard } from "metabase/dashboard/hooks";
import type { SharingModalType } from "metabase/notifications/NotificationsActionsMenu/types";
import { PLUGIN_MODERATION } from "metabase/plugins";
import { EmbedMenuItem } from "metabase/sharing/components/SharingMenu/MenuItems/EmbedMenuItem";
import { ExportPdfMenuItem } from "metabase/sharing/components/SharingMenu/MenuItems/ExportPdfMenuItem";
import { PublicLinkMenuItem } from "metabase/sharing/components/SharingMenu/MenuItems/PublicLinkMenuItem";
import { Icon, Menu } from "metabase/ui";
const DashboardActionMenuInner = ({
@@ -22,6 +28,8 @@ const DashboardActionMenuInner = ({
}: HeaderButtonProps & WithRouterProps): JSX.Element => {
const [opened, setOpened] = useState(false);
const [modalType, setModalType] = useState<SharingModalType | null>(null);
const { refreshDashboard } = useRefreshDashboard({
dashboardId: dashboard.id,
parameterQueryParams: location.query,
@@ -33,7 +41,15 @@ const DashboardActionMenuInner = ({
refreshDashboard,
);
const hasPublicLink = !!dashboard?.public_uuid;
const isArchived = dashboard.archived;
const isAnalytics =
dashboard.collection && isInstanceAnalyticsCollection(dashboard.collection);
const canShare = !isArchived && !isAnalytics;
return (
<>
<Menu position="bottom-end" opened={opened} onChange={setOpened}>
<Menu.Target>
<div>
@@ -75,6 +91,19 @@ const DashboardActionMenuInner = ({
</>
)}
<ExportPdfMenuItem dashboard={dashboard} />
{canShare && (
<>
<Menu.Divider />
<PublicLinkMenuItem
hasPublicLink={hasPublicLink}
onClick={() => setModalType("dashboard-public-link")}
/>
<EmbedMenuItem onClick={() => setModalType("dashboard-embed")} />
</>
)}
{canEdit && (
<>
<Menu.Divider />
@@ -105,6 +134,12 @@ const DashboardActionMenuInner = ({
)}
</Menu.Dropdown>
</Menu>
<EmbeddingModals
modalType={modalType}
dashboard={dashboard}
onClose={() => setModalType(null)}
/>
</>
);
};

View File

@@ -1,98 +0,0 @@
import { type Ref, forwardRef, useState } from "react";
import { useMount } from "react-use";
import { useListCardAlertsQuery } from "metabase/api";
import Modal from "metabase/components/Modal";
import { Popover } from "metabase/ui";
import type Question from "metabase-lib/v1/Question";
import type { Alert } from "metabase-types/api";
import {
CreateAlertModalContent,
UpdateAlertModalContent,
} from "../AlertModals";
import { AlertListPopoverContent } from "./AlertListPopoverContent";
type AlertModalMode = "popover" | "create-modal" | "update-modal";
export const AlertPopover = forwardRef(function _AlertPopover(
{
question,
target,
onClose,
}: {
question: Question;
target: JSX.Element;
onClose: () => void;
},
ref: Ref<HTMLDivElement>,
) {
const [showingElement, setShowingElement] =
useState<AlertModalMode>("popover");
const [editingAlert, setEditingAlert] = useState<Alert | null>(null);
const { data: questionAlerts } = useListCardAlertsQuery({
id: question.id(),
});
useMount(() => {
// if there are no alerts when the component mounts, show the create modal
if (questionAlerts && questionAlerts.length === 0) {
setShowingElement("create-modal");
}
});
if (!question.isSaved()) {
return null;
}
return (
<>
<Popover
opened={showingElement === "popover"}
onClose={() => {
// only close if we're not showing a modal
if (showingElement === "popover") {
onClose();
}
}}
position="bottom-end"
>
<Popover.Target ref={ref}>{target}</Popover.Target>
<Popover.Dropdown>
<AlertListPopoverContent
questionAlerts={questionAlerts}
onCreate={() => setShowingElement("create-modal")}
onEdit={(alert: Alert) => {
setEditingAlert(alert);
setShowingElement("update-modal");
}}
onClose={onClose}
/>
</Popover.Dropdown>
</Popover>
<Modal
medium
isOpen={showingElement === "create-modal"}
onClose={onClose}
>
<CreateAlertModalContent onCancel={onClose} onAlertCreated={onClose} />
</Modal>
<Modal
medium
isOpen={showingElement === "update-modal"}
onClose={onClose}
>
{editingAlert && (
<UpdateAlertModalContent
alert={editingAlert}
onCancel={onClose}
onAlertUpdated={onClose}
/>
)}
</Modal>
</>
);
});

View File

@@ -1,4 +0,0 @@
export { AlertCreatorTitle } from "./AlertCreatorTitle";
export { AlertScheduleText } from "./AlertScheduleText";
export { AlertListItem } from "./AlertListItem";
export { AlertListPopoverContent } from "./AlertListPopoverContent";

View File

@@ -1,72 +0,0 @@
/* eslint-disable react/prop-types */
import cx from "classnames";
import { Component } from "react";
import { jt, t } from "ttag";
import _ from "underscore";
import CS from "metabase/css/core/index.css";
import Users from "metabase/entities/users";
import { connect } from "metabase/lib/redux";
import { fetchPulseFormInput } from "metabase/notifications/pulse/actions";
import { PulseEditChannels } from "metabase/notifications/pulse/components/PulseEditChannels";
import { getPulseFormInput } from "metabase/notifications/pulse/selectors";
import { getUser } from "metabase/selectors/user";
import { getScheduleFromChannel } from "./schedule";
class AlertEditChannelsInner extends Component {
componentDidMount() {
this.props.fetchPulseFormInput();
}
// Technically pulse definition is equal to alert definition
onSetPulse = alert => {
// If the pulse channel has been added, it PulseEditChannels puts the default schedule to it
// We want to have same schedule for all channels
const schedule = getScheduleFromChannel(
alert.channels.find(c => c.channel_type === "email"),
);
this.props.onAlertChange({
...alert,
channels: alert.channels.map(channel => ({ ...channel, ...schedule })),
});
};
render() {
const { alert, user, users, formInput } = this.props;
return (
<div className={cx(CS.mt4, CS.pt2)}>
<h3
className={cx(CS.textDark, CS.mb3)}
>{jt`Where do you want to send these alerts?`}</h3>
<div className={CS.mb2}>
<PulseEditChannels
pulse={alert}
formInput={formInput}
user={user}
users={users}
setPulse={this.onSetPulse}
emailRecipientText={t`Email alerts to:`}
invalidRecipientText={domains =>
t`You're only allowed to email alerts to addresses ending in ${domains}`
}
/>
</div>
</div>
);
}
}
export const AlertEditChannels = _.compose(
Users.loadList(),
connect(
(state, props) => ({
user: getUser(state),
formInput: getPulseFormInput(state),
}),
{
fetchPulseFormInput,
},
),
)(AlertEditChannelsInner);

View File

@@ -1,18 +0,0 @@
/* eslint-disable react/prop-types */
import cx from "classnames";
import CS from "metabase/css/core/index.css";
export const AlertModalTitle = ({ text }) => (
<div className={cx(CS.mlAuto, CS.mrAuto, CS.my4, CS.pb2, CS.textCentered)}>
<img
className={CS.mb3}
src="app/assets/img/alerts/alert-bell-confetti-illustration.png"
srcSet="
app/assets/img/alerts/alert-bell-confetti-illustration.png 1x,
app/assets/img/alerts/alert-bell-confetti-illustration@2x.png 2x
"
/>
<h1 className={CS.textDark}>{text}</h1>
</div>
);

View File

@@ -0,0 +1,54 @@
import { useState } from "react";
import { setSharing as setDashboardSubscriptionSidebarOpen } from "metabase/dashboard/actions";
import { getIsSharing as getIsDashboardSubscriptionSidebarOpen } from "metabase/dashboard/selectors";
import { useDispatch, useSelector } from "metabase/lib/redux";
import type { DashboardNotificationsModalType } from "metabase/notifications/types";
import { Flex } from "metabase/ui";
import type { Dashboard } from "metabase-types/api";
import { DashboardSubscriptionMenuItem } from "../../sharing/components/SharingMenu/MenuItems/DashboardSubscriptionMenuItem";
import { SharingMenu } from "./SharingMenu";
import { SharingModals } from "./SharingModals";
export function DashboardNotificationsMenu({
dashboard,
}: {
dashboard: Dashboard;
}) {
const dispatch = useDispatch();
const isDashboardSubscriptionSidebarOpen = useSelector(
getIsDashboardSubscriptionSidebarOpen,
);
const toggleSubscriptionSidebar = () =>
dispatch(
setDashboardSubscriptionSidebarOpen(!isDashboardSubscriptionSidebarOpen),
);
const [modalType, setModalType] =
useState<DashboardNotificationsModalType | null>(null);
const isArchived = dashboard.archived;
if (isArchived) {
return null;
}
return (
<Flex>
<SharingMenu>
<DashboardSubscriptionMenuItem
onClick={toggleSubscriptionSidebar}
dashboard={dashboard}
/>
</SharingMenu>
<SharingModals
modalType={modalType}
dashboard={dashboard}
onClose={() => setModalType(null)}
/>
</Flex>
);
}

View File

@@ -2,37 +2,26 @@ import { useState } from "react";
import { t } from "ttag";
import { isInstanceAnalyticsCollection } from "metabase/collections/utils";
import { useSetting } from "metabase/common/hooks";
import { useDispatch, useSelector } from "metabase/lib/redux";
import { useDispatch } from "metabase/lib/redux";
import { QuestionAlertsMenuItem } from "metabase/notifications/QuestionAlertsMenuItem";
import { QuestionSubscriptionsMenuItem } from "metabase/notifications/QuestionSubscriptionsMenuItem";
import type { QuestionNotificationsModalType } from "metabase/notifications/types";
import { setUIControls } from "metabase/query_builder/actions";
import { MODAL_TYPES } from "metabase/query_builder/constants";
import {
canManageSubscriptions as canManageSubscriptionsSelector,
getUserIsAdmin,
} from "metabase/selectors/user";
import { Flex, Menu } from "metabase/ui";
import { Flex } from "metabase/ui";
import type Question from "metabase-lib/v1/Question";
import { EmbedMenuItem } from "./MenuItems/EmbedMenuItem";
import { PublicLinkMenuItem } from "./MenuItems/PublicLinkMenuItem";
import { SharingButton, SharingMenu } from "./SharingMenu";
import { SharingModals } from "./SharingModals";
import type { QuestionSharingModalType } from "./types";
export function QuestionSharingMenu({ question }: { question: Question }) {
const dispatch = useDispatch();
const [modalType, setModalType] = useState<QuestionSharingModalType | null>(
null,
);
const hasPublicLink = !!question?.publicUUID?.();
const [modalType, setModalType] =
useState<QuestionNotificationsModalType | null>(null);
const isModel = question.type() === "model";
const isArchived = question.isArchived();
const isPublicSharingEnabled = useSetting("enable-public-sharing");
const isAdmin = useSelector(getUserIsAdmin);
const collection = question.collection();
const isAnalytics = collection && isInstanceAnalyticsCollection(collection);
const canManageSubscriptions = useSelector(canManageSubscriptionsSelector);
if (isModel || isArchived || isAnalytics) {
return null;
@@ -53,35 +42,6 @@ export function QuestionSharingMenu({ question }: { question: Question }) {
);
}
if (
!isAdmin &&
(!isPublicSharingEnabled || !hasPublicLink) &&
!canManageSubscriptions
) {
return (
<SharingButton
tooltip={t`Ask your admin to create a public link`}
disabled
/>
);
}
if (!isAdmin && hasPublicLink && !canManageSubscriptions) {
return (
<Flex>
<SharingButton
tooltip={t`Public link`}
onClick={() => setModalType("question-public-link")}
/>
<SharingModals
modalType={modalType}
question={question}
onClose={() => setModalType(null)}
/>
</Flex>
);
}
return (
<Flex>
<SharingMenu>
@@ -89,13 +49,10 @@ export function QuestionSharingMenu({ question }: { question: Question }) {
question={question}
onClick={() => setModalType("question-alert")}
/>
<Menu.Divider />
<PublicLinkMenuItem
hasPublicLink={hasPublicLink}
onClick={() => setModalType("question-public-link")}
<QuestionSubscriptionsMenuItem
question={question}
onClick={() => setModalType("question-subscription")}
/>
<EmbedMenuItem onClick={() => setModalType("question-embed")} />
</SharingMenu>
<SharingModals
modalType={modalType}

View File

@@ -4,6 +4,7 @@ import { t } from "ttag";
import { ToolbarButton } from "metabase/components/ToolbarButton";
import { Menu } from "metabase/ui";
// TODO: rename to notifications
export function SharingMenu({
children,
tooltip,
@@ -17,14 +18,14 @@ export function SharingMenu({
<Menu withinPortal position="bottom-end">
<Menu.Target>
<ToolbarButton
icon="share"
data-testid="sharing-menu-button"
tooltipLabel={tooltip ?? t`Sharing`}
aria-label={tooltip ?? t`Sharing`}
icon="alert"
data-testid="notifications-menu-button"
tooltipLabel={tooltip ?? t`Notifications`}
aria-label={tooltip ?? t`Notifications`}
disabled={hasNoChildren}
/>
</Menu.Target>
<Menu.Dropdown data-testid="sharing-menu">{children}</Menu.Dropdown>
<Menu.Dropdown data-testid="notifications-menu">{children}</Menu.Dropdown>
</Menu>
);
}
@@ -45,7 +46,7 @@ export const SharingButton = forwardRef(function _SharingButton(
<ToolbarButton
ref={ref}
icon="share"
data-testid="sharing-menu-button"
data-testid="notifications-menu-button"
tooltipLabel={tooltip ?? t`Sharing`}
aria-label={tooltip ?? t`Sharing`}
onClick={onClick}

View File

@@ -0,0 +1,71 @@
import { AlertListModal } from "metabase/notifications/modals/AlertListModal/AlertListModal";
import type {
DashboardNotificationsModalType,
QuestionNotificationsModalType,
} from "metabase/notifications/types";
import type Question from "metabase-lib/v1/Question";
import type { Dashboard } from "metabase-types/api";
type BaseSharingModalProps = {
onClose: () => void;
};
type SharingModalProps = BaseSharingModalProps &
(
| {
modalType: QuestionNotificationsModalType;
question: Question;
dashboard?: never;
}
| {
modalType: DashboardNotificationsModalType;
dashboard: Dashboard;
question?: never;
}
| {
modalType: null;
question?: Question;
dashboard?: Dashboard;
}
);
export const SharingModals = ({
modalType,
onClose,
question,
// dashboard,
}: SharingModalProps) => {
if (modalType === "question-alert") {
return <AlertListModal question={question} onClose={onClose} />;
}
if (modalType === "question-subscription") {
return <AlertListModal question={question} onClose={onClose} />;
}
// TODO: add Dashboard modal
// if (modalType === "dashboard-public-link") {
// return (
// <DashboardPublicLinkPopover
// dashboard={dashboard}
// target={<MenuTarget />}
// onClose={onClose}
// isOpen
// />
// );
// }
//
// if (modalType === "dashboard-embed" && dashboard) {
// return (
// <DashboardSharingEmbeddingModal
// key="dashboard-embed"
// dashboard={dashboard}
// onClose={onClose}
// isOpen
// />
// );
// }
return null;
};

View File

@@ -0,0 +1,2 @@
export * from "./DashboardNotificationsMenu";
export * from "./QuestionSharingMenu";

View File

@@ -13,7 +13,7 @@ describe("DashboardSharingMenu", () => {
setupDashboardSharingMenu({
isAdmin: true,
});
expect(screen.getByTestId("sharing-menu-button")).toHaveAttribute(
expect(screen.getByTestId("notifications-menu-button")).toHaveAttribute(
"aria-label",
"Sharing",
);
@@ -25,7 +25,9 @@ describe("DashboardSharingMenu", () => {
dashboard: { archived: true },
});
expect(screen.queryByTestId("sharing-menu-button")).not.toBeInTheDocument();
expect(
screen.queryByTestId("notifications-menu-button"),
).not.toBeInTheDocument();
});
describe("dashboard subscriptions", () => {

View File

@@ -35,7 +35,7 @@ describe("QuestionSharingMenu > Enterprise", () => {
hasPublicLink: true,
isEnterprise: true,
});
const sharingButton = screen.getByTestId("sharing-menu-button");
const sharingButton = screen.getByTestId("notifications-menu-button");
expect(sharingButton).toBeEnabled();
expect(sharingButton).toHaveAttribute("aria-label", "Public link");
@@ -50,7 +50,7 @@ describe("QuestionSharingMenu > Enterprise", () => {
isEnterprise: true,
});
await userEvent.click(screen.getByTestId("sharing-menu-button"));
await userEvent.click(screen.getByTestId("notifications-menu-button"));
expect(
screen.getByTestId("public-link-popover-content"),
@@ -67,7 +67,7 @@ describe("QuestionSharingMenu > Enterprise", () => {
canManageSubscriptions: false,
isEnterprise: true,
});
const sharingButton = screen.getByTestId("sharing-menu-button");
const sharingButton = screen.getByTestId("notifications-menu-button");
expect(sharingButton).toBeDisabled();
expect(sharingButton).toHaveAttribute(
@@ -83,7 +83,7 @@ describe("QuestionSharingMenu > Enterprise", () => {
canManageSubscriptions: false,
hasPublicLink: false,
});
const sharingButton = screen.getByTestId("sharing-menu-button");
const sharingButton = screen.getByTestId("notifications-menu-button");
expect(sharingButton).toBeDisabled();
expect(sharingButton).toHaveAttribute(
@@ -108,6 +108,8 @@ describe("QuestionSharingMenu > Enterprise", () => {
}),
},
});
expect(screen.queryByTestId("sharing-menu-button")).not.toBeInTheDocument();
expect(
screen.queryByTestId("notifications-menu-button"),
).not.toBeInTheDocument();
});
});

View File

@@ -10,14 +10,16 @@ describe("QuestionSharingMenu", () => {
setupQuestionSharingMenu({
question: { type: "model" },
});
expect(screen.queryByTestId("sharing-menu-button")).not.toBeInTheDocument();
expect(
screen.queryByTestId("notifications-menu-button"),
).not.toBeInTheDocument();
});
it("should have a 'sharing' tooltip by default", () => {
setupQuestionSharingMenu({
isAdmin: true,
});
expect(screen.getByTestId("sharing-menu-button")).toHaveAttribute(
expect(screen.getByTestId("notifications-menu-button")).toHaveAttribute(
"aria-label",
"Sharing",
);
@@ -29,7 +31,9 @@ describe("QuestionSharingMenu", () => {
question: { archived: true },
});
expect(screen.queryByTestId("sharing-menu-button")).not.toBeInTheDocument();
expect(
screen.queryByTestId("notifications-menu-button"),
).not.toBeInTheDocument();
});
it("should prompt you to save an unsaved question", async () => {
@@ -38,7 +42,7 @@ describe("QuestionSharingMenu", () => {
question: { id: undefined },
});
expect(screen.getByTestId("sharing-menu-button")).toHaveAttribute(
expect(screen.getByTestId("notifications-menu-button")).toHaveAttribute(
"aria-label",
"You must save this question before sharing",
);

View File

@@ -27,7 +27,7 @@ import {
} from "metabase-types/api/mocks";
import { createMockState } from "metabase-types/store/mocks";
import { DashboardSharingMenu } from "../DashboardSharingMenu";
import { DashboardNotificationsMenu } from "../DashboardNotificationsMenu";
import { QuestionSharingMenu } from "../QuestionSharingMenu";
// This is a fake sidebar that we can use to check if the correct redux state is getting updated
@@ -142,7 +142,7 @@ export function setupDashboardSharingMenu({
renderWithProviders(
<div>
<DashboardSharingMenu dashboard={dashboard} />
<DashboardNotificationsMenu dashboard={dashboard} />
<FakeSidebar />
</div>,
{ storeInitialState: state },
@@ -199,5 +199,5 @@ export function setupQuestionSharingMenu({
}
export const openMenu = () => {
return userEvent.click(screen.getByTestId("sharing-menu-button"));
return userEvent.click(screen.getByTestId("notifications-menu-button"));
};

View File

@@ -3,13 +3,4 @@ export type PublicLinkModalType =
| "question-public-link"
| "dashboard-public-link";
export type QuestionSharingModalType =
| "question-public-link"
| "question-embed"
| "question-alert";
export type DashboardSharingModalType =
| "dashboard-public-link"
| "dashboard-embed";
export type SharingModalType = PublicLinkModalType | EmbedModalType;

View File

@@ -3,8 +3,14 @@ import { t } from "ttag";
import { skipToken, useListCardAlertsQuery } from "metabase/api";
import { useSelector } from "metabase/lib/redux";
import { CommonNotificationsMenuItem } from "metabase/notifications/CommonNotificationsMenuItem";
import { hasProperGoalForAlert } from "metabase/notifications/utils";
import { getVisualizationSettings } from "metabase/query_builder/selectors";
import { canManageSubscriptions as canManageSubscriptionsSelector } from "metabase/selectors/user";
import type Question from "metabase-lib/v1/Question";
import type { Alert } from "metabase-types/api";
const isAlert = (notification: Alert) =>
notification.alert_condition === "goal";
export function QuestionAlertsMenuItem({
question,
@@ -14,22 +20,29 @@ export function QuestionAlertsMenuItem({
onClick: () => void;
}) {
const canManageSubscriptions = useSelector(canManageSubscriptionsSelector);
const visualizationSettings = useSelector(getVisualizationSettings);
const { data: questionAlerts, isLoading } = useListCardAlertsQuery({
const { data: questionNotifications, isLoading } = useListCardAlertsQuery({
id: question.id() ?? skipToken,
});
const alerts = questionNotifications?.filter(isAlert);
const canAddAlertOnThisQuestion = hasProperGoalForAlert({
question,
visualizationSettings,
});
const showAlerts = question.canRun() && !isLoading && canManageSubscriptions;
if (!showAlerts) {
if (!showAlerts || !canAddAlertOnThisQuestion) {
return null;
}
const hasAlerts = !!questionAlerts?.length;
const hasAlerts = !!alerts?.length;
return (
<CommonNotificationsMenuItem
title={hasAlerts ? t`Edit alerts` : t`Create alert`}
title={hasAlerts ? t`Edit alerts` : t`Create alerts`}
iconName={hasAlerts ? "alert_filled" : "alert"}
onClick={onClick}
/>

View File

@@ -1,9 +1,14 @@
import { t } from "ttag";
import { skipToken, useListCardAlertsQuery } from "metabase/api";
import { useSelector } from "metabase/lib/redux";
import { CommonNotificationsMenuItem } from "metabase/notifications/CommonNotificationsMenuItem";
import { canManageSubscriptions as canManageSubscriptionsSelector } from "metabase/selectors/user";
import type Question from "metabase-lib/v1/Question";
import type { Alert } from "metabase-types/api";
const isSubscription = (notification: Alert) =>
notification.alert_condition === "rows";
export function QuestionSubscriptionsMenuItem({
question,
@@ -14,12 +19,11 @@ export function QuestionSubscriptionsMenuItem({
}) {
const canManageSubscriptions = useSelector(canManageSubscriptionsSelector);
// const { data: questionAlerts, isLoading } = useListCardAlertsQuery({
// id: question.id() ?? skipToken,
// });
const { data: questionNotifications, isLoading } = useListCardAlertsQuery({
id: question.id() ?? skipToken,
});
const questionSubscriptions = [];
const isLoading = false;
const subscriptions = questionNotifications?.filter(isSubscription);
const showAlerts = question.canRun() && !isLoading && canManageSubscriptions;
@@ -27,7 +31,7 @@ export function QuestionSubscriptionsMenuItem({
return null;
}
const hasSubscriptions = !!questionSubscriptions?.length;
const hasSubscriptions = !!subscriptions?.length;
return (
<CommonNotificationsMenuItem

View File

@@ -0,0 +1,95 @@
import cx from "classnames";
import type { JSX } from "react";
import { useMount } from "react-use";
import { t } from "ttag";
import _ from "underscore";
import CS from "metabase/css/core/index.css";
import Users from "metabase/entities/users";
import { connect } from "metabase/lib/redux";
import { fetchPulseFormInput } from "metabase/notifications/pulse/actions";
import { NotificationChannelsPicker } from "metabase/notifications/pulse/components/NotificationChannelsPicker";
import { getPulseFormInput } from "metabase/notifications/pulse/selectors";
import { getUser } from "metabase/selectors/user";
import type {
Alert,
Channel,
ChannelApiResponse,
User,
} from "metabase-types/api";
import type { State } from "metabase-types/store";
import { getScheduleFromChannel } from "./schedule";
type AlertEditChannelsProps = {
formInput: ChannelApiResponse | undefined | null;
user: User | null;
users: User[];
alert: Alert;
fetchPulseFormInput: () => Promise<ChannelApiResponse>;
onAlertChange: (alert: Alert) => void;
};
const AlertEditChannelsInner = ({
formInput,
user,
users,
alert,
fetchPulseFormInput,
onAlertChange,
}: AlertEditChannelsProps): JSX.Element | null => {
useMount(() => {
fetchPulseFormInput();
});
const onSetPulse = (alert: Alert) => {
// If the pulse channel has been added, it PulseEditChannels puts the default schedule to it
// We want to have same schedule for all channels
const schedule = getScheduleFromChannel(
alert.channels.find(c => c.channel_type === "email") as Channel, // TODO: remove typecast
);
onAlertChange({
...alert,
channels: alert.channels.map(channel => ({ ...channel, ...schedule })),
});
};
if (!user) {
return null;
}
return (
<div>
<h3
className={cx(CS.textDark, CS.mb3)}
>{t`Where do you want to be alerted?`}</h3>
<div className={CS.mb2}>
<NotificationChannelsPicker
pulse={alert}
channels={formInput?.channels}
user={user}
users={users}
setPulse={onSetPulse}
emailRecipientText={t`Email alerts to:`}
invalidRecipientText={domains =>
t`You're only allowed to email alerts to addresses ending in ${domains}`
}
/>
</div>
</div>
);
};
export const AlertEditChannels = _.compose(
Users.loadList(),
connect(
(state: State) => ({
user: getUser(state),
formInput: getPulseFormInput(state),
}),
{
fetchPulseFormInput,
},
),
)(AlertEditChannelsInner);

View File

@@ -3,6 +3,7 @@ import { Component } from "react";
import { connect } from "metabase/lib/redux";
import { getUserIsAdmin } from "metabase/selectors/user";
import { Flex } from "metabase/ui";
import { AlertEditChannels } from "./AlertEditChannels";
import { AlertEditSchedule } from "./AlertEditSchedule";
@@ -21,18 +22,23 @@ class AlertEditFormInner extends Component {
};
render() {
const { alertType, alert, isAdmin, onAlertChange } = this.props;
const { type, alertType, alert, isAdmin, onAlertChange } = this.props;
const isAlert = type === "alert";
// the schedule should be same for all channels so we can use the first one
const schedule = getScheduleFromChannel(alert.channels[0]);
return (
<div>
<AlertGoalToggles
alertType={alertType}
alert={alert}
onAlertChange={onAlertChange}
/>
<Flex gap={40} direction="column">
{isAlert && (
<AlertGoalToggles
alertType={alertType}
alert={alert}
onAlertChange={onAlertChange}
/>
)}
<AlertEditSchedule
alert={alert}
alertType={alertType}
@@ -42,7 +48,7 @@ class AlertEditFormInner extends Component {
{isAdmin && (
<AlertEditChannels alert={alert} onAlertChange={onAlertChange} />
)}
</div>
</Flex>
);
}
}

View File

@@ -33,11 +33,11 @@ export function AlertEditSchedule({
}) {
return (
<div>
<h3 className={cx(CS.mt4, CS.mb3, CS.textDark)}>
<h3 className={cx(CS.mb3, CS.textDark)}>
{t`How often should we check for results?`}
</h3>
<div className={cx(CS.bordered, CS.rounded, CS.mb2)}>
<div className={cx(CS.bordered, CS.rounded)}>
{alertType === ALERT_TYPE_ROWS && <RawDataAlertTip />}
<div className={cx(CS.p3, CS.bgLight)}>
<SchedulePicker

View File

@@ -1,22 +1,14 @@
/* eslint-disable react/prop-types */
import { t } from "ttag";
import {
ALERT_TYPE_PROGRESS_BAR_GOAL,
ALERT_TYPE_TIMESERIES_GOAL,
} from "metabase-lib/v1/Alert";
import { ALERT_TYPE_TIMESERIES_GOAL } from "metabase-lib/v1/Alert";
import { AlertAboveGoalToggle } from "./AlertAboveGoalToggle";
import { AlertFirstOnlyToggle } from "./AlertFirstOnlyToggle";
export const AlertGoalToggles = ({ alertType, alert, onAlertChange }) => {
// alertType can be only ALERT_TYPE_TIMESERIES_GOAL or ALERT_TYPE_PROGRESS_BAR_GOAL
const isTimeseries = alertType === ALERT_TYPE_TIMESERIES_GOAL;
const isProgress = alertType === ALERT_TYPE_PROGRESS_BAR_GOAL;
if (!isTimeseries && !isProgress) {
// not a goal alert
return null;
}
return (
<div>

View File

@@ -0,0 +1,66 @@
import { useState } from "react";
import { useListCardAlertsQuery } from "metabase/api";
import Modal from "metabase/components/Modal";
import type Question from "metabase-lib/v1/Question";
import type { Alert } from "metabase-types/api";
import { CreateAlertModalContent, UpdateAlertModalContent } from "../index";
import { AlertListModalContent } from "./AlertListModalContent";
type AlertModalMode = "list-modal" | "create-modal" | "update-modal";
export const AlertListModal = ({
question,
onClose,
}: {
question: Question;
onClose: () => void;
}) => {
const [showingElement, setShowingElement] =
useState<AlertModalMode>("list-modal");
const [editingAlert, setEditingAlert] = useState<Alert | null>(null);
const { data: questionAlerts } = useListCardAlertsQuery({
id: question.id(),
});
if (!question.isSaved()) {
return null;
}
return (
<>
<Modal isOpen={showingElement === "list-modal"} onClose={onClose}>
<AlertListModalContent
questionAlerts={questionAlerts}
onCreate={() => setShowingElement("create-modal")}
onEdit={(alert: Alert) => {
setEditingAlert(alert);
setShowingElement("update-modal");
}}
onClose={onClose}
/>
</Modal>
<Modal isOpen={showingElement === "create-modal"} onClose={onClose}>
<CreateAlertModalContent
type="alert"
onCancel={onClose}
onAlertCreated={onClose}
/>
</Modal>
<Modal isOpen={showingElement === "update-modal"} onClose={onClose}>
{editingAlert && (
<UpdateAlertModalContent
alert={editingAlert}
onCancel={onClose}
onAlertUpdated={onClose}
/>
)}
</Modal>
</>
);
};

View File

@@ -18,7 +18,7 @@ type AlertListPopoverContentProps = {
onClose: () => void;
};
export const AlertListPopoverContent = ({
export const AlertListModalContent = ({
questionAlerts,
onCreate,
onEdit,

View File

@@ -0,0 +1,2 @@
export { AlertListModal } from "./AlertListModal";
export { AlertListModalContent } from "./AlertListModalContent";

View File

@@ -12,7 +12,7 @@ export const AlertSettingToggle = ({
falseText,
setting,
}) => (
<div className={cx(CS.mb4, CS.pb2)}>
<div /*className={cx(CS.mb4, CS.pb2)}*/>
<h3 className={cx(CS.textDark, CS.mb1)}>{title}</h3>
<Radio
value={alert[setting]}

View File

@@ -6,7 +6,7 @@ import { jt, t } from "ttag";
import Button from "metabase/core/components/Button";
import CS from "metabase/css/core/index.css";
import AlertModalsS from "./AlertModals.module.css";
import AlertModalsS from "../AlertModals.module.css";
export class AlertEducationalScreen extends Component {
render() {

View File

@@ -4,7 +4,6 @@ import { t } from "ttag";
import { useGetChannelInfoQuery } from "metabase/api";
import ButtonWithStatus from "metabase/components/ButtonWithStatus";
import ChannelSetupModal from "metabase/components/ChannelSetupModal";
import ModalContent from "metabase/components/ModalContent";
import Button from "metabase/core/components/Button";
import CS from "metabase/css/core/index.css";
@@ -22,21 +21,23 @@ import {
getVisualizationSettings,
} from "metabase/query_builder/selectors";
import { getUser, getUserIsAdmin } from "metabase/selectors/user";
import { Flex } from "metabase/ui";
import { Flex, Icon } from "metabase/ui";
import { getDefaultAlert } from "metabase-lib/v1/Alert";
import type { Alert } from "metabase-types/api";
import { AlertEditForm } from "./AlertEditForm";
import { AlertEditForm } from "../AlertEditForm";
import { AlertEducationalScreen } from "./AlertEducationalScreen";
import { AlertModalTitle } from "./AlertModalTitle";
import AlertModalsS from "./AlertModals.module.css";
import ChannelSetupModal from "./ChannelSetupModal";
interface CreateAlertModalContentProps {
type: "alert" | "subscription";
onAlertCreated: () => void;
onCancel: () => void;
}
export const CreateAlertModalContent = ({
type,
onAlertCreated,
onCancel,
}: CreateAlertModalContentProps) => {
@@ -109,20 +110,18 @@ export const CreateAlertModalContent = ({
);
}
// TODO: Remove PulseEdit css hack
return (
<ModalContent data-testid="alert-create" onClose={onCancel}>
<div
className={cx(CS.mlAuto, CS.mrAuto, CS.mb4)}
style={{ maxWidth: "550px" }}
>
<AlertModalTitle text={t`Let's set up your alert`} />
<AlertEditForm
alertType={question?.alertType(visualizationSettings)}
alert={alert}
onAlertChange={onAlertChange}
/>
<Flex className={AlertModalsS.AlertModalsFooter}>
<ModalContent
data-testid="alert-create"
title={
<Flex gap={10} align="center">
<Icon name="alert" />
{t`Create new alert`}
</Flex>
}
footer={
<Flex justify="space-between" w="100%">
{/*<span>{"Send test message now"}</span>*/}
<Button onClick={onCancel} className={CS.mr2}>{t`Cancel`}</Button>
<ButtonWithStatus
titleForState={{ default: t`Done` }}
@@ -130,6 +129,16 @@ export const CreateAlertModalContent = ({
onClickOperation={onCreateAlert}
/>
</Flex>
}
onClose={onCancel}
>
<div className={cx(CS.borderBottom)}>
<AlertEditForm
type={type}
alertType={question?.alertType(visualizationSettings)}
alert={alert}
onAlertChange={onAlertChange}
/>
</div>
</ModalContent>
);

View File

@@ -15,12 +15,12 @@ import {
getQuestion,
getVisualizationSettings,
} from "metabase/query_builder/selectors";
import { getUser, getUserIsAdmin } from "metabase/selectors/user";
import { getUserIsAdmin } from "metabase/selectors/user";
import { Flex } from "metabase/ui";
import type { Alert } from "metabase-types/api";
import { AlertEditForm } from "./AlertEditForm";
import { AlertModalTitle } from "./AlertModalTitle";
// import { AlertModalTitle } from "./AlertModalTitle";
import AlertModalsS from "./AlertModals.module.css";
import { DeleteAlertSection } from "./DeleteAlertSection";
@@ -37,7 +37,7 @@ export const UpdateAlertModalContent = ({
}: UpdateAlertModalContentProps) => {
const dispatch = useDispatch();
const user = useSelector(getUser);
// const user = useSelector(getUser);
const isAdmin = useSelector(getUserIsAdmin);
const question = useSelector(getQuestion);
const visualizationSettings = useSelector(getVisualizationSettings);
@@ -59,8 +59,8 @@ export const UpdateAlertModalContent = ({
onAlertUpdated();
};
const isCurrentUser = alert?.creator.id === user?.id;
const title = isCurrentUser ? t`Edit your alert` : t`Edit alert`;
// const isCurrentUser = alert?.creator.id === user?.id;
// const title = isCurrentUser ? t`Edit your alert` : t`Edit alert`;
const isValid = alertIsValid(modifiedAlert, channelSpec);
// TODO: Remove PulseEdit css hack
@@ -70,7 +70,7 @@ export const UpdateAlertModalContent = ({
className={cx(CS.mlAuto, CS.mrAuto, CS.mb4)}
style={{ maxWidth: "550px" }}
>
<AlertModalTitle text={title} />
{/*<AlertModalTitle text={title} />*/}
<AlertEditForm
alertType={question?.alertType(visualizationSettings)}
alert={modifiedAlert}

View File

@@ -1,7 +1,6 @@
export { CreateAlertModalContent } from "./CreateAlertModalContent";
export { CreateAlertModalContent } from "./CreateAlertModal/CreateAlertModalContent";
export { UpdateAlertModalContent } from "./UpdateAlertModalContent";
export { AlertEditForm } from "./AlertEditForm";
export { AlertModalTitle } from "./AlertModalTitle";
export { AlertAboveGoalToggle } from "./AlertAboveGoalToggle";
export { AlertFirstOnlyToggle } from "./AlertFirstOnlyToggle";
export { AlertSettingToggle } from "./AlertSettingToggle";
@@ -11,5 +10,5 @@ export { MultiSeriesAlertTip } from "./MultiSeriesAlertTip";
export { RawDataAlertTip } from "./RawDataAlertTip";
export { AlertGoalToggles } from "./AlertGoalToggles";
export { DeleteAlertSection } from "./DeleteAlertSection";
export { AlertEducationalScreen } from "./AlertEducationalScreen";
export { AlertEducationalScreen } from "./CreateAlertModal/AlertEducationalScreen";
export { AlertEditSchedule } from "./AlertEditSchedule";

View File

@@ -1,20 +1,11 @@
import cx from "classnames";
import { t } from "ttag";
import ChannelSetupMessage from "metabase/components/ChannelSetupMessage";
import CS from "metabase/css/core/index.css";
import { Icon, Switch } from "metabase/ui";
import type { Alert, EmailChannelSpec, User } from "metabase-types/api";
import { RecipientPicker } from "./RecipientPicker";
export const EmailChannelEdit = ({
channelSpec,
alert,
toggleChannel,
onChannelPropertyChange,
users,
user,
invalidRecipientText,
}: {
channelSpec: EmailChannelSpec;
@@ -34,46 +25,12 @@ export const EmailChannelEdit = ({
onChannelPropertyChange(channelIndex, "recipients", recipients);
return (
<li className={CS.borderRowDivider}>
<div className={cx(CS.flex, CS.alignCenter, CS.p3, CS.borderRowDivider)}>
<Icon className={cx(CS.mr1, CS.textLight)} name="mail" size={28} />
<h2>{channelSpec.name}</h2>
<Switch
className={CS.flexAlignRight}
checked={channel?.enabled}
onChange={val =>
toggleChannel("email", channelIndex, val.target.checked)
}
/>
</div>
{channel?.enabled && channelSpec.configured ? (
<ul className={cx(CS.bgLight, CS.px3)}>
<li className={CS.py2}>
<div>
<div className={cx(CS.h4, CS.textBold, CS.mb1)}>
{t`Email alerts to:`}
</div>
<RecipientPicker
autoFocus={!!alert.name}
recipients={channel.recipients}
users={users}
onRecipientsChange={handleRecipientsChange}
invalidRecipientText={invalidRecipientText}
/>
</div>
</li>
{/* {renderChannel(channel, channelSpec, channelIndex)} */}
</ul>
) : channel?.enabled && !channelSpec.configured ? (
<div className={cx(CS.p4, CS.textCentered)}>
<h3
className={CS.mb2}
>{t`${channelSpec.name} needs to be set up by an administrator.`}</h3>
<ChannelSetupMessage user={user} channels={[channelSpec.name]} />
</div>
) : null}
</li>
<RecipientPicker
autoFocus={!!alert.name}
recipients={channel.recipients}
users={users}
onRecipientsChange={handleRecipientsChange}
invalidRecipientText={invalidRecipientText}
/>
);
};

View File

@@ -1,13 +1,9 @@
/* eslint "react/prop-types": "warn" */
import cx from "classnames";
import { assoc, updateIn } from "icepick";
import { t } from "ttag";
import _ from "underscore";
import { useListChannelsQuery } from "metabase/api/channel";
import CS from "metabase/css/core/index.css";
import { createChannel } from "metabase/lib/pulse";
import type { IconName } from "metabase/ui";
import { Accordion } from "metabase/ui";
import type {
Alert,
Channel,
@@ -21,40 +17,37 @@ import { EmailChannelEdit } from "./EmailChannelEdit";
import { SlackChannelEdit } from "./SlackChannelEdit";
import { WebhookChannelEdit } from "./WebhookChannelEdit";
export const CHANNEL_ICONS: Record<ChannelType, IconName> = {
email: "mail",
slack: "slack",
http: "webhook",
const DEFAULT_CHANNELS_CONFIG = {
email: { name: t`Email`, type: "email" },
slack: { name: t`Slack`, type: "slack" },
http: { name: t`Http`, type: "http" },
};
interface PulseEditChannelsProps {
interface NotificationChannelsPickerProps {
pulse: Alert;
pulseId: Alert["id"];
pulseIsValid: boolean;
formInput: ChannelApiResponse;
channels: ChannelApiResponse["channels"] | undefined;
user: User;
users: User[];
setPulse: (value: Alert) => void;
hideSchedulePicker: boolean;
emailRecipientText: string;
invalidRecipientText: (domains: string) => string;
}
export const PulseEditChannels = ({
export const NotificationChannelsPicker = ({
pulse,
formInput,
channels: nullableChannels,
user,
users,
setPulse,
invalidRecipientText,
}: PulseEditChannelsProps) => {
}: NotificationChannelsPickerProps) => {
const { data: notificationChannels = [] } = useListChannelsQuery();
const addChannel = (
type: ChannelType,
notification?: NotificationChannel,
) => {
const channelSpec = formInput.channels[type];
const channelSpec = channels[type];
if (!channelSpec) {
return;
}
@@ -109,41 +102,57 @@ export const PulseEditChannels = ({
};
// Default to show the default channels until full formInput is loaded
const channels = formInput.channels || {
email: { name: t`Email`, type: "email" },
slack: { name: t`Slack`, type: "slack" },
http: { name: t`Http`, type: "http" },
};
const channels = (nullableChannels ||
DEFAULT_CHANNELS_CONFIG) as ChannelApiResponse["channels"];
return (
<ul className={cx(CS.bordered, CS.rounded, CS.bgWhite)}>
<EmailChannelEdit
user={user}
users={users}
toggleChannel={toggleChannel}
onChannelPropertyChange={onChannelPropertyChange}
channelSpec={channels.email}
alert={pulse}
invalidRecipientText={invalidRecipientText}
/>
<Accordion defaultValue={["email", "slack"]} variant="separated" multiple>
<Accordion.Item value="email">
<Accordion.Control>{t`Email`}</Accordion.Control>
<Accordion.Panel>
<EmailChannelEdit
user={user}
users={users}
toggleChannel={toggleChannel}
onChannelPropertyChange={onChannelPropertyChange}
channelSpec={channels.email}
alert={pulse}
invalidRecipientText={invalidRecipientText}
/>
</Accordion.Panel>
</Accordion.Item>
{channels.slack.configured && (
<SlackChannelEdit
user={user}
toggleChannel={toggleChannel}
onChannelPropertyChange={onChannelPropertyChange}
channelSpec={channels.slack}
alert={pulse}
/>
<Accordion.Item value="slack">
<Accordion.Control>{t`Slack`}</Accordion.Control>
<Accordion.Panel>
<SlackChannelEdit
user={user}
toggleChannel={toggleChannel}
onChannelPropertyChange={onChannelPropertyChange}
channelSpec={channels.slack}
alert={pulse}
/>
</Accordion.Panel>
</Accordion.Item>
)}
{notificationChannels.map(notification => (
<WebhookChannelEdit
<Accordion.Item
key={`webhook-${notification.id}`}
toggleChannel={toggleChannel}
channelSpec={channels.http}
alert={pulse}
notification={notification}
/>
value={`webhook-${notification.id}`}
>
<Accordion.Control>{notification.name}</Accordion.Control>
<Accordion.Panel>
<WebhookChannelEdit
toggleChannel={toggleChannel}
channelSpec={channels.http}
alert={pulse}
notification={notification}
/>
</Accordion.Panel>
</Accordion.Item>
))}
</ul>
</Accordion>
);
};

View File

@@ -26,7 +26,7 @@ export const SlackChannelEdit = ({
const channel = alert.channels[channelIndex];
return (
<li className={CS.borderRowDivider}>
<div className={CS.borderRowDivider}>
<div className={cx(CS.flex, CS.alignCenter, CS.p3, CS.borderRowDivider)}>
<Icon className={cx(CS.mr1, CS.textLight)} name="mail" size={28} />
@@ -59,6 +59,6 @@ export const SlackChannelEdit = ({
<ChannelSetupMessage user={user} channels={[channelSpec.name]} />
</div>
) : null}
</li>
</div>
);
};

View File

@@ -0,0 +1,5 @@
export type QuestionNotificationsModalType =
| "question-alert"
| "question-subscription";
export type DashboardNotificationsModalType = "dashboard-subscription";

View File

@@ -0,0 +1,25 @@
import {
ALERT_TYPE_PROGRESS_BAR_GOAL,
ALERT_TYPE_TIMESERIES_GOAL,
} from "metabase-lib/v1/Alert";
import type Question from "metabase-lib/v1/Question";
import type { VisualizationSettings } from "metabase-types/api";
export const hasProperGoalForAlert = ({
question,
visualizationSettings,
}: {
question: Question | undefined;
visualizationSettings: VisualizationSettings;
}): boolean => {
const alertType = question?.alertType(visualizationSettings);
if (!alertType) {
return false;
}
return (
alertType === ALERT_TYPE_TIMESERIES_GOAL ||
alertType === ALERT_TYPE_PROGRESS_BAR_GOAL
);
};

View File

@@ -1,4 +1,4 @@
import { useCallback } from "react";
import { type JSX, type Ref, forwardRef, useCallback } from "react";
import { push } from "react-router-redux";
import _ from "underscore";
@@ -10,29 +10,37 @@ import { SaveQuestionModal } from "metabase/containers/SaveQuestionModal";
import EntityCopyModal from "metabase/entities/containers/EntityCopyModal";
import { useDispatch, useSelector } from "metabase/lib/redux";
import * as Urls from "metabase/lib/urls";
import { CreateAlertModalContent } from "metabase/notifications/AlertModals";
import type { UpdateQuestionOpts } from "metabase/query_builder/actions/core/updateQuestion";
import { ImpossibleToCreateModelModal } from "metabase/query_builder/components/ImpossibleToCreateModelModal";
import NewDatasetModal from "metabase/query_builder/components/NewDatasetModal";
import { QuestionEmbedWidget } from "metabase/query_builder/components/QuestionEmbedWidget";
import { PreviewQueryModal } from "metabase/query_builder/components/view/PreviewQueryModal";
import type { QueryModalType } from "metabase/query_builder/constants";
import { MODAL_TYPES } from "metabase/query_builder/constants";
import { getQuestionWithParameters } from "metabase/query_builder/selectors";
import { FilterModal } from "metabase/querying/filters/components/FilterModal";
import ArchiveQuestionModal from "metabase/questions/containers/ArchiveQuestionModal";
import { QuestionPublicLinkPopover } from "metabase/sharing/components/PublicLinkPopover";
import EditEventModal from "metabase/timelines/questions/containers/EditEventModal";
import MoveEventModal from "metabase/timelines/questions/containers/MoveEventModal";
import NewEventModal from "metabase/timelines/questions/containers/NewEventModal";
import { Box } from "metabase/ui";
import * as Lib from "metabase-lib";
import type Question from "metabase-lib/v1/Question";
import type { Alert, Card, User } from "metabase-types/api";
import type { Card } from "metabase-types/api";
import type { QueryBuilderMode } from "metabase-types/store";
import { CreateAlertModalContent } from "../../../notifications/modals";
import { MoveQuestionModal } from "../MoveQuestionModal";
const MenuTarget = forwardRef(function _MenuTarget(
_props,
ref: Ref<HTMLDivElement>,
) {
return <Box h="2rem" ref={ref} />;
});
interface QueryModalsProps {
questionAlerts: Alert[];
user: User;
modal: QueryModalType;
modalContext: number;
question: Question;
@@ -51,8 +59,6 @@ interface QueryModalsProps {
}
export function QueryModals({
questionAlerts,
user,
onSave,
onCreate,
updateQuestion,
@@ -65,28 +71,12 @@ export function QueryModals({
setQueryBuilderMode,
originalQuestion,
onChangeLocation,
}: QueryModalsProps) {
}: QueryModalsProps): JSX.Element {
const dispatch = useDispatch();
const initialCollectionId = useGetDefaultCollectionId();
const questionWithParameters = useSelector(getQuestionWithParameters);
const showAlertsAfterQuestionSaved = useCallback(() => {
const hasAlertsCreatedByCurrentUser = _.any(
questionAlerts,
alert => alert.creator.id === user.id,
);
if (hasAlertsCreatedByCurrentUser) {
// TODO Atte Keinänen 11/10/17: The question was replaced and there is already an alert created by current user.
// Should we show pop up the alerts list in this case or do nothing (as we do currently)?
onCloseModal();
} else {
// HACK: in a timeout because save modal closes itself
setTimeout(() => onOpenModal(MODAL_TYPES.CREATE_ALERT));
}
}, [onCloseModal, onOpenModal, questionAlerts, user.id]);
const onQueryChange = useCallback(
(query: Lib.Query) => {
const nextLegacyQuery = Lib.toLegacyQuery(query);
@@ -221,33 +211,14 @@ export function QueryModals({
);
case MODAL_TYPES.CREATE_ALERT:
return (
<Modal medium onClose={onCloseModal}>
<Modal onClose={onCloseModal}>
<CreateAlertModalContent
type="alert"
onCancel={onCloseModal}
onAlertCreated={onCloseModal}
/>
</Modal>
);
case MODAL_TYPES.SAVE_QUESTION_BEFORE_ALERT:
return (
<SaveQuestionModal
question={question}
originalQuestion={originalQuestion}
onSave={async question => {
await onSave(question);
showAlertsAfterQuestionSaved();
}}
onCreate={async question => {
const newQuestion = await onCreate(question);
showAlertsAfterQuestionSaved();
return newQuestion;
}}
onClose={onCloseModal}
opened={true}
multiStep
initialCollectionId={initialCollectionId}
/>
);
case MODAL_TYPES.SAVE_QUESTION_BEFORE_EMBED:
return (
<SaveQuestionModal
@@ -352,7 +323,19 @@ export function QueryModals({
<PreviewQueryModal onClose={onCloseModal} />
</Modal>
);
default:
return null;
case MODAL_TYPES.QUESTION_PUBLIC_LINK:
return (
<QuestionPublicLinkPopover
question={question}
target={<MenuTarget />}
onClose={onCloseModal}
isOpen
/>
);
case MODAL_TYPES.QUESTION_EMBED:
return (
<QuestionEmbedWidget card={question._card} onClose={onCloseModal} />
);
}
}

View File

@@ -8,7 +8,7 @@ import { ErrorMessage } from "metabase/components/ErrorMessage";
import Modal from "metabase/components/Modal";
import ButtonsS from "metabase/css/components/buttons.module.css";
import CS from "metabase/css/core/index.css";
import { CreateAlertModalContent } from "metabase/notifications/AlertModals";
import { CreateAlertModalContent } from "metabase/notifications/modals/CreateAlertModal/CreateAlertModalContent";
import Visualization from "metabase/visualizations/components/Visualization";
import * as Lib from "metabase-lib";
import { ALERT_TYPE_ROWS } from "metabase-lib/v1/Alert";
@@ -103,8 +103,9 @@ export default class VisualizationResult extends Component {
}
/>
{showCreateAlertModal && (
<Modal medium onClose={this.onCloseCreateAlertModal}>
<Modal onClose={this.onCloseCreateAlertModal}>
<CreateAlertModalContent
type="alert"
onCancel={this.onCloseCreateAlertModal}
onAlertCreated={this.onCloseCreateAlertModal}
/>

View File

@@ -67,8 +67,6 @@ const ViewInner = props => {
onCreate,
onSave,
onChangeLocation,
questionAlerts,
user,
modal,
modalContext,
card,
@@ -139,8 +137,6 @@ const ViewInner = props => {
/>
)}
<QueryModals
questionAlerts={questionAlerts}
user={user}
onSave={onSave}
onCreate={onCreate}
updateQuestion={updateQuestion}
@@ -248,8 +244,6 @@ const ViewInner = props => {
)}
<QueryModals
questionAlerts={questionAlerts}
user={user}
onSave={onSave}
onCreate={onCreate}
updateQuestion={updateQuestion}

View File

@@ -0,0 +1,88 @@
import type { JSX } from "react";
import { t } from "ttag";
import { isInstanceAnalyticsCollection } from "metabase/collections/utils";
import {
MODAL_TYPES,
type QueryModalType,
} from "metabase/query_builder/constants";
import { EmbedMenuItem } from "metabase/sharing/components/SharingMenu/MenuItems/EmbedMenuItem";
import { PublicLinkMenuItem } from "metabase/sharing/components/SharingMenu/MenuItems/PublicLinkMenuItem";
import { Icon, Menu } from "metabase/ui";
import type Question from "metabase-lib/v1/Question";
type EmbeddingQuestionActionsProps = {
question: Question;
isAdmin: boolean;
isPublicSharingEnabled: boolean;
onOpenModal: (modalType: QueryModalType) => void;
};
export const EmbeddingQuestionActions = ({
question,
isAdmin,
isPublicSharingEnabled,
onOpenModal,
}: EmbeddingQuestionActionsProps): JSX.Element | null => {
const isModel = question.type() === "model";
const isArchived = question.isArchived();
const collection = question.collection();
const isAnalytics = collection && isInstanceAnalyticsCollection(collection);
if (isModel || isArchived || isAnalytics) {
return null;
}
const hasPublicLink = !!question?.publicUUID?.();
const isUnsaved = !question.isSaved();
if (!isAdmin && (!isPublicSharingEnabled || !hasPublicLink)) {
return (
<>
<Menu.Divider />
<Menu.Item icon={<Icon name="share" />} disabled>
{t`Ask your admin to create a public link`}
</Menu.Item>
</>
);
}
if (isUnsaved) {
return (
<>
<Menu.Divider />
<Menu.Item
icon={<Icon name="share" />}
onClick={() => onOpenModal(MODAL_TYPES.SAVE_QUESTION_BEFORE_EMBED)}
>
{t`You must save this question before sharing`}
</Menu.Item>
</>
);
}
if (!isAdmin && hasPublicLink) {
return (
<>
<Menu.Divider />
<Menu.Item
icon={<Icon name="share" />}
onClick={() => onOpenModal("question-public-link")}
>
{t`Public link`}
</Menu.Item>
</>
);
}
return (
<>
<Menu.Divider />
<PublicLinkMenuItem
hasPublicLink={hasPublicLink}
onClick={() => onOpenModal("question-public-link")}
/>
<EmbedMenuItem onClick={() => onOpenModal("question-embed")} />
</>
);
};

View File

@@ -2,9 +2,10 @@ import { type JSX, useState } from "react";
import { c, t } from "ttag";
import _ from "underscore";
import { useSetting } from "metabase/common/hooks";
import Button from "metabase/core/components/Button";
import Tooltip from "metabase/core/components/Tooltip";
import { useDispatch } from "metabase/lib/redux";
import { useDispatch, useSelector } from "metabase/lib/redux";
import { PLUGIN_MODERATION } from "metabase/plugins";
import {
onOpenQuestionSettings,
@@ -12,12 +13,14 @@ import {
turnModelIntoQuestion,
} from "metabase/query_builder/actions";
import { trackTurnIntoModelClicked } from "metabase/query_builder/analytics";
import { EmbeddingQuestionActions } from "metabase/query_builder/components/view/ViewHeader/components/QuestionActions/EmbeddingQuestionActions";
import DatasetMetadataStrengthIndicator from "metabase/query_builder/components/view/sidebars/DatasetManagementSection/DatasetMetadataStrengthIndicator";
import { shouldShowQuestionSettingsSidebar } from "metabase/query_builder/components/view/sidebars/QuestionSettingsSidebar";
import {
MODAL_TYPES,
type QueryModalType,
} from "metabase/query_builder/constants";
import { getUserIsAdmin } from "metabase/selectors/user";
import { Icon, Menu } from "metabase/ui";
import * as Lib from "metabase-lib";
import type Question from "metabase-lib/v1/Question";
@@ -49,6 +52,9 @@ export const QuestionMoreActionsMenu = ({
onOpenModal,
onSetQueryBuilderMode,
}: QuestionMoreActionsMenuProps): JSX.Element | null => {
const isAdmin = useSelector(getUserIsAdmin);
const isPublicSharingEnabled = useSetting("enable-public-sharing");
const [opened, setOpened] = useState(false);
const dispatch = useDispatch();
@@ -162,6 +168,13 @@ export const QuestionMoreActionsMenu = ({
{t`Edit settings`}
</Menu.Item>
),
<EmbeddingQuestionActions
key="embeddingActions"
question={question}
isAdmin={isAdmin}
isPublicSharingEnabled={isPublicSharingEnabled}
onOpenModal={onOpenModal}
/>,
hasCollectionPermissions && (
<>
<Menu.Divider />

View File

@@ -6,11 +6,11 @@ import { t } from "ttag";
import CS from "metabase/css/core/index.css";
import { SERVER_ERROR_TYPES } from "metabase/lib/errors";
import MetabaseSettings from "metabase/lib/settings";
import { QuestionSharingMenu } from "metabase/notifications/NotificationsActionsMenu";
import RunButtonWithTooltip from "metabase/query_builder/components/RunButtonWithTooltip";
import { canExploreResults } from "metabase/query_builder/components/view/ViewHeader/utils";
import type { QueryModalType } from "metabase/query_builder/constants";
import { MODAL_TYPES } from "metabase/query_builder/constants";
import { QuestionSharingMenu } from "metabase/sharing/components/SharingMenu";
import { Box, Button, Flex, Tooltip } from "metabase/ui";
import * as Lib from "metabase-lib";
import type Question from "metabase-lib/v1/Question";

View File

@@ -7,7 +7,6 @@ export const MODAL_TYPES = {
SAVED: "saved",
ADD_TO_DASHBOARD_SAVE: "add-to-dashboard-save",
CREATE_ALERT: "create-alert",
SAVE_QUESTION_BEFORE_ALERT: "save-question-before-alert",
SAVE_QUESTION_BEFORE_EMBED: "save-question-before-embed",
FILTERS: "filters",
TURN_INTO_DATASET: "turn-into-dataset",
@@ -16,6 +15,8 @@ export const MODAL_TYPES = {
EDIT_EVENT: "edit-event",
MOVE_EVENT: "move-event",
PREVIEW_QUERY: "preview-query",
QUESTION_PUBLIC_LINK: "question-public-link",
QUESTION_EMBED: "question-embed",
} as const;
export type QueryModalType = (typeof MODAL_TYPES)[keyof typeof MODAL_TYPES];

View File

@@ -24,7 +24,6 @@ import { getMetadata } from "metabase/selectors/metadata";
import { getSetting } from "metabase/selectors/settings";
import {
canManageSubscriptions,
getUser,
getUserIsAdmin,
} from "metabase/selectors/user";
import type {
@@ -74,7 +73,6 @@ import {
getQueryResults,
getQueryStartTime,
getQuestion,
getQuestionAlerts,
getRawSeries,
getSampleDatabaseId,
getSelectedTimelineEventIds,
@@ -121,7 +119,6 @@ type EntityListLoaderMergedProps = {
const mapStateToProps = (state: State, props: EntityListLoaderMergedProps) => {
return {
user: getUser(state),
canManageSubscriptions: canManageSubscriptions(state),
isAdmin: getUserIsAdmin(state),
@@ -178,7 +175,6 @@ const mapStateToProps = (state: State, props: EntityListLoaderMergedProps) => {
isResultDirty: getIsResultDirty(state),
isMetadataDirty: isResultsMetadataDirty(state),
questionAlerts: getQuestionAlerts(state),
visualizationSettings: getVisualizationSettings(state),
queryStartTime: getQueryStartTime(state),

View File

@@ -1,70 +0,0 @@
import { useState } from "react";
import { isInstanceAnalyticsCollection } from "metabase/collections/utils";
import { setSharing as setDashboardSubscriptionSidebarOpen } from "metabase/dashboard/actions";
import { getIsSharing as getIsDashboardSubscriptionSidebarOpen } from "metabase/dashboard/selectors";
import { useDispatch, useSelector } from "metabase/lib/redux";
import { Flex, Menu } from "metabase/ui";
import type { Dashboard } from "metabase-types/api";
import { DashboardSubscriptionMenuItem } from "./MenuItems/DashboardSubscriptionMenuItem";
import { EmbedMenuItem } from "./MenuItems/EmbedMenuItem";
import { ExportPdfMenuItem } from "./MenuItems/ExportPdfMenuItem";
import { PublicLinkMenuItem } from "./MenuItems/PublicLinkMenuItem";
import { SharingMenu } from "./SharingMenu";
import { SharingModals } from "./SharingModals";
import type { DashboardSharingModalType } from "./types";
export function DashboardSharingMenu({ dashboard }: { dashboard: Dashboard }) {
const dispatch = useDispatch();
const isDashboardSubscriptionSidebarOpen = useSelector(
getIsDashboardSubscriptionSidebarOpen,
);
const toggleSubscriptionSidebar = () =>
dispatch(
setDashboardSubscriptionSidebarOpen(!isDashboardSubscriptionSidebarOpen),
);
const [modalType, setModalType] = useState<DashboardSharingModalType | null>(
null,
);
const hasPublicLink = !!dashboard?.public_uuid;
const isArchived = dashboard.archived;
const isAnalytics =
dashboard.collection && isInstanceAnalyticsCollection(dashboard.collection);
const canShare = !isAnalytics;
if (isArchived) {
return null;
}
return (
<Flex>
<SharingMenu>
<DashboardSubscriptionMenuItem
onClick={toggleSubscriptionSidebar}
dashboard={dashboard}
/>
<ExportPdfMenuItem dashboard={dashboard} />
{!!canShare && (
<>
<Menu.Divider />
<PublicLinkMenuItem
hasPublicLink={hasPublicLink}
onClick={() => setModalType("dashboard-public-link")}
/>
<EmbedMenuItem onClick={() => setModalType("dashboard-embed")} />
</>
)}
</SharingMenu>
<SharingModals
modalType={modalType}
dashboard={dashboard}
onClose={() => setModalType(null)}
/>
</Flex>
);
}

View File

@@ -1,104 +0,0 @@
import { type Ref, forwardRef } from "react";
import { DashboardSharingEmbeddingModal } from "metabase/dashboard/containers/DashboardSharingEmbeddingModal";
import { AlertPopover } from "metabase/notifications/AlertListPopoverContent/AlertPopover";
import { QuestionEmbedWidget } from "metabase/query_builder/components/QuestionEmbedWidget";
import { Box } from "metabase/ui";
import type Question from "metabase-lib/v1/Question";
import type { Dashboard } from "metabase-types/api";
import {
DashboardPublicLinkPopover,
QuestionPublicLinkPopover,
} from "../PublicLinkPopover";
import type {
DashboardSharingModalType,
QuestionSharingModalType,
} from "./types";
const MenuTarget = forwardRef(function _MenuTarget(
_props,
ref: Ref<HTMLDivElement>,
) {
return <Box h="2rem" ref={ref} />;
});
type BaseSharingModalProps = {
onClose: () => void;
};
type SharingModalProps = BaseSharingModalProps &
(
| {
modalType: QuestionSharingModalType;
question: Question;
dashboard?: never;
}
| {
modalType: DashboardSharingModalType;
dashboard: Dashboard;
question?: never;
}
| {
modalType: null;
question?: Question;
dashboard?: Dashboard;
}
);
export const SharingModals = ({
modalType,
onClose,
question,
dashboard,
}: SharingModalProps) => {
if (modalType === "question-alert") {
return (
<AlertPopover
question={question}
target={<MenuTarget />}
onClose={onClose}
/>
);
}
if (modalType === "question-public-link") {
return (
<QuestionPublicLinkPopover
question={question}
target={<MenuTarget />}
onClose={onClose}
isOpen
/>
);
}
if (modalType === "question-embed" && question) {
return <QuestionEmbedWidget card={question._card} onClose={onClose} />;
}
if (modalType === "dashboard-public-link") {
return (
<DashboardPublicLinkPopover
dashboard={dashboard}
target={<MenuTarget />}
onClose={onClose}
isOpen
/>
);
}
if (modalType === "dashboard-embed" && dashboard) {
return (
<DashboardSharingEmbeddingModal
key="dashboard-embed"
dashboard={dashboard}
onClose={onClose}
isOpen
/>
);
}
return null;
};

View File

@@ -1,2 +0,0 @@
export * from "./DashboardSharingMenu";
export * from "./QuestionSharingMenu";

View File

@@ -0,0 +1,20 @@
import { Fragment } from "react";
import { Canvas, Story, Meta } from "@storybook/blocks";
import { Box, Accordion, Stack, Text } from "metabase/ui";
import * as AccordionStories from "./Accordion.stories";
<Meta of={AccordionStories} />
# Accordion
Our themed wrapper around [Mantine Accordion](https://v6.mantine.dev/core/accordion/).
## Docs
- [Mantine Accordion Docs](https://v6.mantine.dev/core/accordion/)
## Examples
<Canvas>
<Story of={AccordionStories.Default} />
</Canvas>

View File

@@ -0,0 +1,40 @@
import { Accordion, type AccordionProps } from "metabase/ui";
const args = {
defaultValue: "first",
};
const argTypes = {
defaultValue: {
options: ["first", "second", "third"],
control: { type: "inline-radio" },
},
};
const DefaultTemplate = (args: AccordionProps) => (
<Accordion {...args}>
<Accordion.Item value="first">
<Accordion.Control>First</Accordion.Control>
<Accordion.Panel>FirstContent</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="second">
<Accordion.Control>Second</Accordion.Control>
<Accordion.Panel>SecondContent</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="third">
<Accordion.Control>Third</Accordion.Control>
<Accordion.Panel>ThirdContent</Accordion.Panel>
</Accordion.Item>
</Accordion>
);
export default {
title: "Data display/Accordion",
component: Accordion,
args,
argTypes,
};
export const Default = {
render: DefaultTemplate,
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB