feat(ui): better labels for missing/unexpected fields

This commit is contained in:
psychedelicious
2025-03-21 10:22:56 +10:00
parent 3e4c808b23
commit efaa20a7a1
10 changed files with 161 additions and 95 deletions

View File

@@ -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": "Unknown field \"{{name}}\" (edit workflow to fix)",
"updateNode": "Update Node",
"updateApp": "Update App",
"loadingTemplates": "Loading {{name}}",

View File

@@ -1,24 +1,83 @@
import { InputFieldUnknownPlaceholder } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldUnknownPlaceholder';
import { FormControl, FormLabel } 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, ReactNode } from 'react';
import { memo } from 'react';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
type Props = PropsWithChildren<{
nodeId: string;
fieldName: string;
placeholder?: ReactNode;
fallback?: ReactNode;
formatLabel?: (name: string) => string;
}>;
export const InputFieldGate = memo(({ nodeId, fieldName, children, placeholder }: 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 placeholder ?? <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>
<FormControl isInvalid={true} alignItems="stretch" justifyContent="center" gap={2} h="full" w="full">
<FormLabel display="flex" mb={0} px={1} py={2} gap={2}>
{label}
</FormLabel>
</FormControl>
</InputFieldWrapper>
);
}
);
Fallback.displayName = 'Fallback';

View File

@@ -1,27 +0,0 @@
import { FormControl, FormLabel } from '@invoke-ai/ui-library';
import { InputFieldWrapper } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldWrapper';
import { useInputFieldName } from 'features/nodes/hooks/useInputFieldName';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
type Props = {
nodeId: string;
fieldName: string;
};
export const InputFieldUnknownPlaceholder = memo(({ nodeId, fieldName }: Props) => {
const { t } = useTranslation();
const name = useInputFieldName(nodeId, fieldName);
return (
<InputFieldWrapper>
<FormControl isInvalid={true} alignItems="stretch" justifyContent="center" gap={2} h="full" w="full">
<FormLabel display="flex" mb={0} px={1} py={2} gap={2}>
{t('nodes.unknownInput', { name })}
</FormLabel>
</FormControl>
</InputFieldWrapper>
);
});
InputFieldUnknownPlaceholder.displayName = 'InputFieldUnknownPlaceholder';

View File

@@ -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;
@@ -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';

View File

@@ -1,27 +0,0 @@
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 { memo } from 'react';
import { useTranslation } from 'react-i18next';
type Props = {
nodeId: string;
fieldName: string;
};
export const OutputFieldUnknownPlaceholder = 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.unknownOutput', { name })}
</FormLabel>
</FormControl>
</OutputFieldWrapper>
);
});
OutputFieldUnknownPlaceholder.displayName = 'OutputFieldUnknownPlaceholder';

View File

@@ -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';
@@ -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>
);

View File

@@ -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';
@@ -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';

View File

