Compare commits

...

6 Commits

Author SHA1 Message Date
Denis Berezin
ed0a176cc5 Merge branch 'master' into 50766-split-subscriptions
# Conflicts:
#	frontend/src/metabase/notifications/modals/CreateAlertModal/CreateAlertModalContent.tsx
#	frontend/src/metabase/notifications/modals/UpdateAlertModalContent.tsx
2024-12-18 16:34:38 +03:00
Denis Berezin
8e26bf14e0 Working split between alerts and subscriptions 2024-12-18 01:45:25 +03:00
Denis Berezin
f654e77e8c Fix e2e tests 2024-12-18 01:14:54 +03:00
Denis Berezin
a771bec635 Fix e2e tests 2024-12-18 01:13:51 +03:00
Denis Berezin
814cf8a760 Temp 2024-12-18 01:10:11 +03:00
Denis Berezin
d3557d45d7 Extracted alerts and subscriptions scope 2024-12-17 23:07:24 +03:00
70 changed files with 416 additions and 567 deletions

View File

@@ -22,7 +22,7 @@ To start using alerts, an administrator will need to have set up at least one of
## Creating an alert
Save your question, click on the sharing icon in the top-right of the screen, and select **Create alert**.
Save your question, click on the sharing icon in the top-right of the screen, and select **Create alerts**.
![Get alerts](../images/get-alerts-about-this.png)

View File

