Update folder functionality (#1404)

This commit is contained in:
Leendert de Borst
2025-12-12 16:48:08 +01:00
parent 871852ff59
commit 7a9c63b2d5
6 changed files with 369 additions and 30 deletions

View File

@@ -0,0 +1,167 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
type DeleteFolderModalProps = {
isOpen: boolean;
onClose: () => void;
onDeleteFolderOnly: () => Promise<void>;
onDeleteFolderAndContents: () => Promise<void>;
folderName: string;
itemCount: number;
};
/**
* Modal for deleting a folder with options to keep or delete contents
*/
const DeleteFolderModal: React.FC<DeleteFolderModalProps> = ({
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<void> => {
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<void> => {
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
<div
className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md mx-4"
onKeyDown={handleKeyDown}
>
{/* Header */}
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
{t('items.deleteFolder')}
</h2>
</div>
{/* Body */}
<div className="px-6 py-4 space-y-4">
<p className="text-gray-700 dark:text-gray-300">
{t('items.deleteFolderConfirm', { folderName })}
</p>
{itemCount > 0 && (
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('items.folderContainsItems', { count: itemCount })}
</p>
)}
{/* Option buttons */}
<div className="space-y-3 pt-2">
{/* Delete folder only - move items to root */}
<button
type="button"
onClick={handleDeleteFolderOnly}
disabled={isSubmitting}
className="w-full p-3 text-left border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50"
>
<div className="flex items-start gap-3">
<div className="mt-0.5 text-orange-500">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 14l2 2 4-4" />
</svg>
</div>
<div>
<p className="font-medium text-gray-900 dark:text-white">
{t('items.deleteFolderKeepItems')}
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('items.deleteFolderKeepItemsDescription')}
</p>
</div>
</div>
</button>
{/* Delete folder and contents */}
{itemCount > 0 && (
<button
type="button"
onClick={handleDeleteFolderAndContents}
disabled={isSubmitting}
className="w-full p-3 text-left border border-red-300 dark:border-red-700 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors disabled:opacity-50"
>
<div className="flex items-start gap-3">
<div className="mt-0.5 text-red-500">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</div>
<div>
<p className="font-medium text-red-600 dark:text-red-400">
{t('items.deleteFolderAndItems')}
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('items.deleteFolderAndItemsDescription', { count: itemCount })}
</p>
</div>
</div>
</button>
)}
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end">
<button
type="button"
onClick={onClose}
disabled={isSubmitting}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
>
{t('common.cancel')}
</button>
</div>
</div>
</div>
);
};
export default DeleteFolderModal;

View File

