another new workflow library

This commit is contained in:
Mary Hipp
2025-02-27 13:31:46 -05:00
committed by psychedelicious
parent 518a7c941f
commit d5c5e8e8ed
27 changed files with 682 additions and 461 deletions

View File

@@ -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(

View File

@@ -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],

View File

@@ -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]

View 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 />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
import { buildUseDisclosure } from 'common/hooks/useBoolean';
/**
* Tracks the state for the workflow library modal.
*/
export const [useWorkflowLibraryModal, $isWorkflowLibraryModalOpen] = buildUseDisclosure(false);

View File

@@ -1,6 +0,0 @@
import { buildUseDisclosure } from 'common/hooks/useBoolean';
/**
* Tracks the state for the workflow list menu.
*/
export const [useWorkflowListMenu, $isWorkflowListMenuIsOpen] = buildUseDisclosure(false);

View File

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

View File

@@ -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') => {

View File

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

View File

@@ -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 }],
}),

View File

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