Selaa lähdekoodia

feat: Status controls could have a way to have icons instead of the default letters (#2177)

* feat: Status controls could have a way to have icons instead of the default letters #1157

* Changes: options to have intials or icons, option to add a svg

* Removing console.log used for debugging

* Request Changes + Test cases + Linter Issue

* frontend test fix: useMemo hook inside another hook

---------

Co-authored-by: Fred Lefévère-Laoide <90181748+FredLL-Avaiga@users.noreply.github.com>
Vaibhav Tomar 6 kuukautta sitten
vanhempi
säilyke
f0804904b2

+ 54 - 0
frontend/taipy-gui/src/components/Taipy/Status.spec.tsx

@@ -46,4 +46,58 @@ describe("Status Component", () => {
         const {getByTestId} = render(<Status value={status} icon={<PlusOneOutlined/>} onClose={jest.fn()} />);
         getByTestId("PlusOneOutlinedIcon");
     })
+     // Test case for Inline SVG content
+     it("renders an Avatar with inline SVG", () => {
+        const inlineSvg = "<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24'><circle cx='12' cy='12' r='10' fill='red'/></svg>";
+        const { getByTestId } = render(<Status value={status} content={inlineSvg} />);
+        const avatar = getByTestId("Avatar");
+        // Inline SVG should be rendered as inner HTML inside the Avatar
+        const svgElement = avatar.querySelector("svg");
+        expect(svgElement).toBeInTheDocument();
+    });
+
+    // Test case for Text content (default behavior)
+    it("renders Avatar with initial when content is text", () => {
+        const { getByTestId } = render(<Status value={status} content="Text content" />);
+        const avatar = getByTestId("Avatar");
+        expect(avatar).toHaveTextContent("S");
+    });
+
+    // Test case for empty content
+    it("renders Avatar with initial when no content is provided", () => {
+        const { getByTestId } = render(<Status value={status} content="Text content" />);
+        const avatar = getByTestId("Avatar");
+        expect(avatar).toHaveTextContent("S");
+    });
+
+    // Test case for an invalid content type (like a non-SVG string)
+    it("renders Avatar with initial if content is invalid", () => {
+        const { getByTestId } = render(<Status value={status} content="invalid-content" />);
+        const avatar = getByTestId("Avatar");
+        expect(avatar).toHaveTextContent("S");
+    });
+
+    it("renders an avatar with initial when withIcons is false", () => {
+        const statusWithoutIcons: StatusType = { status: "warning", message: "Warning detected" };
+    
+        const { getByTestId } = render(<Status value={statusWithoutIcons} withIcons={false} />);
+        
+        // Check if the avatar has the initial of the status (W)
+        const avatar = getByTestId("Avatar");
+        expect(avatar).toHaveTextContent("W");
+    });
+
+    it("renders the correct icon when withIcons is true", () => {
+        const statusWithIcons: StatusType = { status: "success", message: "Operation successful" };
+    
+        const { getByTestId } = render(<Status value={statusWithIcons} withIcons={true} />);
+        
+        // Check if the Avatar element contains the icon (CheckCircleIcon for success status)
+        const avatar = getByTestId("Avatar");
+        
+        // Check if the avatar contains the appropriate icon, in this case CheckCircleIcon
+        // Since CheckCircleIcon is rendered as part of the Avatar, we can check for its presence by looking for SVGs or icon classes
+        const icon = avatar.querySelector("svg");
+        expect(icon).toBeInTheDocument();  // The icon should be present inside the Avatar
+    });
 });

+ 110 - 5
frontend/taipy-gui/src/components/Taipy/Status.tsx

@@ -11,9 +11,13 @@
  * specific language governing permissions and limitations under the License.
  */
 
-import React, { MouseEvent, ReactNode, useMemo } from "react";
+import React, { MouseEvent, ReactNode, useEffect, useMemo, useRef} from "react";
 import Chip from "@mui/material/Chip";
 import Avatar from "@mui/material/Avatar";
