Compare commits
6 Commits
python-run
...
50766-spli
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed0a176cc5 | ||
|
|
8e26bf14e0 | ||
|
|
f654e77e8c | ||
|
|
a771bec635 | ||
|
|
814cf8a760 | ||
|
|
d3557d45d7 |
@@ -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**.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 #???)
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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}:`}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -1,4 +0,0 @@
|
||||
export { AlertCreatorTitle } from "./AlertCreatorTitle";
|
||||
export { AlertScheduleText } from "./AlertScheduleText";
|
||||
export { AlertListItem } from "./AlertListItem";
|
||||
export { AlertListPopoverContent } from "./AlertListPopoverContent";
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
)}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { AlertListModal } from "./AlertListModal";
|
||||
export { AlertListModalContent } from "./AlertListModalContent";
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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";
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
31
frontend/src/metabase/notifications/utils.ts
Normal file
31
frontend/src/metabase/notifications/utils.ts
Normal 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";
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 |
Reference in New Issue
Block a user