From ae8fad2753532c12ac6752ea8d80f9d550f861ff Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Wed, 11 Feb 2026 16:49:12 +0100 Subject: [PATCH] Add drag-and-drop reorder to mobile app custom fields (#1673) --- apps/mobile-app/app/(tabs)/items/add-edit.tsx | 48 ++-- .../form/DraggableCustomFieldsList.tsx | 228 ++++++++++++++++++ apps/mobile-app/package-lock.json | 15 ++ apps/mobile-app/package.json | 1 + .../utils/db/queries/ItemQueries.ts | 2 +- .../utils/db/repositories/ItemRepository.ts | 14 +- 6 files changed, 273 insertions(+), 35 deletions(-) create mode 100644 apps/mobile-app/components/form/DraggableCustomFieldsList.tsx diff --git a/apps/mobile-app/app/(tabs)/items/add-edit.tsx b/apps/mobile-app/app/(tabs)/items/add-edit.tsx index 518a53f53..c91ca2106 100644 --- a/apps/mobile-app/app/(tabs)/items/add-edit.tsx +++ b/apps/mobile-app/app/(tabs)/items/add-edit.tsx @@ -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 && ( - {customFields.map(field => ( - - handleUpdateCustomFieldLabel(field.tempId, newLabel)} - onDelete={() => handleDeleteCustomField(field.tempId)} - /> - {renderFieldInput( - field.tempId, - '', // Label is shown by EditableFieldLabel - field.fieldType, - field.isHidden, - false - )} - - ))} + handleFieldChange(tempId, value)} + onFieldLabelChange={handleUpdateCustomFieldLabel} + onFieldDelete={handleDeleteCustomField} + /> )} diff --git a/apps/mobile-app/components/form/DraggableCustomFieldsList.tsx b/apps/mobile-app/components/form/DraggableCustomFieldsList.tsx new file mode 100644 index 000000000..39c33060e --- /dev/null +++ b/apps/mobile-app/components/form/DraggableCustomFieldsList.tsx @@ -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 = ({ + 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 ( + + ); + } + + if (field.isHidden || field.fieldType === FieldTypes.Hidden || field.fieldType === FieldTypes.Password) { + return ( + + ); + } + + return ( + + ); + }; + + 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 ( + + + {/* Field content */} + + {/* Label row */} + + + + {/* Input field */} + {renderFieldInput()} + + {/* Drag handle on right side */} + + + + + + ); +}; + +type DraggableCustomFieldsListProps = { + customFields: CustomFieldDefinition[]; + fieldValues: Record; + 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 = ({ + 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) => { + return ( + + onFieldValueChange(item.tempId, value)} + onLabelChange={(newLabel) => onFieldLabelChange(item.tempId, newLabel)} + onDelete={() => onFieldDelete(item.tempId)} + drag={drag} + /> + + ); + }, [fieldValues, onFieldValueChange, onFieldLabelChange, onFieldDelete]); + + /** + * Key extractor for FlatList + */ + const keyExtractor = useCallback((item: CustomFieldDefinition) => item.tempId, []); + + if (customFields.length === 0) { + return null; + } + + return ( + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + itemWrapper: { + marginVertical: 4, + }, +}); diff --git a/apps/mobile-app/package-lock.json b/apps/mobile-app/package-lock.json index e07529890..ade739248 100644 --- a/apps/mobile-app/package-lock.json +++ b/apps/mobile-app/package-lock.json @@ -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", diff --git a/apps/mobile-app/package.json b/apps/mobile-app/package.json index 65e6b2e51..e06336842 100644 --- a/apps/mobile-app/package.json +++ b/apps/mobile-app/package.json @@ -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", diff --git a/apps/mobile-app/utils/db/queries/ItemQueries.ts b/apps/mobile-app/utils/db/queries/ItemQueries.ts index 17a2528b0..09610284b 100644 --- a/apps/mobile-app/utils/db/queries/ItemQueries.ts +++ b/apps/mobile-app/utils/db/queries/ItemQueries.ts @@ -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`; diff --git a/apps/mobile-app/utils/db/repositories/ItemRepository.ts b/apps/mobile-app/utils/db/repositories/ItemRepository.ts index ea8df5891..d0c9c1ff2 100644 --- a/apps/mobile-app/utils/db/repositories/ItemRepository.ts +++ b/apps/mobile-app/utils/db/repositories/ItemRepository.ts @@ -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(); + const existingByKey = new Map(); 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