diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/EditableFieldTitle.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/EditableFieldTitle.tsx index 49fd3205583..d7690669e03 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/EditableFieldTitle.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/EditableFieldTitle.tsx @@ -23,13 +23,13 @@ interface Props { nodeId: string; fieldName: string; kind: 'inputs' | 'outputs'; - isMissingInput?: boolean; + isInvalid?: boolean; withTooltip?: boolean; shouldDim?: boolean; } const EditableFieldTitle = forwardRef((props: Props, ref) => { - const { nodeId, fieldName, kind, isMissingInput = false, withTooltip = false, shouldDim = false } = props; + const { nodeId, fieldName, kind, isInvalid = false, withTooltip = false, shouldDim = false } = props; const label = useFieldLabel(nodeId, fieldName); const fieldTemplateTitle = useFieldTemplateTitle(nodeId, fieldName, kind); const { t } = useTranslation(); @@ -78,7 +78,7 @@ const EditableFieldTitle = forwardRef((props: Props, ref) => { fontWeight="semibold" sx={editablePreviewStyles} noOfLines={1} - color={isMissingInput ? 'error.300' : 'base.300'} + color={isInvalid ? 'error.300' : 'base.300'} opacity={shouldDim ? 0.5 : 1} /> diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputField.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputField.tsx index fd3cc6d6bf6..c5c1e5f3129 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputField.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputField.tsx @@ -1,8 +1,8 @@ import { Flex, FormControl } from '@invoke-ai/ui-library'; import { useConnectionState } from 'features/nodes/hooks/useConnectionState'; -import { useDoesInputHaveValue } from 'features/nodes/hooks/useDoesInputHaveValue'; import { useFieldInputTemplate } from 'features/nodes/hooks/useFieldInputTemplate'; -import { memo, useCallback, useMemo, useState } from 'react'; +import { useFieldIsInvalid } from 'features/nodes/hooks/useFieldIsInvalid'; +import { memo, useCallback, useState } from 'react'; import EditableFieldTitle from './EditableFieldTitle'; import FieldHandle from './FieldHandle'; @@ -17,32 +17,12 @@ interface Props { const InputField = ({ nodeId, fieldName }: Props) => { const fieldTemplate = useFieldInputTemplate(nodeId, fieldName); - const doesFieldHaveValue = useDoesInputHaveValue(nodeId, fieldName); const [isHovered, setIsHovered] = useState(false); + const isInvalid = useFieldIsInvalid(nodeId, fieldName); const { isConnected, isConnectionInProgress, isConnectionStartField, validationResult, shouldDim } = useConnectionState({ nodeId, fieldName, kind: 'inputs' }); - const isMissingInput = useMemo(() => { - if (!fieldTemplate) { - return false; - } - - if (!fieldTemplate.required) { - return false; - } - - if (!isConnected && fieldTemplate.input === 'connection') { - return true; - } - - if (!doesFieldHaveValue && !isConnected && fieldTemplate.input !== 'connection') { - return true; - } - - return false; - }, [fieldTemplate, isConnected, doesFieldHaveValue]); - const onMouseEnter = useCallback(() => { setIsHovered(true); }, []); @@ -54,12 +34,12 @@ const InputField = ({ nodeId, fieldName }: Props) => { if (fieldTemplate.input === 'connection' || isConnected) { return ( - + @@ -79,7 +59,7 @@ const InputField = ({ nodeId, fieldName }: Props) => { return ( { > - + {isHovered && } diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/ImageFieldCollectionInputComponent.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/ImageFieldCollectionInputComponent.tsx index 741d906ab9e..16aa095f31d 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/ImageFieldCollectionInputComponent.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/ImageFieldCollectionInputComponent.tsx @@ -1,3 +1,4 @@ +import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Flex, Grid, GridItem, IconButton } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; import { UploadMultipleImageButton } from 'common/hooks/useImageUploadButton'; @@ -5,6 +6,7 @@ import type { AddImagesToNodeImageFieldCollection } from 'features/dnd/dnd'; import { addImagesToNodeImageFieldCollectionDndTarget } from 'features/dnd/dnd'; import { DndDropTarget } from 'features/dnd/DndDropTarget'; import { DndImageFromImageName } from 'features/dnd/DndImageFromImageName'; +import { useFieldIsInvalid } from 'features/nodes/hooks/useFieldIsInvalid'; import { fieldImageCollectionValueChanged } from 'features/nodes/store/nodesSlice'; import type { ImageFieldCollectionInputInstance, ImageFieldCollectionInputTemplate } from 'features/nodes/types/field'; import { memo, useCallback, useMemo } from 'react'; @@ -14,11 +16,21 @@ import type { ImageDTO } from 'services/api/types'; import type { FieldComponentProps } from './types'; +const sx = { + '&[data-error=true]': { + borderColor: 'error.500', + borderStyle: 'solid', + borderWidth: 1, + }, +} satisfies SystemStyleObject; + export const ImageFieldCollectionInputComponent = memo( (props: FieldComponentProps) => { const { t } = useTranslation(); - const { nodeId, field, fieldTemplate } = props; + const { nodeId, field } = props; const dispatch = useAppDispatch(); + const isInvalid = useFieldIsInvalid(nodeId, field.name); + const onReset = useCallback(() => { dispatch( fieldImageCollectionValueChanged({ @@ -47,19 +59,6 @@ export const ImageFieldCollectionInputComponent = memo( [dispatch, field.name, nodeId] ); - const isInvalid = useMemo(() => { - if (!field.value) { - if (fieldTemplate.required) { - return true; - } - } else if (fieldTemplate.minLength !== undefined && field.value.length < fieldTemplate.minLength) { - return true; - } else if (fieldTemplate.maxLength !== undefined && field.value.length > fieldTemplate.maxLength) { - return true; - } - return false; - }, [field.value, fieldTemplate.maxLength, fieldTemplate.minLength, fieldTemplate.required]); - return ( {field.value.map(({ image_name }) => ( diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useDoesInputHaveValue.ts b/invokeai/frontend/web/src/features/nodes/hooks/useDoesInputHaveValue.ts deleted file mode 100644 index 29befda260b..00000000000 --- a/invokeai/frontend/web/src/features/nodes/hooks/useDoesInputHaveValue.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; -import { useAppSelector } from 'app/store/storeHooks'; -import { selectNodeData, selectNodesSlice } from 'features/nodes/store/selectors'; -import { useMemo } from 'react'; - -export const useDoesInputHaveValue = (nodeId: string, fieldName: string): boolean => { - const selector = useMemo( - () => - createMemoizedSelector(selectNodesSlice, (nodes) => { - const data = selectNodeData(nodes, nodeId); - if (!data) { - return false; - } - return data.inputs[fieldName]?.value !== undefined; - }), - [fieldName, nodeId] - ); - - const doesFieldHaveValue = useAppSelector(selector); - - return doesFieldHaveValue; -}; diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useFieldIsInvalid.ts b/invokeai/frontend/web/src/features/nodes/hooks/useFieldIsInvalid.ts new file mode 100644 index 00000000000..0052ab691e5 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/hooks/useFieldIsInvalid.ts @@ -0,0 +1,57 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { useAppSelector } from 'app/store/storeHooks'; +import { useConnectionState } from 'features/nodes/hooks/useConnectionState'; +import { useFieldInputTemplate } from 'features/nodes/hooks/useFieldInputTemplate'; +import { selectFieldInputInstance, selectNodesSlice } from 'features/nodes/store/selectors'; +import { isImageFieldCollectionInputInstance, isImageFieldCollectionInputTemplate } from 'features/nodes/types/field'; +import { useMemo } from 'react'; + +export const useFieldIsInvalid = (nodeId: string, fieldName: string) => { + const template = useFieldInputTemplate(nodeId, fieldName); + const connectionState = useConnectionState({ nodeId, fieldName, kind: 'inputs' }); + + const selectIsInvalid = useMemo(() => { + return createSelector(selectNodesSlice, (nodes) => { + const field = selectFieldInputInstance(nodes, nodeId, fieldName); + + // No field instance is a problem - should not happen + if (!field) { + return true; + } + + // 'connection' input fields have no data validation - only connection validation + if (template.input === 'connection') { + return template.required && !connectionState.isConnected; + } + + // 'any' input fields are valid if they are connected + if (template.input === 'any' && connectionState.isConnected) { + return false; + } + + // If there is no valid for the field & the field is required, it is invalid + if (field.value === undefined) { + return template.required; + } + + // Else special handling for individual field types + if (isImageFieldCollectionInputInstance(field) && isImageFieldCollectionInputTemplate(template)) { + // Image collections may have min or max item counts + if (template.minItems !== undefined && field.value.length < template.minItems) { + return true; + } + + if (template.maxItems !== undefined && field.value.length > template.maxItems) { + return true; + } + } + + // Field looks OK + return false; + }); + }, [connectionState.isConnected, fieldName, nodeId, template]); + + const isInvalid = useAppSelector(selectIsInvalid); + + return isInvalid; +};