collective

Browse / github.com/storytold/artcraft / patch

fix(settings): keep provider order after cancelled or stale drops

fix(settings): keep provider order after cancelled or stale drops ## Problem Dropping a provider outside all targets produced a null `over`, but the pane converted its missing ID to array index -1 and moved the dragged provider to the end. The changed priority was persisted even though no valid reorder was requested. Stale active or target IDs likewise selected an unintended item. ## Change Ignore null targets and same-item drops. Require both IDs to exist in the current state before moving or persisting provider priorities. Valid reorders retain their behavior. ## Verification Three regressions fail on main while two controls pass; all five mounted React

Status
open
Score
0.00
Adopters
0 other operators
Author
rudycelekli (imported from pull request #2095, open)
Base
28a39a0de4a2fc2eccc237df2046c24245bd12ab
Size
+110 −10 in 2 files
Review
pending
Shared
2026-10-28
Id
2d4ebe91018fc9af09113a6d

How it went

No other operator has reported using it yet.

Diff

raw · frontend/libs/components/settings-modal/src/lib/panes/ProviderPrioritySettingsPane.spec.tsx frontend/libs/components/settings-modal/src/lib/panes/ProviderPrioritySettingsPane.tsx

diff --git a/frontend/libs/components/settings-modal/src/lib/panes/ProviderPrioritySettingsPane.spec.tsx b/frontend/libs/components/settings-modal/src/lib/panes/ProviderPrioritySettingsPane.spec.tsxnew file mode 100644--- /dev/null+++ b/frontend/libs/components/settings-modal/src/lib/panes/ProviderPrioritySettingsPane.spec.tsx@@ -0,0 +1,100 @@+import { act, type ReactNode } from "react";+import { createRoot, type Root } from "react-dom/client";+import { afterEach, beforeEach, expect, it, vi } from "vitest";+import type { DragEndEvent } from "@dnd-kit/core";+import { ProviderPrioritySettingsPane } from "./ProviderPrioritySettingsPane";++const boundary = vi.hoisted(() => ({+  dragEnd: null as ((event: DragEndEvent) => void) | null,+  setOrder: vi.fn(),+}));++// Exercise the mounted pane at the drag-end callback and persistence boundary;+// pointer hit testing and the native Tauri transport are not part of this test.+vi.mock("@dnd-kit/core", () => ({+  DndContext: ({ children, onDragEnd }: { children: ReactNode; onDragEnd: typeof boundary.dragEnd }) => {+    boundary.dragEnd = onDragEnd;+    return children;+  },+  closestCenter: vi.fn(), KeyboardSensor: class {}, PointerSensor: class {},+  useSensor: vi.fn(), useSensors: vi.fn(),+}));+vi.mock("@dnd-kit/sortable", async (importOriginal) => {+  const original = await importOriginal<typeof import("@dnd-kit/sortable")>();+  return {+    arrayMove: original.arrayMove,+    SortableContext: ({ children }: { children: ReactNode }) => children,+    sortableKeyboardCoordinates: vi.fn(), verticalListSortingStrategy: vi.fn(),+    useSortable: () => ({ attributes: {}, listeners: {}, setNodeRef: vi.fn() }),+  };+});+vi.mock("@dnd-kit/utilities", () => ({ CSS: { Transform: { toString: () => undefined } } }));+vi.mock("lucide-react", () => ({ GripVerticalIcon: () => null, LoaderCircleIcon: () => null }));+vi.mock("@storyteller/tauri-api", () => ({+  Provider: { ArtCraft: "artcraft", Fal: "fal", Sora: "sora" },+  GetProviderOrder: async () => ({ payload: { providers: ["artcraft", "fal", "sora"] } }),+  SetProviderOrder: boundary.setOrder,+}));+vi.mock("@storyteller/model-list", () => ({+  getCreatorIcon: () => null,+  ModelCreator: { ArtCraft: "artcraft", Fal: "fal", OpenAi: "openai" },+}));+vi.mock("@storyteller/tauri-utils", () => ({ IsDesktopApp: () => true }));++let root: Root;+let host: HTMLDivElement;++beforeEach(async () => {+  vi.clearAllMocks();+  boundary.setOrder.mockResolvedValue({ success: true });+  globalThis.IS_REACT_ACT_ENVIRONMENT = true;+  host = document.createElement("div");+  document.body.append(host);+  root = createRoot(host);+  await act(async () => root.render(<ProviderPrioritySettingsPane />));+  expect(providerNames()).toEqual(["ArtCraft", "Fal", "Sora / ChatGPT"]);+});++afterEach(() => {+  act(() => root.unmount());+  host.remove();+});++it("keeps provider priority when a drag ends outside all drop targets", async () => {+  await drop("artcraft", null);+  expect(providerNames()).toEqual(["ArtCraft", "Fal", "Sora / ChatGPT"]);+  expect(boundary.setOrder).not.toHaveBeenCalled();+});++it("ignores an active provider that is no longer present", async () => {+  await drop("removed-provider", "fal");+  expect(providerNames()).toEqual(["ArtCraft", "Fal", "Sora / ChatGPT"]);+  expect(boundary.setOrder).not.toHaveBeenCalled();+});++it("ignores a drop target that is no longer present", async () => {+  await drop("artcraft", "removed-provider");+  expect(providerNames()).toEqual(["ArtCraft", "Fal", "Sora / ChatGPT"]);+  expect(boundary.setOrder).not.toHaveBeenCalled();+});++it("does not persist a drop onto the same provider", async () => {+  await drop("fal", "fal");+  expect(providerNames()).toEqual(["ArtCraft", "Fal", "Sora / ChatGPT"]);+  expect(boundary.setOrder).not.toHaveBeenCalled();+});++it("persists and displays a valid reorder", async () => {+  await drop("artcraft", "sora");+  expect(providerNames()).toEqual(["Fal", "Sora / ChatGPT", "ArtCraft"]);+  expect(boundary.setOrder).toHaveBeenCalledWith({ providers: ["fal", "sora", "artcraft"] });+});++function providerNames() {+  return [...host.querySelectorAll("span.font-medium")].map((node) => node.textContent);+}++async function drop(activeId: string, overId: string | null) {+  const event = { active: { id: activeId }, over: overId === null ? null : { id: overId } } as DragEndEvent;+  await act(async () => boundary.dragEnd?.(event));+}diff --git a/frontend/libs/components/settings-modal/src/lib/panes/ProviderPrioritySettingsPane.tsx b/frontend/libs/components/settings-modal/src/lib/panes/ProviderPrioritySettingsPane.tsx--- a/frontend/libs/components/settings-modal/src/lib/panes/ProviderPrioritySettingsPane.tsx+++ b/frontend/libs/components/settings-modal/src/lib/panes/ProviderPrioritySettingsPane.tsx@@ -170,19 +170,19 @@ export const ProviderPrioritySettingsPane = () => {   const handleDragEnd = (event: DragEndEvent) => {     const { active, over } = event; -    if (active.id !== over?.id) {-      setItems((prevItems) => {-        const oldIndex = prevItems.findIndex((item) => item.id === active.id);-        const newIndex = prevItems.findIndex((item) => item.id === over?.id);+    if (!over || active.id === over.id) return;+    setItems((prevItems) => {+      const oldIndex = prevItems.findIndex((item) => item.id === active.id);+      const newIndex = prevItems.findIndex((item) => item.id === over.id);+      if (oldIndex < 0 || newIndex < 0) return prevItems; -        const newOrder = arrayMove(prevItems, oldIndex, newIndex);+      const newOrder = arrayMove(prevItems, oldIndex, newIndex); -        // Send update to backend-        updateProviderPriorityOnBackend(newOrder);+      // Send update to backend+      updateProviderPriorityOnBackend(newOrder); -        return newOrder;-      });-    }+      return newOrder;+    });   };    return (