mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-24 16:32:20 -04:00
Add drag-and-drop reorder to mobile app custom fields (#1673)
This commit is contained in:
committed by
Leendert de Borst
parent
7b354b47ad
commit
ae8fad2753
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
228
apps/mobile-app/components/form/DraggableCustomFieldsList.tsx
Normal file
228
apps/mobile-app/components/form/DraggableCustomFieldsList.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
15
apps/mobile-app/package-lock.json
generated
15
apps/mobile-app/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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`;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user