feat(frontend): add expandable text input modal for better editing experience (#11510)

Text inputs in the form builder can be difficult to edit when dealing
with longer content. Users need a way to expand text inputs into a
larger, more comfortable editing interface, especially for multi-line
text, passwords, and longer string values.



https://github.com/user-attachments/assets/443bf4eb-c77c-4bf6-b34c-77091e005c6d


### Changes 🏗️

- **Added `InputExpanderModal` component**: A new modal component that
provides a larger textarea (300px min-height) for editing text inputs
with the following features:
- Copy-to-clipboard functionality with visual feedback (checkmark icon)
  - Toast notification on successful copy
  - Auto-focus on open for better UX
  - Proper state management to reset values when modal opens/closes
  
- **Enhanced `TextInputWidget`**:
- Added expand button (ArrowsOutIcon) with tooltip for text, password,
and textarea input types
  - Button appears inline next to the input field
  - Integrated the new `InputExpanderModal` component
  - Improved layout with flexbox to accommodate the expand button
- Added padding-right to input when expand button is visible to prevent
text overlap

- **Refactored file structure**:
  - Moved `TextInputWidget.tsx` into `TextInputWidget/` directory
  - Updated import path in `widgets/index.ts`

- **UX improvements**:
- Expand button only shows for applicable input types (text, password,
textarea)
  - Number and integer inputs don't show expand button (not needed)
- Modal preserves schema title, description, and placeholder for context

### Checklist 📋

#### For code changes:

- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] Test expand button appears for text input fields
  - [x] Test expand button appears for password input fields  
  - [x] Test expand button appears for textarea fields
  - [x] Test expand button does NOT appear for number/integer inputs
  - [x] Test clicking expand button opens modal with current value
  - [x] Test editing text in modal and saving updates the input field
  - [x] Test cancel button closes modal without saving changes
- [x] Test copy-to-clipboard button copies text and shows success state
  - [x] Test toast notification appears on successful copy
  - [x] Test modal resets to original value when reopened
  - [x] Test modal auto-focuses textarea on open
  - [x] Test expand button tooltip displays correctly
  - [x] Test input field layout with expand button (no text overlap)
This commit is contained in:
Abhimanyu Yadav
2025-12-04 20:42:32 +05:30
committed by GitHub
parent 78c2245269
commit f6608e99c8
3 changed files with 192 additions and 14 deletions

View File

@@ -0,0 +1,118 @@
"use client";
import React, { FC, useEffect, useState } from "react";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { CheckIcon, CopyIcon } from "@phosphor-icons/react";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { cn } from "@/lib/utils";
import { Input } from "@/components/atoms/Input/Input";
interface InputExpanderModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (value: string) => void;
title?: string;
defaultValue: string;
description?: string;
placeholder?: string;
}
export const InputExpanderModal: FC<InputExpanderModalProps> = ({
isOpen,
onClose,
onSave,
title,
defaultValue,
description,
placeholder,
}) => {
const [tempValue, setTempValue] = useState(defaultValue);
const [isCopied, setIsCopied] = useState(false);
const { toast } = useToast();
useEffect(() => {
if (isOpen) {
setTempValue(defaultValue);
setIsCopied(false);
}
}, [isOpen, defaultValue]);
const handleSave = () => {
onSave(tempValue);
onClose();
};
const copyValue = () => {
navigator.clipboard.writeText(tempValue).then(() => {
setIsCopied(true);
toast({
title: "Copied to clipboard!",
duration: 2000,
});
setTimeout(() => setIsCopied(false), 2000);
});
};
return (
<Dialog
controlled={{
isOpen,
set: async (open) => {
if (!open) onClose();
},
}}
onClose={onClose}
styling={{ maxWidth: "600px", minWidth: "600px" }}
>
<Dialog.Content>
<div className="flex flex-col gap-4 px-1">
<Text variant="h4" className="text-slate-900">
{title || "Edit Text"}
</Text>
<Text variant="body">{description}</Text>
<Input
type="textarea"
label=""
hideLabel
id="input-expander-modal"
value={tempValue}
className="!min-h-[300px] rounded-2xlarge"
onChange={(e) => setTempValue(e.target.value)}
placeholder={placeholder || "Enter text..."}
autoFocus
/>
<div className="flex items-center justify-end gap-1">
<Button
variant="secondary"
size="small"
onClick={copyValue}
className={cn(
"h-fit min-w-0 gap-1.5 border border-zinc-200 p-2 text-black hover:text-slate-900",
isCopied &&
"border-green-400 bg-green-100 hover:border-green-400 hover:bg-green-200",
)}
>
{isCopied ? (
<CheckIcon size={16} className="text-green-600" />
) : (
<CopyIcon size={16} />
)}
</Button>
</div>
<Dialog.Footer>
<Button variant="secondary" size="small" onClick={onClose}>
Cancel
</Button>
<Button variant="primary" size="small" onClick={handleSave}>
Save
</Button>
</Dialog.Footer>
</div>
</Dialog.Content>
</Dialog>
);
};

