Persist item filter on folder navigation (#1970)

This commit is contained in:
Leendert de Borst
2026-04-26 20:07:51 +02:00
committed by Leendert de Borst
parent 28f03ea321
commit 3ce53185f7
5 changed files with 80 additions and 22 deletions

View File

@@ -24,7 +24,7 @@ import type { Folder } from '@/utils/db/repositories/FolderRepository';
import type { CredentialSortOrder } from '@/utils/db/repositories/SettingsRepository';
import type { Item } from '@/utils/dist/core/models/vault';
import { canHaveSubfolders, getDescendantFolderIds, getFolderPath, getRecursiveItemCount } from '@/utils/folderUtils';
import { applyTypeFilter, isItemTypeFilter, type ItemFilterType } from '@/utils/itemFilters';
import { applyTypeFilter, isItemTypeFilter, parseItemFilterType, type ItemFilterType } from '@/utils/itemFilters';
import { LocalPreferencesService } from '@/utils/LocalPreferencesService';
import { useMinDurationLoading } from '@/hooks/useMinDurationLoading';
@@ -108,7 +108,11 @@ const ItemsList: React.FC = () => {
const [searchTerm, setSearchTerm] = useState(() => {
return searchParams.get('search') || '';
});
const [filterType, setItemFilterType] = useState<ItemFilterType>(getStoredFilter());
// URL filter takes precedence so navigating between folders preserves the active filter.
const [filterType, setItemFilterType] = useState<ItemFilterType>(() => {
const urlFilter = searchParams.get('filter');
return urlFilter ? parseItemFilterType(urlFilter) : getStoredFilter();
});
const [showFilterMenu, setShowFilterMenu] = useState(false);
const [showFolderModal, setShowFolderModal] = useState(false);
const [showDeleteFolderModal, setShowDeleteFolderModal] = useState(false);
@@ -157,10 +161,19 @@ const ItemsList: React.FC = () => {
/**
* Update URL when search term changes to persist it in navigation history.
* Preserves any existing filter param in the URL (set by folder navigation) — the
* filter is initialized from the URL on mount and otherwise managed in state, so
* we just leave whatever filter param is there alone.
*/
useEffect(() => {
// Build the new URL with or without search param
const newUrl = searchTerm ? `${location.pathname}?search=${encodeURIComponent(searchTerm)}` : location.pathname;
const params = new URLSearchParams(location.search);
if (searchTerm) {
params.set('search', searchTerm);
} else {
params.delete('search');
}
const queryString = params.toString();
const newUrl = queryString ? `${location.pathname}?${queryString}` : location.pathname;
// Only update if the URL actually changed
const currentUrl = location.pathname + location.search;
@@ -434,13 +447,11 @@ const ItemsList: React.FC = () => {
}, [dbContext?.sqliteClient, setIsLoading, setIsInitialLoading]);
/**
* Get the title based on the active filter and current folder
* Get the title based on the active filter and current folder.
* An active filter takes precedence over the folder name so the title matches the
* filter that's actually being applied — same behavior as the root view.
*/
const getFilterTitle = () : string => {
if (currentFolderId && currentFolderName) {
return currentFolderName;
}
switch (filterType) {
case 'passkeys':
return t('items.filters.passkeys');
@@ -449,6 +460,9 @@ const ItemsList: React.FC = () => {
case 'totp':
return t('items.filters.totp');
case 'all':
if (currentFolderId && currentFolderName) {
return currentFolderName;
}
return t('items.title');
default:
// Check if it's an item type filter
@@ -458,17 +472,21 @@ const ItemsList: React.FC = () => {
return t(itemTypeOption.titleKey);
}
}
if (currentFolderId && currentFolderName) {
return currentFolderName;
}
return t('items.title');
}
};
/**
* Navigate into a folder via URL
* Navigate into a folder via URL, preserving the active filter so the folder view
* shows the same subset reflected in the folder pill's badge count.
*/
const handleFolderClick = useCallback((folderId: string, _folderName: string) => {
setSearchTerm(''); // Clear search when entering folder
navigate(`/items/folder/${folderId}`);
}, [navigate]);
navigate(`/items/folder/${folderId}?filter=${encodeURIComponent(filterType)}`);
}, [navigate, filterType]);
/**
* Get folders with item counts.

View File

@@ -18,6 +18,23 @@ export function isItemTypeFilter(filter: ItemFilterType): filter is ItemType {
return Object.values(ItemTypes).includes(filter as ItemType);
}
/**
* Parse a filter value from a URL/route param. Returns 'all' if the value is missing
* or doesn't match a known filter, so an unexpected param can't break the screen.
*/
export function parseItemFilterType(value: string | null | undefined): ItemFilterType {
if (!value) {
return 'all';
}
if (value === 'all' || value === 'passkeys' || value === 'attachments' || value === 'totp') {
return value;
}
if (isItemTypeFilter(value as ItemFilterType)) {
return value as ItemType;
}
return 'all';
}
/**
* 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

View File

@@ -14,7 +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 { applyTypeFilter, isItemTypeFilter, parseItemFilterType, type ItemFilterType } from '@/utils/itemFilters';
import { VaultAuthenticationError } from '@/utils/types/errors/VaultAuthenticationError';
import { useColors } from '@/hooks/useColorScheme';
@@ -65,7 +65,7 @@ const ITEM_TYPE_OPTIONS: ItemTypeOption[] = [
* Simplified view with search scoped to this folder only.
*/
export default function FolderViewScreen(): React.ReactNode {
const { id: folderId } = useLocalSearchParams<{ id: string }>();
const { id: folderId, filter: filterParam } = useLocalSearchParams<{ id: string; filter?: string }>();
const { syncVault } = useVaultSync();
const colors = useColors();
const { t } = useTranslation();
@@ -85,9 +85,9 @@ export default function FolderViewScreen(): React.ReactNode {
const [refreshing, setRefreshing] = useMinDurationLoading(false, 200);
const { executeVaultMutation } = useVaultMutate();
// Search and filter state (scoped to this folder)
// Initial filter comes from the route param so navigating from a filtered list into a folder keeps the filter active.
const [searchQuery, setSearchQuery] = useState('');
const [filterType, setFilterType] = useState<ItemFilterType>('all');
const [filterType, setFilterType] = useState<ItemFilterType>(() => parseItemFilterType(filterParam));
const [showFilterMenu, setShowFilterMenu] = useState(false);
// Sort state
@@ -428,11 +428,14 @@ export default function FolderViewScreen(): React.ReactNode {
}, [folderId, router, navigate]);
/**
* Handle subfolder click - navigate to subfolder view.
* Handle subfolder click - navigate to subfolder view, preserving the active filter
* so the subfolder opens with the same filter that produced its badge count.
*/
const handleSubfolderClick = useCallback((subfolderId: string) => {
navigate(() => router.push(`/(tabs)/items/folder/${subfolderId}`));
}, [router, navigate]);
navigate(() => {
router.push(`/(tabs)/items/folder/${subfolderId}?filter=${encodeURIComponent(filterType)}` as '/(tabs)/items/folder/[id]');
});
}, [router, navigate, filterType]);
/**
* Create a new subfolder.

View File

@@ -504,11 +504,14 @@ export default function ItemsScreen(): React.ReactNode {
}, [dbContext.sqliteClient, executeVaultMutation, loadItems]);
/**
* Navigate to a folder.
* Navigate to a folder, preserving the active type/feature filter so the folder view
* shows the same subset of items reflected in the folder's badge count.
*/
const handleFolderClick = useCallback((folderId: string) => {
navigate(() => router.push(`/(tabs)/items/folder/${folderId}`));
}, [router, navigate]);
navigate(() => {
router.push(`/(tabs)/items/folder/${folderId}?filter=${encodeURIComponent(filterType)}` as '/(tabs)/items/folder/[id]');
});
}, [router, navigate, filterType]);
/**
* Create a new folder.

View File

@@ -18,6 +18,23 @@ export function isItemTypeFilter(filter: ItemFilterType): filter is ItemType {
return Object.values(ItemTypes).includes(filter as ItemType);
}
/**
* Parse a filter value from a URL/route param. Returns 'all' if the value is missing
* or doesn't match a known filter, so an unexpected param can't break the screen.
*/
export function parseItemFilterType(value: string | null | undefined): ItemFilterType {
if (!value) {
return 'all';
}
if (value === 'all' || value === 'passkeys' || value === 'attachments' || value === 'totp') {
return value;
}
if (isItemTypeFilter(value as ItemFilterType)) {
return value as ItemType;
}
return 'all';
}
/**
* 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