Compare commits
6 Commits
python-run
...
notificati
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8acad0e43 | ||
|
|
b707c21282 | ||
|
|
04517f982f | ||
|
|
c1bcf0b1eb | ||
|
|
c9390e6d22 | ||
|
|
ee635244e7 |
@@ -1,5 +1,6 @@
|
||||
export const sharingMenuButton = () => cy.findByTestId("sharing-menu-button");
|
||||
export const sharingMenu = () => cy.findByTestId("sharing-menu");
|
||||
export const sharingMenuButton = () =>
|
||||
cy.findByTestId("notifications-menu-button");
|
||||
export const sharingMenu = () => cy.findByTestId("notifications-menu");
|
||||
|
||||
export const openSharingMenu = (menuItemText?: string) => {
|
||||
sharingMenuButton().click();
|
||||
|
||||
@@ -461,7 +461,7 @@ describe(
|
||||
|
||||
it("should allow you to enable a webhook alert", () => {
|
||||
H.visitQuestion(ORDERS_COUNT_QUESTION_ID);
|
||||
cy.findByTestId("sharing-menu-button").click();
|
||||
cy.findByTestId("notifications-menu-button").click();
|
||||
H.popover().findByText("Create alert").click();
|
||||
H.modal().button("Set up an alert").click();
|
||||
H.modal().within(() => {
|
||||
@@ -469,7 +469,7 @@ describe(
|
||||
H.toggleAlertChannel(secondWebhookName);
|
||||
cy.button("Done").click();
|
||||
});
|
||||
cy.findByTestId("sharing-menu-button").click();
|
||||
cy.findByTestId("notifications-menu-button").click();
|
||||
H.popover().findByText("Edit alerts").click();
|
||||
H.popover().within(() => {
|
||||
cy.findByText("You set up an alert").should("exist");
|
||||
@@ -486,7 +486,7 @@ describe(
|
||||
|
||||
it("should allow you to test a webhook", () => {
|
||||
H.visitQuestion(ORDERS_COUNT_QUESTION_ID);
|
||||
cy.findByTestId("sharing-menu-button").click();
|
||||
cy.findByTestId("notifications-menu-button").click();
|
||||
H.popover().findByText("Create alert").click();
|
||||
H.modal().button("Set up an alert").click();
|
||||
H.modal().within(() => {
|
||||
|
||||
@@ -283,7 +283,9 @@ describe("DashboardHeaderButtonRow", () => {
|
||||
|
||||
it("should show sharing button", () => {
|
||||
setup({ isEditing: false });
|
||||
expect(screen.getByTestId("sharing-menu-button")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId("notifications-menu-button"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show editing-related buttons", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DashboardSharingMenu } from "metabase/sharing/components/SharingMenu";
|
||||
import { DashboardNotificationsMenu } from "metabase/notifications/NotificationsActionsMenu";
|
||||
import { Center, Divider } from "metabase/ui";
|
||||
|
||||
import { DashboardBookmark } from "../../DashboardBookmark";
|
||||
@@ -87,7 +87,7 @@ export const dashboardActionButtons: Record<
|
||||
!isFullscreen && !isEditing && canEdit,
|
||||
},
|
||||
[DASHBOARD_ACTION.DASHBOARD_SHARING]: {
|
||||
component: DashboardSharingMenu,
|
||||
component: DashboardNotificationsMenu,
|
||||
enabled: ({ isEditing }) => !isEditing,
|
||||
},
|
||||
[DASHBOARD_ACTION.REFRESH_WIDGET]: {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { type Ref, forwardRef } from "react";
|
||||
|
||||
import { DashboardSharingEmbeddingModal } from "metabase/dashboard/containers/DashboardSharingEmbeddingModal";
|
||||
import type { SharingModalType } from "metabase/notifications/NotificationsActionsMenu/types";
|
||||
import { DashboardPublicLinkPopover } from "metabase/sharing/components/PublicLinkPopover";
|
||||
import { Box } from "metabase/ui";
|
||||
import type { Dashboard } from "metabase-types/api";
|
||||
|
||||
const MenuTarget = forwardRef(function _MenuTarget(
|
||||
_props,
|
||||
ref: Ref<HTMLDivElement>,
|
||||
) {
|
||||
return <Box h="2rem" ref={ref} />;
|
||||
});
|
||||
|
||||
type SharingModalProps = {
|
||||
modalType: SharingModalType | null;
|
||||
dashboard: Dashboard;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const EmbeddingModals = ({
|
||||
modalType,
|
||||
onClose,
|
||||
dashboard,
|
||||
}: SharingModalProps) => {
|
||||
if (modalType === "dashboard-public-link") {
|
||||
return (
|
||||
<DashboardPublicLinkPopover
|
||||
dashboard={dashboard}
|
||||
target={<MenuTarget />}
|
||||
onClose={onClose}
|
||||
isOpen
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (modalType === "dashboard-embed" && dashboard) {
|
||||
return (
|
||||
<DashboardSharingEmbeddingModal
|
||||
key="dashboard-embed"
|
||||
dashboard={dashboard}
|
||||
onClose={onClose}
|
||||
isOpen
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -3,11 +3,17 @@ import { withRouter } from "react-router";
|
||||
import type { WithRouterProps } from "react-router/lib/withRouter";
|
||||
import { c, t } from "ttag";
|
||||
|
||||
import { isInstanceAnalyticsCollection } from "metabase/collections/utils";
|
||||
import Button from "metabase/core/components/Button";
|
||||
import Tooltip from "metabase/core/components/Tooltip";
|
||||
import type { HeaderButtonProps } from "metabase/dashboard/components/DashboardHeader/DashboardHeaderButtonRow/types";
|
||||
import { EmbeddingModals } from "metabase/dashboard/components/DashboardHeader/EmbeddingModals";
|
||||
import { useRefreshDashboard } from "metabase/dashboard/hooks";
|
||||
import type { SharingModalType } from "metabase/notifications/NotificationsActionsMenu/types";
|
||||
import { PLUGIN_MODERATION } from "metabase/plugins";
|
||||
import { EmbedMenuItem } from "metabase/sharing/components/SharingMenu/MenuItems/EmbedMenuItem";
|
||||
import { ExportPdfMenuItem } from "metabase/sharing/components/SharingMenu/MenuItems/ExportPdfMenuItem";
|
||||
import { PublicLinkMenuItem } from "metabase/sharing/components/SharingMenu/MenuItems/PublicLinkMenuItem";
|
||||
import { Icon, Menu } from "metabase/ui";
|
||||
|
||||
const DashboardActionMenuInner = ({
|
||||
@@ -22,6 +28,8 @@ const DashboardActionMenuInner = ({
|
||||
}: HeaderButtonProps & WithRouterProps): JSX.Element => {
|
||||
const [opened, setOpened] = useState(false);
|
||||
|
||||
const [modalType, setModalType] = useState<SharingModalType | null>(null);
|
||||
|
||||
const { refreshDashboard } = useRefreshDashboard({
|
||||
dashboardId: dashboard.id,
|
||||
parameterQueryParams: location.query,
|
||||
@@ -33,7 +41,15 @@ const DashboardActionMenuInner = ({
|
||||
refreshDashboard,
|
||||
);
|
||||
|
||||
const hasPublicLink = !!dashboard?.public_uuid;
|
||||
const isArchived = dashboard.archived;
|
||||
const isAnalytics =
|
||||
dashboard.collection && isInstanceAnalyticsCollection(dashboard.collection);
|
||||
|
||||
const canShare = !isArchived && !isAnalytics;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu position="bottom-end" opened={opened} onChange={setOpened}>
|
||||
<Menu.Target>
|
||||
<div>
|
||||
@@ -75,6 +91,19 @@ const DashboardActionMenuInner = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
<ExportPdfMenuItem dashboard={dashboard} />
|
||||
|
||||
{canShare && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<PublicLinkMenuItem
|
||||
hasPublicLink={hasPublicLink}
|
||||
onClick={() => setModalType("dashboard-public-link")}
|
||||
/>
|
||||
<EmbedMenuItem onClick={() => setModalType("dashboard-embed")} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{canEdit && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
@@ -105,6 +134,12 @@ const DashboardActionMenuInner = ({
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
<EmbeddingModals
|
||||
modalType={modalType}
|
||||
dashboard={dashboard}
|
||||
onClose={() => setModalType(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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,18 +0,0 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
import cx from "classnames";
|
||||
|
||||
import CS from "metabase/css/core/index.css";
|
||||
|
||||
export const AlertModalTitle = ({ text }) => (
|
||||
<div className={cx(CS.mlAuto, CS.mrAuto, CS.my4, CS.pb2, CS.textCentered)}>
|
||||
<img
|
||||
className={CS.mb3}
|
||||
src="app/assets/img/alerts/alert-bell-confetti-illustration.png"
|
||||
srcSet="
|
||||
app/assets/img/alerts/alert-bell-confetti-illustration.png 1x,
|
||||
app/assets/img/alerts/alert-bell-confetti-illustration@2x.png 2x
|
||||
"
|
||||
/>
|
||||
<h1 className={CS.textDark}>{text}</h1>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { setSharing as setDashboardSubscriptionSidebarOpen } from "metabase/dashboard/actions";
|
||||
import { getIsSharing as getIsDashboardSubscriptionSidebarOpen } from "metabase/dashboard/selectors";
|
||||
import { useDispatch, useSelector } from "metabase/lib/redux";
|
||||
import type { DashboardNotificationsModalType } from "metabase/notifications/types";
|
||||
import { Flex } from "metabase/ui";
|
||||
import type { Dashboard } from "metabase-types/api";
|
||||
|
||||
import { DashboardSubscriptionMenuItem } from "../../sharing/components/SharingMenu/MenuItems/DashboardSubscriptionMenuItem";
|
||||
|
||||
import { SharingMenu } from "./SharingMenu";
|
||||
import { SharingModals } from "./SharingModals";
|
||||
|
||||
export function DashboardNotificationsMenu({
|
||||
dashboard,
|
||||
}: {
|
||||
dashboard: Dashboard;
|
||||
}) {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const isDashboardSubscriptionSidebarOpen = useSelector(
|
||||
getIsDashboardSubscriptionSidebarOpen,
|
||||
);
|
||||
const toggleSubscriptionSidebar = () =>
|
||||
dispatch(
|
||||
setDashboardSubscriptionSidebarOpen(!isDashboardSubscriptionSidebarOpen),
|
||||
);
|
||||
|
||||
const [modalType, setModalType] =
|
||||
useState<DashboardNotificationsModalType | null>(null);
|
||||
|
||||
const isArchived = dashboard.archived;
|
||||
|
||||
if (isArchived) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex>
|
||||
<SharingMenu>
|
||||
<DashboardSubscriptionMenuItem
|
||||
onClick={toggleSubscriptionSidebar}
|
||||
dashboard={dashboard}
|
||||
/>
|
||||
</SharingMenu>
|
||||
<SharingModals
|
||||
modalType={modalType}
|
||||
dashboard={dashboard}
|
||||
onClose={() => setModalType(null)}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -2,37 +2,26 @@ import { useState } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { isInstanceAnalyticsCollection } from "metabase/collections/utils";
|
||||
import { useSetting } from "metabase/common/hooks";
|
||||
import { useDispatch, useSelector } from "metabase/lib/redux";
|
||||
import { useDispatch } from "metabase/lib/redux";
|
||||
import { QuestionAlertsMenuItem } from "metabase/notifications/QuestionAlertsMenuItem";
|
||||
import { QuestionSubscriptionsMenuItem } from "metabase/notifications/QuestionSubscriptionsMenuItem";
|
||||
import type { QuestionNotificationsModalType } from "metabase/notifications/types";
|
||||
import { setUIControls } from "metabase/query_builder/actions";
|
||||
import { MODAL_TYPES } from "metabase/query_builder/constants";
|
||||
import {
|
||||
canManageSubscriptions as canManageSubscriptionsSelector,
|
||||
getUserIsAdmin,
|
||||
} from "metabase/selectors/user";
|
||||
import { Flex, Menu } from "metabase/ui";
|
||||
import { Flex } from "metabase/ui";
|
||||
import type Question from "metabase-lib/v1/Question";
|
||||
|
||||
import { EmbedMenuItem } from "./MenuItems/EmbedMenuItem";
|
||||
import { PublicLinkMenuItem } from "./MenuItems/PublicLinkMenuItem";
|
||||
import { SharingButton, SharingMenu } from "./SharingMenu";
|
||||
import { SharingModals } from "./SharingModals";
|
||||
import type { QuestionSharingModalType } from "./types";
|
||||
|
||||
export function QuestionSharingMenu({ question }: { question: Question }) {
|
||||
const dispatch = useDispatch();
|
||||
const [modalType, setModalType] = useState<QuestionSharingModalType | null>(
|
||||
null,
|
||||
);
|
||||
const hasPublicLink = !!question?.publicUUID?.();
|
||||
const [modalType, setModalType] =
|
||||
useState<QuestionNotificationsModalType | null>(null);
|
||||
const isModel = question.type() === "model";
|
||||
const isArchived = question.isArchived();
|
||||
const isPublicSharingEnabled = useSetting("enable-public-sharing");
|
||||
const isAdmin = useSelector(getUserIsAdmin);
|
||||
const collection = question.collection();
|
||||
const isAnalytics = collection && isInstanceAnalyticsCollection(collection);
|
||||
const canManageSubscriptions = useSelector(canManageSubscriptionsSelector);
|
||||
|
||||
if (isModel || isArchived || isAnalytics) {
|
||||
return null;
|
||||
@@ -53,35 +42,6 @@ export function QuestionSharingMenu({ question }: { question: Question }) {
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!isAdmin &&
|
||||
(!isPublicSharingEnabled || !hasPublicLink) &&
|
||||
!canManageSubscriptions
|
||||
) {
|
||||
return (
|
||||
<SharingButton
|
||||
tooltip={t`Ask your admin to create a public link`}
|
||||
disabled
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAdmin && hasPublicLink && !canManageSubscriptions) {
|
||||
return (
|
||||
<Flex>
|
||||
<SharingButton
|
||||
tooltip={t`Public link`}
|
||||
onClick={() => setModalType("question-public-link")}
|
||||
/>
|
||||
<SharingModals
|
||||
modalType={modalType}
|
||||
question={question}
|
||||
onClose={() => setModalType(null)}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex>
|
||||
<SharingMenu>
|
||||
@@ -89,13 +49,10 @@ export function QuestionSharingMenu({ question }: { question: Question }) {
|
||||
question={question}
|
||||
onClick={() => setModalType("question-alert")}
|
||||
/>
|
||||
|
||||
<Menu.Divider />
|
||||
<PublicLinkMenuItem
|
||||
hasPublicLink={hasPublicLink}
|
||||
onClick={() => setModalType("question-public-link")}
|
||||
<QuestionSubscriptionsMenuItem
|
||||
question={question}
|
||||
onClick={() => setModalType("question-subscription")}
|
||||
/>
|
||||
<EmbedMenuItem onClick={() => setModalType("question-embed")} />
|
||||
</SharingMenu>
|
||||
<SharingModals
|
||||
modalType={modalType}
|
||||
@@ -4,6 +4,7 @@ import { t } from "ttag";
|
||||
import { ToolbarButton } from "metabase/components/ToolbarButton";
|
||||
import { Menu } from "metabase/ui";
|
||||
|
||||
// TODO: rename to notifications
|
||||
export function SharingMenu({
|
||||
children,
|
||||
tooltip,
|
||||
@@ -17,14 +18,14 @@ export function SharingMenu({
|
||||
<Menu withinPortal position="bottom-end">
|
||||
<Menu.Target>
|
||||
<ToolbarButton
|
||||
icon="share"
|
||||
data-testid="sharing-menu-button"
|
||||
tooltipLabel={tooltip ?? t`Sharing`}
|
||||
aria-label={tooltip ?? t`Sharing`}
|
||||
icon="alert"
|
||||
data-testid="notifications-menu-button"
|
||||
tooltipLabel={tooltip ?? t`Notifications`}
|
||||
aria-label={tooltip ?? t`Notifications`}
|
||||
disabled={hasNoChildren}
|
||||
/>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown data-testid="sharing-menu">{children}</Menu.Dropdown>
|
||||
<Menu.Dropdown data-testid="notifications-menu">{children}</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
@@ -45,7 +46,7 @@ export const SharingButton = forwardRef(function _SharingButton(
|
||||
<ToolbarButton
|
||||
ref={ref}
|
||||
icon="share"
|
||||
data-testid="sharing-menu-button"
|
||||
data-testid="notifications-menu-button"
|
||||
tooltipLabel={tooltip ?? t`Sharing`}
|
||||
aria-label={tooltip ?? t`Sharing`}
|
||||
onClick={onClick}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { AlertListModal } from "metabase/notifications/modals/AlertListModal/AlertListModal";
|
||||
import type {
|
||||
DashboardNotificationsModalType,
|
||||
QuestionNotificationsModalType,
|
||||
} from "metabase/notifications/types";
|
||||
import type Question from "metabase-lib/v1/Question";
|
||||
import type { Dashboard } from "metabase-types/api";
|
||||
|
||||
type BaseSharingModalProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
type SharingModalProps = BaseSharingModalProps &
|
||||
(
|
||||
| {
|
||||
modalType: QuestionNotificationsModalType;
|
||||
question: Question;
|
||||
dashboard?: never;
|
||||
}
|
||||
| {
|
||||
modalType: DashboardNotificationsModalType;
|
||||
dashboard: Dashboard;
|
||||
question?: never;
|
||||
}
|
||||
| {
|
||||
modalType: null;
|
||||
question?: Question;
|
||||
dashboard?: Dashboard;
|
||||
}
|
||||
);
|
||||
|
||||
export const SharingModals = ({
|
||||
modalType,
|
||||
onClose,
|
||||
question,
|
||||
// dashboard,
|
||||
}: SharingModalProps) => {
|
||||
if (modalType === "question-alert") {
|
||||
return <AlertListModal question={question} onClose={onClose} />;
|
||||
}
|
||||
|
||||
if (modalType === "question-subscription") {
|
||||
return <AlertListModal question={question} onClose={onClose} />;
|
||||
}
|
||||
|
||||
// TODO: add Dashboard modal
|
||||
|
||||
// if (modalType === "dashboard-public-link") {
|
||||
// return (
|
||||
// <DashboardPublicLinkPopover
|
||||
// dashboard={dashboard}
|
||||
// target={<MenuTarget />}
|
||||
// onClose={onClose}
|
||||
// isOpen
|
||||
// />
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// if (modalType === "dashboard-embed" && dashboard) {
|
||||
// return (
|
||||
// <DashboardSharingEmbeddingModal
|
||||
// key="dashboard-embed"
|
||||
// dashboard={dashboard}
|
||||
// onClose={onClose}
|
||||
// isOpen
|
||||
// />
|
||||
// );
|
||||
// }
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./DashboardNotificationsMenu";
|
||||
export * from "./QuestionSharingMenu";
|
||||
@@ -13,7 +13,7 @@ describe("DashboardSharingMenu", () => {
|
||||
setupDashboardSharingMenu({
|
||||
isAdmin: true,
|
||||
});
|
||||
expect(screen.getByTestId("sharing-menu-button")).toHaveAttribute(
|
||||
expect(screen.getByTestId("notifications-menu-button")).toHaveAttribute(
|
||||
"aria-label",
|
||||
"Sharing",
|
||||
);
|
||||
@@ -25,7 +25,9 @@ describe("DashboardSharingMenu", () => {
|
||||
dashboard: { archived: true },
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("sharing-menu-button")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("notifications-menu-button"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("dashboard subscriptions", () => {
|
||||
@@ -35,7 +35,7 @@ describe("QuestionSharingMenu > Enterprise", () => {
|
||||
hasPublicLink: true,
|
||||
isEnterprise: true,
|
||||
});
|
||||
const sharingButton = screen.getByTestId("sharing-menu-button");
|
||||
const sharingButton = screen.getByTestId("notifications-menu-button");
|
||||
|
||||
expect(sharingButton).toBeEnabled();
|
||||
expect(sharingButton).toHaveAttribute("aria-label", "Public link");
|
||||
@@ -50,7 +50,7 @@ describe("QuestionSharingMenu > Enterprise", () => {
|
||||
isEnterprise: true,
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByTestId("sharing-menu-button"));
|
||||
await userEvent.click(screen.getByTestId("notifications-menu-button"));
|
||||
|
||||
expect(
|
||||
screen.getByTestId("public-link-popover-content"),
|
||||
@@ -67,7 +67,7 @@ describe("QuestionSharingMenu > Enterprise", () => {
|
||||
canManageSubscriptions: false,
|
||||
isEnterprise: true,
|
||||
});
|
||||
const sharingButton = screen.getByTestId("sharing-menu-button");
|
||||
const sharingButton = screen.getByTestId("notifications-menu-button");
|
||||
|
||||
expect(sharingButton).toBeDisabled();
|
||||
expect(sharingButton).toHaveAttribute(
|
||||
@@ -83,7 +83,7 @@ describe("QuestionSharingMenu > Enterprise", () => {
|
||||
canManageSubscriptions: false,
|
||||
hasPublicLink: false,
|
||||
});
|
||||
const sharingButton = screen.getByTestId("sharing-menu-button");
|
||||
const sharingButton = screen.getByTestId("notifications-menu-button");
|
||||
|
||||
expect(sharingButton).toBeDisabled();
|
||||
expect(sharingButton).toHaveAttribute(
|
||||
@@ -108,6 +108,8 @@ describe("QuestionSharingMenu > Enterprise", () => {
|
||||
}),
|
||||
},
|
||||
});
|
||||
expect(screen.queryByTestId("sharing-menu-button")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("notifications-menu-button"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -10,14 +10,16 @@ describe("QuestionSharingMenu", () => {
|
||||
setupQuestionSharingMenu({
|
||||
question: { type: "model" },
|
||||
});
|
||||
expect(screen.queryByTestId("sharing-menu-button")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("notifications-menu-button"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should have a 'sharing' tooltip by default", () => {
|
||||
setupQuestionSharingMenu({
|
||||
isAdmin: true,
|
||||
});
|
||||
expect(screen.getByTestId("sharing-menu-button")).toHaveAttribute(
|
||||
expect(screen.getByTestId("notifications-menu-button")).toHaveAttribute(
|
||||
"aria-label",
|
||||
"Sharing",
|
||||
);
|
||||
@@ -29,7 +31,9 @@ describe("QuestionSharingMenu", () => {
|
||||
question: { archived: true },
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("sharing-menu-button")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("notifications-menu-button"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should prompt you to save an unsaved question", async () => {
|
||||
@@ -38,7 +42,7 @@ describe("QuestionSharingMenu", () => {
|
||||
question: { id: undefined },
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("sharing-menu-button")).toHaveAttribute(
|
||||
expect(screen.getByTestId("notifications-menu-button")).toHaveAttribute(
|
||||
"aria-label",
|
||||
"You must save this question before sharing",
|
||||
);
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
} from "metabase-types/api/mocks";
|
||||
import { createMockState } from "metabase-types/store/mocks";
|
||||
|
||||
import { DashboardSharingMenu } from "../DashboardSharingMenu";
|
||||
import { DashboardNotificationsMenu } from "../DashboardNotificationsMenu";
|
||||
import { QuestionSharingMenu } from "../QuestionSharingMenu";
|
||||
|
||||
// This is a fake sidebar that we can use to check if the correct redux state is getting updated
|
||||
@@ -142,7 +142,7 @@ export function setupDashboardSharingMenu({
|
||||
|
||||
renderWithProviders(
|
||||
<div>
|
||||
<DashboardSharingMenu dashboard={dashboard} />
|
||||
<DashboardNotificationsMenu dashboard={dashboard} />
|
||||
<FakeSidebar />
|
||||
</div>,
|
||||
{ storeInitialState: state },
|
||||
@@ -199,5 +199,5 @@ export function setupQuestionSharingMenu({
|
||||
}
|
||||
|
||||
export const openMenu = () => {
|
||||
return userEvent.click(screen.getByTestId("sharing-menu-button"));
|
||||
return userEvent.click(screen.getByTestId("notifications-menu-button"));
|
||||
};
|
||||
@@ -3,13 +3,4 @@ export type PublicLinkModalType =
|
||||
| "question-public-link"
|
||||
| "dashboard-public-link";
|
||||
|
||||
export type QuestionSharingModalType =
|
||||
| "question-public-link"
|
||||
| "question-embed"
|
||||
| "question-alert";
|
||||
|
||||
export type DashboardSharingModalType =
|
||||
| "dashboard-public-link"
|
||||
| "dashboard-embed";
|
||||
|
||||
export type SharingModalType = PublicLinkModalType | EmbedModalType;
|
||||
@@ -3,8 +3,14 @@ import { t } from "ttag";
|
||||
import { skipToken, useListCardAlertsQuery } from "metabase/api";
|
||||
import { useSelector } from "metabase/lib/redux";
|
||||
import { CommonNotificationsMenuItem } from "metabase/notifications/CommonNotificationsMenuItem";
|
||||
import { hasProperGoalForAlert } from "metabase/notifications/utils";
|
||||
import { getVisualizationSettings } from "metabase/query_builder/selectors";
|
||||
import { canManageSubscriptions as canManageSubscriptionsSelector } from "metabase/selectors/user";
|
||||
import type Question from "metabase-lib/v1/Question";
|
||||
import type { Alert } from "metabase-types/api";
|
||||
|
||||
const isAlert = (notification: Alert) =>
|
||||
notification.alert_condition === "goal";
|
||||
|
||||
export function QuestionAlertsMenuItem({
|
||||
question,
|
||||
@@ -14,22 +20,29 @@ export function QuestionAlertsMenuItem({
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const canManageSubscriptions = useSelector(canManageSubscriptionsSelector);
|
||||
const visualizationSettings = useSelector(getVisualizationSettings);
|
||||
|
||||
const { data: questionAlerts, isLoading } = useListCardAlertsQuery({
|
||||
const { data: questionNotifications, isLoading } = useListCardAlertsQuery({
|
||||
id: question.id() ?? skipToken,
|
||||
});
|
||||
|
||||
const alerts = questionNotifications?.filter(isAlert);
|
||||
|
||||
const canAddAlertOnThisQuestion = hasProperGoalForAlert({
|
||||
question,
|
||||
visualizationSettings,
|
||||
});
|
||||
const showAlerts = question.canRun() && !isLoading && canManageSubscriptions;
|
||||
|
||||
if (!showAlerts) {
|
||||
if (!showAlerts || !canAddAlertOnThisQuestion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasAlerts = !!questionAlerts?.length;
|
||||
const hasAlerts = !!alerts?.length;
|
||||
|
||||
return (
|
||||
<CommonNotificationsMenuItem
|
||||
title={hasAlerts ? t`Edit alerts` : t`Create alert`}
|
||||
title={hasAlerts ? t`Edit alerts` : t`Create alerts`}
|
||||
iconName={hasAlerts ? "alert_filled" : "alert"}
|
||||
onClick={onClick}
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
import { skipToken, useListCardAlertsQuery } from "metabase/api";
|
||||
import { useSelector } from "metabase/lib/redux";
|
||||
import { CommonNotificationsMenuItem } from "metabase/notifications/CommonNotificationsMenuItem";
|
||||
import { canManageSubscriptions as canManageSubscriptionsSelector } from "metabase/selectors/user";
|
||||
import type Question from "metabase-lib/v1/Question";
|
||||
import type { Alert } from "metabase-types/api";
|
||||
|
||||
const isSubscription = (notification: Alert) =>
|
||||
notification.alert_condition === "rows";
|
||||
|
||||
export function QuestionSubscriptionsMenuItem({
|
||||
question,
|
||||
@@ -14,12 +19,11 @@ export function QuestionSubscriptionsMenuItem({
|
||||
}) {
|
||||
const canManageSubscriptions = useSelector(canManageSubscriptionsSelector);
|
||||
|
||||
// const { data: questionAlerts, isLoading } = useListCardAlertsQuery({
|
||||
// id: question.id() ?? skipToken,
|
||||
// });
|
||||
const { data: questionNotifications, isLoading } = useListCardAlertsQuery({
|
||||
id: question.id() ?? skipToken,
|
||||
});
|
||||
|
||||
const questionSubscriptions = [];
|
||||
const isLoading = false;
|
||||
const subscriptions = questionNotifications?.filter(isSubscription);
|
||||
|
||||
const showAlerts = question.canRun() && !isLoading && canManageSubscriptions;
|
||||
|
||||
@@ -27,7 +31,7 @@ export function QuestionSubscriptionsMenuItem({
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasSubscriptions = !!questionSubscriptions?.length;
|
||||
const hasSubscriptions = !!subscriptions?.length;
|
||||
|
||||
return (
|
||||
<CommonNotificationsMenuItem
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import cx from "classnames";
|
||||
import type { JSX } from "react";
|
||||
import { useMount } from "react-use";
|
||||
import { t } from "ttag";
|
||||
import _ from "underscore";
|
||||
|
||||
import CS from "metabase/css/core/index.css";
|
||||
import Users from "metabase/entities/users";
|
||||
import { connect } from "metabase/lib/redux";
|
||||
import { fetchPulseFormInput } from "metabase/notifications/pulse/actions";
|
||||
import { NotificationChannelsPicker } from "metabase/notifications/pulse/components/NotificationChannelsPicker";
|
||||
import { getPulseFormInput } from "metabase/notifications/pulse/selectors";
|
||||
import { getUser } from "metabase/selectors/user";
|
||||
import type {
|
||||
Alert,
|
||||
Channel,
|
||||
ChannelApiResponse,
|
||||
User,
|
||||
} from "metabase-types/api";
|
||||
import type { State } from "metabase-types/store";
|
||||
|
||||
import { getScheduleFromChannel } from "./schedule";
|
||||
|
||||
type AlertEditChannelsProps = {
|
||||
formInput: ChannelApiResponse | undefined | null;
|
||||
user: User | null;
|
||||
users: User[];
|
||||
alert: Alert;
|
||||
fetchPulseFormInput: () => Promise<ChannelApiResponse>;
|
||||
onAlertChange: (alert: Alert) => void;
|
||||
};
|
||||
|
||||
const AlertEditChannelsInner = ({
|
||||
formInput,
|
||||
user,
|
||||
users,
|
||||
alert,
|
||||
fetchPulseFormInput,
|
||||
onAlertChange,
|
||||
}: AlertEditChannelsProps): JSX.Element | null => {
|
||||
useMount(() => {
|
||||
fetchPulseFormInput();
|
||||
});
|
||||
|
||||
const onSetPulse = (alert: Alert) => {
|
||||
// If the pulse channel has been added, it PulseEditChannels puts the default schedule to it
|
||||
// We want to have same schedule for all channels
|
||||
const schedule = getScheduleFromChannel(
|
||||
alert.channels.find(c => c.channel_type === "email") as Channel, // TODO: remove typecast
|
||||
);
|
||||
|
||||
onAlertChange({
|
||||
...alert,
|
||||
channels: alert.channels.map(channel => ({ ...channel, ...schedule })),
|
||||
});
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3
|
||||
className={cx(CS.textDark, CS.mb3)}
|
||||
>{t`Where do you want to be alerted?`}</h3>
|
||||
<div className={CS.mb2}>
|
||||
<NotificationChannelsPicker
|
||||
pulse={alert}
|
||||
channels={formInput?.channels}
|
||||
user={user}
|
||||
users={users}
|
||||
setPulse={onSetPulse}
|
||||
emailRecipientText={t`Email alerts to:`}
|
||||
invalidRecipientText={domains =>
|
||||
t`You're only allowed to email alerts to addresses ending in ${domains}`
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AlertEditChannels = _.compose(
|
||||
Users.loadList(),
|
||||
connect(
|
||||
(state: State) => ({
|
||||
user: getUser(state),
|
||||
formInput: getPulseFormInput(state),
|
||||
}),
|
||||
{
|
||||
fetchPulseFormInput,
|
||||
},
|
||||
),
|
||||
)(AlertEditChannelsInner);
|
||||
@@ -3,6 +3,7 @@ import { Component } from "react";
|
||||
|
||||
import { connect } from "metabase/lib/redux";
|
||||
import { getUserIsAdmin } from "metabase/selectors/user";
|
||||
import { Flex } from "metabase/ui";
|
||||
|
||||
import { AlertEditChannels } from "./AlertEditChannels";
|
||||
import { AlertEditSchedule } from "./AlertEditSchedule";
|
||||
@@ -21,18 +22,23 @@ class AlertEditFormInner extends Component {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { alertType, alert, isAdmin, onAlertChange } = this.props;
|
||||
const { type, alertType, alert, isAdmin, onAlertChange } = this.props;
|
||||
|
||||
const isAlert = type === "alert";
|
||||
|
||||
// the schedule should be same for all channels so we can use the first one
|
||||
const schedule = getScheduleFromChannel(alert.channels[0]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AlertGoalToggles
|
||||
alertType={alertType}
|
||||
alert={alert}
|
||||
onAlertChange={onAlertChange}
|
||||
/>
|
||||
<Flex gap={40} direction="column">
|
||||
{isAlert && (
|
||||
<AlertGoalToggles
|
||||
alertType={alertType}
|
||||
alert={alert}
|
||||
onAlertChange={onAlertChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AlertEditSchedule
|
||||
alert={alert}
|
||||
alertType={alertType}
|
||||
@@ -42,7 +48,7 @@ class AlertEditFormInner extends Component {
|
||||
{isAdmin && (
|
||||
<AlertEditChannels alert={alert} onAlertChange={onAlertChange} />
|
||||
)}
|
||||
</div>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -33,11 +33,11 @@ export function AlertEditSchedule({
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className={cx(CS.mt4, CS.mb3, CS.textDark)}>
|
||||
<h3 className={cx(CS.mb3, CS.textDark)}>
|
||||
{t`How often should we check for results?`}
|
||||
</h3>
|
||||
|
||||
<div className={cx(CS.bordered, CS.rounded, CS.mb2)}>
|
||||
<div className={cx(CS.bordered, CS.rounded)}>
|
||||
{alertType === ALERT_TYPE_ROWS && <RawDataAlertTip />}
|
||||
<div className={cx(CS.p3, CS.bgLight)}>
|
||||
<SchedulePicker
|
||||
@@ -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,66 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { useListCardAlertsQuery } from "metabase/api";
|
||||
import Modal from "metabase/components/Modal";
|
||||
import type Question from "metabase-lib/v1/Question";
|
||||
import type { Alert } from "metabase-types/api";
|
||||
|
||||
import { CreateAlertModalContent, UpdateAlertModalContent } from "../index";
|
||||
|
||||
import { AlertListModalContent } from "./AlertListModalContent";
|
||||
|
||||
type AlertModalMode = "list-modal" | "create-modal" | "update-modal";
|
||||
|
||||
export const AlertListModal = ({
|
||||
question,
|
||||
onClose,
|
||||
}: {
|
||||
question: Question;
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
const [showingElement, setShowingElement] =
|
||||
useState<AlertModalMode>("list-modal");
|
||||
const [editingAlert, setEditingAlert] = useState<Alert | null>(null);
|
||||
|
||||
const { data: questionAlerts } = useListCardAlertsQuery({
|
||||
id: question.id(),
|
||||
});
|
||||
|
||||
if (!question.isSaved()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal isOpen={showingElement === "list-modal"} onClose={onClose}>
|
||||
<AlertListModalContent
|
||||
questionAlerts={questionAlerts}
|
||||
onCreate={() => setShowingElement("create-modal")}
|
||||
onEdit={(alert: Alert) => {
|
||||
setEditingAlert(alert);
|
||||
setShowingElement("update-modal");
|
||||
}}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal isOpen={showingElement === "create-modal"} onClose={onClose}>
|
||||
<CreateAlertModalContent
|
||||
type="alert"
|
||||
onCancel={onClose}
|
||||
onAlertCreated={onClose}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal isOpen={showingElement === "update-modal"} onClose={onClose}>
|
||||
{editingAlert && (
|
||||
<UpdateAlertModalContent
|
||||
alert={editingAlert}
|
||||
onCancel={onClose}
|
||||
onAlertUpdated={onClose}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -18,7 +18,7 @@ type AlertListPopoverContentProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const AlertListPopoverContent = ({
|
||||
export const AlertListModalContent = ({
|
||||
questionAlerts,
|
||||
onCreate,
|
||||
onEdit,
|
||||
@@ -0,0 +1,2 @@
|
||||
export { AlertListModal } from "./AlertListModal";
|
||||
export { AlertListModalContent } from "./AlertListModalContent";
|
||||
@@ -12,7 +12,7 @@ export const AlertSettingToggle = ({
|
||||
falseText,
|
||||
setting,
|
||||
}) => (
|
||||
<div className={cx(CS.mb4, CS.pb2)}>
|
||||
<div /*className={cx(CS.mb4, CS.pb2)}*/>
|
||||
<h3 className={cx(CS.textDark, CS.mb1)}>{title}</h3>
|
||||
<Radio
|
||||
value={alert[setting]}
|
||||
@@ -6,7 +6,7 @@ import { jt, t } from "ttag";
|
||||
import Button from "metabase/core/components/Button";
|
||||
import CS from "metabase/css/core/index.css";
|
||||
|
||||
import AlertModalsS from "./AlertModals.module.css";
|
||||
import AlertModalsS from "../AlertModals.module.css";
|
||||
|
||||
export class AlertEducationalScreen extends Component {
|
||||
render() {
|
||||
@@ -4,7 +4,6 @@ import { t } from "ttag";
|
||||
|
||||
import { useGetChannelInfoQuery } from "metabase/api";
|
||||
import ButtonWithStatus from "metabase/components/ButtonWithStatus";
|
||||
import ChannelSetupModal from "metabase/components/ChannelSetupModal";
|
||||
import ModalContent from "metabase/components/ModalContent";
|
||||
import Button from "metabase/core/components/Button";
|
||||
import CS from "metabase/css/core/index.css";
|
||||
@@ -22,21 +21,23 @@ import {
|
||||
getVisualizationSettings,
|
||||
} from "metabase/query_builder/selectors";
|
||||
import { getUser, getUserIsAdmin } from "metabase/selectors/user";
|
||||
import { Flex } from "metabase/ui";
|
||||
import { Flex, Icon } from "metabase/ui";
|
||||
import { getDefaultAlert } from "metabase-lib/v1/Alert";
|
||||
import type { Alert } from "metabase-types/api";
|
||||
|
||||
import { AlertEditForm } from "./AlertEditForm";
|
||||
import { AlertEditForm } from "../AlertEditForm";
|
||||
|
||||
import { AlertEducationalScreen } from "./AlertEducationalScreen";
|
||||
import { AlertModalTitle } from "./AlertModalTitle";
|
||||
import AlertModalsS from "./AlertModals.module.css";
|
||||
import ChannelSetupModal from "./ChannelSetupModal";
|
||||
|
||||
interface CreateAlertModalContentProps {
|
||||
type: "alert" | "subscription";
|
||||
onAlertCreated: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const CreateAlertModalContent = ({
|
||||
type,
|
||||
onAlertCreated,
|
||||
onCancel,
|
||||
}: CreateAlertModalContentProps) => {
|
||||
@@ -109,20 +110,18 @@ export const CreateAlertModalContent = ({
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: Remove PulseEdit css hack
|
||||
return (
|
||||
<ModalContent data-testid="alert-create" onClose={onCancel}>
|
||||
<div
|
||||
className={cx(CS.mlAuto, CS.mrAuto, CS.mb4)}
|
||||
style={{ maxWidth: "550px" }}
|
||||
>
|
||||
<AlertModalTitle text={t`Let's set up your alert`} />
|
||||
<AlertEditForm
|
||||
alertType={question?.alertType(visualizationSettings)}
|
||||
alert={alert}
|
||||
onAlertChange={onAlertChange}
|
||||
/>
|
||||
<Flex className={AlertModalsS.AlertModalsFooter}>
|
||||
<ModalContent
|
||||
data-testid="alert-create"
|
||||
title={
|
||||
<Flex gap={10} align="center">
|
||||
<Icon name="alert" />
|
||||
{t`Create new alert`}
|
||||
</Flex>
|
||||
}
|
||||
footer={
|
||||
<Flex justify="space-between" w="100%">
|
||||
{/*<span>{"Send test message now"}</span>*/}
|
||||
<Button onClick={onCancel} className={CS.mr2}>{t`Cancel`}</Button>
|
||||
<ButtonWithStatus
|
||||
titleForState={{ default: t`Done` }}
|
||||
@@ -130,6 +129,16 @@ export const CreateAlertModalContent = ({
|
||||
onClickOperation={onCreateAlert}
|
||||
/>
|
||||
</Flex>
|
||||
}
|
||||
onClose={onCancel}
|
||||
>
|
||||
<div className={cx(CS.borderBottom)}>
|
||||
<AlertEditForm
|
||||
type={type}
|
||||
alertType={question?.alertType(visualizationSettings)}
|
||||
alert={alert}
|
||||
onAlertChange={onAlertChange}
|
||||
/>
|
||||
</div>
|
||||
</ModalContent>
|
||||
);
|
||||
@@ -15,12 +15,12 @@ import {
|
||||
getQuestion,
|
||||
getVisualizationSettings,
|
||||
} from "metabase/query_builder/selectors";
|
||||
import { getUser, getUserIsAdmin } from "metabase/selectors/user";
|
||||
import { getUserIsAdmin } from "metabase/selectors/user";
|
||||
import { Flex } from "metabase/ui";
|
||||
import type { Alert } from "metabase-types/api";
|
||||
|
||||
import { AlertEditForm } from "./AlertEditForm";
|
||||
import { AlertModalTitle } from "./AlertModalTitle";
|
||||
// import { AlertModalTitle } from "./AlertModalTitle";
|
||||
import AlertModalsS from "./AlertModals.module.css";
|
||||
import { DeleteAlertSection } from "./DeleteAlertSection";
|
||||
|
||||
@@ -37,7 +37,7 @@ export const UpdateAlertModalContent = ({
|
||||
}: UpdateAlertModalContentProps) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const user = useSelector(getUser);
|
||||
// const user = useSelector(getUser);
|
||||
const isAdmin = useSelector(getUserIsAdmin);
|
||||
const question = useSelector(getQuestion);
|
||||
const visualizationSettings = useSelector(getVisualizationSettings);
|
||||
@@ -59,8 +59,8 @@ export const UpdateAlertModalContent = ({
|
||||
onAlertUpdated();
|
||||
};
|
||||
|
||||
const isCurrentUser = alert?.creator.id === user?.id;
|
||||
const title = isCurrentUser ? t`Edit your alert` : t`Edit alert`;
|
||||
// const isCurrentUser = alert?.creator.id === user?.id;
|
||||
// const title = isCurrentUser ? t`Edit your alert` : t`Edit alert`;
|
||||
const isValid = alertIsValid(modifiedAlert, channelSpec);
|
||||
|
||||
// TODO: Remove PulseEdit css hack
|
||||
@@ -70,7 +70,7 @@ export const UpdateAlertModalContent = ({
|
||||
className={cx(CS.mlAuto, CS.mrAuto, CS.mb4)}
|
||||
style={{ maxWidth: "550px" }}
|
||||
>
|
||||
<AlertModalTitle text={title} />
|
||||
{/*<AlertModalTitle text={title} />*/}
|
||||
<AlertEditForm
|
||||
alertType={question?.alertType(visualizationSettings)}
|
||||
alert={modifiedAlert}
|
||||
@@ -1,7 +1,6 @@
|
||||
export { CreateAlertModalContent } from "./CreateAlertModalContent";
|
||||
export { CreateAlertModalContent } from "./CreateAlertModal/CreateAlertModalContent";
|
||||
export { UpdateAlertModalContent } from "./UpdateAlertModalContent";
|
||||
export { AlertEditForm } from "./AlertEditForm";
|
||||
export { AlertModalTitle } from "./AlertModalTitle";
|
||||
export { AlertAboveGoalToggle } from "./AlertAboveGoalToggle";
|
||||
export { AlertFirstOnlyToggle } from "./AlertFirstOnlyToggle";
|
||||
export { AlertSettingToggle } from "./AlertSettingToggle";
|
||||
@@ -11,5 +10,5 @@ export { MultiSeriesAlertTip } from "./MultiSeriesAlertTip";
|
||||
export { RawDataAlertTip } from "./RawDataAlertTip";
|
||||
export { AlertGoalToggles } from "./AlertGoalToggles";
|
||||
export { DeleteAlertSection } from "./DeleteAlertSection";
|
||||
export { AlertEducationalScreen } from "./AlertEducationalScreen";
|
||||
export { AlertEducationalScreen } from "./CreateAlertModal/AlertEducationalScreen";
|
||||
export { AlertEditSchedule } from "./AlertEditSchedule";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
5
frontend/src/metabase/notifications/types.ts
Normal file
5
frontend/src/metabase/notifications/types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type QuestionNotificationsModalType =
|
||||
| "question-alert"
|
||||
| "question-subscription";
|
||||
|
||||
export type DashboardNotificationsModalType = "dashboard-subscription";
|
||||
25
frontend/src/metabase/notifications/utils.ts
Normal file
25
frontend/src/metabase/notifications/utils.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
ALERT_TYPE_PROGRESS_BAR_GOAL,
|
||||
ALERT_TYPE_TIMESERIES_GOAL,
|
||||
} from "metabase-lib/v1/Alert";
|
||||
import type Question from "metabase-lib/v1/Question";
|
||||
import type { VisualizationSettings } from "metabase-types/api";
|
||||
|
||||
export const hasProperGoalForAlert = ({
|
||||
question,
|
||||
visualizationSettings,
|
||||
}: {
|
||||
question: Question | undefined;
|
||||
visualizationSettings: VisualizationSettings;
|
||||
}): boolean => {
|
||||
const alertType = question?.alertType(visualizationSettings);
|
||||
|
||||
if (!alertType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
alertType === ALERT_TYPE_TIMESERIES_GOAL ||
|
||||
alertType === ALERT_TYPE_PROGRESS_BAR_GOAL
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from "react";
|
||||
import { type JSX, type Ref, forwardRef, useCallback } from "react";
|
||||
import { push } from "react-router-redux";
|
||||
import _ from "underscore";
|
||||
|
||||
@@ -10,29 +10,37 @@ import { SaveQuestionModal } from "metabase/containers/SaveQuestionModal";
|
||||
import EntityCopyModal from "metabase/entities/containers/EntityCopyModal";
|
||||
import { useDispatch, useSelector } from "metabase/lib/redux";
|
||||
import * as Urls from "metabase/lib/urls";
|
||||
import { CreateAlertModalContent } from "metabase/notifications/AlertModals";
|
||||
import type { UpdateQuestionOpts } from "metabase/query_builder/actions/core/updateQuestion";
|
||||
import { ImpossibleToCreateModelModal } from "metabase/query_builder/components/ImpossibleToCreateModelModal";
|
||||
import NewDatasetModal from "metabase/query_builder/components/NewDatasetModal";
|
||||
import { QuestionEmbedWidget } from "metabase/query_builder/components/QuestionEmbedWidget";
|
||||
import { PreviewQueryModal } from "metabase/query_builder/components/view/PreviewQueryModal";
|
||||
import type { QueryModalType } from "metabase/query_builder/constants";
|
||||
import { MODAL_TYPES } from "metabase/query_builder/constants";
|
||||
import { getQuestionWithParameters } from "metabase/query_builder/selectors";
|
||||
import { FilterModal } from "metabase/querying/filters/components/FilterModal";
|
||||
import ArchiveQuestionModal from "metabase/questions/containers/ArchiveQuestionModal";
|
||||
import { QuestionPublicLinkPopover } from "metabase/sharing/components/PublicLinkPopover";
|
||||
import EditEventModal from "metabase/timelines/questions/containers/EditEventModal";
|
||||
import MoveEventModal from "metabase/timelines/questions/containers/MoveEventModal";
|
||||
import NewEventModal from "metabase/timelines/questions/containers/NewEventModal";
|
||||
import { Box } from "metabase/ui";
|
||||
import * as Lib from "metabase-lib";
|
||||
import type Question from "metabase-lib/v1/Question";
|
||||
import type { Alert, Card, User } from "metabase-types/api";
|
||||
import type { Card } from "metabase-types/api";
|
||||
import type { QueryBuilderMode } from "metabase-types/store";
|
||||
|
||||
import { CreateAlertModalContent } from "../../../notifications/modals";
|
||||
import { MoveQuestionModal } from "../MoveQuestionModal";
|
||||
|
||||
const MenuTarget = forwardRef(function _MenuTarget(
|
||||
_props,
|
||||
ref: Ref<HTMLDivElement>,
|
||||
) {
|
||||
return <Box h="2rem" ref={ref} />;
|
||||
});
|
||||
|
||||
interface QueryModalsProps {
|
||||
questionAlerts: Alert[];
|
||||
user: User;
|
||||
modal: QueryModalType;
|
||||
modalContext: number;
|
||||
question: Question;
|
||||
@@ -51,8 +59,6 @@ interface QueryModalsProps {
|
||||
}
|
||||
|
||||
export function QueryModals({
|
||||
questionAlerts,
|
||||
user,
|
||||
onSave,
|
||||
onCreate,
|
||||
updateQuestion,
|
||||
@@ -65,28 +71,12 @@ export function QueryModals({
|
||||
setQueryBuilderMode,
|
||||
originalQuestion,
|
||||
onChangeLocation,
|
||||
}: QueryModalsProps) {
|
||||
}: QueryModalsProps): JSX.Element {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const initialCollectionId = useGetDefaultCollectionId();
|
||||
const questionWithParameters = useSelector(getQuestionWithParameters);
|
||||
|
||||
const showAlertsAfterQuestionSaved = useCallback(() => {
|
||||
const hasAlertsCreatedByCurrentUser = _.any(
|
||||
questionAlerts,
|
||||
alert => alert.creator.id === user.id,
|
||||
);
|
||||
|
||||
if (hasAlertsCreatedByCurrentUser) {
|
||||
// TODO Atte Keinänen 11/10/17: The question was replaced and there is already an alert created by current user.
|
||||
// Should we show pop up the alerts list in this case or do nothing (as we do currently)?
|
||||
onCloseModal();
|
||||
} else {
|
||||
// HACK: in a timeout because save modal closes itself
|
||||
setTimeout(() => onOpenModal(MODAL_TYPES.CREATE_ALERT));
|
||||
}
|
||||
}, [onCloseModal, onOpenModal, questionAlerts, user.id]);
|
||||
|
||||
const onQueryChange = useCallback(
|
||||
(query: Lib.Query) => {
|
||||
const nextLegacyQuery = Lib.toLegacyQuery(query);
|
||||
@@ -221,33 +211,14 @@ export function QueryModals({
|
||||
);
|
||||
case MODAL_TYPES.CREATE_ALERT:
|
||||
return (
|
||||
<Modal medium onClose={onCloseModal}>
|
||||
<Modal onClose={onCloseModal}>
|
||||
<CreateAlertModalContent
|
||||
type="alert"
|
||||
onCancel={onCloseModal}
|
||||
onAlertCreated={onCloseModal}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
case MODAL_TYPES.SAVE_QUESTION_BEFORE_ALERT:
|
||||
return (
|
||||
<SaveQuestionModal
|
||||
question={question}
|
||||
originalQuestion={originalQuestion}
|
||||
onSave={async question => {
|
||||
await onSave(question);
|
||||
showAlertsAfterQuestionSaved();
|
||||
}}
|
||||
onCreate={async question => {
|
||||
const newQuestion = await onCreate(question);
|
||||
showAlertsAfterQuestionSaved();
|
||||
return newQuestion;
|
||||
}}
|
||||
onClose={onCloseModal}
|
||||
opened={true}
|
||||
multiStep
|
||||
initialCollectionId={initialCollectionId}
|
||||
/>
|
||||
);
|
||||
case MODAL_TYPES.SAVE_QUESTION_BEFORE_EMBED:
|
||||
return (
|
||||
<SaveQuestionModal
|
||||
@@ -352,7 +323,19 @@ export function QueryModals({
|
||||
<PreviewQueryModal onClose={onCloseModal} />
|
||||
</Modal>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
case MODAL_TYPES.QUESTION_PUBLIC_LINK:
|
||||
return (
|
||||
<QuestionPublicLinkPopover
|
||||
question={question}
|
||||
target={<MenuTarget />}
|
||||
onClose={onCloseModal}
|
||||
isOpen
|
||||
/>
|
||||
);
|
||||
|
||||
case MODAL_TYPES.QUESTION_EMBED:
|
||||
return (
|
||||
<QuestionEmbedWidget card={question._card} onClose={onCloseModal} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ErrorMessage } from "metabase/components/ErrorMessage";
|
||||
import Modal from "metabase/components/Modal";
|
||||
import ButtonsS from "metabase/css/components/buttons.module.css";
|
||||
import CS from "metabase/css/core/index.css";
|
||||
import { CreateAlertModalContent } from "metabase/notifications/AlertModals";
|
||||
import { CreateAlertModalContent } from "metabase/notifications/modals/CreateAlertModal/CreateAlertModalContent";
|
||||
import Visualization from "metabase/visualizations/components/Visualization";
|
||||
import * as Lib from "metabase-lib";
|
||||
import { ALERT_TYPE_ROWS } from "metabase-lib/v1/Alert";
|
||||
@@ -103,8 +103,9 @@ export default class VisualizationResult extends Component {
|
||||
}
|
||||
/>
|
||||
{showCreateAlertModal && (
|
||||
<Modal medium onClose={this.onCloseCreateAlertModal}>
|
||||
<Modal onClose={this.onCloseCreateAlertModal}>
|
||||
<CreateAlertModalContent
|
||||
type="alert"
|
||||
onCancel={this.onCloseCreateAlertModal}
|
||||
onAlertCreated={this.onCloseCreateAlertModal}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { JSX } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { isInstanceAnalyticsCollection } from "metabase/collections/utils";
|
||||
import {
|
||||
MODAL_TYPES,
|
||||
type QueryModalType,
|
||||
} from "metabase/query_builder/constants";
|
||||
import { EmbedMenuItem } from "metabase/sharing/components/SharingMenu/MenuItems/EmbedMenuItem";
|
||||
import { PublicLinkMenuItem } from "metabase/sharing/components/SharingMenu/MenuItems/PublicLinkMenuItem";
|
||||
import { Icon, Menu } from "metabase/ui";
|
||||
import type Question from "metabase-lib/v1/Question";
|
||||
|
||||
type EmbeddingQuestionActionsProps = {
|
||||
question: Question;
|
||||
isAdmin: boolean;
|
||||
isPublicSharingEnabled: boolean;
|
||||
onOpenModal: (modalType: QueryModalType) => void;
|
||||
};
|
||||
|
||||
export const EmbeddingQuestionActions = ({
|
||||
question,
|
||||
isAdmin,
|
||||
isPublicSharingEnabled,
|
||||
onOpenModal,
|
||||
}: EmbeddingQuestionActionsProps): JSX.Element | null => {
|
||||
const isModel = question.type() === "model";
|
||||
const isArchived = question.isArchived();
|
||||
const collection = question.collection();
|
||||
const isAnalytics = collection && isInstanceAnalyticsCollection(collection);
|
||||
|
||||
if (isModel || isArchived || isAnalytics) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasPublicLink = !!question?.publicUUID?.();
|
||||
const isUnsaved = !question.isSaved();
|
||||
|
||||
if (!isAdmin && (!isPublicSharingEnabled || !hasPublicLink)) {
|
||||
return (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Item icon={<Icon name="share" />} disabled>
|
||||
{t`Ask your admin to create a public link`}
|
||||
</Menu.Item>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (isUnsaved) {
|
||||
return (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
icon={<Icon name="share" />}
|
||||
onClick={() => onOpenModal(MODAL_TYPES.SAVE_QUESTION_BEFORE_EMBED)}
|
||||
>
|
||||
{t`You must save this question before sharing`}
|
||||
</Menu.Item>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAdmin && hasPublicLink) {
|
||||
return (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
icon={<Icon name="share" />}
|
||||
onClick={() => onOpenModal("question-public-link")}
|
||||
>
|
||||
{t`Public link`}
|
||||
</Menu.Item>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<PublicLinkMenuItem
|
||||
hasPublicLink={hasPublicLink}
|
||||
onClick={() => onOpenModal("question-public-link")}
|
||||
/>
|
||||
<EmbedMenuItem onClick={() => onOpenModal("question-embed")} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -2,9 +2,10 @@ import { type JSX, useState } from "react";
|
||||
import { c, t } from "ttag";
|
||||
import _ from "underscore";
|
||||
|
||||
import { useSetting } from "metabase/common/hooks";
|
||||
import Button from "metabase/core/components/Button";
|
||||
import Tooltip from "metabase/core/components/Tooltip";
|
||||
import { useDispatch } from "metabase/lib/redux";
|
||||
import { useDispatch, useSelector } from "metabase/lib/redux";
|
||||
import { PLUGIN_MODERATION } from "metabase/plugins";
|
||||
import {
|
||||
onOpenQuestionSettings,
|
||||
@@ -12,12 +13,14 @@ import {
|
||||
turnModelIntoQuestion,
|
||||
} from "metabase/query_builder/actions";
|
||||
import { trackTurnIntoModelClicked } from "metabase/query_builder/analytics";
|
||||
import { EmbeddingQuestionActions } from "metabase/query_builder/components/view/ViewHeader/components/QuestionActions/EmbeddingQuestionActions";
|
||||
import DatasetMetadataStrengthIndicator from "metabase/query_builder/components/view/sidebars/DatasetManagementSection/DatasetMetadataStrengthIndicator";
|
||||
import { shouldShowQuestionSettingsSidebar } from "metabase/query_builder/components/view/sidebars/QuestionSettingsSidebar";
|
||||
import {
|
||||
MODAL_TYPES,
|
||||
type QueryModalType,
|
||||
} from "metabase/query_builder/constants";
|
||||
import { getUserIsAdmin } from "metabase/selectors/user";
|
||||
import { Icon, Menu } from "metabase/ui";
|
||||
import * as Lib from "metabase-lib";
|
||||
import type Question from "metabase-lib/v1/Question";
|
||||
@@ -49,6 +52,9 @@ export const QuestionMoreActionsMenu = ({
|
||||
onOpenModal,
|
||||
onSetQueryBuilderMode,
|
||||
}: QuestionMoreActionsMenuProps): JSX.Element | null => {
|
||||
const isAdmin = useSelector(getUserIsAdmin);
|
||||
const isPublicSharingEnabled = useSetting("enable-public-sharing");
|
||||
|
||||
const [opened, setOpened] = useState(false);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
@@ -162,6 +168,13 @@ export const QuestionMoreActionsMenu = ({
|
||||
{t`Edit settings`}
|
||||
</Menu.Item>
|
||||
),
|
||||
<EmbeddingQuestionActions
|
||||
key="embeddingActions"
|
||||
question={question}
|
||||
isAdmin={isAdmin}
|
||||
isPublicSharingEnabled={isPublicSharingEnabled}
|
||||
onOpenModal={onOpenModal}
|
||||
/>,
|
||||
hasCollectionPermissions && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
|
||||
@@ -6,11 +6,11 @@ import { t } from "ttag";
|
||||
import CS from "metabase/css/core/index.css";
|
||||
import { SERVER_ERROR_TYPES } from "metabase/lib/errors";
|
||||
import MetabaseSettings from "metabase/lib/settings";
|
||||
import { QuestionSharingMenu } from "metabase/notifications/NotificationsActionsMenu";
|
||||
import RunButtonWithTooltip from "metabase/query_builder/components/RunButtonWithTooltip";
|
||||
import { canExploreResults } from "metabase/query_builder/components/view/ViewHeader/utils";
|
||||
import type { QueryModalType } from "metabase/query_builder/constants";
|
||||
import { MODAL_TYPES } from "metabase/query_builder/constants";
|
||||
import { QuestionSharingMenu } from "metabase/sharing/components/SharingMenu";
|
||||
import { Box, Button, Flex, Tooltip } from "metabase/ui";
|
||||
import * as Lib from "metabase-lib";
|
||||
import type Question from "metabase-lib/v1/Question";
|
||||
|
||||
@@ -7,7 +7,6 @@ export const MODAL_TYPES = {
|
||||
SAVED: "saved",
|
||||
ADD_TO_DASHBOARD_SAVE: "add-to-dashboard-save",
|
||||
CREATE_ALERT: "create-alert",
|
||||
SAVE_QUESTION_BEFORE_ALERT: "save-question-before-alert",
|
||||
SAVE_QUESTION_BEFORE_EMBED: "save-question-before-embed",
|
||||
FILTERS: "filters",
|
||||
TURN_INTO_DATASET: "turn-into-dataset",
|
||||
@@ -16,6 +15,8 @@ export const MODAL_TYPES = {
|
||||
EDIT_EVENT: "edit-event",
|
||||
MOVE_EVENT: "move-event",
|
||||
PREVIEW_QUERY: "preview-query",
|
||||
QUESTION_PUBLIC_LINK: "question-public-link",
|
||||
QUESTION_EMBED: "question-embed",
|
||||
} as const;
|
||||
|
||||
export type QueryModalType = (typeof MODAL_TYPES)[keyof typeof MODAL_TYPES];
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { isInstanceAnalyticsCollection } from "metabase/collections/utils";
|
||||
import { setSharing as setDashboardSubscriptionSidebarOpen } from "metabase/dashboard/actions";
|
||||
import { getIsSharing as getIsDashboardSubscriptionSidebarOpen } from "metabase/dashboard/selectors";
|
||||
import { useDispatch, useSelector } from "metabase/lib/redux";
|
||||
import { Flex, Menu } from "metabase/ui";
|
||||
import type { Dashboard } from "metabase-types/api";
|
||||
|
||||
import { DashboardSubscriptionMenuItem } from "./MenuItems/DashboardSubscriptionMenuItem";
|
||||
import { EmbedMenuItem } from "./MenuItems/EmbedMenuItem";
|
||||
import { ExportPdfMenuItem } from "./MenuItems/ExportPdfMenuItem";
|
||||
import { PublicLinkMenuItem } from "./MenuItems/PublicLinkMenuItem";
|
||||
import { SharingMenu } from "./SharingMenu";
|
||||
import { SharingModals } from "./SharingModals";
|
||||
import type { DashboardSharingModalType } from "./types";
|
||||
|
||||
export function DashboardSharingMenu({ dashboard }: { dashboard: Dashboard }) {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const isDashboardSubscriptionSidebarOpen = useSelector(
|
||||
getIsDashboardSubscriptionSidebarOpen,
|
||||
);
|
||||
const toggleSubscriptionSidebar = () =>
|
||||
dispatch(
|
||||
setDashboardSubscriptionSidebarOpen(!isDashboardSubscriptionSidebarOpen),
|
||||
);
|
||||
|
||||
const [modalType, setModalType] = useState<DashboardSharingModalType | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const hasPublicLink = !!dashboard?.public_uuid;
|
||||
const isArchived = dashboard.archived;
|
||||
const isAnalytics =
|
||||
dashboard.collection && isInstanceAnalyticsCollection(dashboard.collection);
|
||||
|
||||
const canShare = !isAnalytics;
|
||||
|
||||
if (isArchived) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex>
|
||||
<SharingMenu>
|
||||
<DashboardSubscriptionMenuItem
|
||||
onClick={toggleSubscriptionSidebar}
|
||||
dashboard={dashboard}
|
||||
/>
|
||||
<ExportPdfMenuItem dashboard={dashboard} />
|
||||
{!!canShare && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<PublicLinkMenuItem
|
||||
hasPublicLink={hasPublicLink}
|
||||
onClick={() => setModalType("dashboard-public-link")}
|
||||
/>
|
||||
<EmbedMenuItem onClick={() => setModalType("dashboard-embed")} />
|
||||
</>
|
||||
)}
|
||||
</SharingMenu>
|
||||
<SharingModals
|
||||
modalType={modalType}
|
||||
dashboard={dashboard}
|
||||
onClose={() => setModalType(null)}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { type Ref, forwardRef } from "react";
|
||||
|
||||
import { DashboardSharingEmbeddingModal } from "metabase/dashboard/containers/DashboardSharingEmbeddingModal";
|
||||
import { AlertPopover } from "metabase/notifications/AlertListPopoverContent/AlertPopover";
|
||||
import { QuestionEmbedWidget } from "metabase/query_builder/components/QuestionEmbedWidget";
|
||||
import { Box } from "metabase/ui";
|
||||
import type Question from "metabase-lib/v1/Question";
|
||||
import type { Dashboard } from "metabase-types/api";
|
||||
|
||||
import {
|
||||
DashboardPublicLinkPopover,
|
||||
QuestionPublicLinkPopover,
|
||||
} from "../PublicLinkPopover";
|
||||
|
||||
import type {
|
||||
DashboardSharingModalType,
|
||||
QuestionSharingModalType,
|
||||
} from "./types";
|
||||
|
||||
const MenuTarget = forwardRef(function _MenuTarget(
|
||||
_props,
|
||||
ref: Ref<HTMLDivElement>,
|
||||
) {
|
||||
return <Box h="2rem" ref={ref} />;
|
||||
});
|
||||
|
||||
type BaseSharingModalProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
type SharingModalProps = BaseSharingModalProps &
|
||||
(
|
||||
| {
|
||||
modalType: QuestionSharingModalType;
|
||||
question: Question;
|
||||
dashboard?: never;
|
||||
}
|
||||
| {
|
||||
modalType: DashboardSharingModalType;
|
||||
dashboard: Dashboard;
|
||||
question?: never;
|
||||
}
|
||||
| {
|
||||
modalType: null;
|
||||
question?: Question;
|
||||
dashboard?: Dashboard;
|
||||
}
|
||||
);
|
||||
|
||||
export const SharingModals = ({
|
||||
modalType,
|
||||
onClose,
|
||||
question,
|
||||
dashboard,
|
||||
}: SharingModalProps) => {
|
||||
if (modalType === "question-alert") {
|
||||
return (
|
||||
<AlertPopover
|
||||
question={question}
|
||||
target={<MenuTarget />}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (modalType === "question-public-link") {
|
||||
return (
|
||||
<QuestionPublicLinkPopover
|
||||
question={question}
|
||||
target={<MenuTarget />}
|
||||
onClose={onClose}
|
||||
isOpen
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (modalType === "question-embed" && question) {
|
||||
return <QuestionEmbedWidget card={question._card} onClose={onClose} />;
|
||||
}
|
||||
|
||||
if (modalType === "dashboard-public-link") {
|
||||
return (
|
||||
<DashboardPublicLinkPopover
|
||||
dashboard={dashboard}
|
||||
target={<MenuTarget />}
|
||||
onClose={onClose}
|
||||
isOpen
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (modalType === "dashboard-embed" && dashboard) {
|
||||
return (
|
||||
<DashboardSharingEmbeddingModal
|
||||
key="dashboard-embed"
|
||||
dashboard={dashboard}
|
||||
onClose={onClose}
|
||||
isOpen
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./DashboardSharingMenu";
|
||||
export * from "./QuestionSharingMenu";
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Fragment } from "react";
|
||||
import { Canvas, Story, Meta } from "@storybook/blocks";
|
||||
import { Box, Accordion, Stack, Text } from "metabase/ui";
|
||||
import * as AccordionStories from "./Accordion.stories";
|
||||
|
||||
<Meta of={AccordionStories} />
|
||||
|
||||
# Accordion
|
||||
|
||||
Our themed wrapper around [Mantine Accordion](https://v6.mantine.dev/core/accordion/).
|
||||
|
||||
## Docs
|
||||
|
||||
- [Mantine Accordion Docs](https://v6.mantine.dev/core/accordion/)
|
||||
|
||||
## Examples
|
||||
|
||||
<Canvas>
|
||||
<Story of={AccordionStories.Default} />
|
||||
</Canvas>
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Accordion, type AccordionProps } from "metabase/ui";
|
||||
|
||||
const args = {
|
||||
defaultValue: "first",
|
||||
};
|
||||
|
||||
const argTypes = {
|
||||
defaultValue: {
|
||||
options: ["first", "second", "third"],
|
||||
control: { type: "inline-radio" },
|
||||
},
|
||||
};
|
||||
|
||||
const DefaultTemplate = (args: AccordionProps) => (
|
||||
<Accordion {...args}>
|
||||
<Accordion.Item value="first">
|
||||
<Accordion.Control>First</Accordion.Control>
|
||||
<Accordion.Panel>FirstContent</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value="second">
|
||||
<Accordion.Control>Second</Accordion.Control>
|
||||
<Accordion.Panel>SecondContent</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value="third">
|
||||
<Accordion.Control>Third</Accordion.Control>
|
||||
<Accordion.Panel>ThirdContent</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
);
|
||||
|
||||
export default {
|
||||
title: "Data display/Accordion",
|
||||
component: Accordion,
|
||||
args,
|
||||
argTypes,
|
||||
};
|
||||
|
||||
export const Default = {
|
||||
render: DefaultTemplate,
|
||||
};
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 8.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 18 KiB |
Reference in New Issue
Block a user