Add drag-and-drop reorder to browser extension custom fields (#1673)

This commit is contained in:
Leendert de Borst
2026-02-10 21:01:24 +01:00
committed by Leendert de Borst
parent 22e8a58f69
commit 72e214629b
6 changed files with 308 additions and 38 deletions

View File

@@ -1,14 +1,17 @@
{
"name": "aliasvault-browser-extension",
"version": "0.26.0",
"version": "0.27.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aliasvault-browser-extension",
"version": "0.26.0",
"version": "0.27.0",
"hasInstallScript": true,
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.1.1",
"@types/dompurify": "^3.0.5",
"argon2-browser": "^1.18.0",
@@ -710,6 +713,59 @@
}
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz",
@@ -11803,7 +11859,6 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/tunnel-agent": {

View File

@@ -29,6 +29,9 @@
"postinstall": "wxt prepare"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.1.1",
"@types/dompurify": "^3.0.5",
"argon2-browser": "^1.18.0",

View File

@@ -0,0 +1,220 @@
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import type { FieldType } from '@/utils/dist/core/models/vault';
import { FieldTypes } from '@/utils/dist/core/models/vault';
import EditableFieldLabel from './EditableFieldLabel';
import { FormInput } from './FormInput';
import HiddenField from './HiddenField';
/**
* Custom field definition type
*/
export type CustomFieldDefinition = {
tempId: string;
label: string;
fieldType: FieldType;
isHidden: boolean;
displayOrder: number;
};
interface ISortableCustomFieldProps {
field: CustomFieldDefinition;
value: string;
onValueChange: (value: string) => void;
onLabelChange: (newLabel: string) => void;
onDelete: () => void;
}
/**
* Individual sortable custom field item
*/
const SortableCustomField: React.FC<ISortableCustomFieldProps> = ({
field,
value,
onValueChange,
onLabelChange,
onDelete,
}) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: field.tempId });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 1000 : 'auto',
};
/**
* Renders the appropriate input field based on field type
*/
const renderFieldInput = (): React.ReactNode => {
if (field.fieldType === FieldTypes.TextArea) {
return (
<textarea
id={field.tempId}
value={value}
onChange={(e) => onValueChange(e.target.value)}
rows={4}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-700 dark:text-white"
/>
);
}
if (field.isHidden || field.fieldType === FieldTypes.Hidden || field.fieldType === FieldTypes.Password) {
return (
<HiddenField
id={field.tempId}
label=""
value={value}
onChange={onValueChange}
/>
);
}
return (
<FormInput
id={field.tempId}
label=""
value={value}
onChange={onValueChange}
type="text"
/>
);
};
return (
<div
ref={setNodeRef}
style={style}
className="relative bg-white dark:bg-gray-800"
>
{/* Draggable label row */}
<div
className="cursor-grab active:cursor-grabbing"
{...attributes}
{...listeners}
>
<EditableFieldLabel
htmlFor={field.tempId}
label={field.label}
onLabelChange={onLabelChange}
onDelete={onDelete}
/>
</div>
{/* Input field */}
{renderFieldInput()}
</div>
);
};
interface IDraggableCustomFieldsListProps {
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 support.
* Uses @dnd-kit for accessible and performant drag-and-drop functionality.
*/
const DraggableCustomFieldsList: React.FC<IDraggableCustomFieldsListProps> = ({
customFields,
fieldValues,
onFieldsReorder,
onFieldValueChange,
onFieldLabelChange,
onFieldDelete,
}) => {
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8, // 8px movement required before drag starts
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
/**
* Handle drag end event
*/
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = customFields.findIndex((f) => f.tempId === active.id);
const newIndex = customFields.findIndex((f) => f.tempId === over.id);
const reorderedFields = arrayMove(customFields, oldIndex, newIndex);
const updatedFields = reorderedFields.map((field, index) => ({
...field,
displayOrder: index,
}));
onFieldsReorder(updatedFields);
}
}, [customFields, onFieldsReorder]);
if (customFields.length === 0) {
return null;
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={customFields.map((f) => f.tempId)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-3">
{customFields.map((field) => (
<SortableCustomField
key={field.tempId}
field={field}
value={(fieldValues[field.tempId] as string) || ''}
onValueChange={(value) => onFieldValueChange(field.tempId, value)}
onLabelChange={(newLabel) => onFieldLabelChange(field.tempId, newLabel)}
onDelete={() => onFieldDelete(field.tempId)}
/>
))}
</div>
</SortableContext>
</DndContext>
);
};
export default DraggableCustomFieldsList;

