diff --git a/invokeai/app/api/routers/workflows.py b/invokeai/app/api/routers/workflows.py
index 07f7b9ad0c..439f4353ce 100644
--- a/invokeai/app/api/routers/workflows.py
+++ b/invokeai/app/api/routers/workflows.py
@@ -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(
diff --git a/invokeai/app/services/workflow_records/workflow_records_base.py b/invokeai/app/services/workflow_records/workflow_records_base.py
index 9da830eaaf..a884c7b7ed 100644
--- a/invokeai/app/services/workflow_records/workflow_records_base.py
+++ b/invokeai/app/services/workflow_records/workflow_records_base.py
@@ -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],
diff --git a/invokeai/app/services/workflow_records/workflow_records_sqlite.py b/invokeai/app/services/workflow_records/workflow_records_sqlite.py
index d652530b0a..a54fc916a7 100644
--- a/invokeai/app/services/workflow_records/workflow_records_sqlite.py
+++ b/invokeai/app/services/workflow_records/workflow_records_sqlite.py
@@ -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]
diff --git a/invokeai/frontend/web/src/app/components/App.tsx b/invokeai/frontend/web/src/app/components/App.tsx
index ea9fa81311..78b22e8f8e 100644
--- a/invokeai/frontend/web/src/app/components/App.tsx
+++ b/invokeai/frontend/web/src/app/components/App.tsx
@@ -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(() => {
+
diff --git a/invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts b/invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts
index 9b7b38427e..8af256ad38 100644
--- a/invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts
+++ b/invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts
@@ -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
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowList.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowList.tsx
deleted file mode 100644
index 62eaf3316c..0000000000
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowList.tsx
+++ /dev/null
@@ -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[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 (
-
-
-
- {isLoading ? (
-
-
-
- ) : data.length ? (
- data.map((workflow) => )
- ) : (
-
- )}
-
-
- );
-};
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowListItem.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowListItem.tsx
deleted file mode 100644
index 68355552b7..0000000000
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowListItem.tsx
+++ /dev/null
@@ -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) => {
- e.stopPropagation();
- setIsHovered(false);
- loadWorkflow.loadWithDialog(workflow.workflow_id, 'view');
- },
- [loadWorkflow, workflow.workflow_id]
- );
-
- const handleClickEdit = useCallback(
- (e: MouseEvent) => {
- e.stopPropagation();
- setIsHovered(false);
- loadWorkflow.loadWithDialog(workflow.workflow_id, 'edit');
- },
- [loadWorkflow, workflow.workflow_id]
- );
-
- const handleClickDelete = useCallback(
- (e: MouseEvent) => {
- e.stopPropagation();
- setIsHovered(false);
- deleteWorkflow(workflow);
- },
- [deleteWorkflow, workflow]
- );
-
- const handleClickShare = useCallback(
- (e: MouseEvent) => {
- e.stopPropagation();
- setIsHovered(false);
- shareWorkflow(workflow);
- },
- [shareWorkflow, workflow]
- );
-
- const handleClickDownload = useCallback(
- (e: MouseEvent) => {
- e.stopPropagation();
- setIsHovered(false);
- downloadWorkflow(workflow.workflow_id);
- },
- [downloadWorkflow, workflow.workflow_id]
- );
-
- return (
-
- } closeOnScroll>
-
-
- {workflow.name}
-
- {isActive && (
-
- {t('workflows.opened')}
-
- )}
-
- {workflow.category !== 'default' && (
-
- {t('common.updated')}: {dateFormat(workflow.updated_at, masks.shortDate)}{' '}
- {dateFormat(workflow.updated_at, masks.shortTime)}
-
- )}
-
-
-
-
-
-
- }
- />
-
-
- }
- isLoading={isLoadingDownloadWorkflow}
- />
-
- {!!projectUrl && workflow.workflow_id && workflow.category !== 'user' && (
-
- }
- />
-
- )}
- {workflow.category !== 'default' && (
-
- }
- />
-
- )}
-
-
- );
-};
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowListMenuTrigger.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowListMenuTrigger.tsx
index c2fe14669f..2b8fe8bd20 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowListMenuTrigger.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowListMenuTrigger.tsx
@@ -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(null);
const workflowName = useAppSelector(selectWorkflowName);
return (
-
-
- } size="sm">
-
- {workflowName || t('workflows.chooseWorkflowFromLibrary')}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {workflowCategories.map((category) => (
-
- ))}
-
-
-
-
-
-
-
+ } size="sm" onClick={workflowLibraryModal.open}>
+
+ {workflowName || t('workflows.chooseWorkflowFromLibrary')}
+
+
);
};
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/viewMode/EmptyState.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/viewMode/EmptyState.tsx
index b23803ef22..861ca8adce 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/viewMode/EmptyState.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/viewMode/EmptyState.tsx
@@ -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 = () => {
-
+
+ )}
+
+ Browse Workflows
+
+
+ );
+};
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryTopNav.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryTopNav.tsx
new file mode 100644
index 0000000000..01c7545e23
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryTopNav.tsx
@@ -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(null);
+ return (
+
+
+
+
+ );
+};
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx
new file mode 100644
index 0000000000..2655fc35f1
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx
@@ -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[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 (
+
+
+
+ );
+ }
+
+ if (!data?.items.length) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ {data?.items.map((workflow) => (
+
+
+
+ ))}
+
+
+
+ );
+};
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx
new file mode 100644
index 0000000000..cd71935a91
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItem.tsx
@@ -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) => {
+ e.stopPropagation();
+ setIsHovered(false);
+ deleteWorkflow(workflow);
+ },
+ [deleteWorkflow, workflow]
+ );
+
+ const handleClickShare = useCallback(
+ (e: MouseEvent) => {
+ e.stopPropagation();
+ setIsHovered(false);
+ shareWorkflow(workflow);
+ },
+ [shareWorkflow, workflow]
+ );
+
+ const handleClickDownload = useCallback(
+ (e: MouseEvent) => {
+ e.stopPropagation();
+ setIsHovered(false);
+ downloadWorkflowById.downloadWorkflow(workflow.workflow_id);
+ },
+ [downloadWorkflowById, workflow.workflow_id]
+ );
+
+ return (
+
+
+
+
+ }
+ objectFit="cover"
+ objectPosition="50% 50%"
+ height={IMAGE_THUMBNAIL_SIZE}
+ width={IMAGE_THUMBNAIL_SIZE}
+ minHeight={IMAGE_THUMBNAIL_SIZE}
+ minWidth={IMAGE_THUMBNAIL_SIZE}
+ borderRadius="base"
+ />
+
+
+ {workflow.name}
+
+ {isActive && (
+
+ {t('workflows.opened')}
+
+ )}
+
+
+ {workflow.description}
+
+
+
+
+
+
+ {workflow.category === 'project' && }
+ {workflow.category === 'default' && }
+
+
+ {workflow.category !== 'default' ? (
+
+
+ }
+ />
+
+
+ }
+ isLoading={downloadWorkflowById.isLoading}
+ />
+
+ {!!projectUrl && workflow.workflow_id && workflow.category !== 'user' && (
+
+ }
+ />
+
+ )}
+
+ }
+ />
+
+
+ ) : (
+
+ Try it out
+ Copy to account
+
+ )}
+
+
+ );
+};
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowListItemTooltip.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItemTooltip.tsx
similarity index 100%
rename from invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowListItemTooltip.tsx
rename to invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowListItemTooltip.tsx
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowSearch.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowSearch.tsx
similarity index 66%
rename from invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowSearch.tsx
rename to invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowSearch.tsx
index 145c48f29b..716c24269a 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowSearch.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowSearch.tsx
@@ -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 (
-
-
- {searchTerm && searchTerm.length && (
-
- }
- />
-
- )}
-
+
+
+
+ {searchTerm && searchTerm.length && (
+
+ }
+ />
+
+ )}
+
+
);
});
diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowSortControl.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowSortControl.tsx
similarity index 55%
rename from invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowSortControl.tsx
rename to invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowSortControl.tsx
index c80d8f0b87..f43c5b3942 100644
--- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/WorkflowListMenu/WorkflowSortControl.tsx
+++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowSortControl.tsx
@@ -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 (
-
-
- : }
- variant="ghost"
- />
-
-
-
-
-
-
- {t('common.orderBy')}
-
-
-
- {t('common.direction')}
-
-
-
-
-
-
+
+
+ {t('common.orderBy')}
+
+
+
+ {t('common.direction')}
+
+
+
);
};
diff --git a/invokeai/frontend/web/src/features/nodes/store/types.ts b/invokeai/frontend/web/src/features/nodes/store/types.ts
index c5ee26665b..4f63cb72d4 100644
--- a/invokeai/frontend/web/src/features/nodes/store/types.ts
+++ b/invokeai/frontend/web/src/features/nodes/store/types.ts
@@ -22,10 +22,13 @@ export type NodesState = {
export type WorkflowMode = 'edit' | 'view';
+export type WorkflowLibraryCategory = 'account' | 'private' | 'shared' | 'favorites' | `default`;
+
export type WorkflowsState = Omit & {
_version: 1;
isTouched: boolean;
mode: WorkflowMode;
+ browsingCategory: WorkflowLibraryCategory;
searchTerm: string;
orderBy?: WorkflowRecordOrderBy;
orderDirection: SQLiteDirection;
diff --git a/invokeai/frontend/web/src/features/nodes/store/workflowLibraryModal.ts b/invokeai/frontend/web/src/features/nodes/store/workflowLibraryModal.ts
new file mode 100644
index 0000000000..bf490f67dc
--- /dev/null
+++ b/invokeai/frontend/web/src/features/nodes/store/workflowLibraryModal.ts
@@ -0,0 +1,6 @@
+import { buildUseDisclosure } from 'common/hooks/useBoolean';
+
+/**
+ * Tracks the state for the workflow library modal.
+ */
+export const [useWorkflowLibraryModal, $isWorkflowLibraryModalOpen] = buildUseDisclosure(false);
diff --git a/invokeai/frontend/web/src/features/nodes/store/workflowListMenu.ts b/invokeai/frontend/web/src/features/nodes/store/workflowListMenu.ts
deleted file mode 100644
index cfc182046c..0000000000
--- a/invokeai/frontend/web/src/features/nodes/store/workflowListMenu.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import { buildUseDisclosure } from 'common/hooks/useBoolean';
-
-/**
- * Tracks the state for the workflow list menu.
- */
-export const [useWorkflowListMenu, $isWorkflowListMenuIsOpen] = buildUseDisclosure(false);
diff --git a/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts b/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts
index 7dce3bfb26..2b974e9275 100644
--- a/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts
+++ b/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts
@@ -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) => {
state.orderDirection = action.payload;
},
+ workflowBrowsingCategoryChanged: (state, action: PayloadAction) => {
+ 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);
diff --git a/invokeai/frontend/web/src/features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog.tsx b/invokeai/frontend/web/src/features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog.tsx
index 1f639a9bc8..9deda79605 100644
--- a/invokeai/frontend/web/src/features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog.tsx
+++ b/invokeai/frontend/web/src/features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog.tsx
@@ -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') => {
diff --git a/invokeai/frontend/web/src/features/workflowLibrary/components/UploadWorkflowButton.tsx b/invokeai/frontend/web/src/features/workflowLibrary/components/UploadWorkflowButton.tsx
index 1695206041..d41e0f2984 100644
--- a/invokeai/frontend/web/src/features/workflowLibrary/components/UploadWorkflowButton.tsx
+++ b/invokeai/frontend/web/src/features/workflowLibrary/components/UploadWorkflowButton.tsx
@@ -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);
},
});
diff --git a/invokeai/frontend/web/src/services/api/endpoints/workflows.ts b/invokeai/frontend/web/src/services/api/endpoints/workflows.ts
index 23d4763f85..31650170db 100644
--- a/invokeai/frontend/web/src/services/api/endpoints/workflows.ts
+++ b/invokeai/frontend/web/src/services/api/endpoints/workflows.ts
@@ -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
>({
query: (params) => ({
- url: buildWorkflowsUrl(),
- params,
+ url: `${buildWorkflowsUrl()}?${queryString.stringify(params, { arrayFormat: 'none' })}`,
}),
providesTags: ['FetchOnReconnect', { type: 'Workflow', id: LIST_TAG }],
}),
diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts
index 926381bde9..714250405b 100644
--- a/invokeai/frontend/web/src/services/api/schema.ts
+++ b/invokeai/frontend/web/src/services/api/schema.ts
@@ -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;
};