浏览代码

fix(dashboard): Improve default string list component

Relates to #3916
Michael Bromley 2 月之前
父节点
当前提交
79fd8a4a54
共有 1 个文件被更改,包括 181 次插入26 次删除
  1. 181 26
      packages/dashboard/src/lib/components/data-input/string-list-input.tsx

+ 181 - 26
packages/dashboard/src/lib/components/data-input/string-list-input.tsx

@@ -1,13 +1,144 @@
-import { X } from 'lucide-react';
-import { KeyboardEvent, useId, useRef, useState } from 'react';
+import { GripVertical, X } from 'lucide-react';
+import { KeyboardEvent, useEffect, useId, useRef, useState } from 'react';
 
 
 import { Badge } from '@/vdb/components/ui/badge.js';
 import { Badge } from '@/vdb/components/ui/badge.js';
 import { Input } from '@/vdb/components/ui/input.js';
 import { Input } from '@/vdb/components/ui/input.js';
 import type { DashboardFormComponentProps } from '@/vdb/framework/form-engine/form-engine-types.js';
 import type { DashboardFormComponentProps } from '@/vdb/framework/form-engine/form-engine-types.js';
 import { isReadonlyField } from '@/vdb/framework/form-engine/utils.js';
 import { isReadonlyField } from '@/vdb/framework/form-engine/utils.js';
 import { cn } from '@/vdb/lib/utils.js';
 import { cn } from '@/vdb/lib/utils.js';
+import {
+    closestCenter,
+    DndContext,
+    type DragEndEvent,
+    KeyboardSensor,
+    PointerSensor,
+    useSensor,
+    useSensors,
+} from '@dnd-kit/core';
+import {
+    arrayMove,
+    SortableContext,
+    sortableKeyboardCoordinates,
+    useSortable,
+    verticalListSortingStrategy,
+} from '@dnd-kit/sortable';
+import { CSS } from '@dnd-kit/utilities';
 import { useLingui } from '@lingui/react';
 import { useLingui } from '@lingui/react';
 
 
