From e4678201cb7202733d2c3a089127b4f319a4e2f6 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 2 Apr 2025 16:21:58 +1000 Subject: [PATCH] feat(ui): add conditionally-enabled workflow publishing ui This is a squash of a lot of scattered commits that became very difficult to clean up and make individually. Sorry. Besides the new UI, there are a number of notable changes: - Publishing logic is disabled in OSS by default. To enable it, provided a `disabledFeatures` prop _without_ "publishWorkflow". - Enqueuing a workflow is no longer handled in a redux listener. It was hard to track the state of the enqueue logic in the listener. It is now in a hook. I did not migrate the canvas and upscaling tabs - their enqueue logic is still in the listener. - When queueing a validation run, the new `useEnqueueWorkflows()` hook will update the payload with the required data for the run. - Some logic is added to the socket event listeners to handle workflow publish runs completing. - The workflow library side nav has a new "published" view. It is hidden when the "publishWorkflow" feature is disabled. - I've added `Safe` and `OrThrow` versions of some workflows hooks. These hooks typically retrieve some data from redux. For example, a node. The `Safe` hooks return the node or null if it cannot be found, while the `OrThrow` hooks return the node or raise if it cannot be found. The `OrThrow` hooks should be used within one of the gate components. These components use the `Safe` hooks and render a fallback if e.g. the node isn't found. This change is required for some of the publish flow UI. - Add support for locking the workflow editor. When locked, you can pan and zoom but that's it. Currently, it is only locked during publish flow and if a published workflow is opened. --- invokeai/frontend/web/public/locales/en.json | 32 +- .../frontend/web/src/app/store/actions.ts | 7 - .../middleware/listenerMiddleware/index.ts | 2 - ...addAdHocPostProcessingRequestedListener.ts | 4 +- .../listeners/enqueueRequestedLinear.ts | 7 +- .../listeners/enqueueRequestedUpscale.ts | 7 +- invokeai/frontend/web/src/app/store/store.ts | 2 + .../frontend/web/src/app/types/invokeai.ts | 3 +- .../web/src/common/hooks/useGlobalHotkeys.ts | 4 +- .../konva/CanvasStateApiModule.ts | 4 +- .../features/nodes/components/NodeEditor.tsx | 8 +- .../flow/AddNodeCmdk/AddNodeCmdk.tsx | 6 +- .../features/nodes/components/flow/Flow.tsx | 52 ++- .../InvocationNodeCollapsedHandles.tsx | 4 +- .../Invocation/InvocationNodeInfoIcon.tsx | 8 +- .../fields/InputFieldDescriptionPopover.tsx | 4 +- .../fields/InputFieldEditModeNodes.tsx | 4 +- .../Invocation/fields/InputFieldHandle.tsx | 21 +- .../Invocation/fields/InputFieldRenderer.tsx | 2 +- .../Invocation/fields/InputFieldTitle.tsx | 8 +- .../fields/InputFieldTooltipContent.tsx | 2 +- .../Invocation/fields/OutputFieldHandle.tsx | 20 +- .../flow/nodes/common/NodeTitle.tsx | 8 +- .../flow/nodes/common/NodeWrapper.tsx | 15 +- .../flow/panels/TopPanel/TopCenterPanel.tsx | 15 + .../flow/panels/TopPanel/TopLeftPanel.tsx | 54 +++ .../flow/panels/TopPanel/TopPanel.tsx | 40 -- .../flow/panels/TopPanel/TopRightPanel.tsx | 34 ++ .../sidePanel/EditModeLeftPanelContent.tsx | 33 +- .../PublishedWorkflowPanelContent.tsx | 25 + .../ActiveWorkflowNameAndActions.tsx | 5 +- .../sidePanel/WorkflowsTabLeftPanel.tsx | 18 +- .../builder/FormElementEditModeHeader.tsx | 7 +- .../NodeFieldElementDescriptionEditable.tsx | 6 +- .../builder/NodeFieldElementEditMode.tsx | 8 +- .../builder/NodeFieldElementLabel.tsx | 6 +- .../builder/NodeFieldElementLabelEditable.tsx | 6 +- .../builder/NodeFieldElementSettings.tsx | 2 +- .../builder/NodeFieldElementViewMode.tsx | 9 +- .../components/sidePanel/builder/dnd-hooks.ts | 94 +++- .../builder/use-add-node-field-to-root.ts | 2 +- .../inspector/InspectorDetailsTab.tsx | 4 +- .../inspector/InspectorOutputsTab.tsx | 4 +- .../InspectorTabEditableNodeTitle.tsx | 8 +- .../inspector/InspectorTemplateTab.tsx | 4 +- .../sidePanel/inspector/NodeTemplateGate.tsx | 2 +- .../workflow/PublishWorkflowPanelContent.tsx | 429 ++++++++++++++++++ .../LockedWorkflowIcon.tsx | 23 + .../WorkflowLibrarySideNav.tsx | 9 +- .../workflow/WorkflowLibrary/WorkflowList.tsx | 3 + .../WorkflowLibrary/WorkflowListItem.tsx | 23 +- .../sidePanel/workflow/WorkflowPanel.tsx | 7 +- .../components/sidePanel/workflow/publish.ts | 90 ++++ .../nodes/hooks/useInputFieldDefaultValue.ts | 2 +- .../nodes/hooks/useInputFieldNamesByStatus.ts | 9 +- ...ate.ts => useInputFieldTemplateOrThrow.ts} | 19 +- .../nodes/hooks/useInputFieldTemplateSafe.ts | 17 + ...s => useInputFieldTemplateTitleOrThrow.ts} | 7 +- ...ts => useInputFieldUserDescriptionSafe.ts} | 2 +- .../hooks/useInputFieldUserTitleOrThrow.ts | 23 + ...lSafe.ts => useInputFieldUserTitleSafe.ts} | 8 +- .../features/nodes/hooks/useIsBatchNode.ts | 5 +- .../nodes/hooks/useIsWorkflowEditorLocked.ts | 13 + .../nodes/hooks/useNodeClassification.ts | 5 +- .../nodes/hooks/useNodeHasImageOutput.ts | 5 +- .../nodes/hooks/useNodeNeedsUpdate.ts | 5 +- ...eTemplate.ts => useNodeTemplateOrThrow.ts} | 9 +- .../nodes/hooks/useNodeTemplateSafe.ts | 12 + .../hooks/useNodeTemplateTitleOrThrow.ts | 25 + ...teTitle.ts => useNodeTemplateTitleSafe.ts} | 2 +- .../nodes/hooks/useNodeUserTitleOrThrow.ts | 21 + ...seNodeLabel.ts => useNodeUserTitleSafe.ts} | 8 +- .../nodes/hooks/useOutputFieldNames.ts | 5 +- .../nodes/hooks/useOutputFieldTemplate.ts | 5 +- .../src/features/nodes/hooks/useZoomToNode.ts | 6 +- .../web/src/features/nodes/store/selectors.ts | 6 +- .../nodes/store/workflowLibrarySlice.ts | 2 +- .../src/features/nodes/store/workflowSlice.ts | 23 +- .../web/src/features/nodes/types/field.ts | 3 + .../src/features/nodes/types/invocation.ts | 2 +- .../features/nodes/types/workflow.test-d.ts | 4 +- .../web/src/features/nodes/types/workflow.ts | 1 + .../util/graph/buildLinearBatchConfig.ts | 6 +- .../nodes/util/graph/buildNodesGraph.ts | 2 +- .../nodes/util/workflow/migrations.ts | 2 + .../InvokeButtonTooltip.tsx | 12 +- .../components/InvokeQueueBackButton.tsx | 2 +- .../QueueList/QueueItemComponent.tsx | 6 +- .../queue/components/QueueList/constants.ts | 1 + .../queue/hooks/useEnqueueWorkflows.ts} | 87 +++- .../web/src/features/queue/hooks/useInvoke.ts | 65 ++- .../web/src/features/queue/store/readiness.ts | 15 +- .../src/features/system/store/configSlice.ts | 2 +- .../FloatingParametersPanelButtons.tsx | 2 +- .../LoadWorkflowConfirmationAlertDialog.tsx | 10 +- .../components/SaveWorkflowAsDialog.tsx | 1 + .../SaveWorkflowMenuItem.tsx | 10 +- .../hooks/useCreateNewWorkflow.ts | 2 + .../hooks/useSaveOrSaveAsWorkflow.ts | 11 +- .../frontend/web/src/services/api/types.ts | 2 +- .../src/services/events/setEventListeners.tsx | 55 ++- 101 files changed, 1410 insertions(+), 341 deletions(-) delete mode 100644 invokeai/frontend/web/src/app/store/actions.ts create mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/TopCenterPanel.tsx create mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/TopLeftPanel.tsx delete mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/TopPanel.tsx create mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/TopRightPanel.tsx create mode 100644 invokeai/frontend/web/src/features/nodes/components/sidePanel/PublishedWorkflowPanelContent.tsx create mode 100644 invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/PublishWorkflowPanelContent.tsx create mode 100644 invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/LockedWorkflowIcon.tsx create mode 100644 invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/publish.ts rename invokeai/frontend/web/src/features/nodes/hooks/{useInputFieldTemplate.ts => useInputFieldTemplateOrThrow.ts} (54%) create mode 100644 invokeai/frontend/web/src/features/nodes/hooks/useInputFieldTemplateSafe.ts rename invokeai/frontend/web/src/features/nodes/hooks/{useInputFieldTemplateTitle.ts => useInputFieldTemplateTitleOrThrow.ts} (59%) rename invokeai/frontend/web/src/features/nodes/hooks/{useInputFieldDescriptionSafe.ts => useInputFieldUserDescriptionSafe.ts} (89%) create mode 100644 invokeai/frontend/web/src/features/nodes/hooks/useInputFieldUserTitleOrThrow.ts rename invokeai/frontend/web/src/features/nodes/hooks/{useInputFieldLabelSafe.ts => useInputFieldUserTitleSafe.ts} (74%) create mode 100644 invokeai/frontend/web/src/features/nodes/hooks/useIsWorkflowEditorLocked.ts rename invokeai/frontend/web/src/features/nodes/hooks/{useNodeTemplate.ts => useNodeTemplateOrThrow.ts} (63%) create mode 100644 invokeai/frontend/web/src/features/nodes/hooks/useNodeTemplateSafe.ts create mode 100644 invokeai/frontend/web/src/features/nodes/hooks/useNodeTemplateTitleOrThrow.ts rename invokeai/frontend/web/src/features/nodes/hooks/{useNodeTemplateTitle.ts => useNodeTemplateTitleSafe.ts} (91%) create mode 100644 invokeai/frontend/web/src/features/nodes/hooks/useNodeUserTitleOrThrow.ts rename invokeai/frontend/web/src/features/nodes/hooks/{useNodeLabel.ts => useNodeUserTitleSafe.ts} (72%) rename invokeai/frontend/web/src/{app/store/middleware/listenerMiddleware/listeners/enqueueRequestedNodes.ts => features/queue/hooks/useEnqueueWorkflows.ts} (57%) diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 9005b80e94..246509ea88 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -1783,7 +1783,37 @@ "textPlaceholder": "Empty Text", "workflowBuilderAlphaWarning": "The workflow builder is currently in alpha. There may be breaking changes before the stable release.", "minimum": "Minimum", - "maximum": "Maximum" + "maximum": "Maximum", + "publish": "Publish", + "published": "Published", + "unpublish": "Unpublish", + "workflowLocked": "Workflow Locked", + "workflowLockedPublished": "Published workflows are locked for editing.\nYou can unpublish the workflow to edit it, or make a copy of it.", + "workflowLockedDuringPublishing": "Workflow is locked while configuring for publishing.", + "selectOutputNode": "Select Output Node", + "changeOutputNode": "Change Output Node", + "publishedWorkflowOutputs": "Outputs", + "publishedWorkflowInputs": "Inputs", + "unpublishableInputs": "These unpublishable inputs will be omitted", + "noPublishableInputs": "No publishable inputs", + "noOutputNodeSelected": "No output node selected", + "cannotPublish": "Cannot publish workflow", + "publishWarnings": "Warnings", + "errorWorkflowHasUnsavedChanges": "Workflow has unsaved changes", + "errorWorkflowHasBatchOrGeneratorNodes": "Workflow has batch and/or generator nodes", + "errorWorkflowHasInvalidGraph": "Workflow graph invalid (hover Invoke button for details)", + "errorWorkflowHasNoOutputNode": "No output node selected", + "warningWorkflowHasNoPublishableInputFields": "No publishable input fields selected - published workflow will run with only default values", + "warningWorkflowHasUnpublishableInputFields": "Workflow has some unpublishable inputs - these will be omitted from the published workflow", + "publishFailed": "Publish failed", + "publishFailedDesc": "There was a problem publishing the workflow. Please try again.", + "publishSuccess": "Your workflow is being published", + "publishSuccessDesc": "Check your Project Dashboard to see its progress.", + "publishInProgress": "Publishing in progress", + "publishedWorkflowIsLocked": "Published workflow is locked", + "publishingValidationRun": "Publishing Validation Run", + "publishingValidationRunInProgress": "Publishing validation run in progress.", + "publishedWorkflowsLocked": "Published workflows are locked and cannot be edited or run. Either unpublish the workflow or save a copy to edit or run this workflow." } }, "controlLayers": { diff --git a/invokeai/frontend/web/src/app/store/actions.ts b/invokeai/frontend/web/src/app/store/actions.ts deleted file mode 100644 index 6b7475d1b6..0000000000 --- a/invokeai/frontend/web/src/app/store/actions.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { createAction } from '@reduxjs/toolkit'; -import type { TabName } from 'features/ui/store/uiTypes'; - -export const enqueueRequested = createAction<{ - tabName: TabName; - prepend: boolean; -}>('app/enqueueRequested'); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts index bd21d83175..f57ad8cac9 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts @@ -10,7 +10,6 @@ import { addDeleteBoardAndImagesFulfilledListener } from 'app/store/middleware/l import { addBoardIdSelectedListener } from 'app/store/middleware/listenerMiddleware/listeners/boardIdSelected'; import { addBulkDownloadListeners } from 'app/store/middleware/listenerMiddleware/listeners/bulkDownload'; import { addEnqueueRequestedLinear } from 'app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear'; -import { addEnqueueRequestedNodes } from 'app/store/middleware/listenerMiddleware/listeners/enqueueRequestedNodes'; import { addGalleryImageClickedListener } from 'app/store/middleware/listenerMiddleware/listeners/galleryImageClicked'; import { addGalleryOffsetChangedListener } from 'app/store/middleware/listenerMiddleware/listeners/galleryOffsetChanged'; import { addGetOpenAPISchemaListener } from 'app/store/middleware/listenerMiddleware/listeners/getOpenAPISchema'; @@ -63,7 +62,6 @@ addGalleryImageClickedListener(startAppListening); addGalleryOffsetChangedListener(startAppListening); // User Invoked -addEnqueueRequestedNodes(startAppListening); addEnqueueRequestedLinear(startAppListening); addEnqueueRequestedUpscale(startAppListening); addAnyEnqueuedListener(startAppListening); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addAdHocPostProcessingRequestedListener.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addAdHocPostProcessingRequestedListener.ts index 7ab053c185..e8a7ce23cf 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addAdHocPostProcessingRequestedListener.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addAdHocPostProcessingRequestedListener.ts @@ -5,7 +5,7 @@ import { buildAdHocPostProcessingGraph } from 'features/nodes/util/graph/buildAd import { toast } from 'features/toast/toast'; import { t } from 'i18next'; import { enqueueMutationFixedCacheKeyOptions, queueApi } from 'services/api/endpoints/queue'; -import type { BatchConfig, ImageDTO } from 'services/api/types'; +import type { EnqueueBatchArg, ImageDTO } from 'services/api/types'; import type { JsonObject } from 'type-fest'; const log = logger('queue'); @@ -19,7 +19,7 @@ export const addAdHocPostProcessingRequestedListener = (startAppListening: AppSt const { imageDTO } = action.payload; const state = getState(); - const enqueueBatchArg: BatchConfig = { + const enqueueBatchArg: EnqueueBatchArg = { prepend: true, batch: { graph: await buildAdHocPostProcessingGraph({ diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts index 53301f6a9d..9de26aa07c 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts @@ -1,5 +1,5 @@ +import { createAction } from '@reduxjs/toolkit'; import { logger } from 'app/logging/logger'; -import { enqueueRequested } from 'app/store/actions'; import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; import { extractMessageFromAssertionError } from 'common/util/extractMessageFromAssertionError'; import { withResult, withResultAsync } from 'common/util/result'; @@ -17,10 +17,11 @@ import { assert, AssertionError } from 'tsafe'; const log = logger('generation'); +export const enqueueRequestedCanvas = createAction<{ prepend: boolean }>('app/enqueueRequestedCanvas'); + export const addEnqueueRequestedLinear = (startAppListening: AppStartListening) => { startAppListening({ - predicate: (action): action is ReturnType => - enqueueRequested.match(action) && action.payload.tabName === 'canvas', + actionCreator: enqueueRequestedCanvas, effect: async (action, { getState, dispatch }) => { log.debug('Enqueue requested'); const state = getState(); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedUpscale.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedUpscale.ts index 62e7026315..d979f597ba 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedUpscale.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedUpscale.ts @@ -1,5 +1,5 @@ +import { createAction } from '@reduxjs/toolkit'; import { logger } from 'app/logging/logger'; -import { enqueueRequested } from 'app/store/actions'; import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; import { parseify } from 'common/util/serialize'; import { prepareLinearUIBatch } from 'features/nodes/util/graph/buildLinearBatchConfig'; @@ -9,10 +9,11 @@ import { enqueueMutationFixedCacheKeyOptions, queueApi } from 'services/api/endp const log = logger('generation'); +export const enqueueRequestedUpscaling = createAction<{ prepend: boolean }>('app/enqueueRequestedUpscaling'); + export const addEnqueueRequestedUpscale = (startAppListening: AppStartListening) => { startAppListening({ - predicate: (action): action is ReturnType => - enqueueRequested.match(action) && action.payload.tabName === 'upscaling', + actionCreator: enqueueRequestedUpscaling, effect: async (action, { getState, dispatch }) => { const state = getState(); const { prepend } = action.payload; diff --git a/invokeai/frontend/web/src/app/store/store.ts b/invokeai/frontend/web/src/app/store/store.ts index a2028b49e1..1bcddceabf 100644 --- a/invokeai/frontend/web/src/app/store/store.ts +++ b/invokeai/frontend/web/src/app/store/store.ts @@ -3,6 +3,7 @@ import { autoBatchEnhancer, combineReducers, configureStore } from '@reduxjs/too import { logger } from 'app/logging/logger'; import { idbKeyValDriver } from 'app/store/enhancers/reduxRemember/driver'; import { errorHandler } from 'app/store/enhancers/reduxRemember/errors'; +import { getDebugLoggerMiddleware } from 'app/store/middleware/debugLoggerMiddleware'; import { deepClone } from 'common/util/deepClone'; import { changeBoardModalSlice } from 'features/changeBoardModal/store/slice'; import { canvasSettingsPersistConfig, canvasSettingsSlice } from 'features/controlLayers/store/canvasSettingsSlice'; @@ -175,6 +176,7 @@ export const createStore = (uniqueStoreKey?: string, persist = true) => .concat(api.middleware) .concat(dynamicMiddlewares) .concat(authToastMiddleware) + .concat(getDebugLoggerMiddleware()) .prepend(listenerMiddleware.middleware), enhancers: (getDefaultEnhancers) => { const _enhancers = getDefaultEnhancers().concat(autoBatchEnhancer()); diff --git a/invokeai/frontend/web/src/app/types/invokeai.ts b/invokeai/frontend/web/src/app/types/invokeai.ts index a837894916..e09c4e68f1 100644 --- a/invokeai/frontend/web/src/app/types/invokeai.ts +++ b/invokeai/frontend/web/src/app/types/invokeai.ts @@ -28,7 +28,8 @@ export type AppFeature = | 'starterModels' | 'hfToken' | 'retryQueueItem' - | 'cancelAndClearAll'; + | 'cancelAndClearAll' + | 'publishWorkflow'; /** * A disable-able Stable Diffusion feature */ diff --git a/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts b/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts index 8ea6f88745..76c2db9a6d 100644 --- a/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts +++ b/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts @@ -14,7 +14,7 @@ export const useGlobalHotkeys = () => { useRegisteredHotkeys({ id: 'invoke', category: 'app', - callback: queue.queueBack, + callback: queue.enqueueBack, options: { enabled: !queue.isDisabled && !queue.isLoading, preventDefault: true, @@ -26,7 +26,7 @@ export const useGlobalHotkeys = () => { useRegisteredHotkeys({ id: 'invokeFront', category: 'app', - callback: queue.queueFront, + callback: queue.enqueueFront, options: { enabled: !queue.isDisabled && !queue.isLoading, preventDefault: true, diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApiModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApiModule.ts index fbb5705a6d..84dad91f03 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApiModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApiModule.ts @@ -54,7 +54,7 @@ import { atom, computed } from 'nanostores'; import type { Logger } from 'roarr'; import { getImageDTO } from 'services/api/endpoints/images'; import { enqueueMutationFixedCacheKeyOptions, queueApi } from 'services/api/endpoints/queue'; -import type { BatchConfig, ImageDTO, S } from 'services/api/types'; +import type { EnqueueBatchArg, ImageDTO, S } from 'services/api/types'; import { QueueError } from 'services/events/errors'; import type { Param0 } from 'tsafe'; import { assert } from 'tsafe'; @@ -291,7 +291,7 @@ export class CanvasStateApiModule extends CanvasModuleBase { */ const origin = getPrefixedId(graph.id); - const batch: BatchConfig = { + const batch: EnqueueBatchArg = { prepend, batch: { graph: graph.getGraph(), diff --git a/invokeai/frontend/web/src/features/nodes/components/NodeEditor.tsx b/invokeai/frontend/web/src/features/nodes/components/NodeEditor.tsx index 02e2abeadc..168af9dff9 100644 --- a/invokeai/frontend/web/src/features/nodes/components/NodeEditor.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/NodeEditor.tsx @@ -2,7 +2,9 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { FocusRegionWrapper } from 'common/components/FocusRegionWrapper'; import { IAINoContentFallback } from 'common/components/IAIImageFallback'; import { AddNodeCmdk } from 'features/nodes/components/flow/AddNodeCmdk/AddNodeCmdk'; -import TopPanel from 'features/nodes/components/flow/panels/TopPanel/TopPanel'; +import { TopCenterPanel } from 'features/nodes/components/flow/panels/TopPanel/TopCenterPanel'; +import { TopLeftPanel } from 'features/nodes/components/flow/panels/TopPanel/TopLeftPanel'; +import { TopRightPanel } from 'features/nodes/components/flow/panels/TopPanel/TopRightPanel'; import WorkflowEditorSettings from 'features/nodes/components/flow/panels/TopRightPanel/WorkflowEditorSettings'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -32,7 +34,9 @@ const NodeEditor = () => { <> - + + + diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/AddNodeCmdk/AddNodeCmdk.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/AddNodeCmdk/AddNodeCmdk.tsx index 334325d096..3f12cf10a5 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/AddNodeCmdk/AddNodeCmdk.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/AddNodeCmdk/AddNodeCmdk.tsx @@ -18,6 +18,7 @@ import { CommandEmpty, CommandItem, CommandList, CommandRoot } from 'cmdk'; import { IAINoContentFallback } from 'common/components/IAIImageFallback'; import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent'; import { useBuildNode } from 'features/nodes/hooks/useBuildNode'; +import { useIsWorkflowEditorLocked } from 'features/nodes/hooks/useIsWorkflowEditorLocked'; import { $addNodeCmdk, $cursorPos, @@ -146,6 +147,7 @@ export const AddNodeCmdk = memo(() => { const [searchTerm, setSearchTerm] = useState(''); const addNode = useAddNode(); const tab = useAppSelector(selectActiveTab); + const isLocked = useIsWorkflowEditorLocked(); // Filtering the list is expensive - debounce the search term to avoid stutters const [debouncedSearchTerm] = useDebounce(searchTerm, 300); const isOpen = useStore($addNodeCmdk); @@ -160,8 +162,8 @@ export const AddNodeCmdk = memo(() => { id: 'addNode', category: 'workflows', callback: open, - options: { enabled: tab === 'workflows', preventDefault: true }, - dependencies: [open, tab], + options: { enabled: tab === 'workflows' && !isLocked, preventDefault: true }, + dependencies: [open, tab, isLocked], }); const onChange = useCallback((e: ChangeEvent) => { diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx index 8e63098db6..24397ce3ec 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx @@ -4,6 +4,7 @@ import type { EdgeChange, HandleType, NodeChange, + NodeMouseHandler, OnEdgesChange, OnInit, OnMoveEnd, @@ -16,8 +17,10 @@ import type { import { Background, ReactFlow, useStore as useReactFlowStore, useUpdateNodeInternals } from '@xyflow/react'; import { useAppDispatch, useAppSelector, useAppStore } from 'app/store/storeHooks'; import { useFocusRegion, useIsRegionFocused } from 'common/hooks/focus'; +import { $isSelectingOutputNode, $outputNodeId } from 'features/nodes/components/sidePanel/workflow/publish'; import { useConnection } from 'features/nodes/hooks/useConnection'; import { useIsValidConnection } from 'features/nodes/hooks/useIsValidConnection'; +import { useIsWorkflowEditorLocked } from 'features/nodes/hooks/useIsWorkflowEditorLocked'; import { useNodeCopyPaste } from 'features/nodes/hooks/useNodeCopyPaste'; import { useSyncExecutionState } from 'features/nodes/hooks/useNodeExecutionState'; import { @@ -44,7 +47,7 @@ import { import { connectionToEdge } from 'features/nodes/store/util/reactFlowUtil'; import { selectSelectionMode, selectShouldSnapToGrid } from 'features/nodes/store/workflowSettingsSlice'; import { NO_DRAG_CLASS, NO_PAN_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants'; -import type { AnyEdge, AnyNode } from 'features/nodes/types/invocation'; +import { type AnyEdge, type AnyNode, isInvocationNode } from 'features/nodes/types/invocation'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import type { CSSProperties, MouseEvent } from 'react'; import { memo, useCallback, useMemo, useRef } from 'react'; @@ -92,6 +95,8 @@ export const Flow = memo(() => { const updateNodeInternals = useUpdateNodeInternals(); const store = useAppStore(); const isWorkflowsFocused = useIsRegionFocused('workflows'); + const isLocked = useIsWorkflowEditorLocked(); + useFocusRegion('workflows', flowWrapper); useSyncExecutionState(); @@ -215,7 +220,7 @@ export const Flow = memo(() => { id: 'copySelection', category: 'workflows', callback: copySelection, - options: { preventDefault: true }, + options: { enabled: isWorkflowsFocused && !isLocked, preventDefault: true }, dependencies: [copySelection], }); @@ -244,24 +249,24 @@ export const Flow = memo(() => { id: 'selectAll', category: 'workflows', callback: selectAll, - options: { enabled: isWorkflowsFocused, preventDefault: true }, - dependencies: [selectAll, isWorkflowsFocused], + options: { enabled: isWorkflowsFocused && !isLocked, preventDefault: true }, + dependencies: [selectAll, isWorkflowsFocused, isLocked], }); useRegisteredHotkeys({ id: 'pasteSelection', category: 'workflows', callback: pasteSelection, - options: { enabled: isWorkflowsFocused, preventDefault: true }, - dependencies: [pasteSelection], + options: { enabled: isWorkflowsFocused && !isLocked, preventDefault: true }, + dependencies: [pasteSelection, isLocked, isWorkflowsFocused], }); useRegisteredHotkeys({ id: 'pasteSelectionWithEdges', category: 'workflows', callback: pasteSelectionWithEdges, - options: { enabled: isWorkflowsFocused, preventDefault: true }, - dependencies: [pasteSelectionWithEdges], + options: { enabled: isWorkflowsFocused && !isLocked, preventDefault: true }, + dependencies: [pasteSelectionWithEdges, isLocked, isWorkflowsFocused], }); useRegisteredHotkeys({ @@ -270,8 +275,8 @@ export const Flow = memo(() => { callback: () => { dispatch(undo()); }, - options: { enabled: isWorkflowsFocused && mayUndo, preventDefault: true }, - dependencies: [mayUndo], + options: { enabled: isWorkflowsFocused && !isLocked && mayUndo, preventDefault: true }, + dependencies: [mayUndo, isLocked, isWorkflowsFocused], }); useRegisteredHotkeys({ @@ -280,8 +285,8 @@ export const Flow = memo(() => { callback: () => { dispatch(redo()); }, - options: { enabled: isWorkflowsFocused && mayRedo, preventDefault: true }, - dependencies: [mayRedo], + options: { enabled: isWorkflowsFocused && !isLocked && mayRedo, preventDefault: true }, + dependencies: [mayRedo, isLocked, isWorkflowsFocused], }); const onEscapeHotkey = useCallback(() => { @@ -318,10 +323,22 @@ export const Flow = memo(() => { id: 'deleteSelection', category: 'workflows', callback: deleteSelection, - options: { preventDefault: true, enabled: isWorkflowsFocused }, - dependencies: [deleteSelection, isWorkflowsFocused], + options: { preventDefault: true, enabled: isWorkflowsFocused && !isLocked }, + dependencies: [deleteSelection, isWorkflowsFocused, isLocked], }); + const onNodeClick = useCallback>((e, node) => { + if (!$isSelectingOutputNode.get()) { + return; + } + if (!isInvocationNode(node)) { + return; + } + const { id } = node.data; + $outputNodeId.set(id); + $isSelectingOutputNode.set(false); + }, []); + return ( id="workflow-editor" @@ -332,6 +349,7 @@ export const Flow = memo(() => { nodes={nodes} edges={edges} onInit={onInit} + onNodeClick={onNodeClick} onMouseMove={onMouseMove} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} @@ -344,6 +362,12 @@ export const Flow = memo(() => { onMoveEnd={handleMoveEnd} connectionLineComponent={CustomConnectionLine} isValidConnection={isValidConnection} + edgesFocusable={!isLocked} + edgesReconnectable={!isLocked} + nodesDraggable={!isLocked} + nodesConnectable={!isLocked} + nodesFocusable={!isLocked} + elementsSelectable={!isLocked} minZoom={0.1} snapToGrid={shouldSnapToGrid} snapGrid={snapGrid} diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeCollapsedHandles.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeCollapsedHandles.tsx index 456f89daa0..0f50e0595b 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeCollapsedHandles.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeCollapsedHandles.tsx @@ -1,5 +1,5 @@ import { Handle, Position } from '@xyflow/react'; -import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate'; +import { useNodeTemplateOrThrow } from 'features/nodes/hooks/useNodeTemplateOrThrow'; import { map } from 'lodash-es'; import type { CSSProperties } from 'react'; import { memo } from 'react'; @@ -19,7 +19,7 @@ const collapsedHandleStyles: CSSProperties = { }; const InvocationNodeCollapsedHandles = ({ nodeId }: Props) => { - const template = useNodeTemplate(nodeId); + const template = useNodeTemplateOrThrow(nodeId); if (!template) { return null; diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeInfoIcon.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeInfoIcon.tsx index f563d0b68c..7b074580df 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeInfoIcon.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeInfoIcon.tsx @@ -1,9 +1,9 @@ import { Flex, Icon, Text, Tooltip } from '@invoke-ai/ui-library'; import { compare } from 'compare-versions'; -import { useNodeLabel } from 'features/nodes/hooks/useNodeLabel'; import { useNodeNeedsUpdate } from 'features/nodes/hooks/useNodeNeedsUpdate'; import { useInvocationNodeNotes } from 'features/nodes/hooks/useNodeNotes'; -import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate'; +import { useNodeTemplateOrThrow } from 'features/nodes/hooks/useNodeTemplateOrThrow'; +import { useNodeUserTitleSafe } from 'features/nodes/hooks/useNodeUserTitleSafe'; import { useNodeVersion } from 'features/nodes/hooks/useNodeVersion'; import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -27,9 +27,9 @@ InvocationNodeInfoIcon.displayName = 'InvocationNodeInfoIcon'; const TooltipContent = memo(({ nodeId }: { nodeId: string }) => { const notes = useInvocationNodeNotes(nodeId); - const label = useNodeLabel(nodeId); + const label = useNodeUserTitleSafe(nodeId); const version = useNodeVersion(nodeId); - const nodeTemplate = useNodeTemplate(nodeId); + const nodeTemplate = useNodeTemplateOrThrow(nodeId); const { t } = useTranslation(); const title = useMemo(() => { diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldDescriptionPopover.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldDescriptionPopover.tsx index cf832ef610..2aff85553e 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldDescriptionPopover.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldDescriptionPopover.tsx @@ -8,7 +8,7 @@ import { Textarea, } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; -import { useInputFieldDescriptionSafe } from 'features/nodes/hooks/useInputFieldDescriptionSafe'; +import { useInputFieldUserDescriptionSafe } from 'features/nodes/hooks/useInputFieldUserDescriptionSafe'; 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'; @@ -48,7 +48,7 @@ InputFieldDescriptionPopover.displayName = 'InputFieldDescriptionPopover'; const Content = memo(({ nodeId, fieldName }: Props) => { const dispatch = useAppDispatch(); const { t } = useTranslation(); - const description = useInputFieldDescriptionSafe(nodeId, fieldName); + const description = useInputFieldUserDescriptionSafe(nodeId, fieldName); const onChange = useCallback( (e: ChangeEvent) => { dispatch(fieldDescriptionChanged({ nodeId, fieldName, val: e.target.value })); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldEditModeNodes.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldEditModeNodes.tsx index 34b1373853..e2bede8a19 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldEditModeNodes.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldEditModeNodes.tsx @@ -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 { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplate'; +import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplateOrThrow'; import { NO_DRAG_CLASS } from 'features/nodes/types/constants'; import type { FieldInputTemplate } from 'features/nodes/types/field'; import { memo, useRef } from 'react'; @@ -100,7 +100,7 @@ const DirectField = memo(({ nodeId, fieldName, isInvalid, isConnected, fieldTemp const draggableRef = useRef(null); const dragHandleRef = useRef(null); - const isDragging = useNodeFieldDnd({ nodeId, fieldName }, fieldTemplate, draggableRef, dragHandleRef); + const isDragging = useNodeFieldDnd(nodeId, fieldName, fieldTemplate, draggableRef, dragHandleRef); return ( diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldHandle.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldHandle.tsx index d25664070e..b526ca46a1 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldHandle.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldHandle.tsx @@ -7,7 +7,8 @@ import { useIsConnectionInProgress, useIsConnectionStartField, } from 'features/nodes/hooks/useFieldConnectionState'; -import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplate'; +import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplateOrThrow'; +import { useIsWorkflowEditorLocked } from 'features/nodes/hooks/useIsWorkflowEditorLocked'; 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'; @@ -105,9 +106,16 @@ type HandleCommonProps = { }; const IdleHandle = memo(({ fieldTemplate, fieldTypeName, fieldColor, isModelField }: HandleCommonProps) => { + const isLocked = useIsWorkflowEditorLocked(); return ( - + { if (connectionError !== null) { @@ -140,7 +149,13 @@ const ConnectionInProgressHandle = memo( return ( - + { const { nodeId, fieldName, isInvalid, isDragging } = props; const inputRef = useRef(null); - const label = useInputFieldLabelSafe(nodeId, fieldName); - const fieldTemplateTitle = useInputFieldTemplateTitle(nodeId, fieldName); + const label = useInputFieldUserTitleSafe(nodeId, fieldName); + const fieldTemplateTitle = useInputFieldTemplateTitleOrThrow(nodeId, fieldName); const { t } = useTranslation(); const isConnected = useInputFieldIsConnected(nodeId, fieldName); const isConnectionStartField = useIsConnectionStartField(nodeId, fieldName, 'target'); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldTooltipContent.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldTooltipContent.tsx index e456f771aa..43ca1fc90c 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldTooltipContent.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldTooltipContent.tsx @@ -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 { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplate'; +import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplateOrThrow'; import { useFieldTypeName } from 'features/nodes/hooks/usePrettyFieldType'; import { startCase } from 'lodash-es'; import { memo, useMemo } from 'react'; diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/OutputFieldHandle.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/OutputFieldHandle.tsx index 536fb58e62..c76cf7e5d6 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/OutputFieldHandle.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/OutputFieldHandle.tsx @@ -7,6 +7,7 @@ import { useIsConnectionInProgress, useIsConnectionStartField, } from 'features/nodes/hooks/useFieldConnectionState'; +import { useIsWorkflowEditorLocked } from 'features/nodes/hooks/useIsWorkflowEditorLocked'; import { useOutputFieldTemplate } from 'features/nodes/hooks/useOutputFieldTemplate'; import { useFieldTypeName } from 'features/nodes/hooks/usePrettyFieldType'; import { HANDLE_TOOLTIP_OPEN_DELAY } from 'features/nodes/types/constants'; @@ -105,9 +106,17 @@ type HandleCommonProps = { }; const IdleHandle = memo(({ fieldTemplate, fieldTypeName, fieldColor, isModelField }: HandleCommonProps) => { + const isLocked = useIsWorkflowEditorLocked(); + return ( - + { if (connectionErrorTKey !== null) { @@ -140,7 +150,13 @@ const ConnectionInProgressHandle = memo( return ( - + { const dispatch = useAppDispatch(); - const label = useNodeLabel(nodeId); + const label = useNodeUserTitleSafe(nodeId); const batchGroupId = useBatchGroupId(nodeId); const batchGroupColorToken = useBatchGroupColorToken(batchGroupId); - const templateTitle = useNodeTemplateTitle(nodeId); + const templateTitle = useNodeTemplateTitleSafe(nodeId); const { t } = useTranslation(); const inputRef = useRef(null); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/common/NodeWrapper.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/common/NodeWrapper.tsx index 418b402e85..ab9743c155 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/common/NodeWrapper.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/common/NodeWrapper.tsx @@ -1,6 +1,7 @@ import type { ChakraProps, SystemStyleObject } from '@invoke-ai/ui-library'; import { Box, useGlobalMenuClose } from '@invoke-ai/ui-library'; import { useAppSelector } from 'app/store/storeHooks'; +import { useIsWorkflowEditorLocked } from 'features/nodes/hooks/useIsWorkflowEditorLocked'; import { useMouseOverFormField, useMouseOverNode } from 'features/nodes/hooks/useMouseOverNode'; import { useNodeExecutionState } from 'features/nodes/hooks/useNodeExecutionState'; import { useZoomToNode } from 'features/nodes/hooks/useZoomToNode'; @@ -62,6 +63,12 @@ const containerSx: SystemStyleObject = { display: 'block', shadow: '0 0 0 3px var(--invoke-colors-blue-300)', }, + '&[data-is-editor-locked="true"]': { + '& *': { + cursor: 'not-allowed', + pointerEvents: 'none', + }, + }, }; const shadowsSx: SystemStyleObject = { @@ -98,7 +105,8 @@ const NodeWrapper = (props: NodeWrapperProps) => { const { nodeId, width, children, selected } = props; const mouseOverNode = useMouseOverNode(nodeId); const mouseOverFormField = useMouseOverFormField(nodeId); - const zoomToNode = useZoomToNode(); + const zoomToNode = useZoomToNode(nodeId); + const isLocked = useIsWorkflowEditorLocked(); const executionState = useNodeExecutionState(nodeId); const isInProgress = executionState?.status === zNodeStatus.enum.IN_PROGRESS; @@ -126,9 +134,9 @@ const NodeWrapper = (props: NodeWrapperProps) => { // This target is marked as not fitting the view on double click return; } - zoomToNode(nodeId); + zoomToNode(); }, - [nodeId, zoomToNode] + [zoomToNode] ); return ( @@ -141,6 +149,7 @@ const NodeWrapper = (props: NodeWrapperProps) => { sx={containerSx} width={width || NODE_WIDTH} opacity={opacity} + data-is-editor-locked={isLocked} data-is-selected={selected} data-is-mouse-over-form-field={mouseOverFormField.isMouseOverFormField} > diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/TopCenterPanel.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/TopCenterPanel.tsx new file mode 100644 index 0000000000..ea4b3a9a43 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/TopCenterPanel.tsx @@ -0,0 +1,15 @@ +import { Flex } from '@invoke-ai/ui-library'; +import { useAppSelector } from 'app/store/storeHooks'; +import { WorkflowName } from 'features/nodes/components/sidePanel/WorkflowName'; +import { selectWorkflowName } from 'features/nodes/store/workflowSlice'; +import { memo } from 'react'; + +export const TopCenterPanel = memo(() => { + const name = useAppSelector(selectWorkflowName); + return ( + + {!!name.length && } + + ); +}); +TopCenterPanel.displayName = 'TopCenterPanel'; diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/TopLeftPanel.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/TopLeftPanel.tsx new file mode 100644 index 0000000000..bed3fe8152 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/TopLeftPanel.tsx @@ -0,0 +1,54 @@ +import { Alert, AlertDescription, AlertIcon, AlertTitle, Box, Flex } from '@invoke-ai/ui-library'; +import { useStore } from '@nanostores/react'; +import { useAppSelector } from 'app/store/storeHooks'; +import AddNodeButton from 'features/nodes/components/flow/panels/TopPanel/AddNodeButton'; +import UpdateNodesButton from 'features/nodes/components/flow/panels/TopPanel/UpdateNodesButton'; +import { $isInPublishFlow, useIsValidationRunInProgress } from 'features/nodes/components/sidePanel/workflow/publish'; +import { useIsWorkflowEditorLocked } from 'features/nodes/hooks/useIsWorkflowEditorLocked'; +import { selectWorkflowIsPublished } from 'features/nodes/store/workflowSlice'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; + +export const TopLeftPanel = memo(() => { + const isLocked = useIsWorkflowEditorLocked(); + const isInPublishFlow = useStore($isInPublishFlow); + const isPublished = useAppSelector(selectWorkflowIsPublished); + const isValidationRunInProgress = useIsValidationRunInProgress(); + + const { t } = useTranslation(); + return ( + + {!isLocked && ( + + + + + )} + {isLocked && ( + + + + {t('workflows.builder.workflowLocked')} + {isValidationRunInProgress && ( + + {t('workflows.builder.publishingValidationRunInProgress')} + + )} + {isInPublishFlow && !isValidationRunInProgress && ( + + {t('workflows.builder.workflowLockedDuringPublishing')} + + )} + {isPublished && ( + + {t('workflows.builder.workflowLockedPublished')} + + )} + + + )} + + ); +}); + +TopLeftPanel.displayName = 'TopLeftPanel'; diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/TopPanel.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/TopPanel.tsx deleted file mode 100644 index e45eb937dc..0000000000 --- a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/TopPanel.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { Flex, IconButton, Spacer } from '@invoke-ai/ui-library'; -import { useAppSelector } from 'app/store/storeHooks'; -import AddNodeButton from 'features/nodes/components/flow/panels/TopPanel/AddNodeButton'; -import ClearFlowButton from 'features/nodes/components/flow/panels/TopPanel/ClearFlowButton'; -import SaveWorkflowButton from 'features/nodes/components/flow/panels/TopPanel/SaveWorkflowButton'; -import UpdateNodesButton from 'features/nodes/components/flow/panels/TopPanel/UpdateNodesButton'; -import { useWorkflowEditorSettingsModal } from 'features/nodes/components/flow/panels/TopRightPanel/WorkflowEditorSettings'; -import { WorkflowName } from 'features/nodes/components/sidePanel/WorkflowName'; -import { selectWorkflowName } from 'features/nodes/store/workflowSlice'; -import { memo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { PiGearSixFill } from 'react-icons/pi'; - -const TopCenterPanel = () => { - const name = useAppSelector(selectWorkflowName); - const modal = useWorkflowEditorSettingsModal(); - - const { t } = useTranslation(); - return ( - - - - - - - {!!name.length && } - - - - } - onClick={modal.setTrue} - /> - - ); -}; - -export default memo(TopCenterPanel); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/TopRightPanel.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/TopRightPanel.tsx new file mode 100644 index 0000000000..af778d3a9f --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/TopRightPanel.tsx @@ -0,0 +1,34 @@ +import { Flex, IconButton } from '@invoke-ai/ui-library'; +import ClearFlowButton from 'features/nodes/components/flow/panels/TopPanel/ClearFlowButton'; +import SaveWorkflowButton from 'features/nodes/components/flow/panels/TopPanel/SaveWorkflowButton'; +import { useWorkflowEditorSettingsModal } from 'features/nodes/components/flow/panels/TopRightPanel/WorkflowEditorSettings'; +import { useIsWorkflowEditorLocked } from 'features/nodes/hooks/useIsWorkflowEditorLocked'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiGearSixFill } from 'react-icons/pi'; + +export const TopRightPanel = memo(() => { + const modal = useWorkflowEditorSettingsModal(); + const isLocked = useIsWorkflowEditorLocked(); + + const { t } = useTranslation(); + + if (isLocked) { + return null; + } + + return ( + + + + } + onClick={modal.setTrue} + /> + + ); +}); + +TopRightPanel.displayName = 'TopRightPanel'; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/EditModeLeftPanelContent.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/EditModeLeftPanelContent.tsx index 24805a659a..e411a8976d 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/EditModeLeftPanelContent.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/EditModeLeftPanelContent.tsx @@ -1,5 +1,4 @@ import { Box } from '@invoke-ai/ui-library'; -import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent'; import { HorizontalResizeHandle } from 'features/ui/components/tabs/ResizeHandle'; import type { CSSProperties } from 'react'; import { memo, useCallback, useRef } from 'react'; @@ -23,23 +22,21 @@ export const EditModeLeftPanelContent = memo(() => { return ( - - - - - - - - - - - + + + + + + + + + ); }); diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/PublishedWorkflowPanelContent.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/PublishedWorkflowPanelContent.tsx new file mode 100644 index 0000000000..849bba652c --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/PublishedWorkflowPanelContent.tsx @@ -0,0 +1,25 @@ +import { Button, Flex, Heading, Text } from '@invoke-ai/ui-library'; +import { useSaveOrSaveAsWorkflow } from 'features/workflowLibrary/hooks/useSaveOrSaveAsWorkflow'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiCopyBold, PiLockOpenBold } from 'react-icons/pi'; + +export const PublishedWorkflowPanelContent = memo(() => { + const { t } = useTranslation(); + const saveAs = useSaveOrSaveAsWorkflow(); + return ( + + + {t('workflows.builder.workflowLocked')} + + {t('workflows.builder.publishedWorkflowsLocked')} + + + + ); +}); +PublishedWorkflowPanelContent.displayName = 'PublishedWorkflowPanelContent'; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/ActiveWorkflowNameAndActions.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/ActiveWorkflowNameAndActions.tsx index 3bb2dff02b..eb0d851f28 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/ActiveWorkflowNameAndActions.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/ActiveWorkflowNameAndActions.tsx @@ -2,7 +2,7 @@ import { Flex, Spacer } from '@invoke-ai/ui-library'; import { useAppSelector } from 'app/store/storeHooks'; import { WorkflowListMenuTrigger } from 'features/nodes/components/sidePanel/WorkflowListMenu/WorkflowListMenuTrigger'; import { WorkflowViewEditToggleButton } from 'features/nodes/components/sidePanel/WorkflowViewEditToggleButton'; -import { selectWorkflowMode } from 'features/nodes/store/workflowSlice'; +import { selectWorkflowIsPublished, selectWorkflowMode } from 'features/nodes/store/workflowSlice'; import { WorkflowLibraryMenu } from 'features/workflowLibrary/components/WorkflowLibraryMenu/WorkflowLibraryMenu'; import { memo } from 'react'; @@ -10,12 +10,13 @@ import SaveWorkflowButton from './SaveWorkflowButton'; export const ActiveWorkflowNameAndActions = memo(() => { const mode = useAppSelector(selectWorkflowMode); + const isPublished = useAppSelector(selectWorkflowIsPublished); return ( - {mode === 'edit' && } + {mode === 'edit' && !isPublished && } diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowsTabLeftPanel.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowsTabLeftPanel.tsx index 089be12f08..9789c179c3 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowsTabLeftPanel.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowsTabLeftPanel.tsx @@ -1,22 +1,30 @@ import { Flex } from '@invoke-ai/ui-library'; +import { useStore } from '@nanostores/react'; import { useAppSelector } from 'app/store/storeHooks'; import { EditModeLeftPanelContent } from 'features/nodes/components/sidePanel/EditModeLeftPanelContent'; +import { PublishedWorkflowPanelContent } from 'features/nodes/components/sidePanel/PublishedWorkflowPanelContent'; +import { $isInPublishFlow } from 'features/nodes/components/sidePanel/workflow/publish'; +import { PublishWorkflowPanelContent } from 'features/nodes/components/sidePanel/workflow/PublishWorkflowPanelContent'; import { ActiveWorkflowDescription } from 'features/nodes/components/sidePanel/WorkflowListMenu/ActiveWorkflowDescription'; import { ActiveWorkflowNameAndActions } from 'features/nodes/components/sidePanel/WorkflowListMenu/ActiveWorkflowNameAndActions'; -import { selectWorkflowMode } from 'features/nodes/store/workflowSlice'; +import { selectWorkflowIsPublished, selectWorkflowMode } from 'features/nodes/store/workflowSlice'; import { memo } from 'react'; import { ViewModeLeftPanelContent } from './viewMode/ViewModeLeftPanelContent'; const WorkflowsTabLeftPanel = () => { const mode = useAppSelector(selectWorkflowMode); + const isPublished = useAppSelector(selectWorkflowIsPublished); + const isInPublishFlow = useStore($isInPublishFlow); return ( - - {mode === 'view' && } - {mode === 'view' && } - {mode === 'edit' && } + {isInPublishFlow && } + {!isInPublishFlow && } + {!isInPublishFlow && !isPublished && mode === 'view' && } + {!isInPublishFlow && !isPublished && mode === 'view' && } + {!isInPublishFlow && !isPublished && mode === 'edit' && } + {isPublished && } ); }; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/FormElementEditModeHeader.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/FormElementEditModeHeader.tsx index 5c24b26bc9..753f93f063 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/FormElementEditModeHeader.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/FormElementEditModeHeader.tsx @@ -67,11 +67,8 @@ FormElementEditModeHeader.displayName = 'FormElementEditModeHeader'; const ZoomToNodeButton = memo(({ element }: { element: NodeFieldElement }) => { const { t } = useTranslation(); const { nodeId } = element.data.fieldIdentifier; - const zoomToNode = useZoomToNode(); + const zoomToNode = useZoomToNode(nodeId); const mouseOverFormField = useMouseOverFormField(nodeId); - const onClick = useCallback(() => { - zoomToNode(nodeId); - }, [nodeId, zoomToNode]); return ( { onMouseOut={mouseOverFormField.handleMouseOut} tooltip={t('workflows.builder.zoomToNode')} aria-label={t('workflows.builder.zoomToNode')} - onClick={onClick} + onClick={zoomToNode} icon={} variant="link" size="sm" diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementDescriptionEditable.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementDescriptionEditable.tsx index 9f386b496b..08b31fe22b 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementDescriptionEditable.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementDescriptionEditable.tsx @@ -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 { useInputFieldDescriptionSafe } from 'features/nodes/hooks/useInputFieldDescriptionSafe'; -import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplate'; +import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplateOrThrow'; +import { useInputFieldUserDescriptionSafe } from 'features/nodes/hooks/useInputFieldUserDescriptionSafe'; import { fieldDescriptionChanged } from 'features/nodes/store/nodesSlice'; import type { NodeFieldElement } from 'features/nodes/types/workflow'; import Linkify from 'linkify-react'; @@ -13,7 +13,7 @@ export const NodeFieldElementDescriptionEditable = memo(({ el }: { el: NodeField const { data } = el; const { fieldIdentifier } = data; const dispatch = useAppDispatch(); - const description = useInputFieldDescriptionSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName); + const description = useInputFieldUserDescriptionSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName); const fieldTemplate = useInputFieldTemplateOrThrow(fieldIdentifier.nodeId, fieldIdentifier.fieldName); const inputRef = useRef(null); diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementEditMode.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementEditMode.tsx index 8aa3dccc97..60fd35fd44 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementEditMode.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementEditMode.tsx @@ -39,7 +39,7 @@ export const NodeFieldElementEditMode = memo(({ el }: { el: NodeFieldElement }) return ( - + ); @@ -105,9 +105,9 @@ const nodeFieldOverlaySx: SystemStyleObject = { }, }; -const NodeFieldElementOverlay = memo(({ element }: { element: NodeFieldElement }) => { - const mouseOverNode = useMouseOverNode(element.data.fieldIdentifier.nodeId); - const mouseOverFormField = useMouseOverFormField(element.data.fieldIdentifier.nodeId); +export const NodeFieldElementOverlay = memo(({ nodeId }: { nodeId: string }) => { + const mouseOverNode = useMouseOverNode(nodeId); + const mouseOverFormField = useMouseOverFormField(nodeId); return ( { const { data } = el; const { fieldIdentifier } = data; - const label = useInputFieldLabelSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName); + const label = useInputFieldUserTitleSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName); const fieldTemplate = useInputFieldTemplateOrThrow(fieldIdentifier.nodeId, fieldIdentifier.fieldName); const _label = useMemo(() => label || fieldTemplate.title, [label, fieldTemplate.title]); diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementLabelEditable.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementLabelEditable.tsx index b78315cc7c..570bf17570 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementLabelEditable.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementLabelEditable.tsx @@ -2,8 +2,8 @@ import { Flex, FormLabel, Input, Spacer } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; import { useEditable } from 'common/hooks/useEditable'; import { NodeFieldElementResetToInitialValueIconButton } from 'features/nodes/components/flow/nodes/Invocation/fields/NodeFieldElementResetToInitialValueIconButton'; -import { useInputFieldLabelSafe } from 'features/nodes/hooks/useInputFieldLabelSafe'; -import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplate'; +import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplateOrThrow'; +import { useInputFieldUserTitleSafe } from 'features/nodes/hooks/useInputFieldUserTitleSafe'; import { fieldLabelChanged } from 'features/nodes/store/nodesSlice'; import type { NodeFieldElement } from 'features/nodes/types/workflow'; import { memo, useCallback, useRef } from 'react'; @@ -12,7 +12,7 @@ export const NodeFieldElementLabelEditable = memo(({ el }: { el: NodeFieldElemen const { data } = el; const { fieldIdentifier } = data; const dispatch = useAppDispatch(); - const label = useInputFieldLabelSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName); + const label = useInputFieldUserTitleSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName); const fieldTemplate = useInputFieldTemplateOrThrow(fieldIdentifier.nodeId, fieldIdentifier.fieldName); const inputRef = useRef(null); diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementSettings.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementSettings.tsx index c3998b5317..4f0c3369bb 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementSettings.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementSettings.tsx @@ -15,7 +15,7 @@ import { useAppDispatch } from 'app/store/storeHooks'; import { NodeFieldElementFloatSettings } from 'features/nodes/components/sidePanel/builder/NodeFieldElementFloatSettings'; import { NodeFieldElementIntegerSettings } from 'features/nodes/components/sidePanel/builder/NodeFieldElementIntegerSettings'; import { NodeFieldElementStringSettings } from 'features/nodes/components/sidePanel/builder/NodeFieldElementStringSettings'; -import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplate'; +import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplateOrThrow'; import { formElementNodeFieldDataChanged } from 'features/nodes/store/workflowSlice'; import { isFloatFieldInputTemplate, diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementViewMode.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementViewMode.tsx index 5f6dc87dbc..ded312661d 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementViewMode.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/NodeFieldElementViewMode.tsx @@ -5,8 +5,9 @@ import { InputFieldGate } from 'features/nodes/components/flow/nodes/Invocation/ 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'; -import { useInputFieldDescriptionSafe } from 'features/nodes/hooks/useInputFieldDescriptionSafe'; -import { useInputFieldTemplateOrThrow, useInputFieldTemplateSafe } from 'features/nodes/hooks/useInputFieldTemplate'; +import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplateOrThrow'; +import { useInputFieldTemplateSafe } from 'features/nodes/hooks/useInputFieldTemplateSafe'; +import { useInputFieldUserDescriptionSafe } from 'features/nodes/hooks/useInputFieldUserDescriptionSafe'; import type { NodeFieldElement } from 'features/nodes/types/workflow'; import { NODE_FIELD_CLASS_NAME } from 'features/nodes/types/workflow'; import Linkify from 'linkify-react'; @@ -36,7 +37,7 @@ const useFormatFallbackLabel = () => { export const NodeFieldElementViewMode = memo(({ el }: { el: NodeFieldElement }) => { const { id, data } = el; const { fieldIdentifier, showDescription } = data; - const description = useInputFieldDescriptionSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName); + const description = useInputFieldUserDescriptionSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName); const fieldTemplate = useInputFieldTemplateSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName); const containerCtx = useContainerContext(); const formatFallbackLabel = useFormatFallbackLabel(); @@ -69,7 +70,7 @@ NodeFieldElementViewMode.displayName = 'NodeFieldElementViewMode'; const NodeFieldElementViewModeContent = memo(({ el }: { el: NodeFieldElement }) => { const { data } = el; const { fieldIdentifier, showDescription } = data; - const description = useInputFieldDescriptionSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName); + const description = useInputFieldUserDescriptionSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName); const fieldTemplate = useInputFieldTemplateOrThrow(fieldIdentifier.nodeId, fieldIdentifier.fieldName); const _description = useMemo( diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/dnd-hooks.ts b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/dnd-hooks.ts index 946e9cc22b..8b607f129f 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/dnd-hooks.ts +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/dnd-hooks.ts @@ -1,4 +1,6 @@ import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; +import type { DropTargetRecord } from '@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types'; +import type { ElementDragPayload } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import { draggable, dropTargetForElements, @@ -33,7 +35,7 @@ import { selectFormRootElementId, selectWorkflowSlice, } from 'features/nodes/store/workflowSlice'; -import type { FieldIdentifier, FieldInputTemplate, StatefulFieldValue } from 'features/nodes/types/field'; +import type { FieldInputTemplate, StatefulFieldValue } from 'features/nodes/types/field'; import type { ElementId, FormElement } from 'features/nodes/types/workflow'; import { buildNodeFieldElement, isContainerElement } from 'features/nodes/types/workflow'; import type { RefObject } from 'react'; @@ -58,6 +60,27 @@ const isFormElementDndData = (data: Record): data is F return uniqueFormElementDndKey in data; }; +const uniqueNodeFieldDndKey = Symbol('node-field'); +type NodeFieldDndData = { + [uniqueNodeFieldDndKey]: true; + nodeId: string; + fieldName: string; + fieldTemplate: FieldInputTemplate; +}; +const buildNodeFieldDndData = ( + nodeId: string, + fieldName: string, + fieldTemplate: FieldInputTemplate +): NodeFieldDndData => ({ + [uniqueNodeFieldDndKey]: true, + nodeId, + fieldName, + fieldTemplate, +}); +const isNodeFieldDndData = (data: Record): data is NodeFieldDndData => { + return uniqueNodeFieldDndKey in data; +}; + /** * Flashes an element by changing its background color. Used to indicate that an element has been moved. * @param elementId The id of the element to flash @@ -133,6 +156,27 @@ const useGetInitialValue = () => { return _getInitialValue; }; +const getSourceElement = (source: ElementDragPayload) => { + if (isNodeFieldDndData(source.data)) { + const { nodeId, fieldName, fieldTemplate } = source.data; + return buildNodeFieldElement(nodeId, fieldName, fieldTemplate.type); + } + + if (isFormElementDndData(source.data)) { + return source.data.element; + } + + return null; +}; + +const getTargetElement = (target: DropTargetRecord) => { + if (isFormElementDndData(target.data)) { + return target.data.element; + } + + return null; +}; + /** * Singleton hook that monitors for builder dnd events and dispatches actions accordingly. */ @@ -156,20 +200,20 @@ export const useBuilderDndMonitor = () => { useEffect(() => { return monitorForElements({ - canMonitor: ({ source }) => isFormElementDndData(source.data), + canMonitor: ({ source }) => isFormElementDndData(source.data) || isNodeFieldDndData(source.data), onDrop: ({ location, source }) => { const target = location.current.dropTargets[0]; if (!target) { return; } - if (!isFormElementDndData(source.data) || !isFormElementDndData(target.data)) { + const sourceElement = getSourceElement(source); + const targetElement = getTargetElement(target); + + if (!sourceElement || !targetElement) { return; } - const sourceElement = source.data.element; - const targetElement = target.data.element; - if (sourceElement.id === targetElement.id) { // Dropping on self is a no-op return; @@ -359,8 +403,15 @@ export const useFormElementDnd = ( element: draggableElement, // TODO(psyche): This causes a kinda jittery behaviour - need a better heuristic to determine stickiness getIsSticky: () => false, - canDrop: ({ source }) => - isFormElementDndData(source.data) && source.data.element.id !== getElement(elementId).parentId, + canDrop: ({ source }) => { + if (isNodeFieldDndData(source.data)) { + return true; + } + if (isFormElementDndData(source.data)) { + return source.data.element.id !== getElement(elementId).parentId; + } + return false; + }, getData: ({ input }) => { const element = getElement(elementId); @@ -423,8 +474,16 @@ export const useRootElementDropTarget = (droppableRef: RefObject dropTargetForElements({ element: droppableElement, getIsSticky: () => false, - canDrop: ({ source }) => - getElement(rootElementId, isContainerElement).data.children.length === 0 && isFormElementDndData(source.data), + canDrop: ({ source }) => { + const rootElement = getElement(rootElementId, isContainerElement); + if (rootElement.data.children.length !== 0) { + return false; + } + if (isNodeFieldDndData(source.data) || isFormElementDndData(source.data)) { + return true; + } + return false; + }, getData: ({ input }) => { const element = getElement(rootElementId, isContainerElement); @@ -455,7 +514,8 @@ export const useRootElementDropTarget = (droppableRef: RefObject /** * Hook that provides dnd functionality for node fields. * - * @param fieldIdentifier The identifier of the node field + * @param nodeId: The id of the node + * @param fieldName: The name of the field * @param fieldTemplate The template of the node field, required to build the form element * @param draggableRef The ref of the draggable HTML element * @param dragHandleRef The ref of the drag handle HTML element @@ -463,7 +523,8 @@ export const useRootElementDropTarget = (droppableRef: RefObject * @returns Whether the node field is currently being dragged */ export const useNodeFieldDnd = ( - fieldIdentifier: FieldIdentifier, + nodeId: string, + fieldName: string, fieldTemplate: FieldInputTemplate, draggableRef: RefObject, dragHandleRef: RefObject @@ -481,12 +542,7 @@ export const useNodeFieldDnd = ( draggable({ element: draggableElement, dragHandle: dragHandleElement, - getInitialData: () => { - const { nodeId, fieldName } = fieldIdentifier; - const { type } = fieldTemplate; - const element = buildNodeFieldElement(nodeId, fieldName, type); - return buildFormElementDndData(element); - }, + getInitialData: () => buildNodeFieldDndData(nodeId, fieldName, fieldTemplate), onDragStart: () => { setIsDragging(true); }, @@ -495,7 +551,7 @@ export const useNodeFieldDnd = ( }, }) ); - }, [dragHandleRef, draggableRef, fieldIdentifier, fieldTemplate]); + }, [dragHandleRef, draggableRef, fieldName, fieldTemplate, nodeId]); return isDragging; }; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/use-add-node-field-to-root.ts b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/use-add-node-field-to-root.ts index ed2e6b7281..b9a306052a 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/use-add-node-field-to-root.ts +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/builder/use-add-node-field-to-root.ts @@ -1,6 +1,6 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useInputFieldInstance } from 'features/nodes/hooks/useInputFieldInstance'; -import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplate'; +import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplateOrThrow'; import { formElementAdded, selectFormRootElementId } from 'features/nodes/store/workflowSlice'; import { buildNodeFieldElement } from 'features/nodes/types/workflow'; import { useCallback } from 'react'; 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 index 2ffbdb0b9d..83f5713bff 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorDetailsTab.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorDetailsTab.tsx @@ -5,7 +5,7 @@ import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableCon import { InvocationNodeNotesTextarea } from 'features/nodes/components/flow/nodes/Invocation/InvocationNodeNotesTextarea'; import { TemplateGate } from 'features/nodes/components/sidePanel/inspector/NodeTemplateGate'; import { useNodeNeedsUpdate } from 'features/nodes/hooks/useNodeNeedsUpdate'; -import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate'; +import { useNodeTemplateOrThrow } from 'features/nodes/hooks/useNodeTemplateOrThrow'; import { useNodeVersion } from 'features/nodes/hooks/useNodeVersion'; import { selectLastSelectedNodeId } from 'features/nodes/store/selectors'; import { memo } from 'react'; @@ -36,7 +36,7 @@ export default memo(InspectorDetailsTab); const Content = memo(({ nodeId }: { nodeId: string }) => { const { t } = useTranslation(); const version = useNodeVersion(nodeId); - const template = useNodeTemplate(nodeId); + const template = useNodeTemplateOrThrow(nodeId); const needsUpdate = useNodeNeedsUpdate(nodeId); return ( diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorOutputsTab.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorOutputsTab.tsx index d1b5009f8d..8f23f747dc 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorOutputsTab.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorOutputsTab.tsx @@ -5,7 +5,7 @@ import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableCon import DataViewer from 'features/gallery/components/ImageMetadataViewer/DataViewer'; import { TemplateGate } from 'features/nodes/components/sidePanel/inspector/NodeTemplateGate'; import { useNodeExecutionState } from 'features/nodes/hooks/useNodeExecutionState'; -import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate'; +import { useNodeTemplateOrThrow } from 'features/nodes/hooks/useNodeTemplateOrThrow'; import { selectLastSelectedNodeId } from 'features/nodes/store/selectors'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -37,7 +37,7 @@ const getKey = (result: AnyInvocationOutput, i: number) => `${result.type}-${i}` const Content = memo(({ nodeId }: { nodeId: string }) => { const { t } = useTranslation(); - const template = useNodeTemplate(nodeId); + const template = useNodeTemplateOrThrow(nodeId); const nes = useNodeExecutionState(nodeId); if (!nes || nes.outputs.length === 0) { diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorTabEditableNodeTitle.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorTabEditableNodeTitle.tsx index 8431187bd5..e0e1d26827 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorTabEditableNodeTitle.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorTabEditableNodeTitle.tsx @@ -1,8 +1,8 @@ import { Flex, Input, Text } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; import { useEditable } from 'common/hooks/useEditable'; -import { useNodeLabel } from 'features/nodes/hooks/useNodeLabel'; -import { useNodeTemplateTitle } from 'features/nodes/hooks/useNodeTemplateTitle'; +import { useNodeTemplateTitleSafe } from 'features/nodes/hooks/useNodeTemplateTitleSafe'; +import { useNodeUserTitleSafe } from 'features/nodes/hooks/useNodeUserTitleSafe'; import { nodeLabelChanged } from 'features/nodes/store/nodesSlice'; import { memo, useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; @@ -14,8 +14,8 @@ type Props = { const InspectorTabEditableNodeTitle = ({ nodeId, title }: Props) => { const dispatch = useAppDispatch(); - const label = useNodeLabel(nodeId); - const templateTitle = useNodeTemplateTitle(nodeId); + const label = useNodeUserTitleSafe(nodeId); + const templateTitle = useNodeTemplateTitleSafe(nodeId); const { t } = useTranslation(); const inputRef = useRef(null); const onChange = useCallback( diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorTemplateTab.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorTemplateTab.tsx index d92366ce7b..685bd53f8e 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorTemplateTab.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/InspectorTemplateTab.tsx @@ -2,7 +2,7 @@ import { useAppSelector } from 'app/store/storeHooks'; import { IAINoContentFallback } from 'common/components/IAIImageFallback'; import DataViewer from 'features/gallery/components/ImageMetadataViewer/DataViewer'; import { TemplateGate } from 'features/nodes/components/sidePanel/inspector/NodeTemplateGate'; -import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate'; +import { useNodeTemplateOrThrow } from 'features/nodes/hooks/useNodeTemplateOrThrow'; import { selectLastSelectedNodeId } from 'features/nodes/store/selectors'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -29,7 +29,7 @@ export default memo(NodeTemplateInspector); const Content = memo(({ nodeId }: { nodeId: string }) => { const { t } = useTranslation(); - const template = useNodeTemplate(nodeId); + const template = useNodeTemplateOrThrow(nodeId); return ; }); diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/NodeTemplateGate.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/NodeTemplateGate.tsx index 87ea032368..c6028dc1c0 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/NodeTemplateGate.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/inspector/NodeTemplateGate.tsx @@ -1,4 +1,4 @@ -import { useNodeTemplateSafe } from 'features/nodes/hooks/useNodeTemplate'; +import { useNodeTemplateSafe } from 'features/nodes/hooks/useNodeTemplateSafe'; import type { PropsWithChildren, ReactNode } from 'react'; import { memo } from 'react'; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/PublishWorkflowPanelContent.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/PublishWorkflowPanelContent.tsx new file mode 100644 index 0000000000..e54455ad1c --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/PublishWorkflowPanelContent.tsx @@ -0,0 +1,429 @@ +import { + Button, + ButtonGroup, + Divider, + Flex, + ListItem, + Spacer, + Text, + Tooltip, + UnorderedList, +} from '@invoke-ai/ui-library'; +import { useStore } from '@nanostores/react'; +import { logger } from 'app/logging/logger'; +import { $projectUrl } from 'app/store/nanostores/projectId'; +import { useAppSelector } from 'app/store/storeHooks'; +import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent'; +import { withResultAsync } from 'common/util/result'; +import { parseify } from 'common/util/serialize'; +import { ExternalLink } from 'features/gallery/components/ImageViewer/NoContentForViewer'; +import { NodeFieldElementOverlay } from 'features/nodes/components/sidePanel/builder/NodeFieldElementEditMode'; +import { + $isInPublishFlow, + $isReadyToDoValidationRun, + $isSelectingOutputNode, + $outputNodeId, + $validationRunBatchId, + usePublishInputs, +} from 'features/nodes/components/sidePanel/workflow/publish'; +import { useInputFieldTemplateTitleOrThrow } from 'features/nodes/hooks/useInputFieldTemplateTitleOrThrow'; +import { useInputFieldUserTitleOrThrow } from 'features/nodes/hooks/useInputFieldUserTitleOrThrow'; +import { useMouseOverFormField } from 'features/nodes/hooks/useMouseOverNode'; +import { useNodeTemplateTitleOrThrow } from 'features/nodes/hooks/useNodeTemplateTitleOrThrow'; +import { useNodeUserTitleOrThrow } from 'features/nodes/hooks/useNodeUserTitleOrThrow'; +import { useOutputFieldNames } from 'features/nodes/hooks/useOutputFieldNames'; +import { useOutputFieldTemplate } from 'features/nodes/hooks/useOutputFieldTemplate'; +import { useZoomToNode } from 'features/nodes/hooks/useZoomToNode'; +import { selectHasBatchOrGeneratorNodes } from 'features/nodes/store/selectors'; +import { selectIsWorkflowSaved } from 'features/nodes/store/workflowSlice'; +import { useEnqueueWorkflows } from 'features/queue/hooks/useEnqueueWorkflows'; +import { $isReadyToEnqueue } from 'features/queue/store/readiness'; +import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; +import { toast } from 'features/toast/toast'; +import type { PropsWithChildren } from 'react'; +import { memo, useCallback, useMemo } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { PiLightningFill, PiSignOutBold, PiXBold } from 'react-icons/pi'; +import { serializeError } from 'serialize-error'; +import { assert } from 'tsafe'; + +const log = logger('generation'); + +export const PublishWorkflowPanelContent = memo(() => { + return ( + + + + + + + + + + + + + + + + ); +}); +PublishWorkflowPanelContent.displayName = 'PublishWorkflowPanelContent'; + +const OutputFields = memo(() => { + const { t } = useTranslation(); + const outputNodeId = useStore($outputNodeId); + + if (!outputNodeId) { + return ( + + + {t('workflows.builder.noOutputNodeSelected')} + + + ); + } + + return ; +}); +OutputFields.displayName = 'OutputFields'; + +const OutputFieldsContent = memo(({ outputNodeId }: { outputNodeId: string }) => { + const { t } = useTranslation(); + const outputFieldNames = useOutputFieldNames(outputNodeId); + + return ( + + {t('workflows.builder.publishedWorkflowOutputs')} + + {outputFieldNames.map((fieldName) => ( + + ))} + + ); +}); +OutputFieldsContent.displayName = 'OutputFieldsContent'; + +const PublishableInputFields = memo(() => { + const { t } = useTranslation(); + const inputs = usePublishInputs(); + + if (inputs.publishable.length === 0) { + return ( + + + {t('workflows.builder.noPublishableInputs')} + + + ); + } + + return ( + + {t('workflows.builder.publishedWorkflowInputs')} + + {inputs.publishable.map(({ nodeId, fieldName }) => { + return ; + })} + + ); +}); +PublishableInputFields.displayName = 'PublishableInputFields'; + +const UnpublishableInputFields = memo(() => { + const { t } = useTranslation(); + const inputs = usePublishInputs(); + + if (inputs.unpublishable.length === 0) { + return null; + } + + return ( + + + {t('workflows.builder.unpublishableInputs')} + + + {inputs.unpublishable.map(({ nodeId, fieldName }) => { + return ; + })} + + ); +}); +UnpublishableInputFields.displayName = 'UnpublishableInputFields'; + +const SelectOutputNodeButton = memo(() => { + const { t } = useTranslation(); + const outputNodeId = useStore($outputNodeId); + const isSelectingOutputNode = useStore($isSelectingOutputNode); + const onClick = useCallback(() => { + $outputNodeId.set(null); + $isSelectingOutputNode.set(true); + }, []); + return ( + + ); +}); +SelectOutputNodeButton.displayName = 'SelectOutputNodeButton'; + +const CancelPublishButton = memo(() => { + const { t } = useTranslation(); + const onClick = useCallback(() => { + $isInPublishFlow.set(false); + $isSelectingOutputNode.set(false); + $outputNodeId.set(null); + }, []); + return ( + + ); +}); +CancelPublishButton.displayName = 'CancelDeployButton'; + +const PublishWorkflowButton = memo(() => { + const { t } = useTranslation(); + const isReadyToDoValidationRun = useStore($isReadyToDoValidationRun); + const isReadyToEnqueue = useStore($isReadyToEnqueue); + const isWorkflowSaved = useAppSelector(selectIsWorkflowSaved); + const hasBatchOrGeneratorNodes = useAppSelector(selectHasBatchOrGeneratorNodes); + const outputNodeId = useStore($outputNodeId); + const isSelectingOutputNode = useStore($isSelectingOutputNode); + const inputs = usePublishInputs(); + + const projectUrl = useStore($projectUrl); + + const enqueue = useEnqueueWorkflows(); + const onClick = useCallback(async () => { + const result = await withResultAsync(() => enqueue(true, true)); + if (result.isErr()) { + toast({ + id: 'TOAST_PUBLISH_FAILED', + status: 'error', + title: t('workflows.builder.publishFailed'), + description: t('workflows.builder.publishFailedDesc'), + duration: null, + }); + log.error({ error: serializeError(result.error) }, 'Failed to enqueue batch'); + } else { + toast({ + id: 'TOAST_PUBLISH_SUCCESSFUL', + status: 'success', + title: t('workflows.builder.publishSuccess'), + description: ( + , + }} + /> + ), + duration: null, + }); + assert(result.value.enqueueResult.batch.batch_id); + $validationRunBatchId.set(result.value.enqueueResult.batch.batch_id); + log.debug(parseify(result.value), 'Enqueued batch'); + } + }, [enqueue, projectUrl, t]); + + return ( + 0} + hasUnpublishableInputs={inputs.unpublishable.length > 0} + > + + + ); +}); +PublishWorkflowButton.displayName = 'DoValidationRunButton'; + +const NodeInputFieldPreview = memo(({ nodeId, fieldName }: { nodeId: string; fieldName: string }) => { + const mouseOverFormField = useMouseOverFormField(nodeId); + const nodeUserTitle = useNodeUserTitleOrThrow(nodeId); + const nodeTemplateTitle = useNodeTemplateTitleOrThrow(nodeId); + const fieldUserTitle = useInputFieldUserTitleOrThrow(nodeId, fieldName); + const fieldTemplateTitle = useInputFieldTemplateTitleOrThrow(nodeId, fieldName); + const zoomToNode = useZoomToNode(nodeId); + + return ( + + {`${nodeUserTitle || nodeTemplateTitle} -> ${fieldUserTitle || fieldTemplateTitle}`} + {`${nodeId} -> ${fieldName}`} + + + ); +}); +NodeInputFieldPreview.displayName = 'NodeInputFieldPreview'; + +const NodeOutputFieldPreview = memo(({ nodeId, fieldName }: { nodeId: string; fieldName: string }) => { + const mouseOverFormField = useMouseOverFormField(nodeId); + const nodeUserTitle = useNodeUserTitleOrThrow(nodeId); + const nodeTemplateTitle = useNodeTemplateTitleOrThrow(nodeId); + const fieldTemplate = useOutputFieldTemplate(nodeId, fieldName); + const zoomToNode = useZoomToNode(nodeId); + + return ( + + {`${nodeUserTitle || nodeTemplateTitle} -> ${fieldTemplate.title}`} + {`${nodeId} -> ${fieldName}`} + + + ); +}); +NodeOutputFieldPreview.displayName = 'NodeOutputFieldPreview'; + +export const StartPublishFlowButton = memo(() => { + const { t } = useTranslation(); + const publishWorkflowIsEnabled = useFeatureStatus('publishWorkflow'); + const isReadyToEnqueue = useStore($isReadyToEnqueue); + const isWorkflowSaved = useAppSelector(selectIsWorkflowSaved); + const hasBatchOrGeneratorNodes = useAppSelector(selectHasBatchOrGeneratorNodes); + const inputs = usePublishInputs(); + + const onClick = useCallback(() => { + $isInPublishFlow.set(true); + }, []); + + return ( + 0} + hasUnpublishableInputs={inputs.unpublishable.length > 0} + > + + + ); +}); + +StartPublishFlowButton.displayName = 'StartPublishFlowButton'; + +const PublishTooltip = memo( + ({ + isWorkflowSaved, + hasBatchOrGeneratorNodes, + isReadyToEnqueue, + hasOutputNode, + hasPublishableInputs, + hasUnpublishableInputs, + children, + }: PropsWithChildren<{ + isWorkflowSaved: boolean; + hasBatchOrGeneratorNodes: boolean; + isReadyToEnqueue: boolean; + hasOutputNode: boolean; + hasPublishableInputs: boolean; + hasUnpublishableInputs: boolean; + }>) => { + const { t } = useTranslation(); + const warnings = useMemo(() => { + const _warnings: string[] = []; + if (!hasPublishableInputs) { + _warnings.push(t('workflows.builder.warningWorkflowHasNoPublishableInputFields')); + } + if (hasUnpublishableInputs) { + _warnings.push(t('workflows.builder.warningWorkflowHasUnpublishableInputFields')); + } + return _warnings; + }, [hasPublishableInputs, hasUnpublishableInputs, t]); + const errors = useMemo(() => { + const _errors: string[] = []; + if (!isWorkflowSaved) { + _errors.push(t('workflows.builder.errorWorkflowHasUnsavedChanges')); + } + if (hasBatchOrGeneratorNodes) { + _errors.push(t('workflows.builder.errorWorkflowHasBatchOrGeneratorNodes')); + } + if (!isReadyToEnqueue) { + _errors.push(t('workflows.builder.errorWorkflowHasInvalidGraph')); + } + if (!hasOutputNode) { + _errors.push(t('workflows.builder.errorWorkflowHasNoOutputNode')); + } + return _errors; + }, [hasBatchOrGeneratorNodes, hasOutputNode, isReadyToEnqueue, isWorkflowSaved, t]); + + if (errors.length === 0 && warnings.length === 0) { + return children; + } + + return ( + + {errors.length > 0 && ( + <> + + {t('workflows.builder.cannotPublish')}: + + + {errors.map((problem, index) => ( + {problem} + ))} + + + )} + {warnings.length > 0 && ( + <> + + {t('workflows.builder.publishWarnings')}: + + + {warnings.map((problem, index) => ( + {problem} + ))} + + + )} + + } + > + {children} + + ); + } +); +PublishTooltip.displayName = 'PublishTooltip'; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/LockedWorkflowIcon.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/LockedWorkflowIcon.tsx new file mode 100644 index 0000000000..4b08b72bf7 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/LockedWorkflowIcon.tsx @@ -0,0 +1,23 @@ +import { IconButton, Tooltip } from '@invoke-ai/ui-library'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiLockBold } from 'react-icons/pi'; + +export const LockedWorkflowIcon = memo(() => { + const { t } = useTranslation(); + + return ( + + } + /> + + ); +}); + +LockedWorkflowIcon.displayName = 'LockedWorkflowIcon'; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx index 89763cb310..8c52d92959 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibrarySideNav.tsx @@ -26,6 +26,7 @@ import { workflowLibraryTagToggled, workflowLibraryViewChanged, } from 'features/nodes/store/workflowLibrarySlice'; +import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; import { NewWorkflowButton } from 'features/workflowLibrary/components/NewWorkflowButton'; import { UploadWorkflowButton } from 'features/workflowLibrary/components/UploadWorkflowButton'; import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'; @@ -39,13 +40,12 @@ export const WorkflowLibrarySideNav = () => { const { t } = useTranslation(); const categoryOptions = useStore($workflowLibraryCategoriesOptions); const view = useAppSelector(selectWorkflowLibraryView); + const publishWorkflow = useFeatureStatus('publishWorkflow'); return ( - + {t('workflows.recentlyOpened')} - - {t('workflows.yourWorkflows')} {categoryOptions.includes('project') && ( @@ -60,6 +60,9 @@ export const WorkflowLibrarySideNav = () => { )} + {publishWorkflow && ( + {t('workflows.publishedWorkflows')} + )} diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx index 0eb60f490f..61802b37fb 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx @@ -36,6 +36,8 @@ const getCategories = (view: WorkflowLibraryView): WorkflowCategory[] => { return ['user']; case 'shared': return ['project']; + case 'published': + return ['user', 'project', 'default']; default: assert>(false); } @@ -66,6 +68,7 @@ const useInfiniteQueryAry = () => { query: debouncedSearchTerm, tags: view === 'defaults' ? selectedTags : [], has_been_opened: getHasBeenOpened(view), + is_published: view === 'published' ? true : undefined, } satisfies Parameters[0]; }, [orderBy, direction, view, debouncedSearchTerm, selectedTags]); diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx index 005b26f409..f1f27572a9 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx @@ -1,6 +1,7 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Badge, Flex, Icon, Image, Spacer, Text } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { LockedWorkflowIcon } from 'features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/LockedWorkflowIcon'; import { ShareWorkflowButton } from 'features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryListItemActions/ShareWorkflow'; import { selectWorkflowId, workflowModeChanged } from 'features/nodes/store/workflowSlice'; import { useLoadWorkflowWithDialog } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog'; @@ -54,7 +55,6 @@ export const WorkflowListItem = memo(({ workflow }: { workflow: WorkflowRecordLi position="relative" role="button" onClick={handleClickLoad} - cursor="pointer" bg="base.750" borderRadius="base" w="full" @@ -81,7 +81,7 @@ export const WorkflowListItem = memo(({ workflow }: { workflow: WorkflowRecordLi {workflow.name} - {isActive && ( + {isActive && !workflow.is_published && ( )} + {workflow.is_published && ( + + {t('workflows.builder.published')} + + )} {workflow.category === 'project' && } {workflow.category === 'default' && ( )} - {workflow.category === 'default' && } - {workflow.category !== 'default' && ( + {workflow.category === 'default' && !workflow.is_published && ( + + )} + {workflow.category !== 'default' && !workflow.is_published && ( <> @@ -128,6 +142,7 @@ export const WorkflowListItem = memo(({ workflow }: { workflow: WorkflowRecordLi )} {workflow.category === 'project' && } + {workflow.is_published && } diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowPanel.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowPanel.tsx index 5541cacd72..85635d6ff5 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowPanel.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowPanel.tsx @@ -1,5 +1,7 @@ -import { Tab, TabList, TabPanel, TabPanels, Tabs } from '@invoke-ai/ui-library'; +import { Spacer, Tab, TabList, TabPanel, TabPanels, Tabs } from '@invoke-ai/ui-library'; import { WorkflowBuilder } from 'features/nodes/components/sidePanel/builder/WorkflowBuilder'; +import { StartPublishFlowButton } from 'features/nodes/components/sidePanel/workflow/PublishWorkflowPanelContent'; +import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -8,12 +10,15 @@ import WorkflowJSONTab from './WorkflowJSONTab'; const WorkflowFieldsLinearViewPanel = () => { const { t } = useTranslation(); + const publishWorkflowIsEnabled = useFeatureStatus('publishWorkflow'); return ( {t('workflows.builder.builder')} {t('common.details')} JSON + + {publishWorkflowIsEnabled && } diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/publish.ts b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/publish.ts new file mode 100644 index 0000000000..0f5b53ba5b --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/publish.ts @@ -0,0 +1,90 @@ +import { useStore } from '@nanostores/react'; +import { createSelector } from '@reduxjs/toolkit'; +import { skipToken } from '@reduxjs/toolkit/query'; +import { useAppSelector } from 'app/store/storeHooks'; +import { $templates } from 'features/nodes/store/nodesSlice'; +import { selectNodesSlice } from 'features/nodes/store/selectors'; +import type { Templates } from 'features/nodes/store/types'; +import { selectWorkflowFormNodeFieldFieldIdentifiersDeduped } from 'features/nodes/store/workflowSlice'; +import type { FieldIdentifier } from 'features/nodes/types/field'; +import { isBoardFieldType } from 'features/nodes/types/field'; +import { isInvocationNode } from 'features/nodes/types/invocation'; +import { atom, computed } from 'nanostores'; +import { useMemo } from 'react'; +import { useGetBatchStatusQuery } from 'services/api/endpoints/queue'; +import { assert } from 'tsafe'; + +export const $isInPublishFlow = atom(false); +export const $outputNodeId = atom(null); +export const $isSelectingOutputNode = atom(false); +export const $isReadyToDoValidationRun = computed( + [$isInPublishFlow, $outputNodeId, $isSelectingOutputNode], + (isInPublishFlow, outputNodeId, isSelectingOutputNode) => { + return isInPublishFlow && outputNodeId !== null && !isSelectingOutputNode; + } +); +export const $validationRunBatchId = atom(null); + +export const useIsValidationRunInProgress = () => { + const validationRunBatchId = useStore($validationRunBatchId); + const { isValidationRunInProgress } = useGetBatchStatusQuery( + validationRunBatchId ? { batch_id: validationRunBatchId } : skipToken, + { + selectFromResult: ({ currentData }) => { + if (!currentData) { + return { isValidationRunInProgress: false }; + } + if (currentData && currentData.in_progress > 0) { + return { isValidationRunInProgress: true }; + } + return { isValidationRunInProgress: false }; + }, + } + ); + return validationRunBatchId !== null || isValidationRunInProgress; +}; + +export const selectFieldIdentifiersWithInvocationTypes = createSelector( + selectWorkflowFormNodeFieldFieldIdentifiersDeduped, + selectNodesSlice, + (fieldIdentifiers, nodes) => { + const result: { nodeId: string; fieldName: string; type: string }[] = []; + for (const fieldIdentifier of fieldIdentifiers) { + const node = nodes.nodes.find((node) => node.id === fieldIdentifier.nodeId); + assert(isInvocationNode(node), `Node ${fieldIdentifier.nodeId} not found`); + result.push({ nodeId: fieldIdentifier.nodeId, fieldName: fieldIdentifier.fieldName, type: node.data.type }); + } + + return result; + } +); + +export const getPublishInputs = (fieldIdentifiers: (FieldIdentifier & { type: string })[], templates: Templates) => { + // Certain field types are not allowed to be input fields on a published workflow + const publishable: FieldIdentifier[] = []; + const unpublishable: FieldIdentifier[] = []; + for (const fieldIdentifier of fieldIdentifiers) { + const fieldTemplate = templates[fieldIdentifier.type]?.inputs[fieldIdentifier.fieldName]; + if (!fieldTemplate) { + unpublishable.push(fieldIdentifier); + continue; + } + if (isBoardFieldType(fieldTemplate.type)) { + unpublishable.push(fieldIdentifier); + continue; + } + publishable.push(fieldIdentifier); + } + return { publishable, unpublishable }; +}; + +export const usePublishInputs = () => { + const templates = useStore($templates); + const fieldIdentifiersWithInvocationTypes = useAppSelector(selectFieldIdentifiersWithInvocationTypes); + const fieldIdentifiers = useMemo( + () => getPublishInputs(fieldIdentifiersWithInvocationTypes, templates), + [fieldIdentifiersWithInvocationTypes, templates] + ); + + return fieldIdentifiers; +}; diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldDefaultValue.ts b/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldDefaultValue.ts index 68aa97e13e..1c9c929b8c 100644 --- a/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldDefaultValue.ts +++ b/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldDefaultValue.ts @@ -1,6 +1,6 @@ import { createSelector } from '@reduxjs/toolkit'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplate'; +import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplateOrThrow'; import { fieldValueReset } from 'features/nodes/store/nodesSlice'; import { selectNodesSlice } from 'features/nodes/store/selectors'; import { isInvocationNode } from 'features/nodes/types/invocation'; diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldNamesByStatus.ts b/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldNamesByStatus.ts index bab0bbb66a..f665d37e6d 100644 --- a/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldNamesByStatus.ts +++ b/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldNamesByStatus.ts @@ -1,10 +1,11 @@ import { useNodeData } from 'features/nodes/hooks/useNodeData'; -import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate'; import type { FieldInputTemplate } from 'features/nodes/types/field'; import { isSingleOrCollection } from 'features/nodes/types/field'; import { TEMPLATE_BUILDER_MAP } from 'features/nodes/util/schema/buildFieldInputTemplate'; import { useMemo } from 'react'; +import { useNodeTemplateOrThrow } from './useNodeTemplateOrThrow'; + const isConnectionInputField = (field: FieldInputTemplate) => { return ( (field.input === 'connection' && !isSingleOrCollection(field.type)) || !(field.type.name in TEMPLATE_BUILDER_MAP) @@ -19,7 +20,7 @@ const isAnyOrDirectInputField = (field: FieldInputTemplate) => { }; export const useInputFieldNamesMissing = (nodeId: string) => { - const template = useNodeTemplate(nodeId); + const template = useNodeTemplateOrThrow(nodeId); const node = useNodeData(nodeId); const fieldNames = useMemo(() => { const instanceFields = new Set(Object.keys(node.inputs)); @@ -30,7 +31,7 @@ export const useInputFieldNamesMissing = (nodeId: string) => { }; export const useInputFieldNamesAnyOrDirect = (nodeId: string) => { - const template = useNodeTemplate(nodeId); + const template = useNodeTemplateOrThrow(nodeId); const fieldNames = useMemo(() => { const anyOrDirectFields: string[] = []; for (const [fieldName, fieldTemplate] of Object.entries(template.inputs)) { @@ -44,7 +45,7 @@ export const useInputFieldNamesAnyOrDirect = (nodeId: string) => { }; export const useInputFieldNamesConnection = (nodeId: string) => { - const template = useNodeTemplate(nodeId); + const template = useNodeTemplateOrThrow(nodeId); const fieldNames = useMemo(() => { const connectionFields: string[] = []; for (const [fieldName, fieldTemplate] of Object.entries(template.inputs)) { diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldTemplate.ts b/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldTemplateOrThrow.ts similarity index 54% rename from invokeai/frontend/web/src/features/nodes/hooks/useInputFieldTemplate.ts rename to invokeai/frontend/web/src/features/nodes/hooks/useInputFieldTemplateOrThrow.ts index e5c2367bed..2683921c6f 100644 --- a/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldTemplate.ts +++ b/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldTemplateOrThrow.ts @@ -1,8 +1,9 @@ -import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate'; import type { FieldInputTemplate } from 'features/nodes/types/field'; import { useMemo } from 'react'; import { assert } from 'tsafe'; +import { useNodeTemplateOrThrow } from './useNodeTemplateOrThrow'; + /** * Returns the template for a specific input field of a node. * @@ -13,7 +14,7 @@ import { assert } from 'tsafe'; * @throws Will throw an error if the template for the input field is not found. */ export const useInputFieldTemplateOrThrow = (nodeId: string, fieldName: string): FieldInputTemplate => { - const template = useNodeTemplate(nodeId); + const template = useNodeTemplateOrThrow(nodeId); const fieldTemplate = useMemo(() => { const _fieldTemplate = template.inputs[fieldName]; assert(_fieldTemplate, `Template for input field ${fieldName} not found.`); @@ -21,17 +22,3 @@ export const useInputFieldTemplateOrThrow = (nodeId: string, fieldName: string): }, [fieldName, template.inputs]); return fieldTemplate; }; - -/** - * Returns the template for a specific input field of a node. - * - * **Note:** This function is a safe version of `useInputFieldTemplate` and will not throw an error if the template is not found. - * - * @param nodeId - The ID of the node. - * @param fieldName - The name of the input field. - */ -export const useInputFieldTemplateSafe = (nodeId: string, fieldName: string): FieldInputTemplate | null => { - const template = useNodeTemplate(nodeId); - const fieldTemplate = useMemo(() => template.inputs[fieldName] ?? null, [fieldName, template.inputs]); - return fieldTemplate; -}; diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldTemplateSafe.ts b/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldTemplateSafe.ts new file mode 100644 index 0000000000..0dd761919c --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldTemplateSafe.ts @@ -0,0 +1,17 @@ +import { useNodeTemplateSafe } from 'features/nodes/hooks/useNodeTemplateSafe'; +import type { FieldInputTemplate } from 'features/nodes/types/field'; +import { useMemo } from 'react'; + +/** + * Returns the template for a specific input field of a node. + * + * **Note:** This function is a safe version of `useInputFieldTemplate` and will not throw an error if the template is not found. + * + * @param nodeId - The ID of the node. + * @param fieldName - The name of the input field. + */ +export const useInputFieldTemplateSafe = (nodeId: string, fieldName: string): FieldInputTemplate | null => { + const template = useNodeTemplateSafe(nodeId); + const fieldTemplate = useMemo(() => template?.inputs[fieldName] ?? null, [fieldName, template?.inputs]); + return fieldTemplate; +}; diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldTemplateTitle.ts b/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldTemplateTitleOrThrow.ts similarity index 59% rename from invokeai/frontend/web/src/features/nodes/hooks/useInputFieldTemplateTitle.ts rename to invokeai/frontend/web/src/features/nodes/hooks/useInputFieldTemplateTitleOrThrow.ts index 319c219faf..2c1aa1ca1c 100644 --- a/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldTemplateTitle.ts +++ b/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldTemplateTitleOrThrow.ts @@ -1,9 +1,10 @@ -import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate'; import { useMemo } from 'react'; import { assert } from 'tsafe'; -export const useInputFieldTemplateTitle = (nodeId: string, fieldName: string): string => { - const template = useNodeTemplate(nodeId); +import { useNodeTemplateOrThrow } from './useNodeTemplateOrThrow'; + +export const useInputFieldTemplateTitleOrThrow = (nodeId: string, fieldName: string): string => { + const template = useNodeTemplateOrThrow(nodeId); const title = useMemo(() => { const fieldTemplate = template.inputs[fieldName]; diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldDescriptionSafe.ts b/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldUserDescriptionSafe.ts similarity index 89% rename from invokeai/frontend/web/src/features/nodes/hooks/useInputFieldDescriptionSafe.ts rename to invokeai/frontend/web/src/features/nodes/hooks/useInputFieldUserDescriptionSafe.ts index 0aef7ddd7e..18ebcf0869 100644 --- a/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldDescriptionSafe.ts +++ b/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldUserDescriptionSafe.ts @@ -11,7 +11,7 @@ import { useMemo } from 'react'; * @param nodeId The ID of the node * @param fieldName The name of the field */ -export const useInputFieldDescriptionSafe = (nodeId: string, fieldName: string) => { +export const useInputFieldUserDescriptionSafe = (nodeId: string, fieldName: string) => { const selector = useMemo( () => createSelector( diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldUserTitleOrThrow.ts b/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldUserTitleOrThrow.ts new file mode 100644 index 0000000000..f4db341a60 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldUserTitleOrThrow.ts @@ -0,0 +1,23 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { useAppSelector } from 'app/store/storeHooks'; +import { selectFieldInputInstance, selectNodesSlice } from 'features/nodes/store/selectors'; +import { useMemo } from 'react'; + +/** + * Gets the user-defined title of an input field for a given node. + * + * If the node doesn't exist or is not an invocation node, an error is thrown. + * + * @param nodeId The ID of the node + * @param fieldName The name of the field + */ +export const useInputFieldUserTitleOrThrow = (nodeId: string, fieldName: string): string => { + const selector = useMemo( + () => createSelector(selectNodesSlice, (nodes) => selectFieldInputInstance(nodes, nodeId, fieldName).label), + [fieldName, nodeId] + ); + + const title = useAppSelector(selector); + + return title; +}; diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldLabelSafe.ts b/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldUserTitleSafe.ts similarity index 74% rename from invokeai/frontend/web/src/features/nodes/hooks/useInputFieldLabelSafe.ts rename to invokeai/frontend/web/src/features/nodes/hooks/useInputFieldUserTitleSafe.ts index 8ee6d8716b..177357ff74 100644 --- a/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldLabelSafe.ts +++ b/invokeai/frontend/web/src/features/nodes/hooks/useInputFieldUserTitleSafe.ts @@ -4,21 +4,21 @@ import { selectFieldInputInstanceSafe, selectNodesSlice } from 'features/nodes/s import { useMemo } from 'react'; /** - * Gets the user-defined label of an input field for a given node. + * Gets the user-defined title of an input field for a given node. * * If the node doesn't exist or is not an invocation node, an empty string is returned. * * @param nodeId The ID of the node * @param fieldName The name of the field */ -export const useInputFieldLabelSafe = (nodeId: string, fieldName: string): string => { +export const useInputFieldUserTitleSafe = (nodeId: string, fieldName: string): string => { const selector = useMemo( () => createSelector(selectNodesSlice, (nodes) => selectFieldInputInstanceSafe(nodes, nodeId, fieldName)?.label ?? ''), [fieldName, nodeId] ); - const label = useAppSelector(selector); + const title = useAppSelector(selector); - return label; + return title; }; diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useIsBatchNode.ts b/invokeai/frontend/web/src/features/nodes/hooks/useIsBatchNode.ts index 6c4d29f58a..2daaf4a11d 100644 --- a/invokeai/frontend/web/src/features/nodes/hooks/useIsBatchNode.ts +++ b/invokeai/frontend/web/src/features/nodes/hooks/useIsBatchNode.ts @@ -1,9 +1,10 @@ -import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate'; import { isBatchNodeType, isGeneratorNodeType } from 'features/nodes/types/invocation'; import { useMemo } from 'react'; +import { useNodeTemplateOrThrow } from './useNodeTemplateOrThrow'; + export const useIsExecutableNode = (nodeId: string) => { - const template = useNodeTemplate(nodeId); + const template = useNodeTemplateOrThrow(nodeId); const isExecutableNode = useMemo( () => !isBatchNodeType(template.type) && !isGeneratorNodeType(template.type), [template] diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useIsWorkflowEditorLocked.ts b/invokeai/frontend/web/src/features/nodes/hooks/useIsWorkflowEditorLocked.ts new file mode 100644 index 0000000000..fae669adb7 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/hooks/useIsWorkflowEditorLocked.ts @@ -0,0 +1,13 @@ +import { useStore } from '@nanostores/react'; +import { useAppSelector } from 'app/store/storeHooks'; +import { $isInPublishFlow, useIsValidationRunInProgress } from 'features/nodes/components/sidePanel/workflow/publish'; +import { selectWorkflowIsPublished } from 'features/nodes/store/workflowSlice'; + +export const useIsWorkflowEditorLocked = () => { + const isInPublishFlow = useStore($isInPublishFlow); + const isPublished = useAppSelector(selectWorkflowIsPublished); + const isValidationRunInProgress = useIsValidationRunInProgress(); + + const isLocked = isInPublishFlow || isPublished || isValidationRunInProgress; + return isLocked; +}; diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useNodeClassification.ts b/invokeai/frontend/web/src/features/nodes/hooks/useNodeClassification.ts index 75431c949f..770d04462b 100644 --- a/invokeai/frontend/web/src/features/nodes/hooks/useNodeClassification.ts +++ b/invokeai/frontend/web/src/features/nodes/hooks/useNodeClassification.ts @@ -1,9 +1,10 @@ -import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate'; import type { Classification } from 'features/nodes/types/common'; import { useMemo } from 'react'; +import { useNodeTemplateOrThrow } from './useNodeTemplateOrThrow'; + export const useNodeClassification = (nodeId: string): Classification => { - const template = useNodeTemplate(nodeId); + const template = useNodeTemplateOrThrow(nodeId); const classification = useMemo(() => template.classification, [template]); return classification; }; diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useNodeHasImageOutput.ts b/invokeai/frontend/web/src/features/nodes/hooks/useNodeHasImageOutput.ts index 5855f5542b..9407fb00ff 100644 --- a/invokeai/frontend/web/src/features/nodes/hooks/useNodeHasImageOutput.ts +++ b/invokeai/frontend/web/src/features/nodes/hooks/useNodeHasImageOutput.ts @@ -1,9 +1,10 @@ -import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate'; import { some } from 'lodash-es'; import { useMemo } from 'react'; +import { useNodeTemplateOrThrow } from './useNodeTemplateOrThrow'; + export const useNodeHasImageOutput = (nodeId: string): boolean => { - const template = useNodeTemplate(nodeId); + const template = useNodeTemplateOrThrow(nodeId); const hasImageOutput = useMemo( () => some( diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useNodeNeedsUpdate.ts b/invokeai/frontend/web/src/features/nodes/hooks/useNodeNeedsUpdate.ts index 71c2138276..ae42f8fbd3 100644 --- a/invokeai/frontend/web/src/features/nodes/hooks/useNodeNeedsUpdate.ts +++ b/invokeai/frontend/web/src/features/nodes/hooks/useNodeNeedsUpdate.ts @@ -1,12 +1,13 @@ -import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate'; import { useNodeType } from 'features/nodes/hooks/useNodeType'; import { useNodeVersion } from 'features/nodes/hooks/useNodeVersion'; import { useMemo } from 'react'; +import { useNodeTemplateOrThrow } from './useNodeTemplateOrThrow'; + export const useNodeNeedsUpdate = (nodeId: string) => { const type = useNodeType(nodeId); const version = useNodeVersion(nodeId); - const template = useNodeTemplate(nodeId); + const template = useNodeTemplateOrThrow(nodeId); const needsUpdate = useMemo(() => { if (type !== template.type) { return true; diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useNodeTemplate.ts b/invokeai/frontend/web/src/features/nodes/hooks/useNodeTemplateOrThrow.ts similarity index 63% rename from invokeai/frontend/web/src/features/nodes/hooks/useNodeTemplate.ts rename to invokeai/frontend/web/src/features/nodes/hooks/useNodeTemplateOrThrow.ts index 0b15324b2c..dfad641b4a 100644 --- a/invokeai/frontend/web/src/features/nodes/hooks/useNodeTemplate.ts +++ b/invokeai/frontend/web/src/features/nodes/hooks/useNodeTemplateOrThrow.ts @@ -5,7 +5,7 @@ import type { InvocationTemplate } from 'features/nodes/types/invocation'; import { useMemo } from 'react'; import { assert } from 'tsafe'; -export const useNodeTemplate = (nodeId: string): InvocationTemplate => { +export const useNodeTemplateOrThrow = (nodeId: string): InvocationTemplate => { const templates = useStore($templates); const type = useNodeType(nodeId); const template = useMemo(() => { @@ -15,10 +15,3 @@ export const useNodeTemplate = (nodeId: string): InvocationTemplate => { }, [templates, type]); return template; }; - -export const useNodeTemplateSafe = (nodeId: string): InvocationTemplate | null => { - const templates = useStore($templates); - const type = useNodeType(nodeId); - const template = useMemo(() => templates[type] ?? null, [templates, type]); - return template; -}; diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useNodeTemplateSafe.ts b/invokeai/frontend/web/src/features/nodes/hooks/useNodeTemplateSafe.ts new file mode 100644 index 0000000000..bbe8e9d775 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/hooks/useNodeTemplateSafe.ts @@ -0,0 +1,12 @@ +import { useStore } from '@nanostores/react'; +import { useNodeType } from 'features/nodes/hooks/useNodeType'; +import { $templates } from 'features/nodes/store/nodesSlice'; +import type { InvocationTemplate } from 'features/nodes/types/invocation'; +import { useMemo } from 'react'; + +export const useNodeTemplateSafe = (nodeId: string): InvocationTemplate | null => { + const templates = useStore($templates); + const type = useNodeType(nodeId); + const template = useMemo(() => templates[type] ?? null, [templates, type]); + return template; +}; diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useNodeTemplateTitleOrThrow.ts b/invokeai/frontend/web/src/features/nodes/hooks/useNodeTemplateTitleOrThrow.ts new file mode 100644 index 0000000000..a8b737e141 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/hooks/useNodeTemplateTitleOrThrow.ts @@ -0,0 +1,25 @@ +import { useStore } from '@nanostores/react'; +import { createSelector } from '@reduxjs/toolkit'; +import { useAppSelector } from 'app/store/storeHooks'; +import { $templates } from 'features/nodes/store/nodesSlice'; +import { selectNodesSlice } from 'features/nodes/store/selectors'; +import { isInvocationNode } from 'features/nodes/types/invocation'; +import { useMemo } from 'react'; +import { assert } from 'tsafe'; + +export const useNodeTemplateTitleOrThrow = (nodeId: string): string => { + const templates = useStore($templates); + const selector = useMemo( + () => + createSelector(selectNodesSlice, (nodesSlice) => { + const node = nodesSlice.nodes.find((node) => node.id === nodeId); + assert(isInvocationNode(node), 'Node not found'); + const template = templates[node.data.type]; + assert(template, 'Template not found'); + return template.title; + }), + [nodeId, templates] + ); + const title = useAppSelector(selector); + return title; +}; diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useNodeTemplateTitle.ts b/invokeai/frontend/web/src/features/nodes/hooks/useNodeTemplateTitleSafe.ts similarity index 91% rename from invokeai/frontend/web/src/features/nodes/hooks/useNodeTemplateTitle.ts rename to invokeai/frontend/web/src/features/nodes/hooks/useNodeTemplateTitleSafe.ts index 9855424ee3..2503d79a94 100644 --- a/invokeai/frontend/web/src/features/nodes/hooks/useNodeTemplateTitle.ts +++ b/invokeai/frontend/web/src/features/nodes/hooks/useNodeTemplateTitleSafe.ts @@ -6,7 +6,7 @@ import { selectNodesSlice } from 'features/nodes/store/selectors'; import { isInvocationNode } from 'features/nodes/types/invocation'; import { useMemo } from 'react'; -export const useNodeTemplateTitle = (nodeId: string): string | null => { +export const useNodeTemplateTitleSafe = (nodeId: string): string | null => { const templates = useStore($templates); const selector = useMemo( () => diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useNodeUserTitleOrThrow.ts b/invokeai/frontend/web/src/features/nodes/hooks/useNodeUserTitleOrThrow.ts new file mode 100644 index 0000000000..ed5688fbe9 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/hooks/useNodeUserTitleOrThrow.ts @@ -0,0 +1,21 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { useAppSelector } from 'app/store/storeHooks'; +import { selectNodesSlice } from 'features/nodes/store/selectors'; +import { isInvocationNode } from 'features/nodes/types/invocation'; +import { useMemo } from 'react'; +import { assert } from 'tsafe'; + +export const useNodeUserTitleOrThrow = (nodeId: string) => { + const selector = useMemo( + () => + createSelector(selectNodesSlice, (nodesSlice) => { + const node = nodesSlice.nodes.find((node) => node.id === nodeId); + assert(isInvocationNode(node), 'Node not found'); + return node.data.label; + }), + [nodeId] + ); + + const title = useAppSelector(selector); + return title; +}; diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useNodeLabel.ts b/invokeai/frontend/web/src/features/nodes/hooks/useNodeUserTitleSafe.ts similarity index 72% rename from invokeai/frontend/web/src/features/nodes/hooks/useNodeLabel.ts rename to invokeai/frontend/web/src/features/nodes/hooks/useNodeUserTitleSafe.ts index cd589d6240..60679128b7 100644 --- a/invokeai/frontend/web/src/features/nodes/hooks/useNodeLabel.ts +++ b/invokeai/frontend/web/src/features/nodes/hooks/useNodeUserTitleSafe.ts @@ -3,16 +3,16 @@ import { useAppSelector } from 'app/store/storeHooks'; import { selectNodesSlice } from 'features/nodes/store/selectors'; import { useMemo } from 'react'; -export const useNodeLabel = (nodeId: string) => { +export const useNodeUserTitleSafe = (nodeId: string) => { const selector = useMemo( () => createSelector(selectNodesSlice, (nodesSlice) => { const node = nodesSlice.nodes.find((node) => node.id === nodeId); - return node?.data.label; + return node?.data.label ?? null; }), [nodeId] ); - const label = useAppSelector(selector); - return label; + const title = useAppSelector(selector); + return title; }; diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useOutputFieldNames.ts b/invokeai/frontend/web/src/features/nodes/hooks/useOutputFieldNames.ts index b19d20ab80..8a38377d01 100644 --- a/invokeai/frontend/web/src/features/nodes/hooks/useOutputFieldNames.ts +++ b/invokeai/frontend/web/src/features/nodes/hooks/useOutputFieldNames.ts @@ -1,10 +1,11 @@ -import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate'; import { getSortedFilteredFieldNames } from 'features/nodes/util/node/getSortedFilteredFieldNames'; import { map } from 'lodash-es'; import { useMemo } from 'react'; +import { useNodeTemplateOrThrow } from './useNodeTemplateOrThrow'; + export const useOutputFieldNames = (nodeId: string): string[] => { - const template = useNodeTemplate(nodeId); + const template = useNodeTemplateOrThrow(nodeId); const fieldNames = useMemo(() => getSortedFilteredFieldNames(map(template.outputs)), [template.outputs]); return fieldNames; }; diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useOutputFieldTemplate.ts b/invokeai/frontend/web/src/features/nodes/hooks/useOutputFieldTemplate.ts index d30b4a8bb5..8554fdf12d 100644 --- a/invokeai/frontend/web/src/features/nodes/hooks/useOutputFieldTemplate.ts +++ b/invokeai/frontend/web/src/features/nodes/hooks/useOutputFieldTemplate.ts @@ -1,10 +1,11 @@ -import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate'; import type { FieldOutputTemplate } from 'features/nodes/types/field'; import { useMemo } from 'react'; import { assert } from 'tsafe'; +import { useNodeTemplateOrThrow } from './useNodeTemplateOrThrow'; + export const useOutputFieldTemplate = (nodeId: string, fieldName: string): FieldOutputTemplate => { - const template = useNodeTemplate(nodeId); + const template = useNodeTemplateOrThrow(nodeId); const fieldTemplate = useMemo(() => { const _fieldTemplate = template.outputs[fieldName]; assert(_fieldTemplate, `Template for output field ${fieldName} not found`); diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useZoomToNode.ts b/invokeai/frontend/web/src/features/nodes/hooks/useZoomToNode.ts index f306bfbe02..a57f380559 100644 --- a/invokeai/frontend/web/src/features/nodes/hooks/useZoomToNode.ts +++ b/invokeai/frontend/web/src/features/nodes/hooks/useZoomToNode.ts @@ -4,14 +4,14 @@ import { useCallback } from 'react'; const log = logger('workflows'); -export const useZoomToNode = () => { - const zoomToNode = useCallback((nodeId: string) => { +export const useZoomToNode = (nodeId: string) => { + const zoomToNode = useCallback(() => { const flow = $flow.get(); if (!flow) { log.warn('No flow instance found, cannot zoom to node'); return; } flow.fitView({ duration: 300, maxZoom: 1.5, nodes: [{ id: nodeId }] }); - }, []); + }, [nodeId]); return zoomToNode; }; diff --git a/invokeai/frontend/web/src/features/nodes/store/selectors.ts b/invokeai/frontend/web/src/features/nodes/store/selectors.ts index 99decb90ff..7994b12b34 100644 --- a/invokeai/frontend/web/src/features/nodes/store/selectors.ts +++ b/invokeai/frontend/web/src/features/nodes/store/selectors.ts @@ -4,7 +4,7 @@ import type { RootState } from 'app/store/store'; import type { NodesState } from 'features/nodes/store/types'; import type { FieldInputInstance } from 'features/nodes/types/field'; import type { AnyNode, InvocationNode, InvocationNodeData } from 'features/nodes/types/invocation'; -import { isInvocationNode } from 'features/nodes/types/invocation'; +import { isBatchNode, isGeneratorNode, isInvocationNode } from 'features/nodes/types/invocation'; import { assert } from 'tsafe'; export const selectNode = (nodesSlice: NodesState, nodeId: string): AnyNode => { @@ -81,3 +81,7 @@ export const selectMayRedo = createSelector( (state: RootState) => state.nodes, (nodes) => nodes.future.length > 0 ); + +export const selectHasBatchOrGeneratorNodes = createSelector(selectNodes, (nodes) => + nodes.filter(isInvocationNode).some((node) => isBatchNode(node) || isGeneratorNode(node)) +); diff --git a/invokeai/frontend/web/src/features/nodes/store/workflowLibrarySlice.ts b/invokeai/frontend/web/src/features/nodes/store/workflowLibrarySlice.ts index 907b905a73..e2cf6f7e26 100644 --- a/invokeai/frontend/web/src/features/nodes/store/workflowLibrarySlice.ts +++ b/invokeai/frontend/web/src/features/nodes/store/workflowLibrarySlice.ts @@ -5,7 +5,7 @@ import type { WorkflowCategory } from 'features/nodes/types/workflow'; import { atom, computed } from 'nanostores'; import type { SQLiteDirection, WorkflowRecordOrderBy } from 'services/api/types'; -export type WorkflowLibraryView = 'recent' | 'yours' | 'private' | 'shared' | 'defaults'; +export type WorkflowLibraryView = 'recent' | 'yours' | 'private' | 'shared' | 'defaults' | 'published'; type WorkflowLibraryState = { view: WorkflowLibraryView; diff --git a/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts b/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts index fafb121cd4..c9b1d33915 100644 --- a/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts +++ b/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts @@ -33,7 +33,7 @@ import { isNodeFieldElement, isTextElement, } from 'features/nodes/types/workflow'; -import { isEqual } from 'lodash-es'; +import { isEqual, uniqBy } from 'lodash-es'; import { useMemo } from 'react'; import { selectNodesSlice } from './selectors'; @@ -67,8 +67,11 @@ const getBlankWorkflow = (): Omit => { notes: '', exposedFields: [], meta: { version: '3.0.0', category: 'user' }, - id: undefined, form: getDefaultForm(), + // Even though these values are `undefined`, the keys _must_ be present for the presistence layer to rehydrate + // them correctly. It uses a merge strategy that relies on the keys being present. + id: undefined, + is_published: undefined, }; }; @@ -123,6 +126,9 @@ export const workflowSlice = createSlice({ workflowIDChanged: (state, action: PayloadAction) => { state.id = action.payload; }, + workflowIsPublishedChanged(state, action: PayloadAction) { + state.is_published = action.payload; + }, workflowSaved: (state) => { state.isTouched = false; }, @@ -285,6 +291,7 @@ export const { workflowVersionChanged, workflowContactChanged, workflowIDChanged, + workflowIsPublishedChanged, workflowSaved, formReset, formElementAdded, @@ -350,6 +357,10 @@ export const selectWorkflowMode = createWorkflowSelector((workflow) => workflow. export const selectWorkflowIsTouched = createWorkflowSelector((workflow) => workflow.isTouched); export const selectWorkflowDescription = createWorkflowSelector((workflow) => workflow.description); export const selectWorkflowForm = createWorkflowSelector((workflow) => workflow.form); +export const selectWorkflowIsPublished = createWorkflowSelector((workflow) => workflow.is_published); +export const selectIsWorkflowSaved = createSelector(selectWorkflowId, selectWorkflowIsTouched, (id, isTouched) => { + return id !== undefined && !isTouched; +}); export const selectCleanEditor = createSelector([selectNodesSlice, selectWorkflowSlice], (nodes, workflow) => { const noNodes = !nodes.nodes.length; @@ -375,6 +386,14 @@ export const selectFormInitialValues = createWorkflowSelector((workflow) => work export const selectNodeFieldElements = createWorkflowSelector((workflow) => Object.values(workflow.form.elements).filter(isNodeFieldElement) ); +export const selectWorkflowFormNodeFieldFieldIdentifiersDeduped = createSelector( + selectNodeFieldElements, + (nodeFieldElements) => + uniqBy(nodeFieldElements, (el) => `${el.data.fieldIdentifier.nodeId}-${el.data.fieldIdentifier.fieldName}`).map( + (el) => el.data.fieldIdentifier + ) +); + const buildSelectElement = (id: string) => createWorkflowSelector((workflow) => workflow.form?.elements[id]); export const useElement = (id: string): FormElement | undefined => { const selector = useMemo(() => buildSelectElement(id), [id]); diff --git a/invokeai/frontend/web/src/features/nodes/types/field.ts b/invokeai/frontend/web/src/features/nodes/types/field.ts index 90fec2d464..41914dc279 100644 --- a/invokeai/frontend/web/src/features/nodes/types/field.ts +++ b/invokeai/frontend/web/src/features/nodes/types/field.ts @@ -153,6 +153,9 @@ const zBoardFieldType = zFieldTypeBase.extend({ name: z.literal('BoardField'), originalType: zStatelessFieldType.optional(), }); +export const isBoardFieldType = (fieldType: FieldType): fieldType is z.infer => + fieldType.name === zBoardFieldType.shape.name.value; + const zColorFieldType = zFieldTypeBase.extend({ name: z.literal('ColorField'), originalType: zStatelessFieldType.optional(), diff --git a/invokeai/frontend/web/src/features/nodes/types/invocation.ts b/invokeai/frontend/web/src/features/nodes/types/invocation.ts index 9a70812c29..f3d864aa63 100644 --- a/invokeai/frontend/web/src/features/nodes/types/invocation.ts +++ b/invokeai/frontend/web/src/features/nodes/types/invocation.ts @@ -100,7 +100,7 @@ export const isGeneratorNodeType = (type: string) => export const isBatchNode = (node: InvocationNode) => isBatchNodeType(node.data.type); -const isGeneratorNode = (node: InvocationNode) => isGeneratorNodeType(node.data.type); +export const isGeneratorNode = (node: InvocationNode) => isGeneratorNodeType(node.data.type); export const isExecutableNode = (node: InvocationNode) => { return !isBatchNode(node) && !isGeneratorNode(node); diff --git a/invokeai/frontend/web/src/features/nodes/types/workflow.test-d.ts b/invokeai/frontend/web/src/features/nodes/types/workflow.test-d.ts index 071417dc2e..c6804a56c0 100644 --- a/invokeai/frontend/web/src/features/nodes/types/workflow.test-d.ts +++ b/invokeai/frontend/web/src/features/nodes/types/workflow.test-d.ts @@ -3,6 +3,7 @@ import type { WorkflowCategory, WorkflowV3, XYPosition } from 'features/nodes/ty import type { S } from 'services/api/types'; import type { Equals, Extends } from 'tsafe'; import { assert } from 'tsafe'; +import type { SetRequired } from 'type-fest'; import { describe, test } from 'vitest'; /** @@ -13,6 +14,5 @@ import { describe, test } from 'vitest'; describe('Workflow types', () => { test('XYPosition', () => assert>()); test('WorkflowCategory', () => assert>()); - // @ts-expect-error TODO(psyche): Need to revise server types! - test('WorkflowV3', () => assert>()); + test('WorkflowV3', () => assert, S['Workflow']>>()); }); diff --git a/invokeai/frontend/web/src/features/nodes/types/workflow.ts b/invokeai/frontend/web/src/features/nodes/types/workflow.ts index 3a852d6deb..77576d882e 100644 --- a/invokeai/frontend/web/src/features/nodes/types/workflow.ts +++ b/invokeai/frontend/web/src/features/nodes/types/workflow.ts @@ -377,6 +377,7 @@ export const zWorkflowV3 = z.object({ }), // Use the validated form schema! form: zValidatedBuilderForm, + is_published: z.boolean().nullish(), }); export type WorkflowV3 = z.infer; // #endregion diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/buildLinearBatchConfig.ts b/invokeai/frontend/web/src/features/nodes/util/graph/buildLinearBatchConfig.ts index 665efeeefd..94b0a1e5a7 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/buildLinearBatchConfig.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/buildLinearBatchConfig.ts @@ -3,7 +3,7 @@ import { generateSeeds } from 'common/util/generateSeeds'; import type { Graph } from 'features/nodes/util/graph/generation/Graph'; import { range } from 'lodash-es'; import type { components } from 'services/api/schema'; -import type { Batch, BatchConfig, Invocation } from 'services/api/types'; +import type { Batch, EnqueueBatchArg, Invocation } from 'services/api/types'; export const prepareLinearUIBatch = ( state: RootState, @@ -13,7 +13,7 @@ export const prepareLinearUIBatch = ( posCond: Invocation<'compel' | 'sdxl_compel_prompt' | 'flux_text_encoder' | 'sd3_text_encoder'>, origin: 'canvas' | 'workflows' | 'upscaling', destination: 'canvas' | 'gallery' -): BatchConfig => { +): EnqueueBatchArg => { const { iterations, model, shouldRandomizeSeed, seed, shouldConcatPrompts } = state.params; const { prompts, seedBehaviour } = state.dynamicPrompts; @@ -99,7 +99,7 @@ export const prepareLinearUIBatch = ( data.push(firstBatchDatumList); - const enqueueBatchArg: BatchConfig = { + const enqueueBatchArg: EnqueueBatchArg = { prepend, batch: { graph: g.getGraph(), diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.ts index 0f86341ea3..6926b5ed82 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/buildNodesGraph.ts @@ -37,7 +37,7 @@ const getBoardField = (field: BoardFieldInputInstance, state: RootState): BoardF /** * Builds a graph from the node editor state. */ -export const buildNodesGraph = (state: RootState, templates: Templates): Graph => { +export const buildNodesGraph = (state: RootState, templates: Templates): Required => { const { nodes, edges } = selectNodesSlice(state); // Exclude all batch nodes - we will handle these in the batch setup in a diff function diff --git a/invokeai/frontend/web/src/features/nodes/util/workflow/migrations.ts b/invokeai/frontend/web/src/features/nodes/util/workflow/migrations.ts index 47dc48e748..dc584ee4f5 100644 --- a/invokeai/frontend/web/src/features/nodes/util/workflow/migrations.ts +++ b/invokeai/frontend/web/src/features/nodes/util/workflow/migrations.ts @@ -78,6 +78,8 @@ const migrateV2toV3 = (workflowToMigrate: WorkflowV2): WorkflowV3 => { /** * Parses a workflow and migrates it to the latest version if necessary. + * + * This function will return a new workflow object, so the original workflow is not modified. */ export const parseAndMigrateWorkflow = (data: unknown): WorkflowV3 => { const workflowVersionResult = zWorkflowMetaVersion.safeParse(data); diff --git a/invokeai/frontend/web/src/features/queue/components/InvokeButtonTooltip/InvokeButtonTooltip.tsx b/invokeai/frontend/web/src/features/queue/components/InvokeButtonTooltip/InvokeButtonTooltip.tsx index d5f608e97e..d5eb9e3032 100644 --- a/invokeai/frontend/web/src/features/queue/components/InvokeButtonTooltip/InvokeButtonTooltip.tsx +++ b/invokeai/frontend/web/src/features/queue/components/InvokeButtonTooltip/InvokeButtonTooltip.tsx @@ -6,8 +6,10 @@ import { selectSendToCanvas } from 'features/controlLayers/store/canvasSettingsS import { selectIterations } from 'features/controlLayers/store/paramsSlice'; import { selectDynamicPromptsIsLoading } from 'features/dynamicPrompts/store/dynamicPromptsSlice'; import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors'; +import { $isInPublishFlow } from 'features/nodes/components/sidePanel/workflow/publish'; import { selectNodesSlice } from 'features/nodes/store/selectors'; import type { NodesState } from 'features/nodes/store/types'; +import { selectWorkflowIsPublished } from 'features/nodes/store/workflowSlice'; import type { BatchSizeResult } from 'features/nodes/util/node/resolveBatchValue'; import { getBatchSize } from 'features/nodes/util/node/resolveBatchValue'; import type { Reason } from 'features/queue/store/readiness'; @@ -175,6 +177,8 @@ const IsReadyText = memo(({ isReady, prepend }: { isReady: boolean; prepend: boo const { t } = useTranslation(); const isLoadingDynamicPrompts = useAppSelector(selectDynamicPromptsIsLoading); const [_, enqueueMutation] = useEnqueueBatchMutation(enqueueMutationFixedCacheKeyOptions); + const isInPublishFlow = useStore($isInPublishFlow); + const isPublished = useAppSelector(selectWorkflowIsPublished); const text = useMemo(() => { if (enqueueMutation.isLoading) { @@ -183,6 +187,12 @@ const IsReadyText = memo(({ isReady, prepend }: { isReady: boolean; prepend: boo if (isLoadingDynamicPrompts) { return t('dynamicPrompts.loading'); } + if (isInPublishFlow) { + return t('workflows.builder.publishInProgress'); + } + if (isPublished) { + return t('workflows.builder.publishedWorkflowIsLocked'); + } if (isReady) { if (prepend) { return t('queue.queueFront'); @@ -190,7 +200,7 @@ const IsReadyText = memo(({ isReady, prepend }: { isReady: boolean; prepend: boo return t('queue.queueBack'); } return t('queue.notReady'); - }, [enqueueMutation.isLoading, isLoadingDynamicPrompts, isReady, prepend, t]); + }, [enqueueMutation.isLoading, isLoadingDynamicPrompts, isInPublishFlow, isPublished, isReady, t, prepend]); return {text}; }); diff --git a/invokeai/frontend/web/src/features/queue/components/InvokeQueueBackButton.tsx b/invokeai/frontend/web/src/features/queue/components/InvokeQueueBackButton.tsx index 33863d6634..b175e4d8b0 100644 --- a/invokeai/frontend/web/src/features/queue/components/InvokeQueueBackButton.tsx +++ b/invokeai/frontend/web/src/features/queue/components/InvokeQueueBackButton.tsx @@ -20,7 +20,7 @@ export const InvokeButton = memo(() => {