feat: add new Tab input (#7032)
* Added TabMixin * Added Tab component on inputs * Added Tab component to initializations * Added tests for tab input * Added Tab Component type * Added options and active tab to input field type * Added tab component on frontend * Instantiate tab component * Update package lock * Refactor input classes and imports for consistency - Reordered imports to maintain consistency across files. - Simplified class definitions by removing unnecessary line breaks. - Updated the `__all__` list in `__init__.py` files to include `TableInput` consistently. - Adjusted test cases for cleaner syntax without altering functionality. * Add constants for tab options limits in input mixin - Introduced `MAX_TAB_OPTIONS` and `MAX_TAB_OPTION_LENGTH` constants for better maintainability. - Updated validation logic in `TabMixin` to use these constants for clearer and more flexible error messages. * Refactor tab input validation tests for improved clarity - Replaced individual test cases for invalid tab inputs with a parameterized test function. - Enhanced test coverage by including cases for too many options, exceeding character limits, and non-string values. - Improved documentation within the test function for better understanding of validation scenarios. * Enhance tab input validation tests with parameterization - Refactored `test_tab_input_valid` to use `pytest.mark.parametrize` for improved test coverage and clarity. - Included multiple scenarios for valid tab inputs, such as standard, fewer options, and empty options. - Updated assertions to reflect the expected outcomes based on parameterized inputs. * Enhance TabInput validation to ensure value is a string and one of the specified options - Updated the `validate_value` method to enforce that the input value is a string. - Added a check to validate that the value is among the allowed options, raising a ValueError with a descriptive message if not. - Improved error handling for better user feedback on invalid inputs. * Fix optional chaining in error handling within CodeAreaModal - Updated the error check in the `delayedFunction` to use optional chaining for safer access to the error detail. - This change ensures that the code handles cases where `detail` may be undefined, improving robustness. * Add 'TAB' field type to schema and update direct types list - Included 'TAB' as a valid field type in the schema conversion dictionary. - Updated the DIRECT_TYPES list to include 'tab', ensuring consistency across type definitions. - These changes enhance the flexibility of the input handling for tab components. * Add unit test * Re-added noqa * fix: unit tests * [autofix.ci] apply automated fixes --------- Co-authored-by: Gabriel Luiz Freitas Almeida <gabriel@langflow.org> Co-authored-by: italojohnny <italojohnnydosanjos@gmail.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
7aca264fec
commit
95ceb52fa3
14 changed files with 408 additions and 7 deletions
|
|
@ -0,0 +1,60 @@
|
|||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs-button";
|
||||
import { useEffect, useState } from "react";
|
||||
import { InputProps, TabComponentType } from "../../types";
|
||||
|
||||
export default function TabComponent({
|
||||
id,
|
||||
value,
|
||||
editNode,
|
||||
handleOnNewValue,
|
||||
disabled,
|
||||
options = [],
|
||||
...baseInputProps
|
||||
}: InputProps<string, TabComponentType>) {
|
||||
const [activeTab, setActiveTab] = useState<string>(value || "");
|
||||
|
||||
// Update the active tab when the component props change
|
||||
useEffect(() => {
|
||||
if (options.length > 0) {
|
||||
// If value is one of the options, use it
|
||||
if (value && options.includes(value)) {
|
||||
setActiveTab(value);
|
||||
}
|
||||
}
|
||||
}, [options, value]);
|
||||
|
||||
// Handle tab change
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveTab(value);
|
||||
handleOnNewValue({ value }, {});
|
||||
};
|
||||
|
||||
// Validate tab values - maximum 3 tabs, each with maximum 20 characters
|
||||
const validOptions = options
|
||||
.slice(0, 3)
|
||||
.map((tab) => (tab.length > 20 ? tab.substring(0, 20) : tab));
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Tabs
|
||||
defaultValue={activeTab}
|
||||
value={activeTab}
|
||||
onValueChange={handleTabChange}
|
||||
className={`w-full ${disabled ? "pointer-events-none opacity-70" : ""}`}
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
{validOptions.map((tab, index) => (
|
||||
<TabsTrigger
|
||||
key={`${id}_tab_${index}`}
|
||||
value={tab}
|
||||
className="flex-1"
|
||||
disabled={disabled}
|
||||
>
|
||||
{tab}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import { handleOnNewValueType } from "@/CustomNodes/hooks/use-handle-new-value";
|
|||
import TableNodeComponent from "@/components/core/parameterRenderComponent/components/TableNodeComponent";
|
||||
import CodeAreaComponent from "@/components/core/parameterRenderComponent/components/codeAreaComponent";
|
||||
import SliderComponent from "@/components/core/parameterRenderComponent/components/sliderComponent";
|
||||
import TabComponent from "@/components/core/parameterRenderComponent/components/tabComponent";
|
||||
import { TEXT_FIELD_TYPES } from "@/constants/constants";
|
||||
import { APIClassType, InputFieldType } from "@/types/api";
|
||||
import { useMemo } from "react";
|
||||
|
|
@ -208,6 +209,14 @@ export function ParameterRenderComponent({
|
|||
id={`slider_${id}`}
|
||||
/>
|
||||
);
|
||||
case "tab":
|
||||
return (
|
||||
<TabComponent
|
||||
{...baseInputProps}
|
||||
options={templateData?.options || []}
|
||||
id={`tab_${id}`}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <EmptyParameterComponent {...baseInputProps} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,6 +104,10 @@ export type MultiselectComponentType = {
|
|||
combobox?: boolean;
|
||||
};
|
||||
|
||||
export type TabComponentType = {
|
||||
options: string[];
|
||||
};
|
||||
|
||||
export type NodeInfoType = {
|
||||
flowId: string;
|
||||
nodeType: string;
|
||||
|
|
|
|||
|
|
@ -145,7 +145,7 @@ export default function CodeAreaModal({
|
|||
useEffect(() => {
|
||||
// Function to be executed after the state changes
|
||||
const delayedFunction = setTimeout(() => {
|
||||
if (error?.detail.error !== undefined) {
|
||||
if (error?.detail?.error !== undefined) {
|
||||
//trigger to update the height, does not really apply any height
|
||||
setHeight("90%");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,6 +85,8 @@ export type InputFieldType = {
|
|||
refresh_button_text?: string;
|
||||
combobox?: boolean;
|
||||
info?: string;
|
||||
options?: string[];
|
||||
active_tab?: number;
|
||||
[key: string]: any;
|
||||
icon?: string;
|
||||
text?: string;
|
||||
|
|
|
|||
164
src/frontend/tests/core/unit/tabComponent.spec.ts
Normal file
164
src/frontend/tests/core/unit/tabComponent.spec.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import { expect, Page, test } from "@playwright/test";
|
||||
import { awaitBootstrapTest } from "../../utils/await-bootstrap-test";
|
||||
|
||||
test(
|
||||
"user should interact with tab component",
|
||||
{ tag: ["@release", "@workspace"] },
|
||||
async ({ context, page }) => {
|
||||
await awaitBootstrapTest(page);
|
||||
|
||||
await page.waitForSelector('[data-testid="blank-flow"]', {
|
||||
timeout: 30000,
|
||||
});
|
||||
await page.getByTestId("blank-flow").click();
|
||||
|
||||
await page.waitForSelector(
|
||||
'[data-testid="sidebar-custom-component-button"]',
|
||||
{
|
||||
timeout: 3000,
|
||||
},
|
||||
);
|
||||
|
||||
await page.getByTestId("sidebar-custom-component-button").click();
|
||||
await page.getByTitle("fit view").click();
|
||||
await page.getByTitle("zoom out").click();
|
||||
|
||||
await page.getByTestId("title-Custom Component").first().click();
|
||||
|
||||
await page.waitForSelector('[data-testid="code-button-modal"]', {
|
||||
timeout: 3000,
|
||||
});
|
||||
|
||||
await page.getByTestId("code-button-modal").click();
|
||||
|
||||
let cleanCode = await extractAndCleanCode(page);
|
||||
|
||||
// Use regex pattern to match the imports section more flexibly
|
||||
cleanCode = updateComponentCode(cleanCode, {
|
||||
imports: ["MessageTextInput", "Output", "TabInput"],
|
||||
inputs: [
|
||||
{
|
||||
name: "MessageTextInput",
|
||||
config: {
|
||||
name: "input_value",
|
||||
display_name: "Input Value",
|
||||
info: "This is a custom component Input",
|
||||
value: "Hello, World!",
|
||||
tool_mode: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "TabInput",
|
||||
config: {
|
||||
name: "tab_selection",
|
||||
display_name: "Tab Selection",
|
||||
options: ["Tab 1", "Tab 2", "Tab 3"],
|
||||
value: "Tab 1",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await page.locator("textarea").last().press(`ControlOrMeta+a`);
|
||||
await page.keyboard.press("Backspace");
|
||||
await page.locator("textarea").last().fill(cleanCode);
|
||||
await page.locator('//*[@id="checkAndSaveBtn"]').click();
|
||||
|
||||
await page.waitForSelector('[data-testid="fit_view"]', {
|
||||
timeout: 3000,
|
||||
});
|
||||
|
||||
await page.getByTestId("fit_view").click();
|
||||
await page.getByTestId("zoom_out").click();
|
||||
|
||||
// Verify that all tabs are visible
|
||||
expect(await page.getByText("Tab 1").isVisible()).toBeTruthy();
|
||||
expect(await page.getByText("Tab 2").isVisible()).toBeTruthy();
|
||||
expect(await page.getByText("Tab 3").isVisible()).toBeTruthy();
|
||||
|
||||
// Verify that Tab 1 is active by default (as specified in the value)
|
||||
expect(
|
||||
await page
|
||||
.getByRole("tab", { name: "Tab 1", selected: true })
|
||||
.isVisible(),
|
||||
).toBeTruthy();
|
||||
|
||||
// Click on Tab 2 and verify it becomes active
|
||||
await page.getByRole("tab", { name: "Tab 2" }).click();
|
||||
expect(
|
||||
await page
|
||||
.getByRole("tab", { name: "Tab 2", selected: true })
|
||||
.isVisible(),
|
||||
).toBeTruthy();
|
||||
|
||||
// Click on Tab 3 and verify it becomes active
|
||||
await page.getByRole("tab", { name: "Tab 3" }).click();
|
||||
expect(
|
||||
await page
|
||||
.getByRole("tab", { name: "Tab 3", selected: true })
|
||||
.isVisible(),
|
||||
).toBeTruthy();
|
||||
},
|
||||
);
|
||||
|
||||
async function extractAndCleanCode(page: Page): Promise<string> {
|
||||
const outerHTML = await page
|
||||
.locator('//*[@id="codeValue"]')
|
||||
.evaluate((el) => el.outerHTML);
|
||||
|
||||
const valueMatch = outerHTML.match(/value="([\s\S]*?)"/);
|
||||
if (!valueMatch) {
|
||||
throw new Error("Could not find value attribute in the HTML");
|
||||
}
|
||||
|
||||
let codeContent = valueMatch[1]
|
||||
.replace(/"/g, '"')
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/'/g, "'")
|
||||
.replace(///g, "/");
|
||||
|
||||
return codeContent;
|
||||
}
|
||||
|
||||
function updateComponentCode(
|
||||
code: string,
|
||||
updates: {
|
||||
imports?: string[];
|
||||
inputs?: Array<{ name: string; config: Record<string, any> }>;
|
||||
},
|
||||
): string {
|
||||
let updatedCode = code;
|
||||
|
||||
// Update imports
|
||||
if (updates.imports) {
|
||||
const importPattern = /from\s+langflow\.io\s+import\s+([^;\n]+)/;
|
||||
const newImports = updates.imports.join(", ");
|
||||
updatedCode = updatedCode.replace(
|
||||
importPattern,
|
||||
`from langflow.io import ${newImports}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Update inputs
|
||||
if (updates.inputs) {
|
||||
const inputsPattern = /inputs\s*=\s*\[([\s\S]*?)\]/;
|
||||
const newInputs = updates.inputs
|
||||
.map(({ name, config }) => {
|
||||
const params = Object.entries(config)
|
||||
.map(([key, value]) => `${key}=${JSON.stringify(value)}`)
|
||||
.join(",\n ");
|
||||
return ` ${name}(\n ${params}\n )`;
|
||||
})
|
||||
.join(",\n");
|
||||
updatedCode = updatedCode.replace(
|
||||
inputsPattern,
|
||||
`inputs = [\n${newInputs}\n ]`,
|
||||
);
|
||||
updatedCode = updatedCode.replace("true", "True");
|
||||
updatedCode = updatedCode.replace("false", "False");
|
||||
}
|
||||
|
||||
return updatedCode;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue