mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-04-23 03:00:31 -04:00
(ui): clean up old workflow library
This commit is contained in:
committed by
Mary Hipp Rogers
parent
9092280583
commit
1d32e70a75
@@ -1,26 +0,0 @@
|
||||
import { IconButton } from '@invoke-ai/ui-library';
|
||||
import { NewWorkflowConfirmationAlertDialog } from 'features/workflowLibrary/components/NewWorkflowConfirmationAlertDialog';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiFilePlusBold } from 'react-icons/pi';
|
||||
|
||||
export const NewWorkflowButton = memo(() => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const renderButton = useCallback(
|
||||
(onClick: () => void) => (
|
||||
<IconButton
|
||||
aria-label={t('nodes.newWorkflow')}
|
||||
tooltip={t('nodes.newWorkflow')}
|
||||
icon={<PiFilePlusBold />}
|
||||
onClick={onClick}
|
||||
pointerEvents="auto"
|
||||
/>
|
||||
),
|
||||
[t]
|
||||
);
|
||||
|
||||
return <NewWorkflowConfirmationAlertDialog renderButton={renderButton} />;
|
||||
});
|
||||
|
||||
NewWorkflowButton.displayName = 'NewWorkflowButton';
|
||||
@@ -1,28 +0,0 @@
|
||||
import { IconButton } from '@invoke-ai/ui-library';
|
||||
import { useWorkflowLibraryModal } from 'features/workflowLibrary/store/isWorkflowLibraryModalOpen';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiFolderOpenBold } from 'react-icons/pi';
|
||||
|
||||
import WorkflowLibraryModal from './WorkflowLibraryModal';
|
||||
|
||||
const WorkflowLibraryButton = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const workflowLibraryModal = useWorkflowLibraryModal();
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
aria-label={t('workflows.workflowLibrary')}
|
||||
tooltip={t('workflows.workflowLibrary')}
|
||||
icon={<PiFolderOpenBold />}
|
||||
onClick={workflowLibraryModal.setTrue}
|
||||
pointerEvents="auto"
|
||||
/>
|
||||
<WorkflowLibraryModal />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(WorkflowLibraryButton);
|
||||
@@ -1,13 +0,0 @@
|
||||
import WorkflowLibraryList from 'features/workflowLibrary/components/WorkflowLibraryList';
|
||||
import WorkflowLibraryListWrapper from 'features/workflowLibrary/components/WorkflowLibraryListWrapper';
|
||||
import { memo } from 'react';
|
||||
|
||||
const WorkflowLibraryContent = () => {
|
||||
return (
|
||||
<WorkflowLibraryListWrapper>
|
||||
<WorkflowLibraryList />
|
||||
</WorkflowLibraryListWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(WorkflowLibraryContent);
|
||||
@@ -1,252 +0,0 @@
|
||||
import type { ComboboxOnChange, ComboboxOption } from '@invoke-ai/ui-library';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Combobox,
|
||||
Divider,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
IconButton,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputRightElement,
|
||||
} from '@invoke-ai/ui-library';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { $projectId } from 'app/store/nanostores/projectId';
|
||||
import { $workflowCategories } from 'app/store/nanostores/workflowCategories';
|
||||
import { IAINoContentFallback, IAINoContentFallbackWithSpinner } from 'common/components/IAIImageFallback';
|
||||
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
|
||||
import type { WorkflowCategory } from 'features/nodes/types/workflow';
|
||||
import WorkflowLibraryListItem from 'features/workflowLibrary/components/WorkflowLibraryListItem';
|
||||
import WorkflowLibraryPagination from 'features/workflowLibrary/components/WorkflowLibraryPagination';
|
||||
import type { ChangeEvent, KeyboardEvent } from 'react';
|
||||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PiXBold } from 'react-icons/pi';
|
||||
import { useListWorkflowsQuery } from 'services/api/endpoints/workflows';
|
||||
import type { SQLiteDirection, WorkflowRecordOrderBy } from 'services/api/types';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
import { z } from 'zod';
|
||||
|
||||
import UploadWorkflowButton from './UploadWorkflowButton';
|
||||
|
||||
const PER_PAGE = 10;
|
||||
|
||||
const zOrderBy = z.enum(['opened_at', 'created_at', 'updated_at', 'name']);
|
||||
type OrderBy = z.infer<typeof zOrderBy>;
|
||||
const isOrderBy = (v: unknown): v is OrderBy => zOrderBy.safeParse(v).success;
|
||||
|
||||
const zDirection = z.enum(['ASC', 'DESC']);
|
||||
type Direction = z.infer<typeof zDirection>;
|
||||
const isDirection = (v: unknown): v is Direction => zDirection.safeParse(v).success;
|
||||
|
||||
const WorkflowLibraryList = () => {
|
||||
const { t } = useTranslation();
|
||||
const workflowCategories = useStore($workflowCategories);
|
||||
const [selectedCategory, setSelectedCategory] = useState<WorkflowCategory>('user');
|
||||
const [page, setPage] = useState(0);
|
||||
const [query, setQuery] = useState('');
|
||||
const projectId = useStore($projectId);
|
||||
|
||||
const ORDER_BY_OPTIONS: ComboboxOption[] = useMemo(
|
||||
() => [
|
||||
{ value: 'opened_at', label: t('workflows.opened') },
|
||||
{ value: 'created_at', label: t('workflows.created') },
|
||||
{ value: 'updated_at', label: t('workflows.updated') },
|
||||
{ value: 'name', label: t('workflows.name') },
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const DIRECTION_OPTIONS: ComboboxOption[] = useMemo(
|
||||
() => [
|
||||
{ value: 'ASC', label: t('workflows.ascending') },
|
||||
{ value: 'DESC', label: t('workflows.descending') },
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const orderByOptions = useMemo(() => {
|
||||
return projectId ? ORDER_BY_OPTIONS.filter((option) => option.value !== 'opened_at') : ORDER_BY_OPTIONS;
|
||||
}, [projectId, ORDER_BY_OPTIONS]);
|
||||
|
||||
const [order_by, setOrderBy] = useState<WorkflowRecordOrderBy>(orderByOptions[0]?.value as WorkflowRecordOrderBy);
|
||||
const [direction, setDirection] = useState<SQLiteDirection>('DESC');
|
||||
const [debouncedQuery] = useDebounce(query, 500);
|
||||
|
||||
const queryArg = useMemo<Parameters<typeof useListWorkflowsQuery>[0]>(() => {
|
||||
if (selectedCategory !== 'default') {
|
||||
return {
|
||||
page,
|
||||
per_page: PER_PAGE,
|
||||
order_by,
|
||||
direction,
|
||||
category: selectedCategory,
|
||||
query: debouncedQuery,
|
||||
};
|
||||
}
|
||||
return {
|
||||
page,
|
||||
per_page: PER_PAGE,
|
||||
order_by: 'name' as const,
|
||||
direction: 'ASC' as const,
|
||||
category: selectedCategory,
|
||||
query: debouncedQuery,
|
||||
};
|
||||
}, [selectedCategory, debouncedQuery, direction, order_by, page]);
|
||||
|
||||
const { data, isLoading, isError, isFetching } = useListWorkflowsQuery(queryArg);
|
||||
|
||||
const onChangeOrderBy = useCallback<ComboboxOnChange>(
|
||||
(v) => {
|
||||
if (!isOrderBy(v?.value) || v.value === order_by) {
|
||||
return;
|
||||
}
|
||||
setOrderBy(v.value);
|
||||
setPage(0);
|
||||
},
|
||||
[order_by]
|
||||
);
|
||||
const valueOrderBy = useMemo(() => orderByOptions.find((o) => o.value === order_by), [order_by, orderByOptions]);
|
||||
|
||||
const onChangeDirection = useCallback<ComboboxOnChange>(
|
||||
(v) => {
|
||||
if (!isDirection(v?.value) || v.value === direction) {
|
||||
return;
|
||||
}
|
||||
setDirection(v.value);
|
||||
setPage(0);
|
||||
},
|
||||
[direction]
|
||||
);
|
||||
const valueDirection = useMemo(
|
||||
() => DIRECTION_OPTIONS.find((o) => o.value === direction),
|
||||
[direction, DIRECTION_OPTIONS]
|
||||
);
|
||||
|
||||
const resetFilterText = useCallback(() => {
|
||||
setQuery('');
|
||||
setPage(0);
|
||||
}, []);
|
||||
|
||||
const handleKeydownFilterText = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
// exit search mode on escape
|
||||
if (e.key === 'Escape') {
|
||||
resetFilterText();
|
||||
e.preventDefault();
|
||||
setPage(0);
|
||||
}
|
||||
},
|
||||
[resetFilterText]
|
||||
);
|
||||
|
||||
const handleChangeFilterText = useCallback((e: ChangeEvent<HTMLInputElement>) => {
|
||||
setQuery(e.target.value);
|
||||
setPage(0);
|
||||
}, []);
|
||||
|
||||
const handleSetCategory = useCallback((category: WorkflowCategory) => {
|
||||
setSelectedCategory(category);
|
||||
setPage(0);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex gap={4} alignItems="center" h={16} flexShrink={0} flexGrow={0} justifyContent="space-between">
|
||||
<ButtonGroup alignSelf="flex-end">
|
||||
{workflowCategories.map((category) => (
|
||||
<Button
|
||||
key={category}
|
||||
variant={selectedCategory === category ? undefined : 'ghost'}
|
||||
onClick={handleSetCategory.bind(null, category)}
|
||||
isChecked={selectedCategory === category}
|
||||
>
|
||||
{t(`workflows.${category}Workflows`)}
|
||||
</Button>
|
||||
))}
|
||||
</ButtonGroup>
|
||||
{selectedCategory !== 'default' && (
|
||||
<>
|
||||
<FormControl
|
||||
isDisabled={isFetching}
|
||||
sx={{
|
||||
flexDir: 'column',
|
||||
alignItems: 'flex-start',
|
||||
gap: 1,
|
||||
maxW: 56,
|
||||
}}
|
||||
>
|
||||
<FormLabel>{t('common.orderBy')}</FormLabel>
|
||||
<Combobox value={valueOrderBy} options={orderByOptions} onChange={onChangeOrderBy} />
|
||||
</FormControl>
|
||||
<FormControl
|
||||
isDisabled={isFetching}
|
||||
sx={{
|
||||
flexDir: 'column',
|
||||
alignItems: 'flex-start',
|
||||
gap: 1,
|
||||
maxW: 56,
|
||||
}}
|
||||
>
|
||||
<FormLabel>{t('common.direction')}</FormLabel>
|
||||
<Combobox value={valueDirection} options={DIRECTION_OPTIONS} onChange={onChangeDirection} />
|
||||
</FormControl>
|
||||
</>
|
||||
)}
|
||||
<InputGroup w="20rem" alignSelf="flex-end">
|
||||
<Input
|
||||
placeholder={t('workflows.searchWorkflows')}
|
||||
value={query}
|
||||
onKeyDown={handleKeydownFilterText}
|
||||
onChange={handleChangeFilterText}
|
||||
data-testid="workflow-search-input"
|
||||
minW={64}
|
||||
/>
|
||||
{query.trim().length && (
|
||||
<InputRightElement h="full" pe={2}>
|
||||
<IconButton
|
||||
onClick={resetFilterText}
|
||||
size="sm"
|
||||
variant="link"
|
||||
aria-label={t('workflows.clearWorkflowSearchFilter')}
|
||||
icon={<PiXBold />}
|
||||
/>
|
||||
</InputRightElement>
|
||||
)}
|
||||
</InputGroup>
|
||||
</Flex>
|
||||
<Divider />
|
||||
{isLoading ? (
|
||||
<IAINoContentFallbackWithSpinner label={t('workflows.loading')} />
|
||||
) : !data || isError ? (
|
||||
<IAINoContentFallback label={t('workflows.problemLoading')} />
|
||||
) : data.items.length ? (
|
||||
<ScrollableContent>
|
||||
<Flex w="full" h="full" flexDir="column">
|
||||
{data.items.map((w) => (
|
||||
<WorkflowLibraryListItem key={w.workflow_id} workflowDTO={w} />
|
||||
))}
|
||||
</Flex>
|
||||
</ScrollableContent>
|
||||
) : (
|
||||
<IAINoContentFallback label={t('workflows.noWorkflows')} />
|
||||
)}
|
||||
<Divider />
|
||||
|
||||
<Flex w="full">
|
||||
<Box flex="1">
|
||||
<UploadWorkflowButton />
|
||||
</Box>
|
||||
<Box flex="1" textAlign="center">
|
||||
{data && <WorkflowLibraryPagination data={data} page={page} setPage={setPage} />}
|
||||
</Box>
|
||||
<Box flex="1"></Box>
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(WorkflowLibraryList);
|
||||
@@ -1,98 +0,0 @@
|
||||
import { Button, Flex, Heading, Spacer, Text } from '@invoke-ai/ui-library';
|
||||
import { EMPTY_OBJECT } from 'app/store/constants';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import dateFormat, { masks } from 'dateformat';
|
||||
import { selectWorkflowId } from 'features/nodes/store/workflowSlice';
|
||||
import { useDeleteLibraryWorkflow } from 'features/workflowLibrary/hooks/useDeleteLibraryWorkflow';
|
||||
import { useGetAndLoadLibraryWorkflow } from 'features/workflowLibrary/hooks/useGetAndLoadLibraryWorkflow';
|
||||
import { useWorkflowLibraryModal } from 'features/workflowLibrary/store/isWorkflowLibraryModalOpen';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { WorkflowRecordListItemDTO } from 'services/api/types';
|
||||
|
||||
type Props = {
|
||||
workflowDTO: WorkflowRecordListItemDTO;
|
||||
};
|
||||
|
||||
const WorkflowLibraryListItem = ({ workflowDTO }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const workflowId = useAppSelector(selectWorkflowId);
|
||||
const workflowLibraryModal = useWorkflowLibraryModal();
|
||||
|
||||
const { deleteWorkflow, deleteWorkflowResult } = useDeleteLibraryWorkflow(EMPTY_OBJECT);
|
||||
const { getAndLoadWorkflow, getAndLoadWorkflowResult } = useGetAndLoadLibraryWorkflow({
|
||||
onSuccess: workflowLibraryModal.setFalse,
|
||||
});
|
||||
|
||||
const handleDeleteWorkflow = useCallback(() => {
|
||||
deleteWorkflow(workflowDTO.workflow_id);
|
||||
}, [deleteWorkflow, workflowDTO.workflow_id]);
|
||||
|
||||
const handleGetAndLoadWorkflow = useCallback(() => {
|
||||
getAndLoadWorkflow(workflowDTO.workflow_id);
|
||||
}, [getAndLoadWorkflow, workflowDTO.workflow_id]);
|
||||
|
||||
const isOpen = useMemo(() => workflowId === workflowDTO.workflow_id, [workflowId, workflowDTO.workflow_id]);
|
||||
|
||||
return (
|
||||
<Flex key={workflowDTO.workflow_id} w="full" p={2} borderRadius="base" _hover={{ bg: 'base.750' }}>
|
||||
<Flex w="full" alignItems="center" gap={2} h={12}>
|
||||
<Flex flexDir="column" flexGrow={1} h="full">
|
||||
<Flex alignItems="center" w="full" h="50%">
|
||||
<Heading size="sm" noOfLines={1} variant={isOpen ? 'invokeBlue' : undefined}>
|
||||
{workflowDTO.name || t('workflows.unnamedWorkflow')}
|
||||
</Heading>
|
||||
<Spacer />
|
||||
{workflowDTO.category !== 'default' && (
|
||||
<Text fontSize="sm" variant="subtext" flexShrink={0} noOfLines={1}>
|
||||
{t('common.updated')}: {dateFormat(workflowDTO.updated_at, masks.shortDate)}{' '}
|
||||
{dateFormat(workflowDTO.updated_at, masks.shortTime)}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
<Flex alignItems="center" w="full" h="50%">
|
||||
{workflowDTO.description ? (
|
||||
<Text fontSize="sm" noOfLines={1}>
|
||||
{workflowDTO.description}
|
||||
</Text>
|
||||
) : (
|
||||
<Text fontSize="sm" variant="subtext" fontStyle="italic" noOfLines={1}>
|
||||
{t('workflows.noDescription')}
|
||||
</Text>
|
||||
)}
|
||||
<Spacer />
|
||||
{workflowDTO.category !== 'default' && (
|
||||
<Text fontSize="sm" variant="subtext" flexShrink={0} noOfLines={1}>
|
||||
{t('common.created')}: {dateFormat(workflowDTO.created_at, masks.shortDate)}{' '}
|
||||
{dateFormat(workflowDTO.created_at, masks.shortTime)}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Button
|
||||
flexShrink={0}
|
||||
isDisabled={isOpen}
|
||||
onClick={handleGetAndLoadWorkflow}
|
||||
isLoading={getAndLoadWorkflowResult.isLoading}
|
||||
aria-label={t('workflows.openWorkflow')}
|
||||
>
|
||||
{t('common.load')}
|
||||
</Button>
|
||||
{workflowDTO.category !== 'default' && (
|
||||
<Button
|
||||
flexShrink={0}
|
||||
colorScheme="error"
|
||||
isDisabled={isOpen}
|
||||
onClick={handleDeleteWorkflow}
|
||||
isLoading={deleteWorkflowResult.isLoading}
|
||||
aria-label={t('workflows.deleteWorkflow')}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(WorkflowLibraryListItem);
|
||||
@@ -1,13 +0,0 @@
|
||||
import { Flex } from '@invoke-ai/ui-library';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import { memo } from 'react';
|
||||
|
||||
const WorkflowLibraryListWrapper = (props: PropsWithChildren) => {
|
||||
return (
|
||||
<Flex w="full" h="full" flexDir="column" layerStyle="second" py={2} px={4} gap={2} borderRadius="base">
|
||||
{props.children}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(WorkflowLibraryListWrapper);
|
||||
@@ -1,34 +0,0 @@
|
||||
import {
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
} from '@invoke-ai/ui-library';
|
||||
import WorkflowLibraryContent from 'features/workflowLibrary/components/WorkflowLibraryContent';
|
||||
import { useWorkflowLibraryModal } from 'features/workflowLibrary/store/isWorkflowLibraryModalOpen';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const WorkflowLibraryModal = () => {
|
||||
const { t } = useTranslation();
|
||||
const workflowLibraryModal = useWorkflowLibraryModal();
|
||||
|
||||
return (
|
||||
<Modal isOpen={workflowLibraryModal.isTrue} onClose={workflowLibraryModal.setFalse} isCentered useInert={false}>
|
||||
<ModalOverlay />
|
||||
<ModalContent w="80%" h="80%" minW="unset" minH="unset" maxW="1200px" maxH="664px">
|
||||
<ModalHeader>{t('workflows.workflowLibrary')}</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<WorkflowLibraryContent />
|
||||
</ModalBody>
|
||||
<ModalFooter />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(WorkflowLibraryModal);
|
||||
@@ -1,80 +0,0 @@
|
||||
import { Button, ButtonGroup, IconButton } from '@invoke-ai/ui-library';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { memo, 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 = 7;
|
||||
|
||||
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'];
|
||||
};
|
||||
|
||||
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 (
|
||||
<ButtonGroup>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
onClick={handlePrevPage}
|
||||
isDisabled={page === 0}
|
||||
aria-label={t('common.prevPage')}
|
||||
icon={<PiCaretLeftBold />}
|
||||
/>
|
||||
{pages.map((p) => (
|
||||
<Button
|
||||
w={10}
|
||||
isDisabled={data.pages === 1}
|
||||
onClick={p.page === page ? undefined : p.onClick}
|
||||
variant={p.page === page ? 'solid' : 'ghost'}
|
||||
key={p.page}
|
||||
transitionDuration="0s" // the delay in animation looks jank
|
||||
>
|
||||
{p.page + 1}
|
||||
</Button>
|
||||
))}
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
onClick={handleNextPage}
|
||||
isDisabled={page === data.pages - 1}
|
||||
aria-label={t('common.nextPage')}
|
||||
icon={<PiCaretRightBold />}
|
||||
/>
|
||||
</ButtonGroup>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(WorkflowLibraryPagination);
|
||||
Reference in New Issue
Block a user