+interface SortableItemProps {
+    id: string;
+    item: string;
+    isDisabled: boolean;
+    isEditing: boolean;
+    onRemove: () => void;
+    onEdit: () => void;
+    onSave: (newValue: string) => void;
+}
+
+function SortableItem({ id, item, isDisabled, isEditing, onRemove, onEdit, onSave }: SortableItemProps) {
+    const [editValue, setEditValue] = useState(item);
+    const inputRef = useRef<HTMLInputElement>(null);
+    const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
+        id,
+    });
+
+    const style = {
+        transform: CSS.Transform.toString(transform),
+        transition,
+    };
+
+    const handleSave = () => {
+        const trimmedValue = editValue.trim();
+        if (trimmedValue && trimmedValue !== item) {
+            onSave(trimmedValue);
+        } else {
+            setEditValue(item);
+            onSave(item);
+        }
+    };
+
+    const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
+        if (e.key === 'Enter') {
+            e.preventDefault();
+            handleSave();
+        } else if (e.key === 'Escape') {
+            setEditValue(item);
+            onSave(item);
+        }
+    };
+
+    // Focus and select input when entering edit mode
+    useEffect(() => {
+        if (isEditing && inputRef.current) {
+            inputRef.current.focus();
+            inputRef.current.select();
+        }
+    }, [isEditing]);
+
+    return (
+        <Badge
+            ref={setNodeRef}
+            style={style}
+            variant="secondary"
+            className={cn(
+                isDragging && 'opacity-50',
+                'flex items-center gap-1',
+                isEditing && 'border-muted-foreground/30',
+            )}
+        >
+            {!isDisabled && (
+                <button
+                    type="button"
+                    className={cn(
+                        'cursor-grab active:cursor-grabbing text-muted-foreground',
+                        'hover:bg-muted rounded p-0.5',
+                    )}
+                    {...attributes}
+                    {...listeners}
+                    aria-label={`Drag ${item}`}
+                >
+                    <GripVertical className="h-3 w-3" />
+                </button>
+            )}
+            {isEditing ? (
+                <input
+                    ref={inputRef}
+                    type="text"
+                    value={editValue}
+                    onChange={e => setEditValue(e.target.value)}
+                    onKeyDown={handleKeyDown}
+                    onBlur={handleSave}
+                    className="bg-transparent border-none outline-none focus:ring-0 p-0 h-auto min-w-[60px] w-auto"
+                    style={{ width: `${Math.max(editValue.length * 8, 60)}px` }}
+                />
+            ) : (
+                <span
+                    onClick={!isDisabled ? onEdit : undefined}
+                    className={cn(!isDisabled && 'cursor-text hover:underline')}
+                >
+                    {item}
+                </span>
+            )}
+            {!isDisabled && (
+                <button
+                    type="button"
+                    onClick={e => {
+                        e.stopPropagation();
+                        onRemove();
+                    }}
+                    className={cn(
+                        'ml-1 rounded-full outline-none ring-offset-background text-muted-foreground',
+                        'hover:bg-muted focus:ring-2 focus:ring-ring focus:ring-offset-2',
+                    )}
+                    aria-label={`Remove ${item}`}
+                >
+                    <X className="h-3 w-3" />
+                </button>
+            )}
+        </Badge>
+    );
+}
+
 export function StringListInput({
 export function StringListInput({
     value,
     value,
     onChange,
     onChange,
@@ -17,13 +148,21 @@ export function StringListInput({
     fieldDef,
     fieldDef,
 }: DashboardFormComponentProps) {
 }: DashboardFormComponentProps) {
     const [inputValue, setInputValue] = useState('');
     const [inputValue, setInputValue] = useState('');
+    const [editingIndex, setEditingIndex] = useState<number | null>(null);
     const inputRef = useRef<HTMLInputElement>(null);
     const inputRef = useRef<HTMLInputElement>(null);
     const { i18n } = useLingui();
     const { i18n } = useLingui();
-    const isDisabled = isReadonlyField(fieldDef) || disabled;
+    const isDisabled = isReadonlyField(fieldDef) || disabled || false;
     const id = useId();
     const id = useId();
 
 
     const items = Array.isArray(value) ? value : [];
     const items = Array.isArray(value) ? value : [];
 
 
+    const sensors = useSensors(
+        useSensor(PointerSensor),
+        useSensor(KeyboardSensor, {
+            coordinateGetter: sortableKeyboardCoordinates,
+        }),
+    );
+
     const addItem = (item: string) => {
     const addItem = (item: string) => {
         const trimmedItem = item.trim();
         const trimmedItem = item.trim();
         if (trimmedItem) {
         if (trimmedItem) {
@@ -36,6 +175,24 @@ export function StringListInput({
         onChange(items.filter((_, index) => index !== indexToRemove));
         onChange(items.filter((_, index) => index !== indexToRemove));
     };
     };
 
 
+    const editItem = (index: number, newValue: string) => {
+        const newItems = [...items];
+        newItems[index] = newValue;
+        onChange(newItems);
+        setEditingIndex(null);
+    };
+
+    const handleDragEnd = (event: DragEndEvent) => {
+        const { active, over } = event;
+
+        if (over && active.id !== over.id) {
+            const oldIndex = items.findIndex((_, idx) => `${id}-${idx}` === active.id);
+            const newIndex = items.findIndex((_, idx) => `${id}-${idx}` === over.id);
+
+            onChange(arrayMove(items, oldIndex, newIndex));
+        }
+    };
+
     const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
     const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
         if (e.key === 'Enter' || e.key === ',') {
         if (e.key === 'Enter' || e.key === ',') {
             e.preventDefault();
             e.preventDefault();
@@ -74,29 +231,27 @@ export function StringListInput({
                     className="min-w-[120px]"
                     className="min-w-[120px]"
                 />
                 />
             )}
             )}
-            <div className="flex flex-wrap gap-1 items-start justify-start">
-                {items.map((item, index) => (
-                    <Badge key={id + index} variant="secondary">
-                        <span>{item}</span>
-                        {!isDisabled && (
-                            <button
-                                type="button"
-                                onClick={e => {
-                                    e.stopPropagation();
-                                    removeItem(index);
-                                }}
-                                className={cn(
-                                    'ml-1 rounded-full outline-none ring-offset-background',
-                                    'hover:bg-muted focus:ring-2 focus:ring-ring focus:ring-offset-2',
-                                )}
-                                aria-label={`Remove ${item}`}
-                            >
-                                <X className="h-3 w-3" />
-                            </button>
-                        )}
-                    </Badge>
-                ))}
-            </div>
+            <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
+                <SortableContext
+                    items={items.map((_, index) => `${id}-${index}`)}
+                    strategy={verticalListSortingStrategy}
+                >
+                    <div className="flex flex-wrap gap-1 items-start justify-start">
+                        {items.map((item, index) => (
+                            <SortableItem
+                                key={`${id}-${index}`}
+                                id={`${id}-${index}`}
+                                item={item}
+                                isDisabled={isDisabled}
+                                isEditing={editingIndex === index}
+                                onRemove={() => removeItem(index)}
+                                onEdit={() => setEditingIndex(index)}
+                                onSave={newValue => editItem(index, newValue)}
+                            />
+                        ))}
+                    </div>
+                </SortableContext>
+            </DndContext>
         </div>
         </div>
     );
     );
 }
 }