Skip to content

Commit

Permalink
feat: Status controls could have a way to have icons instead of the d…
Browse files Browse the repository at this point in the history
…efault 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 <[email protected]>
  • Loading branch information
Vaibhav91one and FredLL-Avaiga authored Nov 19, 2024
1 parent f3ca62d commit f080490
Show file tree
Hide file tree
Showing 5 changed files with 207 additions and 6 deletions.
54 changes: 54 additions & 0 deletions frontend/taipy-gui/src/components/Taipy/Status.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
});
115 changes: 110 additions & 5 deletions frontend/taipy-gui/src/components/Taipy/Status.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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" => {
Expand All @@ -44,25 +50,124 @@ 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;
}
if (props.icon) {
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} />;
};
Expand Down
25 changes: 25 additions & 0 deletions frontend/taipy-gui/src/components/Taipy/StatusList.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

});
17 changes: 16 additions & 1 deletion frontend/taipy-gui/src/components/Taipy/StatusList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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);
Expand Down Expand Up @@ -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
Expand All @@ -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] || ''}
/>
);
})}
Expand Down
2 changes: 2 additions & 0 deletions taipy/gui/_renderers/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down

0 comments on commit f080490

Please sign in to comment.