feat(ui): workflows panel redesign WIP

This commit is contained in:
psychedelicious
2025-02-20 19:24:05 +10:00
parent b78ac40a22
commit 560910ed2f
6 changed files with 99 additions and 63 deletions

View File

@@ -11,7 +11,7 @@ export const ActiveWorkflowName = memo(() => {
if (workflowName) {
return (
<Text colorScheme="invokeBlue" fontWeight="semibold" fontSize="md" justifySelf="flex-start" noOfLines={1}>
<Text fontWeight="semibold" fontSize="md" justifySelf="flex-start" noOfLines={1}>
{workflowName}
</Text>
);
@@ -19,7 +19,7 @@ export const ActiveWorkflowName = memo(() => {
// activeWorkflowName is always a string - if it is an empty string, it implies we do not have a workflow selected
return (
<Text fontSize="sm" fontWeight="semibold" color="base.300" noOfLines={1}>
<Text fontSize="md" fontWeight="semibold" color="base.300" noOfLines={1}>
{t('workflows.chooseWorkflowFromLibrary')}
</Text>
);

View File

@@ -1,7 +1,6 @@
import { Flex, Spacer } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { NewWorkflowButton } from 'features/nodes/components/sidePanel/NewWorkflowButton';
import { ActiveWorkflowName } from 'features/nodes/components/sidePanel/WorkflowListMenu/ActiveWorkflowName';
import { WorkflowListMenuTrigger } from 'features/nodes/components/sidePanel/WorkflowListMenu/WorkflowListMenuTrigger';
import { WorkflowViewEditToggleButton } from 'features/nodes/components/sidePanel/WorkflowViewEditToggleButton';
import { selectWorkflowMode } from 'features/nodes/store/workflowSlice';
@@ -14,12 +13,11 @@ export const ActiveWorkflowNameAndActions = memo(() => {
return (
<Flex w="full" alignItems="center" gap={2} minW={0}>
<ActiveWorkflowName />
<WorkflowListMenuTrigger />
<Spacer />
{mode === 'edit' && <SaveWorkflowButton />}
<WorkflowViewEditToggleButton />
<NewWorkflowButton />
<WorkflowListMenuTrigger />
</Flex>
);
});

View File

@@ -3,19 +3,20 @@ import { useStore } from '@nanostores/react';
import { $workflowCategories } from 'app/store/nanostores/workflowCategories';
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
import UploadWorkflowButton from 'features/workflowLibrary/components/UploadWorkflowButton';
import type { RefObject } from 'react';
import { memo } from 'react';
import { WorkflowList } from './WorkflowList';
import WorkflowSearch from './WorkflowSearch';
import { WorkflowSearch } from './WorkflowSearch';
import { WorkflowSortControl } from './WorkflowSortControl';
export const WorkflowListMenuContent = memo(() => {
export const WorkflowListMenuContent = memo(({ searchInputRef }: { searchInputRef: RefObject<HTMLInputElement> }) => {
const workflowCategories = useStore($workflowCategories);
return (
<Flex w="full" h="full" flexDir="column" gap={2}>
<Flex alignItems="center" gap={2} w="full" justifyContent="space-between">
<WorkflowSearch />
<WorkflowSearch searchInputRef={searchInputRef} />
<WorkflowSortControl />
<UploadWorkflowButton />
</Flex>

View File

@@ -1,28 +1,58 @@
import { IconButton, Popover, PopoverBody, PopoverContent, PopoverTrigger, Portal } from '@invoke-ai/ui-library';
import { WorkflowListMenuContent } from 'features/nodes/components/sidePanel/WorkflowListMenu/WorkflowListMenuContent';
import { Box, Button, Flex, Popover, PopoverBody, PopoverContent, PopoverTrigger, Portal } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { $workflowCategories } from 'app/store/nanostores/workflowCategories';
import { useAppSelector } from 'app/store/storeHooks';
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
import { useWorkflowListMenu } from 'features/nodes/store/workflowListMenu';
import { selectWorkflowName } from 'features/nodes/store/workflowSlice';
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 { t } = useTranslation();
const workflowCategories = useStore($workflowCategories);
const searchInputRef = useRef<HTMLInputElement>(null);
const workflowName = useAppSelector(selectWorkflowName);
return (
<Popover isOpen={workflowListMenu.isOpen} onClose={workflowListMenu.close} onOpen={workflowListMenu.open}>
<Popover
isOpen={workflowListMenu.isOpen}
onClose={workflowListMenu.close}
onOpen={workflowListMenu.open}
isLazy
lazyBehavior="unmount"
placement="bottom-end"
initialFocusRef={searchInputRef}
>
<PopoverTrigger>
<IconButton
aria-label={t('workflows.openLibrary')}
tooltip={t('workflows.openLibrary')}
variant="ghost"
icon={<PiFolderOpenFill />}
size="sm"
/>
<Button variant="ghost" rightIcon={<PiFolderOpenFill />}>
{workflowName || t('workflows.chooseWorkflowFromLibrary')}
</Button>
</PopoverTrigger>
<Portal appendToParentPortal={false}>
<Portal>
<PopoverContent p={4} w={512} maxW="full" minH={512} maxH="full">
<PopoverBody flex="1 1 0">
<WorkflowListMenuContent />
<Flex w="full" h="full" flexDir="column" gap={2}>
<Flex alignItems="center" gap={2} w="full" justifyContent="space-between">
<WorkflowSearch searchInputRef={searchInputRef} />
<WorkflowSortControl />
<UploadWorkflowButton />
</Flex>
<Box position="relative" w="full" h="full">
<ScrollableContent>
{workflowCategories.map((category) => (
<WorkflowList key={category} category={category} />
))}
</ScrollableContent>
</Box>
</Flex>
</PopoverBody>
</PopoverContent>
</Portal>

View File

@@ -1,12 +1,12 @@
import { 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 } from 'react';
import { memo, useCallback } from 'react';
import type { ChangeEvent, KeyboardEvent, RefObject } from 'react';
import { memo, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { PiXBold } from 'react-icons/pi';
const WorkflowSearch = () => {
export const WorkflowSearch = memo(({ searchInputRef }: { searchInputRef: RefObject<HTMLInputElement> }) => {
const dispatch = useAppDispatch();
const searchTerm = useAppSelector(selectWorkflowSearchTerm);
const { t } = useTranslation();
@@ -39,9 +39,16 @@ const WorkflowSearch = () => {
[handleWorkflowSearch]
);
useEffect(() => {
if (searchInputRef.current) {
searchInputRef.current.focus();
}
}, [searchInputRef]);
return (
<InputGroup>
<Input
ref={searchInputRef}
placeholder={t('stylePresets.searchByName')}
value={searchTerm}
onKeyDown={handleKeydown}
@@ -60,6 +67,6 @@ const WorkflowSearch = () => {
)}
</InputGroup>
);
};
});
export default memo(WorkflowSearch);
WorkflowSearch.displayName = 'WorkflowSearch';

View File

@@ -1,6 +1,4 @@
import type { ComboboxOnChange, ComboboxOption } from '@invoke-ai/ui-library';
import {
Combobox,
Flex,
FormControl,
FormLabel,
@@ -9,6 +7,7 @@ import {
PopoverBody,
PopoverContent,
PopoverTrigger,
Select,
} from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { $projectId } from 'app/store/nanostores/projectId';
@@ -19,6 +18,7 @@ import {
workflowOrderByChanged,
workflowOrderDirectionChanged,
} from 'features/nodes/store/workflowSlice';
import type { ChangeEvent } from 'react';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiSortAscendingBold, PiSortDescendingBold } from 'react-icons/pi';
@@ -39,62 +39,54 @@ export const WorkflowSortControl = () => {
const orderBy = useAppSelector(selectWorkflowOrderBy);
const direction = useAppSelector(selectWorkflowOrderDirection);
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') },
],
const ORDER_BY_LABELS = useMemo(
() => ({
opened_at: t('workflows.opened'),
created_at: t('workflows.created'),
updated_at: t('workflows.updated'),
name: t('workflows.name'),
}),
[t]
);
const DIRECTION_OPTIONS: ComboboxOption[] = useMemo(
() => [
{ value: 'ASC', label: t('workflows.ascending') },
{ value: 'DESC', label: t('workflows.descending') },
],
const DIRECTION_LABELS = useMemo(
() => ({
ASC: t('workflows.ascending'),
DESC: t('workflows.descending'),
}),
[t]
);
const dispatch = useAppDispatch();
const orderByOptions = useMemo(() => {
return projectId ? ORDER_BY_OPTIONS.filter((option) => option.value !== 'opened_at') : ORDER_BY_OPTIONS;
}, [projectId, ORDER_BY_OPTIONS]);
const onChangeOrderBy = useCallback<ComboboxOnChange>(
(v) => {
if (!isOrderBy(v?.value) || v.value === orderBy) {
const onChangeOrderBy = useCallback(
(e: ChangeEvent<HTMLSelectElement>) => {
if (!isOrderBy(e.target.value)) {
return;
}
dispatch(workflowOrderByChanged(v.value));
dispatch(workflowOrderByChanged(e.target.value));
},
[orderBy, dispatch]
[dispatch]
);
const valueOrderBy = useMemo(() => {
return orderByOptions.find((o) => o.value === orderBy) || orderByOptions[0];
}, [orderBy, orderByOptions]);
const onChangeDirection = useCallback<ComboboxOnChange>(
(v) => {
if (!isDirection(v?.value) || v.value === direction) {
const onChangeDirection = useCallback(
(e: ChangeEvent<HTMLSelectElement>) => {
if (!isDirection(e.target.value)) {
return;
}
dispatch(workflowOrderDirectionChanged(v.value));
dispatch(workflowOrderDirectionChanged(e.target.value));
},
[direction, dispatch]
);
const valueDirection = useMemo(
() => DIRECTION_OPTIONS.find((o) => o.value === direction),
[direction, DIRECTION_OPTIONS]
[dispatch]
);
// In OSS, we don't have the concept of "opened_at" for workflows. This is only available in the Enterprise version.
const defaultOrderBy = projectId !== undefined ? 'opened_at' : 'created_at';
return (
<Popover placement="bottom">
<PopoverTrigger>
<IconButton
tooltip={`Sorting by ${valueOrderBy?.label} ${valueDirection?.label}`}
tooltip={`Sorting by ${ORDER_BY_LABELS[orderBy ?? defaultOrderBy]} ${DIRECTION_LABELS[direction]}`}
aria-label="Sort Workflow Library"
icon={direction === 'ASC' ? <PiSortAscendingBold /> : <PiSortDescendingBold />}
variant="ghost"
@@ -106,11 +98,19 @@ export const WorkflowSortControl = () => {
<Flex flexDir="column" gap={4}>
<FormControl orientation="horizontal" gap={1}>
<FormLabel>{t('common.orderBy')}</FormLabel>
<Combobox value={valueOrderBy} options={orderByOptions} onChange={onChangeOrderBy} />
<Select value={orderBy ?? defaultOrderBy} onChange={onChangeOrderBy} size="sm">
{projectId !== undefined && <option value="opened_at">{ORDER_BY_LABELS['opened_at']}</option>}
<option value="created_at">{ORDER_BY_LABELS['created_at']}</option>
<option value="updated_at">{ORDER_BY_LABELS['updated_at']}</option>
<option value="name">{ORDER_BY_LABELS['name']}</option>
</Select>
</FormControl>
<FormControl orientation="horizontal" gap={1}>
<FormLabel>{t('common.direction')}</FormLabel>
<Combobox value={valueDirection} options={DIRECTION_OPTIONS} onChange={onChangeDirection} />
<Select value={direction} onChange={onChangeDirection} size="sm">
<option value="ASC">{DIRECTION_LABELS['ASC']}</option>
<option value="DESC">{DIRECTION_LABELS['DESC']}</option>
</Select>
</FormControl>
</Flex>
</PopoverBody>