Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add AlertComponent for displaying alerts in the GUI #1975

Open
wants to merge 33 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
a4c1f02
Add AlertComponent for displaying alerts in the GUI
Rishi-0007 Oct 8, 2024
31f9bc2
Merge branch 'develop' into feature/#693-AddAlertVisualElement
Rishi-0007 Oct 8, 2024
7b3d3d7
Add AlertComponent in index.ts
Rishi-0007 Oct 9, 2024
965deb7
Refactor AlertComponent to handle dynamic message and update dependen…
Rishi-0007 Oct 9, 2024
97bbda4
Renamed AlertComponent to Notification and updated dependencies
Rishi-0007 Oct 9, 2024
5d7b543
Merge branch 'develop' into feature/#693-AddAlertVisualElement
Rishi-0007 Oct 9, 2024
1654d62
updated dependencies
Rishi-0007 Oct 9, 2024
af84cfb
updated viselements.json
Rishi-0007 Oct 9, 2024
3b6fd8a
Refactor Notification component to handle dynamic message and add def…
Rishi-0007 Oct 10, 2024
2ed97c0
Merge branch 'develop' into feature/#693-AddAlertVisualElement
Rishi-0007 Oct 10, 2024
1cff75e
Apply suggestions from code review
Rishi-0007 Oct 11, 2024
c50e8ca
Refactor Notification component and add dynamic message handling
Rishi-0007 Oct 11, 2024
55ace5a
Renamed Notification component to Alert and vice versa and updated de…
Rishi-0007 Oct 12, 2024
e3023e1
Refactor Alert component and update dependencies
Rishi-0007 Oct 12, 2024
80d3621
Merge branch 'develop' into feature/#693-AddAlertVisualElement
Rishi-0007 Oct 13, 2024
67ed55d
Merge branch 'develop' into feature/#693-AddAlertVisualElement
Rishi-0007 Oct 14, 2024
c69b596
Refactor Alert component to add dynamic rendering capability
Rishi-0007 Oct 14, 2024
23bb22b
feat: Enhance TaipyAlert with dynamic classNames and dispatch actions
Rishi-0007 Oct 14, 2024
ce58645
Merge branch 'develop' into feature/#693-AddAlertVisualElement
Rishi-0007 Oct 14, 2024
73aabd8
Merge branch 'develop' into feature/#693-AddAlertVisualElement
Rishi-0007 Oct 15, 2024
f053daa
Merge branch 'develop' into feature/#693-AddAlertVisualElement
Rishi-0007 Oct 19, 2024
d4fec0e
Add Alert.py example with dynamic properties and button to update alert
Rishi-0007 Oct 19, 2024
e69a0ec
Refactor Alert.py example and add package.json
Rishi-0007 Oct 19, 2024
5346893
refactor package.json to match it with develop branch
Rishi-0007 Oct 19, 2024
5f6e40f
Merge branch 'develop' into feature/#693-AddAlertVisualElement
Rishi-0007 Oct 21, 2024
9ead544
Merge branch 'develop' into feature/#693-AddAlertVisualElement
FredLL-Avaiga Oct 22, 2024
7582bbb
Add license headers to Alert components
Rishi-0007 Oct 22, 2024
2633fd3
Merge branch 'develop' into feature/#693-AddAlertVisualElement
Rishi-0007 Oct 22, 2024
474cddf
Merge branch 'develop' into feature/#693-AddAlertVisualElement
Rishi-0007 Oct 22, 2024
0bf9b5c
Fixed linter issue using ruff
Rishi-0007 Oct 22, 2024
9becf2f
Merge branch 'develop' into feature/#693-AddAlertVisualElement
Rishi-0007 Oct 23, 2024
1346fe7
Refactor Notification component and fix merge issue
Rishi-0007 Oct 23, 2024
0119c35
Refactor Notification component and fix issue due to other PR
Rishi-0007 Oct 23, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions frontend/taipy-gui/src/components/Router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,10 @@ import {
taipyInitialize,
taipyReducer,
} from "../context/taipyReducers";
import Alert from "./Taipy/Alert";
import UIBlocker from "./Taipy/UIBlocker";
import Navigate from "./Taipy/Navigate";
import Menu from "./Taipy/Menu";
import TaipyNotification from "./Taipy/Notification";
import GuiDownload from "./Taipy/GuiDownload";
import ErrorFallback from "../utils/ErrorBoundary";
import MainPage from "./pages/MainPage";
Expand Down Expand Up @@ -152,7 +152,7 @@ const Router = () => {
) : null}
</Box>
<ErrorBoundary FallbackComponent={ErrorFallback}>
<Alert alerts={state.alerts} />
<TaipyNotification alerts={state.alerts} />
<UIBlocker block={state.block} />
<Navigate
to={state.navigateTo}
Expand Down
215 changes: 26 additions & 189 deletions frontend/taipy-gui/src/components/Taipy/Alert.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,202 +1,39 @@
/*
* Copyright 2021-2024 Avaiga Private Limited
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
* specific language governing permissions and limitations under the License.
*/

