Skip to content

Commit 73c6b31

Browse files
joshistoastlstein
andauthored
feat(model manager): 💄 refactor model manager bulk actions UI (#8684)
* feat(model manager): 💄 refactor model manager bulk actions UI * feat(model manager): 💄 tweak model list item ui for checkbox selects * style(model manager): 🚨 satisfy the linter * feat(model manager): 💄 tweak search and actions dropdown placement * refactor(model manager): 🔥 remove unused `ModelListHeader` component * fix(model manager): 🐛 list items overlapping sticky headers --------- Co-authored-by: Lincoln Stein <[email protected]>
1 parent f82bcd4 commit 73c6b31

File tree

8 files changed

+257
-257
lines changed

8 files changed

+257
-257
lines changed

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -864,6 +864,7 @@
864864
},
865865
"modelManager": {
866866
"active": "active",
867+
"actions": "Bulk Actions",
867868
"addModel": "Add Model",
868869
"addModels": "Add Models",
869870
"advanced": "Advanced",
@@ -899,6 +900,7 @@
899900
"delete": "Delete",
900901
"deleteConfig": "Delete Config",
901902
"deleteModel": "Delete Model",
903+
"deleteModels": "Delete Models",
902904
"deleteModelImage": "Delete Model Image",
903905
"deleteMsg1": "Are you sure you want to delete this model from InvokeAI?",
904906
"deleteMsg2": "This WILL delete the model from disk if it is in the InvokeAI root folder. If you are using a custom location, then the model WILL NOT be deleted from disk.",
@@ -1030,6 +1032,7 @@
10301032
"triggerPhrases": "Trigger Phrases",
10311033
"loraTriggerPhrases": "LoRA Trigger Phrases",
10321034
"mainModelTriggerPhrases": "Main Model Trigger Phrases",
1035+
"selectAll": "Select All",
10331036
"typePhraseHere": "Type phrase here",
10341037
"t5Encoder": "T5 Encoder",
10351038
"qwen3Encoder": "Qwen3 Encoder",

invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelList.tsx

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { Flex, Text, useDisclosure, useToast } from '@invoke-ai/ui-library';
1+
import { Flex, Text, useToast } from '@invoke-ai/ui-library';
22
import { logger } from 'app/logging/logger';
33
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
44
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
5+
import { buildUseDisclosure } from 'common/hooks/useBoolean';
56
import { MODEL_CATEGORIES_AS_LIST } from 'features/modelManagerV2/models';
67
import {
78
clearModelSelection,
@@ -23,19 +24,20 @@ import type { AnyModelConfig } from 'services/api/types';
2324

2425
import { BulkDeleteModelsModal } from './BulkDeleteModelsModal';
2526
import { FetchingModelsLoader } from './FetchingModelsLoader';
26-
import { ModelListHeader } from './ModelListHeader';
2727
import { ModelListWrapper } from './ModelListWrapper';
2828

2929
const log = logger('models');
3030

31+
export const [useBulkDeleteModal] = buildUseDisclosure(false);
32+
3133
const ModelList = () => {
3234
const dispatch = useAppDispatch();
3335
const filteredModelType = useAppSelector(selectFilteredModelType);
3436
const searchTerm = useAppSelector(selectSearchTerm);
3537
const selectedModelKeys = useAppSelector(selectSelectedModelKeys);
3638
const { t } = useTranslation();
3739
const toast = useToast();
38-
const { isOpen, onOpen, onClose } = useDisclosure();
40+
const { isOpen, close } = useBulkDeleteModal();
3941
const [isDeleting, setIsDeleting] = useState(false);
4042

4143
const { data, isLoading } = useGetModelConfigsQuery();
@@ -62,10 +64,6 @@ const ModelList = () => {
6264
return { total, byCategory };
6365
}, [data, filteredModelType, searchTerm]);
6466

65-
const handleBulkDelete = useCallback(() => {
66-
onOpen();
67-
}, [onOpen]);
68-
6967
const handleConfirmBulkDelete = useCallback(async () => {
7068
setIsDeleting(true);
7169
try {
@@ -74,7 +72,7 @@ const ModelList = () => {
7472
// Clear selection and close modal
7573
dispatch(clearModelSelection());
7674
dispatch(setSelectedModelKey(null));
77-
onClose();
75+
close();
7876

7977
// Show success/failure toast
8078
if (result.failed.length === 0) {
@@ -127,12 +125,11 @@ const ModelList = () => {
127125
} finally {
128126
setIsDeleting(false);
129127
}
130-
}, [bulkDeleteModels, selectedModelKeys, dispatch, onClose, toast, t]);
128+
}, [bulkDeleteModels, selectedModelKeys, dispatch, close, toast, t]);
131129

132130
return (
133131
<>
134132
<Flex flexDirection="column" w="full" h="full">
135-
<ModelListHeader onBulkDelete={handleBulkDelete} />
136133
<ScrollableContent>
137134
<Flex flexDirection="column" w="full" h="full" gap={4}>
138135
{isLoading && <FetchingModelsLoader loadingMessage="Loading..." />}
@@ -147,9 +144,10 @@ const ModelList = () => {
147144
</Flex>
148145
</ScrollableContent>
149146
</Flex>
147+
150148
<BulkDeleteModelsModal
151149
isOpen={isOpen}
152-
onClose={onClose}
150+
onClose={close}
153151
onConfirm={handleConfirmBulkDelete}
154152
modelCount={selectedModelKeys.length}
155153
isDeleting={isDeleting}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import type { SystemStyleObject } from '@invoke-ai/ui-library';
2+
import { Button, Checkbox, Flex, Menu, MenuButton, MenuItem, MenuList, Text } from '@invoke-ai/ui-library';
3+
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
4+
import type { FilterableModelType } from 'features/modelManagerV2/store/modelManagerV2Slice';
5+
import {
6+
modelSelectionChanged,
7+
selectFilteredModelType,
8+
selectSearchTerm,
9+
selectSelectedModelKeys,
10+
} from 'features/modelManagerV2/store/modelManagerV2Slice';
11+
import { t } from 'i18next';
12+
import { memo, useCallback, useMemo } from 'react';
13+
import { PiCaretDownBold, PiTrashSimpleBold } from 'react-icons/pi';
14+
import { modelConfigsAdapterSelectors, useGetModelConfigsQuery } from 'services/api/endpoints/models';
15+
import type { AnyModelConfig } from 'services/api/types';
16+
17+
import { useBulkDeleteModal } from './ModelList';
18+
19+
const ModelListBulkActionsSx: SystemStyleObject = {
20+
alignItems: 'center',
21+
justifyContent: 'space-between',
22+
width: '100%',
23+
};
24+
25+
type ModelListBulkActionsProps = {
26+
sx?: SystemStyleObject;
27+
};
28+
29+
export const ModelListBulkActions = memo(({ sx }: ModelListBulkActionsProps) => {
30+
const dispatch = useAppDispatch();
31+
const filteredModelType = useAppSelector(selectFilteredModelType);
32+
const selectedModelKeys = useAppSelector(selectSelectedModelKeys);
33+
const searchTerm = useAppSelector(selectSearchTerm);
34+
const { data } = useGetModelConfigsQuery();
35+
const bulkDeleteModal = useBulkDeleteModal();
36+
37+
const handleBulkDelete = useCallback(() => {
38+
bulkDeleteModal.open();
39+
}, [bulkDeleteModal]);
40+
41+
// Calculate displayed (filtered) model keys
42+
const displayedModelKeys = useMemo(() => {
43+
const modelConfigs = modelConfigsAdapterSelectors.selectAll(data ?? { ids: [], entities: {} });
44+
const filteredModels = modelsFilter(modelConfigs, searchTerm, filteredModelType);
45+
return filteredModels.map((m) => m.key);
46+
}, [data, searchTerm, filteredModelType]);
47+
48+
const { allSelected, someSelected } = useMemo(() => {
49+
if (displayedModelKeys.length === 0) {
50+
return { allSelected: false, someSelected: false };
51+
}
52+
const selectedSet = new Set(selectedModelKeys);
53+
const displayedSelectedCount = displayedModelKeys.filter((key) => selectedSet.has(key)).length;
54+
return {
55+
allSelected: displayedSelectedCount === displayedModelKeys.length,
56+
someSelected: displayedSelectedCount > 0 && displayedSelectedCount < displayedModelKeys.length,
57+
};
58+
}, [displayedModelKeys, selectedModelKeys]);
59+
60+
const handleToggleAll = useCallback(() => {
61+
if (allSelected) {
62+
// Deselect all displayed models
63+
const displayedSet = new Set(displayedModelKeys);
64+
const newSelection = selectedModelKeys.filter((key) => !displayedSet.has(key));
65+
dispatch(modelSelectionChanged(newSelection));
66+
} else {
67+
// Select all displayed models (merge with existing selection)
68+
const selectedSet = new Set(selectedModelKeys);
69+
displayedModelKeys.forEach((key) => selectedSet.add(key));
70+
dispatch(modelSelectionChanged(Array.from(selectedSet)));
71+
}
72+
}, [allSelected, displayedModelKeys, selectedModelKeys, dispatch]);
73+
74+
const selectionCount = selectedModelKeys.length;
75+
76+
return (
77+
<Flex sx={{ ...ModelListBulkActionsSx, sx }}>
78+
<Checkbox
79+
isChecked={allSelected}
80+
isIndeterminate={someSelected}
81+
onChange={handleToggleAll}
82+
isDisabled={displayedModelKeys.length === 0}
83+
aria-label={t('modelManager.selectAll')}
84+
>
85+
<Text variant="subtext1" color="base.400">
86+
{t('modelManager.selectAll')}
87+
</Text>
88+
</Checkbox>
89+
90+
<Flex alignItems="center" gap={4}>
91+
<Text variant="subtext" color="base.400">
92+
{selectionCount} {t('common.selected')}
93+
</Text>
94+
<Menu placement="bottom-end">
95+
<MenuButton
96+
as={Button}
97+
disabled={selectionCount === 0}
98+
size="sm"
99+
rightIcon={<PiCaretDownBold />}
100+
flexShrink={0}
101+
variant="outline"
102+
>
103+
{t('modelManager.actions')}
104+
</MenuButton>
105+
<MenuList>
106+
<MenuItem icon={<PiTrashSimpleBold />} onClick={handleBulkDelete} color="error.300">
107+
{t('modelManager.deleteModels', { count: selectionCount })}
108+
</MenuItem>
109+
</MenuList>
110+
</Menu>
111+
</Flex>
112+
</Flex>
113+
);
114+
});
115+
116+
ModelListBulkActions.displayName = 'ModelListBulkActions';
117+
118+
const modelsFilter = <T extends AnyModelConfig>(
119+
data: T[],
120+
nameFilter: string,
121+
filteredModelType: FilterableModelType | null
122+
): T[] => {
123+
return data.filter((model) => {
124+
const matchesFilter =
125+
model.name.toLowerCase().includes(nameFilter.toLowerCase()) ||
126+
model.base.toLowerCase().includes(nameFilter.toLowerCase()) ||
127+
model.type.toLowerCase().includes(nameFilter.toLowerCase()) ||
128+
model.description?.toLowerCase().includes(nameFilter.toLowerCase()) ||
129+
model.format.toLowerCase().includes(nameFilter.toLowerCase());
130+
131+
const matchesType = getMatchesType(model, filteredModelType);
132+
133+
return matchesFilter && matchesType;
134+
});
135+
};
136+
137+
const getMatchesType = (modelConfig: AnyModelConfig, filteredModelType: FilterableModelType | null): boolean => {
138+
if (filteredModelType === 'refiner') {
139+
return modelConfig.base === 'sdxl-refiner';
140+
}
141+
142+
if (filteredModelType === 'main' && modelConfig.base === 'sdxl-refiner') {
143+
return false;
144+
}
145+
146+
return filteredModelType ? modelConfig.type === filteredModelType : true;
147+
};

invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelListHeader.tsx

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

0 commit comments

Comments
 (0)