From d5ad0a49f0db64539c7784763b7348de1611162d Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Fri, 23 Jan 2026 22:15:22 +0100 Subject: [PATCH] Persist custom fields in edit mode even if they have no values (#1473) --- .../popup/pages/items/ItemAddEdit.tsx | 73 +++++++++---------- .../popup/pages/items/ItemDetails.tsx | 25 ++++--- .../src/i18n/locales/en.json | 2 +- .../utils/db/repositories/ItemRepository.ts | 29 +++++--- apps/mobile-app/app/(tabs)/items/[id].tsx | 2 +- apps/mobile-app/app/(tabs)/items/add-edit.tsx | 70 +++++++++--------- .../utils/db/repositories/ItemRepository.ts | 26 +++++-- .../AliasVault.Client/Main/Models/ItemEdit.cs | 4 +- 8 files changed, 125 insertions(+), 106 deletions(-) diff --git a/apps/browser-extension/src/entrypoints/popup/pages/items/ItemAddEdit.tsx b/apps/browser-extension/src/entrypoints/popup/pages/items/ItemAddEdit.tsx index 695dbf5bf..0386af67b 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/items/ItemAddEdit.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/items/ItemAddEdit.tsx @@ -618,23 +618,20 @@ const ItemAddEdit: React.FC = () => { } }); - // Add custom fields + // Add custom fields - always persist even if empty (only deleted when explicitly removed) customFields.forEach(customField => { - const value = fieldValues[customField.tempId]; + 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, - IsCustomField: true, - EnableHistory: false // Custom fields don't have history enabled by default - }); - } + fields.push({ + FieldKey: customField.tempId, + Label: customField.label, + FieldType: customField.fieldType, + Value: value, + IsHidden: customField.isHidden, + DisplayOrder: customField.displayOrder, + IsCustomField: true, + EnableHistory: false // Custom fields don't have history enabled by default + }); }); let updatedItem: Item = { @@ -1303,29 +1300,6 @@ const ItemAddEdit: React.FC = () => { ); })} - {/* Custom Fields Section */} - {customFields.length > 0 && ( - - {customFields.map(field => ( -
- handleUpdateCustomFieldLabel(field.tempId, newLabel)} - onDelete={() => handleDeleteCustomField(field.tempId)} - /> - {renderFieldInput( - field.tempId, - '', - field.fieldType, - field.isHidden, - false - )} -
- ))} -
- )} - {/* Notes Section */} {notesField && visibleFieldKeys.has('notes.content') && ( { )} + {/* Custom Fields Section */} + {customFields.length > 0 && ( + + {customFields.map(field => ( +
+ handleUpdateCustomFieldLabel(field.tempId, newLabel)} + onDelete={() => handleDeleteCustomField(field.tempId)} + /> + {renderFieldInput( + field.tempId, + '', + field.fieldType, + field.isHidden, + false + )} +
+ ))} +
+ )} + {/* 2FA TOTP Section - only for types with login fields */} {show2FA && hasLoginFields && ( { )} - {groupedFields[FieldCategories.Custom] && groupedFields[FieldCategories.Custom].length > 0 && ( -
-

- {t('common.customFields')} -

- {groupedFields[FieldCategories.Custom].map((field) => ( - - ))} -
- )} - - {/* Notes - shown at bottom for non-Note types (metadata) */} + {/* Notes - shown before custom fields for non-Note types */} {item.ItemType !== ItemTypes.Note && groupedFields[FieldCategories.Notes] && groupedFields[FieldCategories.Notes].length > 0 && ( groupedFields[FieldCategories.Notes].map((field) => (
@@ -249,6 +238,18 @@ const ItemDetails: React.FC = (): React.ReactElement => {
)) )} + + {/* Custom Fields */} + {groupedFields[FieldCategories.Custom] && groupedFields[FieldCategories.Custom].length > 0 && ( +
+

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

+ {groupedFields[FieldCategories.Custom].map((field) => ( + + ))} +
+ )} )} diff --git a/apps/browser-extension/src/i18n/locales/en.json b/apps/browser-extension/src/i18n/locales/en.json index fe09ee531..6a158403a 100644 --- a/apps/browser-extension/src/i18n/locales/en.json +++ b/apps/browser-extension/src/i18n/locales/en.json @@ -75,7 +75,7 @@ "settings": "Settings", "recentEmails": "Recent emails", "credentials": "Credentials", - "customFields": "Custom", + "customFields": "Custom Fields", "twoFactorAuthentication": "Two-factor authentication", "alias": "Alias", "notes": "Notes", diff --git a/apps/browser-extension/src/utils/db/repositories/ItemRepository.ts b/apps/browser-extension/src/utils/db/repositories/ItemRepository.ts index 4afdfe9fc..c913a1cdb 100644 --- a/apps/browser-extension/src/utils/db/repositories/ItemRepository.ts +++ b/apps/browser-extension/src/utils/db/repositories/ItemRepository.ts @@ -436,8 +436,9 @@ export class ItemRepository extends BaseRepository { currentDateTime: string ): void { for (const field of fields) { - // Skip empty fields - if (!field.Value || (typeof field.Value === 'string' && field.Value.trim() === '')) { + // Skip empty system fields, but always persist custom fields (even if empty) + const isEmpty = !field.Value || (typeof field.Value === 'string' && field.Value.trim() === ''); + if (isEmpty && !field.IsCustomField) { continue; } @@ -452,7 +453,12 @@ export class ItemRepository extends BaseRepository { const values = Array.isArray(field.Value) ? field.Value : [field.Value]; const filteredValues = values.filter(v => v && v.trim() !== ''); - for (const value of filteredValues) { + // For custom fields with no values, insert with empty string to preserve the field + const valuesToInsert = field.IsCustomField && filteredValues.length === 0 + ? [''] + : filteredValues; + + for (const value of valuesToInsert) { this.client.executeUpdate(FieldValueQueries.INSERT, [ this.generateId(), itemId, @@ -546,7 +552,9 @@ export class ItemRepository extends BaseRepository { // Update existing or insert new FieldValues if (item.Fields && item.Fields.length > 0) { for (const field of item.Fields) { - if (!field.Value || (typeof field.Value === 'string' && field.Value.trim() === '')) { + // Skip empty system fields, but always persist custom fields (even if empty) + const isEmpty = !field.Value || (typeof field.Value === 'string' && field.Value.trim() === ''); + if (isEmpty && !field.IsCustomField) { continue; } @@ -559,11 +567,14 @@ export class ItemRepository extends BaseRepository { const values = Array.isArray(field.Value) ? field.Value : [field.Value]; const effectiveKey = field.FieldKey; - for (let i = 0; i < values.length; i++) { - const value = values[i]; - if (!value || (typeof value === 'string' && value.trim() === '')) { - continue; - } + // For custom fields with no values, use empty string to preserve the field + const filteredValues = values.filter(v => v && (typeof v !== 'string' || v.trim() !== '')); + const valuesToProcess = field.IsCustomField && filteredValues.length === 0 + ? [''] + : filteredValues; + + for (let i = 0; i < valuesToProcess.length; i++) { + const value = valuesToProcess[i]; const lookupKey = `${effectiveKey}:${i}`; const existing = existingByKey.get(lookupKey); diff --git a/apps/mobile-app/app/(tabs)/items/[id].tsx b/apps/mobile-app/app/(tabs)/items/[id].tsx index 4bc498bb6..302174fc5 100644 --- a/apps/mobile-app/app/(tabs)/items/[id].tsx +++ b/apps/mobile-app/app/(tabs)/items/[id].tsx @@ -189,8 +189,8 @@ export default function ItemDetailsScreen() : React.ReactNode { - + diff --git a/apps/mobile-app/app/(tabs)/items/add-edit.tsx b/apps/mobile-app/app/(tabs)/items/add-edit.tsx index 60967a8a8..58d8aa97b 100644 --- a/apps/mobile-app/app/(tabs)/items/add-edit.tsx +++ b/apps/mobile-app/app/(tabs)/items/add-edit.tsx @@ -760,22 +760,20 @@ export default function AddEditItemScreen(): React.ReactNode { } }); - // Add custom fields + // Add custom fields - always persist even if empty (only deleted when explicitly removed) customFields.forEach(customField => { - const value = fieldValues[customField.tempId]; + const value = fieldValues[customField.tempId] || ''; - if (value && (Array.isArray(value) ? value.length > 0 : value.toString().trim() !== '')) { - fields.push({ - FieldKey: customField.tempId, - Label: customField.label, - FieldType: customField.fieldType, - Value: value, - IsHidden: customField.isHidden, - DisplayOrder: customField.displayOrder, - IsCustomField: true, - EnableHistory: false - }); - } + fields.push({ + FieldKey: customField.tempId, + Label: customField.label, + FieldType: customField.fieldType, + Value: value, + IsHidden: customField.isHidden, + DisplayOrder: customField.displayOrder, + IsCustomField: true, + EnableHistory: false + }); }); // Normalize birthdate if present @@ -1533,28 +1531,6 @@ export default function AddEditItemScreen(): React.ReactNode { ); })} - {/* Custom Fields Section */} - {customFields.length > 0 && ( - - {customFields.map(field => ( - - handleUpdateCustomFieldLabel(field.tempId, newLabel)} - onDelete={() => handleDeleteCustomField(field.tempId)} - /> - {renderFieldInput( - field.tempId, - '', // Label is shown by EditableFieldLabel - field.fieldType, - field.isHidden, - false - )} - - ))} - - )} - {/* Notes Section */} {notesField && visibleFieldKeys.has('notes.content') && ( )} + {/* Custom Fields Section */} + {customFields.length > 0 && ( + + {customFields.map(field => ( + + handleUpdateCustomFieldLabel(field.tempId, newLabel)} + onDelete={() => handleDeleteCustomField(field.tempId)} + /> + {renderFieldInput( + field.tempId, + '', // Label is shown by EditableFieldLabel + field.fieldType, + field.isHidden, + false + )} + + ))} + + )} + {/* 2FA TOTP Section - only for types with login fields */} {show2FA && hasLoginFields && ( v !== undefined && v !== null && v !== ''); - // Skip empty fields - if (filteredValues.length === 0) { + // Skip empty system fields, but always persist custom fields (even if empty) + if (filteredValues.length === 0 && !field.IsCustomField) { continue; } + // For custom fields with no values, use empty string to preserve the field + const valuesToInsert = field.IsCustomField && filteredValues.length === 0 + ? [''] + : filteredValues; + let fieldDefinitionId: string | null = null; // For custom fields, create or get FieldDefinition first @@ -448,8 +453,8 @@ export class ItemRepository extends BaseRepository { fieldDefinitionId = await this.ensureFieldDefinition(field, itemType, now); } - for (let j = 0; j < filteredValues.length; j++) { - const value = filteredValues[j]; + for (let j = 0; j < valuesToInsert.length; j++) { + const value = valuesToInsert[j]; await this.client.executeUpdate(FieldValueQueries.INSERT, [ this.generateId(), @@ -597,12 +602,17 @@ export class ItemRepository extends BaseRepository { const values = Array.isArray(field.Value) ? field.Value : [field.Value]; const existingForKey = existingByKey.get(field.FieldKey) || []; - // Skip empty fields + // Skip empty system fields, but always persist custom fields (even if empty) const filteredValues = values.filter(v => v !== undefined && v !== null && v !== ''); - if (filteredValues.length === 0) { + if (filteredValues.length === 0 && !field.IsCustomField) { continue; } + // For custom fields with no values, use empty string to preserve the field + const valuesToProcess = field.IsCustomField && filteredValues.length === 0 + ? [''] + : filteredValues; + let fieldDefinitionId: string | null = null; // For custom fields, ensure FieldDefinition exists and is up-to-date @@ -610,8 +620,8 @@ export class ItemRepository extends BaseRepository { fieldDefinitionId = await this.ensureOrUpdateFieldDefinition(field, itemType, now); } - for (let j = 0; j < filteredValues.length; j++) { - const value = filteredValues[j]; + for (let j = 0; j < valuesToProcess.length; j++) { + const value = valuesToProcess[j]; const existingEntry = existingForKey[j]; diff --git a/apps/server/AliasVault.Client/Main/Models/ItemEdit.cs b/apps/server/AliasVault.Client/Main/Models/ItemEdit.cs index 52f04e2e1..6f5b252d7 100644 --- a/apps/server/AliasVault.Client/Main/Models/ItemEdit.cs +++ b/apps/server/AliasVault.Client/Main/Models/ItemEdit.cs @@ -175,7 +175,9 @@ public sealed class ItemEdit var hasValue = !string.IsNullOrEmpty(field.Value) || (field.IsMultiValue && field.Values.Any(v => !string.IsNullOrEmpty(v))); - if (!hasValue) + // For system fields, skip if no value + // For custom fields, always persist (even if empty) - they're only deleted when explicitly removed + if (!hasValue && !field.IsCustomField) { continue; }