mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-19 09:50:15 +01:00
🗨️ refactor(VariableForm): use InputCombobox, fix Dropdown Variables (#3692)
* feat: Add SimpleCombobox component * feat: Add labelClassName and add manual focus handling * feat: Update VariableForm component to use SimpleCombobox The VariableForm component in the client/src/components/Prompts/Groups/VariableForm.tsx file has been updated to use the SimpleCombobox component instead of the InputWithDropdown component. This change improves the functionality and styling of the form. * chore: Update VariableForm component placeholder text * refactor: Improve VariableForm component The VariableForm component in the client/src/components/Prompts/Groups/VariableForm.tsx file has been refactored to improve its functionality. The `parseFieldConfig` function now trims the `variable` string before processing it. Additionally, the `onSubmit` function now properly escapes potential regex special chars that may cause issues when replacing text * refactor: Improve VariableForm using ariakit helpers/custom fields, open menu on input focus * refactor: rename SimpleCombobox to InputCombobox
This commit is contained in:
parent
8ca1e4f94f
commit
598e2be225
3 changed files with 134 additions and 14 deletions
|
|
@ -15,7 +15,7 @@ import {
|
|||
extractVariableInfo,
|
||||
} from '~/utils';
|
||||
import { useAuthContext, useLocalize, useSubmitMessage } from '~/hooks';
|
||||
import { TextareaAutosize, InputWithDropdown } from '~/components/ui';
|
||||
import { TextareaAutosize, InputCombobox } from '~/components/ui';
|
||||
import { code } from '~/components/Chat/Messages/Content/Markdown';
|
||||
|
||||
type FieldType = 'text' | 'select';
|
||||
|
|
@ -51,11 +51,15 @@ type FormValues = {
|
|||
*/
|
||||
|
||||
const parseFieldConfig = (variable: string): FieldConfig => {
|
||||
const content = variable;
|
||||
const content = variable.trim();
|
||||
if (content.includes(':')) {
|
||||
const [name, options] = content.split(':');
|
||||
if (options && options.includes('|')) {
|
||||
return { variable: name, type: 'select', options: options.split('|') };
|
||||
return {
|
||||
variable: name.trim(),
|
||||
type: 'select',
|
||||
options: options.split('|').map((opt) => opt.trim()),
|
||||
};
|
||||
}
|
||||
}
|
||||
return { variable: content, type: 'text' };
|
||||
|
|
@ -121,10 +125,13 @@ export default function VariableForm({
|
|||
const onSubmit = (data: FormValues) => {
|
||||
let text = mainText;
|
||||
data.fields.forEach(({ variable, value }) => {
|
||||
if (value) {
|
||||
const regex = new RegExp(variable, 'g');
|
||||
text = text.replace(regex, value);
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const escapedVariable = variable.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&');
|
||||
const regex = new RegExp(escapedVariable, 'g');
|
||||
text = text.replace(regex, value);
|
||||
});
|
||||
|
||||
submitPrompt(text);
|
||||
|
|
@ -153,22 +160,29 @@ export default function VariableForm({
|
|||
<Controller
|
||||
name={`fields.${index}.value`}
|
||||
control={control}
|
||||
render={({ field: inputField }) => {
|
||||
render={({ field: { onChange, onBlur, value, ref } }) => {
|
||||
if (field.config.type === 'select') {
|
||||
return (
|
||||
<InputWithDropdown
|
||||
{...inputField}
|
||||
id={`fields.${index}.value`}
|
||||
className={cn(defaultTextProps, 'focus:bg-surface-tertiary')}
|
||||
placeholder={localize('com_ui_enter_var', field.config.variable)}
|
||||
<InputCombobox
|
||||
options={field.config.options || []}
|
||||
placeholder={localize('com_ui_enter_var', field.config.variable)}
|
||||
className={cn(
|
||||
defaultTextProps,
|
||||
'rounded px-3 py-2 focus:bg-surface-tertiary',
|
||||
)}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TextareaAutosize
|
||||
{...inputField}
|
||||
ref={ref}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
id={`fields.${index}.value`}
|
||||
className={cn(
|
||||
defaultTextProps,
|
||||
|
|
|
|||
105
client/src/components/ui/InputCombobox.tsx
Normal file
105
client/src/components/ui/InputCombobox.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import React from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import type { OptionWithIcon } from '~/common';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
type ComboboxProps = {
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
options: OptionWithIcon[] | string[];
|
||||
className?: string;
|
||||
labelClassName?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onBlur: () => void;
|
||||
};
|
||||
|
||||
export const InputCombobox: React.FC<ComboboxProps> = ({
|
||||
label,
|
||||
labelClassName,
|
||||
placeholder = 'Select an option',
|
||||
options,
|
||||
className,
|
||||
value,
|
||||
onChange,
|
||||
onBlur,
|
||||
}) => {
|
||||
const isOptionObject = (option: unknown): option is OptionWithIcon => {
|
||||
return option != null && typeof option === 'object' && 'value' in option;
|
||||
};
|
||||
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [inputValue, setInputValue] = React.useState(value);
|
||||
const [isKeyboardFocus, setIsKeyboardFocus] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setInputValue(value);
|
||||
}, [value]);
|
||||
|
||||
const handleChange = (newValue: string) => {
|
||||
setInputValue(newValue);
|
||||
onChange(newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<Ariakit.ComboboxProvider value={inputValue} setValue={handleChange}>
|
||||
{label != null && (
|
||||
<Ariakit.ComboboxLabel
|
||||
className={cn('mb-2 block text-sm font-medium text-text-primary', labelClassName ?? '')}
|
||||
>
|
||||
{label}
|
||||
</Ariakit.ComboboxLabel>
|
||||
)}
|
||||
<div className={cn('relative', isKeyboardFocus ? 'rounded-md ring-2 ring-ring-primary' : '')}>
|
||||
<Ariakit.Combobox
|
||||
placeholder={placeholder}
|
||||
className={cn(
|
||||
'h-10 w-full rounded-md border border-border-light bg-surface-primary px-3 py-2 text-sm',
|
||||
'placeholder-text-secondary hover:bg-surface-hover',
|
||||
'focus:outline-none',
|
||||
className,
|
||||
)}
|
||||
onChange={(event) => handleChange(event.target.value)}
|
||||
onBlur={() => {
|
||||
setIsKeyboardFocus(false);
|
||||
onBlur();
|
||||
}}
|
||||
onFocusVisible={() => {
|
||||
setIsKeyboardFocus(true);
|
||||
setIsOpen(true);
|
||||
}}
|
||||
onMouseDown={() => {
|
||||
setIsKeyboardFocus(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Ariakit.ComboboxPopover
|
||||
gutter={4}
|
||||
sameWidth
|
||||
open={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
className={cn(
|
||||
'z-50 max-h-60 w-full overflow-auto rounded-md bg-surface-primary p-1 shadow-lg',
|
||||
'animate-in fade-in-0 zoom-in-95',
|
||||
)}
|
||||
>
|
||||
{options.map((option: string | OptionWithIcon, index: number) => (
|
||||
<Ariakit.ComboboxItem
|
||||
key={index}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none',
|
||||
'cursor-pointer hover:bg-surface-tertiary hover:text-text-primary',
|
||||
'data-[active-item]:bg-surface-tertiary data-[active-item]:text-text-primary',
|
||||
)}
|
||||
value={isOptionObject(option) ? `${option.value ?? ''}` : option}
|
||||
>
|
||||
{isOptionObject(option) && option.icon != null && (
|
||||
<span className="mr-2 flex-shrink-0">{option.icon}</span>
|
||||
)}
|
||||
{isOptionObject(option) ? option.label : option}
|
||||
</Ariakit.ComboboxItem>
|
||||
))}
|
||||
</Ariakit.ComboboxPopover>
|
||||
</Ariakit.ComboboxProvider>
|
||||
);
|
||||
};
|
||||
|
|
@ -14,6 +14,7 @@ export * from './Prompt';
|
|||
export * from './QuestionMark';
|
||||
export * from './Slider';
|
||||
export * from './Separator';
|
||||
export * from './InputCombobox';
|
||||
export * from './Skeleton';
|
||||
export * from './Switch';
|
||||
export * from './Table';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue