fix: React Console Warnings and Accessibility Issues in Langflow Frontend (#6950)

* fix errors on console jsx

* ✨ (NodeOutputfield/index.tsx): Refactor InspectButton component to use forwardRef for better performance and maintainability
♻️ (NodeOutputfield/index.tsx): Refactor AlertDropdown component to use forwardRef for better performance and maintainability

* 📝 (dialog.tsx): Add VisuallyHidden component for accessibility and improve semantics in DialogContent component
🔧 (dialog.tsx): Update DialogContent component to conditionally include VisuallyHidden component for DialogTitle
🔧 (dialog.tsx): Export VisuallyHidden component from dialog.tsx
♻️ (textAnimation.tsx): Refactor TextEffectPerChar component to use TextEffect component with per="char" and as="span" properties for consistency

* ✨ (CustomEdges/index.tsx): Destructure props to extract specific properties and pass the rest as domSafeProps for cleaner code
♻️ (singleAlertComponent/index.tsx): Remove unnecessary aria-hidden attribute from IconComponent to improve accessibility and reduce redundancy

* ✨ (index.tsx): introduce ShortUniqueId library to generate unique keys for alert items in the dropdown list

* ✨ (frontend): add support for displaying a list of items in the NoticeAlert component
📝 (frontend): update NoticeAlertType interface to include a list property for displaying multiple items in the alert

* ✨ (styleUtils.ts): introduce new icon PencilRuler to the list of nodeIconsLucide for use in the frontend styling utilities.
This commit is contained in:
Cristhian Zanforlin Lousa 2025-03-10 09:44:44 -03:00 • committed by GitHub
commit 08f886f507
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 243 additions and 197 deletions

View file

@ -56,11 +56,26 @@ export function DefaultEdge({
targetY: targetYNew, targetY: targetYNew,
}); });
const {
animated,
selectable,
deletable,
sourcePosition,
targetPosition,
pathOptions,
selected,
...domSafeProps
} = props;
return ( return (
<BaseEdge <BaseEdge
path={targetHandleObject.output_types ? edgePathLoop : edgePath} path={targetHandleObject.output_types ? edgePathLoop : edgePath}
strokeDasharray={targetHandleObject.output_types ? "5 5" : "0"} strokeDasharray={targetHandleObject.output_types ? "5 5" : "0"}
{...props} {...domSafeProps}
data-animated={animated ? "true" : "false"}
data-selectable={selectable ? "true" : "false"}
data-deletable={deletable ? "true" : "false"}
data-selected={selected ? "true" : "false"}
/> />
); );
} }

View file

