Skip to content

Commit

Permalink
Add AlertComponent for displaying alerts in the GUI (#1975)
Browse files Browse the repository at this point in the history
* Add AlertComponent for displaying alerts in the GUI

* Add AlertComponent in index.ts

* Refactor AlertComponent to handle dynamic message and update dependencies

* Renamed AlertComponent to Notification and updated dependencies

* updated dependencies

* updated viselements.json

* Refactor Notification component to handle dynamic message and add defaultMessage property

* Apply suggestions from code review

Co-authored-by: Fabien Lelaquais <[email protected]>

* Refactor Notification component and add dynamic message handling

- Refactor the Notification component in Taipy to improve code readability and maintainability.
- Add support for dynamic message handling using the useDynamicProperty hook.
- Remove the variant and defaultMessage properties from the Notification component, as they are no longer needed.

Closes #693

* Renamed Notification component to Alert and vice versa and updated dependencies

* Refactor Alert component and update dependencies

* Refactor Alert component to add dynamic rendering capability

* feat: Enhance TaipyAlert with dynamic classNames and dispatch actions

- Added dynamic className handling to TaipyAlert component.
- Implemented dispatching of update actions.
- Fixed issues with severity and variant properties.
- Added unit tests to validate the new behavior, with all tests passing successfully.

* Add Alert.py example with dynamic properties and button to update alert

* Refactor Alert.py example and add package.json

- Refactor Alert.py example to remove unused code and simplify the page structure.
- Add package.json file for frontend/taipy directory from develop branch.

* refactor package.json to match it with develop branch

* Add license headers to Alert components

* Fixed linter issue using ruff

* Refactor Notification component and fix merge issue

* Refactor Notification component and fix issue due to other PR

* Refactor Notification test component to include notificationId in alerts

---------

Co-authored-by: Fabien Lelaquais <[email protected]>
Co-authored-by: Fred Lefévère-Laoide <[email protected]>
  • Loading branch information
3 people authored Oct 24, 2024
1 parent 31a4629 commit 3621961
Show file tree
Hide file tree
Showing 9 changed files with 424 additions and 244 deletions.
28 changes: 28 additions & 0 deletions doc/gui/examples/Alert.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# 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.
# -----------------------------------------------------------------------------------------
# To execute this script, make sure that the taipy-gui package is installed in your
# Python environment and run:
# python <script>
# -----------------------------------------------------------------------------------------
from taipy.gui import Gui

severity = "error"
variant = "filled"
message = "This is an error message."

page = """
<|{message}|alert|severity={severity}|variant={variant}|>
"""

if __name__ == "__main__":
gui = Gui(page)
gui.run(title="Test Alert")
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
202 changes: 26 additions & 176 deletions frontend/taipy-gui/src/components/Taipy/Alert.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,191 +12,41 @@
*/

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 TaipyAlert from "./Alert";

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();
});
});

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");
});
});
96 changes: 32 additions & 64 deletions frontend/taipy-gui/src/components/Taipy/Alert.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,72 +11,40 @@
* specific language governing permissions and limitations under the License.
*/

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

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 { 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) {
const notificationId = nanoid();
if (alert.atype === "") {
closeSnackbar(notificationId);
} else {
enqueueSnackbar(alert.message, {
variant: alert.atype as VariantType,
action: notifAction,
autoHideDuration: alert.duration,
key: notificationId,
});
alert.system && new Notification(document.title || "Taipy", { body: alert.message, icon: faviconUrl });
}
dispatch(createDeleteAlertAction(notificationId));
}
}, [alert, enqueueSnackbar, closeSnackbar, notifAction, faviconUrl, dispatch]);
useEffect(() => {
alert?.system && window.Notification && Notification.requestPermission();
}, [alert?.system]);

return null;
};

export default Alert;
export default TaipyAlert;
Loading

0 comments on commit 3621961

Please sign in to comment.