From 3f6e8e9d6b9b9303cf3647a7bae65ee2b8880037 Mon Sep 17 00:00:00 2001
From: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
Date: Thu, 16 Nov 2023 11:36:20 +1100
Subject: [PATCH] feat(ui): add update node functionality
A workflow's nodes may update itself, if its major version matches the template's major version.
If the major versions do not match, the user will need to delete and re-add the node (current behaviour).
The update functionality is not automatic (for now). The logic to update the node is pretty simple, but I want to ensure it works well first before doing it automatically when a workflow is loaded.
- New `Details` tab on Workflow Inspector, displays node title, type, version, and notes
- Button to update the node is displayed on the `Details` tab
- Add hook to determine if a node needs an update, may be updated (i.e. major versions match), and the callback to update the node in state
- Remove the notes modal from the little info icon
- Modularize the node building logic
---
.../nodes/Invocation/InvocationNodeHeader.tsx | 4 +-
...deNotes.tsx => InvocationNodeInfoIcon.tsx} | 86 +++---------
.../inspector/InspectorDetailsTab.tsx | 125 +++++++++++++++++
.../sidePanel/inspector/InspectorPanel.tsx | 8 +-
.../inspector/details/EditableNodeTitle.tsx | 74 ++++++++++
.../features/nodes/hooks/useBuildNodeData.ts | 119 +---------------
.../nodes/hooks/useNodeTemplateByType.ts | 27 ++++
.../features/nodes/hooks/useNodeVersion.ts | 80 +++++++++++
.../src/features/nodes/store/nodesSlice.ts | 13 ++
.../nodes/store/util/buildNodeData.ts | 127 ++++++++++++++++++
10 files changed, 478 insertions(+), 185 deletions(-)
rename invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/{InvocationNodeNotes.tsx => InvocationNodeInfoIcon.tsx} (58%)
create mode 100644 invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorDetailsTab.tsx
create mode 100644 invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/details/EditableNodeTitle.tsx
create mode 100644 invokeai/frontend/web/src/features/nodes/hooks/useNodeTemplateByType.ts
create mode 100644 invokeai/frontend/web/src/features/nodes/hooks/useNodeVersion.ts
create mode 100644 invokeai/frontend/web/src/features/nodes/store/util/buildNodeData.ts
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeHeader.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeHeader.tsx
index cd6c5215d1..643e003f72 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeHeader.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeHeader.tsx
@@ -3,7 +3,7 @@ import { memo } from 'react';
import NodeCollapseButton from '../common/NodeCollapseButton';
import NodeTitle from '../common/NodeTitle';
import InvocationNodeCollapsedHandles from './InvocationNodeCollapsedHandles';
-import InvocationNodeNotes from './InvocationNodeNotes';
+import InvocationNodeInfoIcon from './InvocationNodeInfoIcon';
import InvocationNodeStatusIndicator from './InvocationNodeStatusIndicator';
type Props = {
@@ -34,7 +34,7 @@ const InvocationNodeHeader = ({ nodeId, isOpen }: Props) => {
-
+
{!isOpen && }
diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeNotes.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeInfoIcon.tsx
similarity index 58%
rename from invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeNotes.tsx
rename to invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeInfoIcon.tsx
index 8a96fb4230..83867a35cb 100644
--- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeNotes.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeInfoIcon.tsx
@@ -1,85 +1,39 @@
-import {
- Flex,
- Icon,
- Modal,
- ModalBody,
- ModalCloseButton,
- ModalContent,
- ModalFooter,
- ModalHeader,
- ModalOverlay,
- Text,
- Tooltip,
- useDisclosure,
-} from '@chakra-ui/react';
+import { Flex, Icon, Text, Tooltip } from '@chakra-ui/react';
import { compare } from 'compare-versions';
import { useNodeData } from 'features/nodes/hooks/useNodeData';
-import { useNodeLabel } from 'features/nodes/hooks/useNodeLabel';
import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate';
-import { useNodeTemplateTitle } from 'features/nodes/hooks/useNodeTemplateTitle';
+import { useNodeVersion } from 'features/nodes/hooks/useNodeVersion';
import { isInvocationNodeData } from 'features/nodes/types/types';
import { memo, useMemo } from 'react';
-import { FaInfoCircle } from 'react-icons/fa';
-import NotesTextarea from './NotesTextarea';
-import { useDoNodeVersionsMatch } from 'features/nodes/hooks/useDoNodeVersionsMatch';
import { useTranslation } from 'react-i18next';
+import { FaInfoCircle } from 'react-icons/fa';
interface Props {
nodeId: string;
}
-const InvocationNodeNotes = ({ nodeId }: Props) => {
- const { isOpen, onOpen, onClose } = useDisclosure();
- const label = useNodeLabel(nodeId);
- const title = useNodeTemplateTitle(nodeId);
- const doVersionsMatch = useDoNodeVersionsMatch(nodeId);
- const { t } = useTranslation();
+const InvocationNodeInfoIcon = ({ nodeId }: Props) => {
+ const { needsUpdate } = useNodeVersion(nodeId);
return (
- <>
- }
- placement="top"
- shouldWrapChildren
- >
-
-
-
-
-
-
-
-
- {label || title || t('nodes.unknownNode')}
-
-
-
-
-
-
-
- >
+ }
+ placement="top"
+ shouldWrapChildren
+ >
+
+
);
};
-export default memo(InvocationNodeNotes);
+export default memo(InvocationNodeInfoIcon);
const TooltipContent = memo(({ nodeId }: { nodeId: string }) => {
const data = useNodeData(nodeId);
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorDetailsTab.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorDetailsTab.tsx
new file mode 100644
index 0000000000..9e765ff01e
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorDetailsTab.tsx
@@ -0,0 +1,125 @@
+import {
+ Box,
+ Flex,
+ FormControl,
+ FormLabel,
+ HStack,
+ Text,
+} from '@chakra-ui/react';
+import { createSelector } from '@reduxjs/toolkit';
+import { stateSelector } from 'app/store/store';
+import { useAppSelector } from 'app/store/storeHooks';
+import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
+import IAIIconButton from 'common/components/IAIIconButton';
+import { IAINoContentFallback } from 'common/components/IAIImageFallback';
+import { useNodeVersion } from 'features/nodes/hooks/useNodeVersion';
+import {
+ InvocationNodeData,
+ InvocationTemplate,
+ isInvocationNode,
+} from 'features/nodes/types/types';
+import { memo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { FaSync } from 'react-icons/fa';
+import { Node } from 'reactflow';
+import NotesTextarea from '../../flow/nodes/Invocation/NotesTextarea';
+import ScrollableContent from '../ScrollableContent';
+import EditableNodeTitle from './details/EditableNodeTitle';
+
+const selector = createSelector(
+ stateSelector,
+ ({ nodes }) => {
+ const lastSelectedNodeId =
+ nodes.selectedNodes[nodes.selectedNodes.length - 1];
+
+ const lastSelectedNode = nodes.nodes.find(
+ (node) => node.id === lastSelectedNodeId
+ );
+
+ const lastSelectedNodeTemplate = lastSelectedNode
+ ? nodes.nodeTemplates[lastSelectedNode.data.type]
+ : undefined;
+
+ return {
+ node: lastSelectedNode,
+ template: lastSelectedNodeTemplate,
+ };
+ },
+ defaultSelectorOptions
+);
+
+const InspectorDetailsTab = () => {
+ const { node, template } = useAppSelector(selector);
+ const { t } = useTranslation();
+
+ if (!template || !isInvocationNode(node)) {
+ return (
+
+ );
+ }
+
+ return ;
+};
+
+export default memo(InspectorDetailsTab);
+
+const Content = (props: {
+ node: Node;
+ template: InvocationTemplate;
+}) => {
+ const { t } = useTranslation();
+ const { needsUpdate, mayUpdate, updateNode } = useNodeVersion(props.node.id);
+ return (
+
+
+
+
+
+
+ Node Type
+
+ {props.template.title}
+
+
+
+
+ Node Version
+
+ {props.node.data.version}
+
+
+ {mayUpdate && (
+ }
+ onClick={updateNode}
+ />
+ )}
+
+
+
+
+
+
+ );
+};
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorPanel.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorPanel.tsx
index 9b13cf9e1c..e3dc6645c5 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorPanel.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorPanel.tsx
@@ -10,7 +10,7 @@ import { memo } from 'react';
import InspectorDataTab from './InspectorDataTab';
import InspectorOutputsTab from './InspectorOutputsTab';
import InspectorTemplateTab from './InspectorTemplateTab';
-// import InspectorDetailsTab from './InspectorDetailsTab';
+import InspectorDetailsTab from './InspectorDetailsTab';
const InspectorPanel = () => {
return (
@@ -30,16 +30,16 @@ const InspectorPanel = () => {
sx={{ display: 'flex', flexDir: 'column', w: 'full', h: 'full' }}
>
- {/* Details */}
+ Details
Outputs
Data
Template
- {/*
+
- */}
+
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/details/EditableNodeTitle.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/details/EditableNodeTitle.tsx
new file mode 100644
index 0000000000..bf32046c6c
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/details/EditableNodeTitle.tsx
@@ -0,0 +1,74 @@
+import {
+ Editable,
+ EditableInput,
+ EditablePreview,
+ Flex,
+} from '@chakra-ui/react';
+import { useAppDispatch } from 'app/store/storeHooks';
+import { useNodeLabel } from 'features/nodes/hooks/useNodeLabel';
+import { useNodeTemplateTitle } from 'features/nodes/hooks/useNodeTemplateTitle';
+import { nodeLabelChanged } from 'features/nodes/store/nodesSlice';
+import { memo, useCallback, useEffect, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+
+type Props = {
+ nodeId: string;
+ title?: string;
+};
+
+const EditableNodeTitle = ({ nodeId, title }: Props) => {
+ const dispatch = useAppDispatch();
+ const label = useNodeLabel(nodeId);
+ const templateTitle = useNodeTemplateTitle(nodeId);
+ const { t } = useTranslation();
+
+ const [localTitle, setLocalTitle] = useState('');
+ const handleSubmit = useCallback(
+ async (newTitle: string) => {
+ dispatch(nodeLabelChanged({ nodeId, label: newTitle }));
+ setLocalTitle(
+ label || title || templateTitle || t('nodes.problemSettingTitle')
+ );
+ },
+ [dispatch, nodeId, title, templateTitle, label, t]
+ );
+
+ const handleChange = useCallback((newTitle: string) => {
+ setLocalTitle(newTitle);
+ }, []);
+
+ useEffect(() => {
+ // Another component may change the title; sync local title with global state
+ setLocalTitle(
+ label || title || templateTitle || t('nodes.problemSettingTitle')
+ );
+ }, [label, templateTitle, title, t]);
+
+ return (
+
+
+
+
+
+
+ );
+};
+
+export default memo(EditableNodeTitle);
diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useBuildNodeData.ts b/invokeai/frontend/web/src/features/nodes/hooks/useBuildNodeData.ts
index 40c3f029d7..036ce8d44e 100644
--- a/invokeai/frontend/web/src/features/nodes/hooks/useBuildNodeData.ts
+++ b/invokeai/frontend/web/src/features/nodes/hooks/useBuildNodeData.ts
@@ -1,19 +1,10 @@
import { createSelector } from '@reduxjs/toolkit';
import { RootState } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
-import { reduce } from 'lodash-es';
import { useCallback } from 'react';
import { Node, useReactFlow } from 'reactflow';
import { AnyInvocationType } from 'services/events/types';
-import { v4 as uuidv4 } from 'uuid';
-import {
- CurrentImageNodeData,
- InputFieldValue,
- InvocationNodeData,
- NotesNodeData,
- OutputFieldValue,
-} from '../types/types';
-import { buildInputFieldValue } from '../util/fieldValueBuilders';
+import { buildNodeData } from '../store/util/buildNodeData';
import { DRAG_HANDLE_CLASSNAME, NODE_WIDTH } from '../types/constants';
const templatesSelector = createSelector(
@@ -26,14 +17,12 @@ export const SHARED_NODE_PROPERTIES: Partial = {
};
export const useBuildNodeData = () => {
- const invocationTemplates = useAppSelector(templatesSelector);
+ const nodeTemplates = useAppSelector(templatesSelector);
const flow = useReactFlow();
return useCallback(
(type: AnyInvocationType | 'current_image' | 'notes') => {
- const nodeId = uuidv4();
-
let _x = window.innerWidth / 2;
let _y = window.innerHeight / 2;
@@ -47,111 +36,15 @@ export const useBuildNodeData = () => {
_y = rect.height / 2 - NODE_WIDTH / 2;
}
- const { x, y } = flow.project({
+ const position = flow.project({
x: _x,
y: _y,
});
- if (type === 'current_image') {
- const node: Node = {
- ...SHARED_NODE_PROPERTIES,
- id: nodeId,
- type: 'current_image',
- position: { x: x, y: y },
- data: {
- id: nodeId,
- type: 'current_image',
- isOpen: true,
- label: 'Current Image',
- },
- };
+ const template = nodeTemplates[type];
- return node;
- }
-
- if (type === 'notes') {
- const node: Node = {
- ...SHARED_NODE_PROPERTIES,
- id: nodeId,
- type: 'notes',
- position: { x: x, y: y },
- data: {
- id: nodeId,
- isOpen: true,
- label: 'Notes',
- notes: '',
- type: 'notes',
- },
- };
-
- return node;
- }
-
- const template = invocationTemplates[type];
-
- if (template === undefined) {
- console.error(`Unable to find template ${type}.`);
- return;
- }
-
- const inputs = reduce(
- template.inputs,
- (inputsAccumulator, inputTemplate, inputName) => {
- const fieldId = uuidv4();
-
- const inputFieldValue: InputFieldValue = buildInputFieldValue(
- fieldId,
- inputTemplate
- );
-
- inputsAccumulator[inputName] = inputFieldValue;
-
- return inputsAccumulator;
- },
- {} as Record
- );
-
- const outputs = reduce(
- template.outputs,
- (outputsAccumulator, outputTemplate, outputName) => {
- const fieldId = uuidv4();
-
- const outputFieldValue: OutputFieldValue = {
- id: fieldId,
- name: outputName,
- type: outputTemplate.type,
- fieldKind: 'output',
- };
-
- outputsAccumulator[outputName] = outputFieldValue;
-
- return outputsAccumulator;
- },
- {} as Record
- );
-
- const invocation: Node = {
- ...SHARED_NODE_PROPERTIES,
- id: nodeId,
- type: 'invocation',
- position: { x: x, y: y },
- data: {
- id: nodeId,
- type,
- version: template.version,
- label: '',
- notes: '',
- isOpen: true,
- embedWorkflow: false,
- isIntermediate: type === 'save_image' ? false : true,
- inputs,
- outputs,
- useCache: template.useCache,
- },
- };
-
- return invocation;
+ return buildNodeData(type, position, template);
},
- [invocationTemplates, flow]
+ [nodeTemplates, flow]
);
};
diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useNodeTemplateByType.ts b/invokeai/frontend/web/src/features/nodes/hooks/useNodeTemplateByType.ts
new file mode 100644
index 0000000000..6fd0615563
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/hooks/useNodeTemplateByType.ts
@@ -0,0 +1,27 @@
+import { createSelector } from '@reduxjs/toolkit';
+import { stateSelector } from 'app/store/store';
+import { useAppSelector } from 'app/store/storeHooks';
+import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
+import { useMemo } from 'react';
+import { AnyInvocationType } from 'services/events/types';
+
+export const useNodeTemplateByType = (
+ type: AnyInvocationType | 'current_image' | 'notes'
+) => {
+ const selector = useMemo(
+ () =>
+ createSelector(
+ stateSelector,
+ ({ nodes }) => {
+ const nodeTemplate = nodes.nodeTemplates[type];
+ return nodeTemplate;
+ },
+ defaultSelectorOptions
+ ),
+ [type]
+ );
+
+ const nodeTemplate = useAppSelector(selector);
+
+ return nodeTemplate;
+};
diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useNodeVersion.ts b/invokeai/frontend/web/src/features/nodes/hooks/useNodeVersion.ts
new file mode 100644
index 0000000000..60192de61d
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/hooks/useNodeVersion.ts
@@ -0,0 +1,80 @@
+import { createSelector } from '@reduxjs/toolkit';
+import { stateSelector } from 'app/store/store';
+import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions';
+import { satisfies } from 'compare-versions';
+import { useCallback, useMemo } from 'react';
+import {
+ InvocationNodeData,
+ isInvocationNode,
+ zParsedSemver,
+} from '../types/types';
+import { cloneDeep, defaultsDeep } from 'lodash-es';
+import { buildNodeData } from '../store/util/buildNodeData';
+import { AnyInvocationType } from 'services/events/types';
+import { Node } from 'reactflow';
+import { nodeReplaced } from '../store/nodesSlice';
+
+export const useNodeVersion = (nodeId: string) => {
+ const dispatch = useAppDispatch();
+ const selector = useMemo(
+ () =>
+ createSelector(
+ stateSelector,
+ ({ nodes }) => {
+ const node = nodes.nodes.find((node) => node.id === nodeId);
+ const nodeTemplate = nodes.nodeTemplates[node?.data.type ?? ''];
+ return { node, nodeTemplate };
+ },
+ defaultSelectorOptions
+ ),
+ [nodeId]
+ );
+
+ const { node, nodeTemplate } = useAppSelector(selector);
+
+ const needsUpdate = useMemo(() => {
+ if (!isInvocationNode(node) || !nodeTemplate) {
+ return false;
+ }
+ return node.data.version !== nodeTemplate.version;
+ }, [node, nodeTemplate]);
+
+ const mayUpdate = useMemo(() => {
+ if (
+ !needsUpdate ||
+ !isInvocationNode(node) ||
+ !nodeTemplate ||
+ !node.data.version
+ ) {
+ return false;
+ }
+ const templateMajor = zParsedSemver.parse(nodeTemplate.version).major;
+
+ return satisfies(node.data.version, `^${templateMajor}`);
+ }, [needsUpdate, node, nodeTemplate]);
+
+ const updateNode = useCallback(() => {
+ if (
+ !mayUpdate ||
+ !isInvocationNode(node) ||
+ !nodeTemplate ||
+ !node.data.version
+ ) {
+ return;
+ }
+
+ const defaults = buildNodeData(
+ node.data.type as AnyInvocationType,
+ node.position,
+ nodeTemplate
+ ) as Node;
+
+ const clone = cloneDeep(node);
+ clone.data.version = nodeTemplate.version;
+ defaultsDeep(clone, defaults);
+ dispatch(nodeReplaced({ nodeId: clone.id, node: clone }));
+ }, [dispatch, mayUpdate, node, nodeTemplate]);
+
+ return { needsUpdate, mayUpdate, updateNode };
+};
diff --git a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts
index cb8f3b7d28..3acef5978f 100644
--- a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts
+++ b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts
@@ -149,6 +149,18 @@ const nodesSlice = createSlice({
nodesChanged: (state, action: PayloadAction) => {
state.nodes = applyNodeChanges(action.payload, state.nodes);
},
+ nodeReplaced: (
+ state,
+ action: PayloadAction<{ nodeId: string; node: Node }>
+ ) => {
+ const nodeIndex = state.nodes.findIndex(
+ (n) => n.id === action.payload.nodeId
+ );
+ if (nodeIndex < 0) {
+ return;
+ }
+ state.nodes[nodeIndex] = action.payload.node;
+ },
nodeAdded: (
state,
action: PayloadAction<
@@ -1029,6 +1041,7 @@ export const {
mouseOverFieldChanged,
mouseOverNodeChanged,
nodeAdded,
+ nodeReplaced,
nodeEditorReset,
nodeEmbedWorkflowChanged,
nodeExclusivelySelected,
diff --git a/invokeai/frontend/web/src/features/nodes/store/util/buildNodeData.ts b/invokeai/frontend/web/src/features/nodes/store/util/buildNodeData.ts
new file mode 100644
index 0000000000..6cecc8c409
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/store/util/buildNodeData.ts
@@ -0,0 +1,127 @@
+import { DRAG_HANDLE_CLASSNAME } from 'features/nodes/types/constants';
+import {
+ CurrentImageNodeData,
+ InputFieldValue,
+ InvocationNodeData,
+ InvocationTemplate,
+ NotesNodeData,
+ OutputFieldValue,
+} from 'features/nodes/types/types';
+import { buildInputFieldValue } from 'features/nodes/util/fieldValueBuilders';
+import { reduce } from 'lodash-es';
+import { Node, XYPosition } from 'reactflow';
+import { AnyInvocationType } from 'services/events/types';
+import { v4 as uuidv4 } from 'uuid';
+
+export const SHARED_NODE_PROPERTIES: Partial = {
+ dragHandle: `.${DRAG_HANDLE_CLASSNAME}`,
+};
+export const buildNodeData = (
+ type: AnyInvocationType | 'current_image' | 'notes',
+ position: XYPosition,
+ template?: InvocationTemplate
+):
+ | Node
+ | Node
+ | Node
+ | undefined => {
+ const nodeId = uuidv4();
+
+ if (type === 'current_image') {
+ const node: Node = {
+ ...SHARED_NODE_PROPERTIES,
+ id: nodeId,
+ type: 'current_image',
+ position,
+ data: {
+ id: nodeId,
+ type: 'current_image',
+ isOpen: true,
+ label: 'Current Image',
+ },
+ };
+
+ return node;
+ }
+
+ if (type === 'notes') {
+ const node: Node = {
+ ...SHARED_NODE_PROPERTIES,
+ id: nodeId,
+ type: 'notes',
+ position,
+ data: {
+ id: nodeId,
+ isOpen: true,
+ label: 'Notes',
+ notes: '',
+ type: 'notes',
+ },
+ };
+
+ return node;
+ }
+
+ if (template === undefined) {
+ console.error(`Unable to find template ${type}.`);
+ return;
+ }
+
+ const inputs = reduce(
+ template.inputs,
+ (inputsAccumulator, inputTemplate, inputName) => {
+ const fieldId = uuidv4();
+
+ const inputFieldValue: InputFieldValue = buildInputFieldValue(
+ fieldId,
+ inputTemplate
+ );
+
+ inputsAccumulator[inputName] = inputFieldValue;
+
+ return inputsAccumulator;
+ },
+ {} as Record
+ );
+
+ const outputs = reduce(
+ template.outputs,
+ (outputsAccumulator, outputTemplate, outputName) => {
+ const fieldId = uuidv4();
+
+ const outputFieldValue: OutputFieldValue = {
+ id: fieldId,
+ name: outputName,
+ type: outputTemplate.type,
+ fieldKind: 'output',
+ };
+
+ outputsAccumulator[outputName] = outputFieldValue;
+
+ return outputsAccumulator;
+ },
+ {} as Record
+ );
+
+ const invocation: Node = {
+ ...SHARED_NODE_PROPERTIES,
+ id: nodeId,
+ type: 'invocation',
+ position,
+ data: {
+ id: nodeId,
+ type,
+ version: template.version,
+ label: '',
+ notes: '',
+ isOpen: true,
+ embedWorkflow: false,
+ isIntermediate: type === 'save_image' ? false : true,
+ inputs,
+ outputs,
+ useCache: template.useCache,
+ },
+ };
+
+ return invocation;
+};