From f68f8898c0f053629cf28256b82f43d50421add9 Mon Sep 17 00:00:00 2001 From: Mary Hipp Rogers Date: Wed, 31 Jan 2024 08:32:31 -0500 Subject: [PATCH] Workflow navigation & save-as (#5607) * redo top panel of workflow editor * add checkbox option to save to project, integrate save-as flow into first time saving workflow * remove log * remove workflowLibrary as a feature that can be disabled * lint * feat(ui): make SaveWorkflowAsDialog a singleton Fixes an issue where the workflow name would erroneously be an empty string (which it should show the current workflow name). Also makes it easier to interact with this component. - Extract the dialog state to a hook - Render the dialog once in `` - Use the hook in the various buttons that should open the dialog - Fix a few wonkily named components (pre-existing issue) * fix(ui): when saving a never-before-saved workflow, do not append " (copy)" to the name * fix(ui): do not obscure workflow library button with add node popover This component is kinda janky :/ the popover content somehow renders invisibly over the button. I think it's related to the `. Need to redo this in the future, but for now, making the popover render lazily fixes this. --------- Co-authored-by: Mary Hipp Co-authored-by: psychedelicious <4822129+psychedelicious@users.noreply.github.com> --- invokeai/frontend/web/public/locales/en.json | 5 + .../frontend/web/src/app/types/invokeai.ts | 4 +- .../features/nodes/components/NodeEditor.tsx | 2 + .../flow/AddNodePopover/AddNodePopover.tsx | 1 + .../flow/panels/TopPanel/ClearFlowButton.tsx | 64 +++++++++++ .../panels/TopPanel/SaveWorkflowButton.tsx | 42 ++++++++ .../flow/panels/TopPanel/TopPanel.tsx | 19 ++-- .../panels/TopPanel/UpdateNodesButton.tsx | 12 ++- .../flow/panels/TopPanel/WorkflowName.tsx | 17 +-- .../panels/TopRightPanel/TopRightPanel.tsx | 5 +- .../src/features/nodes/store/workflowSlice.ts | 8 +- .../SaveWorkflowAsDialog.tsx | 102 ++++++++++++++++++ .../useSaveWorkflowAsDialog.ts | 52 +++++++++ .../components/WorkflowLibraryButton.tsx | 12 ++- .../SaveWorkflowAsMenuItem.tsx | 53 ++------- .../SaveWorkflowMenuItem.tsx | 30 +++++- .../WorkflowLibraryMenu.tsx | 13 +-- .../workflowLibrary/hooks/useSaveWorkflow.ts | 3 +- .../hooks/useSaveWorkflowAs.ts | 16 ++- 19 files changed, 362 insertions(+), 98 deletions(-) create mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/ClearFlowButton.tsx create mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/SaveWorkflowButton.tsx create mode 100644 invokeai/frontend/web/src/features/workflowLibrary/components/SaveWorkflowAsDialog/SaveWorkflowAsDialog.tsx create mode 100644 invokeai/frontend/web/src/features/workflowLibrary/components/SaveWorkflowAsDialog/useSaveWorkflowAsDialog.ts diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 57b69199df..3df56c10ac 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -1014,6 +1014,9 @@ "newWorkflow": "New Workflow", "newWorkflowDesc": "Create a new workflow?", "newWorkflowDesc2": "Your current workflow has unsaved changes.", + "clearWorkflow": "Clear Workflow", + "clearWorkflowDesc": "Clear this workflow and start a new one?", + "clearWorkflowDesc2": "Your current workflow has unsaved changes.", "scheduler": "Scheduler", "schedulerDescription": "TODO", "sDXLMainModelField": "SDXL Model", @@ -1698,6 +1701,7 @@ "downloadWorkflow": "Save to File", "saveWorkflow": "Save Workflow", "saveWorkflowAs": "Save Workflow As", + "saveWorkflowToProject": "Save Workflow to Project", "savingWorkflow": "Saving Workflow...", "problemSavingWorkflow": "Problem Saving Workflow", "workflowSaved": "Workflow Saved", @@ -1712,6 +1716,7 @@ "clearWorkflowSearchFilter": "Clear Workflow Search Filter", "workflowName": "Workflow Name", "newWorkflowCreated": "New Workflow Created", + "workflowCleared": "Workflow Cleared", "workflowEditorMenu": "Workflow Editor Menu", "workflowIsOpen": "Workflow is Open" }, diff --git a/invokeai/frontend/web/src/app/types/invokeai.ts b/invokeai/frontend/web/src/app/types/invokeai.ts index faee66a9a9..d511812cb4 100644 --- a/invokeai/frontend/web/src/app/types/invokeai.ts +++ b/invokeai/frontend/web/src/app/types/invokeai.ts @@ -23,9 +23,7 @@ export type AppFeature = | 'resumeQueue' | 'prependQueue' | 'invocationCache' - | 'bulkDownload' - | 'workflowLibrary'; - + | 'bulkDownload'; /** * A disable-able Stable Diffusion feature */ diff --git a/invokeai/frontend/web/src/features/nodes/components/NodeEditor.tsx b/invokeai/frontend/web/src/features/nodes/components/NodeEditor.tsx index 501d263c38..bb801b6f39 100644 --- a/invokeai/frontend/web/src/features/nodes/components/NodeEditor.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/NodeEditor.tsx @@ -4,6 +4,7 @@ import { Flex } from '@invoke-ai/ui-library'; import { useAppSelector } from 'app/store/storeHooks'; import { IAINoContentFallback } from 'common/components/IAIImageFallback'; import TopPanel from 'features/nodes/components/flow/panels/TopPanel/TopPanel'; +import { SaveWorkflowAsDialog } from 'features/workflowLibrary/components/SaveWorkflowAsDialog/SaveWorkflowAsDialog'; import type { AnimationProps } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion'; import type { CSSProperties } from 'react'; @@ -59,6 +60,7 @@ const NodeEditor = () => { + )} diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/AddNodePopover/AddNodePopover.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/AddNodePopover/AddNodePopover.tsx index faa4c0b054..b24b52c6ab 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/AddNodePopover/AddNodePopover.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/AddNodePopover/AddNodePopover.tsx @@ -179,6 +179,7 @@ const AddNodePopover = () => { closeOnBlur={true} returnFocusOnClose={true} initialFocusRef={inputRef} + isLazy > diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/ClearFlowButton.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/ClearFlowButton.tsx new file mode 100644 index 0000000000..3ea6c54c2c --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/ClearFlowButton.tsx @@ -0,0 +1,64 @@ +import { ConfirmationAlertDialog, Flex, IconButton, Text, useDisclosure } from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiTrashSimpleFill } from 'react-icons/pi'; + +import { addToast } from '../../../../../system/store/systemSlice'; +import { makeToast } from '../../../../../system/util/makeToast'; +import { nodeEditorReset } from '../../../../store/nodesSlice'; + +const ClearFlowButton = () => { + const dispatch = useAppDispatch(); + const { t } = useTranslation(); + const { isOpen, onOpen, onClose } = useDisclosure(); + const isTouched = useAppSelector((s) => s.workflow.isTouched); + + const handleNewWorkflow = useCallback(() => { + dispatch(nodeEditorReset()); + + dispatch( + addToast( + makeToast({ + title: t('workflows.workflowCleared'), + status: 'success', + }) + ) + ); + + onClose(); + }, [dispatch, onClose, t]); + + const onClick = useCallback(() => { + if (!isTouched) { + handleNewWorkflow(); + return; + } + onOpen(); + }, [handleNewWorkflow, isTouched, onOpen]); + + return ( + <> + } + onClick={onClick} + pointerEvents="auto" + /> + + + {t('nodes.clearWorkflowDesc')} + {t('nodes.clearWorkflowDesc2')} + + + + ); +}; + +export default memo(ClearFlowButton); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/SaveWorkflowButton.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/SaveWorkflowButton.tsx new file mode 100644 index 0000000000..2d0abf3af5 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/SaveWorkflowButton.tsx @@ -0,0 +1,42 @@ +import { IconButton } from '@invoke-ai/ui-library'; +import { useAppSelector } from 'app/store/storeHooks'; +import { useSaveWorkflowAsDialog } from 'features/workflowLibrary/components/SaveWorkflowAsDialog/useSaveWorkflowAsDialog'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiFloppyDiskBold } from 'react-icons/pi'; + +import { isWorkflowWithID, useSaveLibraryWorkflow } from '../../../../../workflowLibrary/hooks/useSaveWorkflow'; +import { $builtWorkflow } from '../../../../hooks/useWorkflowWatcher'; + +const SaveWorkflowButton = () => { + const { t } = useTranslation(); + const isTouched = useAppSelector((s) => s.workflow.isTouched); + const { onOpen } = useSaveWorkflowAsDialog(); + const { saveWorkflow } = useSaveLibraryWorkflow(); + + const handleClickSave = useCallback(async () => { + const builtWorkflow = $builtWorkflow.get(); + if (!builtWorkflow) { + return; + } + + if (isWorkflowWithID(builtWorkflow)) { + saveWorkflow(); + } else { + onOpen(); + } + }, [onOpen, saveWorkflow]); + + return ( + } + isDisabled={!isTouched} + onClick={handleClickSave} + pointerEvents="auto" + /> + ); +}; + +export default memo(SaveWorkflowButton); 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 index 442b0d33b8..c87af124bf 100644 --- 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 @@ -1,23 +1,28 @@ import { Flex, Spacer } from '@invoke-ai/ui-library'; 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 WorkflowName from 'features/nodes/components/flow/panels/TopPanel/WorkflowName'; -import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; import WorkflowLibraryButton from 'features/workflowLibrary/components/WorkflowLibraryButton'; import WorkflowLibraryMenu from 'features/workflowLibrary/components/WorkflowLibraryMenu/WorkflowLibraryMenu'; import { memo } from 'react'; const TopCenterPanel = () => { - const isWorkflowLibraryEnabled = useFeatureStatus('workflowLibrary').isFeatureEnabled; - return ( - - - + + + + + + + + - {isWorkflowLibraryEnabled && } + + ); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/UpdateNodesButton.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/UpdateNodesButton.tsx index 300eb19396..d356eaa4e1 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/UpdateNodesButton.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/UpdateNodesButton.tsx @@ -1,4 +1,4 @@ -import { Button } from '@invoke-ai/ui-library'; +import { IconButton } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; import { useGetNodesNeedUpdate } from 'features/nodes/hooks/useGetNodesNeedUpdate'; import { updateAllNodesRequested } from 'features/nodes/store/actions'; @@ -19,9 +19,13 @@ const UpdateNodesButton = () => { } return ( - + } + onClick={handleClickUpdateNodes} + pointerEvents="auto" + /> ); }; diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/WorkflowName.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/WorkflowName.tsx index 5573e89270..527147c67d 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/WorkflowName.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopPanel/WorkflowName.tsx @@ -1,26 +1,13 @@ import { Text } from '@invoke-ai/ui-library'; import { useAppSelector } from 'app/store/storeHooks'; -import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; -import { memo, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; +import { memo } from 'react'; const TopCenterPanel = () => { - const { t } = useTranslation(); const name = useAppSelector((s) => s.workflow.name); - const isTouched = useAppSelector((s) => s.workflow.isTouched); - const isWorkflowLibraryEnabled = useFeatureStatus('workflowLibrary').isFeatureEnabled; - - const displayName = useMemo(() => { - let _displayName = name || t('workflows.unnamedWorkflow'); - if (isTouched && isWorkflowLibraryEnabled) { - _displayName += ` (${t('common.unsaved')})`; - } - return _displayName; - }, [t, name, isTouched, isWorkflowLibraryEnabled]); return ( - {displayName} + {name} ); }; diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopRightPanel/TopRightPanel.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopRightPanel/TopRightPanel.tsx index f3c903bf4a..be939f35bd 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopRightPanel/TopRightPanel.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/panels/TopRightPanel/TopRightPanel.tsx @@ -1,15 +1,12 @@ import { Flex } from '@invoke-ai/ui-library'; -import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; import WorkflowLibraryButton from 'features/workflowLibrary/components/WorkflowLibraryButton'; import WorkflowLibraryMenu from 'features/workflowLibrary/components/WorkflowLibraryMenu/WorkflowLibraryMenu'; import { memo } from 'react'; const TopRightPanel = () => { - const isWorkflowLibraryEnabled = useFeatureStatus('workflowLibrary').isFeatureEnabled; - return ( - {isWorkflowLibraryEnabled && } + ); diff --git a/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts b/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts index 5f6859c623..f6ffa20f13 100644 --- a/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts +++ b/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts @@ -5,7 +5,7 @@ import { workflowLoaded } from 'features/nodes/store/actions'; import { isAnyNodeOrEdgeMutation, nodeEditorReset, nodesDeleted } from 'features/nodes/store/nodesSlice'; import type { WorkflowsState as WorkflowState } from 'features/nodes/store/types'; import type { FieldIdentifier } from 'features/nodes/types/field'; -import type { WorkflowV2 } from 'features/nodes/types/workflow'; +import type { WorkflowCategory, WorkflowV2 } from 'features/nodes/types/workflow'; import { cloneDeep, isEqual, uniqBy } from 'lodash-es'; export const blankWorkflow: Omit = { @@ -46,6 +46,11 @@ const workflowSlice = createSlice({ state.name = action.payload; state.isTouched = true; }, + workflowCategoryChanged: (state, action: PayloadAction) => { + if (action.payload) { + state.meta.category = action.payload; + } + }, workflowDescriptionChanged: (state, action: PayloadAction) => { state.description = action.payload; state.isTouched = true; @@ -102,6 +107,7 @@ export const { workflowExposedFieldAdded, workflowExposedFieldRemoved, workflowNameChanged, + workflowCategoryChanged, workflowDescriptionChanged, workflowTagsChanged, workflowAuthorChanged, diff --git a/invokeai/frontend/web/src/features/workflowLibrary/components/SaveWorkflowAsDialog/SaveWorkflowAsDialog.tsx b/invokeai/frontend/web/src/features/workflowLibrary/components/SaveWorkflowAsDialog/SaveWorkflowAsDialog.tsx new file mode 100644 index 0000000000..74a8916475 --- /dev/null +++ b/invokeai/frontend/web/src/features/workflowLibrary/components/SaveWorkflowAsDialog/SaveWorkflowAsDialog.tsx @@ -0,0 +1,102 @@ +import { + AlertDialog, + AlertDialogBody, + AlertDialogContent, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogOverlay, + Button, + Checkbox, + Flex, + FormControl, + FormLabel, + Input, +} from '@invoke-ai/ui-library'; +import { useStore } from '@nanostores/react'; +import { useSaveWorkflowAsDialog } from 'features/workflowLibrary/components/SaveWorkflowAsDialog/useSaveWorkflowAsDialog'; +import { t } from 'i18next'; +import type { ChangeEvent } from 'react'; +import { useCallback, useRef } from 'react'; + +import { $workflowCategories } from '../../../../app/store/nanostores/workflowCategories'; +import { useSaveWorkflowAs } from '../../hooks/useSaveWorkflowAs'; + +export const SaveWorkflowAsDialog = () => { + const { isOpen, onClose, workflowName, setWorkflowName, shouldSaveToProject, setShouldSaveToProject } = + useSaveWorkflowAsDialog(); + + const workflowCategories = useStore($workflowCategories); + + const { saveWorkflowAs } = useSaveWorkflowAs(); + + const cancelRef = useRef(null); + const inputRef = useRef(null); + + const onChange = useCallback( + (e: ChangeEvent) => { + setWorkflowName(e.target.value); + }, + [setWorkflowName] + ); + + const onChangeCheckbox = useCallback( + (e: ChangeEvent) => { + setShouldSaveToProject(e.target.checked); + }, + [setShouldSaveToProject] + ); + + const clearAndClose = useCallback(() => { + onClose(); + }, [onClose]); + + const onSave = useCallback(async () => { + const category = shouldSaveToProject ? 'project' : 'user'; + await saveWorkflowAs({ + name: workflowName, + category, + onSuccess: clearAndClose, + onError: clearAndClose, + }); + }, [workflowName, saveWorkflowAs, shouldSaveToProject, clearAndClose]); + + return ( + + + + + {t('workflows.saveWorkflowAs')} + + + + + {t('workflows.workflowName')} + + + {workflowCategories.includes('project') && ( + + {t('workflows.saveWorkflowToProject')} + + )} + + + + + + + + + + + + ); +}; diff --git a/invokeai/frontend/web/src/features/workflowLibrary/components/SaveWorkflowAsDialog/useSaveWorkflowAsDialog.ts b/invokeai/frontend/web/src/features/workflowLibrary/components/SaveWorkflowAsDialog/useSaveWorkflowAsDialog.ts new file mode 100644 index 0000000000..78f3e3f537 --- /dev/null +++ b/invokeai/frontend/web/src/features/workflowLibrary/components/SaveWorkflowAsDialog/useSaveWorkflowAsDialog.ts @@ -0,0 +1,52 @@ +import { useStore } from '@nanostores/react'; +import { createSelector } from '@reduxjs/toolkit'; +import { useAppSelector } from 'app/store/storeHooks'; +import { selectWorkflowSlice } from 'features/nodes/store/workflowSlice'; +import { getWorkflowCopyName } from 'features/workflowLibrary/util/getWorkflowCopyName'; +import { atom } from 'nanostores'; +import { useCallback } from 'react'; + +const $isOpen = atom(false); +const $workflowName = atom(''); +const $shouldSaveToProject = atom(false); + +const selectNewWorkflowName = createSelector(selectWorkflowSlice, ({ name, id }): string => { + // If the workflow has no ID, it's a new workflow that has never been saved to the server. The dialog should use + // whatever the user has entered in the workflow name field. + if (!id) { + return name; + } + // Else, the workflow is already saved to the server. The dialog should use the workflow's name with " (copy)" + // appended to it. + if (name.length) { + return getWorkflowCopyName(name); + } + // Else, we have a workflow that has been saved to the server, but has no name. This should never happen, but if + // it does, we just return an empty string and let the dialog use the default name. + return ''; +}); + +export const useSaveWorkflowAsDialog = () => { + const newWorkflowName = useAppSelector(selectNewWorkflowName); + + const isOpen = useStore($isOpen); + const onOpen = useCallback(() => { + $workflowName.set(newWorkflowName); + $isOpen.set(true); + }, [newWorkflowName]); + const onClose = useCallback(() => { + $isOpen.set(false); + $workflowName.set(''); + $shouldSaveToProject.set(false); + }, []); + + const workflowName = useStore($workflowName); + const setWorkflowName = useCallback((workflowName: string) => $workflowName.set(workflowName), []); + + const shouldSaveToProject = useStore($shouldSaveToProject); + const setShouldSaveToProject = useCallback((shouldSaveToProject: boolean) => { + $shouldSaveToProject.set(shouldSaveToProject); + }, []); + + return { workflowName, setWorkflowName, shouldSaveToProject, setShouldSaveToProject, isOpen, onOpen, onClose }; +}; diff --git a/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryButton.tsx b/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryButton.tsx index f128505dbe..33c3cee2bb 100644 --- a/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryButton.tsx +++ b/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryButton.tsx @@ -1,4 +1,4 @@ -import { Button, useDisclosure } from '@invoke-ai/ui-library'; +import { IconButton, useDisclosure } from '@invoke-ai/ui-library'; import { WorkflowLibraryModalContext } from 'features/workflowLibrary/context/WorkflowLibraryModalContext'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -12,9 +12,13 @@ const WorkflowLibraryButton = () => { return ( - + } + onClick={disclosure.onOpen} + pointerEvents="auto" + /> ); diff --git a/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryMenu/SaveWorkflowAsMenuItem.tsx b/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryMenu/SaveWorkflowAsMenuItem.tsx index 55add16267..bd8a909ace 100644 --- a/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryMenu/SaveWorkflowAsMenuItem.tsx +++ b/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryMenu/SaveWorkflowAsMenuItem.tsx @@ -1,53 +1,18 @@ -import { ConfirmationAlertDialog, FormControl, FormLabel, Input, MenuItem, useDisclosure } from '@invoke-ai/ui-library'; -import { useAppSelector } from 'app/store/storeHooks'; -import { useSaveWorkflowAs } from 'features/workflowLibrary/hooks/useSaveWorkflowAs'; -import { getWorkflowCopyName } from 'features/workflowLibrary/util/getWorkflowCopyName'; -import type { ChangeEvent } from 'react'; -import { memo, useCallback, useRef, useState } from 'react'; +import { MenuItem } from '@invoke-ai/ui-library'; +import { useSaveWorkflowAsDialog } from 'features/workflowLibrary/components/SaveWorkflowAsDialog/useSaveWorkflowAsDialog'; +import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiCopyBold } from 'react-icons/pi'; -const SaveWorkflowAsButton = () => { - const currentName = useAppSelector((s) => s.workflow.name); +const SaveWorkflowAsMenuItem = () => { const { t } = useTranslation(); - const { saveWorkflowAs } = useSaveWorkflowAs(); - const [name, setName] = useState(getWorkflowCopyName(currentName)); - const { isOpen, onOpen, onClose } = useDisclosure(); - const inputRef = useRef(null); - - const onOpenCallback = useCallback(() => { - setName(getWorkflowCopyName(currentName)); - onOpen(); - inputRef.current?.focus(); - }, [currentName, onOpen]); - - const onSave = useCallback(async () => { - saveWorkflowAs({ name, onSuccess: onClose, onError: onClose }); - }, [name, onClose, saveWorkflowAs]); - - const onChange = useCallback((e: ChangeEvent) => { - setName(e.target.value); - }, []); + const { onOpen } = useSaveWorkflowAsDialog(); return ( - <> - } onClick={onOpenCallback}> - {t('workflows.saveWorkflowAs')} - - - - - {t('workflows.workflowName')} - - - - + } onClick={onOpen}> + {t('workflows.saveWorkflowAs')} + ); }; -export default memo(SaveWorkflowAsButton); +export default memo(SaveWorkflowAsMenuItem); diff --git a/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryMenu/SaveWorkflowMenuItem.tsx b/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryMenu/SaveWorkflowMenuItem.tsx index 1e2866cb77..3fc7cee257 100644 --- a/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryMenu/SaveWorkflowMenuItem.tsx +++ b/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryMenu/SaveWorkflowMenuItem.tsx @@ -1,17 +1,37 @@ import { MenuItem } from '@invoke-ai/ui-library'; -import { useSaveLibraryWorkflow } from 'features/workflowLibrary/hooks/useSaveWorkflow'; -import { memo } from 'react'; +import { useSaveWorkflowAsDialog } from 'features/workflowLibrary/components/SaveWorkflowAsDialog/useSaveWorkflowAsDialog'; +import { isWorkflowWithID, useSaveLibraryWorkflow } from 'features/workflowLibrary/hooks/useSaveWorkflow'; +import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiFloppyDiskBold } from 'react-icons/pi'; -const SaveLibraryWorkflowMenuItem = () => { +import { useAppSelector } from '../../../../app/store/storeHooks'; +import { $builtWorkflow } from '../../../nodes/hooks/useWorkflowWatcher'; + +const SaveWorkflowMenuItem = () => { const { t } = useTranslation(); const { saveWorkflow } = useSaveLibraryWorkflow(); + const { onOpen } = useSaveWorkflowAsDialog(); + const isTouched = useAppSelector((s) => s.workflow.isTouched); + + const handleClickSave = useCallback(async () => { + const builtWorkflow = $builtWorkflow.get(); + if (!builtWorkflow) { + return; + } + + if (isWorkflowWithID(builtWorkflow)) { + saveWorkflow(); + } else { + onOpen(); + } + }, [onOpen, saveWorkflow]); + return ( - } onClick={saveWorkflow}> + } onClick={handleClickSave}> {t('workflows.saveWorkflow')} ); }; -export default memo(SaveLibraryWorkflowMenuItem); +export default memo(SaveWorkflowMenuItem); diff --git a/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryMenu/WorkflowLibraryMenu.tsx b/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryMenu/WorkflowLibraryMenu.tsx index 158eb73edc..73d0249d3d 100644 --- a/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryMenu/WorkflowLibraryMenu.tsx +++ b/invokeai/frontend/web/src/features/workflowLibrary/components/WorkflowLibraryMenu/WorkflowLibraryMenu.tsx @@ -7,7 +7,6 @@ import { useDisclosure, useGlobalMenuClose, } from '@invoke-ai/ui-library'; -import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; import DownloadWorkflowMenuItem from 'features/workflowLibrary/components/WorkflowLibraryMenu/DownloadWorkflowMenuItem'; import NewWorkflowMenuItem from 'features/workflowLibrary/components/WorkflowLibraryMenu/NewWorkflowMenuItem'; import SaveWorkflowAsMenuItem from 'features/workflowLibrary/components/WorkflowLibraryMenu/SaveWorkflowAsMenuItem'; @@ -22,9 +21,6 @@ const WorkflowLibraryMenu = () => { const { t } = useTranslation(); const { isOpen, onOpen, onClose } = useDisclosure(); useGlobalMenuClose(onClose); - - const isWorkflowLibraryEnabled = useFeatureStatus('workflowLibrary').isFeatureEnabled; - return ( { pointerEvents="auto" /> - {isWorkflowLibraryEnabled && } - {isWorkflowLibraryEnabled && } - - + + + + + diff --git a/invokeai/frontend/web/src/features/workflowLibrary/hooks/useSaveWorkflow.ts b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useSaveWorkflow.ts index 15d0fc650e..78d7071c20 100644 --- a/invokeai/frontend/web/src/features/workflowLibrary/hooks/useSaveWorkflow.ts +++ b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useSaveWorkflow.ts @@ -17,7 +17,8 @@ type UseSaveLibraryWorkflowReturn = { type UseSaveLibraryWorkflow = () => UseSaveLibraryWorkflowReturn; -const isWorkflowWithID = (workflow: WorkflowV2): workflow is O.Required => Boolean(workflow.id); +export const isWorkflowWithID = (workflow: WorkflowV2): workflow is O.Required => + Boolean(workflow.id); export const useSaveLibraryWorkflow: UseSaveLibraryWorkflow = () => { const { t } = useTranslation(); diff --git a/invokeai/frontend/web/src/features/workflowLibrary/hooks/useSaveWorkflowAs.ts b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useSaveWorkflowAs.ts index c24961b918..2bd7aabbe4 100644 --- a/invokeai/frontend/web/src/features/workflowLibrary/hooks/useSaveWorkflowAs.ts +++ b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useSaveWorkflowAs.ts @@ -2,13 +2,21 @@ import type { ToastId } from '@invoke-ai/ui-library'; import { useToast } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; import { $builtWorkflow } from 'features/nodes/hooks/useWorkflowWatcher'; -import { workflowIDChanged, workflowNameChanged, workflowSaved } from 'features/nodes/store/workflowSlice'; +import { + workflowCategoryChanged, + workflowIDChanged, + workflowNameChanged, + workflowSaved, +} from 'features/nodes/store/workflowSlice'; import { useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { useCreateWorkflowMutation, workflowsApi } from 'services/api/endpoints/workflows'; +import type { WorkflowCategory } from '../../nodes/types/workflow'; + type SaveWorkflowAsArg = { name: string; + category: WorkflowCategory; onSuccess?: () => void; onError?: () => void; }; @@ -28,7 +36,7 @@ export const useSaveWorkflowAs: UseSaveWorkflowAs = () => { const toast = useToast(); const toastRef = useRef(); const saveWorkflowAs = useCallback( - async ({ name: newName, onSuccess, onError }: SaveWorkflowAsArg) => { + async ({ name: newName, category, onSuccess, onError }: SaveWorkflowAsArg) => { const workflow = $builtWorkflow.get(); if (!workflow) { return; @@ -42,10 +50,14 @@ export const useSaveWorkflowAs: UseSaveWorkflowAs = () => { try { workflow.id = undefined; workflow.name = newName; + workflow.meta.category = category; + const data = await createWorkflow(workflow).unwrap(); dispatch(workflowIDChanged(data.workflow.id)); dispatch(workflowNameChanged(data.workflow.name)); + dispatch(workflowCategoryChanged(data.workflow.meta.category)); dispatch(workflowSaved()); + onSuccess && onSuccess(); toast.update(toastRef.current, { title: t('workflows.workflowSaved'),