mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-18 13:28:12 -04:00
Update mobile app to refresh folder counts when a filter is active (#1970)
This commit is contained in:
committed by
Leendert de Borst
parent
f2fd267703
commit
28f03ea321
@@ -14,6 +14,7 @@ import { getFieldValue, FieldKey, ItemTypes } from '@/utils/dist/core/models/vau
|
||||
import emitter from '@/utils/EventEmitter';
|
||||
import { canHaveSubfolders, getRecursiveItemCount } from '@/utils/folderUtils';
|
||||
import { HapticsUtility } from '@/utils/HapticsUtility';
|
||||
import { applyTypeFilter, isItemTypeFilter, type ItemFilterType } from '@/utils/itemFilters';
|
||||
import { VaultAuthenticationError } from '@/utils/types/errors/VaultAuthenticationError';
|
||||
|
||||
import { useColors } from '@/hooks/useColorScheme';
|
||||
@@ -40,18 +41,6 @@ import { useDialog } from '@/context/DialogContext';
|
||||
|
||||
import type { FolderWithCount } from '@/components/folders/FolderPill';
|
||||
|
||||
/**
|
||||
* Filter types for the items list.
|
||||
*/
|
||||
type FilterType = 'all' | 'passkeys' | 'attachments' | ItemType;
|
||||
|
||||
/**
|
||||
* Check if a filter is an item type filter.
|
||||
*/
|
||||
const isItemTypeFilter = (filter: FilterType): filter is ItemType => {
|
||||
return Object.values(ItemTypes).includes(filter as ItemType);
|
||||
};
|
||||
|
||||
/**
|
||||
* Item type filter option configuration.
|
||||
*/
|
||||
@@ -87,8 +76,8 @@ export default function FolderViewScreen(): React.ReactNode {
|
||||
const flatListRef = useRef<FlatList<Item | null>>(null);
|
||||
|
||||
const [itemsList, setItemsList] = useState<Item[]>([]);
|
||||
const [allItemsInVault, setAllItemsInVault] = useState<Item[]>([]);
|
||||
const [folder, setFolder] = useState<Folder | null>(null);
|
||||
const [subfolders, setSubfolders] = useState<FolderWithCount[]>([]);
|
||||
const [canCreateSubfolder, setCanCreateSubfolder] = useState(false);
|
||||
const [allFolders, setAllFolders] = useState<Folder[]>([]);
|
||||
// No minimum loading delay for folder view since data is already in memory
|
||||
@@ -98,7 +87,7 @@ export default function FolderViewScreen(): React.ReactNode {
|
||||
|
||||
// Search and filter state (scoped to this folder)
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filterType, setFilterType] = useState<FilterType>('all');
|
||||
const [filterType, setFilterType] = useState<ItemFilterType>('all');
|
||||
const [showFilterMenu, setShowFilterMenu] = useState(false);
|
||||
|
||||
// Sort state
|
||||
@@ -143,29 +132,16 @@ export default function FolderViewScreen(): React.ReactNode {
|
||||
* Filter items by search query and type (within this folder only).
|
||||
*/
|
||||
const filteredItems = useMemo(() => {
|
||||
return itemsList.filter(item => {
|
||||
// Apply type filter
|
||||
let passesTypeFilter = true;
|
||||
const typeFiltered = applyTypeFilter(itemsList, filterType);
|
||||
|
||||
if (filterType === 'passkeys') {
|
||||
passesTypeFilter = item.HasPasskey === true;
|
||||
} else if (filterType === 'attachments') {
|
||||
passesTypeFilter = item.HasAttachment === true;
|
||||
} else if (isItemTypeFilter(filterType)) {
|
||||
passesTypeFilter = item.ItemType === filterType;
|
||||
}
|
||||
const searchLower = searchQuery.toLowerCase().trim();
|
||||
if (!searchLower) {
|
||||
return typeFiltered;
|
||||
}
|
||||
|
||||
if (!passesTypeFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Apply search filter
|
||||
const searchLower = searchQuery.toLowerCase().trim();
|
||||
|
||||
if (!searchLower) {
|
||||
return true;
|
||||
}
|
||||
const searchWords = searchLower.split(/\s+/).filter(word => word.length > 0);
|
||||
|
||||
return typeFiltered.filter(item => {
|
||||
const searchableFields = [
|
||||
item.Name?.toLowerCase() || '',
|
||||
getFieldValue(item, FieldKey.LoginUsername)?.toLowerCase() || '',
|
||||
@@ -174,8 +150,6 @@ export default function FolderViewScreen(): React.ReactNode {
|
||||
getFieldValue(item, FieldKey.NotesContent)?.toLowerCase() || '',
|
||||
];
|
||||
|
||||
const searchWords = searchLower.split(/\s+/).filter(word => word.length > 0);
|
||||
|
||||
return searchWords.every(word =>
|
||||
searchableFields.some(field => field.includes(word))
|
||||
);
|
||||
@@ -196,8 +170,27 @@ export default function FolderViewScreen(): React.ReactNode {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return getRecursiveItemCount(folderId, itemsList, allFolders);
|
||||
}, [folderId, allFolders, itemsList]);
|
||||
return getRecursiveItemCount(folderId, allItemsInVault, allFolders);
|
||||
}, [folderId, allFolders, allItemsInVault]);
|
||||
|
||||
/**
|
||||
* Direct subfolders of the current folder, with item counts that respect the active
|
||||
* type/feature filter so each badge matches what the user will see when opening the folder.
|
||||
*/
|
||||
const subfolders = useMemo((): FolderWithCount[] => {
|
||||
if (!folderId || allFolders.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const itemsForCount = applyTypeFilter(allItemsInVault, filterType);
|
||||
|
||||
const childFolders = allFolders.filter((f: Folder) => f.ParentFolderId === folderId);
|
||||
return childFolders.map((f) => ({
|
||||
id: f.Id,
|
||||
name: f.Name,
|
||||
itemCount: getRecursiveItemCount(f.Id, itemsForCount, allFolders),
|
||||
}));
|
||||
}, [folderId, allFolders, allItemsInVault, filterType]);
|
||||
|
||||
/**
|
||||
* Load items in this folder, subfolders, and folder details.
|
||||
@@ -216,21 +209,12 @@ export default function FolderViewScreen(): React.ReactNode {
|
||||
// Filter to only items in this folder
|
||||
const folderItems = items.filter((item: Item) => item.FolderId === folderId);
|
||||
setItemsList(folderItems);
|
||||
setAllItemsInVault(items);
|
||||
|
||||
// Find this folder
|
||||
const currentFolder = folders.find((f: Folder) => f.Id === folderId);
|
||||
setFolder(currentFolder || null);
|
||||
|
||||
// Get subfolders (direct children only)
|
||||
const childFolders = folders.filter((f: Folder) => f.ParentFolderId === folderId);
|
||||
|
||||
const subfoldersWithCounts: FolderWithCount[] = childFolders.map((f) => ({
|
||||
id: f.Id,
|
||||
name: f.Name,
|
||||
itemCount: getRecursiveItemCount(f.Id, items, folders),
|
||||
}));
|
||||
|
||||
setSubfolders(subfoldersWithCounts);
|
||||
setAllFolders(folders);
|
||||
|
||||
// Calculate if we can create subfolders (check depth)
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { Item, ItemType } from '@/utils/dist/core/models/vault';
|
||||
import { getFieldValue, FieldKey, ItemTypes } from '@/utils/dist/core/models/vault';
|
||||
import emitter from '@/utils/EventEmitter';
|
||||
import { HapticsUtility } from '@/utils/HapticsUtility';
|
||||
import { applyTypeFilter, isItemTypeFilter, type ItemFilterType } from '@/utils/itemFilters';
|
||||
import { VaultAuthenticationError } from '@/utils/types/errors/VaultAuthenticationError';
|
||||
|
||||
import { useColors } from '@/hooks/useColorScheme';
|
||||
@@ -38,18 +39,6 @@ import { useApp } from '@/context/AppContext';
|
||||
import { useDb } from '@/context/DbContext';
|
||||
import { LocalPreferencesService } from '@/services/LocalPreferencesService';
|
||||
|
||||
/**
|
||||
* Filter types for the items list.
|
||||
*/
|
||||
type FilterType = 'all' | 'passkeys' | 'attachments' | 'totp' | ItemType;
|
||||
|
||||
/**
|
||||
* Check if a filter is an item type filter.
|
||||
*/
|
||||
const isItemTypeFilter = (filter: FilterType): filter is ItemType => {
|
||||
return Object.values(ItemTypes).includes(filter as ItemType);
|
||||
};
|
||||
|
||||
/**
|
||||
* Item type filter option configuration.
|
||||
*/
|
||||
@@ -93,7 +82,7 @@ export default function ItemsScreen(): React.ReactNode {
|
||||
|
||||
// Search and filter state
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filterType, setFilterType] = useState<FilterType>('all');
|
||||
const [filterType, setFilterType] = useState<ItemFilterType>('all');
|
||||
const [showFilterMenu, setShowFilterMenu] = useState(false);
|
||||
const [sortOrder, setSortOrder] = useState<CredentialSortOrder>('OldestFirst');
|
||||
const [showSortMenu, setShowSortMenu] = useState(false);
|
||||
@@ -174,6 +163,8 @@ export default function ItemsScreen(): React.ReactNode {
|
||||
|
||||
/**
|
||||
* Get folders with item counts for display.
|
||||
* Counts respect the active type/feature filter so the folder badge always matches
|
||||
* the number of items the user will see when navigating into the folder.
|
||||
*/
|
||||
const foldersWithCounts = useMemo((): FolderWithCount[] => {
|
||||
// Don't show folders when searching
|
||||
@@ -181,6 +172,9 @@ export default function ItemsScreen(): React.ReactNode {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Apply the active type/feature filter to the items used for counting.
|
||||
const itemsForCount = applyTypeFilter(itemsList, filterType);
|
||||
|
||||
/**
|
||||
* Count items per folder (including items in subfolders recursively).
|
||||
* @param folderId - The folder ID to count items for
|
||||
@@ -188,7 +182,7 @@ export default function ItemsScreen(): React.ReactNode {
|
||||
*/
|
||||
const getRecursiveItemCount = (folderId: string): number => {
|
||||
// Get items directly in this folder
|
||||
const directItems = itemsList.filter((item: Item) => item.FolderId === folderId);
|
||||
const directItems = itemsForCount.filter((item: Item) => item.FolderId === folderId);
|
||||
|
||||
// Get all child folders
|
||||
const childFolders = folders.filter(f => f.ParentFolderId === folderId);
|
||||
@@ -210,7 +204,7 @@ export default function ItemsScreen(): React.ReactNode {
|
||||
itemCount: getRecursiveItemCount(folder.Id)
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [folders, itemsList, searchQuery]);
|
||||
}, [folders, itemsList, searchQuery, filterType]);
|
||||
|
||||
/**
|
||||
* Get the title based on the active filter.
|
||||
@@ -248,40 +242,24 @@ export default function ItemsScreen(): React.ReactNode {
|
||||
* Filter items by folder, type, and search query.
|
||||
*/
|
||||
const filteredItems = useMemo(() => {
|
||||
return itemsList.filter(item => {
|
||||
/*
|
||||
* When showing folders (checkbox ON): only show root items (exclude items in folders)
|
||||
* When not showing folders (checkbox OFF): show all items flat
|
||||
*/
|
||||
if (!searchQuery && showFolderItems && item.FolderId) {
|
||||
return false;
|
||||
}
|
||||
// When searching or not showing folders: show all matching items regardless of folder
|
||||
/*
|
||||
* When showing folders (checkbox ON): only show root items (exclude items in folders)
|
||||
* When not showing folders (checkbox OFF): show all items flat
|
||||
*/
|
||||
const folderScoped = !searchQuery && showFolderItems
|
||||
? itemsList.filter(item => !item.FolderId)
|
||||
: itemsList;
|
||||
|
||||
// Apply type filter
|
||||
let passesTypeFilter = true;
|
||||
const typeFiltered = applyTypeFilter(folderScoped, filterType);
|
||||
|
||||
if (filterType === 'passkeys') {
|
||||
passesTypeFilter = item.HasPasskey === true;
|
||||
} else if (filterType === 'attachments') {
|
||||
passesTypeFilter = item.HasAttachment === true;
|
||||
} else if (filterType === 'totp') {
|
||||
passesTypeFilter = item.HasTotp === true;
|
||||
} else if (isItemTypeFilter(filterType)) {
|
||||
passesTypeFilter = item.ItemType === filterType;
|
||||
}
|
||||
const searchLower = searchQuery.toLowerCase().trim();
|
||||
if (!searchLower) {
|
||||
return typeFiltered;
|
||||
}
|
||||
|
||||
if (!passesTypeFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Apply search filter
|
||||
const searchLower = searchQuery.toLowerCase().trim();
|
||||
|
||||
if (!searchLower) {
|
||||
return true;
|
||||
}
|
||||
const searchWords = searchLower.split(/\s+/).filter(word => word.length > 0);
|
||||
|
||||
return typeFiltered.filter(item => {
|
||||
const searchableFields = [
|
||||
item.Name?.toLowerCase() || '',
|
||||
getFieldValue(item, FieldKey.LoginUsername)?.toLowerCase() || '',
|
||||
@@ -290,8 +268,6 @@ export default function ItemsScreen(): React.ReactNode {
|
||||
getFieldValue(item, FieldKey.NotesContent)?.toLowerCase() || '',
|
||||
];
|
||||
|
||||
const searchWords = searchLower.split(/\s+/).filter(word => word.length > 0);
|
||||
|
||||
return searchWords.every(word =>
|
||||
searchableFields.some(field => field.includes(word))
|
||||
);
|
||||
|
||||
46
apps/mobile-app/utils/itemFilters.ts
Normal file
46
apps/mobile-app/utils/itemFilters.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { Item, ItemType } from './dist/core/models/vault';
|
||||
import { ItemTypes } from './dist/core/models/vault';
|
||||
|
||||
/**
|
||||
* Filter types for the items list.
|
||||
* - 'all': Show all items
|
||||
* - 'passkeys': Show only items with passkeys
|
||||
* - 'attachments': Show only items with attachments
|
||||
* - 'totp': Show only items with 2FA codes
|
||||
* - ItemType values: Filter by specific item type (Login, Alias, CreditCard, Note)
|
||||
*/
|
||||
export type ItemFilterType = 'all' | 'passkeys' | 'attachments' | 'totp' | ItemType;
|
||||
|
||||
/**
|
||||
* Check if a filter is an item type filter (Login, Alias, CreditCard, Note).
|
||||
*/
|
||||
export function isItemTypeFilter(filter: ItemFilterType): filter is ItemType {
|
||||
return Object.values(ItemTypes).includes(filter as ItemType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the active type/feature filter to a list of items.
|
||||
* Used both for the visible item list and for computing folder badge counts so they
|
||||
* stay consistent — when a filter is active, folder counts only include matching items.
|
||||
*/
|
||||
export function applyTypeFilter(items: Item[], filterType: ItemFilterType): Item[] {
|
||||
if (filterType === 'all') {
|
||||
return items;
|
||||
}
|
||||
|
||||
return items.filter((item: Item) => {
|
||||
if (filterType === 'passkeys') {
|
||||
return item.HasPasskey === true;
|
||||
}
|
||||
if (filterType === 'attachments') {
|
||||
return item.HasAttachment === true;
|
||||
}
|
||||
if (filterType === 'totp') {
|
||||
return item.HasTotp === true;
|
||||
}
|
||||
if (isItemTypeFilter(filterType)) {
|
||||
return item.ItemType === filterType;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user