+import CheckCircleIcon from "@mui/icons-material/CheckCircle";
+import WarningIcon from "@mui/icons-material/Warning";
+import ErrorIcon from "@mui/icons-material/Error";
+import InfoIcon from "@mui/icons-material/Info";
 
 import { getInitials } from "../../utils";
 import { TaipyBaseProps } from "./utils";
@@ -28,6 +32,8 @@ interface StatusProps extends TaipyBaseProps {
     value: StatusType;
     onClose?: (evt: MouseEvent) => void;
     icon?: ReactNode;
+    withIcons?: boolean;
+    content?: string;
 }
 
 const status2Color = (status: string): "error" | "info" | "success" | "warning" => {
@@ -44,17 +50,116 @@ const status2Color = (status: string): "error" | "info" | "success" | "warning"
     return "info";
 };
 
+// Function to get the appropriate icon based on the status
+const GetStatusIcon = (status: string, withIcons?: boolean): ReactNode => {
+    // Use useMemo to memoize the iconProps as well
+    const color = status2Color(status);
+
+    // Memoize the iconProps
+    const iconProps = {
+        sx: { fontSize: 20, color: `${color}.main` }}
+        
+        if (withIcons) {
+            switch (color) {
+                case "success":
+                    return <CheckCircleIcon {...iconProps} />;
+                case "warning":
+                    return <WarningIcon {...iconProps} />;
+                case "error":
+                    return <ErrorIcon {...iconProps} />;
+                default:
+                    return <InfoIcon {...iconProps} />;
+            }
+        } else {
+            return getInitials(status);
+        }
+
+};
+
+
 const chipSx = { alignSelf: "flex-start" };
 
+const defaultAvatarStyle = {
+    width: '100%',
+    height: '100%',
+    display: 'flex',
+    alignItems: 'center',
+    justifyContent: 'center',
+  };
+
+const defaultAvatarSx = {
+    bgcolor: 'transparent'
+};
+
+const baseStyles = {
+    fontSize: '1rem', 
+    textShadow: '1px 1px 4px black, -1px -1px 4px black',
+};
+
+const isSvgUrl = (content?: string) => {
+    return content?.substring(content?.length - 4).toLowerCase() === ".svg"; // Check if it ends with ".svg"
+};
+
+const isInlineSvg = (content?: string) => {
+return content?.substring(0, 4).toLowerCase() === "<svg"; // Check if the content starts with "<svg"
+};
+
 const Status = (props: StatusProps) => {
     const { value, id } = props;
-
+    const content = props.content || undefined;
+    const withIcons = props.withIcons;
+    const svgRef = useRef<HTMLDivElement>(null);
     const className = useClassNames(props.libClassName, props.dynamicClassName, props.className);
 
+    useEffect(() => {
+        if (content && svgRef.current) {
+            svgRef.current.innerHTML = content;
+        }
+    }, [content]);
+
+
     const chipProps = useMemo(() => {
         const cp: Record<string, unknown> = {};
-        cp.color = status2Color(value.status);
-        cp.avatar = <Avatar sx={{ bgcolor: `${cp.color}.main` }}>{getInitials(value.status)}</Avatar>;
+        const statusColor = status2Color(value.status);
+        cp.color = statusColor;
+       
+        if (isSvgUrl(content)) {
+            cp.avatar = (
+                <Avatar src={content}  data-testid="Avatar" />
+            );
+        } 
+        
+        else if(content && isInlineSvg(content)){
+            cp.avatar = (
+                <Avatar
+                    sx={defaultAvatarSx}
+                    data-testid="Avatar"
+                >
+                    <div
+                      ref={svgRef}
+                      style={defaultAvatarStyle}
+                    />
+                </Avatar>
+            );
+        }
+
+        else {
+            cp.avatar = (
+                <Avatar
+                    sx={{
+                        bgcolor: withIcons
+                            ? 'transparent' 
+                            : `${statusColor}.main`,  
+                        color: `${statusColor}.contrastText`, 
+                        ...baseStyles
+                    }}
+                    data-testid="Avatar"
+                >
+                    {GetStatusIcon(value.status, withIcons)}
+                </Avatar>
+            );
+        }
+
         if (props.onClose) {
             cp.onDelete = props.onClose;
         }
@@ -62,7 +167,7 @@ const Status = (props: StatusProps) => {
             cp.deleteIcon = props.icon;
         }
         return cp;
-    }, [value.status, props.onClose, props.icon]);
+    }, [value.status, props.onClose, props.icon, withIcons, content]);
 
     return <Chip id={id} variant="outlined" {...chipProps} label={value.message} sx={chipSx} className={className} />;
 };

