Add drag-and-drop reorder to mobile app custom fields (#1673)

This commit is contained in:
Leendert de Borst
2026-02-11 16:49:12 +01:00
committed by Leendert de Borst
parent 7b354b47ad
commit ae8fad2753
6 changed files with 273 additions and 35 deletions

View File

@@ -24,7 +24,7 @@ import { useVaultMutate } from '@/hooks/useVaultMutate';
import { ConfirmDialog } from '@/components/common/ConfirmDialog';
import { AddFieldMenu, type OptionalSection } from '@/components/form/AddFieldMenu';
import { AdvancedPasswordField } from '@/components/form/AdvancedPasswordField';
import { EditableFieldLabel } from '@/components/form/EditableFieldLabel';
import { DraggableCustomFieldsList, type CustomFieldDefinition } from '@/components/form/DraggableCustomFieldsList';
import { EmailDomainField } from '@/components/form/EmailDomainField';
import { FormField } from '@/components/form/FormField';
import { FormSection } from '@/components/form/FormSection';
@@ -48,17 +48,6 @@ const VALID_ITEM_TYPES: ItemType[] = [ItemTypes.Login, ItemTypes.Alias, ItemType
// Default item type for new items
const DEFAULT_ITEM_TYPE: ItemType = ItemTypes.Login;
/**
* Temporary custom field definition (before persisting to database).
*/
type CustomFieldDefinition = {
tempId: string;
label: string;
fieldType: FieldType;
isHidden: boolean;
displayOrder: number;
};
/**
* Add or edit an item screen.
*/
@@ -486,6 +475,8 @@ export default function AddEditItemScreen(): React.ReactNode {
}
setFieldValues(initialValues);
// Sort custom fields by displayOrder when loading
existingCustomFields.sort((a, b) => a.displayOrder - b.displayOrder);
setCustomFields(existingCustomFields);
setInitiallyVisibleFields(fieldsWithValues);
@@ -749,6 +740,15 @@ export default function AddEditItemScreen(): React.ReactNode {
setHasUnsavedChanges(true);
}, []);
/**
* Handle custom fields reorder (drag-and-drop).
* Updates the displayOrder of all custom fields based on their new positions.
*/
const handleCustomFieldsReorder = useCallback((reorderedFields: CustomFieldDefinition[]) => {
setCustomFields(reorderedFields);
setHasUnsavedChanges(true);
}, []);
/**
* Optional sections for AddFieldMenu.
*/
@@ -1610,22 +1610,14 @@ 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>
))}
<DraggableCustomFieldsList
customFields={customFields}
fieldValues={fieldValues}
onFieldsReorder={handleCustomFieldsReorder}
onFieldValueChange={(tempId, value) => handleFieldChange(tempId, value)}
onFieldLabelChange={handleUpdateCustomFieldLabel}
onFieldDelete={handleDeleteCustomField}
/>
</FormSection>
)}

View File

