mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-01-14 22:18:16 -05:00
another new workflow library
This commit is contained in:
committed by
psychedelicious
parent
518a7c941f
commit
d5c5e8e8ed
@@ -102,13 +102,13 @@ async def list_workflows(
|
||||
default=WorkflowRecordOrderBy.Name, description="The attribute to order by"
|
||||
),
|
||||
direction: SQLiteDirection = Query(default=SQLiteDirection.Ascending, description="The direction to order by"),
|
||||
category: WorkflowCategory = Query(default=WorkflowCategory.User, description="The category of workflow to get"),
|
||||
categories: Optional[list[WorkflowCategory]] = Query(default=None, description="The categories of workflow to get"),
|
||||
query: Optional[str] = Query(default=None, description="The text to query by (matches name and description)"),
|
||||
) -> PaginatedResults[WorkflowRecordListItemWithThumbnailDTO]:
|
||||
"""Gets a page of workflows"""
|
||||
workflows_with_thumbnails: list[WorkflowRecordListItemWithThumbnailDTO] = []
|
||||
workflows = ApiDependencies.invoker.services.workflow_records.get_many(
|
||||
order_by=order_by, direction=direction, page=page, per_page=per_page, query=query, category=category
|
||||
order_by=order_by, direction=direction, page=page, per_page=per_page, query=query, categories=categories
|
||||
)
|
||||
for workflow in workflows.items:
|
||||
workflows_with_thumbnails.append(
|
||||
|
||||
@@ -41,7 +41,7 @@ class WorkflowRecordsStorageBase(ABC):
|
||||
self,
|
||||
order_by: WorkflowRecordOrderBy,
|
||||
direction: SQLiteDirection,
|
||||
category: WorkflowCategory,
|
||||
categories: Optional[list[WorkflowCategory]],
|
||||
page: int,
|
||||
per_page: Optional[int],
|
||||
query: Optional[str],
|
||||
|
||||
@@ -120,7 +120,7 @@ class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase):
|
||||
self,
|
||||
order_by: WorkflowRecordOrderBy,
|
||||
direction: SQLiteDirection,
|
||||
category: WorkflowCategory,
|
||||
categories: Optional[list[WorkflowCategory]],
|
||||
page: int = 0,
|
||||
per_page: Optional[int] = None,
|
||||
query: Optional[str] = None,
|
||||
@@ -128,28 +128,50 @@ class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase):
|
||||
# sanitize!
|
||||
assert order_by in WorkflowRecordOrderBy
|
||||
assert direction in SQLiteDirection
|
||||
assert category in WorkflowCategory
|
||||
count_query = "SELECT COUNT(*) FROM workflow_library WHERE category = ?"
|
||||
main_query = """
|
||||
SELECT
|
||||
workflow_id,
|
||||
category,
|
||||
name,
|
||||
description,
|
||||
created_at,
|
||||
updated_at,
|
||||
opened_at
|
||||
FROM workflow_library
|
||||
WHERE category = ?
|
||||
"""
|
||||
main_params: list[int | str] = [category.value]
|
||||
count_params: list[int | str] = [category.value]
|
||||
if categories:
|
||||
assert all(c in WorkflowCategory for c in categories)
|
||||
count_query = "SELECT COUNT(*) FROM workflow_library WHERE category IN ({})".format(
|
||||
", ".join("?" for _ in categories)
|
||||
)
|
||||
main_query = """
|
||||
SELECT
|
||||
workflow_id,
|
||||
category,
|
||||
name,
|
||||
description,
|
||||
created_at,
|
||||
updated_at,
|
||||
opened_at
|
||||
FROM workflow_library
|
||||
WHERE category IN ({})
|
||||
""".format(", ".join("?" for _ in categories))
|
||||
main_params: list[int | str] = [category.value for category in categories]
|
||||
count_params: list[int | str] = [category.value for category in categories]
|
||||
else:
|
||||
count_query = "SELECT COUNT(*) FROM workflow_library"
|
||||
main_query = """
|
||||
SELECT
|
||||
workflow_id,
|
||||
category,
|
||||
name,
|
||||
description,
|
||||
created_at,
|
||||
updated_at,
|
||||
opened_at
|
||||
FROM workflow_library
|
||||
"""
|
||||
main_params: list[int | str] = []
|
||||
count_params: list[int | str] = []
|
||||
|
||||
stripped_query = query.strip() if query else None
|
||||
if stripped_query:
|
||||
wildcard_query = "%" + stripped_query + "%"
|
||||
main_query += " AND name LIKE ? OR description LIKE ? "
|
||||
count_query += " AND name LIKE ? OR description LIKE ?;"
|
||||
if categories:
|
||||
main_query += " AND (name LIKE ? OR description LIKE ?) "
|
||||
count_query += " AND (name LIKE ? OR description LIKE ?);"
|
||||
else:
|
||||
main_query += " WHERE name LIKE ? OR description LIKE ? "
|
||||
count_query += " WHERE name LIKE ? OR description LIKE ?;"
|
||||
main_params.extend([wildcard_query, wildcard_query])
|
||||
count_params.extend([wildcard_query, wildcard_query])
|
||||
|
||||
@@ -232,7 +254,7 @@ class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase):
|
||||
library_workflows_from_db = self.get_many(
|
||||
order_by=WorkflowRecordOrderBy.Name,
|
||||
direction=SQLiteDirection.Ascending,
|
||||
category=WorkflowCategory.Default,
|
||||
categories=[WorkflowCategory.Default],
|
||||
).items
|
||||
|
||||
workflows_from_file_ids = [w.id for w in workflows_from_file]
|
||||
|
||||
@@ -26,7 +26,8 @@ import { DynamicPromptsModal } from 'features/dynamicPrompts/components/DynamicP
|
||||
import DeleteBoardModal from 'features/gallery/components/Boards/DeleteBoardModal';
|
||||
import { ImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu';
|
||||
import { useStarterModelsToast } from 'features/modelManagerV2/hooks/useStarterModelsToast';
|
||||
import { ShareWorkflowModal } from 'features/nodes/components/sidePanel/WorkflowListMenu/ShareWorkflowModal';
|
||||
import { ShareWorkflowModal } from 'features/nodes/components/sidePanel/workflow/WorkflowLibrary/ShareWorkflowModal';
|
||||
import { WorkflowLibraryModal } from 'features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryModal';
|
||||
import { CancelAllExceptCurrentQueueItemConfirmationAlertDialog } from 'features/queue/components/CancelAllExceptCurrentQueueItemConfirmationAlertDialog';
|
||||
import { ClearQueueConfirmationsAlertDialog } from 'features/queue/components/ClearQueueConfirmationAlertDialog';
|
||||
import { useReadinessWatcher } from 'features/queue/store/readiness';
|
||||
@@ -50,7 +51,6 @@ import { useGetOpenAPISchemaQuery } from 'services/api/endpoints/appInfo';
|
||||
import { useSocketIO } from 'services/events/useSocketIO';
|
||||
|
||||
import AppErrorBoundaryFallback from './AppErrorBoundaryFallback';
|
||||
|
||||
const DEFAULT_CONFIG = {};
|
||||
|
||||
interface Props {
|
||||
@@ -129,6 +129,7 @@ const ModalIsolator = memo(() => {
|
||||
<ChangeBoardModal />
|
||||
<DynamicPromptsModal />
|
||||
<StylePresetModal />
|
||||
<WorkflowLibraryModal />
|
||||
<CancelAllExceptCurrentQueueItemConfirmationAlertDialog />
|
||||
<ClearQueueConfirmationsAlertDialog />
|
||||
<NewWorkflowConfirmationAlertDialog />
|
||||
|
||||
@@ -11,7 +11,7 @@ import { $imageViewer } from 'features/gallery/components/ImageViewer/useImageVi
|
||||
import { sentImageToCanvas } from 'features/gallery/store/actions';
|
||||
import { parseAndRecallAllMetadata } from 'features/metadata/util/handlers';
|
||||
import { $hasTemplates } from 'features/nodes/store/nodesSlice';
|
||||
import { $isWorkflowListMenuIsOpen } from 'features/nodes/store/workflowListMenu';
|
||||
import { $isWorkflowLibraryModalOpen } from 'features/nodes/store/workflowLibraryModal';
|
||||
import { $isStylePresetsMenuOpen, activeStylePresetIdChanged } from 'features/stylePresets/store/stylePresetSlice';
|
||||
import { toast } from 'features/toast/toast';
|
||||
import { activeTabCanvasRightPanelChanged, setActiveTab } from 'features/ui/store/uiSlice';
|
||||
@@ -166,7 +166,7 @@ export const useStudioInitAction = (action?: StudioInitAction) => {
|
||||
case 'viewAllWorkflows':
|
||||
// Go to the workflows tab and open the workflow library modal
|
||||
store.dispatch(setActiveTab('workflows'));
|
||||
$isWorkflowListMenuIsOpen.set(true);
|
||||
$isWorkflowLibraryModalOpen.set(true);
|
||||
break;
|
||||
case 'viewAllStylePresets':
|
||||
// Go to the canvas tab and open the style presets menu
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import { Button, Collapse, Flex, Icon, Spinner, Text } from '@invoke-ai/ui-library';
|
||||
import { EMPTY_ARRAY } from 'app/store/constants';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
|
||||
import { fixTooltipCloseOnScrollStyles } from 'common/util/fixTooltipCloseOnScrollStyles';
|
||||
import { useCategorySections } from 'features/nodes/hooks/useCategorySections';
|
||||
import {
|
||||
selectWorkflowOrderBy,
|
||||
selectWorkflowOrderDirection,
|
||||
selectWorkflowSearchTerm,
|
||||
} from 'features/nodes/store/workflowSlice';
|
||||
import type { WorkflowCategory } from 'features/nodes/types/workflow';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiCaretDownBold } from 'react-icons/pi';
|
||||
import { useListWorkflowsQuery } from 'services/api/endpoints/workflows';
|
||||
|
||||
import { WorkflowListItem } from './WorkflowListItem';
|
||||
|
||||
export const WorkflowList = ({ category }: { category: WorkflowCategory }) => {
|
||||
const searchTerm = useAppSelector(selectWorkflowSearchTerm);
|
||||
const orderBy = useAppSelector(selectWorkflowOrderBy);
|
||||
const direction = useAppSelector(selectWorkflowOrderDirection);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const queryArg = useMemo<Parameters<typeof useListWorkflowsQuery>[0]>(() => {
|
||||
if (category !== 'default') {
|
||||
return {
|
||||
order_by: orderBy,
|
||||
direction,
|
||||
category: category,
|
||||
};
|
||||
}
|
||||
return {
|
||||
order_by: 'name' as const,
|
||||
direction: 'ASC' as const,
|
||||
category: category,
|
||||
};
|
||||
}, [category, direction, orderBy]);
|
||||
|
||||
const { data, isLoading } = useListWorkflowsQuery(queryArg, {
|
||||
selectFromResult: ({ data, isLoading }) => {
|
||||
const filteredData =
|
||||
data?.items.filter((workflow) => workflow.name.toLowerCase().includes(searchTerm.toLowerCase())) || EMPTY_ARRAY;
|
||||
|
||||
return {
|
||||
data: filteredData,
|
||||
isLoading,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const { isOpen, onToggle } = useCategorySections(category);
|
||||
|
||||
return (
|
||||
<Flex flexDir="column">
|
||||
<Button variant="unstyled" onClick={onToggle}>
|
||||
<Flex gap={2} alignItems="center">
|
||||
<Icon boxSize={4} as={PiCaretDownBold} transform={isOpen ? undefined : 'rotate(-90deg)'} fill="base.500" />
|
||||
<Text fontSize="sm" fontWeight="semibold" userSelect="none" color="base.500">
|
||||
{t(`workflows.${category}Workflows`)}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Button>
|
||||
<Collapse in={isOpen} style={fixTooltipCloseOnScrollStyles}>
|
||||
{isLoading ? (
|
||||
<Flex alignItems="center" justifyContent="center" p={20}>
|
||||
<Spinner />
|
||||
</Flex>
|
||||
) : data.length ? (
|
||||
data.map((workflow) => <WorkflowListItem workflow={workflow} key={workflow.workflow_id} />)
|
||||
) : (
|
||||
<IAINoContentFallback
|
||||
fontSize="sm"
|
||||
py={4}
|
||||
label={searchTerm ? t('nodes.noMatchingWorkflows') : t('nodes.noWorkflows')}
|
||||
icon={null}
|
||||
/>
|
||||
)}
|
||||
</Collapse>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -1,194 +0,0 @@
|
||||
import { Badge, Flex, IconButton, Spacer, Text, Tooltip } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { $projectUrl } from 'app/store/nanostores/projectId';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import dateFormat, { masks } from 'dateformat';
|
||||
import { selectWorkflowId } from 'features/nodes/store/workflowSlice';
|
||||
import { useDeleteWorkflow } from 'features/workflowLibrary/components/DeleteLibraryWorkflowConfirmationAlertDialog';
|
||||
import { useLoadWorkflow } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog';
|
||||
import { useDownloadWorkflowById } from 'features/workflowLibrary/hooks/useDownloadWorkflowById';
|
||||
import type { MouseEvent } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiDownloadSimpleBold, PiPencilBold, PiShareFatBold, PiTrashBold } from 'react-icons/pi';
|
||||
import type { WorkflowRecordListItemWithThumbnailDTO } from 'services/api/types';
|
||||
|
||||
import { useShareWorkflow } from './ShareWorkflowModal';
|
||||
import { WorkflowListItemTooltip } from './WorkflowListItemTooltip';
|
||||
|
||||
export const WorkflowListItem = ({ workflow }: { workflow: WorkflowRecordListItemWithThumbnailDTO }) => {
|
||||
const { t } = useTranslation();
|
||||
const projectUrl = useStore($projectUrl);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const handleMouseOver = useCallback(() => {
|
||||
setIsHovered(true);
|
||||
}, []);
|
||||
|
||||
const handleMouseOut = useCallback(() => {
|
||||
setIsHovered(false);
|
||||
}, []);
|
||||
|
||||
const workflowId = useAppSelector(selectWorkflowId);
|
||||
const { downloadWorkflow, isLoading: isLoadingDownloadWorkflow } = useDownloadWorkflowById();
|
||||
const shareWorkflow = useShareWorkflow();
|
||||
const deleteWorkflow = useDeleteWorkflow();
|
||||
const loadWorkflow = useLoadWorkflow();
|
||||
|
||||
const isActive = useMemo(() => {
|
||||
return workflowId === workflow.workflow_id;
|
||||
}, [workflowId, workflow.workflow_id]);
|
||||
|
||||
const handleClickLoad = useCallback(
|
||||
(e: MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
setIsHovered(false);
|
||||
loadWorkflow.loadWithDialog(workflow.workflow_id, 'view');
|
||||
},
|
||||
[loadWorkflow, workflow.workflow_id]
|
||||
);
|
||||
|
||||
const handleClickEdit = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
setIsHovered(false);
|
||||
loadWorkflow.loadWithDialog(workflow.workflow_id, 'edit');
|
||||
},
|
||||
[loadWorkflow, workflow.workflow_id]
|
||||
);
|
||||
|
||||
const handleClickDelete = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
setIsHovered(false);
|
||||
deleteWorkflow(workflow);
|
||||
},
|
||||
[deleteWorkflow, workflow]
|
||||
);
|
||||
|
||||
const handleClickShare = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
setIsHovered(false);
|
||||
shareWorkflow(workflow);
|
||||
},
|
||||
[shareWorkflow, workflow]
|
||||
);
|
||||
|
||||
const handleClickDownload = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
setIsHovered(false);
|
||||
downloadWorkflow(workflow.workflow_id);
|
||||
},
|
||||
[downloadWorkflow, workflow.workflow_id]
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
gap={4}
|
||||
onClick={handleClickLoad}
|
||||
cursor="pointer"
|
||||
_hover={{ backgroundColor: 'base.750' }}
|
||||
p={2}
|
||||
ps={3}
|
||||
borderRadius="base"
|
||||
w="full"
|
||||
onMouseOver={handleMouseOver}
|
||||
onMouseOut={handleMouseOut}
|
||||
alignItems="center"
|
||||
>
|
||||
<Tooltip label={<WorkflowListItemTooltip workflow={workflow} />} closeOnScroll>
|
||||
<Flex flexDir="column" gap={1}>
|
||||
<Flex gap={4} alignItems="center">
|
||||
<Text noOfLines={2}>{workflow.name}</Text>
|
||||
|
||||
{isActive && (
|
||||
<Badge
|
||||
color="invokeBlue.400"
|
||||
borderColor="invokeBlue.700"
|
||||
borderWidth={1}
|
||||
bg="transparent"
|
||||
flexShrink={0}
|
||||
>
|
||||
{t('workflows.opened')}
|
||||
</Badge>
|
||||
)}
|
||||
</Flex>
|
||||
{workflow.category !== 'default' && (
|
||||
<Text fontSize="xs" variant="subtext" flexShrink={0} noOfLines={1}>
|
||||
{t('common.updated')}: {dateFormat(workflow.updated_at, masks.shortDate)}{' '}
|
||||
{dateFormat(workflow.updated_at, masks.shortTime)}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Tooltip>
|
||||
<Spacer />
|
||||
|
||||
<Flex alignItems="center" gap={1} opacity={isHovered ? 1 : 0}>
|
||||
<Tooltip
|
||||
label={t('workflows.edit')}
|
||||
// This prevents an issue where the tooltip isn't closed after the modal is opened
|
||||
isOpen={!isHovered ? false : undefined}
|
||||
closeOnScroll
|
||||
>
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={t('workflows.edit')}
|
||||
onClick={handleClickEdit}
|
||||
icon={<PiPencilBold />}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
label={t('workflows.download')}
|
||||
// This prevents an issue where the tooltip isn't closed after the modal is opened
|
||||
isOpen={!isHovered ? false : undefined}
|
||||
closeOnScroll
|
||||
>
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={t('workflows.download')}
|
||||
onClick={handleClickDownload}
|
||||
icon={<PiDownloadSimpleBold />}
|
||||
isLoading={isLoadingDownloadWorkflow}
|
||||
/>
|
||||
</Tooltip>
|
||||
{!!projectUrl && workflow.workflow_id && workflow.category !== 'user' && (
|
||||
<Tooltip
|
||||
label={t('workflows.copyShareLink')}
|
||||
// This prevents an issue where the tooltip isn't closed after the modal is opened
|
||||
isOpen={!isHovered ? false : undefined}
|
||||
closeOnScroll
|
||||
>
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={t('workflows.copyShareLink')}
|
||||
onClick={handleClickShare}
|
||||
icon={<PiShareFatBold />}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{workflow.category !== 'default' && (
|
||||
<Tooltip
|
||||
label={t('workflows.delete')}
|
||||
// This prevents an issue where the tooltip isn't closed after the modal is opened
|
||||
isOpen={!isHovered ? false : undefined}
|
||||
closeOnScroll
|
||||
>
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={t('workflows.delete')}
|
||||
onClick={handleClickDelete}
|
||||
colorScheme="error"
|
||||
icon={<PiTrashBold />}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -1,82 +1,27 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
Popover,
|
||||
PopoverBody,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Portal,
|
||||
Text,
|
||||
} from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { $workflowCategories } from 'app/store/nanostores/workflowCategories';
|
||||
import { Button, Text } from '@invoke-ai/ui-library';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
|
||||
import { useWorkflowListMenu } from 'features/nodes/store/workflowListMenu';
|
||||
import { useWorkflowLibraryModal } from 'features/nodes/store/workflowLibraryModal';
|
||||
import { selectWorkflowName } from 'features/nodes/store/workflowSlice';
|
||||
import { NewWorkflowButton } from 'features/workflowLibrary/components/NewWorkflowButton';
|
||||
import UploadWorkflowButton from 'features/workflowLibrary/components/UploadWorkflowButton';
|
||||
import { useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiFolderOpenFill } from 'react-icons/pi';
|
||||
|
||||
import { WorkflowList } from './WorkflowList';
|
||||
import { WorkflowSearch } from './WorkflowSearch';
|
||||
import { WorkflowSortControl } from './WorkflowSortControl';
|
||||
|
||||
export const WorkflowListMenuTrigger = () => {
|
||||
const workflowListMenu = useWorkflowListMenu();
|
||||
const workflowLibraryModal = useWorkflowLibraryModal();
|
||||
const { t } = useTranslation();
|
||||
const workflowCategories = useStore($workflowCategories);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const workflowName = useAppSelector(selectWorkflowName);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
isOpen={workflowListMenu.isOpen}
|
||||
onClose={workflowListMenu.close}
|
||||
onOpen={workflowListMenu.open}
|
||||
isLazy
|
||||
lazyBehavior="unmount"
|
||||
placement="bottom-end"
|
||||
initialFocusRef={searchInputRef}
|
||||
>
|
||||
<PopoverTrigger>
|
||||
<Button variant="ghost" rightIcon={<PiFolderOpenFill />} size="sm">
|
||||
<Text
|
||||
display="auto"
|
||||
noOfLines={1}
|
||||
overflow="hidden"
|
||||
textOverflow="ellipsis"
|
||||
whiteSpace="nowrap"
|
||||
wordBreak="break-all"
|
||||
>
|
||||
{workflowName || t('workflows.chooseWorkflowFromLibrary')}
|
||||
</Text>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<Portal>
|
||||
<PopoverContent p={4} w={512} maxW="full" minH={512} maxH="full">
|
||||
<PopoverBody flex="1 1 0">
|
||||
<Flex w="full" h="full" flexDir="column" gap={2}>
|
||||
<Flex alignItems="center" gap={2} w="full" justifyContent="space-between">
|
||||
<WorkflowSearch searchInputRef={searchInputRef} />
|
||||
<WorkflowSortControl />
|
||||
<UploadWorkflowButton />
|
||||
<NewWorkflowButton />
|
||||
</Flex>
|
||||
<Box position="relative" w="full" h="full">
|
||||
<ScrollableContent>
|
||||
{workflowCategories.map((category) => (
|
||||
<WorkflowList key={category} category={category} />
|
||||
))}
|
||||
</ScrollableContent>
|
||||
</Box>
|
||||
</Flex>
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Portal>
|
||||
</Popover>
|
||||
<Button variant="ghost" rightIcon={<PiFolderOpenFill />} size="sm" onClick={workflowLibraryModal.open}>
|
||||
<Text
|
||||
display="auto"
|
||||
noOfLines={1}
|
||||
overflow="hidden"
|
||||
textOverflow="ellipsis"
|
||||
whiteSpace="nowrap"
|
||||
wordBreak="break-all"
|
||||
>
|
||||
{workflowName || t('workflows.chooseWorkflowFromLibrary')}
|
||||
</Text>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Button, Flex, Image, Link, Text } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { useWorkflowListMenu } from 'features/nodes/store/workflowListMenu';
|
||||
import { useWorkflowLibraryModal } from 'features/nodes/store/workflowLibraryModal';
|
||||
import { selectCleanEditor, workflowModeChanged } from 'features/nodes/store/workflowSlice';
|
||||
import InvokeLogoSVG from 'public/assets/images/invoke-symbol-wht-lrg.svg';
|
||||
import { useCallback } from 'react';
|
||||
@@ -40,7 +40,7 @@ export const EmptyState = () => {
|
||||
const CleanEditorContent = () => {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useAppDispatch();
|
||||
const workflowListMenu = useWorkflowListMenu();
|
||||
const workflowLibraryModal = useWorkflowLibraryModal();
|
||||
|
||||
const onClickNewWorkflow = useCallback(() => {
|
||||
dispatch(workflowModeChanged('edit'));
|
||||
@@ -52,7 +52,7 @@ const CleanEditorContent = () => {
|
||||
<Button size="sm" onClick={onClickNewWorkflow}>
|
||||
{t('nodes.newWorkflow')}
|
||||
</Button>
|
||||
<Button size="sm" colorScheme="invokeBlue" onClick={workflowListMenu.open}>
|
||||
<Button size="sm" colorScheme="invokeBlue" onClick={workflowLibraryModal.open}>
|
||||
{t('nodes.loadWorkflow')}
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
Flex,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
} from '@invoke-ai/ui-library';
|
||||
import { useWorkflowLibraryModal } from 'features/nodes/store/workflowLibraryModal';
|
||||
|
||||
import { WorkflowLibrarySideNav } from './WorkflowLibrarySideNav';
|
||||
import { WorkflowLibraryTopNav } from './WorkflowLibraryTopNav';
|
||||
import { WorkflowList } from './WorkflowList';
|
||||
|
||||
export const WorkflowLibraryModal = () => {
|
||||
const workflowLibraryModal = useWorkflowLibraryModal();
|
||||
return (
|
||||
<Modal isOpen={workflowLibraryModal.isOpen} onClose={workflowLibraryModal.close} size="5xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Workflow Library</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={6}>
|
||||
<Flex gap={4}>
|
||||
<Flex flexDir="column" gap={4}>
|
||||
<WorkflowLibrarySideNav />
|
||||
</Flex>
|
||||
<Flex flexDir="column" flex={1} gap={6}>
|
||||
<WorkflowLibraryTopNav />
|
||||
<WorkflowList />
|
||||
</Flex>
|
||||
</Flex>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Button, Flex, IconButton } from '@invoke-ai/ui-library';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiCaretLeftBold, PiCaretRightBold } from 'react-icons/pi';
|
||||
import type { paths } from 'services/api/schema';
|
||||
|
||||
const PAGES_TO_DISPLAY = 5;
|
||||
|
||||
type PageData = {
|
||||
page: number;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
page: number;
|
||||
setPage: Dispatch<SetStateAction<number>>;
|
||||
data: paths['/api/v1/workflows/']['get']['responses']['200']['content']['application/json'];
|
||||
};
|
||||
|
||||
export const WorkflowLibraryPagination = ({ page, setPage, data }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handlePrevPage = useCallback(() => {
|
||||
setPage((p) => Math.max(p - 1, 0));
|
||||
}, [setPage]);
|
||||
|
||||
const handleNextPage = useCallback(() => {
|
||||
setPage((p) => Math.min(p + 1, data.pages - 1));
|
||||
}, [data.pages, setPage]);
|
||||
|
||||
const pages: PageData[] = useMemo(() => {
|
||||
const pages = [];
|
||||
let first = data.pages > PAGES_TO_DISPLAY ? Math.max(0, page - Math.floor(PAGES_TO_DISPLAY / 2)) : 0;
|
||||
const last = data.pages > PAGES_TO_DISPLAY ? Math.min(data.pages, first + PAGES_TO_DISPLAY) : data.pages;
|
||||
if (last - first < PAGES_TO_DISPLAY && data.pages > PAGES_TO_DISPLAY) {
|
||||
first = last - PAGES_TO_DISPLAY;
|
||||
}
|
||||
for (let i = first; i < last; i++) {
|
||||
pages.push({
|
||||
page: i,
|
||||
onClick: () => setPage(i),
|
||||
});
|
||||
}
|
||||
return pages;
|
||||
}, [data.pages, page, setPage]);
|
||||
|
||||
return (
|
||||
<Flex justifyContent="center" alignItems="center" w="full" gap={1} pt={2}>
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handlePrevPage}
|
||||
isDisabled={page === 0}
|
||||
aria-label={t('common.prevPage')}
|
||||
icon={<PiCaretLeftBold />}
|
||||
/>
|
||||
|
||||
{pages.map((p) => (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={p.page === page ? 'solid' : 'outline'}
|
||||
isDisabled={data.pages === 1}
|
||||
onClick={p.page === page ? undefined : p.onClick}
|
||||
key={p.page}
|
||||
transitionDuration="0s" // the delay in animation looks jank
|
||||
>
|
||||
{p.page + 1}
|
||||
</Button>
|
||||
))}
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleNextPage}
|
||||
isDisabled={page === data.pages - 1}
|
||||
aria-label={t('common.nextPage')}
|
||||
icon={<PiCaretRightBold />}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
/* eslint-disable i18next/no-literal-string */
|
||||
import { Button, Flex } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { $workflowCategories } from 'app/store/nanostores/workflowCategories';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import type { WorkflowLibraryCategory } from 'features/nodes/store/types';
|
||||
import { selectWorkflowBrowsingCategory, workflowBrowsingCategoryChanged } from 'features/nodes/store/workflowSlice';
|
||||
import { useCallback } from 'react';
|
||||
import { PiUsersBold } from 'react-icons/pi';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
export const WorkflowLibrarySideNav = () => {
|
||||
const dispatch = useDispatch();
|
||||
const browsingCategory = useAppSelector(selectWorkflowBrowsingCategory);
|
||||
const workflowCategories = useStore($workflowCategories);
|
||||
|
||||
const handleCategoryChange = useCallback(
|
||||
(category: WorkflowLibraryCategory) => {
|
||||
dispatch(workflowBrowsingCategoryChanged(category));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex flexDir="column" gap={2} borderRight="1px solid" borderColor="base.400" h="full" pr={4}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
fontWeight="bold"
|
||||
justifyContent="flex-start"
|
||||
size="md"
|
||||
isActive={browsingCategory === 'account'}
|
||||
onClick={handleCategoryChange.bind(null, 'account')}
|
||||
_active={{
|
||||
bg: 'base.700',
|
||||
color: 'base.100',
|
||||
}}
|
||||
>
|
||||
Your Workflows
|
||||
</Button>
|
||||
{workflowCategories.includes('project') && (
|
||||
<Flex flexDir="column" gap={2} pl={4}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
fontWeight="bold"
|
||||
justifyContent="flex-start"
|
||||
size="sm"
|
||||
isActive={browsingCategory === 'private'}
|
||||
onClick={handleCategoryChange.bind(null, 'private')}
|
||||
_active={{
|
||||
bg: 'base.700',
|
||||
color: 'base.100',
|
||||
}}
|
||||
>
|
||||
Private
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
fontWeight="bold"
|
||||
justifyContent="flex-start"
|
||||
size="sm"
|
||||
rightIcon={<PiUsersBold />}
|
||||
isActive={browsingCategory === 'shared'}
|
||||
onClick={handleCategoryChange.bind(null, 'shared')}
|
||||
_active={{
|
||||
bg: 'base.700',
|
||||
color: 'base.100',
|
||||
}}
|
||||
>
|
||||
Shared
|
||||
</Button>
|
||||
</Flex>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
fontWeight="bold"
|
||||
justifyContent="flex-start"
|
||||
size="md"
|
||||
isActive={browsingCategory === 'default'}
|
||||
onClick={handleCategoryChange.bind(null, 'default')}
|
||||
_active={{
|
||||
bg: 'base.700',
|
||||
color: 'base.100',
|
||||
}}
|
||||
>
|
||||
Browse Workflows
|
||||
</Button>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Flex } from '@invoke-ai/ui-library';
|
||||
import { useRef } from 'react';
|
||||
|
||||
import { WorkflowSearch } from './WorkflowSearch';
|
||||
import { WorkflowSortControl } from './WorkflowSortControl';
|
||||
|
||||
export const WorkflowLibraryTopNav = () => {
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
return (
|
||||
<Flex gap={8} justifyContent="space-between">
|
||||
<WorkflowSearch searchInputRef={searchInputRef} />
|
||||
<WorkflowSortControl />
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Flex, Grid, GridItem, Spinner } from '@invoke-ai/ui-library';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
|
||||
import type { WorkflowLibraryCategory } from 'features/nodes/store/types';
|
||||
import {
|
||||
selectWorkflowBrowsingCategory,
|
||||
selectWorkflowOrderBy,
|
||||
selectWorkflowOrderDirection,
|
||||
selectWorkflowSearchTerm,
|
||||
} from 'features/nodes/store/workflowSlice';
|
||||
import type { WorkflowCategory } from 'features/nodes/types/workflow';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useListWorkflowsQuery } from 'services/api/endpoints/workflows';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
|
||||
import { WorkflowLibraryPagination } from './WorkflowLibraryPagination';
|
||||
import { WorkflowListItem } from './WorkflowListItem';
|
||||
|
||||
const PER_PAGE = 6;
|
||||
|
||||
const mapUiCategoryToApiCategory = (sideNav: WorkflowLibraryCategory): WorkflowCategory[] => {
|
||||
switch (sideNav) {
|
||||
case 'account':
|
||||
return ['user', 'project'];
|
||||
case 'private':
|
||||
return ['user'];
|
||||
case 'shared':
|
||||
return ['project'];
|
||||
case 'default':
|
||||
return ['default'];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const WorkflowList = () => {
|
||||
const searchTerm = useAppSelector(selectWorkflowSearchTerm);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [page, setPage] = useState(0);
|
||||
const browsingCategory = useAppSelector(selectWorkflowBrowsingCategory);
|
||||
const orderBy = useAppSelector(selectWorkflowOrderBy);
|
||||
const direction = useAppSelector(selectWorkflowOrderDirection);
|
||||
const query = useAppSelector(selectWorkflowSearchTerm);
|
||||
const [debouncedQuery] = useDebounce(query, 500);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(0);
|
||||
}, [browsingCategory, query]);
|
||||
|
||||
const queryArg = useMemo<Parameters<typeof useListWorkflowsQuery>[0]>(() => {
|
||||
const categories = mapUiCategoryToApiCategory(browsingCategory);
|
||||
return {
|
||||
page,
|
||||
per_page: PER_PAGE,
|
||||
order_by: orderBy,
|
||||
direction,
|
||||
categories,
|
||||
query: debouncedQuery,
|
||||
};
|
||||
}, [direction, orderBy, page, browsingCategory, debouncedQuery]);
|
||||
|
||||
const { data, isLoading } = useListWorkflowsQuery(queryArg);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Flex alignItems="center" justifyContent="center" p={20}>
|
||||
<Spinner />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data?.items.length) {
|
||||
return (
|
||||
<IAINoContentFallback
|
||||
fontSize="sm"
|
||||
py={4}
|
||||
label={searchTerm ? t('nodes.noMatchingWorkflows') : t('nodes.noWorkflows')}
|
||||
icon={null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex flexDir="column" gap={6}>
|
||||
<Grid templateColumns="repeat(2, minmax(200px, 3fr))" gap={4}>
|
||||
{data?.items.map((workflow) => (
|
||||
<GridItem key={workflow.workflow_id}>
|
||||
<WorkflowListItem workflow={workflow} key={workflow.workflow_id} />
|
||||
</GridItem>
|
||||
))}
|
||||
</Grid>
|
||||
<WorkflowLibraryPagination page={page} setPage={setPage} data={data} />
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,219 @@
|
||||
import { Badge, Button, Flex, Icon, IconButton, Image, Spacer, Text, Tooltip } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { $projectUrl } from 'app/store/nanostores/projectId';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { selectWorkflowId } from 'features/nodes/store/workflowSlice';
|
||||
import { useDeleteWorkflow } from 'features/workflowLibrary/components/DeleteLibraryWorkflowConfirmationAlertDialog';
|
||||
import { useLoadWorkflow } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog';
|
||||
import { useDownloadWorkflowById } from 'features/workflowLibrary/hooks/useDownloadWorkflowById';
|
||||
import type { MouseEvent } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
PiDownloadSimpleBold,
|
||||
PiImageBold,
|
||||
PiPencilBold,
|
||||
PiShareFatBold,
|
||||
PiTrashBold,
|
||||
PiUsersBold,
|
||||
} from 'react-icons/pi';
|
||||
import type { WorkflowRecordListItemWithThumbnailDTO } from 'services/api/types';
|
||||
|
||||
import { useShareWorkflow } from './ShareWorkflowModal';
|
||||
|
||||
const IMAGE_THUMBNAIL_SIZE = '80px';
|
||||
const FALLBACK_ICON_SIZE = '24px';
|
||||
|
||||
export const WorkflowListItem = ({ workflow }: { workflow: WorkflowRecordListItemWithThumbnailDTO }) => {
|
||||
const { t } = useTranslation();
|
||||
const projectUrl = useStore($projectUrl);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const handleMouseOver = useCallback(() => {
|
||||
setIsHovered(true);
|
||||
}, []);
|
||||
|
||||
const handleMouseOut = useCallback(() => {
|
||||
setIsHovered(false);
|
||||
}, []);
|
||||
|
||||
const workflowId = useAppSelector(selectWorkflowId);
|
||||
const downloadWorkflowById = useDownloadWorkflowById();
|
||||
const shareWorkflow = useShareWorkflow();
|
||||
const deleteWorkflow = useDeleteWorkflow();
|
||||
const loadWorkflow = useLoadWorkflow();
|
||||
|
||||
const isActive = useMemo(() => {
|
||||
return workflowId === workflow.workflow_id;
|
||||
}, [workflowId, workflow.workflow_id]);
|
||||
|
||||
const handleClickLoad = useCallback(() => {
|
||||
setIsHovered(false);
|
||||
loadWorkflow.loadWithDialog(workflow.workflow_id, 'view');
|
||||
}, [loadWorkflow, workflow.workflow_id]);
|
||||
|
||||
const handleClickEdit = useCallback(() => {
|
||||
setIsHovered(false);
|
||||
loadWorkflow.loadWithDialog(workflow.workflow_id, 'edit');
|
||||
}, [loadWorkflow, workflow.workflow_id]);
|
||||
|
||||
const handleClickDelete = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
setIsHovered(false);
|
||||
deleteWorkflow(workflow);
|
||||
},
|
||||
[deleteWorkflow, workflow]
|
||||
);
|
||||
|
||||
const handleClickShare = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
setIsHovered(false);
|
||||
shareWorkflow(workflow);
|
||||
},
|
||||
[shareWorkflow, workflow]
|
||||
);
|
||||
|
||||
const handleClickDownload = useCallback(
|
||||
(e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
setIsHovered(false);
|
||||
downloadWorkflowById.downloadWorkflow(workflow.workflow_id);
|
||||
},
|
||||
[downloadWorkflowById, workflow.workflow_id]
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
gap={4}
|
||||
onClick={handleClickLoad}
|
||||
cursor="pointer"
|
||||
bg="base.750"
|
||||
_hover={{ backgroundColor: 'base.700' }}
|
||||
p={2}
|
||||
ps={3}
|
||||
borderRadius="base"
|
||||
w="full"
|
||||
onMouseOver={handleMouseOver}
|
||||
onMouseOut={handleMouseOut}
|
||||
alignItems="stretch"
|
||||
>
|
||||
<Image
|
||||
src=""
|
||||
fallbackStrategy="beforeLoadOrError"
|
||||
fallback={
|
||||
<Flex
|
||||
height={IMAGE_THUMBNAIL_SIZE}
|
||||
minWidth={IMAGE_THUMBNAIL_SIZE}
|
||||
bg="base.650"
|
||||
borderRadius="base"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Icon color="base.500" as={PiImageBold} boxSize={FALLBACK_ICON_SIZE} />
|
||||
</Flex>
|
||||
}
|
||||
objectFit="cover"
|
||||
objectPosition="50% 50%"
|
||||
height={IMAGE_THUMBNAIL_SIZE}
|
||||
width={IMAGE_THUMBNAIL_SIZE}
|
||||
minHeight={IMAGE_THUMBNAIL_SIZE}
|
||||
minWidth={IMAGE_THUMBNAIL_SIZE}
|
||||
borderRadius="base"
|
||||
/>
|
||||
<Flex flexDir="column" gap={1} justifyContent="flex-start">
|
||||
<Flex gap={4} alignItems="center">
|
||||
<Text noOfLines={2}>{workflow.name}</Text>
|
||||
|
||||
{isActive && (
|
||||
<Badge color="invokeBlue.400" borderColor="invokeBlue.700" borderWidth={1} bg="transparent" flexShrink={0}>
|
||||
{t('workflows.opened')}
|
||||
</Badge>
|
||||
)}
|
||||
</Flex>
|
||||
<Text variant="subtext" fontSize="xs" noOfLines={2}>
|
||||
{workflow.description}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Spacer />
|
||||
<Flex flexDir="column" gap={1} justifyContent="space-between">
|
||||
<Flex gap={1} justifyContent="flex-end" w="full" p={2}>
|
||||
{workflow.category === 'project' && <Icon as={PiUsersBold} color="base.200" />}
|
||||
{workflow.category === 'default' && <Icon as={PiUsersBold} color="base.200" />}
|
||||
</Flex>
|
||||
|
||||
{workflow.category !== 'default' ? (
|
||||
<Flex alignItems="center" gap={1} opacity={isHovered ? 1 : 0}>
|
||||
<Tooltip
|
||||
label={t('workflows.edit')}
|
||||
// This prevents an issue where the tooltip isn't closed after the modal is opened
|
||||
isOpen={!isHovered ? false : undefined}
|
||||
closeOnScroll
|
||||
>
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={t('workflows.edit')}
|
||||
onClick={handleClickEdit}
|
||||
icon={<PiPencilBold />}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
label={t('workflows.download')}
|
||||
// This prevents an issue where the tooltip isn't closed after the modal is opened
|
||||
isOpen={!isHovered ? false : undefined}
|
||||
closeOnScroll
|
||||
>
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={t('workflows.download')}
|
||||
onClick={handleClickDownload}
|
||||
icon={<PiDownloadSimpleBold />}
|
||||
isLoading={downloadWorkflowById.isLoading}
|
||||
/>
|
||||
</Tooltip>
|
||||
{!!projectUrl && workflow.workflow_id && workflow.category !== 'user' && (
|
||||
<Tooltip
|
||||
label={t('workflows.copyShareLink')}
|
||||
// This prevents an issue where the tooltip isn't closed after the modal is opened
|
||||
isOpen={!isHovered ? false : undefined}
|
||||
closeOnScroll
|
||||
>
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={t('workflows.copyShareLink')}
|
||||
onClick={handleClickShare}
|
||||
icon={<PiShareFatBold />}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip
|
||||
label={t('workflows.delete')}
|
||||
// This prevents an issue where the tooltip isn't closed after the modal is opened
|
||||
isOpen={!isHovered ? false : undefined}
|
||||
closeOnScroll
|
||||
>
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={t('workflows.delete')}
|
||||
onClick={handleClickDelete}
|
||||
colorScheme="error"
|
||||
icon={<PiTrashBold />}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
) : (
|
||||
<Flex flexDir="column" alignItems="center" gap={1} opacity={isHovered ? 1 : 0}>
|
||||
<Button size="xs">Try it out</Button>
|
||||
<Button size="xs">Copy to account</Button>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IconButton, Input, InputGroup, InputRightElement } from '@invoke-ai/ui-library';
|
||||
import { Flex, IconButton, Input, InputGroup, InputRightElement } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { selectWorkflowSearchTerm, workflowSearchTermChanged } from 'features/nodes/store/workflowSlice';
|
||||
import type { ChangeEvent, KeyboardEvent, RefObject } from 'react';
|
||||
@@ -46,26 +46,28 @@ export const WorkflowSearch = memo(({ searchInputRef }: { searchInputRef: RefObj
|
||||
}, [searchInputRef]);
|
||||
|
||||
return (
|
||||
<InputGroup>
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
placeholder={t('stylePresets.searchByName')}
|
||||
value={searchTerm}
|
||||
onKeyDown={handleKeydown}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
{searchTerm && searchTerm.length && (
|
||||
<InputRightElement h="full" pe={2}>
|
||||
<IconButton
|
||||
onClick={clearWorkflowSearch}
|
||||
size="sm"
|
||||
variant="link"
|
||||
aria-label={t('boards.clearSearch')}
|
||||
icon={<PiXBold />}
|
||||
/>
|
||||
</InputRightElement>
|
||||
)}
|
||||
</InputGroup>
|
||||
<Flex justifyContent="flex-end" w="300px">
|
||||
<InputGroup>
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
placeholder={t('stylePresets.searchByName')}
|
||||
value={searchTerm}
|
||||
onKeyDown={handleKeydown}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
{searchTerm && searchTerm.length && (
|
||||
<InputRightElement h="full" pe={2}>
|
||||
<IconButton
|
||||
onClick={clearWorkflowSearch}
|
||||
size="sm"
|
||||
variant="link"
|
||||
aria-label={t('boards.clearSearch')}
|
||||
icon={<PiXBold />}
|
||||
/>
|
||||
</InputRightElement>
|
||||
)}
|
||||
</InputGroup>
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,14 +1,4 @@
|
||||
import {
|
||||
Flex,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
IconButton,
|
||||
Popover,
|
||||
PopoverBody,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Select,
|
||||
} from '@invoke-ai/ui-library';
|
||||
import { Flex, FormControl, FormLabel, Select } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { $projectId } from 'app/store/nanostores/projectId';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
@@ -21,7 +11,6 @@ import {
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiSortAscendingBold, PiSortDescendingBold } from 'react-icons/pi';
|
||||
import { z } from 'zod';
|
||||
|
||||
const zOrderBy = z.enum(['opened_at', 'created_at', 'updated_at', 'name']);
|
||||
@@ -83,38 +72,23 @@ export const WorkflowSortControl = () => {
|
||||
const defaultOrderBy = projectId !== undefined ? 'opened_at' : 'created_at';
|
||||
|
||||
return (
|
||||
<Popover placement="bottom">
|
||||
<PopoverTrigger>
|
||||
<IconButton
|
||||
tooltip={`Sorting by ${ORDER_BY_LABELS[orderBy ?? defaultOrderBy]} ${DIRECTION_LABELS[direction]}`}
|
||||
aria-label="Sort Workflow Library"
|
||||
icon={direction === 'ASC' ? <PiSortAscendingBold /> : <PiSortDescendingBold />}
|
||||
variant="ghost"
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent>
|
||||
<PopoverBody>
|
||||
<Flex flexDir="column" gap={4}>
|
||||
<FormControl orientation="horizontal" gap={1}>
|
||||
<FormLabel>{t('common.orderBy')}</FormLabel>
|
||||
<Select value={orderBy ?? defaultOrderBy} onChange={onChangeOrderBy} size="sm">
|
||||
{projectId !== undefined && <option value="opened_at">{ORDER_BY_LABELS['opened_at']}</option>}
|
||||
<option value="created_at">{ORDER_BY_LABELS['created_at']}</option>
|
||||
<option value="updated_at">{ORDER_BY_LABELS['updated_at']}</option>
|
||||
<option value="name">{ORDER_BY_LABELS['name']}</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl orientation="horizontal" gap={1}>
|
||||
<FormLabel>{t('common.direction')}</FormLabel>
|
||||
<Select value={direction} onChange={onChangeDirection} size="sm">
|
||||
<option value="ASC">{DIRECTION_LABELS['ASC']}</option>
|
||||
<option value="DESC">{DIRECTION_LABELS['DESC']}</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Flex>
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Flex flexDir="row" gap={6}>
|
||||
<FormControl orientation="horizontal" gap={0} w="auto">
|
||||
<FormLabel>{t('common.orderBy')}</FormLabel>
|
||||
<Select value={orderBy ?? defaultOrderBy} onChange={onChangeOrderBy} size="sm">
|
||||
{projectId !== undefined && <option value="opened_at">{ORDER_BY_LABELS['opened_at']}</option>}
|
||||
<option value="created_at">{ORDER_BY_LABELS['created_at']}</option>
|
||||
<option value="updated_at">{ORDER_BY_LABELS['updated_at']}</option>
|
||||
<option value="name">{ORDER_BY_LABELS['name']}</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl orientation="horizontal" gap={0} w="auto">
|
||||
<FormLabel>{t('common.direction')}</FormLabel>
|
||||
<Select value={direction} onChange={onChangeDirection} size="sm">
|
||||
<option value="ASC">{DIRECTION_LABELS['ASC']}</option>
|
||||
<option value="DESC">{DIRECTION_LABELS['DESC']}</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -22,10 +22,13 @@ export type NodesState = {
|
||||
|
||||
export type WorkflowMode = 'edit' | 'view';
|
||||
|
||||
export type WorkflowLibraryCategory = 'account' | 'private' | 'shared' | 'favorites' | `default`;
|
||||
|
||||
export type WorkflowsState = Omit<WorkflowV3, 'nodes' | 'edges'> & {
|
||||
_version: 1;
|
||||
isTouched: boolean;
|
||||
mode: WorkflowMode;
|
||||
browsingCategory: WorkflowLibraryCategory;
|
||||
searchTerm: string;
|
||||
orderBy?: WorkflowRecordOrderBy;
|
||||
orderDirection: SQLiteDirection;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { buildUseDisclosure } from 'common/hooks/useBoolean';
|
||||
|
||||
/**
|
||||
* Tracks the state for the workflow library modal.
|
||||
*/
|
||||
export const [useWorkflowLibraryModal, $isWorkflowLibraryModalOpen] = buildUseDisclosure(false);
|
||||
@@ -1,6 +0,0 @@
|
||||
import { buildUseDisclosure } from 'common/hooks/useBoolean';
|
||||
|
||||
/**
|
||||
* Tracks the state for the workflow list menu.
|
||||
*/
|
||||
export const [useWorkflowListMenu, $isWorkflowListMenuIsOpen] = buildUseDisclosure(false);
|
||||
@@ -11,7 +11,12 @@ import {
|
||||
} from 'features/nodes/components/sidePanel/builder/form-manipulation';
|
||||
import { workflowLoaded } from 'features/nodes/store/actions';
|
||||
import { isAnyNodeOrEdgeMutation, nodeEditorReset, nodesChanged } from 'features/nodes/store/nodesSlice';
|
||||
import type { NodesState, WorkflowMode, WorkflowsState as WorkflowState } from 'features/nodes/store/types';
|
||||
import type {
|
||||
NodesState,
|
||||
WorkflowLibraryCategory,
|
||||
WorkflowMode,
|
||||
WorkflowsState as WorkflowState,
|
||||
} from 'features/nodes/store/types';
|
||||
import type { FieldIdentifier, StatefulFieldValue } from 'features/nodes/types/field';
|
||||
import { isInvocationNode } from 'features/nodes/types/invocation';
|
||||
import type {
|
||||
@@ -82,6 +87,7 @@ const initialWorkflowState: WorkflowState = {
|
||||
orderBy: undefined, // initial value is decided in component
|
||||
orderDirection: 'DESC',
|
||||
categorySections: {},
|
||||
browsingCategory: 'account',
|
||||
...getBlankWorkflow(),
|
||||
};
|
||||
|
||||
@@ -101,6 +107,10 @@ export const workflowSlice = createSlice({
|
||||
workflowOrderDirectionChanged: (state, action: PayloadAction<SQLiteDirection>) => {
|
||||
state.orderDirection = action.payload;
|
||||
},
|
||||
workflowBrowsingCategoryChanged: (state, action: PayloadAction<WorkflowLibraryCategory>) => {
|
||||
state.browsingCategory = action.payload;
|
||||
state.searchTerm = '';
|
||||
},
|
||||
categorySectionsChanged: (state, action: PayloadAction<{ id: string; isOpen: boolean }>) => {
|
||||
const { id, isOpen } = action.payload;
|
||||
state.categorySections[id] = isOpen;
|
||||
@@ -299,6 +309,7 @@ export const {
|
||||
workflowSearchTermChanged,
|
||||
workflowOrderByChanged,
|
||||
workflowOrderDirectionChanged,
|
||||
workflowBrowsingCategoryChanged,
|
||||
categorySectionsChanged,
|
||||
formReset,
|
||||
formElementAdded,
|
||||
@@ -365,6 +376,7 @@ export const selectWorkflowIsTouched = createWorkflowSelector((workflow) => work
|
||||
export const selectWorkflowSearchTerm = createWorkflowSelector((workflow) => workflow.searchTerm);
|
||||
export const selectWorkflowOrderBy = createWorkflowSelector((workflow) => workflow.orderBy);
|
||||
export const selectWorkflowOrderDirection = createWorkflowSelector((workflow) => workflow.orderDirection);
|
||||
export const selectWorkflowBrowsingCategory = createWorkflowSelector((workflow) => workflow.browsingCategory);
|
||||
export const selectWorkflowDescription = createWorkflowSelector((workflow) => workflow.description);
|
||||
export const selectWorkflowForm = createWorkflowSelector((workflow) => workflow.form);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ConfirmationAlertDialog, Flex, Text } from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
|
||||
import { useWorkflowListMenu } from 'features/nodes/store/workflowListMenu';
|
||||
import { useWorkflowLibraryModal } from 'features/nodes/store/workflowLibraryModal';
|
||||
import { selectWorkflowIsTouched, workflowModeChanged } from 'features/nodes/store/workflowSlice';
|
||||
import { useGetAndLoadLibraryWorkflow } from 'features/workflowLibrary/hooks/useGetAndLoadLibraryWorkflow';
|
||||
import { atom } from 'nanostores';
|
||||
@@ -14,7 +14,7 @@ const cleanup = () => $workflowToLoad.set(null);
|
||||
|
||||
export const useLoadWorkflow = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const workflowListMenu = useWorkflowListMenu();
|
||||
const workflowLibraryModal = useWorkflowLibraryModal();
|
||||
const { getAndLoadWorkflow } = useGetAndLoadLibraryWorkflow();
|
||||
|
||||
const isTouched = useAppSelector(selectWorkflowIsTouched);
|
||||
@@ -28,8 +28,8 @@ export const useLoadWorkflow = () => {
|
||||
await getAndLoadWorkflow(workflowId);
|
||||
dispatch(workflowModeChanged(mode));
|
||||
cleanup();
|
||||
workflowListMenu.close();
|
||||
}, [dispatch, getAndLoadWorkflow, workflowListMenu]);
|
||||
workflowLibraryModal.close();
|
||||
}, [dispatch, getAndLoadWorkflow, workflowLibraryModal]);
|
||||
|
||||
const loadWithDialog = useCallback(
|
||||
(workflowId: string, mode: 'view' | 'edit') => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { IconButton } from '@invoke-ai/ui-library';
|
||||
import { useWorkflowListMenu } from 'features/nodes/store/workflowListMenu';
|
||||
import { useWorkflowLibraryModal } from 'features/nodes/store/workflowLibraryModal';
|
||||
import { saveWorkflowAs } from 'features/workflowLibrary/components/SaveWorkflowAsDialog';
|
||||
import { useLoadWorkflowFromFile } from 'features/workflowLibrary/hooks/useLoadWorkflowFromFile';
|
||||
import { memo, useCallback, useRef } from 'react';
|
||||
@@ -10,12 +10,12 @@ import { PiUploadSimpleBold } from 'react-icons/pi';
|
||||
const UploadWorkflowButton = () => {
|
||||
const { t } = useTranslation();
|
||||
const resetRef = useRef<() => void>(null);
|
||||
const workflowListMenu = useWorkflowListMenu();
|
||||
const workflowLibraryModal = useWorkflowLibraryModal();
|
||||
|
||||
const loadWorkflowFromFile = useLoadWorkflowFromFile({
|
||||
resetRef,
|
||||
onSuccess: (workflow) => {
|
||||
workflowListMenu.close();
|
||||
workflowLibraryModal.close();
|
||||
saveWorkflowAs(workflow);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { paths } from 'services/api/schema';
|
||||
|
||||
import { api, buildV1Url, LIST_TAG } from '..';
|
||||
import queryString from 'query-string';
|
||||
|
||||
/**
|
||||
* Builds an endpoint URL for the workflows router
|
||||
@@ -73,8 +74,7 @@ export const workflowsApi = api.injectEndpoints({
|
||||
NonNullable<paths['/api/v1/workflows/']['get']['parameters']['query']>
|
||||
>({
|
||||
query: (params) => ({
|
||||
url: buildWorkflowsUrl(),
|
||||
params,
|
||||
url: `${buildWorkflowsUrl()}?${queryString.stringify(params, { arrayFormat: 'none' })}`,
|
||||
}),
|
||||
providesTags: ['FetchOnReconnect', { type: 'Workflow', id: LIST_TAG }],
|
||||
}),
|
||||
|
||||
@@ -24252,8 +24252,8 @@ export interface operations {
|
||||
order_by?: components["schemas"]["WorkflowRecordOrderBy"];
|
||||
/** @description The direction to order by */
|
||||
direction?: components["schemas"]["SQLiteDirection"];
|
||||
/** @description The category of workflow to get */
|
||||
category?: components["schemas"]["WorkflowCategory"];
|
||||
/** @description The categories of workflow to get */
|
||||
categories?: components["schemas"]["WorkflowCategory"][] | null;
|
||||
/** @description The text to query by (matches name and description) */
|
||||
query?: string | null;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user