@@ -1,5 +1,6 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Box, 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';
import { useFormElementDnd } from 'features/nodes/components/sidePanel/builder/dnd-hooks';
@@ -11,6 +12,7 @@ import { NodeFieldElementLabelEditable } from 'features/nodes/components/sidePan
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';
import type { RefObject } from 'react';
import { memo, useRef } from 'react';
const sx: SystemStyleObject = {
@@ -31,26 +33,11 @@ export const NodeFieldElementEditMode = memo(({ el }: { el: NodeFieldElement })
const dragHandleRef = useRef<HTMLDivElement>(null);
const [activeDropRegion, isDragging] = useFormElementDnd(el.id, draggableRef, dragHandleRef);
const containerCtx = useContainerContext();
const { id, data } = el;
const { fieldIdentifier, showDescription } = data;
const { id } = el;
return (
<Flex ref={draggableRef} id={id} className={NODE_FIELD_CLASS_NAME} sx={sx} data-parent-layout={containerCtx.layout}>
<FormElementEditModeHeader dragHandleRef={dragHandleRef} element={el} data-is-dragging={isDragging} />
<FormElementEditModeContent data-is-dragging={isDragging} p={4}>
<FormControl flex="1 1 0" orientation="vertical">
<NodeFieldElementLabelEditable el={el} />
<Flex w="full" gap={4}>
<InputFieldRenderer
nodeId={fieldIdentifier.nodeId}
fieldName={fieldIdentifier.fieldName}
settings={data.settings}
/>
</Flex>
{showDescription && <NodeFieldElementDescriptionEditable el={el} />}
</FormControl>
</FormElementEditModeContent>
<NodeFieldElementOverlay element={el} />
<NodeFieldElementEditModeContent dragHandleRef={dragHandleRef} el={el} isDragging={isDragging} />
<DndListDropIndicator activeDropRegion={activeDropRegion} gap="var(--invoke-space-4)" />
</Flex>
);
@@ -58,6 +45,42 @@ export const NodeFieldElementEditMode = memo(({ el }: { el: NodeFieldElement })
NodeFieldElementEditMode.displayName = 'NodeFieldElementEditMode';
const NodeFieldElementEditModeContent = memo(
({
el,
dragHandleRef,
isDragging,
}: {
el: NodeFieldElement;
dragHandleRef: RefObject<HTMLDivElement>;
isDragging: boolean;
}) => {
const { data } = el;
const { fieldIdentifier, showDescription } = data;
return (
<>
<FormElementEditModeHeader dragHandleRef={dragHandleRef} element={el} data-is-dragging={isDragging} />
<FormElementEditModeContent data-is-dragging={isDragging} p={4}>
<InputFieldGate nodeId={fieldIdentifier.nodeId} fieldName={fieldIdentifier.fieldName}>
<FormControl flex="1 1 0" orientation="vertical">
<NodeFieldElementLabelEditable el={el} />
<Flex w="full" gap={4}>
<InputFieldRenderer
nodeId={fieldIdentifier.nodeId}
fieldName={fieldIdentifier.fieldName}
settings={data.settings}
/>
</Flex>
{showDescription && <NodeFieldElementDescriptionEditable el={el} />}
</FormControl>
</InputFieldGate>
</FormElementEditModeContent>
</>
);
}
);
NodeFieldElementEditModeContent.displayName = 'NodeFieldElementEditModeContent';
const nodeFieldOverlaySx: SystemStyleObject = {
position: 'absolute',
top: 0,

View File

@@ -1,6 +1,7 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Flex, FormControl, FormHelperText } from '@invoke-ai/ui-library';
import { linkifyOptions, linkifySx } from 'common/components/linkify';
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';
import { NodeFieldElementLabel } from 'features/nodes/components/sidePanel/builder/NodeFieldElementLabel';
@@ -9,7 +10,8 @@ import { useInputFieldTemplateOrThrow, useInputFieldTemplateSafe } from 'feature
import type { NodeFieldElement } from 'features/nodes/types/workflow';
import { NODE_FIELD_CLASS_NAME } from 'features/nodes/types/workflow';
import Linkify from 'linkify-react';
import { memo, useMemo } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
const sx: SystemStyleObject = {
'&[data-parent-layout="column"]': {
@@ -25,12 +27,19 @@ const sx: SystemStyleObject = {
},
};
const useFormatFallbackLabel = () => {
const { t } = useTranslation();
const formatLabel = useCallback((name: string) => t('nodes.unknownFieldEditWorkflowToFix_withName', { name }), [t]);
return formatLabel;
};
export const NodeFieldElementViewMode = memo(({ el }: { el: NodeFieldElement }) => {
const { id, data } = el;
const { fieldIdentifier, showDescription } = data;
const description = useInputFieldDescriptionSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const fieldTemplate = useInputFieldTemplateSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const containerCtx = useContainerContext();
const formatFallbackLabel = useFormatFallbackLabel();
const _description = useMemo(
() => description || fieldTemplate?.description || '',
@@ -45,7 +54,13 @@ export const NodeFieldElementViewMode = memo(({ el }: { el: NodeFieldElement })
data-parent-layout={containerCtx.layout}
data-with-description={showDescription && !!_description}
>
<NodeFieldElementViewModeContent el={el} />
<InputFieldGate
nodeId={el.data.fieldIdentifier.nodeId}
fieldName={el.data.fieldIdentifier.fieldName}
formatLabel={formatFallbackLabel}
>
<NodeFieldElementViewModeContent el={el} />
</InputFieldGate>
</Flex>
);
});

View File

@@ -5,7 +5,7 @@ import { $templates } from 'features/nodes/store/nodesSlice';
import { selectInvocationNodeSafe, selectNodesSlice } from 'features/nodes/store/selectors';
import { useMemo } from 'react';
export const useInputFieldName = (nodeId: string, fieldName: string) => {
export const useInputFieldNameSafe = (nodeId: string, fieldName: string) => {
const templates = useStore($templates);
const selector = useMemo(