@ -3,7 +3,14 @@ import { ICON_STROKE_WIDTH } from "@/constants/constants";
import { targetHandleType } from "@/types/flow"; import { targetHandleType } from "@/types/flow";
import { useUpdateNodeInternals } from "@xyflow/react"; import { useUpdateNodeInternals } from "@xyflow/react";
import { cloneDeep } from "lodash"; import { cloneDeep } from "lodash";
import { memo, useCallback, useEffect, useMemo, useRef } from "react"; import {
forwardRef,
memo,
useCallback,
useEffect,
useMemo,
useRef,
} from "react";
import ForwardedIconComponent, { import ForwardedIconComponent, {
default as IconComponent, default as IconComponent,
} from "../../../../components/common/genericIconComponent"; } from "../../../../components/common/genericIconComponent";
@ -96,49 +103,56 @@ const HideShowButton = memo(
); );
const InspectButton = memo( const InspectButton = memo(
({ forwardRef(
disabled, (
displayOutputPreview, {
unknownOutput, disabled,
errorOutput, displayOutputPreview,
isToolMode, unknownOutput,
title, errorOutput,
onClick, isToolMode,
id, title,
}: { onClick,
disabled: boolean | undefined; id,
displayOutputPreview: boolean; }: {
unknownOutput: boolean | undefined; disabled: boolean | undefined;
errorOutput: boolean; displayOutputPreview: boolean;
isToolMode: boolean; unknownOutput: boolean | undefined;
title: string; errorOutput: boolean;
onClick: () => void; isToolMode: boolean;
id: string; title: string;
}) => ( onClick: () => void;
<Button id: string;
disabled={disabled} },
data-testid={`output-inspection-${title.toLowerCase()}-${id.toLowerCase()}`} ref: React.ForwardedRef<HTMLButtonElement>,
unstyled ) => (
onClick={onClick} <Button
> ref={ref}
<IconComponent disabled={disabled}
name="TextSearchIcon" data-testid={`output-inspection-${title.toLowerCase()}-${id.toLowerCase()}`}
strokeWidth={ICON_STROKE_WIDTH} unstyled
className={cn( onClick={onClick}
"icon-size", >
isToolMode <IconComponent
? displayOutputPreview && !unknownOutput name="TextSearchIcon"
? "text-background hover:text-secondary-hover" strokeWidth={ICON_STROKE_WIDTH}
: "cursor-not-allowed text-placeholder-foreground opacity-80" className={cn(
: displayOutputPreview && !unknownOutput "icon-size",
? "text-foreground hover:text-primary-hover" isToolMode
: "cursor-not-allowed text-placeholder-foreground opacity-60", ? displayOutputPreview && !unknownOutput
errorOutput ? "text-destructive" : "", ? "text-background hover:text-secondary-hover"
)} : "cursor-not-allowed text-placeholder-foreground opacity-80"
/> : displayOutputPreview && !unknownOutput
</Button> ? "text-foreground hover:text-primary-hover"
: "cursor-not-allowed text-placeholder-foreground opacity-60",
errorOutput ? "text-destructive" : "",
)}
/>
</Button>
),
), ),
); );
InspectButton.displayName = "InspectButton";
const MemoizedOutputComponent = memo(OutputComponent); const MemoizedOutputComponent = memo(OutputComponent);

View file

