Skip to content

Commit 3694158

Browse files
feat(ui): workflow library tags
1 parent 814fb93 commit 3694158

File tree

9 files changed

+224
-138
lines changed

9 files changed

+224
-138
lines changed

invokeai/frontend/web/public/locales/en.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1686,6 +1686,15 @@
16861686
"descending": "Descending",
16871687
"workflows": "Workflows",
16881688
"workflowLibrary": "Library",
1689+
"loadMore": "Load More",
1690+
"allLoaded": "All Workflows Loaded",
1691+
"searchPlaceholder": "Search by name, description or tags",
1692+
"filterByTags": "Filter by Tags",
1693+
"yourWorkflows": "Your Workflows",
1694+
"private": "Private",
1695+
"shared": "Shared",
1696+
"browseWorkflows": "Browse Workflows",
1697+
"resetTags": "Reset Tags",
16891698
"opened": "Opened",
16901699
"openWorkflow": "Open Workflow",
16911700
"updated": "Updated",

invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryModal.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export const WorkflowLibraryModal = () => {
3131
<Flex gap={4} h="100%">
3232
<WorkflowLibrarySideNav />
3333
<Divider orientation="vertical" />
34-
<Flex flexDir="column" flex={1} gap={6}>
34+
<Flex flexDir="column" flex={1} gap={4}>
3535
<WorkflowLibraryTopNav />
3636
<WorkflowList />
3737
</Flex>
Original file line numberDiff line numberDiff line change
@@ -1,133 +1,184 @@
1-
/* eslint-disable i18next/no-literal-string */
2-
import { Button, Flex } from '@invoke-ai/ui-library';
1+
import type { ButtonProps, CheckboxProps } from '@invoke-ai/ui-library';
2+
import { Button, Checkbox, Flex, Text } from '@invoke-ai/ui-library';
33
import { useStore } from '@nanostores/react';
44
import { $workflowCategories } from 'app/store/nanostores/workflowCategories';
5-
import { useAppSelector } from 'app/store/storeHooks';
6-
import { selectWorkflowCategories, workflowCategoriesChanged } from 'features/nodes/store/workflowSlice';
7-
import type { WorkflowCategory } from 'features/nodes/types/workflow';
8-
import { useCallback, useMemo } from 'react';
9-
import { PiUsersBold } from 'react-icons/pi';
5+
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
6+
import { WORKFLOW_TAGS, type WorkflowTag } from 'features/nodes/store/types';
7+
import {
8+
selectWorkflowLibrarySelectedTags,
9+
selectWorkflowSelectedCategories,
10+
workflowSelectedCategoriesChanged,
11+
workflowSelectedTagsRese,
12+
workflowSelectedTagToggled,
13+
} from 'features/nodes/store/workflowSlice';
14+
import { memo, useCallback, useMemo } from 'react';
15+
import { useTranslation } from 'react-i18next';
16+
import { PiArrowCounterClockwiseBold, PiUsersBold } from 'react-icons/pi';
1017
import { useDispatch } from 'react-redux';
18+
import { useGetCountsQuery } from 'services/api/endpoints/workflows';
1119

1220
export const WorkflowLibrarySideNav = () => {
21+
const { t } = useTranslation();
1322
const dispatch = useDispatch();
14-
const categories = useAppSelector(selectWorkflowCategories);
23+
const categories = useAppSelector(selectWorkflowSelectedCategories);
1524
const categoryOptions = useStore($workflowCategories);
25+
const selectedTags = useAppSelector(selectWorkflowLibrarySelectedTags);
1626

17-
const handleCategoryChange = useCallback(
18-
(categories: WorkflowCategory[]) => {
19-
dispatch(workflowCategoriesChanged(categories));
20-
},
21-
[dispatch]
22-
);
27+
const selectYourWorkflows = useCallback(() => {
28+
dispatch(workflowSelectedCategoriesChanged(categoryOptions.includes('project') ? ['user', 'project'] : ['user']));
29+
}, [categoryOptions, dispatch]);
2330

24-
const handleSelectYourWorkflows = useCallback(() => {
25-
if (categoryOptions.includes('project')) {
26-
handleCategoryChange(['user', 'project']);
27-
} else {
28-
handleCategoryChange(['user']);
29-
}
30-
}, [categoryOptions, handleCategoryChange]);
31+
const selectPrivateWorkflows = useCallback(() => {
32+
dispatch(workflowSelectedCategoriesChanged(['user']));
33+
}, [dispatch]);
34+
35+
const selectSharedWorkflows = useCallback(() => {
36+
dispatch(workflowSelectedCategoriesChanged(['project']));
37+
}, [dispatch]);
3138

32-
const isYourWorkflowsActive = useMemo(() => {
39+
const selectDefaultWorkflows = useCallback(() => {
40+
dispatch(workflowSelectedCategoriesChanged(['default']));
41+
}, [dispatch]);
42+
43+
const resetTags = useCallback(() => {
44+
dispatch(workflowSelectedTagsRese());
45+
}, [dispatch]);
46+
47+
const isYourWorkflowsSelected = useMemo(() => {
3348
if (categoryOptions.includes('project')) {
3449
return categories.includes('user') && categories.includes('project');
3550
} else {
3651
return categories.includes('user');
3752
}
3853
}, [categoryOptions, categories]);
3954

55+
const isPrivateWorkflowsExclusivelySelected = useMemo(() => {
56+
return categories.length === 1 && categories.includes('user');
57+
}, [categories]);
58+
59+
const isSharedWorkflowsExclusivelySelected = useMemo(() => {
60+
return categories.length === 1 && categories.includes('project');
61+
}, [categories]);
62+
63+
const isDefaultWorkflowsExclusivelySelected = useMemo(() => {
64+
return categories.length === 1 && categories.includes('default');
65+
}, [categories]);
66+
4067
return (
4168
<Flex flexDir="column" gap={2} h="full">
42-
<Button
43-
variant="ghost"
44-
fontWeight="bold"
45-
justifyContent="flex-start"
46-
size="md"
47-
isActive={isYourWorkflowsActive}
48-
onClick={handleSelectYourWorkflows}
49-
_active={{
50-
bg: 'base.700',
51-
color: 'base.100',
52-
}}
53-
>
54-
Your Workflows
55-
</Button>
69+
<CategoryButton isSelected={isYourWorkflowsSelected} onClick={selectYourWorkflows}>
70+
{t('workflows.yourWorkflows')}
71+
</CategoryButton>
5672
{categoryOptions.includes('project') && (
5773
<Flex flexDir="column" gap={2} pl={4}>
58-
<Button
59-
variant="ghost"
60-
fontWeight="bold"
61-
justifyContent="flex-start"
62-
size="sm"
63-
isActive={categories.length === 1 && categories.includes('user')}
64-
onClick={handleCategoryChange.bind(null, ['user'])}
65-
_active={{
66-
bg: 'base.700',
67-
color: 'base.100',
68-
}}
69-
>
70-
Private
71-
</Button>
72-
<Button
73-
variant="ghost"
74-
fontWeight="bold"
75-
justifyContent="flex-start"
74+
<CategoryButton size="sm" onClick={selectPrivateWorkflows} isSelected={isPrivateWorkflowsExclusivelySelected}>
75+
{t('workflows.private')}
76+
</CategoryButton>
77+
<CategoryButton
7678
size="sm"
7779
rightIcon={<PiUsersBold />}
78-
isActive={categories.length === 1 && categories.includes('project')}
79-
onClick={handleCategoryChange.bind(null, ['project'])}
80-
_active={{
81-
bg: 'base.700',
82-
color: 'base.100',
83-
}}
80+
onClick={selectSharedWorkflows}
81+
isSelected={isSharedWorkflowsExclusivelySelected}
8482
>
85-
Shared
86-
</Button>
83+
{t('workflows.shared')}
84+
</CategoryButton>
8785
</Flex>
8886
)}
89-
<Button
90-
variant="ghost"
91-
fontWeight="bold"
92-
justifyContent="flex-start"
93-
size="md"
94-
isActive={categories.includes('default')}
95-
onClick={handleCategoryChange.bind(null, ['default'])}
96-
_active={{
97-
bg: 'base.700',
98-
color: 'base.100',
99-
}}
100-
>
101-
Browse Workflows
102-
</Button>
103-
104-
{/* these are obviously placeholders - we need to figure out the best way to do this. leaning towards "tags" so that we can filter and/or have multiple selected eventually */}
105-
<Flex flexDir="column" gap={2} pl={4}>
87+
<CategoryButton isSelected={isDefaultWorkflowsExclusivelySelected} onClick={selectDefaultWorkflows}>
88+
{t('workflows.browseWorkflows')}
89+
</CategoryButton>
90+
91+
<Flex flexDir="column" gap={2} pl={4} overflow="hidden">
10692
<Button
107-
variant="ghost"
108-
fontWeight="bold"
109-
justifyContent="flex-start"
93+
isDisabled={!isDefaultWorkflowsExclusivelySelected || selectedTags.length === 0}
94+
onClick={resetTags}
11095
size="sm"
111-
_active={{
112-
bg: 'base.700',
113-
color: 'base.100',
114-
}}
115-
>
116-
Architecture
117-
</Button>
118-
<Button
119-
variant="ghost"
96+
variant="link"
12097
fontWeight="bold"
12198
justifyContent="flex-start"
122-
size="sm"
123-
_active={{
124-
bg: 'base.700',
125-
color: 'base.100',
126-
}}
99+
flexGrow={0}
100+
leftIcon={<PiArrowCounterClockwiseBold />}
101+
h={8}
127102
>
128-
Fashion
103+
{t('workflows.resetTags')}
129104
</Button>
105+
<Flex flexDir="column" gap={2} overflow="auto">
106+
{WORKFLOW_TAGS.map((tagCategory) => (
107+
<TagCategory
108+
key={tagCategory.category}
109+
tagCategory={tagCategory}
110+
isDisabled={!isDefaultWorkflowsExclusivelySelected}
111+
/>
112+
))}
113+
</Flex>
130114
</Flex>
131115
</Flex>
132116
);
133117
};
118+
119+
const CategoryButton = memo(({ isSelected, ...rest }: ButtonProps & { isSelected: boolean }) => {
120+
return (
121+
<Button
122+
colorScheme={isSelected ? 'invokeBlue' : 'base'}
123+
variant="ghost"
124+
fontWeight="bold"
125+
justifyContent="flex-start"
126+
size="md"
127+
{...rest}
128+
/>
129+
);
130+
});
131+
CategoryButton.displayName = 'NavButton';
132+
133+
const TagCategory = memo(
134+
({ tagCategory, isDisabled }: { tagCategory: (typeof WORKFLOW_TAGS)[number]; isDisabled: boolean }) => {
135+
const { count } = useGetCountsQuery(
136+
{ tags: [...tagCategory.tags], categories: ['default'] },
137+
{ selectFromResult: ({ data }) => ({ count: data ?? 0 }) }
138+
);
139+
140+
if (count === 0) {
141+
return null;
142+
}
143+
144+
return (
145+
<Flex flexDir="column" gap={2}>
146+
<Text fontWeight="semibold" color="base.300" opacity={isDisabled ? 0.5 : 1}>
147+
{tagCategory.category}
148+
</Text>
149+
<Flex flexDir="column" gap={2} pl={4}>
150+
{tagCategory.tags.map((tag) => (
151+
<TagCheckbox key={tag} tag={tag} isDisabled={isDisabled} />
152+
))}
153+
</Flex>
154+
</Flex>
155+
);
156+
}
157+
);
158+
TagCategory.displayName = 'TagCategory';
159+
160+
const TagCheckbox = memo(({ tag, ...rest }: CheckboxProps & { tag: WorkflowTag }) => {
161+
const dispatch = useAppDispatch();
162+
const selectedTags = useAppSelector(selectWorkflowLibrarySelectedTags);
163+
const isSelected = selectedTags.includes(tag);
164+
165+
const onChange = useCallback(() => {
166+
dispatch(workflowSelectedTagToggled(tag));
167+
}, [dispatch, tag]);
168+
169+
const { count } = useGetCountsQuery(
170+
{ tags: [tag], categories: ['default'] },
171+
{ selectFromResult: ({ data }) => ({ count: data ?? 0 }) }
172+
);
173+
174+
if (count === 0) {
175+
return null;
176+
}
177+
178+
return (
179+
<Checkbox isChecked={isSelected} onChange={onChange} {...rest}>
180+
<Text>{`${tag} (${count})`}</Text>
181+
</Checkbox>
182+
);
183+
});
184+
TagCheckbox.displayName = 'TagCheckbox';

invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowList.tsx

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@ import { EMPTY_ARRAY } from 'app/store/constants';
33
import { useAppSelector } from 'app/store/storeHooks';
44
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
55
import {
6-
selectWorkflowCategories,
6+
selectWorkflowLibrarySelectedTags,
77
selectWorkflowOrderBy,
88
selectWorkflowOrderDirection,
99
selectWorkflowSearchTerm,
10+
selectWorkflowSelectedCategories,
1011
} from 'features/nodes/store/workflowSlice';
1112
import { memo, useCallback, useMemo, useRef } from 'react';
1213
import { useTranslation } from 'react-i18next';
@@ -17,13 +18,14 @@ import { useDebounce } from 'use-debounce';
1718

1819
import { WorkflowListItem } from './WorkflowListItem';
1920

20-
const PER_PAGE = 3;
21+
const PER_PAGE = 30;
2122

2223
const useInfiniteQueryAry = () => {
23-
const categories = useAppSelector(selectWorkflowCategories);
24+
const categories = useAppSelector(selectWorkflowSelectedCategories);
2425
const orderBy = useAppSelector(selectWorkflowOrderBy);
2526
const direction = useAppSelector(selectWorkflowOrderDirection);
2627
const query = useAppSelector(selectWorkflowSearchTerm);
28+
const tags = useAppSelector(selectWorkflowLibrarySelectedTags);
2729
const [debouncedQuery] = useDebounce(query, 500);
2830

2931
const queryArg = useMemo(() => {
@@ -34,8 +36,9 @@ const useInfiniteQueryAry = () => {
3436
direction,
3537
categories,
3638
query: debouncedQuery,
39+
tags: categories.length === 1 && categories.includes('default') ? tags : [],
3740
} satisfies Parameters<typeof useListWorkflowsQuery>[0];
38-
}, [orderBy, direction, categories, debouncedQuery]);
41+
}, [orderBy, direction, categories, debouncedQuery, tags]);
3942

4043
return queryArg;
4144
};
@@ -163,9 +166,18 @@ const WorkflowListContent = memo(
163166
))}
164167
</Grid>
165168
<Spacer />
166-
<Button onClick={loadMore} isDisabled={!hasNextPage} isLoading={isFetching}>
167-
{t('nodes.loadMore')}
168-
</Button>
169+
{hasNextPage && (
170+
<Button
171+
onClick={loadMore}
172+
isLoading={isFetching}
173+
loadingText={t('common.loading')}
174+
variant="ghost"
175+
w="min-content"
176+
alignSelf="center"
177+
>
178+
{t('workflows.loadMore')}
179+
</Button>
180+
)}
169181
</Flex>
170182
);
171183
}

invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowSearch.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export const WorkflowSearch = memo(({ searchInputRef }: { searchInputRef: RefObj
5050
<InputGroup>
5151
<Input
5252
ref={searchInputRef}
53-
placeholder={t('stylePresets.searchByName')}
53+
placeholder={t('workflows.searchPlaceholder')}
5454
value={searchTerm}
5555
onKeyDown={handleKeydown}
5656
onChange={handleChange}

invokeai/frontend/web/src/features/nodes/hooks/useCategorySections.ts

Lines changed: 0 additions & 18 deletions
This file was deleted.

0 commit comments

Comments
 (0)