View File

@@ -4,7 +4,7 @@ import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import Modal from '@/entrypoints/popup/components/Dialogs/Modal';
import AddFieldMenu, { type OptionalSection } from '@/entrypoints/popup/components/Forms/AddFieldMenu';
import EditableFieldLabel from '@/entrypoints/popup/components/Forms/EditableFieldLabel';
import DraggableCustomFieldsList, { type CustomFieldDefinition } from '@/entrypoints/popup/components/Forms/DraggableCustomFieldsList';
import EmailDomainField from '@/entrypoints/popup/components/Forms/EmailDomainField';
import { FormInput } from '@/entrypoints/popup/components/Forms/FormInput';
import FormSection from '@/entrypoints/popup/components/Forms/FormSection';
@@ -40,17 +40,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;
};
/**
* Persisted form data type used for JSON serialization.
* This is the data portion stored via useFormPersistence hook.
@@ -427,6 +416,8 @@ const ItemAddEdit: React.FC = () => {
});
setFieldValues(initialValues);
// Sort custom fields by displayOrder when loading
existingCustomFields.sort((a, b) => a.displayOrder - b.displayOrder);
setCustomFields(existingCustomFields);
setInitiallyVisibleFields(fieldsWithValues);
@@ -811,6 +802,14 @@ const ItemAddEdit: React.FC = () => {
));
}, []);
/**
* 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);
}, []);
/**
* Handle item type change from dropdown.
* Clears field values that don't apply to the new item type.
@@ -1373,26 +1372,17 @@ const ItemAddEdit: React.FC = () => {
</FormSection>
)}
{/* Custom Fields Section */}
{/* Custom Fields Section with Drag-and-Drop Reordering */}
{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>
))}
<DraggableCustomFieldsList
customFields={customFields}
fieldValues={fieldValues}
onFieldsReorder={handleCustomFieldsReorder}
onFieldValueChange={(tempId, value) => handleFieldChange(tempId, value)}
onFieldLabelChange={handleUpdateCustomFieldLabel}
onFieldDelete={handleDeleteCustomField}
/>
</FormSection>
)}

View File

@@ -237,7 +237,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

@@ -534,16 +534,17 @@ export class ItemRepository extends BaseRepository {
FieldKey: string | null;
FieldDefinitionId: string | null;
Value: string;
Weight: number;
}>(FieldValueQueries.GET_EXISTING_FOR_ITEM, [item.Id]);
// Build a map of existing FieldValues by key:index
const existingByKey = new Map<string, { Id: string; Value: string }>();
const existingByKey = new Map<string, { Id: string; Value: string; Weight: number }>();
const fieldValueCounts = new Map<string, number>();
for (const fv of existingFieldValues) {
const key = fv.FieldKey || fv.FieldDefinitionId || '';
const count = fieldValueCounts.get(key) || 0;
existingByKey.set(`${key}:${count}`, { Id: fv.Id, Value: fv.Value });
existingByKey.set(`${key}:${count}`, { Id: fv.Id, Value: fv.Value, Weight: fv.Weight });
fieldValueCounts.set(key, count + 1);
}
@@ -581,10 +582,11 @@ export class ItemRepository extends BaseRepository {
if (existing) {
processedIds.add(existing.Id);
if (existing.Value !== value) {
const newWeight = field.DisplayOrder ?? 0;
if (existing.Value !== value || existing.Weight !== newWeight) {
this.client.executeUpdate(FieldValueQueries.UPDATE, [
value,
field.DisplayOrder ?? 0,
newWeight,
currentDateTime,
existing.Id
]);