From 774f941a13aaa8068336bed97df1c0a8aae079fa Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Wed, 3 Dec 2025 23:12:14 +0100 Subject: [PATCH] Tweak custom field display, tweak details UI (#1404) --- .../components/Forms/EditableFieldLabel.tsx | 105 +++++++++ .../popup/pages/credentials/ItemAddEdit.tsx | 202 ++++++++++++++++++ .../popup/pages/credentials/ItemDetails.tsx | 72 +++++-- .../src/utils/SqliteClient.ts | 107 +++++++++- 4 files changed, 461 insertions(+), 25 deletions(-) create mode 100644 apps/browser-extension/src/entrypoints/popup/components/Forms/EditableFieldLabel.tsx diff --git a/apps/browser-extension/src/entrypoints/popup/components/Forms/EditableFieldLabel.tsx b/apps/browser-extension/src/entrypoints/popup/components/Forms/EditableFieldLabel.tsx new file mode 100644 index 000000000..6546c800e --- /dev/null +++ b/apps/browser-extension/src/entrypoints/popup/components/Forms/EditableFieldLabel.tsx @@ -0,0 +1,105 @@ +import React, { useState } from 'react'; + +interface EditableFieldLabelProps { + htmlFor: string; + label: string; + onLabelChange: (newLabel: string) => void; + onDelete?: () => void; +} + +/** + * Editable field label component with edit button. + * Shows label text with a small edit icon. When clicked, shows an input field. + */ +const EditableFieldLabel: React.FC = ({ + htmlFor, + label, + onLabelChange, + onDelete +}) => { + const [isEditing, setIsEditing] = useState(false); + const [editValue, setEditValue] = useState(label); + + const handleSave = () => { + if (editValue.trim()) { + onLabelChange(editValue.trim()); + setIsEditing(false); + } + }; + + const handleCancel = () => { + setEditValue(label); + setIsEditing(false); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleSave(); + } else if (e.key === 'Escape') { + handleCancel(); + } + }; + + if (isEditing) { + return ( +
+ setEditValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={handleSave} + className="flex-1 px-2 py-1 text-sm font-medium text-gray-700 dark:text-gray-300 border border-primary-500 rounded focus:outline-none focus:ring-1 focus:ring-primary-500 dark:bg-gray-700 dark:border-primary-400" + placeholder="Field label" + autoFocus + /> + + +
+ ); + } + + return ( +
+ + + {onDelete && ( + + )} +
+ ); +}; + +export default EditableFieldLabel; diff --git a/apps/browser-extension/src/entrypoints/popup/pages/credentials/ItemAddEdit.tsx b/apps/browser-extension/src/entrypoints/popup/pages/credentials/ItemAddEdit.tsx index 9e28bf6df..95eebeeff 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/credentials/ItemAddEdit.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/credentials/ItemAddEdit.tsx @@ -5,6 +5,7 @@ import { useNavigate, useParams } from 'react-router-dom'; import { sendMessage } from 'webext-bridge/popup'; import Modal from '@/entrypoints/popup/components/Dialogs/Modal'; +import EditableFieldLabel from '@/entrypoints/popup/components/Forms/EditableFieldLabel'; import { FormInput } from '@/entrypoints/popup/components/Forms/FormInput'; import PasswordField from '@/entrypoints/popup/components/Forms/PasswordField'; import HeaderButton from '@/entrypoints/popup/components/HeaderButton'; @@ -28,6 +29,17 @@ type ItemFormData = { Fields: Record; // FieldKey -> Value mapping }; +/** + * Temporary custom field definition (before persisting to database) + */ +type CustomFieldDefinition = { + tempId: string; // Temporary ID until we create the FieldDefinition + label: string; + fieldType: FieldType; + isHidden: boolean; + displayOrder: number; +}; + /** * Add or edit item page with dynamic field support. * Shows all applicable system fields for the item type, not just fields with values. @@ -44,11 +56,19 @@ const ItemAddEdit: React.FC = () => { const { setIsInitialLoading } = useLoading(); const [localLoading, setLocalLoading] = useState(true); const [showDeleteModal, setShowDeleteModal] = useState(false); + const [showAddCustomFieldModal, setShowAddCustomFieldModal] = useState(false); const [item, setItem] = useState(null); // Form state for dynamic fields const [fieldValues, setFieldValues] = useState>({}); + // Custom field definitions (temporary until saved) + const [customFields, setCustomFields] = useState([]); + + // New custom field form state + const [newCustomFieldLabel, setNewCustomFieldLabel] = useState(''); + const [newCustomFieldType, setNewCustomFieldType] = useState('Text'); + /** * Get all applicable system fields for the current item type. * These are sorted by DefaultDisplayOrder. @@ -101,10 +121,25 @@ const ItemAddEdit: React.FC = () => { // Initialize field values from existing fields const initialValues: Record = {}; + const existingCustomFields: CustomFieldDefinition[] = []; + result.Fields.forEach(field => { initialValues[field.FieldKey] = field.Value; + + // If field key starts with "custom_", it's a custom field + if (field.FieldKey.startsWith('custom_')) { + existingCustomFields.push({ + tempId: field.FieldKey, + label: field.Label, + fieldType: field.FieldType, + isHidden: field.IsHidden, + displayOrder: field.DisplayOrder + }); + } }); + setFieldValues(initialValues); + setCustomFields(existingCustomFields); setLocalLoading(false); setIsInitialLoading(false); @@ -139,6 +174,7 @@ const ItemAddEdit: React.FC = () => { // Build the fields array from fieldValues const fields: ItemField[] = []; + // Add system fields applicableSystemFields.forEach(systemField => { const value = fieldValues[systemField.FieldKey]; @@ -155,6 +191,23 @@ const ItemAddEdit: React.FC = () => { } }); + // Add custom fields + customFields.forEach(customField => { + const value = fieldValues[customField.tempId]; + + // Only include fields with non-empty values + if (value && (Array.isArray(value) ? value.length > 0 : value.trim() !== '')) { + fields.push({ + FieldKey: customField.tempId, + Label: customField.label, + FieldType: customField.fieldType, + Value: value, + IsHidden: customField.isHidden, + DisplayOrder: customField.displayOrder + }); + } + }); + const updatedItem: Item = { ...item, Fields: fields, @@ -216,6 +269,48 @@ const ItemAddEdit: React.FC = () => { } }, [isEditMode, id, navigate]); + /** + * Add custom field handler. + */ + const handleAddCustomField = useCallback(() => { + if (!newCustomFieldLabel.trim()) return; + + const tempId = `custom_${crypto.randomUUID()}`; + const newField: CustomFieldDefinition = { + tempId, + label: newCustomFieldLabel, + fieldType: newCustomFieldType, + isHidden: false, + displayOrder: applicableSystemFields.length + customFields.length + 1 + }; + + setCustomFields(prev => [...prev, newField]); + setNewCustomFieldLabel(''); + setNewCustomFieldType('Text'); + setShowAddCustomFieldModal(false); + }, [newCustomFieldLabel, newCustomFieldType, applicableSystemFields.length, customFields.length]); + + /** + * Delete custom field handler. + */ + const handleDeleteCustomField = useCallback((tempId: string) => { + setCustomFields(prev => prev.filter(f => f.tempId !== tempId)); + setFieldValues(prev => { + const newValues = { ...prev }; + delete newValues[tempId]; + return newValues; + }); + }, []); + + /** + * Update custom field label handler. + */ + const handleUpdateCustomFieldLabel = useCallback((tempId: string, newLabel: string) => { + setCustomFields(prev => prev.map(f => + f.tempId === tempId ? { ...f, label: newLabel } : f + )); + }, []); + // Set header buttons useEffect(() => { const headerButtonsJSX = isEditMode ? ( @@ -370,6 +465,44 @@ const ItemAddEdit: React.FC = () => { ))} + {/* Custom Fields Section */} + {customFields.length > 0 && ( +
+

+ {t('credentials.customFields')} +

+ + {customFields.map(field => ( +
+ handleUpdateCustomFieldLabel(field.tempId, newLabel)} + onDelete={() => handleDeleteCustomField(field.tempId)} + /> + + {/* Field input */} + {renderFieldInput( + field.tempId, + '', + field.fieldType, + field.isHidden, + false + )} +
+ ))} +
+ )} + + {/* Add Custom Field Button */} + + {/* Action Buttons */}
+ {/* Add Custom Field Dialog */} + {showAddCustomFieldModal && ( +
+
+

+ Add Custom Field +

+ +
+
+ + setNewCustomFieldLabel(e.target.value)} + 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" + placeholder="Enter field name" + autoFocus + /> +
+ +
+ + +
+
+ +
+ + +
+
+
+ )} + {/* Delete Confirmation Modal */} {isEditMode && ( { return
{t('common.loading')}
; } - // Group fields by category for organized display - const groupedFields = groupFieldsByCategory(item); + // Extract URL fields for prominent display + const urlFields = item.Fields.filter(field => field.FieldType === 'URL' && field.Value); + + // Create a modified item without URL fields for grouping + const itemWithoutUrls = { + ...item, + Fields: item.Fields.filter(field => field.FieldType !== 'URL') + }; + + // Group fields by category for organized display (excluding URLs) + const groupedFields = groupFieldsByCategory(itemWithoutUrls); console.log('Grouped fields:', groupedFields); console.log('Item:', item); return (
- {/* Header with name and logo */} -
-
- {item.Logo && ( - {item.Name - )} -

- {item.Name || 'Untitled Item'} -

+ {/* Header with name, logo, and URLs */} +
+
+ {item.Name +
+

+ {item.Name || 'Untitled Item'} +

+ {/* Display URLs prominently below title */} + {urlFields.length > 0 && ( +
+ {urlFields.flatMap((urlField) => { + // Handle both single values and arrays of URLs + const urlValues = Array.isArray(urlField.Value) ? urlField.Value : [urlField.Value]; + + return urlValues.map((urlValue, idx) => { + const isValidUrl = /^https?:\/\//i.test(urlValue); + + return isValidUrl ? ( + + {urlValue} + + ) : ( + + {urlValue} + + ); + }); + })} +
+ )} +
diff --git a/apps/browser-extension/src/utils/SqliteClient.ts b/apps/browser-extension/src/utils/SqliteClient.ts index ac7209706..72762bc13 100644 --- a/apps/browser-extension/src/utils/SqliteClient.ts +++ b/apps/browser-extension/src/utils/SqliteClient.ts @@ -436,7 +436,7 @@ export class SqliteClient { // Custom field: has FieldDefinitionId, get metadata from FieldDefinitions return { ItemId: row.ItemId, - FieldKey: `custom_${row.FieldDefinitionId}`, // Generate a key for custom fields + FieldKey: row.FieldDefinitionId || '', // Use FieldDefinitionId as the key for custom fields Label: row.CustomLabel || '', FieldType: row.CustomFieldType || 'Text', IsHidden: row.CustomIsHidden || 0, @@ -581,7 +581,7 @@ export class SqliteClient { } else { // Custom field: has FieldDefinitionId, get metadata from FieldDefinitions return { - FieldKey: `custom_${row.FieldDefinitionId}`, // Generate a key for custom fields + FieldKey: row.FieldDefinitionId || '', // Use FieldDefinitionId as the key for custom fields Label: row.CustomLabel || '', FieldType: row.CustomFieldType || 'Text', IsHidden: row.CustomIsHidden || 0, @@ -1953,22 +1953,54 @@ export class SqliteClient { continue; } + const isCustomField = field.FieldKey.startsWith('custom_'); + let fieldDefinitionId = null; + + // For custom fields, create or get FieldDefinition + if (isCustomField) { + // Check if FieldDefinition already exists for this custom field + const existingDefQuery = ` + SELECT Id FROM FieldDefinitions + WHERE Id = ?`; + + const existingDef = this.executeQuery<{ Id: string }>(existingDefQuery, [field.FieldKey]); + + if (existingDef.length === 0) { + // Create new FieldDefinition for custom field + const fieldDefQuery = ` + INSERT INTO FieldDefinitions (Id, FieldType, Label, IsMultiValue, IsHidden, EnableHistory, Weight, ApplicableToTypes, CreatedAt, UpdatedAt, IsDeleted) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`; + + this.executeUpdate(fieldDefQuery, [ + field.FieldKey, // Use the custom_ ID as the FieldDefinition ID + field.FieldType, + field.Label, + 0, // IsMultiValue + field.IsHidden ? 1 : 0, + 0, // EnableHistory + field.DisplayOrder ?? 0, + item.ItemType, // ApplicableToTypes (single type for now) + currentDateTime, + currentDateTime, + 0 + ]); + } + + fieldDefinitionId = field.FieldKey; // FieldDefinitionId = custom field ID + } + const fieldValueId = crypto.randomUUID().toUpperCase(); const fieldQuery = ` INSERT INTO FieldValues (Id, ItemId, FieldDefinitionId, FieldKey, Value, Weight, CreatedAt, UpdatedAt, IsDeleted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`; - /* - * For system fields: FieldKey is set, FieldDefinitionId is NULL - * For custom fields: FieldDefinitionId is set, FieldKey is NULL (future implementation) - */ const valueString = Array.isArray(field.Value) ? JSON.stringify(field.Value) : field.Value; this.executeUpdate(fieldQuery, [ fieldValueId, itemId, - null, // FieldDefinitionId (NULL for system fields) - field.FieldKey, // FieldKey (set for system fields) + fieldDefinitionId, // NULL for system fields, custom field ID for custom fields + isCustomField ? null : field.FieldKey, // FieldKey set for system fields only valueString, field.DisplayOrder ?? 0, currentDateTime, @@ -2036,6 +2068,61 @@ export class SqliteClient { continue; } + const isCustomField = field.FieldKey.startsWith('custom_'); + let fieldDefinitionId = null; + + // For custom fields, create or update FieldDefinition + if (isCustomField) { + // Check if FieldDefinition already exists + const existingDefQuery = ` + SELECT Id FROM FieldDefinitions + WHERE Id = ? AND IsDeleted = 0`; + + const existingDef = this.executeQuery<{ Id: string }>(existingDefQuery, [field.FieldKey]); + + if (existingDef.length === 0) { + // Create new FieldDefinition + const fieldDefQuery = ` + INSERT INTO FieldDefinitions (Id, FieldType, Label, IsMultiValue, IsHidden, EnableHistory, Weight, ApplicableToTypes, CreatedAt, UpdatedAt, IsDeleted) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`; + + this.executeUpdate(fieldDefQuery, [ + field.FieldKey, + field.FieldType, + field.Label, + 0, // IsMultiValue + field.IsHidden ? 1 : 0, + 0, // EnableHistory + field.DisplayOrder ?? 0, + item.ItemType, + currentDateTime, + currentDateTime, + 0 + ]); + } else { + // Update existing FieldDefinition (label might have changed) + const updateDefQuery = ` + UPDATE FieldDefinitions + SET Label = ?, + FieldType = ?, + IsHidden = ?, + Weight = ?, + UpdatedAt = ? + WHERE Id = ?`; + + this.executeUpdate(updateDefQuery, [ + field.Label, + field.FieldType, + field.IsHidden ? 1 : 0, + field.DisplayOrder ?? 0, + currentDateTime, + field.FieldKey + ]); + } + + fieldDefinitionId = field.FieldKey; + } + const fieldValueId = crypto.randomUUID().toUpperCase(); const fieldQuery = ` INSERT INTO FieldValues (Id, ItemId, FieldDefinitionId, FieldKey, Value, Weight, CreatedAt, UpdatedAt, IsDeleted) @@ -2046,8 +2133,8 @@ export class SqliteClient { this.executeUpdate(fieldQuery, [ fieldValueId, item.Id, - null, // FieldDefinitionId (NULL for system fields) - field.FieldKey, // FieldKey (set for system fields) + fieldDefinitionId, // NULL for system fields, custom field ID for custom fields + isCustomField ? null : field.FieldKey, // FieldKey set for system fields only valueString, field.DisplayOrder ?? 0, currentDateTime,