diff --git a/ui/src/components/CustomSelect/__tests__/CustomSelect.test.tsx b/ui/src/components/CustomSelect/__tests__/CustomSelect.test.tsx new file mode 100644 index 000000000..518c8271f --- /dev/null +++ b/ui/src/components/CustomSelect/__tests__/CustomSelect.test.tsx @@ -0,0 +1,27 @@ +import { render, screen } from '@testing-library/react'; +import { expect, describe, it } from '@jest/globals'; +import '@testing-library/jest-dom/jest-globals'; +import '@testing-library/jest-dom'; +import { ChakraProvider } from '@chakra-ui/react'; +import { CustomSelect } from '../CustomSelect'; +import { Option } from '../Option'; + +describe('CustomSelect', () => { + it('forwards data-testid to the toggle button', () => { + render( + + {}} + placeholder='Choose' + > + + + + , + ); + + expect(screen.getByTestId('connector-widget-select')).toBeInTheDocument(); + }); +}); diff --git a/ui/src/components/DataTable/Table.tsx b/ui/src/components/DataTable/Table.tsx index f230c9cc8..45f226463 100644 --- a/ui/src/components/DataTable/Table.tsx +++ b/ui/src/components/DataTable/Table.tsx @@ -1,14 +1,34 @@ import { Table, Tbody, Td, Th, Thead, Tr } from '@chakra-ui/react'; import { flexRender, getCoreRowModel, useReactTable, ColumnDef, Row } from '@tanstack/react-table'; +<<<<<<< HEAD import { useState } from 'react'; +======= +import { useState, ReactNode, ComponentProps } from 'react'; + +type DataTableRowProps = ComponentProps; +>>>>>>> deba42b89 (feat(CE): data-testid hooks for models, Data Apps, and workflows (#1835)) type DataTableProps = { columns: ColumnDef[]; data: TData[]; onRowClick?: (row: Row) => void; +<<<<<<< HEAD }; const DataTable = ({ data, columns, onRowClick }: DataTableProps) => { +======= + noRowsComponent?: ReactNode; + getRowProps?: (row: Row) => DataTableRowProps; +}; + +const DataTable = ({ + data, + columns, + onRowClick, + noRowsComponent, + getRowProps, +}: DataTableProps) => { +>>>>>>> deba42b89 (feat(CE): data-testid hooks for models, Data Apps, and workflows (#1835)) const [rowSelection, setRowSelection] = useState({}); const table = useReactTable({ @@ -43,6 +63,7 @@ const DataTable = ({ data, columns, onRowClick }: DataTableProps< ))} +<<<<<<< HEAD {table.getRowModel().rows.map((row) => ( ({ data, columns, onRowClick }: DataTableProps< ))} ))} +======= + {table.getRowModel().rows.length > 0 + ? table.getRowModel().rows.map((row) => ( + onRowClick?.(row)} + backgroundColor='gray.100' + > + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + : noRowsComponent && ( + + + {noRowsComponent} + + + )} +>>>>>>> deba42b89 (feat(CE): data-testid hooks for models, Data Apps, and workflows (#1835)) ); diff --git a/ui/src/components/DataTable/__tests__/Table.test.tsx b/ui/src/components/DataTable/__tests__/Table.test.tsx new file mode 100644 index 000000000..16fe45efe --- /dev/null +++ b/ui/src/components/DataTable/__tests__/Table.test.tsx @@ -0,0 +1,58 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { expect, describe, it, jest } from '@jest/globals'; +import '@testing-library/jest-dom/jest-globals'; +import '@testing-library/jest-dom'; +import { ChakraProvider } from '@chakra-ui/react'; +import type { ColumnDef, Row as TanStackRow } from '@tanstack/react-table'; +import DataTable from '../Table'; + +type RowData = { id: string; name: string }; + +describe('DataTable', () => { + const columns: ColumnDef[] = [ + { accessorKey: 'name', header: 'Name', cell: (info) => info.getValue() }, + ]; + + const data: RowData[] = [ + { id: 'a', name: 'Alpha' }, + { id: 'b', name: 'Beta' }, + ]; + + it('merges getRowProps onto each row', () => { + render( + + ({ + 'data-testid': `row-${row.original.id}`, + 'data-row-label': row.original.name, + })} + /> + , + ); + + const first = screen.getByTestId('row-a'); + expect(first).toHaveAttribute('data-row-label', 'Alpha'); + expect(screen.getByTestId('row-b')).toHaveAttribute('data-row-label', 'Beta'); + }); + + it('invokes onRowClick with the row when a row is clicked', () => { + const onRowClick = jest.fn(); + render( + + ({ 'data-testid': `row-${row.original.id}` })} + onRowClick={onRowClick} + /> + , + ); + + fireEvent.click(screen.getByTestId('row-a')); + expect(onRowClick).toHaveBeenCalledTimes(1); + const clicked = onRowClick.mock.calls[0][0] as TanStackRow; + expect(clicked.original).toEqual(data[0]); + }); +}); diff --git a/ui/src/components/FormFooter/__tests__/FormFooter.test.tsx b/ui/src/components/FormFooter/__tests__/FormFooter.test.tsx new file mode 100644 index 000000000..e14ab1088 --- /dev/null +++ b/ui/src/components/FormFooter/__tests__/FormFooter.test.tsx @@ -0,0 +1,51 @@ +import type { ComponentProps } from 'react'; +import { render, screen } from '@testing-library/react'; +import { expect, describe, it } from '@jest/globals'; +import '@testing-library/jest-dom/jest-globals'; +import '@testing-library/jest-dom'; +import { ChakraProvider } from '@chakra-ui/react'; +import { MemoryRouter } from 'react-router-dom'; +import FormFooter from '../FormFooter'; + +const renderFormFooter = (props: ComponentProps) => + render( + + + + + , + ); + +describe('FormFooter', () => { + it('exposes stepped-form data-testid derived from cta label for Continue', () => { + renderFormFooter({ + ctaName: 'Continue', + isContinueCtaRequired: true, + }); + expect(screen.getByTestId('stepped-form-continue')).toHaveTextContent('Continue'); + }); + + it('normalizes cta label to kebab-case for data-testid', () => { + renderFormFooter({ + ctaName: 'Save Changes', + isContinueCtaRequired: true, + }); + expect(screen.getByTestId('stepped-form-save-changes')).toHaveTextContent('Save Changes'); + }); + + it('collapses internal spaces in cta name for data-testid', () => { + renderFormFooter({ + ctaName: 'Save Draft', + isContinueCtaRequired: true, + }); + expect(screen.getByTestId('stepped-form-save-draft')).toBeInTheDocument(); + }); + + it('does not render primary CTA when isContinueCtaRequired is false', () => { + renderFormFooter({ + ctaName: 'Continue', + isContinueCtaRequired: false, + }); + expect(screen.queryByTestId('stepped-form-continue')).not.toBeInTheDocument(); + }); +}); diff --git a/ui/src/components/InputField/InputField.tsx b/ui/src/components/InputField/InputField.tsx new file mode 100644 index 000000000..b8082bd8c --- /dev/null +++ b/ui/src/components/InputField/InputField.tsx @@ -0,0 +1,90 @@ +import { Box, Text, Input, Tooltip as ChakraTooltip } from '@chakra-ui/react'; +import { FiInfo } from 'react-icons/fi'; + +type InputFieldProps = { + label: string; + name: string; + value: string; + onChange: React.ChangeEventHandler | undefined; + id?: string; + type?: 'text' | 'password' | 'number'; + placeholder?: string; + helperText?: string; + isTooltip?: boolean; + tooltipLabel?: string; + disabled?: boolean; + isRequired?: boolean; + testId?: string; +}; + +const InputField = ({ + label, + name, + onChange, + value, + id, + type = 'text', + placeholder = '', + helperText, + isTooltip = false, + tooltipLabel, + disabled = false, + isRequired = false, + testId, +}: InputFieldProps) => ( + + + + {label} + + {isRequired && ( + + * + + )} + {isTooltip && ( + + + + + + )} + + + {helperText && ( + + {helperText} + + )} + +); + +export default InputField; diff --git a/ui/src/components/InputField/__tests__/InputField.test.tsx b/ui/src/components/InputField/__tests__/InputField.test.tsx new file mode 100644 index 000000000..6dfb7c77d --- /dev/null +++ b/ui/src/components/InputField/__tests__/InputField.test.tsx @@ -0,0 +1,123 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { expect } from '@jest/globals'; +import '@testing-library/jest-dom/jest-globals'; +import '@testing-library/jest-dom'; +import { ChakraProvider } from '@chakra-ui/react'; +import InputField from '../InputField'; + +const renderInputField = (props = {}) => { + return render( + + {}} {...props} /> + , + ); +}; + +describe('InputField', () => { + const mockOnChange = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render label', () => { + renderInputField({ label: 'Test Label' }); + expect(screen.getByText('Test Label')).toBeInTheDocument(); + }); + + it('should render input field', () => { + renderInputField(); + expect(screen.getByTestId('input-field')).toBeInTheDocument(); + }); + + it('should use custom testId when provided', () => { + renderInputField({ testId: 'settings-api-key-input' }); + expect(screen.getByTestId('settings-api-key-input')).toBeInTheDocument(); + expect(screen.queryByTestId('input-field')).not.toBeInTheDocument(); + }); + + it('should render input with correct name attribute', () => { + renderInputField({ name: 'testName' }); + const input = screen.getByTestId('input-field'); + expect(input).toHaveAttribute('name', 'testName'); + }); + + it('should render input with correct value', () => { + renderInputField({ value: 'test value' }); + const input = screen.getByTestId('input-field') as HTMLInputElement; + expect(input.value).toBe('test value'); + }); + + it('should call onChange when input value changes', () => { + renderInputField({ onChange: mockOnChange }); + const input = screen.getByTestId('input-field'); + fireEvent.change(input, { target: { value: 'new value' } }); + expect(mockOnChange).toHaveBeenCalledTimes(1); + }); + + it('should render placeholder', () => { + renderInputField({ placeholder: 'Enter text here' }); + const input = screen.getByTestId('input-field'); + expect(input).toHaveAttribute('placeholder', 'Enter text here'); + }); + + it('should render helper text when provided', () => { + renderInputField({ helperText: 'This is helper text' }); + expect(screen.getByText('This is helper text')).toBeInTheDocument(); + }); + + it('should not render helper text when not provided', () => { + renderInputField(); + expect(screen.queryByText('This is helper text')).not.toBeInTheDocument(); + }); + + it('should not render tooltip when isTooltip is false', () => { + renderInputField({ isTooltip: false }); + // Tooltip icon should not be visible + const tooltipIcon = screen.queryByRole('img', { hidden: true }); + // The FiInfo icon might still render but tooltip won't show + expect(tooltipIcon).not.toBeInTheDocument(); + }); + + it('should render disabled input when disabled is true', () => { + renderInputField({ disabled: true }); + const input = screen.getByTestId('input-field'); + expect(input).toBeDisabled(); + }); + + it('should render enabled input when disabled is false', () => { + renderInputField({ disabled: false }); + const input = screen.getByTestId('input-field'); + expect(input).not.toBeDisabled(); + }); + + it('should render text input type by default', () => { + renderInputField(); + const input = screen.getByTestId('input-field'); + expect(input).toHaveAttribute('type', 'text'); + }); + + it('should render password input type', () => { + renderInputField({ type: 'password' }); + const input = screen.getByTestId('input-field'); + expect(input).toHaveAttribute('type', 'password'); + }); + + it('should render number input type', () => { + renderInputField({ type: 'number' }); + const input = screen.getByTestId('input-field'); + expect(input).toHaveAttribute('type', 'number'); + }); + + it('should render input with id when provided', () => { + renderInputField({ id: 'test-id' }); + const input = screen.getByTestId('input-field'); + expect(input).toHaveAttribute('id', 'test-id'); + }); + + it('should render input as required', () => { + renderInputField(); + const input = screen.getByTestId('input-field'); + expect(input).toBeRequired(); + }); +}); diff --git a/ui/src/components/JSONSchemaForm/rjsf/BaseInputTemplate.tsx b/ui/src/components/JSONSchemaForm/rjsf/BaseInputTemplate.tsx index cfc0d7139..81794f2c6 100644 --- a/ui/src/components/JSONSchemaForm/rjsf/BaseInputTemplate.tsx +++ b/ui/src/components/JSONSchemaForm/rjsf/BaseInputTemplate.tsx @@ -70,6 +70,9 @@ export default function BaseInputTemplate< onToggle(); }; + const connectorFieldTestId = + id && id.startsWith('root_') ? `connector-config-field-${id.slice('root_'.length)}` : undefined; + return ( ({ + getChakra: () => ({}), +})); + +import BaseInputTemplate from '../BaseInputTemplate'; + +const baseRegistry = { + templates: {}, + widgets: {}, + rootSchema: {}, + schemaUtils: { + getDisplayLabel: () => false, + }, + globalUiOptions: {}, +} as unknown as BaseInputTemplateProps['registry']; + +describe('BaseInputTemplate', () => { + it('sets connector-config-field test id for root_* ids', () => { + const onChange = jest.fn(); + const onBlur = jest.fn(); + const onFocus = jest.fn(); + + const props = { + id: 'root_database_host', + name: 'root_database_host', + schema: { type: 'string' }, + uiSchema: {}, + value: '', + label: 'Host', + hideLabel: false, + required: false, + disabled: false, + readonly: false, + autofocus: false, + placeholder: 'localhost', + onChange, + onBlur, + onFocus, + onChangeOverride: undefined, + options: {}, + rawErrors: [], + registry: baseRegistry, + } as unknown as BaseInputTemplateProps; + + render( + + + , + ); + + const input = screen.getByTestId('connector-config-field-database_host'); + expect(input).toHaveAttribute('id', 'root_database_host'); + fireEvent.change(input, { target: { value: 'db.example.com' } }); + expect(onChange).toHaveBeenCalledWith('db.example.com'); + }); +}); diff --git a/ui/src/components/SearchBar/SearchBar.tsx b/ui/src/components/SearchBar/SearchBar.tsx index 0275ecbf6..bfbb0611a 100644 --- a/ui/src/components/SearchBar/SearchBar.tsx +++ b/ui/src/components/SearchBar/SearchBar.tsx @@ -6,14 +6,30 @@ type SearchBarProps = { setSearchTerm: Dispatch>; placeholder: string; borderColor: string; +<<<<<<< HEAD }; const SearchBar = ({ setSearchTerm, placeholder, borderColor }: SearchBarProps) => ( +======= + width?: string; + 'data-testid'?: string; +}; + +const SearchBar = ({ + setSearchTerm, + placeholder, + borderColor, + width = '100%', + 'data-testid': dataTestId, +}: SearchBarProps) => ( + +>>>>>>> deba42b89 (feat(CE): data-testid hooks for models, Data Apps, and workflows (#1835)) { + it('forwards data-testid to the input', () => { + const setSearchTerm = jest.fn(); + render( + + + , + ); + + const input = screen.getByTestId('workflow-connector-search'); + expect(input).toHaveAttribute('placeholder', 'Search'); + fireEvent.change(input, { target: { value: 'x' } }); + expect(setSearchTerm).toHaveBeenCalled(); + }); +}); diff --git a/ui/src/components/TabItem/TabItem.tsx b/ui/src/components/TabItem/TabItem.tsx index 07ba77e29..4847223c2 100644 --- a/ui/src/components/TabItem/TabItem.tsx +++ b/ui/src/components/TabItem/TabItem.tsx @@ -7,9 +7,30 @@ type TabItemProps = { isBadgeVisible?: boolean; badgeText?: string; extra?: JSX.Element; +<<<<<<< HEAD }; const TabItem = ({ text, action, isBadgeVisible, badgeText, extra }: TabItemProps): JSX.Element => { +======= + icon?: JSX.Element; + flex?: number | string; + testId?: string; +} & TabProps; + +const TabItem = ({ + text, + action, + isBadgeVisible, + badgeText, + extra, + icon, + testId, + height = '30px', + px = '24px', + py = '6px', + ...props +}: TabItemProps): JSX.Element => { +>>>>>>> deba42b89 (feat(CE): data-testid hooks for models, Data Apps, and workflows (#1835)) return ( >>>>>> deba42b89 (feat(CE): data-testid hooks for models, Data Apps, and workflows (#1835)) > {text} diff --git a/ui/src/components/TabItem/__tests__/TabItem.test.tsx b/ui/src/components/TabItem/__tests__/TabItem.test.tsx new file mode 100644 index 000000000..6bfcdf2ff --- /dev/null +++ b/ui/src/components/TabItem/__tests__/TabItem.test.tsx @@ -0,0 +1,150 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { expect } from '@jest/globals'; +import '@testing-library/jest-dom/jest-globals'; +import '@testing-library/jest-dom'; +import { ChakraProvider, Tabs } from '@chakra-ui/react'; +import TabItem from '../TabItem'; +import { FiHome } from 'react-icons/fi'; + +const renderTabItem = (props = {}) => { + return render( + + + + + , + ); +}; + +describe('TabItem', () => { + const mockAction = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render text', () => { + renderTabItem({ text: 'Test Tab' }); + expect(screen.getByText('Test Tab')).toBeInTheDocument(); + }); + + it('should render with correct test id', () => { + renderTabItem({ text: 'Test Tab' }); + expect(screen.getByTestId('tab-item-Test Tab')).toBeInTheDocument(); + }); + + it('should prefer explicit testId over text-based id', () => { + renderTabItem({ text: 'Workflow', testId: 'tab-item-workflow' }); + expect(screen.getByTestId('tab-item-workflow')).toBeInTheDocument(); + }); + + it('should call action when clicked', () => { + renderTabItem({ text: 'Test Tab', action: mockAction }); + const tab = screen.getByTestId('tab-item-Test Tab'); + fireEvent.click(tab); + expect(mockAction).toHaveBeenCalledTimes(1); + }); + + it('should render badge when isBadgeVisible is true and badgeText is provided', () => { + renderTabItem({ + text: 'Test Tab', + isBadgeVisible: true, + badgeText: '5', + }); + expect(screen.getByText('5')).toBeInTheDocument(); + }); + + it('should not render badge when isBadgeVisible is false', () => { + renderTabItem({ + text: 'Test Tab', + isBadgeVisible: false, + badgeText: '5', + }); + expect(screen.queryByText('5')).not.toBeInTheDocument(); + }); + + it('should not render badge when badgeText is not provided', () => { + renderTabItem({ + text: 'Test Tab', + isBadgeVisible: true, + }); + // Badge should not render without badgeText + const badge = screen.queryByTestId('tab-badge'); + expect(badge).not.toBeInTheDocument(); + }); + + it('should render icon when provided', () => { + renderTabItem({ + text: 'Test Tab', + icon: , + }); + expect(screen.getByTestId('tab-icon')).toBeInTheDocument(); + }); + + it('should render extra content when provided', () => { + renderTabItem({ + text: 'Test Tab', + extra:
Extra
, + }); + expect(screen.getByTestId('extra-content')).toBeInTheDocument(); + }); + + it('should render with custom height', () => { + renderTabItem({ + text: 'Test Tab', + height: '40px', + }); + const tab = screen.getByTestId('tab-item-Test Tab'); + expect(tab).toBeInTheDocument(); + }); + + it('should render with custom padding', () => { + renderTabItem({ + text: 'Test Tab', + px: '16px', + py: '8px', + }); + expect(screen.getByTestId('tab-item-Test Tab')).toBeInTheDocument(); + }); + + it('should render with flex prop', () => { + renderTabItem({ + text: 'Test Tab', + flex: 1, + }); + expect(screen.getByTestId('tab-item-Test Tab')).toBeInTheDocument(); + }); + + it('should handle selected state styling', () => { + renderTabItem({ + text: 'Test Tab', + _selected: { + backgroundColor: 'blue.100', + }, + }); + const tab = screen.getByTestId('tab-item-Test Tab'); + expect(tab).toBeInTheDocument(); + }); + + it('should render all props together', () => { + renderTabItem({ + text: 'Complete Tab', + action: mockAction, + isBadgeVisible: true, + badgeText: '10', + icon: , + extra:
Extra
, + }); + expect(screen.getByText('Complete Tab')).toBeInTheDocument(); + expect(screen.getByText('10')).toBeInTheDocument(); + expect(screen.getByTestId('complete-icon')).toBeInTheDocument(); + expect(screen.getByTestId('complete-extra')).toBeInTheDocument(); + }); + + it('should not call action when action is not provided', () => { + renderTabItem({ text: 'Test Tab' }); + const tab = screen.getByTestId('tab-item-Test Tab'); + // Should not throw error + fireEvent.click(tab); + }); +}); diff --git a/ui/src/components/Toast/__tests__/Toast.test.tsx b/ui/src/components/Toast/__tests__/Toast.test.tsx new file mode 100644 index 000000000..1f8eee7b4 --- /dev/null +++ b/ui/src/components/Toast/__tests__/Toast.test.tsx @@ -0,0 +1,32 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { expect, describe, it, jest } from '@jest/globals'; +import '@testing-library/jest-dom/jest-globals'; +import '@testing-library/jest-dom'; +import { ChakraProvider } from '@chakra-ui/react'; +import Toast, { CustomToastStatus } from '../index'; + +describe('Toast', () => { + it('exposes custom toast test ids and status data attribute', () => { + const onClose = jest.fn(); + render( + + + , + ); + + const root = screen.getByTestId('custom-toast'); + expect(root).toHaveAttribute('data-toast-status', CustomToastStatus.Success); + expect(screen.getByTestId('custom-toast-title')).toHaveTextContent('Saved'); + expect(screen.getByTestId('custom-toast-description')).toHaveTextContent( + 'Your changes were saved.', + ); + + fireEvent.click(root.querySelector('button') as HTMLButtonElement); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/ui/src/components/Toast/index.tsx b/ui/src/components/Toast/index.tsx index 18e1ea9af..f5cfa3f10 100644 --- a/ui/src/components/Toast/index.tsx +++ b/ui/src/components/Toast/index.tsx @@ -51,6 +51,8 @@ const Toast: React.FC = ({ return ( = ({ +<<<<<<< HEAD +======= + '' ? 'semibold' : 400} + > +>>>>>>> deba42b89 (feat(CE): data-testid hooks for models, Data Apps, and workflows (#1835)) {title} {description && ( - + {description} )} diff --git a/ui/src/enterprise/components/ChatbotInterface/ChatPromptInput.tsx b/ui/src/enterprise/components/ChatbotInterface/ChatPromptInput.tsx new file mode 100644 index 000000000..5a5588168 --- /dev/null +++ b/ui/src/enterprise/components/ChatbotInterface/ChatPromptInput.tsx @@ -0,0 +1,308 @@ +import React, { useRef, useEffect, useState, KeyboardEvent } from 'react'; +import { Textarea, Box, HStack, Button, Icon, Flex, Text } from '@chakra-ui/react'; +import { FiArrowUp, FiPaperclip, FiPlus, FiSend, FiX } from 'react-icons/fi'; +import { useAssistantConfigStore } from '@/enterprise/store/useAssistantConfigStore'; +import { FEATURE_FLAG_KEYS, useFeatureFlags } from '@/enterprise/hooks/useFeatureFlags'; +import { FeatureFlagWrapper } from '@/components/FeatureFlagWrapper/FeatureFlagWrapper'; +import ToolTip from '@/components/ToolTip'; +import AssistantFileMessage from './AssistantFileMessage'; +import { FlowComponent } from '@/enterprise/views/Agents/types'; +import ContextComponentSelector, { SelectedAdditionalContexts } from './ContextComponentSelector'; +import { ContextItemType } from '@/enterprise/store/useAppGenStore'; +import FiAiWorkflows from '@/assets/icons/FiAiWorkflows'; +import SessionFileAttach from './SessionFileAttach'; +import AttachedFileChip from '@/enterprise/components/ChatbotComponents/AttachedFileChip'; +import { WORKFLOW_FILE_ACCEPT } from '@/enterprise/services/workflowFileConstants'; + +export type ContextItem = { + id: string; + name: string; + type: ContextItemType; + icon?: string; +}; + +interface ChatPromptInputProps { + value: string; + file: File | null; + isDisabled?: boolean; + placeholder?: string; + isWidget?: boolean; + isAppGen?: boolean; + contextPopup?: React.ReactNode; + contextItems?: ContextItem[]; + borderColor?: string; + onRemoveContextItem?: (id: string, type: ContextItemType) => void; + onChange: (value: string) => void; + onSend: () => void; + handleAttachFile: () => void; + contextComponents?: FlowComponent[]; + selectedContextComponents?: FlowComponent[]; + onToggleContextComponent?: (component: FlowComponent) => void; + /** Session file upload (workflow file_input): separate from NASA attach. */ + isSessionFileUploadEnabled?: boolean; + sessionAttachedFiles?: { file: File; isUploading: boolean }[]; + onSessionAttachFile?: (file: File) => void; + onRemoveSessionFile?: (index: number) => void; + /** When true, disable send (e.g. streaming or any file still uploading). */ + isSessionFilesUploading?: boolean; +} + +const ChatPromptInput: React.FC = ({ + value, + file, + isDisabled = false, + placeholder = 'Type your message...', + isWidget, + isAppGen, + contextPopup, + contextItems = [], + borderColor = 'gray.500', + onRemoveContextItem, + onChange, + onSend, + handleAttachFile, + contextComponents, + selectedContextComponents = [], + onToggleContextComponent, + isSessionFileUploadEnabled = false, + sessionAttachedFiles = [], + onSessionAttachFile, + onRemoveSessionFile, + isSessionFilesUploading = false, +}) => { + const features = useFeatureFlags([FEATURE_FLAG_KEYS.nasaFeatures]); + const { setFile } = useAssistantConfigStore(); + const textareaRef = useRef(null); + const [height, setHeight] = useState('auto'); + const showSessionFileAttach = + !features[FEATURE_FLAG_KEYS.nasaFeatures] && + isSessionFileUploadEnabled === true && + !!onSessionAttachFile; + + const blockSendWithoutMessageWithSessionFiles = + showSessionFileAttach && sessionAttachedFiles.length > 0 && !value.trim(); + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter') { + // Shift+Enter or Cmd/Ctrl+Enter submits the message + if (e.shiftKey || e.metaKey || e.ctrlKey) { + e.preventDefault(); + if (!isDisabled && !isSessionFilesUploading && !blockSendWithoutMessageWithSessionFiles) { + onSend(); + } + } + // Plain Enter adds a newline (default behavior, no action needed) + } + }; + + useEffect(() => { + const textarea = textareaRef.current; + if (!textarea) return; + textarea.style.height = 'auto'; // Reset height to recalc + textarea.style.height = `${textarea.scrollHeight}px`; + setHeight(`${textarea.scrollHeight}px`); + }, [value]); + + return ( + + {file && file.name && ( + setFile(null)} + isViewOnly={false} + /> + )} + {isAppGen && contextItems.length > 0 && ( + + {contextItems.map((item) => ( + + {item.icon ? ( + + ) : ( + + )} + + {item.name} + + onRemoveContextItem?.(item.id, item.type)} + display='flex' + alignItems='center' + cursor='pointer' + ml='2px' + flexShrink={0} + > + + + + ))} + + )} + {selectedContextComponents.length > 0 && onToggleContextComponent && ( + + )} + {/* Session file_input chips; hidden when nasaFeatures (use NASA attach instead). */} + {showSessionFileAttach && sessionAttachedFiles.length > 0 && ( + + {sessionAttachedFiles.map((sessionFile, index) => ( + onRemoveSessionFile?.(index)} + showLoader={sessionFile.isUploading} + /> + ))} + + )} + +