From 7a9c63b2d5334de6a02663111a07a6db65dac5de Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Fri, 12 Dec 2025 16:48:08 +0100 Subject: [PATCH] Update folder functionality (#1404) --- .../components/Folders/DeleteFolderModal.tsx | 167 ++++++++++++++++++ .../popup/components/Items/ItemNameInput.tsx | 47 +++-- .../popup/components/Layout/BottomNav.tsx | 8 +- .../popup/pages/items/ItemsList.tsx | 124 ++++++++++++- .../src/i18n/locales/en.json | 8 + .../src/utils/SqliteClient.ts | 45 +++++ 6 files changed, 369 insertions(+), 30 deletions(-) create mode 100644 apps/browser-extension/src/entrypoints/popup/components/Folders/DeleteFolderModal.tsx diff --git a/apps/browser-extension/src/entrypoints/popup/components/Folders/DeleteFolderModal.tsx b/apps/browser-extension/src/entrypoints/popup/components/Folders/DeleteFolderModal.tsx new file mode 100644 index 000000000..bdcc8a8b6 --- /dev/null +++ b/apps/browser-extension/src/entrypoints/popup/components/Folders/DeleteFolderModal.tsx @@ -0,0 +1,167 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +type DeleteFolderModalProps = { + isOpen: boolean; + onClose: () => void; + onDeleteFolderOnly: () => Promise; + onDeleteFolderAndContents: () => Promise; + folderName: string; + itemCount: number; +}; + +/** + * Modal for deleting a folder with options to keep or delete contents + */ +const DeleteFolderModal: React.FC = ({ + isOpen, + onClose, + onDeleteFolderOnly, + onDeleteFolderAndContents, + folderName, + itemCount +}) => { + const { t } = useTranslation(); + const [isSubmitting, setIsSubmitting] = useState(false); + + /** + * Handle delete folder only (move items to root) + */ + const handleDeleteFolderOnly = async (): Promise => { + setIsSubmitting(true); + try { + await onDeleteFolderOnly(); + onClose(); + } catch (err) { + console.error('Error deleting folder:', err); + } finally { + setIsSubmitting(false); + } + }; + + /** + * Handle delete folder and all contents + */ + const handleDeleteFolderAndContents = async (): Promise => { + setIsSubmitting(true); + try { + await onDeleteFolderAndContents(); + onClose(); + } catch (err) { + console.error('Error deleting folder with contents:', err); + } finally { + setIsSubmitting(false); + } + }; + + /** + * Handle escape key press + */ + const handleKeyDown = (e: React.KeyboardEvent): void => { + if (e.key === 'Escape' && !isSubmitting) { + onClose(); + } + }; + + if (!isOpen) { + return null; + } + + return ( +
+
+ {/* Header */} +
+

+ {t('items.deleteFolder')} +

+
+ + {/* Body */} +
+

+ {t('items.deleteFolderConfirm', { folderName })} +

+ + {itemCount > 0 && ( +

+ {t('items.folderContainsItems', { count: itemCount })} +

+ )} + + {/* Option buttons */} +
+ {/* Delete folder only - move items to root */} + + + {/* Delete folder and contents */} + {itemCount > 0 && ( + + )} +
+
+ + {/* Footer */} +
+ +
+
+
+ ); +}; + +export default DeleteFolderModal; diff --git a/apps/browser-extension/src/entrypoints/popup/components/Items/ItemNameInput.tsx b/apps/browser-extension/src/entrypoints/popup/components/Items/ItemNameInput.tsx index 2d27b5978..93dd894cd 100644 --- a/apps/browser-extension/src/entrypoints/popup/components/Items/ItemNameInput.tsx +++ b/apps/browser-extension/src/entrypoints/popup/components/Items/ItemNameInput.tsx @@ -31,7 +31,6 @@ const ItemNameInput: React.FC = ({ const [showFolderModal, setShowFolderModal] = useState(false); const selectedFolder = folders.find(f => f.Id === selectedFolderId); - const hasFolders = folders.length > 0; /** * Handle folder selection and close the modal. @@ -68,7 +67,7 @@ const ItemNameInput: React.FC = ({ -
+
= ({ value={value} onChange={handleNameChange} placeholder={t('credentials.itemName')} - className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-white ${hasFolders ? 'pr-28' : ''}`} + className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-white ${selectedFolderId ? 'pr-28' : 'pr-10'}`} required /> - {/* Folder Button inside input */} - {hasFolders && ( - - )} + {/* Folder Button inside input - always visible, width adjusts based on whether a folder is selected */} +
diff --git a/apps/browser-extension/src/entrypoints/popup/components/Layout/BottomNav.tsx b/apps/browser-extension/src/entrypoints/popup/components/Layout/BottomNav.tsx index 942ed8be9..ed25ed5a7 100644 --- a/apps/browser-extension/src/entrypoints/popup/components/Layout/BottomNav.tsx +++ b/apps/browser-extension/src/entrypoints/popup/components/Layout/BottomNav.tsx @@ -27,10 +27,16 @@ const BottomNav: React.FC = () => { /** * Handle tab change. + * For items tab, pass state to signal the list should reset search/filters. */ const handleTabChange = (tab: TabName) : void => { setCurrentTab(tab); - navigate(`/${tab}`); + if (tab === 'items') { + // Navigate with state to signal ItemsList to reset search/filters + navigate(`/${tab}`, { state: { resetFilters: true } }); + } else { + navigate(`/${tab}`); + } }; // Auth pages that don't show bottom navigation but still show header diff --git a/apps/browser-extension/src/entrypoints/popup/pages/items/ItemsList.tsx b/apps/browser-extension/src/entrypoints/popup/pages/items/ItemsList.tsx index 4215fe132..4d39e85fb 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/items/ItemsList.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/items/ItemsList.tsx @@ -1,7 +1,8 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { useNavigate, useParams } from 'react-router-dom'; +import { useLocation, useNavigate, useParams } from 'react-router-dom'; +import DeleteFolderModal from '@/entrypoints/popup/components/Folders/DeleteFolderModal'; import FolderModal from '@/entrypoints/popup/components/Folders/FolderModal'; import HeaderButton from '@/entrypoints/popup/components/HeaderButton'; import { HeaderIconType } from '@/entrypoints/popup/components/Icons/HeaderIcons'; @@ -80,6 +81,7 @@ type FolderWithCount = { const ItemsList: React.FC = () => { const { t } = useTranslation(); const { folderId: folderIdParam } = useParams<{ folderId?: string }>(); + const location = useLocation(); const dbContext = useDb(); const app = useApp(); const navigate = useNavigate(); @@ -91,6 +93,7 @@ const ItemsList: React.FC = () => { const [filterType, setFilterType] = useState(getStoredFilter()); const [showFilterMenu, setShowFilterMenu] = useState(false); const [showFolderModal, setShowFolderModal] = useState(false); + const [showDeleteFolderModal, setShowDeleteFolderModal] = useState(false); const [recentlyDeletedCount, setRecentlyDeletedCount] = useState(0); const { setIsInitialLoading } = useLoading(); @@ -112,6 +115,20 @@ const ItemsList: React.FC = () => { */ const [isLoading, setIsLoading] = useMinDurationLoading(true, 100); + /** + * Reset search and filter when navigating via the vault tab (with resetFilters state). + */ + useEffect(() => { + const state = location.state as { resetFilters?: boolean } | null; + if (state?.resetFilters) { + setSearchTerm(''); + setFilterType('all'); + localStorage.removeItem(FILTER_STORAGE_KEY); + // Clear the state to prevent re-triggering on subsequent renders + navigate(location.pathname, { replace: true, state: {} }); + } + }, [location.state, location.pathname, navigate]); + /** * Handle add new item. * Navigate directly to add item page (defaults to Login type). @@ -145,6 +162,50 @@ const ItemsList: React.FC = () => { setItems(results); }, [dbContext, currentFolderId, executeVaultMutationAsync]); + /** + * Handle delete folder (keep items, move them to root). + */ + const handleDeleteFolderOnly = useCallback(async () : Promise => { + if (!dbContext?.sqliteClient || !currentFolderId) { + return; + } + + await executeVaultMutationAsync(async () => { + await dbContext.sqliteClient!.deleteFolder(currentFolderId); + }); + + // Refresh items list to reflect changes + const results = dbContext.sqliteClient!.getAllItems(); + setItems(results); + const deletedCount = dbContext.sqliteClient!.getRecentlyDeletedCount(); + setRecentlyDeletedCount(deletedCount); + + // Navigate back to root + navigate('/items'); + }, [dbContext, currentFolderId, executeVaultMutationAsync, navigate]); + + /** + * Handle delete folder and all its contents. + */ + const handleDeleteFolderAndContents = useCallback(async () : Promise => { + if (!dbContext?.sqliteClient || !currentFolderId) { + return; + } + + await executeVaultMutationAsync(async () => { + await dbContext.sqliteClient!.deleteFolderWithContents(currentFolderId); + }); + + // Refresh items list to reflect changes + const results = dbContext.sqliteClient!.getAllItems(); + setItems(results); + const deletedCount = dbContext.sqliteClient!.getRecentlyDeletedCount(); + setRecentlyDeletedCount(deletedCount); + + // Navigate back to root + navigate('/items'); + }, [dbContext, currentFolderId, executeVaultMutationAsync, navigate]); + /** * Retrieve latest vault and refresh the items list. */ @@ -513,11 +574,29 @@ const ItemsList: React.FC = () => { : t('items.noMatchingItems') }

+ {/* Show help text and delete button when inside an empty folder */} + {currentFolderId && ( + <> +

+ {t('items.emptyFolderHint')} +

+ + + )} ) : ( <> {/* Folders as inline pills (only show at root level when not searching) */} - {!currentFolderId && !searchTerm && folders.length > 0 && ( + {!currentFolderId && !searchTerm && (
{folders.map(folder => ( { ))}
)} @@ -552,6 +644,20 @@ const ItemsList: React.FC = () => { )} + {/* Delete folder button (only show when inside a folder) */} + {currentFolderId && !searchTerm && ( + + )} + {/* Recently Deleted link (only show at root level when not searching) */} {!currentFolderId && !searchTerm && (