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 `<NodeEditor />`
- 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 `<PopoverAnchor />.

Need to redo this in the future, but for now, making the popover render lazily fixes this.

---------

Co-authored-by: Mary Hipp <maryhipp@Marys-MacBook-Air.local>
Co-authored-by: psychedelicious <4822129+psychedelicious@users.noreply.github.com>
This commit is contained in:
Mary Hipp Rogers 2024-01-31 08:32:31 -05:00 committed by GitHub
parent f48a2c5fd2
commit f68f8898c0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 362 additions and 98 deletions

View File

@ -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"
},

View File

@ -23,9 +23,7 @@ export type AppFeature =
| 'resumeQueue'
| 'prependQueue'
| 'invocationCache'
| 'bulkDownload'
| 'workflowLibrary';
| 'bulkDownload';
/**
* A disable-able Stable Diffusion feature
*/

View File

@ -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 = () => {
<TopPanel />
<BottomLeftPanel />
<MinimapPanel />
<SaveWorkflowAsDialog />
</motion.div>
)}
</AnimatePresence>

View File

@ -179,6 +179,7 @@ const AddNodePopover = () => {
closeOnBlur={true}
returnFocusOnClose={true}
initialFocusRef={inputRef}
isLazy
>
<PopoverAnchor>
<Flex position="absolute" top="15%" insetInlineStart="50%" pointerEvents="none" />

View File

@ -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 (
<>
<IconButton
tooltip={t('nodes.clearWorkflow')}
aria-label={t('nodes.clearWorkflow')}
icon={<PiTrashSimpleFill />}
onClick={onClick}
pointerEvents="auto"
/>
<ConfirmationAlertDialog
isOpen={isOpen}
onClose={onClose}
title={t('nodes.clearWorkflow')}
acceptCallback={handleNewWorkflow}
>
<Flex flexDir="column" gap={2}>
<Text>{t('nodes.clearWorkflowDesc')}</Text>
<Text variant="subtext">{t('nodes.clearWorkflowDesc2')}</Text>
</Flex>
</ConfirmationAlertDialog>
</>
);
};
export default memo(ClearFlowButton);

View File

@ -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 (
<IconButton
tooltip={t('workflows.saveWorkflow')}
aria-label={t('workflows.saveWorkflow')}
icon={<PiFloppyDiskBold />}
isDisabled={!isTouched}
onClick={handleClickSave}
pointerEvents="auto"
/>
);
};
export default memo(SaveWorkflowButton);

View File

@ -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 (
<Flex gap={2} top={2} left={2} right={2} position="absolute" alignItems="center" pointerEvents="none">
<AddNodeButton />
<UpdateNodesButton />
<Flex gap={2} top={2} left={2} right={2} position="absolute" alignItems="flex-start" pointerEvents="none">
<Flex flexDir="column" gap="2">
<Flex gap="2">
<AddNodeButton />
<WorkflowLibraryButton />
</Flex>
<UpdateNodesButton />
</Flex>
<Spacer />
<WorkflowName />
<Spacer />
{isWorkflowLibraryEnabled && <WorkflowLibraryButton />}
<ClearFlowButton />
<SaveWorkflowButton />
<WorkflowLibraryMenu />
</Flex>
);

View File

@ -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 (
<Button leftIcon={<PiWarningBold />} onClick={handleClickUpdateNodes} pointerEvents="auto">
{t('nodes.updateAllNodes')}
</Button>
<IconButton
tooltip={t('nodes.updateAllNodes')}
aria-label={t('nodes.updateAllNodes')}
icon={<PiWarningBold />}
onClick={handleClickUpdateNodes}
pointerEvents="auto"
/>
);
};

View File

@ -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 (
<Text m={2} fontSize="lg" userSelect="none" noOfLines={1} wordBreak="break-all" fontWeight="semibold" opacity={0.8}>
{displayName}
{name}
</Text>
);
};

View File

@ -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 (
<Flex gap={2} position="absolute" top={2} insetInlineEnd={2}>
{isWorkflowLibraryEnabled && <WorkflowLibraryButton />}
<WorkflowLibraryButton />
<WorkflowLibraryMenu />
</Flex>
);

View File

@ -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<WorkflowV2, 'nodes' | 'edges'> = {
@ -46,6 +46,11 @@ const workflowSlice = createSlice({
state.name = action.payload;
state.isTouched = true;
},
workflowCategoryChanged: (state, action: PayloadAction<WorkflowCategory | undefined>) => {
if (action.payload) {
state.meta.category = action.payload;
}
},
workflowDescriptionChanged: (state, action: PayloadAction<string>) => {
state.description = action.payload;
state.isTouched = true;
@ -102,6 +107,7 @@ export const {
workflowExposedFieldAdded,
workflowExposedFieldRemoved,
workflowNameChanged,
workflowCategoryChanged,
workflowDescriptionChanged,
workflowTagsChanged,
workflowAuthorChanged,

View File

@ -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<HTMLButtonElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const onChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
setWorkflowName(e.target.value);
},
[setWorkflowName]
);
const onChangeCheckbox = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
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 (
<AlertDialog isOpen={isOpen} onClose={onClose} leastDestructiveRef={cancelRef} isCentered={true}>
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
{t('workflows.saveWorkflowAs')}
</AlertDialogHeader>
<AlertDialogBody>
<FormControl alignItems="flex-start">
<FormLabel mt="2">{t('workflows.workflowName')}</FormLabel>
<Flex flexDir="column" width="full" gap="2">
<Input
ref={inputRef}
value={workflowName}
onChange={onChange}
placeholder={t('workflows.workflowName')}
/>
{workflowCategories.includes('project') && (
<Checkbox isChecked={shouldSaveToProject} onChange={onChangeCheckbox}>
<FormLabel>{t('workflows.saveWorkflowToProject')}</FormLabel>
</Checkbox>
)}
</Flex>
</FormControl>
</AlertDialogBody>
<AlertDialogFooter>
<Button ref={cancelRef} onClick={clearAndClose}>
{t('common.cancel')}
</Button>
<Button colorScheme="invokeBlue" onClick={onSave} ml={3} isDisabled={!workflowName || !workflowName.length}>
{t('common.saveAs')}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
);
};

View File

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

View File

@ -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 (
<WorkflowLibraryModalContext.Provider value={disclosure}>
<Button leftIcon={<PiBooksBold />} onClick={disclosure.onOpen} pointerEvents="auto">
{t('workflows.workflowLibrary')}
</Button>
<IconButton
aria-label={t('workflows.workflowLibrary')}
tooltip={t('workflows.workflowLibrary')}
icon={<PiBooksBold />}
onClick={disclosure.onOpen}
pointerEvents="auto"
/>
<WorkflowLibraryModal />
</WorkflowLibraryModalContext.Provider>
);

View File

@ -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<HTMLInputElement>(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<HTMLInputElement>) => {
setName(e.target.value);
}, []);
const { onOpen } = useSaveWorkflowAsDialog();
return (
<>
<MenuItem as="button" icon={<PiCopyBold />} onClick={onOpenCallback}>
{t('workflows.saveWorkflowAs')}
</MenuItem>
<ConfirmationAlertDialog
isOpen={isOpen}
onClose={onClose}
title={t('workflows.saveWorkflowAs')}
acceptCallback={onSave}
>
<FormControl>
<FormLabel>{t('workflows.workflowName')}</FormLabel>
<Input ref={inputRef} value={name} onChange={onChange} placeholder={t('workflows.workflowName')} />
</FormControl>
</ConfirmationAlertDialog>
</>
<MenuItem as="button" icon={<PiCopyBold />} onClick={onOpen}>
{t('workflows.saveWorkflowAs')}
</MenuItem>
);
};
export default memo(SaveWorkflowAsButton);
export default memo(SaveWorkflowAsMenuItem);

View File

@ -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 (
<MenuItem as="button" icon={<PiFloppyDiskBold />} onClick={saveWorkflow}>
<MenuItem as="button" isDisabled={!isTouched} icon={<PiFloppyDiskBold />} onClick={handleClickSave}>
{t('workflows.saveWorkflow')}
</MenuItem>
);
};
export default memo(SaveLibraryWorkflowMenuItem);
export default memo(SaveWorkflowMenuItem);

View File

@ -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 (
<Menu isOpen={isOpen} onOpen={onOpen} onClose={onClose}>
<MenuButton
@ -34,11 +30,12 @@ const WorkflowLibraryMenu = () => {
pointerEvents="auto"
/>
<MenuList pointerEvents="auto">
{isWorkflowLibraryEnabled && <SaveWorkflowMenuItem />}
{isWorkflowLibraryEnabled && <SaveWorkflowAsMenuItem />}
<DownloadWorkflowMenuItem />
<UploadWorkflowMenuItem />
<NewWorkflowMenuItem />
<UploadWorkflowMenuItem />
<MenuDivider />
<SaveWorkflowMenuItem />
<SaveWorkflowAsMenuItem />
<DownloadWorkflowMenuItem />
<MenuDivider />
<SettingsMenuItem />
</MenuList>

View File

@ -17,7 +17,8 @@ type UseSaveLibraryWorkflowReturn = {
type UseSaveLibraryWorkflow = () => UseSaveLibraryWorkflowReturn;
const isWorkflowWithID = (workflow: WorkflowV2): workflow is O.Required<WorkflowV2, 'id'> => Boolean(workflow.id);
export const isWorkflowWithID = (workflow: WorkflowV2): workflow is O.Required<WorkflowV2, 'id'> =>
Boolean(workflow.id);
export const useSaveLibraryWorkflow: UseSaveLibraryWorkflow = () => {
const { t } = useTranslation();

View File

@ -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<ToastId | undefined>();
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'),