import React from "react";
import { render, screen, waitFor } from "@testing-library/react";
import { render } from "@testing-library/react";
import "@testing-library/jest-dom";
import { SnackbarProvider } from "notistack";

import Alert from "./Alert";
import { AlertMessage } from "../../context/taipyReducers";
import userEvent from "@testing-library/user-event";

const defaultMessage = "message";
const defaultAlerts: AlertMessage[] = [{ atype: "success", message: defaultMessage, system: true, duration: 3000 }];
const getAlertsWithType = (aType: string) => [{ ...defaultAlerts[0], atype: aType }];

class myNotification {
static requestPermission = jest.fn(() => Promise.resolve("granted"));
static permission = "granted";
}

describe("Alert Component", () => {
beforeAll(() => {
globalThis.Notification = myNotification as unknown as jest.Mocked<typeof Notification>;
});
beforeEach(() => {
jest.clearAllMocks();
});
it("renders", async () => {
const { getByText } = render(
<SnackbarProvider>
<Alert alerts={defaultAlerts} />
</SnackbarProvider>,
);
const elt = getByText(defaultMessage);
expect(elt.tagName).toBe("DIV");
});
it("displays a success alert", async () => {
const { getByText } = render(
<SnackbarProvider>
<Alert alerts={defaultAlerts} />
</SnackbarProvider>,
);
const elt = getByText(defaultMessage);
expect(elt.closest(".notistack-MuiContent-success")).toBeInTheDocument();
});
it("displays an error alert", async () => {
const { getByText } = render(
<SnackbarProvider>
<Alert alerts={getAlertsWithType("error")} />
</SnackbarProvider>,
);
const elt = getByText(defaultMessage);
expect(elt.closest(".notistack-MuiContent-error")).toBeInTheDocument();
});
it("displays a warning alert", async () => {
const { getByText } = render(
<SnackbarProvider>
<Alert alerts={getAlertsWithType("warning")} />
</SnackbarProvider>,
);
const elt = getByText(defaultMessage);
expect(elt.closest(".notistack-MuiContent-warning")).toBeInTheDocument();
});
it("displays an info alert", async () => {
const { getByText } = render(
<SnackbarProvider>
<Alert alerts={getAlertsWithType("info")} />
</SnackbarProvider>,
);
const elt = getByText(defaultMessage);
expect(elt.closest(".notistack-MuiContent-info")).toBeInTheDocument();
});
it("gets favicon URL from document link tags", () => {
const link = document.createElement("link");
link.rel = "icon";
link.href = "/test-icon.png";
document.head.appendChild(link);
const alerts: AlertMessage[] = [
{ atype: "success", message: "This is a system alert", system: true, duration: 3000 },
];
render(
<SnackbarProvider>
<Alert alerts={alerts} />
</SnackbarProvider>,
);
const linkElement = document.querySelector("link[rel='icon']");
if (linkElement) {
expect(linkElement.getAttribute("href")).toBe("/test-icon.png");
} else {
expect(true).toBe(false);
}
document.head.removeChild(link);
});

it("closes alert on close button click", async () => {
const alerts = [{ atype: "success", message: "Test Alert", duration: 3000, system: false }];
render(
<SnackbarProvider>
<Alert alerts={alerts} />
</SnackbarProvider>,
);
const closeButton = await screen.findByRole("button", { name: /close/i });
await userEvent.click(closeButton);
await waitFor(() => {
const alertMessage = screen.queryByText("Test Alert");
expect(alertMessage).not.toBeInTheDocument();
});
});
import TaipyAlert from "./Alert";

it("Alert disappears when alert type is empty", async () => {
const alerts = [{ atype: "success", message: "Test Alert", duration: 3000, system: false }];
const { rerender } = render(
<SnackbarProvider>
<Alert alerts={alerts} />
</SnackbarProvider>,
);
await screen.findByRole("button", { name: /close/i });
const newAlerts = [{ atype: "", message: "Test Alert", duration: 3000, system: false }];
rerender(
<SnackbarProvider>
<Alert alerts={newAlerts} />
</SnackbarProvider>,
);
await waitFor(() => {
const alertMessage = screen.queryByText("Test Alert");
expect(alertMessage).not.toBeInTheDocument();
});
describe("TaipyAlert Component", () => {
it("renders with default properties", () => {
const { getByRole } = render(<TaipyAlert message="Default Alert" />);
const alert = getByRole("alert");
expect(alert).toBeInTheDocument();
expect(alert).toHaveClass("MuiAlert-filledError");
});

it("does nothing when alert is undefined", async () => {
render(
<SnackbarProvider>
<Alert alerts={[]} />
</SnackbarProvider>,
);
expect(Notification.requestPermission).not.toHaveBeenCalled();
it("applies the correct severity", () => {
const { getByRole } = render(<TaipyAlert message="Warning Alert" severity="warning" />);
const alert = getByRole("alert");
expect(alert).toBeInTheDocument();
expect(alert).toHaveClass("MuiAlert-filledWarning");
});

it("validates href when rel attribute is 'icon' and href is set", () => {
const link = document.createElement("link");
link.rel = "icon";
link.href = "/test-icon.png";
document.head.appendChild(link);
const alerts: AlertMessage[] = [
{ atype: "success", message: "This is a system alert", system: true, duration: 3000 },
];
render(
<SnackbarProvider>
<Alert alerts={alerts} />
</SnackbarProvider>,
);
const linkElement = document.querySelector("link[rel='icon']");
expect(linkElement?.getAttribute("href")).toBe("/test-icon.png");
document.head.removeChild(link);
it("applies the correct variant", () => {
const { getByRole } = render(<TaipyAlert message="Outlined Alert" variant="outlined" />);
const alert = getByRole("alert");
expect(alert).toBeInTheDocument();
expect(alert).toHaveClass("MuiAlert-outlinedError");
});

it("verifies default favicon for 'icon' rel attribute when href is unset/empty", () => {
const link = document.createElement("link");
link.rel = "icon";
document.head.appendChild(link);
const alerts: AlertMessage[] = [
{ atype: "success", message: "This is a system alert", system: true, duration: 3000 },
];
render(
<SnackbarProvider>
<Alert alerts={alerts} />
</SnackbarProvider>,
);
const linkElement = document.querySelector("link[rel='icon']");
expect(linkElement?.getAttribute("href") || "/favicon.png").toBe("/favicon.png");
document.head.removeChild(link);
it("does not render if render prop is false", () => {
const { queryByRole } = render(<TaipyAlert message="Hidden Alert" render={false} />);
const alert = queryByRole("alert");
expect(alert).toBeNull();
});

it("validates href when rel attribute is 'shortcut icon' and href is provided", () => {
const link = document.createElement("link");
link.rel = "shortcut icon";
link.href = "/test-shortcut-icon.png";
document.head.appendChild(link);
const alerts: AlertMessage[] = [
{ atype: "success", message: "This is a system alert", system: true, duration: 3000 },
];
render(
<SnackbarProvider>
<Alert alerts={alerts} />
</SnackbarProvider>,
);
const linkElement = document.querySelector("link[rel='shortcut icon']");
expect(linkElement?.getAttribute("href")).toBe("/test-shortcut-icon.png");
document.head.removeChild(link);
it("handles dynamic class names", () => {
const { getByRole } = render(<TaipyAlert message="Dynamic Alert" className="custom-class" />);
const alert = getByRole("alert");
expect(alert).toHaveClass("custom-class");
});
});
111 changes: 32 additions & 79 deletions frontend/taipy-gui/src/components/Taipy/Alert.tsx
Original file line number Diff line number Diff line change
@@ -1,84 +1,37 @@
/*
* Copyright 2021-2024 Avaiga Private Limited
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
* specific language governing permissions and limitations under the License.
*/

import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { SnackbarKey, useSnackbar, VariantType } from "notistack";
import IconButton from "@mui/material/IconButton";
import CloseIcon from "@mui/icons-material/Close";

import { AlertMessage, createDeleteAlertAction } from "../../context/taipyReducers";
import { useDispatch } from "../../utils/hooks";

interface AlertProps {
alerts: AlertMessage[];
import React from "react";
import Alert from "@mui/material/Alert";
import { TaipyBaseProps } from "./utils";
import { useClassNames, useDynamicProperty } from "../../utils/hooks";

interface AlertProps extends TaipyBaseProps {
severity?: "error" | "warning" | "info" | "success";
message?: string;
variant?: "filled" | "outlined";
render?: boolean;
defaultMessage?: string;
defaultSeverity?: string;
defaultVariant?: string;
defaultRender?: boolean;
}

const Alert = ({ alerts }: AlertProps) => {
const alert = alerts.length ? alerts[0] : undefined;
const lastKey = useRef<SnackbarKey>("");
const { enqueueSnackbar, closeSnackbar } = useSnackbar();
const dispatch = useDispatch();

const resetAlert = useCallback(
(key: SnackbarKey) => () => {
closeSnackbar(key);
},
[closeSnackbar]
);

const notifAction = useCallback(
(key: SnackbarKey) => (
<IconButton size="small" aria-label="close" color="inherit" onClick={resetAlert(key)}>
<CloseIcon fontSize="small" />
</IconButton>
),
[resetAlert]
const TaipyAlert = (props: AlertProps) => {
const className = useClassNames(props.libClassName, props.dynamicClassName, props.className);
const render = useDynamicProperty(props.render, props.defaultRender, true);
const severity = useDynamicProperty(props.severity, props.defaultSeverity, "error") as
| "error"
| "warning"
| "info"
| "success";
const variant = useDynamicProperty(props.variant, props.defaultVariant, "filled") as "filled" | "outlined";
const message = useDynamicProperty(props.message, props.defaultMessage, "");

if (!render) return null;

return (
<Alert severity={severity} variant={variant} id={props.id} className={className}>
{message}
</Alert>
);

const faviconUrl = useMemo(() => {
const nodeList = document.getElementsByTagName("link");
for (let i = 0; i < nodeList.length; i++) {
if (nodeList[i].getAttribute("rel") == "icon" || nodeList[i].getAttribute("rel") == "shortcut icon") {
return nodeList[i].getAttribute("href") || "/favicon.png";
}
}
return "/favicon.png";
}, []);

useEffect(() => {
if (alert) {
if (alert.atype === "") {
if (lastKey.current) {
closeSnackbar(lastKey.current);
lastKey.current = "";
}
} else {
lastKey.current = enqueueSnackbar(alert.message, {
variant: alert.atype as VariantType,
action: notifAction,
autoHideDuration: alert.duration,
});
alert.system && new Notification(document.title || "Taipy", { body: alert.message, icon: faviconUrl });
}
dispatch(createDeleteAlertAction());
}
}, [alert, enqueueSnackbar, closeSnackbar, notifAction, faviconUrl, dispatch]);

useEffect(() => {
alert?.system && window.Notification && Notification.requestPermission();
}, [alert?.system]);

return null;
};

export default Alert;
export default TaipyAlert;
Loading
Loading