Skip to content

Commit f56dd01

Browse files
feat(ui): workflow library infinite scrolling
1 parent ed9cd6a commit f56dd01

File tree

2 files changed

+150
-47
lines changed

2 files changed

+150
-47
lines changed
Lines changed: 125 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { Flex, Grid, GridItem, Spinner } from '@invoke-ai/ui-library';
1+
import { Button, Flex, Grid, GridItem, Spacer, Spinner } from '@invoke-ai/ui-library';
2+
import { EMPTY_ARRAY } from 'app/store/constants';
23
import { useAppSelector } from 'app/store/storeHooks';
34
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
45
import {
@@ -7,53 +8,65 @@ import {
78
selectWorkflowOrderDirection,
89
selectWorkflowSearchTerm,
910
} from 'features/nodes/store/workflowSlice';
10-
import { useEffect, useMemo, useState } from 'react';
11+
import { memo, useCallback, useMemo, useRef } from 'react';
1112
import { useTranslation } from 'react-i18next';
12-
import { useListWorkflowsQuery } from 'services/api/endpoints/workflows';
13+
import type { useListWorkflowsQuery } from 'services/api/endpoints/workflows';
14+
import { useListWorkflowsInfiniteInfiniteQuery } from 'services/api/endpoints/workflows';
15+
import type { S } from 'services/api/types';
1316
import { useDebounce } from 'use-debounce';
1417

15-
import { WorkflowLibraryPagination } from './WorkflowLibraryPagination';
1618
import { WorkflowListItem } from './WorkflowListItem';
1719

18-
const PER_PAGE = 6;
20+
const PER_PAGE = 3;
1921

20-
export const WorkflowList = () => {
21-
const searchTerm = useAppSelector(selectWorkflowSearchTerm);
22-
const { t } = useTranslation();
23-
24-
const [page, setPage] = useState(0);
22+
const useInfiniteQueryAry = () => {
2523
const categories = useAppSelector(selectWorkflowCategories);
2624
const orderBy = useAppSelector(selectWorkflowOrderBy);
2725
const direction = useAppSelector(selectWorkflowOrderDirection);
2826
const query = useAppSelector(selectWorkflowSearchTerm);
2927
const [debouncedQuery] = useDebounce(query, 500);
3028

31-
useEffect(() => {
32-
setPage(0);
33-
}, [categories, query]);
34-
35-
const queryArg = useMemo<Parameters<typeof useListWorkflowsQuery>[0]>(() => {
29+
const queryArg = useMemo(() => {
3630
return {
37-
page,
31+
page: 0,
3832
per_page: PER_PAGE,
3933
order_by: orderBy,
4034
direction,
4135
categories,
4236
query: debouncedQuery,
43-
};
44-
}, [direction, orderBy, page, categories, debouncedQuery]);
37+
} satisfies Parameters<typeof useListWorkflowsQuery>[0];
38+
}, [orderBy, direction, categories, debouncedQuery]);
39+
40+
return queryArg;
41+
};
4542

46-
const { data, isLoading } = useListWorkflowsQuery(queryArg);
43+
const queryOptions = {
44+
selectFromResult: ({ data, ...rest }) => {
45+
return {
46+
items: data?.pages.map(({ items }) => items).flat() ?? EMPTY_ARRAY,
47+
...rest,
48+
} as const;
49+
},
50+
} satisfies Parameters<typeof useListWorkflowsInfiniteInfiniteQuery>[1];
51+
52+
export const WorkflowList = () => {
53+
const searchTerm = useAppSelector(selectWorkflowSearchTerm);
54+
const { t } = useTranslation();
55+
const queryArg = useInfiniteQueryAry();
56+
const { items, isFetching, isLoading, fetchNextPage, hasNextPage } = useListWorkflowsInfiniteInfiniteQuery(
57+
queryArg,
58+
queryOptions
59+
);
4760

4861
if (isLoading) {
4962
return (
50-
<Flex alignItems="center" justifyContent="center" p={20}>
63+
<Flex alignItems="center" justifyContent="center" w="full" h="full">
5164
<Spinner />
5265
</Flex>
5366
);
5467
}
5568

56-
if (!data?.items.length) {
69+
if (items.length === 0) {
5770
return (
5871
<IAINoContentFallback
5972
fontSize="sm"
@@ -65,15 +78,96 @@ export const WorkflowList = () => {
6578
}
6679

6780
return (
68-
<Flex flexDir="column" gap={6}>
69-
<Grid templateColumns="repeat(2, minmax(200px, 3fr))" templateRows="1fr 1fr 1fr" gap={4}>
70-
{data?.items.map((workflow) => (
71-
<GridItem key={workflow.workflow_id}>
72-
<WorkflowListItem workflow={workflow} key={workflow.workflow_id} />
73-
</GridItem>
74-
))}
75-
</Grid>
76-
<WorkflowLibraryPagination page={page} setPage={setPage} data={data} />
77-
</Flex>
81+
<WorkflowListContent
82+
items={items}
83+
hasNextPage={hasNextPage}
84+
fetchNextPage={fetchNextPage}
85+
isFetching={isFetching}
86+
/>
7887
);
7988
};
89+
90+
const WorkflowListContent = memo(
91+
({
92+
items,
93+
hasNextPage,
94+
isFetching,
95+
fetchNextPage,
96+
}: {
97+
items: S['WorkflowRecordListItemWithThumbnailDTO'][];
98+
hasNextPage: boolean;
99+
isFetching: boolean;
100+
fetchNextPage: ReturnType<typeof useListWorkflowsInfiniteInfiniteQuery>['fetchNextPage'];
101+
}) => {
102+
const { t } = useTranslation();
103+
const ref = useRef<HTMLDivElement>(null);
104+
105+
const onScroll = useCallback(() => {
106+
if (!hasNextPage || isFetching) {
107+
return;
108+
}
109+
const el = ref.current;
110+
if (!el) {
111+
return;
112+
}
113+
const { scrollTop, scrollHeight, clientHeight } = el;
114+
if (Math.abs(scrollHeight - (scrollTop + clientHeight)) <= 1) {
115+
fetchNextPage();
116+
}
117+
}, [hasNextPage, isFetching, fetchNextPage]);
118+
119+
const loadMore = useCallback(() => {
120+
if (!hasNextPage || isFetching) {
121+
return;
122+
}
123+
const el = ref.current;
124+
if (!el) {
125+
return;
126+
}
127+
fetchNextPage();
128+
}, [hasNextPage, isFetching, fetchNextPage]);
129+
130+
// // TODO(psyche): this causes an infinite loop, the scrollIntoView triggers the onScroll which triggers the
131+
// // fetchNextPage which triggers the scrollIntoView again...
132+
// useEffect(() => {
133+
// const el = ref.current;
134+
// if (!el) {
135+
// return;
136+
// }
137+
138+
// const observer = new MutationObserver(() => {
139+
// el.querySelector(':scope > :last-child')?.scrollIntoView({ behavior: 'smooth' });
140+
// });
141+
142+
// observer.observe(el, { childList: true });
143+
144+
// return () => {
145+
// observer.disconnect();
146+
// };
147+
// }, []);
148+
149+
return (
150+
<Flex flexDir="column" gap={4} flex={1} minH={0}>
151+
<Grid
152+
ref={ref}
153+
templateColumns="repeat(auto-fit, minmax(340px, 3fr))"
154+
gridAutoFlow="dense"
155+
gap={4}
156+
overflow="scroll"
157+
onScroll={onScroll}
158+
>
159+
{items.map((workflow) => (
160+
<GridItem id={`grid-${workflow.workflow_id}`} key={workflow.workflow_id}>
161+
<WorkflowListItem workflow={workflow} key={workflow.workflow_id} />
162+
</GridItem>
163+
))}
164+
</Grid>
165+
<Spacer />
166+
<Button onClick={loadMore} isDisabled={!hasNextPage} isLoading={isFetching}>
167+
{t('nodes.loadMore')}
168+
</Button>
169+
</Flex>
170+
);
171+
}
172+
);
173+
WorkflowListContent.displayName = 'WorkflowListContent';

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

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1+
import type { SystemStyleObject } from '@invoke-ai/ui-library';
12
import { Badge, Flex, Icon, Image, Spacer, Text } from '@invoke-ai/ui-library';
23
import { useAppSelector } from 'app/store/storeHooks';
34
import { selectWorkflowId } from 'features/nodes/store/workflowSlice';
45
import { useLoadWorkflow } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog';
56
import InvokeLogo from 'public/assets/images/invoke-symbol-wht-lrg.svg';
6-
import { useCallback, useMemo, useState } from 'react';
7+
import { useCallback, useMemo } from 'react';
78
import { useTranslation } from 'react-i18next';
89
import { PiImageBold, PiUsersBold } from 'react-icons/pi';
910
import type { WorkflowRecordListItemWithThumbnailDTO } from 'services/api/types';
@@ -17,17 +18,19 @@ import { ViewWorkflow } from './WorkflowLibraryListItemActions/ViewWorkflow';
1718
const IMAGE_THUMBNAIL_SIZE = '80px';
1819
const FALLBACK_ICON_SIZE = '24px';
1920

20-
export const WorkflowListItem = ({ workflow }: { workflow: WorkflowRecordListItemWithThumbnailDTO }) => {
21-
const { t } = useTranslation();
22-
const [isHovered, setIsHovered] = useState(false);
21+
const WORKFLOW_ACTION_BUTTONS_CN = 'workflow-action-buttons';
2322

24-
const handleMouseOver = useCallback(() => {
25-
setIsHovered(true);
26-
}, []);
23+
const sx: SystemStyleObject = {
24+
_hover: {
25+
bg: 'base.700',
26+
[`& .${WORKFLOW_ACTION_BUTTONS_CN}`]: {
27+
display: 'flex',
28+
},
29+
},
30+
};
2731

28-
const handleMouseOut = useCallback(() => {
29-
setIsHovered(false);
30-
}, []);
32+
export const WorkflowListItem = ({ workflow }: { workflow: WorkflowRecordListItemWithThumbnailDTO }) => {
33+
const { t } = useTranslation();
3134

3235
const workflowId = useAppSelector(selectWorkflowId);
3336
const loadWorkflow = useLoadWorkflow();
@@ -37,24 +40,22 @@ export const WorkflowListItem = ({ workflow }: { workflow: WorkflowRecordListIte
3740
}, [workflowId, workflow.workflow_id]);
3841

3942
const handleClickLoad = useCallback(() => {
40-
setIsHovered(false);
4143
loadWorkflow.loadWithDialog(workflow.workflow_id, 'view');
4244
}, [loadWorkflow, workflow.workflow_id]);
4345

4446
return (
4547
<Flex
48+
role="button"
4649
gap={4}
4750
onClick={handleClickLoad}
4851
cursor="pointer"
4952
bg="base.750"
50-
_hover={{ backgroundColor: 'base.700' }}
5153
p={2}
5254
ps={3}
5355
borderRadius="base"
5456
w="full"
55-
onMouseOver={handleMouseOver}
56-
onMouseOut={handleMouseOut}
5757
alignItems="stretch"
58+
sx={sx}
5859
>
5960
<Image
6061
src={workflow.thumbnail_url ?? undefined}
@@ -95,15 +96,23 @@ export const WorkflowListItem = ({ workflow }: { workflow: WorkflowRecordListIte
9596
</Flex>
9697

9798
<Spacer />
98-
<Flex flexDir="column" gap={1} justifyContent="space-between">
99+
<Flex flexDir="column" gap={1} justifyContent="space-between" position="relative">
99100
<Flex gap={1} justifyContent="flex-end" w="full" p={2}>
100101
{workflow.category === 'project' && <Icon as={PiUsersBold} color="base.200" />}
101102
{workflow.category === 'default' && (
102103
<Image src={InvokeLogo} alt="invoke-logo" w="14px" h="14px" minW="14px" minH="14px" userSelect="none" />
103104
)}
104105
</Flex>
105106

106-
<Flex alignItems="center" gap={1} opacity={isHovered ? 1 : 0}>
107+
<Flex
108+
alignItems="center"
109+
gap={1}
110+
display="none"
111+
className={WORKFLOW_ACTION_BUTTONS_CN}
112+
position="absolute"
113+
right={0}
114+
bottom={0}
115+
>
107116
{workflow.category === 'default' && (
108117
<>
109118
{/* need to consider what is useful here and which icons show that. idea is to "try it out"/"view" or "clone for your own changes" */}

0 commit comments

Comments
 (0)