feat(ui): workflow library infinite scrolling

This commit is contained in:
psychedelicious
2025-03-05 20:24:59 +10:00
parent ed9cd6a7a2
commit f56dd01419
2 changed files with 150 additions and 47 deletions

View File

@@ -1,4 +1,5 @@
import { Flex, Grid, GridItem, Spinner } from '@invoke-ai/ui-library';
import { Button, Flex, Grid, GridItem, Spacer, Spinner } 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 {
@@ -7,53 +8,65 @@ import {
selectWorkflowOrderDirection,
selectWorkflowSearchTerm,
} from 'features/nodes/store/workflowSlice';
import { useEffect, useMemo, useState } from 'react';
import { memo, useCallback, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useListWorkflowsQuery } from 'services/api/endpoints/workflows';
import type { useListWorkflowsQuery } from 'services/api/endpoints/workflows';
import { useListWorkflowsInfiniteInfiniteQuery } from 'services/api/endpoints/workflows';
import type { S } from 'services/api/types';
import { useDebounce } from 'use-debounce';
import { WorkflowLibraryPagination } from './WorkflowLibraryPagination';
import { WorkflowListItem } from './WorkflowListItem';
const PER_PAGE = 6;
const PER_PAGE = 3;
export const WorkflowList = () => {
const searchTerm = useAppSelector(selectWorkflowSearchTerm);
const { t } = useTranslation();
const [page, setPage] = useState(0);
const useInfiniteQueryAry = () => {
const categories = useAppSelector(selectWorkflowCategories);
const orderBy = useAppSelector(selectWorkflowOrderBy);
const direction = useAppSelector(selectWorkflowOrderDirection);
const query = useAppSelector(selectWorkflowSearchTerm);
const [debouncedQuery] = useDebounce(query, 500);
useEffect(() => {
setPage(0);
}, [categories, query]);
const queryArg = useMemo<Parameters<typeof useListWorkflowsQuery>[0]>(() => {
const queryArg = useMemo(() => {
return {
page,
page: 0,
per_page: PER_PAGE,
order_by: orderBy,
direction,
categories,
query: debouncedQuery,
};
}, [direction, orderBy, page, categories, debouncedQuery]);
} satisfies Parameters<typeof useListWorkflowsQuery>[0];
}, [orderBy, direction, categories, debouncedQuery]);
const { data, isLoading } = useListWorkflowsQuery(queryArg);
return queryArg;
};
const queryOptions = {
selectFromResult: ({ data, ...rest }) => {
return {
items: data?.pages.map(({ items }) => items).flat() ?? EMPTY_ARRAY,
...rest,
} as const;
},
} satisfies Parameters<typeof useListWorkflowsInfiniteInfiniteQuery>[1];
export const WorkflowList = () => {
const searchTerm = useAppSelector(selectWorkflowSearchTerm);
const { t } = useTranslation();
const queryArg = useInfiniteQueryAry();
const { items, isFetching, isLoading, fetchNextPage, hasNextPage } = useListWorkflowsInfiniteInfiniteQuery(
queryArg,
queryOptions
);
if (isLoading) {
return (
<Flex alignItems="center" justifyContent="center" p={20}>
<Flex alignItems="center" justifyContent="center" w="full" h="full">
<Spinner />
</Flex>
);
}
if (!data?.items.length) {
if (items.length === 0) {
return (
<IAINoContentFallback
fontSize="sm"
@@ -65,15 +78,96 @@ export const WorkflowList = () => {
}
return (
<Flex flexDir="column" gap={6}>
<Grid templateColumns="repeat(2, minmax(200px, 3fr))" templateRows="1fr 1fr 1fr" 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>
<WorkflowListContent
items={items}
hasNextPage={hasNextPage}
fetchNextPage={fetchNextPage}
isFetching={isFetching}
/>
);
};
const WorkflowListContent = memo(
({
items,
hasNextPage,
isFetching,
fetchNextPage,
}: {
items: S['WorkflowRecordListItemWithThumbnailDTO'][];
hasNextPage: boolean;
isFetching: boolean;
fetchNextPage: ReturnType<typeof useListWorkflowsInfiniteInfiniteQuery>['fetchNextPage'];
}) => {
const { t } = useTranslation();
const ref = useRef<HTMLDivElement>(null);
const onScroll = useCallback(() => {
if (!hasNextPage || isFetching) {
return;
}
const el = ref.current;
if (!el) {
return;
}
const { scrollTop, scrollHeight, clientHeight } = el;
if (Math.abs(scrollHeight - (scrollTop + clientHeight)) <= 1) {
fetchNextPage();
}
}, [hasNextPage, isFetching, fetchNextPage]);
const loadMore = useCallback(() => {
if (!hasNextPage || isFetching) {
return;
}
const el = ref.current;
if (!el) {
return;
}
fetchNextPage();
}, [hasNextPage, isFetching, fetchNextPage]);
// // TODO(psyche): this causes an infinite loop, the scrollIntoView triggers the onScroll which triggers the
// // fetchNextPage which triggers the scrollIntoView again...
// useEffect(() => {
// const el = ref.current;
// if (!el) {
// return;
// }
// const observer = new MutationObserver(() => {
// el.querySelector(':scope > :last-child')?.scrollIntoView({ behavior: 'smooth' });
// });
// observer.observe(el, { childList: true });
// return () => {
// observer.disconnect();
// };
// }, []);
return (
<Flex flexDir="column" gap={4} flex={1} minH={0}>
<Grid
ref={ref}
templateColumns="repeat(auto-fit, minmax(340px, 3fr))"
gridAutoFlow="dense"
gap={4}
overflow="scroll"
onScroll={onScroll}
>
{items.map((workflow) => (
<GridItem id={`grid-${workflow.workflow_id}`} key={workflow.workflow_id}>
<WorkflowListItem workflow={workflow} key={workflow.workflow_id} />
</GridItem>
))}
</Grid>
<Spacer />
<Button onClick={loadMore} isDisabled={!hasNextPage} isLoading={isFetching}>
{t('nodes.loadMore')}
</Button>
</Flex>
);
}
);
WorkflowListContent.displayName = 'WorkflowListContent';

View File

@@ -1,9 +1,10 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Badge, Flex, Icon, Image, Spacer, Text } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { selectWorkflowId } from 'features/nodes/store/workflowSlice';
import { useLoadWorkflow } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog';
import InvokeLogo from 'public/assets/images/invoke-symbol-wht-lrg.svg';
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiImageBold, PiUsersBold } from 'react-icons/pi';
import type { WorkflowRecordListItemWithThumbnailDTO } from 'services/api/types';
@@ -17,17 +18,19 @@ import { ViewWorkflow } from './WorkflowLibraryListItemActions/ViewWorkflow';
const IMAGE_THUMBNAIL_SIZE = '80px';
const FALLBACK_ICON_SIZE = '24px';
const WORKFLOW_ACTION_BUTTONS_CN = 'workflow-action-buttons';
const sx: SystemStyleObject = {
_hover: {
bg: 'base.700',
[`& .${WORKFLOW_ACTION_BUTTONS_CN}`]: {
display: 'flex',
},
},
};
export const WorkflowListItem = ({ workflow }: { workflow: WorkflowRecordListItemWithThumbnailDTO }) => {
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false);
const handleMouseOver = useCallback(() => {
setIsHovered(true);
}, []);
const handleMouseOut = useCallback(() => {
setIsHovered(false);
}, []);
const workflowId = useAppSelector(selectWorkflowId);
const loadWorkflow = useLoadWorkflow();
@@ -37,24 +40,22 @@ export const WorkflowListItem = ({ workflow }: { workflow: WorkflowRecordListIte
}, [workflowId, workflow.workflow_id]);
const handleClickLoad = useCallback(() => {
setIsHovered(false);
loadWorkflow.loadWithDialog(workflow.workflow_id, 'view');
}, [loadWorkflow, workflow.workflow_id]);
return (
<Flex
role="button"
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"
sx={sx}
>
<Image
src={workflow.thumbnail_url ?? undefined}
@@ -95,7 +96,7 @@ export const WorkflowListItem = ({ workflow }: { workflow: WorkflowRecordListIte
</Flex>
<Spacer />
<Flex flexDir="column" gap={1} justifyContent="space-between">
<Flex flexDir="column" gap={1} justifyContent="space-between" position="relative">
<Flex gap={1} justifyContent="flex-end" w="full" p={2}>
{workflow.category === 'project' && <Icon as={PiUsersBold} color="base.200" />}
{workflow.category === 'default' && (
@@ -103,7 +104,15 @@ export const WorkflowListItem = ({ workflow }: { workflow: WorkflowRecordListIte
)}
</Flex>
<Flex alignItems="center" gap={1} opacity={isHovered ? 1 : 0}>
<Flex
alignItems="center"
gap={1}
display="none"
className={WORKFLOW_ACTION_BUTTONS_CN}
position="absolute"
right={0}
bottom={0}
>
{workflow.category === 'default' && (
<>
{/* 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" */}