+ 25 - 0
frontend/taipy-gui/src/components/Taipy/StatusList.spec.tsx

@@ -105,4 +105,29 @@ describe("StatusList Component", () => {
         expect(elt).toBeInTheDocument();
         consoleSpy.mockRestore();
     });
+    it("renders default content when content is not provided", () => {
+        const statuses = [
+            { status: "info", message: "Information" },
+            { status: "warning", message: "Warning" },
+        ];
+    
+        const { getByText } = render(<StatusList value={statuses} />);
+        getByText("W");
+    });
+    it("renders custom content passed via 'customIcon' prop", () => {
+        const statuses = [
+            { status: "info", message: "Information" },
+            { status: "warning", message: "Warning" },
+        ];
+    
+        const content = "<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'><circle cx='8' cy='8' r='8' fill='red'/></svg>"
+    
+        const { container } = render(<StatusList value={statuses} customIcon={content} />);
+
+    
+        // Check if the SVG is rendered for the warning status
+        const svg = container.querySelector("svg");
+        expect(svg).toBeInTheDocument();
+    });
+    
 });

+ 16 - 1
frontend/taipy-gui/src/components/Taipy/StatusList.tsx

@@ -87,6 +87,8 @@ interface StatusListProps extends TaipyBaseProps, TaipyHoverProps {
     value: Array<[string, string] | StatusType> | [string, string] | StatusType;
     defaultValue?: string;
     withoutClose?: boolean;
+    withIcons?: boolean; 
+    customIcon?: string;
 }
 
 const StatusList = (props: StatusListProps) => {
@@ -95,6 +97,16 @@ const StatusList = (props: StatusListProps) => {
     const [opened, setOpened] = useState(false);
     const [multiple, setMultiple] = useState(false);
     const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
+    const content = useMemo(() => {
+        if (typeof props.customIcon === 'string') {
+            try {
+                return props.customIcon.split(';');
+            } catch (e) {
+                console.info(`Error parsing custom icons\n${(e as Error).message || e}`);
+            }
+        }
+        return [];
+    }, [props.customIcon]);
 
     const className = useClassNames(props.libClassName, props.dynamicClassName, props.className);
     const hover = useDynamicProperty(props.hoverText, props.defaultHoverText, undefined);
@@ -156,10 +168,11 @@ const StatusList = (props: StatusListProps) => {
         [multiple, opened, onOpen]
     );
 
+
     return (
         <Tooltip title={hover || ""}>
             <>
-                <Status id={props.id} value={getGlobalStatus(values)} className={`${className} ${getComponentClassName(props.children)}`} {...globalProps} />
+                <Status id={props.id} value={getGlobalStatus(values)} className={`${className} ${getComponentClassName(props.children)}`} {...globalProps} withIcons={props.withIcons} content={content[0]}/>
                 <Popover open={opened} anchorEl={anchorEl} onClose={onOpen} anchorOrigin={ORIGIN}>
                     <Stack direction="column" spacing={1}>
                         {values
@@ -173,6 +186,8 @@ const StatusList = (props: StatusListProps) => {
                                         value={val}
                                         className={`${className} ${getComponentClassName(props.children)}`}
                                         {...closeProp}
+                                        withIcons={props.withIcons}
+                                        content={content[idx+1] || content[0] || ''}
                                     />
                                 );
                             })}

+ 2 - 0
taipy/gui/_renderers/factory.py

@@ -545,7 +545,9 @@ class _Factory:
         .set_attributes(
             [
                 ("without_close", PropertyType.boolean, False),
+                ("with_icons", PropertyType.boolean, False),
                 ("hover_text", PropertyType.dynamic_string),
+                ("custom_icon", PropertyType.string),
             ]
         ),
         "table": lambda gui, control_type, attrs: _Builder(