Add item type selector scaffolding (#1404)

This commit is contained in:
Leendert de Borst
2025-12-04 12:01:03 +01:00
parent 87347c3411
commit 044f7dd2c5
5 changed files with 269 additions and 36 deletions

View File

@@ -21,6 +21,7 @@ import CredentialDetails from '@/entrypoints/popup/pages/credentials/CredentialD
import CredentialsList from '@/entrypoints/popup/pages/credentials/CredentialsList';
import ItemAddEdit from '@/entrypoints/popup/pages/credentials/ItemAddEdit';
import ItemDetails from '@/entrypoints/popup/pages/credentials/ItemDetails';
import ItemTypeSelector from '@/entrypoints/popup/pages/credentials/ItemTypeSelector';
import EmailDetails from '@/entrypoints/popup/pages/emails/EmailDetails';
import EmailsList from '@/entrypoints/popup/pages/emails/EmailsList';
import Index from '@/entrypoints/popup/pages/Index';
@@ -188,6 +189,7 @@ const App: React.FC = () => {
{ path: '/credentials/add', element: <CredentialAddEdit />, showBackButton: true, title: t('credentials.addCredential') },
{ path: '/credentials/:id', element: <CredentialDetails />, showBackButton: true, title: t('credentials.credentialDetails') },
{ path: '/credentials/:id/edit', element: <CredentialAddEdit />, showBackButton: true, title: t('credentials.editCredential') },
{ path: '/items/select-type', element: <ItemTypeSelector />, showBackButton: true, title: t('itemTypes.selectType') },
{ path: '/items/:id', element: <ItemDetails />, showBackButton: true, title: 'Item Details' },
{ path: '/items/:id/edit', element: <ItemAddEdit />, showBackButton: true, title: 'Edit Item' },
{ path: '/items/add', element: <ItemAddEdit />, showBackButton: true, title: 'Add Item' },

View File

@@ -85,9 +85,10 @@ const CredentialsList: React.FC = () => {
/**
* Handle add new credential.
* Navigate to item type selector for new item-based flow.
*/
const handleAddCredential = useCallback(() : void => {
navigate('/credentials/add');
navigate('/items/select-type');
}, [navigate]);
/**

View File

@@ -1,8 +1,6 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { useNavigate, useParams } from 'react-router-dom';
import { sendMessage } from 'webext-bridge/popup';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import Modal from '@/entrypoints/popup/components/Dialogs/Modal';
import EditableFieldLabel from '@/entrypoints/popup/components/Forms/EditableFieldLabel';
@@ -20,15 +18,8 @@ import { useVaultMutate } from '@/entrypoints/popup/hooks/useVaultMutate';
import type { Item, ItemField, ItemType, FieldType } from '@/utils/dist/shared/models/vault';
import { getSystemFieldsForItemType } from '@/utils/dist/shared/models/vault';
/**
* Form data structure matching the Item model
*/
type ItemFormData = {
Id: string;
Name: string;
ItemType: ItemType;
Fields: Record<string, string | string[]>; // FieldKey -> Value mapping
};
// Valid item types from the shared model
const VALID_ITEM_TYPES: ItemType[] = ['Login', 'CreditCard', 'Identity', 'Note'];
/**
* Temporary custom field definition (before persisting to database)
@@ -48,10 +39,15 @@ type CustomFieldDefinition = {
const ItemAddEdit: React.FC = () => {
const { t } = useTranslation();
const { id } = useParams();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const dbContext = useDb();
const isEditMode = id !== undefined && id.length > 0;
// Get item type and name from URL parameters (for create mode)
const itemTypeParam = searchParams.get('type') as ItemType | null;
const itemNameParam = searchParams.get('name');
const { executeVaultMutation, isLoading, syncStatus } = useVaultMutate();
const { setHeaderButtons } = useHeaderButtons();
const { setIsInitialLoading } = useLoading();
@@ -75,9 +71,11 @@ const ItemAddEdit: React.FC = () => {
* These are sorted by DefaultDisplayOrder.
*/
const applicableSystemFields = useMemo(() => {
if (!item) return [];
if (!item) {
return [];
}
return getSystemFieldsForItemType(item.ItemType);
}, [item?.ItemType]);
}, [item]);
/**
* Group system fields by category for organized rendering.
@@ -101,11 +99,20 @@ const ItemAddEdit: React.FC = () => {
*/
useEffect(() => {
if (!dbContext?.sqliteClient || !id || !isEditMode) {
// Create mode - initialize with defaults
/*
* Create mode - initialize with defaults
* Validate item type parameter
*/
if (!itemTypeParam || !VALID_ITEM_TYPES.includes(itemTypeParam)) {
/* Redirect to type selector if no valid type specified */
navigate('/items/select-type');
return;
}
setItem({
Id: crypto.randomUUID().toUpperCase(),
Name: '',
ItemType: 'Login',
Name: itemNameParam || '',
ItemType: itemTypeParam,
Fields: [],
CreatedAt: new Date().toISOString(),
UpdatedAt: new Date().toISOString()
@@ -153,7 +160,7 @@ const ItemAddEdit: React.FC = () => {
setLocalLoading(false);
setIsInitialLoading(false);
}
}, [dbContext?.sqliteClient, id, isEditMode, navigate, setIsInitialLoading]);
}, [dbContext?.sqliteClient, id, isEditMode, itemTypeParam, itemNameParam, navigate, setIsInitialLoading]);
/**
* Handle field value change.
@@ -169,17 +176,19 @@ const ItemAddEdit: React.FC = () => {
* Handle form submission.
*/
const handleSave = useCallback(async () => {
if (!item) return;
if (!item) {
return;
}
try {
// Build the fields array from fieldValues
const fields: ItemField[] = [];
// Add system fields
/* Add system fields */
applicableSystemFields.forEach(systemField => {
const value = fieldValues[systemField.FieldKey];
// Only include fields with non-empty values
/* Only include fields with non-empty values */
if (value && (Array.isArray(value) ? value.length > 0 : value.trim() !== '')) {
fields.push({
FieldKey: systemField.FieldKey,
@@ -192,11 +201,11 @@ const ItemAddEdit: React.FC = () => {
}
});
// Add custom fields
/* Add custom fields */
customFields.forEach(customField => {
const value = fieldValues[customField.tempId];
// Only include fields with non-empty values
/* Only include fields with non-empty values */
if (value && (Array.isArray(value) ? value.length > 0 : value.trim() !== '')) {
fields.push({
FieldKey: customField.tempId,
@@ -215,7 +224,7 @@ const ItemAddEdit: React.FC = () => {
UpdatedAt: new Date().toISOString()
};
// Save to database and sync vault
/* Save to database and sync vault */
if (!dbContext?.sqliteClient) {
throw new Error('Database not initialized');
}
@@ -223,34 +232,33 @@ const ItemAddEdit: React.FC = () => {
await executeVaultMutation(async () => {
if (isEditMode) {
await dbContext.sqliteClient!.updateItem(updatedItem);
console.log('Item updated:', updatedItem);
} else {
await dbContext.sqliteClient!.createItem(updatedItem);
console.log('Item created:', updatedItem);
}
});
// Navigate back to details page
/* Navigate back to details page */
navigate(`/items/${updatedItem.Id}`);
} catch (err) {
console.error('Error saving item:', err);
}
}, [item, fieldValues, applicableSystemFields, dbContext, isEditMode, executeVaultMutation, navigate]);
}, [item, fieldValues, applicableSystemFields, customFields, dbContext, isEditMode, executeVaultMutation, navigate]);
/**
* Handle delete action.
*/
const handleDelete = useCallback(async () => {
if (!item || !isEditMode || !dbContext?.sqliteClient) return;
if (!item || !isEditMode || !dbContext?.sqliteClient) {
return;
}
try {
// Delete from database and sync vault
/* Delete from database and sync vault */
await executeVaultMutation(async () => {
await dbContext.sqliteClient!.deleteItemById(item.Id);
console.log('Item deleted:', item.Id);
});
// Navigate back to credentials list
/* Navigate back to credentials list */
navigate('/credentials');
} catch (err) {
console.error('Error deleting item:', err);
@@ -274,7 +282,9 @@ const ItemAddEdit: React.FC = () => {
* Add custom field handler.
*/
const handleAddCustomField = useCallback(() => {
if (!newCustomFieldLabel.trim()) return;
if (!newCustomFieldLabel.trim()) {
return;
}
const tempId = `custom_${crypto.randomUUID()}`;
const newField: CustomFieldDefinition = {
@@ -324,13 +334,13 @@ const ItemAddEdit: React.FC = () => {
setHeaderButtons(headerButtonsJSX);
return () => setHeaderButtons(null);
return (): void => setHeaderButtons(null);
}, [setHeaderButtons, isEditMode, t]);
/**
* Render a field input based on field type.
*/
const renderFieldInput = useCallback((fieldKey: string, label: string, fieldType: FieldType, isHidden: boolean, isMultiValue: boolean) => {
const renderFieldInput = useCallback((fieldKey: string, label: string, fieldType: FieldType, isHidden: boolean, isMultiValue: boolean): React.ReactNode => {
const value = fieldValues[fieldKey] || '';
// Handle multi-value fields

View File

@@ -0,0 +1,195 @@
import React, { useState, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { FormInput } from '@/entrypoints/popup/components/Forms/FormInput';
import { useLoading } from '@/entrypoints/popup/context/LoadingContext';
import type { ItemType } from '@/utils/dist/shared/models/vault';
/**
* Item type option configuration.
*/
type ItemTypeOption = {
type: ItemType;
titleKey: string;
iconSvg: React.ReactNode;
};
/**
* Available item type options.
*/
const ITEM_TYPE_OPTIONS: ItemTypeOption[] = [
{
type: 'Login',
titleKey: 'itemTypes.login.title',
iconSvg: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
)
},
{
type: 'CreditCard',
titleKey: 'itemTypes.creditCard.title',
iconSvg: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
</svg>
)
},
{
type: 'Identity',
titleKey: 'itemTypes.identity.title',
iconSvg: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
)
},
{
type: 'Note',
titleKey: 'itemTypes.note.title',
iconSvg: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
)
}
];
/**
* Item type selection page.
* Allows users to enter item name and choose which type of item to create.
*/
const ItemTypeSelector: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { setIsInitialLoading } = useLoading();
const [itemName, setItemName] = useState('');
const [selectedType, setSelectedType] = useState<ItemType>('Login');
const [showDropdown, setShowDropdown] = useState(false);
/**
* Mark page as loaded on mount.
*/
useEffect(() => {
setIsInitialLoading(false);
}, [setIsInitialLoading]);
/**
* Handle continue button click.
*/
const handleContinue = useCallback((): void => {
const params = new URLSearchParams();
params.set('type', selectedType);
if (itemName.trim()) {
params.set('name', itemName.trim());
}
navigate(`/items/add?${params.toString()}`);
}, [selectedType, itemName, navigate]);
/**
* Handle item type selection from dropdown.
*/
const handleSelectType = useCallback((type: ItemType): void => {
setSelectedType(type);
setShowDropdown(false);
}, []);
const selectedOption = ITEM_TYPE_OPTIONS.find(opt => opt.type === selectedType);
return (
<div className="p-4 space-y-6">
{/* Service Name Input */}
<div>
<FormInput
id="itemName"
label={t('credentials.serviceName')}
value={itemName}
onChange={setItemName}
type="text"
placeholder={t('credentials.serviceName')}
/>
</div>
{/* Item Type Selector */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('itemTypes.typeLabel')}
</label>
<div className="relative">
<button
type="button"
onClick={() => setShowDropdown(!showDropdown)}
className="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 flex items-center justify-between"
>
<div className="flex items-center gap-3">
<span className="text-primary-600 dark:text-primary-400">
{selectedOption?.iconSvg}
</span>
<span className="font-medium">
{selectedOption ? t(selectedOption.titleKey) : ''}
</span>
</div>
<svg
className={`w-5 h-5 text-gray-400 transition-transform ${showDropdown ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* Dropdown Menu */}
{showDropdown && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setShowDropdown(false)}
/>
<div className="absolute z-20 mt-1 w-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg">
{ITEM_TYPE_OPTIONS.map((option) => (
<button
key={option.type}
type="button"
onClick={() => handleSelectType(option.type)}
className={`w-full px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center gap-3 border-b border-gray-100 dark:border-gray-700 last:border-b-0 ${
selectedType === option.type
? 'bg-primary-50 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300'
: 'text-gray-900 dark:text-white'
}`}
>
<span className={selectedType === option.type ? 'text-primary-600 dark:text-primary-400' : 'text-gray-500 dark:text-gray-400'}>
{option.iconSvg}
</span>
<span className="font-medium">
{t(option.titleKey)}
</span>
{selectedType === option.type && (
<svg className="w-5 h-5 ml-auto text-primary-600 dark:text-primary-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
)}
</button>
))}
</div>
</>
)}
</div>
</div>
{/* Continue Button */}
<button
type="button"
onClick={handleContinue}
className="w-full px-4 py-3 bg-primary-600 text-white rounded-md hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 font-medium"
>
{t('common.next')}
</button>
</div>
);
};
export default ItemTypeSelector;

View File

@@ -58,6 +58,7 @@
"use": "Use",
"delete": "Delete",
"save": "Save",
"saving": "Saving...",
"or": "Or",
"close": "Close",
"copied": "Copied!",
@@ -194,6 +195,7 @@
"saveCredential": "Save credential",
"deleteCredentialTitle": "Delete Credential",
"deleteCredentialConfirm": "Are you sure you want to delete this credential? This action cannot be undone.",
"deleteCredentialConfirmation": "Are you sure you want to delete this item? This action cannot be undone.",
"filters": {
"all": "(All) Credentials",
"passkeys": "Passkeys",
@@ -225,6 +227,8 @@
"gender": "Gender",
"birthDate": "Birth Date",
"birthDatePlaceholder": "YYYY-MM-DD",
"cardInformation": "Card Information",
"identityInformation": "Identity Information",
"metadata": "Metadata",
"validation": {
"required": "This field is required",
@@ -242,6 +246,27 @@
"enterFullEmail": "Enter full email address",
"enterEmailPrefix": "Enter email prefix"
},
"itemTypes": {
"selectType": "Add New Item",
"selectTypeDescription": "Choose the type of item you want to create",
"typeLabel": "Item Type",
"login": {
"title": "Login",
"description": "Username, password, and website credentials"
},
"creditCard": {
"title": "Credit Card",
"description": "Credit card information and payment details"
},
"identity": {
"title": "Identity",
"description": "Personal information and contact details"
},
"note": {
"title": "Secure Note",
"description": "Encrypted notes and private information"
}
},
"totp": {
"addCode": "Add 2FA Code",
"instructions": "Enter the secret key shown by the website where you want to add two-factor authentication.",