View File

@@ -1,10 +1,21 @@
"use client";
import { useState } from "react";
import { WidgetProps } from "@rjsf/utils";
import {
InputType,
mapJsonSchemaTypeToInputType,
} from "@/app/(platform)/build/components/FlowEditor/nodes/helpers";
import { Input } from "@/components/atoms/Input/Input";
import { Button } from "@/components/atoms/Button/Button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
import { BlockUIType } from "@/lib/autogpt-server-api/types";
import { InputExpanderModal } from "./InputExpanderModal";
import { ArrowsOutIcon } from "@phosphor-icons/react";
export const TextInputWidget = (props: WidgetProps) => {
const { schema, formContext } = props;
@@ -13,6 +24,8 @@ export const TextInputWidget = (props: WidgetProps) => {
size?: string;
};
const [isModalOpen, setIsModalOpen] = useState(false);
const mapped = mapJsonSchemaTypeToInputType(schema);
type InputConfig = {
@@ -59,9 +72,25 @@ export const TextInputWidget = (props: WidgetProps) => {
return props.onChange(config.handleChange(v));
};
const handleModalSave = (value: string) => {
props.onChange(config.handleChange(value));
setIsModalOpen(false);
};
const handleModalOpen = () => {
setIsModalOpen(true);
};
// Determine input size based on context
const inputSize = size === "large" ? "medium" : "small";
// Check if this input type should show the expand button
// Show for text and password types, not for number/integer
const showExpandButton =
config.htmlType === "text" ||
config.htmlType === "password" ||
config.htmlType === "textarea";
if (uiType === BlockUIType.NOTE) {
return (
<Input
@@ -82,18 +111,49 @@ export const TextInputWidget = (props: WidgetProps) => {
}
return (
<Input
id={props.id}
hideLabel={true}
type={config.htmlType as any}
label={""}
size={inputSize as any}
wrapperClassName="mb-0"
value={props.value ?? ""}
onChange={handleChange}
placeholder={schema.placeholder || config.placeholder}
required={props.required}
disabled={props.disabled}
/>
<>
<div className="nodrag relative flex items-center gap-2">
<Input
id={props.id}
hideLabel={true}
type={config.htmlType as any}
label={""}
size={inputSize as any}
wrapperClassName="mb-0 flex-1"
value={props.value ?? ""}
onChange={handleChange}
placeholder={schema.placeholder || config.placeholder}
required={props.required}
disabled={props.disabled}
className={showExpandButton ? "pr-8" : ""}
/>
{showExpandButton && (
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handleModalOpen}
type="button"
className="p-1"
>
<ArrowsOutIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Expand input</TooltipContent>
</Tooltip>
)}
</div>
<InputExpanderModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSave={handleModalSave}
title={schema.title || "Edit value"}
description={schema.description || ""}
defaultValue={props.value ?? ""}
placeholder={schema.placeholder || config.placeholder}
/>
</>
);
};

View File

@@ -1,6 +1,6 @@
import { RegistryWidgetsType } from "@rjsf/utils";
import { SelectWidget } from "./SelectWidget";
import { TextInputWidget } from "./TextInputWidget";
import { TextInputWidget } from "./TextInputWidget/TextInputWidget";
import { SwitchWidget } from "./SwitchWidget";
import { FileWidget } from "./FileWidget";
import { DateInputWidget } from "./DateInputWidget";