From 3e779f5d299dd2af7670cc1e890ee094347e1e8f Mon Sep 17 00:00:00 2001 From: Matt Gaunt-Seo Date: Sun, 3 Nov 2024 08:01:18 -0800 Subject: [PATCH] Support drag and drop for list items (#587) --- .env.development | 2 +- .env.production | 2 +- .env.test | 2 +- src/__mocks__/chrome/storage.ts | 4 +- src/__mocks__/webextension-polyfill.ts | 13 +++ .../apps/options/components/DragGrip.svelte | 20 ++++ .../apps/options/components/PinnedTabs.svelte | 97 ++++++++++++++++++- .../_pinned-tabs-browser-storage.test.ts | 11 --- src/libs/models/_pinned-tabs-store.test.ts | 93 ++++++++++++++++++ src/libs/models/_pinned-tabs-store.ts | 9 +- vite.config.ts | 3 +- vitestSetupMocks.ts | 4 + 12 files changed, 240 insertions(+), 20 deletions(-) create mode 100644 src/__mocks__/webextension-polyfill.ts create mode 100644 src/frontend/apps/options/components/DragGrip.svelte create mode 100644 src/libs/models/_pinned-tabs-store.test.ts create mode 100644 vitestSetupMocks.ts diff --git a/.env.development b/.env.development index 075fc5f..e99e824 100644 --- a/.env.development +++ b/.env.development @@ -1 +1 @@ -BUILD_TYPE=development +VITE_ENV="development" diff --git a/.env.production b/.env.production index 3daf9e2..a48add0 100644 --- a/.env.production +++ b/.env.production @@ -1,2 +1,2 @@ VITE_SENTRY_DSN="https://0b9cdcee8d8040edb9cc9586ecb52a14@o1296550.ingest.sentry.io/6556279" -BUILD_TYPE="production" +VITE_ENV="production" diff --git a/.env.test b/.env.test index 20e91b3..4befc2a 100644 --- a/.env.test +++ b/.env.test @@ -1 +1 @@ -BUILD_TYPE=test +VITE_ENV="test" diff --git a/src/__mocks__/chrome/storage.ts b/src/__mocks__/chrome/storage.ts index 73b1fee..615dbcd 100644 --- a/src/__mocks__/chrome/storage.ts +++ b/src/__mocks__/chrome/storage.ts @@ -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 || {}; @@ -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, diff --git a/src/__mocks__/webextension-polyfill.ts b/src/__mocks__/webextension-polyfill.ts new file mode 100644 index 0000000..40d10aa --- /dev/null +++ b/src/__mocks__/webextension-polyfill.ts @@ -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; diff --git a/src/frontend/apps/options/components/DragGrip.svelte b/src/frontend/apps/options/components/DragGrip.svelte new file mode 100644 index 0000000..09a66e0 --- /dev/null +++ b/src/frontend/apps/options/components/DragGrip.svelte @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/src/frontend/apps/options/components/PinnedTabs.svelte b/src/frontend/apps/options/components/PinnedTabs.svelte index 4ab17ea..48cfc97 100644 --- a/src/frontend/apps/options/components/PinnedTabs.svelte +++ b/src/frontend/apps/options/components/PinnedTabs.svelte @@ -1,9 +1,9 @@
{#each urls as url, i (i)} - + +
onDragOver(e, i)} + on:dragend={(e) => onDragEnd(e)} + > +
gripMouseDown(e)} + on:mouseup={(e) => gripMouseUp(e)} + on:dragstart={(e) => onDragStart(e, i)} + > + +
+
+ +
+
{/each}
@@ -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; + } diff --git a/src/libs/models/_pinned-tabs-browser-storage.test.ts b/src/libs/models/_pinned-tabs-browser-storage.test.ts index dfcbe92..bb811da 100644 --- a/src/libs/models/_pinned-tabs-browser-storage.test.ts +++ b/src/libs/models/_pinned-tabs-browser-storage.test.ts @@ -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(() => { diff --git a/src/libs/models/_pinned-tabs-store.test.ts b/src/libs/models/_pinned-tabs-store.test.ts new file mode 100644 index 0000000..6daf1e7 --- /dev/null +++ b/src/libs/models/_pinned-tabs-store.test.ts @@ -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 + }); +}); diff --git a/src/libs/models/_pinned-tabs-store.ts b/src/libs/models/_pinned-tabs-store.ts index e04dccb..79d9928 100644 --- a/src/libs/models/_pinned-tabs-store.ts +++ b/src/libs/models/_pinned-tabs-store.ts @@ -56,7 +56,10 @@ export class PinnedTabsStore implements Readable { }; saveURLs(urls: string[]) { - if (JSON.stringify(this._urls) === JSON.stringify(urls)) { + if ( + JSON.stringify(this._urls) === JSON.stringify(urls) && + this._pendingChanges === false + ) { return; } @@ -66,6 +69,10 @@ export class PinnedTabsStore implements Readable { this._debounceCallCount++; this._debouncedSetURLs(); } + + cancelSave() { + this._debouncedSetURLs.cancel(); + } } interface PinnedTabs { diff --git a/vite.config.ts b/vite.config.ts index e0c72c4..f77697d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -142,6 +142,7 @@ export default defineConfig({ }, ], test: { - include: ["src/**/*.{test,spec}.?(c|m)[jt]s?(x)"], + root: "src", + setupFiles: ["./vitestSetupMocks.ts"], }, }); diff --git a/vitestSetupMocks.ts b/vitestSetupMocks.ts new file mode 100644 index 0000000..2250be4 --- /dev/null +++ b/vitestSetupMocks.ts @@ -0,0 +1,4 @@ +import { vi } from "vitest"; + +// This will ensure the mock is loaded for all tests +vi.mock("webextension-polyfill");