Persist custom fields in edit mode even if they have no values (#1473)

This commit is contained in:
Leendert de Borst
2026-01-23 22:15:22 +01:00
parent c19c1b8bc9
commit d5ad0a49f0
8 changed files with 125 additions and 106 deletions
@@ -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 && (
<FormSection title={t('common.customFields')}>
{customFields.map(field => (
<div key={field.tempId}>
<EditableFieldLabel
htmlFor={field.tempId}
label={field.label}
onLabelChange={(newLabel) => handleUpdateCustomFieldLabel(field.tempId, newLabel)}
onDelete={() => handleDeleteCustomField(field.tempId)}
/>
{renderFieldInput(
field.tempId,
'',
field.fieldType,
field.isHidden,
false
)}
</div>
))}
</FormSection>
)}
{/* Notes Section */}
{notesField && visibleFieldKeys.has('notes.content') && (
<FormSection
@@ -1356,6 +1330,29 @@ const ItemAddEdit: React.FC = () => {
</FormSection>
)}
{/* Custom Fields Section */}
{customFields.length > 0 && (
<FormSection title={t('common.customFields')}>
{customFields.map(field => (
<div key={field.tempId}>
<EditableFieldLabel
htmlFor={field.tempId}
label={field.label}
onLabelChange={(newLabel) => handleUpdateCustomFieldLabel(field.tempId, newLabel)}
onDelete={() => handleDeleteCustomField(field.tempId)}
/>
{renderFieldInput(
field.tempId,
'',
field.fieldType,
field.isHidden,
false
)}
</div>
))}
</FormSection>
)}
{/* 2FA TOTP Section - only for types with login fields */}
{show2FA && hasLoginFields && (
<TotpEditor
@@ -227,18 +227,7 @@ const ItemDetails: React.FC = (): React.ReactElement => {
</div>
)}
{groupedFields[FieldCategories.Custom] && groupedFields[FieldCategories.Custom].length > 0 && (
<div className="space-y-2">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
{t('common.customFields')}
</h2>
{groupedFields[FieldCategories.Custom].map((field) => (
<FieldBlock key={field.FieldKey} field={field} itemId={item.Id} />
))}
</div>
)}
{/* 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) => (
<div key={field.FieldKey} className="space-y-2">
@@ -249,6 +238,18 @@ const ItemDetails: React.FC = (): React.ReactElement => {
</div>
))
)}
{/* Custom Fields */}
{groupedFields[FieldCategories.Custom] && groupedFields[FieldCategories.Custom].length > 0 && (
<div className="space-y-2">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
{t('common.customFields')}
</h2>
{groupedFields[FieldCategories.Custom].map((field) => (
<FieldBlock key={field.FieldKey} field={field} itemId={item.Id} />
))}
</div>
)}
</>
)}
@@ -75,7 +75,7 @@
"settings": "Settings",
"recentEmails": "Recent emails",
"credentials": "Credentials",
"customFields": "Custom",
"customFields": "Custom Fields",
"twoFactorAuthentication": "Two-factor authentication",
"alias": "Alias",
"notes": "Notes",
@@ -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);
+1 -1
View File
@@ -189,8 +189,8 @@ export default function ItemDetailsScreen() : React.ReactNode {
<LoginFields item={item} />
<CardDetails item={item} />
<AliasDetails item={item} />
<CustomFieldsSection item={item} />
<NotesSection item={item} />
<CustomFieldsSection item={item} />
<AttachmentSection item={item} />
</ThemedScrollView>
</ThemedContainer>
+34 -36
View File
@@ -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 && (
<FormSection title={t('itemTypes.customFields')}>
{customFields.map(field => (
<View key={field.tempId}>
<EditableFieldLabel
label={field.label}
onLabelChange={(newLabel) => handleUpdateCustomFieldLabel(field.tempId, newLabel)}
onDelete={() => handleDeleteCustomField(field.tempId)}
/>
{renderFieldInput(
field.tempId,
'', // Label is shown by EditableFieldLabel
field.fieldType,
field.isHidden,
false
)}
</View>
))}
</FormSection>
)}
{/* Notes Section */}
{notesField && visibleFieldKeys.has('notes.content') && (
<FormSection
@@ -1580,6 +1556,28 @@ export default function AddEditItemScreen(): React.ReactNode {
</FormSection>
)}
{/* Custom Fields Section */}
{customFields.length > 0 && (
<FormSection title={t('itemTypes.customFields')}>
{customFields.map(field => (
<View key={field.tempId}>
<EditableFieldLabel
label={field.label}
onLabelChange={(newLabel) => handleUpdateCustomFieldLabel(field.tempId, newLabel)}
onDelete={() => handleDeleteCustomField(field.tempId)}
/>
{renderFieldInput(
field.tempId,
'', // Label is shown by EditableFieldLabel
field.fieldType,
field.isHidden,
false
)}
</View>
))}
</FormSection>
)}
{/* 2FA TOTP Section - only for types with login fields */}
{show2FA && hasLoginFields && (
<FormSection
@@ -436,11 +436,16 @@ export class ItemRepository extends BaseRepository {
const values = Array.isArray(field.Value) ? field.Value : [field.Value];
const filteredValues = values.filter(v => 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];
@@ -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;
}