Skip to content
Snippets Groups Projects
Unverified Commit 5a79539e authored by Alexander Polyankin's avatar Alexander Polyankin Committed by GitHub
Browse files

Add read-only Notifications tab (#17503)

parent 6a815f5d
No related branches found
No related tags found
No related merge requests found
Showing
with 608 additions and 0 deletions
import React from "react";
import PropTypes from "prop-types";
import { jt, t } from "ttag";
import Settings from "metabase/lib/settings";
import Button from "metabase/components/Button";
import ModalContent from "metabase/components/ModalContent";
import { FormLink, FormMessage } from "./HelpModal.styled";
const propTypes = {
onClose: PropTypes.func,
};
const HelpModal = ({ onClose }) => {
const email = Settings.get("admin-email");
return (
<ModalContent
title={t`Not seeing something listed here?`}
footer={
<Button key="close" onClick={onClose}>
{t`Got it`}
</Button>
}
onClose={onClose}
>
<FormMessage>
{t`It’s possible you may also receive emails from Metabase if you’re a member of an email distribution list, like “team@mycompany.com” and that list is used as the recipient for an alert or dashboard subscription instead of your individual email.`}
</FormMessage>
<FormMessage>
{getAdminMessage(email)}
{t`Hopefully they’ll be able to help you out!`}
</FormMessage>
</ModalContent>
);
};
HelpModal.propTypes = propTypes;
const getAdminLink = (email, text) => {
return email ? <FormLink href={`mailto:${email}`}>{text}</FormLink> : text;
};
const getAdminMessage = email => {
const adminLink = getAdminLink(email, t`your instance administrator`);
return jt`Metabase doesn’t manage those lists, so we’d recommend contacting ${adminLink}. `;
};
export default HelpModal;
import styled from "styled-components";
import { color } from "metabase/lib/colors";
import Link from "metabase/components/Link";
export const FormLink = styled(Link)`
color: ${color("brand")};
&:hover {
text-decoration: underline;
}
`;
export const FormMessage = styled.div`
&:not(:last-child) {
margin-bottom: 1rem;
}
`;
import React from "react";
import { render, screen } from "@testing-library/react";
import Settings from "metabase/lib/settings";
import HelpModal from "./HelpModal";
describe("HelpModal", () => {
it("should render with admin email", () => {
Settings.set("admin-email", "admin@example.com");
render(<HelpModal adminEmail={"admin@example.com"} />);
const link = screen.getByRole("link");
expect(link).toHaveProperty("href", "mailto:admin@example.com");
});
it("should render without admin email", () => {
Settings.set("admin-email", null);
render(<HelpModal />);
screen.getByText("administrator", { exact: false });
});
it("should close on button click", () => {
const onClose = jest.fn();
render(<HelpModal onClose={onClose} />);
screen.getByText("Got it").click();
expect(onClose).toHaveBeenCalled();
});
});
export { default } from "./HelpModal";
import React from "react";
import PropTypes from "prop-types";
import { t } from "ttag";
import Settings from "metabase/lib/settings";
import { formatFrame } from "metabase/lib/time";
import {
formatDateTimeWithUnit,
formatTimeWithUnit,
} from "metabase/lib/formatting";
import * as Urls from "metabase/lib/urls";
import {
NotificationContent,
NotificationDescription,
NotificationItemRoot,
NotificationTitle,
} from "./NotificationCard.styled";
const propTypes = {
item: PropTypes.object.isRequired,
type: PropTypes.oneOf(["pulse", "alert"]).isRequired,
user: PropTypes.object,
};
const NotificationCard = ({ item, type, user }) => {
return (
<NotificationItemRoot>
<NotificationContent>
<NotificationTitle to={formatLink(item, type)}>
{formatTitle(item, type)}
</NotificationTitle>
<NotificationDescription>
{formatDescription(item, user)}
</NotificationDescription>
</NotificationContent>
</NotificationItemRoot>
);
};
NotificationCard.propTypes = propTypes;
const formatTitle = (item, type) => {
switch (type) {
case "pulse":
return item.name;
case "alert":
return item.card.name;
}
};
const formatLink = (item, type) => {
switch (type) {
case "pulse":
return Urls.dashboard({ id: item.dashboard_id });
case "alert":
return Urls.question(item.card);
}
};
const formatDescription = (item, user) => {
const parts = [
...item.channels.map(formatChannel),
formatCreator(item, user),
];
return parts.join(" · ");
};
const formatChannel = ({
channel_type,
schedule_type,
schedule_hour,
schedule_day,
schedule_frame,
details,
}) => {
let scheduleString = "";
const options = Settings.formattingOptions();
switch (channel_type) {
case "email":
scheduleString += t`Emailed `;
break;
case "slack":
scheduleString += t`Slack’d `;
break;
default:
scheduleString += t`Sent`;
break;
}
switch (schedule_type) {
case "hourly":
scheduleString += t`hourly`;
break;
case "daily": {
const ampm = formatTimeWithUnit(schedule_hour, "hour-of-day", options);
scheduleString += t`daily at ${ampm}`;
break;
}
case "weekly": {
const ampm = formatTimeWithUnit(schedule_hour, "hour-of-day", options);
const day = formatDateTimeWithUnit(schedule_day, "day-of-week", options);
scheduleString += t`${day} at ${ampm}`;
break;
}
case "monthly": {
const ampm = formatTimeWithUnit(schedule_hour, "hour-of-day", options);
const day = formatDateTimeWithUnit(schedule_day, "day-of-week", options);
const frame = formatFrame(schedule_frame);
scheduleString += t`monthly on the ${frame} ${day} at ${ampm}`;
break;
}
}
if (channel_type === "slack") {
scheduleString += t` to ${details.channel}`;
}
return scheduleString;
};
const formatCreator = (item, user) => {
let creatorString = "";
const options = Settings.formattingOptions();
if (user?.id === item.creator?.id) {
creatorString += t`Created by you`;
} else if (item.creator?.common_name) {
creatorString += t`Created by ${item.creator.common_name}`;
} else {
creatorString += t`Created`;
}
if (item.created_at) {
const createdAt = formatDateTimeWithUnit(item.created_at, "day", options);
creatorString += t` on ${createdAt}`;
}
return creatorString;
};
export default NotificationCard;
import styled from "styled-components";
import { color } from "metabase/lib/colors";
import Link from "metabase/components/Link";
export const NotificationItemRoot = styled.div`
display: flex;
align-items: center;
padding: 1rem 1.5rem;
border: 1px solid ${color("border")};
border-radius: 6px;
background-color: ${color("white")};
&:not(:last-child) {
margin-bottom: 1.25rem;
}
`;
export const NotificationContent = styled.div`
flex: 1 1 auto;
`;
export const NotificationTitle = styled(Link)`
color: ${color("brand")};
font-weight: bold;
&:hover {
text-decoration: underline;
}
`;
export const NotificationDescription = styled.div`
color: ${color("text-medium")};
font-size: 0.75rem;
line-height: 0.875rem;
margin-top: 0.25rem;
`;
import React from "react";
import { render, screen } from "@testing-library/react";
import NotificationCard from "./NotificationCard";
const getAlert = ({
creatorId = 1,
channel_type = "email",
schedule_type = "hourly",
} = {}) => ({
card: {
name: "Alert",
},
creator: getUser({ id: creatorId }),
channels: getChannels({ channel_type, schedule_type }),
created_at: "2021-05-08T02:02:07.441Z",
});
const getPulse = ({
creatorId = 1,
channel_type = "email",
schedule_type = "hourly",
} = {}) => ({
name: "Pulse",
creator: getUser({ id: creatorId }),
channels: getChannels({ channel_type, schedule_type }),
created_at: "2021-05-08T02:02:07.441Z",
});
const getUser = ({ id = 1 } = {}) => ({
id,
common_name: "John Doe",
});
const getChannels = ({
channel_type = "email",
schedule_type = "hourly",
} = {}) => {
return [
{
channel_type,
schedule_type,
schedule_hour: 8,
schedule_day: "mon",
schedule_frame: "first",
details: {
channel: "@channel",
},
},
];
};
describe("NotificationCard", () => {
it("should render an alert", () => {
const alert = getAlert();
const user = getUser();
render(<NotificationCard item={alert} type="alert" user={user} />);
screen.getByText("Alert");
screen.getByText("Emailed hourly", { exact: false });
screen.getByText("Created by you on May 8, 2021", { exact: false });
});
it("should render a pulse", () => {
const pulse = getPulse();
const user = getUser();
render(<NotificationCard item={pulse} type="pulse" user={user} />);
screen.getByText("Pulse");
screen.getByText("Emailed hourly", { exact: false });
screen.getByText("Created by you on May 8, 2021", { exact: false });
});
it("should render a slack alert", () => {
const alert = getAlert({ channel_type: "slack" });
const user = getUser();
render(<NotificationCard item={alert} type="alert" user={user} />);
screen.getByText("Slack’d hourly to @channel", { exact: false });
});
it("should render a daily alert", () => {
const alert = getAlert({ schedule_type: "daily" });
const user = getUser();
render(<NotificationCard item={alert} type="alert" user={user} />);
screen.getByText("Emailed daily at 8:00 AM", { exact: false });
});
it("should render a weekly alert", () => {
const alert = getAlert({ schedule_type: "weekly" });
const user = getUser();
render(<NotificationCard item={alert} type="alert" user={user} />);
screen.getByText("Emailed Monday at 8:00 AM", { exact: false });
});
it("should render a monthly alert", () => {
const alert = getAlert({ schedule_type: "monthly" });
const user = getUser();
render(<NotificationCard item={alert} type="alert" user={user} />);
screen.getByText("Emailed monthly on the first Monday", { exact: false });
screen.getByText("at 8:00 AM", { exact: false });
});
it("should render an alert created by another user", () => {
const alert = getAlert();
const user = getUser({ id: 2 });
render(<NotificationCard item={alert} type="alert" user={user} />);
screen.getByText("Created by John Doe", { exact: false });
});
});
export { default } from "./NotificationCard";
import React from "react";
import PropTypes from "prop-types";
import { t } from "ttag";
import NotificationCard from "../NotificationCard";
import {
NotificationButton,
NotificationHeader,
NotificationIcon,
NotificationLabel,
NotificationMessage,
NotificationSection,
} from "./NotificationList.styled";
const propTypes = {
items: PropTypes.array.isRequired,
user: PropTypes.object,
children: PropTypes.node,
onHelp: PropTypes.func,
};
const NotificationList = ({ items, user, children, onHelp }) => {
if (!items.length) {
return <NotificationEmptyState />;
}
return (
<div>
<NotificationHeader>
<NotificationLabel>{t`You receive or created these`}</NotificationLabel>
<NotificationButton onClick={onHelp}>
{t`Not seeing one here?`}
</NotificationButton>
</NotificationHeader>
{items.map(({ item, type }) => (
<NotificationCard key={item.id} item={item} type={type} user={user} />
))}
{children}
</div>
);
};
const NotificationEmptyState = () => {
return (
<NotificationSection>
<NotificationIcon name="bell" />
<NotificationMessage>
{t`If you subscribe or are added to dashboard subscriptions or alerts you’ll be able to manage those here.`}
</NotificationMessage>
</NotificationSection>
);
};
NotificationList.propTypes = propTypes;
export default NotificationList;
import styled from "styled-components";
import { color } from "metabase/lib/colors";
import Label from "metabase/components/type/Label";
import { TextButton } from "metabase/components/Button.styled";
import Icon from "metabase/components/Icon";
export const NotificationHeader = styled.div`
display: flex;
align-items: center;
margin-bottom: 1.5rem;
`;
export const NotificationLabel = styled(Label)`
flex: 1 1 auto;
margin: 0;
`;
export const NotificationButton = styled(TextButton).attrs({
size: "small",
})``;
export const NotificationSection = styled.div`
display: flex;
flex-direction: column;
align-items: center;
`;
export const NotificationIcon = styled(Icon)`
color: ${color("bg-dark")};
width: 3.25rem;
height: 3.25rem;
margin-top: 4.875rem;
margin-bottom: 1.75rem;
`;
export const NotificationMessage = styled.div`
max-width: 24rem;
text-align: center;
`;
import React from "react";
import { render, screen } from "@testing-library/react";
import NotificationList from "./NotificationList";
const getPulse = ({
creatorId = 1,
channel_type = "email",
schedule_type = "hourly",
} = {}) => ({
name: "Pulse",
creator: {
id: creatorId,
common_name: "John Doe",
},
channels: [
{
channel_type,
schedule_type,
schedule_hour: 8,
schedule_day: "mon",
schedule_frame: "first",
details: {
channel: "@channel",
},
},
],
created_at: "2021-05-08T02:02:07.441Z",
});
describe("NotificationList", () => {
it("should render items", () => {
const pulse = getPulse();
render(<NotificationList items={[{ item: pulse, type: "pulse" }]} />);
screen.getByText("Pulse");
});
it("should render empty state when there are no items", () => {
render(<NotificationList items={[]} />);
screen.getByText("you’ll be able to manage those here", { exact: false });
});
});
export { default } from "./NotificationList";
import { connect } from "react-redux";
import { push } from "react-router-redux";
import _ from "underscore";
import Alerts from "metabase/entities/alerts";
import Pulses from "metabase/entities/pulses";
import { getUser, getUserId } from "metabase/selectors/user";
import { getNotifications } from "../../selectors";
import NotificationList from "../../components/NotificationList";
const mapStateToProps = (state, props) => ({
user: getUser(state),
items: getNotifications(props),
});
const mapDispatchToProps = {
onHelp: () => push("/account/notifications/help"),
};
export default _.compose(
Alerts.loadList({
query: state => ({ user_id: getUserId(state) }),
}),
Pulses.loadList({
query: state => ({ user_id: getUserId(state) }),
}),
connect(
mapStateToProps,
mapDispatchToProps,
),
)(NotificationList);
export { default } from "./NotificationsApp";
import { createSelector } from "reselect";
import Settings from "metabase/lib/settings";
export const getNotifications = createSelector(
[({ alerts }) => alerts, ({ pulses }) => pulses],
(alerts, pulses) => {
const items = [
...alerts.map(alert => ({
item: alert,
type: "alert",
})),
...pulses.map(pulse => ({
item: pulse,
type: "pulse",
})),
];
return items.sort((a, b) => b.item.created_at - a.item.created_at);
},
);
export const getAdminEmail = () => {
return Settings.get("admin-email");
};
......@@ -2,10 +2,13 @@ import React from "react";
import { t } from "ttag";
import { IndexRedirect } from "react-router";
import { Route } from "metabase/hoc/Title";
import { ModalRoute } from "metabase/hoc/ModalRoute";
import AccountSettingsApp from "./settings/containers/AccountSettingsApp";
import UserProfileApp from "./profile/containers/UserProfileApp";
import UserPasswordApp from "./password/containers/UserPasswordApp";
import LoginHistoryApp from "./login-history/containers/LoginHistoryApp";
import NotificationsApp from "./notifications/containers/NotificationsApp";
import HelpModal from "./notifications/components/HelpModal";
const getRoutes = () => {
return (
......@@ -18,6 +21,9 @@ const getRoutes = () => {
<Route path="profile" component={UserProfileApp} />
<Route path="password" component={UserPasswordApp} />
<Route path="login-history" component={LoginHistoryApp} />
<Route path="notifications" component={NotificationsApp}>
<ModalRoute path="help" modal={HelpModal} />
</Route>
</Route>
);
};
......
......@@ -29,6 +29,7 @@ const AccountHeader = ({ user, path, onChangeLocation }) => {
? [{ name: t`Password`, value: "/account/password" }]
: []),
{ name: t`Login History`, value: "/account/login-history" },
{ name: t`Notifications`, value: "/account/notifications" },
],
[hasPasswordChange],
);
......
......@@ -36,6 +36,7 @@ describe("AccountHeader", () => {
screen.getByText("Profile");
screen.getByText("Password");
screen.getByText("Login History");
screen.getByText("Notifications");
});
it("should show the password tab if it is enabled by a plugin", () => {
......
import { createEntity } from "metabase/lib/entities";
const Alerts = createEntity({
name: "alerts",
path: "/api/alert",
});
export default Alerts;
export alerts from "./alerts";
export collections from "./collections";
export snippetCollections from "./snippet-collections";
export dashboards from "./dashboards";
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment