Skip to content

Commit

Permalink
Support drag and drop for list items
Browse files Browse the repository at this point in the history
  • Loading branch information
gauntface committed Nov 3, 2024
1 parent 7554050 commit 08af345
Show file tree
Hide file tree
Showing 12 changed files with 240 additions and 20 deletions.
2 changes: 1 addition & 1 deletion .env.development
Original file line number Diff line number Diff line change
@@ -1 +1 @@
BUILD_TYPE=development
VITE_ENV="development"
2 changes: 1 addition & 1 deletion .env.production
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
VITE_SENTRY_DSN="https://0b9cdcee8d8040edb9cc9586ecb52a14@o1296550.ingest.sentry.io/6556279"
BUILD_TYPE="production"
VITE_ENV="production"
2 changes: 1 addition & 1 deletion .env.test
Original file line number Diff line number Diff line change
@@ -1 +1 @@
BUILD_TYPE=test
VITE_ENV="test"
4 changes: 2 additions & 2 deletions src/__mocks__/chrome/storage.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any, no-console */
const STORAGE_DELAY_S = 1;
const STORAGE_DELAY_S = 4;

globalThis.chrome = globalThis.chrome || {};
globalThis.chrome.storage = globalThis.chrome.storage || {};
Expand All @@ -13,7 +13,7 @@ globalThis.chrome.storage.sync = {
for (const [key, value] of Object.entries(map)) {
storage[key] = value;
}
if (import.meta.env.BUILD_MODE === "development") {
if (import.meta.env.VITE_ENV === "development") {
console.log(
`Chrome Extension Mock: Storage ${STORAGE_DELAY_S}s delay start`,
storage,
Expand Down
13 changes: 13 additions & 0 deletions src/__mocks__/webextension-polyfill.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { vi } from "vitest";

export const storage = {
sync: {
get: vi.fn().mockResolvedValue({}),
set: vi.fn().mockResolvedValue(undefined),
},
};

const browser = {
storage,
};
export default browser;
20 changes: 20 additions & 0 deletions src/frontend/apps/options/components/DragGrip.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="13" cy="8" r="2" fill="#000" />
<circle cx="13" cy="16" r="2" fill="#000" />
<circle cx="13" cy="24" r="2" fill="#000" />
<circle cx="20" cy="8" r="2" fill="#000" />
<circle cx="20" cy="16" r="2" fill="#000" />
<circle cx="20" cy="24" r="2" fill="#000" />
</svg>

<style>
svg {
width: 18px;
height: auto;
pointer-events: none;
}
circle {
fill: var(--body-fg);
}
</style>
97 changes: 95 additions & 2 deletions src/frontend/apps/options/components/PinnedTabs.svelte
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
<script lang="ts">
import { PinnedTabsStore } from "../../../../libs/models/_pinned-tabs-store";
import { getContext } from "svelte";
import URLItem from "../../../components/url-item/URLItem.svelte";
import LayoutOptionsSection from "./LayoutOptionsSection.svelte";
import DragGrip from "./DragGrip.svelte";
const pinnedURLs = getContext<PinnedTabsStore>("pinned-urls");
Expand All @@ -26,12 +26,81 @@
urls.splice(index, 1);
pinnedURLs.saveURLs([...urls]);
}
let draggedItem: number | null = null;
let originalDraggedItemIndex: number | null = null;
function gripMouseDown(event: MouseEvent) {
const grip = event.target as HTMLElement;
const parent = grip.closest(".c-pinned-urls-list__draggable-grip");
parent?.setAttribute("draggable", "true");
}
function gripMouseUp(event: MouseEvent) {
const grip = event.target as HTMLElement;
const parent = grip?.parentNode as HTMLElement;
parent?.setAttribute("draggable", "false");
}
function onDragStart(event: DragEvent, index: number) {
// Cancel any in progress save so the order doesn't change in the UI
// mid-drag.
pinnedURLs.cancelSave();
draggedItem = index;
originalDraggedItemIndex = index;
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = "move";
}
}
function onDragOver(event: DragEvent, index: number) {
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect =
originalDraggedItemIndex === index ? "none" : "move";
}
if (draggedItem === null || draggedItem === index) {
return;
}
const newUrls = [...urls];
const [removed] = newUrls.splice(draggedItem, 1);
newUrls.splice(index, 0, removed);
urls = newUrls;
draggedItem = index;
}
function onDragEnd(event: DragEvent) {
event.preventDefault();
// Save the new set of URLs (it'll handle the scenario of no change)
pinnedURLs.saveURLs(urls);
draggedItem = null;
originalDraggedItemIndex = null;
}
</script>

<LayoutOptionsSection title="Pinned Tabs">
<div class="c-pinned-urls-list">
{#each urls as url, i (i)}
<URLItem id={i} {url} {onURLChange} {onDeleteURL} />
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="c-pinned-urls-list__draggable-container"
class:c-pinned-urls-list--being-dragged={draggedItem === i}
on:dragover={(e) => onDragOver(e, i)}
on:dragend={(e) => onDragEnd(e)}
>
<div
class="c-pinned-urls-list__draggable-grip"
on:mousedown={(e) => gripMouseDown(e)}
on:mouseup={(e) => gripMouseUp(e)}
on:dragstart={(e) => onDragStart(e, i)}
>
<DragGrip />
</div>
<div class="c-pinned-urls-list__draggable-body">
<URLItem id={i} {url} {onURLChange} {onDeleteURL} />
</div>
</div>
{/each}
<button title="Add URL" on:click={addURL}>Add URL</button>
</div>
Expand All @@ -48,4 +117,28 @@
.c-pinned-urls-list button {
align-self: center;
}
.c-pinned-urls-list__draggable-container {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
cursor: default;
transition: opacity 0.1s linear;
}
.c-pinned-urls-list__draggable-grip {
cursor: grab;
}
.c-pinned-urls-list__draggable-grip:active {
cursor: grabbing;
}
.c-pinned-urls-list--being-dragged {
opacity: 0.5;
}
.c-pinned-urls-list--being-dragged .c-pinned-urls-list__draggable-grip {
cursor: grabbing;
}
</style>
11 changes: 0 additions & 11 deletions src/libs/models/_pinned-tabs-browser-storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { storage } from "webextension-polyfill";
import { getUrlsToPin, setUrlsToPin } from "./_pinned-tabs-browser-storage";

vi.mock("webextension-polyfill", () => {
return {
storage: {
sync: {
get: vi.fn().mockResolvedValue({}),
set: vi.fn().mockResolvedValue(undefined),
},
},
};
});

beforeEach(() => {});

afterEach(() => {
Expand Down
93 changes: 93 additions & 0 deletions src/libs/models/_pinned-tabs-store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import * as browserStorage from "./_pinned-tabs-browser-storage";
import { PinnedTabsStore } from "./_pinned-tabs-store";

vi.mock("./_pinned-tabs-browser-storage");

describe("PinnedTabsStore", () => {
let store: PinnedTabsStore;

beforeEach(() => {
vi.useFakeTimers();
vi.mocked(browserStorage.getUrlsToPin).mockResolvedValue([]);
store = new PinnedTabsStore();
});

afterEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
});

test("initializes with empty array", async () => {
const subscriber = vi.fn();
store.subscribe(subscriber);

await vi.runAllTimersAsync();

expect(subscriber).toHaveBeenCalledWith({
urls: [""],
pendingChanges: false,
});
});

test("saves URLs and notifies subscribers", async () => {
const subscriber = vi.fn();
store.subscribe(subscriber);

store.saveURLs(["https://example.com"]);

expect(subscriber).toHaveBeenCalledWith({
urls: ["https://example.com"],
pendingChanges: true,
});

await vi.advanceTimersByTimeAsync(1000);

expect(browserStorage.setUrlsToPin).toHaveBeenCalledWith([
"https://example.com",
]);
expect(subscriber).toHaveBeenCalledWith({
urls: ["https://example.com"],
pendingChanges: false,
});
});

test("debounces multiple saveURLs calls", async () => {
store.saveURLs(["https://example1.com"]);
store.saveURLs(["https://example2.com"]);
store.saveURLs(["https://example3.com"]);

await vi.advanceTimersByTimeAsync(1000);

expect(browserStorage.setUrlsToPin).toHaveBeenCalledTimes(1);
expect(browserStorage.setUrlsToPin).toHaveBeenCalledWith([
"https://example3.com",
]);
});

test("cancels pending save", async () => {
const subscriber = vi.fn();
store.subscribe(subscriber);

store.saveURLs(["https://example.com"]);
store.cancelSave();

await vi.advanceTimersByTimeAsync(1000);

expect(browserStorage.setUrlsToPin).not.toHaveBeenCalled();
expect(subscriber).toHaveBeenCalledWith({
urls: ["https://example.com"],
pendingChanges: true,
});
});

test("handles unsubscribe correctly", () => {
const subscriber = vi.fn();
const unsubscribe = store.subscribe(subscriber);

unsubscribe();
store.saveURLs(["https://example.com"]);

expect(subscriber).toHaveBeenCalledTimes(1); // Only the initial call
});
});
9 changes: 8 additions & 1 deletion src/libs/models/_pinned-tabs-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,10 @@ export class PinnedTabsStore implements Readable<PinnedTabs> {
};

saveURLs(urls: string[]) {
if (JSON.stringify(this._urls) === JSON.stringify(urls)) {
if (
JSON.stringify(this._urls) === JSON.stringify(urls) &&
this._pendingChanges === false
) {
return;
}

Expand All @@ -66,6 +69,10 @@ export class PinnedTabsStore implements Readable<PinnedTabs> {
this._debounceCallCount++;
this._debouncedSetURLs();
}

cancelSave() {
this._debouncedSetURLs.cancel();
}
}

interface PinnedTabs {
Expand Down
3 changes: 2 additions & 1 deletion vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ export default defineConfig({
},
],
test: {
include: ["src/**/*.{test,spec}.?(c|m)[jt]s?(x)"],
root: "src",
setupFiles: ["./vitestSetupMocks.ts"],
},
});
4 changes: 4 additions & 0 deletions vitestSetupMocks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { vi } from "vitest";

// This will ensure the mock is loaded for all tests
vi.mock("webextension-polyfill");

0 comments on commit 08af345

Please sign in to comment.