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; +};