@ -18,11 +18,7 @@ export default function SingleAlert({
key={dropItem.id} key={dropItem.id}
> >
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<IconComponent <IconComponent name="XCircle" className="h-5 w-5 text-status-red" />
name="XCircle"
className="h-5 w-5 text-status-red"
aria-hidden="true"
/>
</div> </div>
<div className="ml-3"> <div className="ml-3">
<h3 className="text-sm font-medium text-error-foreground word-break-break-word"> <h3 className="text-sm font-medium text-error-foreground word-break-break-word">
@ -80,11 +76,7 @@ export default function SingleAlert({
className="inline-flex rounded-md p-1.5 text-status-red" className="inline-flex rounded-md p-1.5 text-status-red"
> >
<span className="sr-only">Dismiss</span> <span className="sr-only">Dismiss</span>
<IconComponent <IconComponent name="X" className="h-4 w-4 text-error-foreground" />
name="X"
className="h-4 w-4 text-error-foreground"
aria-hidden="true"
/>
</button> </button>
</div> </div>
</div> </div>
@ -95,11 +87,7 @@ export default function SingleAlert({
key={dropItem.id} key={dropItem.id}
> >
<div className="flex-shrink-0 cursor-help"> <div className="flex-shrink-0 cursor-help">
<IconComponent <IconComponent name="Info" className="h-5 w-5 text-status-blue" />
name="Info"
className="h-5 w-5 text-status-blue"
aria-hidden="true"
/>
</div> </div>
<div className="ml-3 flex-1 md:flex md:justify-between"> <div className="ml-3 flex-1 md:flex md:justify-between">
<p className="text-sm font-medium text-info-foreground"> <p className="text-sm font-medium text-info-foreground">
@ -131,11 +119,7 @@ export default function SingleAlert({
className="inline-flex rounded-md p-1.5 text-info-foreground" className="inline-flex rounded-md p-1.5 text-info-foreground"
> >
<span className="sr-only">Dismiss</span> <span className="sr-only">Dismiss</span>
<IconComponent <IconComponent name="X" className="h-4 w-4 text-info-foreground" />
name="X"
className="h-4 w-4 text-info-foreground"
aria-hidden="true"
/>
</button> </button>
</div> </div>
</div> </div>
@ -149,7 +133,6 @@ export default function SingleAlert({
<IconComponent <IconComponent
name="CheckCircle2" name="CheckCircle2"
className="h-5 w-5 text-status-green" className="h-5 w-5 text-status-green"
aria-hidden="true"
/> />
</div> </div>
<div className="ml-3"> <div className="ml-3">
@ -173,7 +156,6 @@ export default function SingleAlert({
<IconComponent <IconComponent
name="X" name="X"
className="h-4 w-4 text-success-foreground" className="h-4 w-4 text-success-foreground"
aria-hidden="true"
/> />
</button> </button>
</div> </div>

View file

@ -1,5 +1,6 @@
import { Cross2Icon } from "@radix-ui/react-icons"; import { Cross2Icon } from "@radix-ui/react-icons";
import { useEffect, useState } from "react"; import { forwardRef, useEffect, useState } from "react";
import ShortUniqueId from "short-unique-id";
import IconComponent from "../../components/common/genericIconComponent"; import IconComponent from "../../components/common/genericIconComponent";
import { import {
Popover, Popover,
@ -11,85 +12,90 @@ import useAlertStore from "../../stores/alertStore";
import { AlertDropdownType } from "../../types/alerts"; import { AlertDropdownType } from "../../types/alerts";
import SingleAlert from "./components/singleAlertComponent"; import SingleAlert from "./components/singleAlertComponent";
export default function AlertDropdown({ const AlertDropdown = forwardRef<HTMLDivElement, AlertDropdownType>(
children, function AlertDropdown({ children, notificationRef, onClose }, ref) {
notificationRef, const notificationList = useAlertStore((state) => state.notificationList);
onClose, const clearNotificationList = useAlertStore(
}: AlertDropdownType): JSX.Element { (state) => state.clearNotificationList,
const notificationList = useAlertStore((state) => state.notificationList); );
const clearNotificationList = useAlertStore( const removeFromNotificationList = useAlertStore(
(state) => state.clearNotificationList, (state) => state.removeFromNotificationList,
); );
const removeFromNotificationList = useAlertStore( const setNotificationCenter = useAlertStore(
(state) => state.removeFromNotificationList, (state) => state.setNotificationCenter,
); );
const setNotificationCenter = useAlertStore(
(state) => state.setNotificationCenter,
);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
onClose?.(); onClose?.();
} }
}, [open]); }, [open, onClose]);
return ( const uid = new ShortUniqueId();
<Popover
data-testid="notification-dropdown" return (
open={open} <Popover
onOpenChange={(target) => { data-testid="notification-dropdown"
setOpen(target); open={open}
if (target) { onOpenChange={(target) => {
setNotificationCenter(false); setOpen(target);
} if (target) {
}} setNotificationCenter(false);
> }
<PopoverTrigger asChild>{children}</PopoverTrigger> }}
<PopoverContent
ref={notificationRef}
data-testid="notification-dropdown-content"
className="noflow nowheel nopan nodelete nodrag z-10 flex h-[500px] w-[500px] flex-col"
> >
<div className="text-md flex flex-row justify-between pl-3 font-medium text-foreground"> <PopoverTrigger asChild>{children}</PopoverTrigger>
Notifications <PopoverContent
<div className="flex gap-3 pr-3"> ref={ref}
<button data-testid="notification-dropdown-content"
className="text-foreground hover:text-status-red" className="noflow nowheel nopan nodelete nodrag z-10 flex h-[500px] w-[500px] flex-col"
onClick={() => { >
setOpen(false); <div
setTimeout(clearNotificationList, 100); ref={notificationRef}
}} className="text-md flex flex-row justify-between pl-3 font-medium text-foreground"
> >
<IconComponent name="Trash2" className="h-4 w-4" /> Notifications
</button> <div className="flex gap-3 pr-3">
<button <button
className="text-foreground opacity-70 hover:opacity-100" className="text-foreground hover:text-status-red"
onClick={() => { onClick={() => {
setOpen(false); setOpen(false);
}} setTimeout(clearNotificationList, 100);
> }}
<Cross2Icon className="h-4 w-4" /> >
</button> <IconComponent name="Trash2" className="h-4 w-4" />
</div> </button>
</div> <button
<div className="text-high-foreground mt-3 flex h-full w-full flex-col overflow-y-scroll scrollbar-hide"> className="text-foreground opacity-70 hover:opacity-100"
{notificationList.length !== 0 ? ( onClick={() => {
notificationList.map((alertItem, index) => ( setOpen(false);
<SingleAlert }}
key={alertItem.id} >
dropItem={alertItem} <Cross2Icon className="h-4 w-4" />
removeAlert={removeFromNotificationList} </button>
/>
))
) : (
<div className="flex h-full w-full items-center justify-center pb-16 text-ring">
{ZERO_NOTIFICATIONS}
</div> </div>
)} </div>
</div> <div className="text-high-foreground mt-3 flex h-full w-full flex-col overflow-y-scroll scrollbar-hide">
</PopoverContent> {notificationList.length !== 0 ? (
</Popover> notificationList.map((alertItem) => (
); <SingleAlert
} key={uid.randomUUID(10)}
dropItem={alertItem}
removeAlert={removeFromNotificationList}
/>
))
) : (
<div className="flex h-full w-full items-center justify-center pb-16 text-ring">
{ZERO_NOTIFICATIONS}
</div>
)}
</div>
</PopoverContent>
</Popover>
);
},
);
export default AlertDropdown;

View file

@ -1,35 +1,35 @@
import { CustomLink } from "@/customization/components/custom-link"; import { CustomLink } from "@/customization/components/custom-link";
import { Transition } from "@headlessui/react"; import { Transition } from "@headlessui/react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import IconComponent from "../../components/common/genericIconComponent"; import IconComponent from "../../components/common/genericIconComponent";
import { NoticeAlertType } from "../../types/alerts"; import { NoticeAlertType } from "../../types/alerts";
export default function NoticeAlert({ export default function NoticeAlert({
title, title,
link, list = [],
id, id,
link,
removeAlert, removeAlert,
}: NoticeAlertType): JSX.Element { }: NoticeAlertType): JSX.Element {
const [show, setShow] = useState(true); const [show, setShow] = useState(true);
useEffect(() => { useEffect(() => {
const timeoutId = setTimeout(() => { if (show) {
setShow(false);
// Wait for the leave transition before calling removeAlert
setTimeout(() => { setTimeout(() => {
removeAlert(id); setShow(false);
}, 500); // match the duration of the leave transition setTimeout(() => {
}, 5000); // auto-dismiss alert after 5 seconds removeAlert(id);
}, 500);
return () => clearTimeout(timeoutId); // Cleanup timeout on component unmount or re-render }, 5000);
}, [id, removeAlert]); }
}, [id, removeAlert, show]);
const handleClick = () => { const handleClick = () => {
setShow(false); setShow(false);
// Wait for the leave transition before calling removeAlert
setTimeout(() => { setTimeout(() => {
removeAlert(id); removeAlert(id);
}, 500); // Ensure the alert is removed after the animation }, 500);
}; };
return ( return (

View file

@ -35,35 +35,65 @@ const DialogOverlay = React.forwardRef<
)); ));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
// Create a VisuallyHidden component for accessibility
const VisuallyHidden = React.forwardRef<
HTMLSpanElement,
React.HTMLAttributes<HTMLSpanElement>
>(({ children, ...props }, ref) => (
<span
ref={ref}
className="absolute h-px w-px overflow-hidden whitespace-nowrap border-0 p-0"
style={{ clip: "rect(0 0 0 0)", clipPath: "inset(50%)" }}
{...props}
>
{children}
</span>
));
VisuallyHidden.displayName = "VisuallyHidden";
const DialogContent = React.forwardRef< const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>, React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
>(({ className, children, ...props }, ref) => ( hideTitle?: boolean;
<DialogPortal> }
<DialogOverlay /> >(({ className, children, hideTitle = false, ...props }, ref) => {
<DialogPrimitive.Content // Check if DialogTitle is included in children
ref={ref} const hasDialogTitle = React.Children.toArray(children).some(
className={cn( (child) => React.isValidElement(child) && child.type === DialogTitle,
"fixed z-50 flex w-full max-w-lg flex-col gap-4 rounded-xl border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]", );
className,
)} return (
{...props} <DialogPortal>
> <DialogOverlay />
{children} <DialogPrimitive.Content
<ShadTooltip ref={ref}
styleClasses="z-50" className={cn(
content="Close" "fixed z-50 flex w-full max-w-lg flex-col gap-4 rounded-xl border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]",
side="bottom" className,
avoidCollisions )}
{...props}
> >
<DialogPrimitive.Close className="absolute right-2 top-2 flex h-8 w-8 items-center justify-center rounded-sm ring-offset-background transition-opacity hover:bg-secondary-hover hover:text-accent-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> {!hasDialogTitle && (
<Cross2Icon className="h-[18px] w-[18px]" /> <VisuallyHidden>
<span className="sr-only">Close</span> <DialogTitle>Dialog</DialogTitle>
</DialogPrimitive.Close> </VisuallyHidden>
</ShadTooltip> )}
</DialogPrimitive.Content> {children}
</DialogPortal> <ShadTooltip
)); styleClasses="z-50"
content="Close"
side="bottom"
avoidCollisions
>
<DialogPrimitive.Close className="absolute right-2 top-2 flex h-8 w-8 items-center justify-center rounded-sm ring-offset-background transition-opacity hover:bg-secondary-hover hover:text-accent-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<Cross2Icon className="h-[18px] w-[18px]" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</ShadTooltip>
</DialogPrimitive.Content>
</DialogPortal>
);
});
DialogContent.displayName = DialogPrimitive.Content.displayName; DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ const DialogHeader = ({
@ -126,4 +156,5 @@ export {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
VisuallyHidden,
}; };

View file

@ -111,11 +111,7 @@ const AnimationComponent: React.FC<{
{segment} {segment}
</motion.span> </motion.span>
) : per === "word" ? ( ) : per === "word" ? (
<motion.span <motion.span variants={variants} className="inline-block whitespace-pre">
aria-hidden="true"
variants={variants}
className="inline-block whitespace-pre"
>
{segment} {segment}
</motion.span> </motion.span>
) : ( ) : (
@ -123,7 +119,6 @@ const AnimationComponent: React.FC<{
{segment.split("").map((char, charIndex) => ( {segment.split("").map((char, charIndex) => (
<motion.span <motion.span
key={`char-${charIndex}`} key={`char-${charIndex}`}
aria-hidden="true"
variants={variants} variants={variants}
className="inline-block whitespace-pre" className="inline-block whitespace-pre"
> >
@ -151,7 +146,7 @@ AnimationComponent.displayName = "AnimationComponent";
export function TextEffect({ export function TextEffect({
children, children,
per = "word", per = "word",
as = "p", as = "span",
variants, variants,
className, className,
preset, preset,
@ -224,7 +219,7 @@ export function TextEffect({
export function TextEffectPerChar({ children }: { children: string }) { export function TextEffectPerChar({ children }: { children: string }) {
return ( return (
<TextEffect per="char" preset="fade"> <TextEffect per="char" preset="fade" as="span">
{children} {children}
</TextEffect> </TextEffect>
); );

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -9,6 +9,7 @@ export type NoticeAlertType = {
link?: string; link?: string;
id: string; id: string;
removeAlert: (id: string) => void; removeAlert: (id: string) => void;
list?: Array<string>;
}; };
export type SuccessAlertType = { export type SuccessAlertType = {
title: string; title: string;

View file

@ -160,6 +160,7 @@ import {
Pen, Pen,
Pencil, Pencil,
PencilLine, PencilLine,
PencilRuler,
PieChart, PieChart,
Pin, Pin,
Plane, Plane,
@ -857,6 +858,7 @@ export const nodeIconsLucide: iconsType = {
UserMinus2, UserMinus2,
UserPlus2, UserPlus2,
Pencil, Pencil,
PencilRuler,
ChevronsRight, ChevronsRight,
ChevronsLeft, ChevronsLeft,
EyeOff, EyeOff,