mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-11 00:37:05 -04:00
Add drag-and-drop reorder to browser extension custom fields (#1673)
This commit is contained in:
committed by
Leendert de Borst
parent
22e8a58f69
commit
72e214629b
61
apps/browser-extension/package-lock.json
generated
61
apps/browser-extension/package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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`;
|
||||
|
||||
|
||||
@@ -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
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user