Skip to content

feat(ui): better missing fields ux in workflows & builder #7816

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Mar 21, 2025
5 changes: 4 additions & 1 deletion invokeai/frontend/web/public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1014,7 +1014,10 @@
"unknownNodeType": "Unknown node type",
"unknownTemplate": "Unknown Template",
"unknownInput": "Unknown input: {{name}}",
"unknownOutput": "Unknown output: {{name}}",
"missingField_withName": "Missing field \"{{name}}\"",
"unexpectedField_withName": "Unexpected field \"{{name}}\"",
"unknownField_withName": "Unknown field \"{{name}}\"",
"unknownFieldEditWorkflowToFix_withName": "Workflow contains an unknown field \"{{name}}\".\nEdit the workflow to fix the issue.",
"updateNode": "Update Node",
"updateApp": "Update App",
"loadingTemplates": "Loading {{name}}",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
Textarea,
} from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useInputFieldDescription } from 'features/nodes/hooks/useInputFieldDescription';
import { useInputFieldDescriptionSafe } from 'features/nodes/hooks/useInputFieldDescriptionSafe';
import { fieldDescriptionChanged } from 'features/nodes/store/nodesSlice';
import { NO_DRAG_CLASS, NO_PAN_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants';
import type { ChangeEvent } from 'react';
Expand Down Expand Up @@ -48,7 +48,7 @@ InputFieldDescriptionPopover.displayName = 'InputFieldDescriptionPopover';
const Content = memo(({ nodeId, fieldName }: Props) => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const description = useInputFieldDescription(nodeId, fieldName);
const description = useInputFieldDescriptionSafe(nodeId, fieldName);
const onChange = useCallback(
(e: ChangeEvent<HTMLTextAreaElement>) => {
dispatch(fieldDescriptionChanged({ nodeId, fieldName, val: e.target.value }));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { InputFieldResetToDefaultValueIconButton } from 'features/nodes/componen
import { useNodeFieldDnd } from 'features/nodes/components/sidePanel/builder/dnd-hooks';
import { useInputFieldIsConnected } from 'features/nodes/hooks/useInputFieldIsConnected';
import { useInputFieldIsInvalid } from 'features/nodes/hooks/useInputFieldIsInvalid';
import { useInputFieldTemplate } from 'features/nodes/hooks/useInputFieldTemplate';
import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplate';
import { NO_DRAG_CLASS } from 'features/nodes/types/constants';
import type { FieldInputTemplate } from 'features/nodes/types/field';
import { memo, useRef } from 'react';
Expand All @@ -22,7 +22,7 @@ interface Props {
}

export const InputFieldEditModeNodes = memo(({ nodeId, fieldName }: Props) => {
const fieldTemplate = useInputFieldTemplate(nodeId, fieldName);
const fieldTemplate = useInputFieldTemplateOrThrow(nodeId, fieldName);
const isInvalid = useInputFieldIsInvalid(nodeId, fieldName);
const isConnected = useInputFieldIsConnected(nodeId, fieldName);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,83 @@
import { InputFieldUnknownPlaceholder } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldUnknownPlaceholder';
import { Flex, Text } from '@invoke-ai/ui-library';
import { InputFieldWrapper } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldWrapper';
import { useInputFieldInstanceExists } from 'features/nodes/hooks/useInputFieldInstanceExists';
import { useInputFieldNameSafe } from 'features/nodes/hooks/useInputFieldNameSafe';
import { useInputFieldTemplateExists } from 'features/nodes/hooks/useInputFieldTemplateExists';
import type { PropsWithChildren } from 'react';
import { memo } from 'react';
import type { PropsWithChildren, ReactNode } from 'react';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';

type Props = PropsWithChildren<{
nodeId: string;
fieldName: string;
fallback?: ReactNode;
formatLabel?: (name: string) => string;
}>;

export const InputFieldGate = memo(({ nodeId, fieldName, children }: Props) => {
export const InputFieldGate = memo(({ nodeId, fieldName, children, fallback, formatLabel }: Props) => {
const hasInstance = useInputFieldInstanceExists(nodeId, fieldName);
const hasTemplate = useInputFieldTemplateExists(nodeId, fieldName);

if (!hasTemplate || !hasInstance) {
return <InputFieldUnknownPlaceholder nodeId={nodeId} fieldName={fieldName} />;
// fallback may be null, indicating we should render nothing at all - must check for undefined explicitly
if (fallback !== undefined) {
return fallback;
}
return (
<Fallback
nodeId={nodeId}
fieldName={fieldName}
formatLabel={formatLabel}
hasInstance={hasInstance}
hasTemplate={hasTemplate}
/>
);
}

return children;
});

InputFieldGate.displayName = 'InputFieldGate';

const Fallback = memo(
({
nodeId,
fieldName,
formatLabel,
hasTemplate,
hasInstance,
}: {
nodeId: string;
fieldName: string;
formatLabel?: (name: string) => string;
hasTemplate: boolean;
hasInstance: boolean;
}) => {
const { t } = useTranslation();
const name = useInputFieldNameSafe(nodeId, fieldName);
const label = useMemo(() => {
if (formatLabel) {
return formatLabel(name);
}
if (hasTemplate && !hasInstance) {
return t('nodes.missingField_withName', { name });
}
if (!hasTemplate && hasInstance) {
return t('nodes.unexpectedField_withName', { name });
}
return t('nodes.unknownField_withName', { name });
}, [formatLabel, hasInstance, hasTemplate, name, t]);

return (
<InputFieldWrapper>
<Flex w="full" px={1} py={1} justifyContent="center">
<Text fontWeight="semibold" color="error.300" whiteSpace="pre" textAlign="center">
{label}
</Text>
</Flex>
</InputFieldWrapper>
);
}
);

Fallback.displayName = 'Fallback';
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
useIsConnectionInProgress,
useIsConnectionStartField,
} from 'features/nodes/hooks/useFieldConnectionState';
import { useInputFieldTemplate } from 'features/nodes/hooks/useInputFieldTemplate';
import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplate';
import { useFieldTypeName } from 'features/nodes/hooks/usePrettyFieldType';
import { HANDLE_TOOLTIP_OPEN_DELAY } from 'features/nodes/types/constants';
import type { FieldInputTemplate } from 'features/nodes/types/field';
Expand Down Expand Up @@ -62,7 +62,7 @@ const handleStyles = {
} satisfies CSSProperties;

export const InputFieldHandle = memo(({ nodeId, fieldName }: Props) => {
const fieldTemplate = useInputFieldTemplate(nodeId, fieldName);
const fieldTemplate = useInputFieldTemplateOrThrow(nodeId, fieldName);
const fieldTypeName = useFieldTypeName(fieldTemplate.type);
const fieldColor = useMemo(() => getFieldColor(fieldTemplate.type), [fieldTemplate.type]);
const isModelField = useMemo(() => isModelFieldType(fieldTemplate.type), [fieldTemplate.type]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { IntegerFieldSlider } from 'features/nodes/components/flow/nodes/Invocat
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';
import { useInputFieldTemplate } from 'features/nodes/hooks/useInputFieldTemplate';
import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplate';
import {
isBoardFieldInputInstance,
isBoardFieldInputTemplate,
Expand Down Expand Up @@ -135,7 +135,7 @@ type Props = {

export const InputFieldRenderer = memo(({ nodeId, fieldName, settings }: Props) => {
const field = useInputFieldInstance(nodeId, fieldName);
const template = useInputFieldTemplate(nodeId, fieldName);
const template = useInputFieldTemplateOrThrow(nodeId, fieldName);

// When deciding which component to render, first we check the type of the template, which is more efficient than the
// instance type check. The instance type check uses zod and is slower.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
useIsConnectionStartField,
} from 'features/nodes/hooks/useFieldConnectionState';
import { useInputFieldIsConnected } from 'features/nodes/hooks/useInputFieldIsConnected';
import { useInputFieldLabel } from 'features/nodes/hooks/useInputFieldLabel';
import { useInputFieldLabelSafe } from 'features/nodes/hooks/useInputFieldLabelSafe';
import { useInputFieldTemplateTitle } from 'features/nodes/hooks/useInputFieldTemplateTitle';
import { fieldLabelChanged } from 'features/nodes/store/nodesSlice';
import { HANDLE_TOOLTIP_OPEN_DELAY, NO_FIT_ON_DOUBLE_CLICK_CLASS } from 'features/nodes/types/constants';
Expand Down Expand Up @@ -43,7 +43,7 @@ interface Props {
export const InputFieldTitle = memo((props: Props) => {
const { nodeId, fieldName, isInvalid, isDragging } = props;
const inputRef = useRef<HTMLInputElement>(null);
const label = useInputFieldLabel(nodeId, fieldName);
const label = useInputFieldLabelSafe(nodeId, fieldName);
const fieldTemplateTitle = useInputFieldTemplateTitle(nodeId, fieldName);
const { t } = useTranslation();
const isConnected = useInputFieldIsConnected(nodeId, fieldName);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Flex, ListItem, Text, UnorderedList } from '@invoke-ai/ui-library';
import { useInputFieldErrors } from 'features/nodes/hooks/useInputFieldErrors';
import { useInputFieldInstance } from 'features/nodes/hooks/useInputFieldInstance';
import { useInputFieldTemplate } from 'features/nodes/hooks/useInputFieldTemplate';
import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplate';
import { useFieldTypeName } from 'features/nodes/hooks/usePrettyFieldType';
import { startCase } from 'lodash-es';
import { memo, useMemo } from 'react';
Expand All @@ -16,7 +16,7 @@ export const InputFieldTooltipContent = memo(({ nodeId, fieldName }: Props) => {
const { t } = useTranslation();

const fieldInstance = useInputFieldInstance(nodeId, fieldName);
const fieldTemplate = useInputFieldTemplate(nodeId, fieldName);
const fieldTemplate = useInputFieldTemplateOrThrow(nodeId, fieldName);
const fieldTypeName = useFieldTypeName(fieldTemplate.type);
const fieldErrors = useInputFieldErrors(nodeId, fieldName);

Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { OutputFieldUnknownPlaceholder } from 'features/nodes/components/flow/nodes/Invocation/fields/OutputFieldUnknownPlaceholder';
import { FormControl, FormLabel } from '@invoke-ai/ui-library';
import { OutputFieldWrapper } from 'features/nodes/components/flow/nodes/Invocation/fields/OutputFieldWrapper';
import { useOutputFieldName } from 'features/nodes/hooks/useOutputFieldName';
import { useOutputFieldTemplateExists } from 'features/nodes/hooks/useOutputFieldTemplateExists';
import type { PropsWithChildren } from 'react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';

type Props = PropsWithChildren<{
nodeId: string;
Expand All @@ -12,10 +15,27 @@ export const OutputFieldGate = memo(({ nodeId, fieldName, children }: Props) =>
const hasTemplate = useOutputFieldTemplateExists(nodeId, fieldName);

if (!hasTemplate) {
return <OutputFieldUnknownPlaceholder nodeId={nodeId} fieldName={fieldName} />;
return <Fallback nodeId={nodeId} fieldName={fieldName} />;
}

return children;
});

OutputFieldGate.displayName = 'OutputFieldGate';

const Fallback = memo(({ nodeId, fieldName }: Props) => {
const { t } = useTranslation();
const name = useOutputFieldName(nodeId, fieldName);

return (
<OutputFieldWrapper>
<FormControl isInvalid={true} alignItems="stretch" justifyContent="space-between" gap={2} h="full" w="full">
<FormLabel display="flex" alignItems="center" h="full" color="error.300" mb={0} px={1} gap={2}>
{t('nodes.unexpectedField_withName', { name })}
</FormLabel>
</FormControl>
</OutputFieldWrapper>
);
});

Fallback.displayName = 'Fallback';

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { FlexProps, SystemStyleObject } from '@invoke-ai/ui-library';
import { Flex, IconButton, Spacer, Text } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { InputFieldGate } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldGate';
import { ContainerElementSettings } from 'features/nodes/components/sidePanel/builder/ContainerElementSettings';
import { useDepthContext } from 'features/nodes/components/sidePanel/builder/contexts';
import { NodeFieldElementSettings } from 'features/nodes/components/sidePanel/builder/NodeFieldElementSettings';
Expand Down Expand Up @@ -47,8 +48,16 @@ export const FormElementEditModeHeader = memo(({ element, dragHandleRef, ...rest
<Label element={element} />
<Spacer />
{isContainerElement(element) && <ContainerElementSettings element={element} />}
{isNodeFieldElement(element) && <ZoomToNodeButton element={element} />}
{isNodeFieldElement(element) && <NodeFieldElementSettings element={element} />}
{isNodeFieldElement(element) && (
<InputFieldGate
nodeId={element.data.fieldIdentifier.nodeId}
fieldName={element.data.fieldIdentifier.fieldName}
fallback={null} // Do not render these buttons if the field is not found
>
<ZoomToNodeButton element={element} />
<NodeFieldElementSettings element={element} />
</InputFieldGate>
)}
<RemoveElementButton element={element} />
</Flex>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { useAppSelector } from 'app/store/storeHooks';
import { InputFieldGate } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldGate';
import { NodeFieldElementEditMode } from 'features/nodes/components/sidePanel/builder/NodeFieldElementEditMode';
import { NodeFieldElementViewMode } from 'features/nodes/components/sidePanel/builder/NodeFieldElementViewMode';
import { selectWorkflowMode, useElement } from 'features/nodes/store/workflowSlice';
Expand All @@ -15,19 +14,11 @@ export const NodeFieldElement = memo(({ id }: { id: string }) => {
}

if (mode === 'view') {
return (
<InputFieldGate nodeId={el.data.fieldIdentifier.nodeId} fieldName={el.data.fieldIdentifier.fieldName}>
<NodeFieldElementViewMode el={el} />
</InputFieldGate>
);
return <NodeFieldElementViewMode el={el} />;
}

// mode === 'edit'
return (
<InputFieldGate nodeId={el.data.fieldIdentifier.nodeId} fieldName={el.data.fieldIdentifier.fieldName}>
<NodeFieldElementEditMode el={el} />
</InputFieldGate>
);
return <NodeFieldElementEditMode el={el} />;
});

NodeFieldElement.displayName = 'NodeFieldElement';
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { FormHelperText, Textarea } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { linkifyOptions, linkifySx } from 'common/components/linkify';
import { useEditable } from 'common/hooks/useEditable';
import { useInputFieldDescription } from 'features/nodes/hooks/useInputFieldDescription';
import { useInputFieldTemplate } from 'features/nodes/hooks/useInputFieldTemplate';
import { useInputFieldDescriptionSafe } from 'features/nodes/hooks/useInputFieldDescriptionSafe';
import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplate';
import { fieldDescriptionChanged } from 'features/nodes/store/nodesSlice';
import type { NodeFieldElement } from 'features/nodes/types/workflow';
import Linkify from 'linkify-react';
Expand All @@ -13,8 +13,8 @@ export const NodeFieldElementDescriptionEditable = memo(({ el }: { el: NodeField
const { data } = el;
const { fieldIdentifier } = data;
const dispatch = useAppDispatch();
const description = useInputFieldDescription(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const fieldTemplate = useInputFieldTemplate(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const description = useInputFieldDescriptionSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const fieldTemplate = useInputFieldTemplateOrThrow(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const inputRef = useRef<HTMLTextAreaElement>(null);

const onChange = useCallback(
Expand Down
Loading