@@ -0,0 +1,228 @@
import { MaterialIcons } from '@expo/vector-icons';
import * as Haptics from 'expo-haptics';
import React, { useCallback } from 'react';
import { View, StyleSheet } from 'react-native';
import DraggableFlatList, {
RenderItemParams,
} from 'react-native-draggable-flatlist';
import type { FieldType } from '@/utils/dist/core/models/vault';
import { FieldTypes } from '@/utils/dist/core/models/vault';
import { useColors } from '@/hooks/useColorScheme';
import { EditableFieldLabel } from './EditableFieldLabel';
import { FormField } from './FormField';
import { HiddenField } from './HiddenField';
/**
* Custom field definition type
*/
export type CustomFieldDefinition = {
tempId: string;
label: string;
fieldType: FieldType;
isHidden: boolean;
displayOrder: number;
};
type CustomFieldItemProps = {
field: CustomFieldDefinition;
value: string;
onValueChange: (value: string) => void;
onLabelChange: (newLabel: string) => void;
onDelete: () => void;
drag: () => void;
};
/**
* Individual custom field item
*/
const CustomFieldItem: React.FC<CustomFieldItemProps> = ({
field,
value,
onValueChange,
onLabelChange,
onDelete,
drag,
}) => {
const colors = useColors();
/**
* Renders the appropriate input field based on field type
*/
const renderFieldInput = (): React.ReactNode => {
if (field.fieldType === FieldTypes.TextArea) {
return (
<FormField
value={value}
onChangeText={onValueChange}
label=""
multiline
numberOfLines={4}
textAlignVertical="top"
/>
);
}
if (field.isHidden || field.fieldType === FieldTypes.Hidden || field.fieldType === FieldTypes.Password) {
return (
<HiddenField
value={value}
onChangeText={onValueChange}
label=""
/>
);
}
return (
<FormField
value={value}
onChangeText={onValueChange}
label=""
/>
);
};
const styles = StyleSheet.create({
container: {
backgroundColor: colors.accentBackground,
borderRadius: 8,
},
contentContainer: {
flex: 1,
},
dragHandle: {
alignItems: 'center',
justifyContent: 'center',
paddingLeft: 4,
paddingRight: 0,
paddingVertical: 12,
},
labelContainer: {
marginBottom: 4,
},
outerContainer: {
flexDirection: 'row',
padding: 8,
paddingRight: 4,
},
});
return (
<View style={styles.container}>
<View style={styles.outerContainer}>
{/* Field content */}
<View style={styles.contentContainer}>
{/* Label row */}
<View style={styles.labelContainer}>
<EditableFieldLabel
label={field.label}
onLabelChange={onLabelChange}
onDelete={onDelete}
/>
</View>
{/* Input field */}
{renderFieldInput()}
</View>
{/* Drag handle on right side */}
<View
style={styles.dragHandle}
onTouchStart={drag}
>
<MaterialIcons name="drag-indicator" size={20} color={colors.textMuted} />
</View>
</View>
</View>
);
};
type DraggableCustomFieldsListProps = {
customFields: CustomFieldDefinition[];
fieldValues: Record<string, string | string[]>;
onFieldsReorder: (reorderedFields: CustomFieldDefinition[]) => void;
onFieldValueChange: (tempId: string, value: string) => void;
onFieldLabelChange: (tempId: string, newLabel: string) => void;
onFieldDelete: (tempId: string) => void;
};
/**
* A sortable list of custom fields with drag-and-drop reordering.
* Uses react-native-draggable-flatlist for smooth, reliable drag animations.
*/
export const DraggableCustomFieldsList: React.FC<DraggableCustomFieldsListProps> = ({
customFields,
fieldValues,
onFieldsReorder,
onFieldValueChange,
onFieldLabelChange,
onFieldDelete,
}) => {
/**
* Handle drag begin
*/
const handleDragBegin = useCallback(() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
}, []);
/**
* Handle drag end
*/
const handleDragEnd = useCallback(({ data }: { data: CustomFieldDefinition[] }) => {
// Update display order for all fields
const updatedFields = data.map((field, index) => ({
...field,
displayOrder: index,
}));
onFieldsReorder(updatedFields);
}, [onFieldsReorder]);
/**
* Render each draggable item
*/
const renderItem = useCallback(({ item, drag }: RenderItemParams<CustomFieldDefinition>) => {
return (
<View style={styles.itemWrapper}>
<CustomFieldItem
field={item}
value={(fieldValues[item.tempId] as string) || ''}
onValueChange={(value) => onFieldValueChange(item.tempId, value)}
onLabelChange={(newLabel) => onFieldLabelChange(item.tempId, newLabel)}
onDelete={() => onFieldDelete(item.tempId)}
drag={drag}
/>
</View>
);
}, [fieldValues, onFieldValueChange, onFieldLabelChange, onFieldDelete]);
/**
* Key extractor for FlatList
*/
const keyExtractor = useCallback((item: CustomFieldDefinition) => item.tempId, []);
if (customFields.length === 0) {
return null;
}
return (
<DraggableFlatList
data={customFields}
onDragBegin={handleDragBegin}
onDragEnd={handleDragEnd}
keyExtractor={keyExtractor}
renderItem={renderItem}
containerStyle={styles.container}
scrollEnabled={false}
/>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
itemWrapper: {
marginVertical: 4,
},
});