@@ -31,7 +31,6 @@ const ItemNameInput: React.FC<ItemNameInputProps> = ({
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<ItemNameInputProps> = ({
<label htmlFor="itemName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('credentials.itemName')} <span className="text-red-500">*</span>
</label>
<div className="relative">
<div className="relative flex items-center">
<input
ref={inputRef}
id="itemName"
@@ -76,31 +75,29 @@ const ItemNameInput: React.FC<ItemNameInputProps> = ({
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 && (
<button
type="button"
onClick={handleOpenFolderModal}
className={`absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-1 px-2 py-1 rounded transition-colors text-xs ${
selectedFolderId
? 'bg-primary-100 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400 hover:bg-primary-200 dark:hover:bg-primary-900/50'
: 'text-gray-400 dark:text-gray-500 hover:text-gray-500 dark:hover:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-600'
}`}
title={selectedFolder?.Name || t('items.noFolder')}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
{selectedFolderId && (
<span className="max-w-16 truncate">
{selectedFolder?.Name}
</span>
)}
</button>
)}
{/* Folder Button inside input - always visible, width adjusts based on whether a folder is selected */}
<button
type="button"
onClick={handleOpenFolderModal}
className={`absolute right-1 z-10 flex items-center gap-1 px-2 py-1 rounded transition-colors text-xs ${
selectedFolderId
? 'bg-primary-100 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400 hover:bg-primary-200 dark:hover:bg-primary-900/50'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600'
}`}
title={selectedFolder?.Name || t('items.noFolder')}
>
<svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
{selectedFolderId && (
<span className="max-w-16 truncate">
{selectedFolder?.Name}
</span>
)}
</button>
</div>
</div>

View File

@@ -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

View File

@@ -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<FilterType>(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<void> => {
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<void> => {
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')
}
</p>
{/* Show help text and delete button when inside an empty folder */}
{currentFolderId && (
<>
<p className="text-sm">
{t('items.emptyFolderHint')}
</p>
<button
onClick={() => setShowDeleteFolderModal(true)}
className="inline-flex items-center gap-2 px-3 py-2 mt-4 text-sm text-red-600 dark:text-red-400 border border-red-300 dark:border-red-700 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
{t('items.deleteFolder')}
</button>
</>
)}
</div>
) : (
<>
{/* Folders as inline pills (only show at root level when not searching) */}
{!currentFolderId && !searchTerm && folders.length > 0 && (
{!currentFolderId && !searchTerm && (
<div className="flex flex-wrap items-center gap-2 mb-4">
{folders.map(folder => (
<FolderPill
@@ -528,13 +607,26 @@ const ItemsList: React.FC = () => {
))}
<button
onClick={handleAddFolder}
className="inline-flex items-center gap-1 px-2.5 py-1.5 text-xs text-gray-500 dark:text-gray-400 hover:text-orange-600 dark:hover:text-orange-400 hover:bg-gray-100 dark:hover:bg-gray-700/50 rounded-full transition-colors focus:outline-none"
className={`inline-flex items-center gap-1 px-2.5 py-1.5 text-xs rounded-full transition-colors focus:outline-none ${
folders.length > 0
? 'text-gray-500 dark:text-gray-400 hover:text-orange-600 dark:hover:text-orange-400 hover:bg-gray-100 dark:hover:bg-gray-700/50'
: 'text-gray-400 dark:text-gray-500 border border-dashed border-gray-300 dark:border-gray-600 hover:border-orange-400 dark:hover:border-orange-500 hover:text-orange-600 dark:hover:text-orange-400'
}`}
>
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
<svg className="w-3 h-3 -ml-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
{t('items.newFolder')}
{folders.length === 0 && (
/**
* Only show text when there are no folders yet
* if there are folders we hide the text to save on UI space
*/
<span>{t('items.newFolder')}</span>
)}
</button>
</div>
)}
@@ -552,6 +644,20 @@ const ItemsList: React.FC = () => {
</ul>
)}
{/* Delete folder button (only show when inside a folder) */}
{currentFolderId && !searchTerm && (
<button
onClick={() => setShowDeleteFolderModal(true)}
className="w-full mt-4 p-3 flex items-center gap-2 text-left text-red-600 dark:text-red-400 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 hover:border-red-300 dark:hover:border-red-700 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
<span>{t('items.deleteFolder')}</span>
</button>
)}
{/* Recently Deleted link (only show at root level when not searching) */}
{!currentFolderId && !searchTerm && (
<button
@@ -590,6 +696,16 @@ const ItemsList: React.FC = () => {
onSave={handleSaveFolder}
mode="create"
/>
{/* Delete Folder Modal */}
<DeleteFolderModal
isOpen={showDeleteFolderModal}
onClose={() => setShowDeleteFolderModal(false)}
onDeleteFolderOnly={handleDeleteFolderOnly}
onDeleteFolderAndContents={handleDeleteFolderAndContents}
folderName={currentFolderName || ''}
itemCount={filteredItems.length}
/>
</div>
);
};

View File

@@ -283,6 +283,14 @@
"noPasskeysFound": "No passkeys have been created yet. Passkeys are created by visiting a website that offers passkeys as an authentication method.",
"noAttachmentsFound": "No items with attachments found",
"noMatchingItems": "No matching items found",
"emptyFolderHint": "To move items to this folder, edit the item and tap the folder icon in the name field.",
"deleteFolder": "Delete Folder",
"deleteFolderConfirm": "How would you like to delete \"{{folderName}}\"?",
"folderContainsItems": "This folder contains {{count}} item(s).",
"deleteFolderKeepItems": "Delete folder only",
"deleteFolderKeepItemsDescription": "Items will be moved back to the main list.",
"deleteFolderAndItems": "Delete folder and all items",
"deleteFolderAndItemsDescription": "{{count}} item(s) will be moved to Recently Deleted.",
"filters": {
"all": "(All) Items",
"passkeys": "Passkeys",

View File

@@ -3045,6 +3045,51 @@ export class SqliteClient {
}
}
/**
* Delete a folder and all items within it (soft delete both folder and items)
* Items are moved to "Recently Deleted" (trash)
* @param folderId - The ID of the folder to delete
* @returns The number of items trashed
*/
public async deleteFolderWithContents(folderId: string): Promise<number> {
if (!this.db) {
throw new Error('Database not initialized');
}
try {
this.beginTransaction();
const currentDateTime = dateFormatter.now();
// 1. Move all items in this folder to trash (set DeletedAt) and clear FolderId
// so that when restored, items won't reference a deleted folder
const itemsQuery = `
UPDATE Items
SET DeletedAt = ?,
UpdatedAt = ?,
FolderId = NULL
WHERE FolderId = ? AND IsDeleted = 0 AND DeletedAt IS NULL`;
const itemsDeleted = this.executeUpdate(itemsQuery, [currentDateTime, currentDateTime, folderId]);
// 2. Soft delete the folder
const folderQuery = `
UPDATE Folders
SET IsDeleted = 1,
UpdatedAt = ?
WHERE Id = ?`;
this.executeUpdate(folderQuery, [currentDateTime, folderId]);
await this.commitTransaction();
return itemsDeleted;
} catch (error) {
this.rollbackTransaction();
console.error('Error deleting folder with contents:', error);
throw error;
}
}
/**
* Move an item to a folder
* @param itemId - The ID of the item to move