@@ -462,7 +462,7 @@ describe(
it("should allow you to enable a webhook alert", () => {
H.visitQuestion(ORDERS_COUNT_QUESTION_ID);
cy.findByTestId("sharing-menu-button").click();
H.popover().findByText("Create alert").click();
H.popover().findByText("Create alerts").click();
H.modal().button("Set up an alert").click();
H.modal().within(() => {
H.toggleAlertChannel("Email");
@@ -487,7 +487,7 @@ describe(
it("should allow you to test a webhook", () => {
H.visitQuestion(ORDERS_COUNT_QUESTION_ID);
cy.findByTestId("sharing-menu-button").click();
H.popover().findByText("Create alert").click();
H.popover().findByText("Create alerts").click();
H.modal().button("Set up an alert").click();
H.modal().within(() => {
H.getAlertChannel(firstWebhookName).scrollIntoView();

View File

@@ -69,7 +69,7 @@ describe("scenarios > alert > alert permissions", { tags: "@external" }, () => {
H.openSharingMenu();
H.sharingMenu().findByText("Edit alerts").should("not.exist");
H.sharingMenu().findByText("Create alert").should("be.visible");
H.sharingMenu().findByText("Create alerts").should("be.visible");
});
it("should let you see other alerts where you are a recipient", () => {
@@ -106,7 +106,7 @@ describe("scenarios > alert > alert permissions", { tags: "@external" }, () => {
});
function createBasicAlert({ firstAlert, includeNormal } = {}) {
H.openSharingMenu("Create alert");
H.openSharingMenu("Create alerts");
if (firstAlert) {
H.modal().findByText("Set up an alert").click();

View File

@@ -56,7 +56,7 @@ describe("scenarios > alert > types", { tags: "@external" }, () => {
it(`should be supported for ${questionType}`, () => {
H.visitQuestion(questionId);
H.openSharingMenu("Create alert");
H.openSharingMenu("Create alerts");
cy.wait("@channel");
H.modal().within(() => {
@@ -86,7 +86,7 @@ describe("scenarios > alert > types", { tags: "@external" }, () => {
H.visitQuestion(timeSeriesQuestionId);
cy.findByTestId("chart-container").should("contain", "Goal");
H.openSharingMenu("Create alert");
H.openSharingMenu("Create alerts");
cy.wait("@channel");
cy.findByTestId("alert-create").within(() => {
@@ -106,7 +106,7 @@ describe("scenarios > alert > types", { tags: "@external" }, () => {
it("should not be possible to create goal based alert for a multi-series question", () => {
cy.createQuestion(multiSeriesQuestionWithGoal, { visitQuestion: true });
H.openSharingMenu("Create alert");
H.openSharingMenu("Create alerts");
cy.wait("@channel");
// *** The warning below is not showing when we try to make an alert (Issue #???)

View File

@@ -29,7 +29,7 @@ describe("scenarios > alert", () => {
describe("with nothing set", () => {
it("should prompt you to add email/slack credentials", () => {
H.visitQuestion(ORDERS_QUESTION_ID);
H.openSharingMenu("Create alert");
H.openSharingMenu("Create alerts");
H.modal().within(() => {
cy.findByText(
@@ -58,7 +58,7 @@ describe("scenarios > alert", () => {
cy.signInAsNormalUser();
H.visitQuestion(ORDERS_QUESTION_ID);
H.openSharingMenu("Create alert");
H.openSharingMenu("Create alerts");
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText(
@@ -79,7 +79,7 @@ describe("scenarios > alert", () => {
// Open the first alert screen and create an alert
H.visitQuestion(ORDERS_QUESTION_ID);
H.openSharingMenu("Create alert");
H.openSharingMenu("Create alerts");
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("The wide world of alerts");
@@ -106,7 +106,7 @@ describe("scenarios > alert", () => {
H.visitQuestion(ORDERS_COUNT_QUESTION_ID);
cy.wait("@questionLoaded");
H.openSharingMenu("Create alert");
H.openSharingMenu("Create alerts");
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Let's set up your alert");
@@ -131,7 +131,7 @@ describe("scenarios > alert", () => {
it("should be able to create and delete alerts with webhooks enabled", () => {
H.visitQuestion(ORDERS_QUESTION_ID);
H.openSharingMenu("Create alert");
H.openSharingMenu("Create alerts");
//Disable Email
H.toggleAlertChannel("Email");
@@ -192,7 +192,7 @@ describe("scenarios > alert", () => {
{ visitQuestion: true },
);
H.openSharingMenu("Create alert");
H.openSharingMenu("Create alerts");
H.modal().button("Set up an alert").click();
H.modal().button("Done").click();

View File

@@ -164,7 +164,7 @@ describe("scenarios > alert > email_alert", { tags: "@external" }, () => {
function openAlertForQuestion(id) {
H.visitQuestion(id);
H.openSharingMenu("Create alert");
H.openSharingMenu("Create alerts");
}
function saveAlert() {
@@ -176,6 +176,6 @@ function saveAlert() {
});
cy.wait("@saveCard");
H.openSharingMenu("Create alert");
H.openSharingMenu("Create alerts");
H.modal().button("Done").click();
}

View File

@@ -295,7 +295,7 @@ H.describeEE(
it("should validate approved email domains for a question alert", () => {
H.visitQuestion(ORDERS_QUESTION_ID);
H.openSharingMenu("Create alert");
H.openSharingMenu("Create alerts");
H.modal().findByText("Set up an alert").click();
H.modal()

View File

@@ -1,15 +1,13 @@
import type Question from "metabase-lib/v1/Question";
import type { VisualizationSettings } from "metabase-types/api/card";
import type { User } from "metabase-types/api/user";
import { ALERT_TYPE_ROWS } from "./constants";
import { ALERT_TYPE_ROWS, type NotificationTriggerType } from "./constants";
export const getDefaultAlert = (
question: Question | undefined,
alertType: NotificationTriggerType,
user: User | null,
visualizationSettings: VisualizationSettings,
) => {
const alertType = question?.alertType(visualizationSettings);
const typeDependentAlertFields =
alertType === ALERT_TYPE_ROWS
? {

View File

@@ -1,3 +1,11 @@
export const ALERT_TYPE_ROWS = "alert-type-rows";
export const ALERT_TYPE_TIMESERIES_GOAL = "alert-type-timeseries-goal";
export const ALERT_TYPE_PROGRESS_BAR_GOAL = "alert-type-progress-bar-goal";
const AlertTypes = [
ALERT_TYPE_ROWS,
ALERT_TYPE_TIMESERIES_GOAL,
ALERT_TYPE_PROGRESS_BAR_GOAL,
] as const;
export type NotificationTriggerType = (typeof AlertTypes)[number];

View File

@@ -11,6 +11,7 @@ import {
ALERT_TYPE_ROWS,
ALERT_TYPE_TIMESERIES_GOAL,
} from "metabase-lib/v1/Alert";
import type { NotificationTriggerType } from "metabase-lib/v1/Alert/constants";
import type Database from "metabase-lib/v1/metadata/Database";
import Metadata from "metabase-lib/v1/metadata/Metadata";
import type Table from "metabase-lib/v1/metadata/Table";
@@ -404,7 +405,7 @@ class Question {
* so you can provide the complete visualization settings object to `alertType`
* for taking those into account
*/
alertType(visualizationSettings) {
alertType(visualizationSettings): NotificationTriggerType | null {
const display = this.display();
if (!this.canRun()) {

View File

@@ -603,7 +603,7 @@ export const Onboarding = () => {
key="alert-icon"
name="alert"
/>
)} ${(<b key="create-alert">{t`Create alert`}</b>)}.`}
)} ${(<b key="create-alert">{t`Create alerts`}</b>)}.`}
</Text>
<Text>
{t`There are three kinds of things you can get alerted about in ${applicationName}:`}

View File

@@ -1,33 +1,2 @@
import Cookies from "js-cookie";
// METABASE_SESSION_COOKIE is only used for e2e tests. In normal usage cookie is set automatically by login endpoints
export const METABASE_SESSION_COOKIE = "metabase.SESSION";
export const METABASE_SEEN_ALERT_SPLASH_COOKIE = "metabase.SEEN_ALERT_SPLASH";
// Handles management of Metabase cookie work
const MetabaseCookies = {
setHasSeenAlertSplash: hasSeen => {
const options = {
path: window.MetabaseRoot || "/",
expires: 365,
secure: window.location.protocol === "https:",
};
try {
Cookies.set(METABASE_SEEN_ALERT_SPLASH_COOKIE, hasSeen, options);
} catch (e) {
console.error("setSeenAlertSplash:", e);
}
},
getHasSeenAlertSplash: () => {
try {
return Cookies.get(METABASE_SEEN_ALERT_SPLASH_COOKIE) || false;
} catch (e) {
console.error("getSeenAlertSplash:", e);
return false;
}
},
};
export default MetabaseCookies;

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,109 +0,0 @@
/* eslint-disable react/prop-types */
import cx from "classnames";
import { Component } from "react";
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";
export class AlertEducationalScreen extends Component {
render() {
const { onProceed } = this.props;
return (
<div
className={cx(CS.pt2, CS.pb4, CS.mlAuto, CS.mrAuto, CS.textCentered)}
>
<div className={CS.pt4}>
<h1
className={cx(CS.mb1, CS.textDark)}
>{t`The wide world of alerts`}</h1>
<h3
className={cx(CS.mb4, CS.textNormal, CS.textDark)}
>{t`There are a few different kinds of alerts you can get`}</h3>
</div>
{
// @mazameli: needed to do some negative margin spacing to match the designs
}
<div className={cx(CS.textNormal, CS.pt3)}>
<div
className={cx(CS.relative, CS.flex, CS.alignCenter, CS.pr4)}
style={{ marginLeft: -80 }}
>
<img
src="app/assets/img/alerts/education-illustration-01-raw-data.png"
srcSet="
app/assets/img/alerts/education-illustration-01-raw-data.png 1x,
app/assets/img/alerts/education-illustration-01-raw-data@2x.png 2x,
"
/>
<p
className={cx(
CS.ml2,
CS.textLeft,
AlertModalsS.AlertModalsTextWidth,
)}
>{jt`When a raw data question ${(
<strong key="bold">{t`returns any results`}</strong>
)}`}</p>
</div>
<div
className={cx(
CS.relative,
CS.flex,
CS.alignCenter,
CS.flexReverse,
CS.pl4,
)}
style={{ marginTop: -50, marginRight: -80 }}
>
<img
src="app/assets/img/alerts/education-illustration-02-goal.png"
srcSet="
app/assets/img/alerts/education-illustration-02-goal.png 1x,
app/assets/img/alerts/education-illustration-02-goal@2x.png 2x,
"
/>
<p
className={cx(
CS.mr2,
CS.textRight,
AlertModalsS.AlertModalsTextWidth,
)}
>{jt`When a line or bar ${(
<strong key="bold">{t`crosses a goal line`}</strong>
)}`}</p>
</div>
<div
className={cx(CS.relative, CS.flex, CS.alignCenter)}
style={{ marginTop: -60, marginLeft: -55 }}
>
<img
src="app/assets/img/alerts/education-illustration-03-progress.png"
srcSet="
app/assets/img/alerts/education-illustration-03-progress.png 1x,
app/assets/img/alerts/education-illustration-03-progress@2x.png 2x,
"
/>
<p
className={cx(
CS.ml2,
CS.textLeft,
AlertModalsS.AlertModalsTextWidth,
)}
>{jt`When a progress bar ${(
<strong key="bold">{t`reaches its goal`}</strong>
)}`}</p>
</div>
</div>
<Button
primary
className={CS.mt4}
onClick={onProceed}
>{t`Set up an alert`}</Button>
</div>
);
}
}

View File

@@ -3,6 +3,8 @@ import { t } from "ttag";
import { skipToken, useListCardAlertsQuery } from "metabase/api";
import { useSelector } from "metabase/lib/redux";
import { CommonNotificationsMenuItem } from "metabase/notifications/CommonNotificationsMenuItem";
import { hasProperGoalForAlert, isAlert } 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";
@@ -14,22 +16,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,7 +1,9 @@
import { t } from "ttag";
import { skipToken, useListCardAlertsQuery } from "metabase/api";
import { useSelector } from "metabase/lib/redux";
import { CommonNotificationsMenuItem } from "metabase/notifications/CommonNotificationsMenuItem";
import { isSubscription } from "metabase/notifications/utils";
import { canManageSubscriptions as canManageSubscriptionsSelector } from "metabase/selectors/user";
import type Question from "metabase-lib/v1/Question";
@@ -14,12 +16,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 +28,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 send these alerts?`}</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

@@ -21,18 +21,22 @@ 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}
/>
{isAlert && (
<AlertGoalToggles
alertType={alertType}
alert={alert}
onAlertChange={onAlertChange}
/>
)}
<AlertEditSchedule
alert={alert}
alertType={alertType}

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,94 @@
import { useState } from "react";
import { useMount } from "react-use";
import { useListCardAlertsQuery } from "metabase/api";
import Modal from "metabase/components/Modal";
import { isAlert, isSubscription } from "metabase/notifications/utils";
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 = ({
notificationType,
question,
onClose,
}: {
notificationType: "alert" | "subscription";
question: Question;
onClose: () => void;
}) => {
const [showingElement, setShowingElement] =
useState<AlertModalMode>("list-modal");
const [editingAlert, setEditingAlert] = useState<Alert | null>(null);
const { data: questionNotifications } = useListCardAlertsQuery({
id: question.id(),
});
const filteredByTypeNotifications = questionNotifications?.filter(
notificationType === "alert" ? isAlert : isSubscription,
);
useMount(() => {
// if there are no alerts when the component mounts, show the create modal
if (
filteredByTypeNotifications &&
filteredByTypeNotifications.length === 0
) {
setShowingElement("create-modal");
}
});
if (!question.isSaved()) {
return null;
}
return (
<>
<Modal isOpen={showingElement === "list-modal"} onClose={onClose}>
<AlertListModalContent
notificationType={notificationType}
questionAlerts={filteredByTypeNotifications}
onCreate={() => setShowingElement("create-modal")}
onEdit={(alert: Alert) => {
setEditingAlert(alert);
setShowingElement("update-modal");
}}
onClose={onClose}
/>
</Modal>
<Modal
medium
isOpen={showingElement === "create-modal"}
onClose={onClose}
>
<CreateAlertModalContent
notificationType={notificationType}
onCancel={onClose}
onAlertCreated={onClose}
/>
</Modal>
<Modal
medium
isOpen={showingElement === "update-modal"}
onClose={onClose}
>
{editingAlert && (
<UpdateAlertModalContent
alert={editingAlert}
notificationType={notificationType}
onCancel={onClose}
onAlertUpdated={onClose}
/>
)}
</Modal>
</>
);
};

View File

@@ -12,13 +12,15 @@ import type { Alert } from "metabase-types/api";
import { AlertListItem } from "./AlertListItem";
type AlertListPopoverContentProps = {
notificationType: "alert" | "subscription";
questionAlerts?: Alert[];
onCreate: () => void;
onEdit: (alert: Alert) => void;
onClose: () => void;
};
export const AlertListPopoverContent = ({
export const AlertListModalContent = ({
notificationType,
questionAlerts,
onCreate,
onEdit,
@@ -90,7 +92,9 @@ export const AlertListPopoverContent = ({
onClick={onCreate}
>
<Icon name="add" style={{ marginLeft: 9, marginRight: 17 }} />{" "}
{t`Set up your own alert`}
{notificationType === "alert"
? t`Set up your own alert`
: t`Set up your own subscription`}
</a>
</div>
)}

View File

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

View File

@@ -4,12 +4,10 @@ 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";
import { alertIsValid } from "metabase/lib/alert";
import MetabaseCookies from "metabase/lib/cookies";
import {
getHasConfiguredAnyChannel,
getHasConfiguredEmailChannel,
@@ -23,20 +21,23 @@ import {
} from "metabase/query_builder/selectors";
import { getUser, getUserIsAdmin } from "metabase/selectors/user";
import { Flex } from "metabase/ui";
import { getDefaultAlert } from "metabase-lib/v1/Alert";
import { ALERT_TYPE_ROWS, getDefaultAlert } from "metabase-lib/v1/Alert";
import type { Alert } from "metabase-types/api";
import { AlertEditForm } from "./AlertEditForm";
import { AlertEducationalScreen } from "./AlertEducationalScreen";
import { AlertModalTitle } from "./AlertModalTitle";
import AlertModalsS from "./AlertModals.module.css";
import { AlertEditForm } from "../AlertEditForm";
import { AlertModalTitle } from "../AlertModalTitle";
import AlertModalsS from "../AlertModals.module.css";
import ChannelSetupModal from "./ChannelSetupModal";
interface CreateAlertModalContentProps {
notificationType: "alert" | "subscription";
onAlertCreated: () => void;
onCancel: () => void;
}
export const CreateAlertModalContent = ({
notificationType,
onAlertCreated,
onCancel,
}: CreateAlertModalContentProps) => {
@@ -52,12 +53,13 @@ export const CreateAlertModalContent = ({
const hasConfiguredAnyChannel = getHasConfiguredAnyChannel(channelSpec);
const hasConfiguredEmailChannel = getHasConfiguredEmailChannel(channelSpec);
const [alert, setAlert] = useState<any>(
getDefaultAlert(question, user, visualizationSettings),
);
const alertType =
(notificationType === "alert" &&
question?.alertType(visualizationSettings)) ||
ALERT_TYPE_ROWS;
const [hasSeenEducationalScreen, setHasSeenEducationalScreen] = useState(
MetabaseCookies.getHasSeenAlertSplash(),
const [alert, setAlert] = useState<any>(
getDefaultAlert(question, alertType, user),
);
useEffect(() => {
@@ -80,11 +82,6 @@ export const CreateAlertModalContent = ({
onAlertCreated();
};
const proceedFromEducationalScreen = () => {
MetabaseCookies.setHasSeenAlertSplash(true);
setHasSeenEducationalScreen(true);
};
const channelRequirementsMet = isAdmin
? hasConfiguredAnyChannel
: hasConfiguredEmailChannel;
@@ -101,15 +98,7 @@ export const CreateAlertModalContent = ({
/>
);
}
if (!hasSeenEducationalScreen) {
return (
<ModalContent onClose={onCancel} data-testid="alert-education-screen">
<AlertEducationalScreen onProceed={proceedFromEducationalScreen} />
</ModalContent>
);
}
// TODO: Remove PulseEdit css hack
return (
<ModalContent data-testid="alert-create" onClose={onCancel}>
<div
@@ -118,7 +107,8 @@ export const CreateAlertModalContent = ({
>
<AlertModalTitle text={t`Let's set up your alert`} />
<AlertEditForm
alertType={question?.alertType(visualizationSettings)}
type={notificationType}
alertType={alertType}
alert={alert}
onAlertChange={onAlertChange}
/>

View File

@@ -17,6 +17,7 @@ import {
} from "metabase/query_builder/selectors";
import { getUser, getUserIsAdmin } from "metabase/selectors/user";
import { Flex } from "metabase/ui";
import { ALERT_TYPE_ROWS } from "metabase-lib/v1/Alert";
import type { Alert } from "metabase-types/api";
import { AlertEditForm } from "./AlertEditForm";
@@ -26,12 +27,14 @@ import { DeleteAlertSection } from "./DeleteAlertSection";
interface UpdateAlertModalContentProps {
alert: Alert;
notificationType: "alert" | "subscription";
onAlertUpdated: () => void;
onCancel: () => void;
}
export const UpdateAlertModalContent = ({
alert,
notificationType,
onAlertUpdated,
onCancel,
}: UpdateAlertModalContentProps) => {
@@ -63,6 +66,11 @@ export const UpdateAlertModalContent = ({
const title = isCurrentUser ? t`Edit your alert` : t`Edit alert`;
const isValid = alertIsValid(modifiedAlert, channelSpec);
const alertType =
notificationType === "subscription"
? ALERT_TYPE_ROWS
: question?.alertType(visualizationSettings);
// TODO: Remove PulseEdit css hack
return (
<ModalContent onClose={onCancel} data-testid="alert-edit">
@@ -72,7 +80,8 @@ export const UpdateAlertModalContent = ({
>
<AlertModalTitle text={title} />
<AlertEditForm
alertType={question?.alertType(visualizationSettings)}
alertType={alertType}
type={notificationType}
alert={modifiedAlert}
onAlertChange={onAlertChange}
/>

View File

@@ -1,4 +1,4 @@
export { CreateAlertModalContent } from "./CreateAlertModalContent";
export { CreateAlertModalContent } from "./CreateAlertModal/CreateAlertModalContent";
export { UpdateAlertModalContent } from "./UpdateAlertModalContent";
export { AlertEditForm } from "./AlertEditForm";
export { AlertModalTitle } from "./AlertModalTitle";
@@ -11,5 +11,4 @@ export { MultiSeriesAlertTip } from "./MultiSeriesAlertTip";
export { RawDataAlertTip } from "./RawDataAlertTip";
export { AlertGoalToggles } from "./AlertGoalToggles";
export { DeleteAlertSection } from "./DeleteAlertSection";
export { AlertEducationalScreen } from "./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,31 @@
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 { Alert, 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
);
};
export const isAlert = (notification: Alert) =>
notification.alert_condition === "goal";
export const isSubscription = (notification: Alert) =>
notification.alert_condition === "rows";

View File

@@ -10,7 +10,7 @@ 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 { CreateAlertModalContent } from "metabase/notifications/modals";
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";
@@ -51,8 +51,6 @@ interface QueryModalsProps {
}
export function QueryModals({
questionAlerts,
user,
onSave,
onCreate,
updateQuestion,
@@ -65,28 +63,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);
@@ -223,31 +205,12 @@ export function QueryModals({
return (
<Modal medium onClose={onCloseModal}>
<CreateAlertModalContent
notificationType="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 +315,5 @@ export function QueryModals({
<PreviewQueryModal onClose={onCloseModal} />
</Modal>
);
default:
return null;
}
}

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
notificationType="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

@@ -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",

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

@@ -4,10 +4,10 @@ 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 { DashboardSubscriptionMenuItem } from "metabase/notifications/DashboardSubscriptionMenuItem";
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";

View File

@@ -5,6 +5,7 @@ import { isInstanceAnalyticsCollection } from "metabase/collections/utils";
import { useSetting } from "metabase/common/hooks";
import { useDispatch, useSelector } from "metabase/lib/redux";
import { QuestionAlertsMenuItem } from "metabase/notifications/QuestionAlertsMenuItem";
import { QuestionSubscriptionsMenuItem } from "metabase/notifications/QuestionSubscriptionsMenuItem";
import { setUIControls } from "metabase/query_builder/actions";
import { MODAL_TYPES } from "metabase/query_builder/constants";
import {
@@ -89,7 +90,10 @@ export function QuestionSharingMenu({ question }: { question: Question }) {
question={question}
onClick={() => setModalType("question-alert")}
/>
<QuestionSubscriptionsMenuItem
question={question}
onClick={() => setModalType("question-subscription")}
/>
<Menu.Divider />
<PublicLinkMenuItem
hasPublicLink={hasPublicLink}

View File

@@ -1,7 +1,7 @@
import { type Ref, forwardRef } from "react";
import { DashboardSharingEmbeddingModal } from "metabase/dashboard/containers/DashboardSharingEmbeddingModal";
import { AlertPopover } from "metabase/notifications/AlertListPopoverContent/AlertPopover";
import { AlertListModal } from "metabase/notifications/modals/AlertListModal";
import { QuestionEmbedWidget } from "metabase/query_builder/components/QuestionEmbedWidget";
import { Box } from "metabase/ui";
import type Question from "metabase-lib/v1/Question";
@@ -55,9 +55,19 @@ export const SharingModals = ({
}: SharingModalProps) => {
if (modalType === "question-alert") {
return (
<AlertPopover
<AlertListModal
notificationType="alert"
question={question}
onClose={onClose}
/>
);
}
if (modalType === "question-subscription") {
return (
<AlertListModal
notificationType="subscription"
question={question}
target={<MenuTarget />}
onClose={onClose}
/>
);

View File

@@ -14,7 +14,7 @@ describe("QuestionSharingMenu > Enterprise", () => {
isAdmin: false,
});
await openMenu();
expect(screen.getByText("Create alert")).toBeInTheDocument();
expect(screen.getByText("Create alerts")).toBeInTheDocument();
});
describe("alerts permission disabled", () => {
@@ -25,7 +25,7 @@ describe("QuestionSharingMenu > Enterprise", () => {
isEnterprise: true,
});
await openMenu();
expect(screen.queryByText("Create alert")).not.toBeInTheDocument();
expect(screen.queryByText("Create alerts")).not.toBeInTheDocument();
});
it('should show a "Public link" button item if public sharing is enabled and the user lacks alerts permissions', async () => {

View File

@@ -46,14 +46,14 @@ describe("QuestionSharingMenu", () => {
describe("alerts", () => {
describe("admins", () => {
it("should show the 'Create alert' menu item if no alerts exist", async () => {
it("should show the 'Create alerts' menu item if no alerts exist", async () => {
setupQuestionSharingMenu({
isAdmin: true,
isEmailSetup: true,
alerts: [],
});
await openMenu();
expect(screen.getByText("Create alert")).toBeInTheDocument();
expect(screen.getByText("Create alerts")).toBeInTheDocument();
});
it("should show the 'Edit alerts' menu item if alerts exist", async () => {
@@ -81,14 +81,14 @@ describe("QuestionSharingMenu", () => {
});
describe("non-admins", () => {
it("should show the 'Create alert' menu item if no alerts exist", async () => {
it("should show the 'Create alerts' menu item if no alerts exist", async () => {
setupQuestionSharingMenu({
isAdmin: false,
isEmailSetup: true,
alerts: [],
});
await openMenu();
expect(screen.getByText("Create alert")).toBeInTheDocument();
expect(screen.getByText("Create alerts")).toBeInTheDocument();
});
it("should show the 'Edit alerts' menu item if alerts exist", async () => {

View File

@@ -1,15 +1,10 @@
export type EmbedModalType = "question-embed" | "dashboard-embed";
export type PublicLinkModalType =
| "question-public-link"
| "dashboard-public-link";
export type QuestionSharingModalType =
| "question-public-link"
| "question-embed"
| "question-alert";
| "question-alert"
| "question-subscription";
export type DashboardSharingModalType =
| "dashboard-public-link"
| "dashboard-embed";
export type SharingModalType = PublicLinkModalType | EmbedModalType;
// TODO: add "dashboard-subscriptions" in further Notifications refactor epic

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB