feat: add minimize and expand functionality for UI nodes (#4388)

*  (frontend): improve UI by dynamically showing/hiding elements based on showNode state
🔧 (frontend): update node internals when toggling showNode state
📝 (frontend): add test for minimizing and expanding components

* 🔧 (handleRenderComponent/index.tsx): refactor getHandleClasses function to improve code readability and maintainability
🔧 (handleRenderComponent/index.tsx): refactor handleClick function to improve code readability and maintainability

---------

Co-authored-by: Gabriel Luiz Freitas Almeida <gabriel@langflow.org>
This commit is contained in:
Cristhian Zanforlin Lousa 2024-11-05 10:02:10 -03:00 committed by GitHub
commit 84d07d9ec3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 193 additions and 62 deletions

File diff suppressed because one or more lines are too long

View file

@ -68,13 +68,20 @@ export default function NodeName({
<div className="group flex w-full items-center gap-1">
<div
onDoubleClick={(event) => {
if (!showNode) {
return;
}
setInputName(true);
takeSnapshot();
event.stopPropagation();
event.preventDefault();
}}
data-testid={"title-" + display_name}
className="nodoubleclick w-full cursor-text truncate font-medium text-primary"
className={
showNode
? "nodoubleclick w-full cursor-text truncate font-medium text-primary"
: "cursor-default"
}
>
<div className="flex items-center gap-2">
<span
@ -82,6 +89,7 @@ export default function NodeName({
"max-w-44 truncate text-[14px]",
validationStatus?.data?.duration && "max-w-36",
isOutdated && "max-w-40",
!showNode && "max-w-28",
)}
>
{display_name}

View file

@ -124,7 +124,7 @@ export default function NodeOutputField({
);
return !showNode ? (
<></>
<>{Handle}</>
) : (
<div
ref={ref}

View file

@ -265,9 +265,34 @@ export default function HandleRenderComponent({
const handleRef = useRef<HTMLDivElement>(null);
const invisibleDivRef = useRef<HTMLDivElement>(null);
const getHandleClasses = ({
left,
showNode,
}: {
left: boolean;
showNode: boolean;
}) => {
return cn(
"noflow nowheel nopan noselect absolute left-3.5 -translate-y-1/2 translate-x-1/3 cursor-crosshair rounded-full",
left && "-left-5 -translate-x-1/2",
left && !showNode && "-translate-y-5 translate-x-4",
!left && !showNode && "-translate-y-5 translate-x-[10.8rem]",
);
};
const handleClick = () => {
setFilterEdge(groupByFamily(myData, tooltipTitle!, left, nodes!));
setFilterType(currentFilter);
if (filterOpenHandle && filterType) {
onConnect(getConnection(filterType));
setFilterType(undefined);
setFilterEdge([]);
}
};
return (
<div>
<div className="relative">
<div className={`${!showNode ? "" : "relative"}`}>
<ShadTooltip
open={openTooltip}
setOpen={setOpenTooltip}
@ -299,18 +324,11 @@ export default function HandleRenderComponent({
isValidConnection={(connection) =>
isValidConnection(connection, nodes, edges)
}
className={classNames(
`group/handle z-50 h-12 w-12 border-none bg-transparent transition-all`,
className={cn(
`group/handle z-50 transition-all`,
!showNode && "no-show",
)}
onClick={() => {
setFilterEdge(groupByFamily(myData, tooltipTitle!, left, nodes!));
setFilterType(currentFilter);
if (filterOpenHandle && filterType) {
onConnect(getConnection(filterType));
setFilterType(undefined);
setFilterEdge([]);
}
}}
onClick={handleClick}
onMouseUp={() => {
setOpenTooltip(false);
}}
@ -342,10 +360,7 @@ export default function HandleRenderComponent({
!showNode ? (left ? "target" : "source") : left ? "left" : "right"
}`}
ref={invisibleDivRef}
className={cn(
"noflow nowheel nopan noselect absolute left-3.5 -translate-y-1/2 translate-x-1/3 cursor-crosshair rounded-full",
left && "-left-5 -translate-x-1/2",
)}
className={getHandleClasses({ left, showNode })}
style={{
background: isNullHandle ? "hsl(var(--border))" : handleColor,
width: "10px",

View file

@ -39,11 +39,19 @@ export function NodeIcon({
if (isLucideIcon) {
return (
<div className="bg-lucide-icon text-foreground">
<div
className={cn(
"bg-lucide-icon text-foreground",
!showNode && "min-h-8 min-w-8",
)}
>
<IconComponent
strokeWidth={ICON_STROKE_WIDTH}
name={iconName}
className={iconClassName}
className={cn(
iconClassName,
!showNode && "absolute -translate-x-0.5",
)}
/>
</div>
);

View file

@ -286,8 +286,10 @@ export default function GenericNode({
<div
className={cn(
borderColor,
showNode ? "w-80 rounded-xl" : "w-26 h-26 rounded-full",
"generic-node-div group/node",
showNode
? "w-80 rounded-xl"
: `h-[4.065rem] w-48 rounded-[0.75rem] ${!selected ? "border-[1px] border-border ring-[0.5px] ring-border" : ""}`,
"generic-node-div group/node relative",
!hasOutputs && "pb-4",
)}
>
@ -308,21 +310,23 @@ export default function GenericNode({
)}
</div>
<div className="grid gap-3 truncate text-wrap border-b p-4 leading-5">
<div
data-testid={`${data.id}-main-node`}
className={cn(
"grid gap-3 truncate text-wrap p-4 leading-5",
showNode && "border-b",
)}
>
<div
data-testid={"div-generic-node"}
className={
"generic-node-div-title justify-between" +
(!showNode
? " relative h-24 w-24 rounded-full"
: " justify-between rounded-t-lg")
!showNode
? ""
: "generic-node-div-title justify-between rounded-t-lg"
}
>
<div
className={
"generic-node-title-arrangement " +
(!showNode ? " justify-center" : "")
}
className={"generic-node-title-arrangement"}
data-testid="generic-node-title-arrangement"
>
<NodeIcon
@ -331,18 +335,16 @@ export default function GenericNode({
icon={data.node?.icon}
isGroup={!!data.node?.flow}
/>
{showNode && (
<div className="generic-node-tooltip-div">
<NodeName
display_name={data.node?.display_name}
nodeId={data.id}
selected={selected}
showNode={showNode}
validationStatus={validationStatus}
isOutdated={isOutdated}
/>
</div>
)}
<div className="generic-node-tooltip-div">
<NodeName
display_name={data.node?.display_name}
nodeId={data.id}
selected={selected}
showNode={showNode}
validationStatus={validationStatus}
isOutdated={isOutdated}
/>
</div>
</div>
<div>
{!showNode && (
@ -378,13 +380,15 @@ export default function GenericNode({
/>
)}
</div>
<div>
<NodeDescription
description={data.node?.description}
nodeId={data.id}
selected={selected}
/>
</div>
{showNode && (
<div>
<NodeDescription
description={data.node?.description}
nodeId={data.id}
selected={selected}
/>
</div>
)}
</div>
{showNode && (

View file

@ -80,6 +80,7 @@ export default function NodeToolbarComponent({
function minimize() {
if (isMinimal) {
setShowNode((data.showNode ?? true) ? false : true);
updateNodeInternals(data.id);
return;
}
setNoticeData({
@ -549,7 +550,10 @@ export default function NodeToolbarComponent({
/>
</SelectItem>
{isMinimal && (
<SelectItem value={"show"}>
<SelectItem
value={"show"}
data-testid={`${showNode ? "minimize" : "expand"}-button-modal`}
>
<ToolbarSelectItem
shortcut={
shortcuts.find((obj) => obj.name === "Minimize")?.shortcut!

View file

@ -191,7 +191,7 @@
/* The same as primary-input but no-truncate */
.textarea-primary {
@apply placeholder:text-placeholder-foreground form-input block w-full rounded-md border-[1px] border-border bg-background px-3 text-left shadow-sm hover:border-muted focus:border-muted focus:placeholder-transparent focus:ring-[0.75px] focus:ring-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-secondary disabled:text-muted disabled:opacity-100 placeholder:disabled:text-muted-foreground sm:text-sm;
@apply form-input block w-full rounded-md border-[1px] border-border bg-background px-3 text-left shadow-sm placeholder:text-placeholder-foreground hover:border-muted focus:border-muted focus:placeholder-transparent focus:ring-[0.75px] focus:ring-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-secondary disabled:text-muted disabled:opacity-100 placeholder:disabled:text-muted-foreground sm:text-sm;
}
.input-edit-node {
@ -319,7 +319,7 @@
@apply grid w-full gap-4 p-4 md:grid-cols-2 lg:grid-cols-4;
}
.generic-node-div {
@apply relative flex flex-col justify-center bg-background transition-all;
@apply flex flex-col justify-center bg-background transition-all;
}
.generic-node-div-title {
@apply flex w-full items-center gap-2;
@ -1219,7 +1219,7 @@
}
.disabled-state {
@apply text-hard-zinc pointer-events-none bg-secondary;
@apply pointer-events-none bg-secondary text-hard-zinc;
}
.background-fade-input {

View file

@ -209,6 +209,12 @@ textarea[class^="ag-"]:focus {
transform: translate(-50%, -50%) !important;
}
/* Modified transform when showNode is false */
.react-flow__handle-right.no-show,
.react-flow__handle-left.no-show {
transform: translate(0%, -50%) !important;
}
.react-flow__node-noteNode:not(.selected) {
z-index: -1 !important;
}

View file

@ -0,0 +1,82 @@
import { expect, test } from "@playwright/test";
test("user must be able to minimize and expand a component", async ({
page,
}) => {
await page.goto("/");
await page.waitForSelector('[data-testid="mainpage_title"]', {
timeout: 30000,
});
await page.waitForSelector('[id="new-project-btn"]', {
timeout: 30000,
});
let modalCount = 0;
try {
const modalTitleElement = await page?.getByTestId("modal-title");
if (modalTitleElement) {
modalCount = await modalTitleElement.count();
}
} catch (error) {
modalCount = 0;
}
while (modalCount === 0) {
await page.getByText("New Flow", { exact: true }).click();
await page.waitForTimeout(3000);
modalCount = await page.getByTestId("modal-title")?.count();
}
await page.getByTestId("blank-flow").click();
await page.getByTestId("sidebar-search-input").click();
await page.getByTestId("sidebar-search-input").fill("text input");
await page.waitForTimeout(1000);
await page
.getByTestId("inputsText Input")
.dragTo(page.locator('//*[@id="react-flow-id"]'));
await page.getByTestId("zoom_out").click();
await page
.locator('//*[@id="react-flow-id"]')
.hover()
.then(async () => {
await page.mouse.down();
await page.mouse.move(-800, 300);
});
await page.mouse.up();
await page.getByTestId("fit_view").click();
await page.getByTestId("zoom_out").click();
await page.getByTestId("zoom_out").click();
await page.getByTestId("zoom_out").click();
await page.getByTestId("more-options-modal").click();
await page.waitForTimeout(1000);
await page.getByTestId("minimize-button-modal").first().click();
await page.waitForTimeout(1000);
await expect(
page.locator(".react-flow__handle-left.no-show").first(),
).toBeVisible();
await expect(
page.locator(".react-flow__handle-right.no-show").first(),
).toBeVisible();
await page.getByTestId("more-options-modal").click();
await page.waitForTimeout(1000);
await page.getByTestId("expand-button-modal").first().click();
await page.waitForTimeout(1000);
await expect(page.locator(".react-flow__handle-left").first()).toBeVisible();
await expect(page.locator(".react-flow__handle-right").first()).toBeVisible();
});