feat(ui): support for custom string field dropdowns in builder

This commit is contained in:
psychedelicious
2025-03-21 16:41:30 +10:00
parent 434f195a96
commit 23a26422fd
8 changed files with 284 additions and 14 deletions

View File

@@ -13,6 +13,7 @@ import { StringGeneratorFieldInputComponent } from 'features/nodes/components/fl
import { IntegerFieldInput } from 'features/nodes/components/flow/nodes/Invocation/fields/IntegerField/IntegerFieldInput';
import { IntegerFieldInputAndSlider } from 'features/nodes/components/flow/nodes/Invocation/fields/IntegerField/IntegerFieldInputAndSlider';
import { IntegerFieldSlider } from 'features/nodes/components/flow/nodes/Invocation/fields/IntegerField/IntegerFieldSlider';
import { StringFieldDropdown } from 'features/nodes/components/flow/nodes/Invocation/fields/StringField/StringFieldDropdown';
import { StringFieldInput } from 'features/nodes/components/flow/nodes/Invocation/fields/StringField/StringFieldInput';
import { StringFieldTextarea } from 'features/nodes/components/flow/nodes/Invocation/fields/StringField/StringFieldTextarea';
import { useInputFieldInstance } from 'features/nodes/hooks/useInputFieldInstance';
@@ -151,7 +152,7 @@ export const InputFieldRenderer = memo(({ nodeId, fieldName, settings }: Props)
if (!isStringFieldInputInstance(field)) {
return null;
}
if (settings?.type !== 'string-field-config') {
if (!settings || settings.type !== 'string-field-config') {
if (template.ui_component === 'textarea') {
return <StringFieldTextarea nodeId={nodeId} field={field} fieldTemplate={template} />;
} else {
@@ -162,8 +163,10 @@ export const InputFieldRenderer = memo(({ nodeId, fieldName, settings }: Props)
return <StringFieldInput nodeId={nodeId} field={field} fieldTemplate={template} />;
} else if (settings.component === 'textarea') {
return <StringFieldTextarea nodeId={nodeId} field={field} fieldTemplate={template} />;
} else if (settings.component === 'dropdown') {
return <StringFieldDropdown nodeId={nodeId} field={field} fieldTemplate={template} settings={settings} />;
} else {
assert<Equals<never, typeof settings.component>>(false, 'Unexpected settings.component');
assert<Equals<never, typeof settings>>(false, 'Unexpected settings');
}
}

View File

@@ -0,0 +1,36 @@
import { Select } from '@invoke-ai/ui-library';
import type { FieldComponentProps } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/types';
import { useStringField } from 'features/nodes/components/flow/nodes/Invocation/fields/StringField/useStringField';
import { NO_DRAG_CLASS, NO_PAN_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
import type { StringFieldInputInstance, StringFieldInputTemplate } from 'features/nodes/types/field';
import type { NodeFieldStringSettings } from 'features/nodes/types/workflow';
import { memo } from 'react';
export const StringFieldDropdown = memo(
(
props: FieldComponentProps<
StringFieldInputInstance,
StringFieldInputTemplate,
{ settings: Extract<NodeFieldStringSettings, { component: 'dropdown' }> }
>
) => {
const { value, onChange } = useStringField(props);
return (
<Select
onChange={onChange}
className={`${NO_DRAG_CLASS} ${NO_PAN_CLASS} ${NO_WHEEL_CLASS}`}
isDisabled={props.settings.options.length === 0}
value={value}
>
{props.settings.options.map((choice, i) => (
<option key={`${i}_${choice.value}`} value={choice.value}>
{choice.label || choice.value || `Option ${i + 1}`}
</option>
))}
</Select>
);
}
);
StringFieldDropdown.displayName = 'StringFieldDropdown';

View File

@@ -10,7 +10,7 @@ export const useStringField = (props: FieldComponentProps<StringFieldInputInstan
const dispatch = useAppDispatch();
const onChange = useCallback(
(e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
(e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
dispatch(
fieldStringValueChanged({
nodeId,

View File

@@ -1,5 +1,5 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Box, Flex, FormControl } from '@invoke-ai/ui-library';
import { Box, Divider, Flex, FormControl } from '@invoke-ai/ui-library';
import { InputFieldGate } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldGate';
import { InputFieldRenderer } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer';
import { useContainerContext } from 'features/nodes/components/sidePanel/builder/contexts';
@@ -9,6 +9,7 @@ import { FormElementEditModeContent } from 'features/nodes/components/sidePanel/
import { FormElementEditModeHeader } from 'features/nodes/components/sidePanel/builder/FormElementEditModeHeader';
import { NodeFieldElementDescriptionEditable } from 'features/nodes/components/sidePanel/builder/NodeFieldElementDescriptionEditable';
import { NodeFieldElementLabelEditable } from 'features/nodes/components/sidePanel/builder/NodeFieldElementLabelEditable';
import { NodeFieldElementStringDropdownConfig } from 'features/nodes/components/sidePanel/builder/NodeFieldElementStringDropdownConfig';
import { useMouseOverFormField, useMouseOverNode } from 'features/nodes/hooks/useMouseOverNode';
import type { NodeFieldElement } from 'features/nodes/types/workflow';
import { NODE_FIELD_CLASS_NAME } from 'features/nodes/types/workflow';
@@ -55,7 +56,7 @@ const NodeFieldElementEditModeContent = memo(
dragHandleRef: RefObject<HTMLDivElement>;
isDragging: boolean;
}) => {
const { data } = el;
const { id, data } = el;
const { fieldIdentifier, showDescription } = data;
return (
<>
@@ -72,6 +73,12 @@ const NodeFieldElementEditModeContent = memo(
/>
</Flex>
{showDescription && <NodeFieldElementDescriptionEditable el={el} />}
{data.settings?.type === 'string-field-config' && data.settings.component === 'dropdown' && (
<>
<Divider />
<NodeFieldElementStringDropdownConfig id={id} settings={data.settings} />
</>
)}
</FormControl>
</InputFieldGate>
</FormElementEditModeContent>

View File

@@ -0,0 +1,184 @@
import { Button, Divider, Flex, Grid, GridItem, IconButton, Input, Text } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { getOverlayScrollbarsParams, overlayScrollbarsStyles } from 'common/components/OverlayScrollbars/constants';
import { formElementNodeFieldDataChanged } from 'features/nodes/store/workflowSlice';
import { NO_DRAG_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
import type { NodeFieldStringDropdownSettings } from 'features/nodes/types/workflow';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
import type { ChangeEvent } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiXBold } from 'react-icons/pi';
const overlayscrollbarsOptions = getOverlayScrollbarsParams({}).options;
export const NodeFieldElementStringDropdownConfig = memo(
({ id, settings }: { id: string; settings: NodeFieldStringDropdownSettings }) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const onRemoveOption = useCallback(
(index: number) => {
const options = [...settings.options];
options.splice(index, 1);
dispatch(formElementNodeFieldDataChanged({ id, changes: { settings: { ...settings, options } } }));
},
[settings, dispatch, id]
);
const onChangeOptionValue = useCallback(
(index: number, value: string) => {
if (!settings.options[index]) {
return;
}
const option = { ...settings.options[index], value };
const options = [...settings.options];
options[index] = option;
dispatch(formElementNodeFieldDataChanged({ id, changes: { settings: { ...settings, options } } }));
},
[dispatch, id, settings]
);
const onChangeOptionLabel = useCallback(
(index: number, label: string) => {
if (!settings.options[index]) {
return;
}
const option = { ...settings.options[index], label };
const options = [...settings.options];
options[index] = option;
dispatch(formElementNodeFieldDataChanged({ id, changes: { settings: { ...settings, options } } }));
},
[dispatch, id, settings]
);
const onAddOption = useCallback(() => {
const options = [...settings.options, { label: '', value: '' }];
dispatch(formElementNodeFieldDataChanged({ id, changes: { settings: { ...settings, options } } }));
}, [dispatch, id, settings]);
return (
<Flex
className={NO_DRAG_CLASS}
position="relative"
w="full"
h="auto"
maxH={64}
alignItems="stretch"
justifyContent="center"
borderRadius="base"
flexDir="column"
gap={1}
>
<Button onClick={onAddOption} variant="ghost">
{t('nodes.addItem')}
</Button>
{settings.options.length > 0 && (
<>
<Divider />
<OverlayScrollbarsComponent
className={NO_WHEEL_CLASS}
defer
style={overlayScrollbarsStyles}
options={overlayscrollbarsOptions}
>
<Grid gap={1} gridTemplateColumns="auto 1fr 1fr auto" gridTemplateRows="auto 1fr" alignItems="center">
<GridItem minW={8}>
<Text textAlign="center" variant="subtext">
#
</Text>
</GridItem>
<GridItem>
<Text textAlign="center" variant="subtext">
{t('common.label')}
</Text>
</GridItem>
<GridItem>
<Text textAlign="center" variant="subtext">
{t('common.value')}
</Text>
</GridItem>
<GridItem />
{settings.options.map(({ value, label }, index) => (
<ListItemContent
key={index}
value={value}
label={label}
index={index}
onRemoveOption={onRemoveOption}
onChangeOptionValue={onChangeOptionValue}
onChangeOptionLabel={onChangeOptionLabel}
/>
))}
</Grid>
</OverlayScrollbarsComponent>
</>
)}
</Flex>
);
}
);
NodeFieldElementStringDropdownConfig.displayName = 'NodeFieldElementStringDropdownConfig';
type ListItemContentProps = {
value: string;
label: string;
index: number;
onRemoveOption: (index: number) => void;
onChangeOptionValue: (index: number, value: string) => void;
onChangeOptionLabel: (index: number, label: string) => void;
};
const ListItemContent = memo(
({ value, label, index, onRemoveOption, onChangeOptionValue, onChangeOptionLabel }: ListItemContentProps) => {
const { t } = useTranslation();
const onClickRemove = useCallback(() => {
onRemoveOption(index);
}, [index, onRemoveOption]);
const onChangeValue = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
onChangeOptionValue(index, e.target.value);
},
[index, onChangeOptionValue]
);
const onChangeLabel = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
onChangeOptionLabel(index, e.target.value);
},
[index, onChangeOptionLabel]
);
return (
<>
<GridItem>
<Text variant="subtext" textAlign="center">
{index + 1}.
</Text>
</GridItem>
<GridItem>
<Input size="sm" resize="none" placeholder="label" value={label} onChange={onChangeLabel} />
</GridItem>
<GridItem>
<Input size="sm" resize="none" placeholder="value" value={value} onChange={onChangeValue} />
</GridItem>
<GridItem>
<IconButton
tabIndex={-1}
size="sm"
variant="link"
minW={8}
minH={8}
onClick={onClickRemove}
icon={<PiXBold />}
aria-label={t('common.delete')}
/>
</GridItem>
</>
);
}
);
ListItemContent.displayName = 'ListItemContent';

View File

@@ -2,6 +2,7 @@ import { FormControl, FormLabel, Select } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { formElementNodeFieldDataChanged } from 'features/nodes/store/workflowSlice';
import { type NodeFieldStringSettings, zStringComponent } from 'features/nodes/types/workflow';
import { omit } from 'lodash-es';
import type { ChangeEvent } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
@@ -13,10 +14,23 @@ export const NodeFieldElementStringSettings = memo(
const onChangeComponent = useCallback(
(e: ChangeEvent<HTMLSelectElement>) => {
const newConfig: NodeFieldStringSettings = {
...config,
component: zStringComponent.parse(e.target.value),
};
const component = zStringComponent.parse(e.target.value);
if (component === config.component) {
// no change
return;
}
if (component === 'dropdown') {
// if the component is changing to dropdown, we need to set the choices
const newConfig: NodeFieldStringSettings = {
...config,
component,
options: [{ value: 'my_value', label: 'My Label' }],
};
dispatch(formElementNodeFieldDataChanged({ id, changes: { settings: newConfig } }));
return;
}
// if the component is changing from dropdown, we need to remove the choices
const newConfig: NodeFieldStringSettings = omit({ ...config, component }, 'choices');
dispatch(formElementNodeFieldDataChanged({ id, changes: { settings: newConfig } }));
},
[config, dispatch, id]
@@ -28,6 +42,7 @@ export const NodeFieldElementStringSettings = memo(
<Select value={config.component} onChange={onChangeComponent} size="sm">
<option value="input">{t('workflows.builder.singleLine')}</option>
<option value="textarea">{t('workflows.builder.multiLine')}</option>
<option value="dropdown">{t('workflows.builder.dropdown')}</option>
</Select>
</FormControl>
);

View File

@@ -91,14 +91,36 @@ const zNodeFieldIntegerSettings = z.object({
export type NodeFieldIntegerSettings = z.infer<typeof zNodeFieldIntegerSettings>;
export const getIntegerFieldSettingsDefaults = (): NodeFieldIntegerSettings => zNodeFieldIntegerSettings.parse({});
export const zStringComponent = z.enum(['input', 'textarea']);
export const zStringOption = z
.object({
label: z.string(),
value: z.string(),
})
.default({ label: '', value: '' });
export type StringChoice = z.infer<typeof zStringOption>;
export const zStringComponent = z.enum(['input', 'textarea', 'dropdown']);
const STRING_FIELD_CONFIG_TYPE = 'string-field-config';
const zNodeFieldStringSettings = z.object({
const zNodeFieldStringInputSettings = z.object({
type: z.literal(STRING_FIELD_CONFIG_TYPE).default(STRING_FIELD_CONFIG_TYPE),
component: zStringComponent.default('input'),
component: z.literal('input').default('input'),
});
const zNodeFieldStringTextareaSettings = z.object({
type: z.literal(STRING_FIELD_CONFIG_TYPE).default(STRING_FIELD_CONFIG_TYPE),
component: z.literal('textarea').default('textarea'),
});
const zNodeFieldStringDropdownSettings = z.object({
type: z.literal(STRING_FIELD_CONFIG_TYPE).default(STRING_FIELD_CONFIG_TYPE),
component: z.literal('dropdown').default('dropdown'),
options: z.array(zStringOption),
});
export type NodeFieldStringDropdownSettings = z.infer<typeof zNodeFieldStringDropdownSettings>;
const zNodeFieldStringSettings = z.union([
zNodeFieldStringInputSettings,
zNodeFieldStringTextareaSettings,
zNodeFieldStringDropdownSettings,
]);
export type NodeFieldStringSettings = z.infer<typeof zNodeFieldStringSettings>;
export const getStringFieldSettingsDefaults = (): NodeFieldStringSettings => zNodeFieldStringSettings.parse({});
export const getStringFieldSettingsDefaults = (): NodeFieldStringSettings => zNodeFieldStringInputSettings.parse({});
const zNodeFieldData = z.object({
fieldIdentifier: zFieldIdentifier,