Add item duplicate option to browser extension (#2293)
This commit is contained in:
committed by
Leendert de Borst
parent
fe45aed298
commit
629f7696a3
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { Item } from '@/utils/dist/core/models/vault';
|
||||
import { FieldKey } from '@/utils/dist/core/models/vault';
|
||||
import { truncateFolderPath } from '@/utils/FolderUtils';
|
||||
|
||||
import ItemContextMenu from './ItemContextMenu';
|
||||
import ItemIcon from './ItemIcon';
|
||||
|
||||
type ItemCardProps = {
|
||||
@@ -15,6 +16,9 @@ type ItemCardProps = {
|
||||
currentFolderPath?: string[] | null;
|
||||
isActive?: boolean;
|
||||
optionId?: string;
|
||||
isHighlighted?: boolean;
|
||||
onDuplicate?: (itemId: string) => void;
|
||||
onDelete?: (itemId: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -24,9 +28,11 @@ type ItemCardProps = {
|
||||
* It allows the user to navigate to the item details page when clicked.
|
||||
*
|
||||
*/
|
||||
const ItemCard: React.FC<ItemCardProps> = ({ item, showFolderPath = false, searchTerm = '', currentFolderPath = null, isActive = false, optionId }) => {
|
||||
const ItemCard: React.FC<ItemCardProps> = ({ item, showFolderPath = false, searchTerm = '', currentFolderPath = null, isActive = false, optionId, isHighlighted = false, onDuplicate, onDelete }) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [menuPosition, setMenuPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
const menuEnabled = Boolean(onDuplicate || onDelete);
|
||||
|
||||
/**
|
||||
* Get the display text for the item (username or email)
|
||||
@@ -102,8 +108,28 @@ const ItemCard: React.FC<ItemCardProps> = ({ item, showFolderPath = false, searc
|
||||
return truncated.join(' > ');
|
||||
};
|
||||
|
||||
/**
|
||||
* Open the item context menu from a right-click at the cursor position.
|
||||
*/
|
||||
const handleContextMenu = (e: React.MouseEvent): void => {
|
||||
if (!menuEnabled) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
setMenuPosition({ x: e.clientX, y: e.clientY });
|
||||
};
|
||||
|
||||
/**
|
||||
* Open the item context menu anchored below the ellipsis button.
|
||||
*/
|
||||
const handleEllipsisClick = (e: React.MouseEvent<HTMLButtonElement>): void => {
|
||||
e.stopPropagation();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
setMenuPosition({ x: rect.right - 160, y: rect.bottom + 4 });
|
||||
};
|
||||
|
||||
return (
|
||||
<li id={optionId} role="option" aria-selected={isActive}>
|
||||
<li id={optionId} data-item-id={item.Id} role="option" aria-selected={isActive} className="relative group" onContextMenu={handleContextMenu}>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Build URL with search query parameter if present
|
||||
@@ -111,7 +137,9 @@ const ItemCard: React.FC<ItemCardProps> = ({ item, showFolderPath = false, searc
|
||||
navigate(url);
|
||||
}}
|
||||
className={`w-full p-2 border rounded flex items-center bg-white dark:bg-gray-800 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-primary-500 ${
|
||||
isActive
|
||||
menuEnabled ? 'pr-7' : ''
|
||||
} ${
|
||||
isActive || isHighlighted
|
||||
? 'border-primary-500 dark:border-primary-400 ring-2 ring-primary-500/40'
|
||||
: 'border-gray-200 dark:border-gray-600'
|
||||
}`}
|
||||
@@ -180,6 +208,31 @@ const ItemCard: React.FC<ItemCardProps> = ({ item, showFolderPath = false, searc
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{getDisplayText(item)}</p>
|
||||
</div>
|
||||
</button>
|
||||
{menuEnabled && (
|
||||
<button
|
||||
onClick={handleEllipsisClick}
|
||||
aria-label={t('items.itemOptions')}
|
||||
aria-haspopup="menu"
|
||||
className={`absolute right-2 top-1/2 -translate-y-1/2 px-1 py-1 rounded text-gray-400 transition-opacity ${
|
||||
isActive || menuPosition ? 'opacity-100' : 'opacity-0'
|
||||
} group-hover:opacity-100 group-focus-within:opacity-100 focus:opacity-100 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-primary-500`}
|
||||
>
|
||||
<svg className="w-2 h-4" viewBox="0 0 12 24" fill="currentColor" aria-hidden="true">
|
||||
<circle cx="6" cy="5" r="2" />
|
||||
<circle cx="6" cy="12" r="2" />
|
||||
<circle cx="6" cy="19" r="2" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{menuPosition && (
|
||||
<ItemContextMenu
|
||||
position={menuPosition}
|
||||
onClose={() => setMenuPosition(null)}
|
||||
onEdit={() => navigate(`/items/${item.Id}/edit`)}
|
||||
onDuplicate={() => onDuplicate?.(item.Id)}
|
||||
onDelete={() => onDelete?.(item.Id)}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type ItemContextMenuProps = {
|
||||
position: { x: number; y: number };
|
||||
onClose: () => void;
|
||||
onEdit: () => void;
|
||||
onDuplicate: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
const MENU_WIDTH = 160;
|
||||
const MENU_HEIGHT = 120;
|
||||
|
||||
/**
|
||||
* Context menu with actions for a single item in the items list.
|
||||
* Opened via right-click on an item row or via the row's ellipsis button.
|
||||
*/
|
||||
const ItemContextMenu: React.FC<ItemContextMenuProps> = ({ position, onClose, onEdit, onDuplicate, onDelete }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Close on Escape while the menu is open.
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Close the menu when Escape is pressed.
|
||||
*/
|
||||
const handleKeyDown = (event: KeyboardEvent): void => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return (): void => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
const left = Math.max(8, Math.min(position.x, window.innerWidth - MENU_WIDTH - 8));
|
||||
const top = Math.max(8, Math.min(position.y, window.innerHeight - MENU_HEIGHT - 8));
|
||||
|
||||
const itemClass = 'w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700';
|
||||
|
||||
/**
|
||||
* Close the menu, then run the given action.
|
||||
*/
|
||||
const runAction = (action: () => void) => (): void => {
|
||||
onClose();
|
||||
action();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-30"
|
||||
onClick={onClose}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
role="menu"
|
||||
style={{ top, left }}
|
||||
className="fixed z-40 w-40 py-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg"
|
||||
>
|
||||
<button role="menuitem" onClick={runAction(onEdit)} className={itemClass}>
|
||||
{t('common.edit')}
|
||||
</button>
|
||||
<button role="menuitem" onClick={runAction(onDuplicate)} className={itemClass}>
|
||||
{t('common.duplicate')}
|
||||
</button>
|
||||
<button role="menuitem" onClick={runAction(onDelete)} className={`${itemClass} text-red-600 dark:text-red-400`}>
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ItemContextMenu;
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import ConfirmDeleteModal from '@/entrypoints/popup/components/Dialogs/ConfirmDeleteModal';
|
||||
import DeleteFolderModal from '@/entrypoints/popup/components/Folders/DeleteFolderModal';
|
||||
import FolderBreadcrumb from '@/entrypoints/popup/components/Folders/FolderBreadcrumb';
|
||||
import FolderModal from '@/entrypoints/popup/components/Folders/FolderModal';
|
||||
@@ -126,6 +127,8 @@ const ItemsList: React.FC = () => {
|
||||
const [showFolderModal, setShowFolderModal] = useState(false);
|
||||
const [showDeleteFolderModal, setShowDeleteFolderModal] = useState(false);
|
||||
const [showEditFolderModal, setShowEditFolderModal] = useState(false);
|
||||
const [deleteItemId, setDeleteItemId] = useState<string | null>(null);
|
||||
const [highlightedItemId, setHighlightedItemId] = useState<string | null>(null);
|
||||
const [recentlyDeletedCount, setRecentlyDeletedCount] = useState(0);
|
||||
const [folderRefreshKey, setFolderRefreshKey] = useState(0);
|
||||
const [sortOrder, setSortOrder] = useState<CredentialSortOrder>('NewestFirst');
|
||||
@@ -255,6 +258,67 @@ const ItemsList: React.FC = () => {
|
||||
setItems(results);
|
||||
}, [dbContext, currentFolderId, executeVaultMutationAsync]);
|
||||
|
||||
/**
|
||||
* Duplicate an item via the item context menu.
|
||||
*/
|
||||
const handleDuplicateItem = useCallback(async (itemId: string) : Promise<void> => {
|
||||
if (!dbContext?.sqliteClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
let newItemId: string | null = null;
|
||||
await executeVaultMutationAsync(async () => {
|
||||
newItemId = await dbContext.sqliteClient!.items.duplicate(itemId);
|
||||
});
|
||||
|
||||
// Refresh items to show the new duplicate
|
||||
const results = dbContext.sqliteClient!.items.getAll();
|
||||
setItems(results);
|
||||
|
||||
// Scroll to and briefly highlight the new duplicate so it's clear where it landed
|
||||
setHighlightedItemId(newItemId);
|
||||
}, [dbContext, executeVaultMutationAsync]);
|
||||
|
||||
/**
|
||||
* Scroll the highlighted item (a freshly created duplicate) into view once it's
|
||||
* rendered, then clear the highlight after a short moment.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!highlightedItemId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait a frame so the re-rendered list contains the new item before scrolling.
|
||||
requestAnimationFrame(() => {
|
||||
document.querySelector(`[data-item-id="${highlightedItemId}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => setHighlightedItemId(null), 2000);
|
||||
return (): void => clearTimeout(timer);
|
||||
}, [highlightedItemId]);
|
||||
|
||||
/**
|
||||
* Move an item to the trash after confirmation via the item context menu.
|
||||
*/
|
||||
const handleConfirmDeleteItem = useCallback(async () : Promise<void> => {
|
||||
if (!dbContext?.sqliteClient || !deleteItemId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const itemId = deleteItemId;
|
||||
setDeleteItemId(null);
|
||||
|
||||
await executeVaultMutationAsync(async () => {
|
||||
await dbContext.sqliteClient!.items.trash(itemId);
|
||||
});
|
||||
|
||||
// Refresh items to reflect the deletion
|
||||
const results = dbContext.sqliteClient!.items.getAll();
|
||||
setItems(results);
|
||||
const deletedCount = dbContext.sqliteClient!.items.getRecentlyDeletedCount();
|
||||
setRecentlyDeletedCount(deletedCount);
|
||||
}, [dbContext, deleteItemId, executeVaultMutationAsync]);
|
||||
|
||||
/**
|
||||
* Handle delete folder (keep items, move them to root).
|
||||
*/
|
||||
@@ -1035,6 +1099,9 @@ const ItemsList: React.FC = () => {
|
||||
currentFolderPath={currentFolderPath}
|
||||
isActive={activeKind === 'item' && activeIndex === index}
|
||||
optionId={itemIdFor(index)}
|
||||
isHighlighted={item.Id === highlightedItemId}
|
||||
onDuplicate={handleDuplicateItem}
|
||||
onDelete={setDeleteItemId}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
@@ -1082,6 +1149,16 @@ const ItemsList: React.FC = () => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Delete Item Confirmation Modal */}
|
||||
<ConfirmDeleteModal
|
||||
isOpen={deleteItemId !== null}
|
||||
onClose={() => setDeleteItemId(null)}
|
||||
onConfirm={handleConfirmDeleteItem}
|
||||
title={t('items.deleteItemTitle')}
|
||||
message={t('items.deleteItemConfirm')}
|
||||
confirmText={t('common.delete')}
|
||||
/>
|
||||
|
||||
{/* Create Folder Modal */}
|
||||
<FolderModal
|
||||
isOpen={showFolderModal}
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
"save": "Save",
|
||||
"saving": "Saving...",
|
||||
"edit": "Edit",
|
||||
"duplicate": "Duplicate",
|
||||
"create": "Create",
|
||||
"or": "Or",
|
||||
"close": "Close",
|
||||
@@ -198,6 +199,7 @@
|
||||
"saveItem": "Save Item",
|
||||
"itemDetails": "Item Details",
|
||||
"editItem": "Edit Item",
|
||||
"itemOptions": "Item options",
|
||||
"untitled": "Untitled",
|
||||
"newFolder": "New Folder",
|
||||
"createFolder": "Create Folder",
|
||||
|
||||
@@ -218,6 +218,131 @@ export class ItemRepository extends BaseRepository {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate an item including all fields. Data that is not duplicated is passkeys and field history.
|
||||
* @param itemId - The ID of the item to duplicate
|
||||
* @returns The ID of the newly created item
|
||||
*/
|
||||
public async duplicate(itemId: string): Promise<string> {
|
||||
return this.withTransaction(async () => {
|
||||
const currentDateTime = this.now();
|
||||
const newItemId = this.generateId();
|
||||
|
||||
const sourceRows = this.client.executeQuery<{ Name: string | null }>(
|
||||
'SELECT Name FROM Items WHERE Id = ? AND IsDeleted = 0',
|
||||
[itemId]
|
||||
);
|
||||
if (sourceRows.length === 0) {
|
||||
throw new Error(`Item not found: ${itemId}`);
|
||||
}
|
||||
|
||||
const existingNames = this.client.executeQuery<{ Name: string | null }>(
|
||||
'SELECT Name FROM Items WHERE IsDeleted = 0 AND DeletedAt IS NULL'
|
||||
);
|
||||
const newName = ItemRepository.generateCopyName(
|
||||
sourceRows[0].Name,
|
||||
existingNames.map(row => row.Name)
|
||||
);
|
||||
|
||||
// 1. Copy the item row itself (same logo, folder and type).
|
||||
this.client.executeUpdate(`
|
||||
INSERT INTO Items (Id, Name, ItemType, LogoId, FolderId, CreatedAt, UpdatedAt, IsDeleted)
|
||||
SELECT ?, ?, ItemType, LogoId, FolderId, ?, ?, 0 FROM Items WHERE Id = ?`,
|
||||
[newItemId, newName, currentDateTime, currentDateTime, itemId]);
|
||||
|
||||
/*
|
||||
* 2. Copy custom field definitions so later edits to the duplicate's
|
||||
* custom fields don't affect the original item.
|
||||
*/
|
||||
const definitionRows = this.client.executeQuery<{ Id: string }>(
|
||||
`SELECT DISTINCT FieldDefinitionId as Id FROM FieldValues
|
||||
WHERE ItemId = ? AND IsDeleted = 0 AND FieldDefinitionId IS NOT NULL`,
|
||||
[itemId]
|
||||
);
|
||||
const definitionIdMap = new Map<string, string>();
|
||||
for (const definition of definitionRows) {
|
||||
const newDefinitionId = this.generateId();
|
||||
definitionIdMap.set(definition.Id, newDefinitionId);
|
||||
this.client.executeUpdate(`
|
||||
INSERT INTO FieldDefinitions (Id, FieldType, Label, IsMultiValue, IsHidden, EnableHistory, Weight, ApplicableToTypes, CreatedAt, UpdatedAt, IsDeleted)
|
||||
SELECT ?, FieldType, Label, IsMultiValue, IsHidden, EnableHistory, Weight, ApplicableToTypes, ?, ?, 0 FROM FieldDefinitions WHERE Id = ?`,
|
||||
[newDefinitionId, currentDateTime, currentDateTime, definition.Id]);
|
||||
}
|
||||
|
||||
// 3. Copy field values, remapping custom fields to the copied definitions.
|
||||
const fieldValueRows = this.client.executeQuery<{ Id: string; FieldDefinitionId: string | null }>(
|
||||
'SELECT Id, FieldDefinitionId FROM FieldValues WHERE ItemId = ? AND IsDeleted = 0',
|
||||
[itemId]
|
||||
);
|
||||
for (const row of fieldValueRows) {
|
||||
this.client.executeUpdate(`
|
||||
INSERT INTO FieldValues (Id, ItemId, FieldDefinitionId, FieldKey, Value, Weight, CreatedAt, UpdatedAt, IsDeleted)
|
||||
SELECT ?, ?, ?, FieldKey, Value, Weight, ?, ?, 0 FROM FieldValues WHERE Id = ?`,
|
||||
[
|
||||
this.generateId(),
|
||||
newItemId,
|
||||
row.FieldDefinitionId ? definitionIdMap.get(row.FieldDefinitionId) ?? null : null,
|
||||
currentDateTime,
|
||||
currentDateTime,
|
||||
row.Id
|
||||
]);
|
||||
}
|
||||
|
||||
/*
|
||||
* 4. Copy remaining child rows with fresh IDs.
|
||||
*/
|
||||
const childCopies = [
|
||||
{
|
||||
table: 'TotpCodes',
|
||||
sql: `INSERT INTO TotpCodes (Id, ItemId, Name, SecretKey, CreatedAt, UpdatedAt, IsDeleted)
|
||||
SELECT ?, ?, Name, SecretKey, ?, ?, 0 FROM TotpCodes WHERE Id = ?`,
|
||||
},
|
||||
{
|
||||
table: 'Attachments',
|
||||
sql: `INSERT INTO Attachments (Id, ItemId, Filename, Blob, CreatedAt, UpdatedAt, IsDeleted)
|
||||
SELECT ?, ?, Filename, Blob, ?, ?, 0 FROM Attachments WHERE Id = ?`,
|
||||
},
|
||||
{
|
||||
table: 'ItemTags',
|
||||
sql: `INSERT INTO ItemTags (Id, ItemId, TagId, CreatedAt, UpdatedAt, IsDeleted)
|
||||
SELECT ?, ?, TagId, ?, ?, 0 FROM ItemTags WHERE Id = ?`,
|
||||
},
|
||||
];
|
||||
|
||||
for (const copy of childCopies) {
|
||||
const rows = this.client.executeQuery<{ Id: string }>(
|
||||
`SELECT Id FROM ${copy.table} WHERE ItemId = ? AND IsDeleted = 0`,
|
||||
[itemId]
|
||||
);
|
||||
for (const row of rows) {
|
||||
this.client.executeUpdate(copy.sql, [this.generateId(), newItemId, currentDateTime, currentDateTime, row.Id]);
|
||||
}
|
||||
}
|
||||
|
||||
return newItemId;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique name for a duplicated item: "Name (1)", "Name (2)", etc.
|
||||
* If the source name already ends with a "(n)" suffix, the counter is incremented
|
||||
* instead of stacking suffixes.
|
||||
*/
|
||||
private static generateCopyName(sourceName: string | null, existingNames: (string | null)[]): string | null {
|
||||
if (!sourceName) {
|
||||
return sourceName;
|
||||
}
|
||||
|
||||
const base = sourceName.replace(/ \(\d+\)$/, '');
|
||||
const taken = new Set(existingNames.filter((name): name is string => name !== null));
|
||||
|
||||
let candidate = `${base} (1)`;
|
||||
for (let counter = 2; taken.has(candidate); counter++) {
|
||||
candidate = `${base} (${counter})`;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing item with field-based structure.
|
||||
* @param item The item object to update
|
||||
|
||||
Reference in New Issue
Block a user