View File

@@ -43,6 +43,7 @@
"react-native": "~0.79.6",
"react-native-aes-gcm-crypto": "^0.2.2",
"react-native-context-menu-view": "^1.19.0",
"react-native-draggable-flatlist": "^4.0.3",
"react-native-edge-to-edge": "1.6.0",
"react-native-gesture-handler": "~2.24.0",
"react-native-get-random-values": "^1.11.0",
@@ -14660,6 +14661,20 @@
"react-native": ">=0.60.0-rc.0 <1.0.x"
}
},
"node_modules/react-native-draggable-flatlist": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/react-native-draggable-flatlist/-/react-native-draggable-flatlist-4.0.3.tgz",
"integrity": "sha512-2F4x5BFieWdGq9SetD2nSAR7s7oQCSgNllYgERRXXtNfSOuAGAVbDb/3H3lP0y5f7rEyNwabKorZAD/SyyNbDw==",
"license": "MIT",
"dependencies": {
"@babel/preset-typescript": "^7.17.12"
},
"peerDependencies": {
"react-native": ">=0.64.0",
"react-native-gesture-handler": ">=2.0.0",
"react-native-reanimated": ">=2.8.0"
}
},
"node_modules/react-native-edge-to-edge": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/react-native-edge-to-edge/-/react-native-edge-to-edge-1.6.0.tgz",

View File

@@ -64,6 +64,7 @@
"react-native": "~0.79.6",
"react-native-aes-gcm-crypto": "^0.2.2",
"react-native-context-menu-view": "^1.19.0",
"react-native-draggable-flatlist": "^4.0.3",
"react-native-edge-to-edge": "1.6.0",
"react-native-gesture-handler": "~2.24.0",
"react-native-get-random-values": "^1.11.0",

View File

@@ -240,7 +240,7 @@ export class FieldValueQueries {
* Get existing field values for an item.
*/
public static readonly GET_EXISTING_FOR_ITEM = `
SELECT Id, FieldKey, FieldDefinitionId, Value
SELECT Id, FieldKey, FieldDefinitionId, Value, Weight
FROM FieldValues
WHERE ItemId = ? AND IsDeleted = 0`;

View File

@@ -581,16 +581,17 @@ export class ItemRepository extends BaseRepository {
FieldKey: string | null;
FieldDefinitionId: string | null;
Value: string;
Weight: number;
}>(FieldValueQueries.GET_EXISTING_FOR_ITEM, [itemId]);
// 2. Build lookup by composite key (FieldKey or FieldDefinitionId + index)
const existingByKey = new Map<string, { Id: string; Value: string }[]>();
const existingByKey = new Map<string, { Id: string; Value: string; Weight: number }[]>();
for (const existing of existingFields) {
const key = existing.FieldKey || existing.FieldDefinitionId || '';
if (!existingByKey.has(key)) {
existingByKey.set(key, []);
}
existingByKey.get(key)!.push({ Id: existing.Id, Value: existing.Value });
existingByKey.get(key)!.push({ Id: existing.Id, Value: existing.Value, Weight: existing.Weight });
}
// 3. Track which existing IDs we've processed
@@ -622,16 +623,17 @@ export class ItemRepository extends BaseRepository {
for (let j = 0; j < valuesToProcess.length; j++) {
const value = valuesToProcess[j];
const newWeight = field.DisplayOrder ?? 0;
const existingEntry = existingForKey[j];
if (existingEntry) {
// Update existing if value changed
// Update existing if value or weight changed
processedIds.add(existingEntry.Id);
if (existingEntry.Value !== value) {
if (existingEntry.Value !== value || existingEntry.Weight !== newWeight) {
await this.client.executeUpdate(FieldValueQueries.UPDATE, [
value,
(i * 100) + j,
newWeight,
now,
existingEntry.Id
]);
@@ -644,7 +646,7 @@ export class ItemRepository extends BaseRepository {
fieldDefinitionId, // FieldDefinitionId for custom, null for system
field.IsCustomField ? null : field.FieldKey, // FieldKey for system, null for custom
value,
(i * 100) + j,
newWeight,
now,
now,
0