From 8e3aab4943cb087146216d53520c7d49331cbb5c Mon Sep 17 00:00:00 2001 From: Trevor Scheer Date: Wed, 17 Jun 2026 12:40:41 -0700 Subject: [PATCH 01/11] Scaffold @graphiql/plugin-collections package --- .../graphiql-plugin-collections/README.md | 9 ++ .../graphiql-plugin-collections/package.json | 62 ++++++++++++++ .../src/__mocks__/@graphiql/react.ts | 85 +++++++++++++++++++ .../graphiql-plugin-collections/src/index.ts | 2 + .../src/test-setup.ts | 7 ++ .../src/vite-env.d.ts | 1 + .../graphiql-plugin-collections/tsconfig.json | 3 + .../vite.config.mts | 46 ++++++++++ .../vitest.config.mts | 27 ++++++ 9 files changed, 242 insertions(+) create mode 100644 packages/graphiql-plugin-collections/README.md create mode 100644 packages/graphiql-plugin-collections/package.json create mode 100644 packages/graphiql-plugin-collections/src/__mocks__/@graphiql/react.ts create mode 100644 packages/graphiql-plugin-collections/src/index.ts create mode 100644 packages/graphiql-plugin-collections/src/test-setup.ts create mode 100644 packages/graphiql-plugin-collections/src/vite-env.d.ts create mode 100644 packages/graphiql-plugin-collections/tsconfig.json create mode 100644 packages/graphiql-plugin-collections/vite.config.mts create mode 100644 packages/graphiql-plugin-collections/vitest.config.mts diff --git a/packages/graphiql-plugin-collections/README.md b/packages/graphiql-plugin-collections/README.md new file mode 100644 index 0000000000..6a376a3a62 --- /dev/null +++ b/packages/graphiql-plugin-collections/README.md @@ -0,0 +1,9 @@ +# @graphiql/plugin-collections + +A first-party operation collections plugin for GraphiQL. Save, organize, and reuse named GraphQL operations in named collections with support for drag-and-drop reorder and JSON import/export. + +## Installation + +```sh +npm install @graphiql/plugin-collections +``` diff --git a/packages/graphiql-plugin-collections/package.json b/packages/graphiql-plugin-collections/package.json new file mode 100644 index 0000000000..c0491f3d50 --- /dev/null +++ b/packages/graphiql-plugin-collections/package.json @@ -0,0 +1,62 @@ +{ + "name": "@graphiql/plugin-collections", + "version": "0.1.0-alpha.0", + "sideEffects": [ + "*.css" + ], + "description": "A first-party operation collections plugin for GraphiQL.", + "repository": { + "type": "git", + "url": "https://github.com/graphql/graphiql", + "directory": "packages/graphiql-plugin-collections" + }, + "homepage": "https://github.com/graphql/graphiql/tree/master/packages/graphiql-plugin-collections#readme", + "bugs": { + "url": "https://github.com/graphql/graphiql/issues?q=issue+label:@graphiql/plugin-collections" + }, + "license": "MIT", + "exports": { + "./package.json": "./package.json", + "./style.css": "./dist/style.css", + ".": "./dist/index.js" + }, + "types": "dist/index.d.ts", + "keywords": [ + "react", + "graphql", + "graphiql", + "plugin", + "collections" + ], + "files": [ + "dist" + ], + "scripts": { + "types:check": "tsgo --noEmit", + "dev": "vite build --watch --emptyOutDir=false", + "build": "vite build", + "test": "vitest run" + }, + "dependencies": { + "zustand": "^5" + }, + "peerDependencies": { + "@graphiql/react": "^0.37.0", + "graphql": "^15.5.0 || ^16.0.0 || ^17.0.0", + "react": "^18 || ^19", + "react-dom": "^18 || ^19" + }, + "devDependencies": { + "@graphiql/react": "^0.37.4", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", + "@vitejs/plugin-react": "^4.4.1", + "graphql": "^16.9.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "vite": "^6.3.4", + "vite-plugin-dts": "^4.5.3", + "vite-plugin-svgr": "^4.3.0" + } +} diff --git a/packages/graphiql-plugin-collections/src/__mocks__/@graphiql/react.ts b/packages/graphiql-plugin-collections/src/__mocks__/@graphiql/react.ts new file mode 100644 index 0000000000..c16e32a868 --- /dev/null +++ b/packages/graphiql-plugin-collections/src/__mocks__/@graphiql/react.ts @@ -0,0 +1,85 @@ +// Minimal stub of @graphiql/react for unit/integration tests. +import type { ReactNode } from 'react'; +import { useStore } from 'zustand'; +import { useShallow } from 'zustand/shallow'; +import type { StoreApi, ExtractState } from 'zustand'; + +type GraphiQLState = { + schema: unknown; + queryEditor: null; + activeTabIndex: number; + tabs: { query: string; variables?: string; headers?: string }[]; +}; + +type Selector = (state: GraphiQLState) => T; + +// Mutable state that tests can override. +export const __state = { + schema: null as unknown, + queryText: '{ __typename }', + addTab: () => {}, + updateActiveTabValues(_values: { query?: string; variables?: string; headers?: string }) {}, +}; + +export function useGraphiQL(selector: Selector): T { + return selector({ + schema: __state.schema, + queryEditor: null, + activeTabIndex: 0, + tabs: [{ query: __state.queryText }], + }); +} + +export const useGraphiQLActions = () => ({ + addTab: __state.addTab, + updateActiveTabValues: __state.updateActiveTabValues, +}); + +export const createBoundedUseStore = ((store: StoreApi) => (selector?: (state: unknown) => unknown) => { + return useStore(store, selector ? useShallow(selector) : undefined); +}) as >( + store: S, +) => { + (): ExtractState; + (selector: (state: ExtractState) => T): T; +}; + +export const GraphiQLProvider = ({ children }: { children: ReactNode }) => + children; + +export const PanelHeader = ({ + title: _title, + subtitle: _subtitle, + actions: _actions, +}: { + title: ReactNode; + subtitle?: ReactNode; + actions?: ReactNode; +}) => null; + +export const MethodPill = ({ operation: _operation }: { operation: string }) => + null; + +export const Dialog = Object.assign( + ({ children, open: _open, onOpenChange: _onOpenChange }: { children?: ReactNode; open?: boolean; onOpenChange?: (open: boolean) => void }) => children ?? null, + { + Title: ({ children }: { children: ReactNode }) => children, + Close: ({ children }: { children: ReactNode }) => children, + Description: ({ children }: { children: ReactNode }) => children, + Trigger: ({ children }: { children: ReactNode }) => children, + } +); + +export const DropdownMenu = Object.assign( + ({ children }: { children?: ReactNode }) => children ?? null, + { + Button: ({ children }: { children: ReactNode }) => children, + Content: ({ children }: { children: ReactNode }) => children, + Item: ({ children, onSelect: _onSelect }: { children: ReactNode; onSelect?: () => void }) => children, + Separator: () => null, + } +); + +export const ChevronDownIcon = () => null; +export const ChevronUpIcon = () => null; +export const MagnifyingGlassIcon = () => null; diff --git a/packages/graphiql-plugin-collections/src/index.ts b/packages/graphiql-plugin-collections/src/index.ts new file mode 100644 index 0000000000..b91d65c328 --- /dev/null +++ b/packages/graphiql-plugin-collections/src/index.ts @@ -0,0 +1,2 @@ +// placeholder — populated in commit 3 +export {}; diff --git a/packages/graphiql-plugin-collections/src/test-setup.ts b/packages/graphiql-plugin-collections/src/test-setup.ts new file mode 100644 index 0000000000..0d74b7352a --- /dev/null +++ b/packages/graphiql-plugin-collections/src/test-setup.ts @@ -0,0 +1,7 @@ +import '@testing-library/jest-dom/vitest'; +import { cleanup } from '@testing-library/react'; +import { afterEach } from 'vitest'; + +afterEach(() => { + cleanup(); +}); diff --git a/packages/graphiql-plugin-collections/src/vite-env.d.ts b/packages/graphiql-plugin-collections/src/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/packages/graphiql-plugin-collections/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/graphiql-plugin-collections/tsconfig.json b/packages/graphiql-plugin-collections/tsconfig.json new file mode 100644 index 0000000000..ee54eec68c --- /dev/null +++ b/packages/graphiql-plugin-collections/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../graphiql-react/tsconfig.json" +} diff --git a/packages/graphiql-plugin-collections/vite.config.mts b/packages/graphiql-plugin-collections/vite.config.mts new file mode 100644 index 0000000000..1a9b7729e9 --- /dev/null +++ b/packages/graphiql-plugin-collections/vite.config.mts @@ -0,0 +1,46 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import svgr from 'vite-plugin-svgr'; +import dts from 'vite-plugin-dts'; +import packageJSON from './package.json' with { type: 'json' }; + +export default defineConfig({ + plugins: [ + react(), + svgr({ + svgrOptions: { + titleProp: true, + }, + }), + dts({ + include: ['src/**'], + exclude: ['**/*.spec.{ts,tsx}', '**/__tests__/'], + }), + ], + css: { + transformer: 'lightningcss', + }, + build: { + minify: false, + sourcemap: true, + lib: { + entry: 'src/index.ts', + fileName(_format, entryName) { + const filePath = entryName.replace(/\.svg$/, ''); + return `${filePath}.js`; + }, + formats: ['es'], + cssFileName: 'style', + }, + rollupOptions: { + external: [ + 'react/jsx-runtime', + // Exclude peer dependencies from bundle + ...Object.keys(packageJSON.peerDependencies), + ], + output: { + preserveModules: true, + }, + }, + }, +}); diff --git a/packages/graphiql-plugin-collections/vitest.config.mts b/packages/graphiql-plugin-collections/vitest.config.mts new file mode 100644 index 0000000000..306f939293 --- /dev/null +++ b/packages/graphiql-plugin-collections/vitest.config.mts @@ -0,0 +1,27 @@ +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; +import svgr from 'vite-plugin-svgr'; +import path from 'path'; + +export default defineConfig({ + plugins: [ + react(), + svgr({ + svgrOptions: { + titleProp: true, + }, + }), + ], + resolve: { + alias: { + '@graphiql/react': path.resolve( + __dirname, + 'src/__mocks__/@graphiql/react.ts', + ), + }, + }, + test: { + environment: 'jsdom', + setupFiles: ['./src/test-setup.ts'], + }, +}); From 24e16342303f6ce5487c8f54213b2096cf3d9327 Mon Sep 17 00:00:00 2001 From: Trevor Scheer Date: Wed, 17 Jun 2026 12:42:40 -0700 Subject: [PATCH 02/11] Collections types, storage adapter, and store --- .../src/storage/local-storage.ts | 45 +++ .../src/store.test.ts | 271 ++++++++++++++++++ .../graphiql-plugin-collections/src/store.ts | 163 +++++++++++ .../graphiql-plugin-collections/src/types.ts | 26 ++ 4 files changed, 505 insertions(+) create mode 100644 packages/graphiql-plugin-collections/src/storage/local-storage.ts create mode 100644 packages/graphiql-plugin-collections/src/store.test.ts create mode 100644 packages/graphiql-plugin-collections/src/store.ts create mode 100644 packages/graphiql-plugin-collections/src/types.ts diff --git a/packages/graphiql-plugin-collections/src/storage/local-storage.ts b/packages/graphiql-plugin-collections/src/storage/local-storage.ts new file mode 100644 index 0000000000..45415f8810 --- /dev/null +++ b/packages/graphiql-plugin-collections/src/storage/local-storage.ts @@ -0,0 +1,45 @@ +import type { Collection, CollectionsStorage } from '../types'; + +const DEFAULT_KEY = 'graphiql:collections'; +const VERSION = 1; + +type StorageEnvelope = { + version: number; + collections: Collection[]; +}; + +export function createLocalStorageAdapter(storageKey = DEFAULT_KEY): CollectionsStorage { + return { + async load() { + try { + const raw = window.localStorage.getItem(storageKey); + if (!raw) return []; + const parsed: StorageEnvelope = JSON.parse(raw); + if (parsed.version !== VERSION) { + // Version mismatch: attempt a best-effort migration. + // V1 has the same shape — just update the version stamp and return collections. + // Future migrations go here when VERSION is bumped. + console.warn( + `[graphiql/plugin-collections] Storage version mismatch (found ${parsed.version}, expected ${VERSION}). Attempting to load collections as-is.`, + ); + return Array.isArray(parsed.collections) ? parsed.collections : []; + } + return parsed.collections ?? []; + } catch { + return []; + } + }, + async save(collections) { + try { + window.localStorage.setItem( + storageKey, + JSON.stringify({ version: VERSION, collections }), + ); + } catch { + // Storage quota exceeded or unavailable — silent no-op + } + }, + }; +} + +export const localStorageAdapter = createLocalStorageAdapter(); diff --git a/packages/graphiql-plugin-collections/src/store.test.ts b/packages/graphiql-plugin-collections/src/store.test.ts new file mode 100644 index 0000000000..647c853913 --- /dev/null +++ b/packages/graphiql-plugin-collections/src/store.test.ts @@ -0,0 +1,271 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { collectionsStore } from './store'; +import type { Collection, CollectionsStorage } from './types'; +import { createLocalStorageAdapter } from './storage/local-storage'; + +const getActions = () => collectionsStore.getState().actions; + +const makeStorage = (initial: Collection[] = []): CollectionsStorage => { + let data = [...initial]; + return { + async load() { + return data; + }, + async save(collections) { + data = [...collections]; + }, + }; +}; + +const initialState = { + collections: [] as Collection[], + loaded: false, + storage: makeStorage(), +}; + +beforeEach(() => { + collectionsStore.setState({ + ...initialState, + storage: makeStorage(), + }); +}); + +describe('init', () => { + it('loads collections from storage', async () => { + const col: Collection = { + id: 'col-1', + name: 'Test', + items: [], + createdAt: 1000, + updatedAt: 1000, + }; + const storage = makeStorage([col]); + await getActions().init(storage); + expect(collectionsStore.getState().collections).toEqual([col]); + expect(collectionsStore.getState().loaded).toBe(true); + }); +}); + +describe('createCollection', () => { + it('adds a collection', async () => { + const storage = makeStorage(); + await getActions().init(storage); + const c = getActions().createCollection('My Queries', 'desc'); + expect(c.name).toBe('My Queries'); + expect(c.description).toBe('desc'); + expect(collectionsStore.getState().collections).toHaveLength(1); + expect(collectionsStore.getState().collections[0]).toBe(c); + }); + + it('persists after create', async () => { + const storage = makeStorage(); + const saveSpy = vi.spyOn(storage, 'save'); + await getActions().init(storage); + getActions().createCollection('Test'); + // allow async persist to run + await new Promise(r => setTimeout(r, 0)); + expect(saveSpy).toHaveBeenCalled(); + }); +}); + +describe('deleteCollection', () => { + it('removes a collection by id', async () => { + const storage = makeStorage(); + await getActions().init(storage); + const c = getActions().createCollection('To Delete'); + expect(collectionsStore.getState().collections).toHaveLength(1); + getActions().deleteCollection(c.id); + expect(collectionsStore.getState().collections).toHaveLength(0); + }); +}); + +describe('renameCollection', () => { + it('changes the collection name', async () => { + const storage = makeStorage(); + await getActions().init(storage); + const c = getActions().createCollection('Old Name'); + getActions().renameCollection(c.id, 'New Name'); + const updated = collectionsStore.getState().collections[0]; + expect(updated?.name).toBe('New Name'); + }); +}); + +describe('addItem', () => { + it('adds an item to the correct collection', async () => { + const storage = makeStorage(); + await getActions().init(storage); + const c = getActions().createCollection('Queries'); + const item = getActions().addItem(c.id, { + name: 'Get User', + query: '{ user { id } }', + }); + const col = collectionsStore.getState().collections.find(x => x.id === c.id)!; + expect(col.items).toHaveLength(1); + expect(col.items[0]).toMatchObject({ name: 'Get User', query: '{ user { id } }' }); + expect(item.id).toBeTruthy(); + }); +}); + +describe('updateItem', () => { + it('updates item fields', async () => { + const storage = makeStorage(); + await getActions().init(storage); + const c = getActions().createCollection('Queries'); + const item = getActions().addItem(c.id, { name: 'Old', query: '{ a }' }); + getActions().updateItem(c.id, item.id, { name: 'New', query: '{ b }' }); + const col = collectionsStore.getState().collections.find(x => x.id === c.id)!; + expect(col.items[0]).toMatchObject({ name: 'New', query: '{ b }' }); + }); +}); + +describe('deleteItem', () => { + it('removes an item from a collection', async () => { + const storage = makeStorage(); + await getActions().init(storage); + const c = getActions().createCollection('Queries'); + const item = getActions().addItem(c.id, { name: 'q1', query: '{ a }' }); + getActions().addItem(c.id, { name: 'q2', query: '{ b }' }); + getActions().deleteItem(c.id, item.id); + const col = collectionsStore.getState().collections.find(x => x.id === c.id)!; + expect(col.items).toHaveLength(1); + expect(col.items[0]?.name).toBe('q2'); + }); +}); + +describe('moveItem', () => { + it('reorders within the same collection', async () => { + const storage = makeStorage(); + await getActions().init(storage); + const c = getActions().createCollection('Queries'); + getActions().addItem(c.id, { name: 'a', query: '{ a }' }); + getActions().addItem(c.id, { name: 'b', query: '{ b }' }); + getActions().addItem(c.id, { name: 'c', query: '{ c }' }); + getActions().moveItem(c.id, 0, c.id, 2); + const col = collectionsStore.getState().collections.find(x => x.id === c.id)!; + expect(col.items.map(i => i.name)).toEqual(['b', 'c', 'a']); + }); + + it('moves an item between different collections', async () => { + const storage = makeStorage(); + await getActions().init(storage); + const c1 = getActions().createCollection('C1'); + const c2 = getActions().createCollection('C2'); + const item = getActions().addItem(c1.id, { name: 'item', query: '{ x }' }); + getActions().moveItem(c1.id, 0, c2.id, 0); + const col1 = collectionsStore.getState().collections.find(x => x.id === c1.id)!; + const col2 = collectionsStore.getState().collections.find(x => x.id === c2.id)!; + expect(col1.items).toHaveLength(0); + expect(col2.items).toHaveLength(1); + expect(col2.items[0]?.name).toBe(item.name); + }); + + it('does nothing for an out-of-bounds index', async () => { + const storage = makeStorage(); + await getActions().init(storage); + const c = getActions().createCollection('C1'); + getActions().addItem(c.id, { name: 'a', query: '{ a }' }); + const before = collectionsStore.getState().collections[0]?.items.length; + getActions().moveItem(c.id, 5, c.id, 0); + const after = collectionsStore.getState().collections[0]?.items.length; + expect(before).toBe(after); + }); +}); + +describe('exportCollections', () => { + it('returns valid JSON with version=1', async () => { + const storage = makeStorage(); + await getActions().init(storage); + getActions().createCollection('Test'); + const exported = getActions().exportCollections(); + const parsed = JSON.parse(exported); + expect(parsed.version).toBe(1); + expect(Array.isArray(parsed.collections)).toBe(true); + expect(parsed.collections[0]?.name).toBe('Test'); + }); +}); + +describe('importCollections', () => { + it('merge mode appends without duplicating by id', async () => { + const storage = makeStorage(); + await getActions().init(storage); + const c = getActions().createCollection('Existing'); + const toImport = JSON.stringify({ + version: 1, + collections: [ + { id: c.id, name: 'Duplicate', items: [], createdAt: 0, updatedAt: 0 }, + { id: 'new-id', name: 'New', items: [], createdAt: 0, updatedAt: 0 }, + ], + }); + getActions().importCollections(toImport, 'merge'); + const state = collectionsStore.getState().collections; + expect(state).toHaveLength(2); + // existing kept, duplicate skipped, new added + expect(state.find(c2 => c2.id === c.id)?.name).toBe('Existing'); + expect(state.find(c2 => c2.id === 'new-id')?.name).toBe('New'); + }); + + it('replace mode replaces all collections', async () => { + const storage = makeStorage(); + await getActions().init(storage); + getActions().createCollection('Will be replaced'); + const replacement = JSON.stringify({ + collections: [ + { id: 'imported-1', name: 'Imported', items: [], createdAt: 0, updatedAt: 0 }, + ], + }); + getActions().importCollections(replacement, 'replace'); + const state = collectionsStore.getState().collections; + expect(state).toHaveLength(1); + expect(state[0]?.name).toBe('Imported'); + }); + + it('does nothing on invalid JSON', async () => { + const storage = makeStorage(); + await getActions().init(storage); + getActions().createCollection('Keep me'); + getActions().importCollections('not valid json', 'replace'); + expect(collectionsStore.getState().collections).toHaveLength(1); + }); +}); + +describe('persistence round-trip', () => { + it('save then load restores data', async () => { + let persisted: Collection[] = []; + const storage: CollectionsStorage = { + async load() { return persisted; }, + async save(cols) { persisted = [...cols]; }, + }; + await getActions().init(storage); + getActions().createCollection('Persistent'); + // allow persist + await new Promise(r => setTimeout(r, 0)); + // Reset and reload + collectionsStore.setState({ collections: [], loaded: false, storage }); + await getActions().init(storage); + expect(collectionsStore.getState().collections[0]?.name).toBe('Persistent'); + }); +}); + +describe('createLocalStorageAdapter', () => { + it('version mismatch logs warning but preserves data', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const key = 'test-collections-key'; + // Store with version 99 + window.localStorage.setItem( + key, + JSON.stringify({ + version: 99, + collections: [{ id: 'x', name: 'Saved', items: [], createdAt: 0, updatedAt: 0 }], + }), + ); + const adapter = createLocalStorageAdapter(key); + const result = await adapter.load(); + expect(result).toHaveLength(1); + expect(result[0]?.name).toBe('Saved'); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('version mismatch'), + ); + warnSpy.mockRestore(); + window.localStorage.removeItem(key); + }); +}); diff --git a/packages/graphiql-plugin-collections/src/store.ts b/packages/graphiql-plugin-collections/src/store.ts new file mode 100644 index 0000000000..7ac9878e65 --- /dev/null +++ b/packages/graphiql-plugin-collections/src/store.ts @@ -0,0 +1,163 @@ +import { createStore } from 'zustand'; +import { createBoundedUseStore } from '@graphiql/react'; +import type { Collection, CollectionItem, CollectionsStorage } from './types'; +import { localStorageAdapter } from './storage/local-storage'; + +type CollectionsState = { + collections: Collection[]; + loaded: boolean; + storage: CollectionsStorage; +}; + +type CollectionsActions = { + init(storage: CollectionsStorage): Promise; + createCollection(name: string, description?: string): Collection; + deleteCollection(id: string): void; + renameCollection(id: string, name: string): void; + addItem(collectionId: string, item: Omit): CollectionItem; + updateItem(collectionId: string, itemId: string, updates: Partial>): void; + deleteItem(collectionId: string, itemId: string): void; + moveItem(fromCollectionId: string, fromIndex: number, toCollectionId: string, toIndex: number): void; + importCollections(json: string, mode: 'merge' | 'replace'): void; + exportCollections(): string; +}; + +type StoreShape = CollectionsState & { actions: CollectionsActions }; + +export const collectionsStore = createStore((set, get) => { + const persist = async () => { + await get().storage.save(get().collections); + }; + + return { + collections: [], + loaded: false, + storage: localStorageAdapter, + actions: { + async init(storage) { + const collections = await storage.load(); + set({ collections, loaded: true, storage }); + }, + createCollection(name, description) { + const c: Collection = { + id: crypto.randomUUID(), + name, + description, + items: [], + createdAt: Date.now(), + updatedAt: Date.now(), + }; + set(s => ({ collections: [...s.collections, c] })); + void persist(); + return c; + }, + deleteCollection(id) { + set(s => ({ collections: s.collections.filter(c => c.id !== id) })); + void persist(); + }, + renameCollection(id, name) { + set(s => ({ + collections: s.collections.map(c => + c.id === id ? { ...c, name, updatedAt: Date.now() } : c, + ), + })); + void persist(); + }, + addItem(collectionId, item) { + const newItem: CollectionItem = { + ...item, + id: crypto.randomUUID(), + createdAt: Date.now(), + updatedAt: Date.now(), + }; + set(s => ({ + collections: s.collections.map(c => + c.id === collectionId + ? { ...c, items: [...c.items, newItem], updatedAt: Date.now() } + : c, + ), + })); + void persist(); + return newItem; + }, + updateItem(collectionId, itemId, updates) { + set(s => ({ + collections: s.collections.map(c => + c.id === collectionId + ? { + ...c, + updatedAt: Date.now(), + items: c.items.map(i => + i.id === itemId ? { ...i, ...updates, updatedAt: Date.now() } : i, + ), + } + : c, + ), + })); + void persist(); + }, + deleteItem(collectionId, itemId) { + set(s => ({ + collections: s.collections.map(c => + c.id === collectionId + ? { ...c, items: c.items.filter(i => i.id !== itemId), updatedAt: Date.now() } + : c, + ), + })); + void persist(); + }, + moveItem(fromCollectionId, fromIndex, toCollectionId, toIndex) { + const { collections } = get(); + const fromCollection = collections.find(c => c.id === fromCollectionId); + if (!fromCollection || fromIndex < 0 || fromIndex >= fromCollection.items.length) return; + + const item = fromCollection.items[fromIndex]; + const newCollections = collections.map(c => { + if (c.id === fromCollectionId && c.id === toCollectionId) { + const items = [...c.items]; + items.splice(fromIndex, 1); + items.splice(toIndex, 0, item); + return { ...c, items, updatedAt: Date.now() }; + } + if (c.id === fromCollectionId) { + return { ...c, items: c.items.filter((_, i) => i !== fromIndex), updatedAt: Date.now() }; + } + if (c.id === toCollectionId) { + const items = [...c.items]; + items.splice(toIndex, 0, item); + return { ...c, items, updatedAt: Date.now() }; + } + return c; + }); + set({ collections: newCollections }); + void persist(); + }, + importCollections(json, mode) { + try { + const parsed = JSON.parse(json); + const incoming: Collection[] = Array.isArray(parsed.collections) + ? parsed.collections + : Array.isArray(parsed) + ? parsed + : []; + if (mode === 'replace') { + set({ collections: incoming }); + } else { + const existing = get().collections; + const existingIds = new Set(existing.map(c => c.id)); + const merged = [...existing, ...incoming.filter(c => !existingIds.has(c.id))]; + set({ collections: merged }); + } + void persist(); + } catch { + // invalid JSON — no-op + } + }, + exportCollections() { + return JSON.stringify({ version: 1, collections: get().collections }, null, 2); + }, + }, + }; +}); + +export const useCollectionsStore = createBoundedUseStore(collectionsStore); diff --git a/packages/graphiql-plugin-collections/src/types.ts b/packages/graphiql-plugin-collections/src/types.ts new file mode 100644 index 0000000000..7e364c427f --- /dev/null +++ b/packages/graphiql-plugin-collections/src/types.ts @@ -0,0 +1,26 @@ +export type Collection = { + id: string; + name: string; + description?: string; + createdAt: number; + updatedAt: number; + items: CollectionItem[]; +}; + +export type CollectionItem = { + id: string; + name: string; + query: string; + variables?: string; + headers?: string; + method?: 'GET' | 'POST'; + createdAt: number; + updatedAt: number; +}; + +/** Pluggable storage interface. Default localStorage adapter ships with the plugin. */ +export type CollectionsStorage = { + storageKey?: string; + load(): Promise; + save(collections: Collection[]): Promise; +}; From b2407a50a36a6abdcd8f73d35629f9d2e876ad99 Mon Sep 17 00:00:00 2001 From: Trevor Scheer Date: Wed, 17 Jun 2026 12:44:49 -0700 Subject: [PATCH 03/11] Collections tree UI: panel, rows, save dialog --- .../src/components/collection-item-row.tsx | 123 ++++++++ .../src/components/collection-row.tsx | 114 +++++++ .../src/components/collections-panel.tsx | 98 ++++++ .../src/components/import-export-dialog.tsx | 120 +++++++ .../src/components/save-button.tsx | 42 +++ .../src/components/save-dialog.tsx | 96 ++++++ .../src/icons/collections.svg | 3 + .../graphiql-plugin-collections/src/index.css | 295 ++++++++++++++++++ .../graphiql-plugin-collections/src/index.ts | 27 +- yarn.lock | 24 ++ 10 files changed, 940 insertions(+), 2 deletions(-) create mode 100644 packages/graphiql-plugin-collections/src/components/collection-item-row.tsx create mode 100644 packages/graphiql-plugin-collections/src/components/collection-row.tsx create mode 100644 packages/graphiql-plugin-collections/src/components/collections-panel.tsx create mode 100644 packages/graphiql-plugin-collections/src/components/import-export-dialog.tsx create mode 100644 packages/graphiql-plugin-collections/src/components/save-button.tsx create mode 100644 packages/graphiql-plugin-collections/src/components/save-dialog.tsx create mode 100644 packages/graphiql-plugin-collections/src/icons/collections.svg create mode 100644 packages/graphiql-plugin-collections/src/index.css diff --git a/packages/graphiql-plugin-collections/src/components/collection-item-row.tsx b/packages/graphiql-plugin-collections/src/components/collection-item-row.tsx new file mode 100644 index 0000000000..3a60277811 --- /dev/null +++ b/packages/graphiql-plugin-collections/src/components/collection-item-row.tsx @@ -0,0 +1,123 @@ +import { FC, useState } from 'react'; +import { DropdownMenu } from '@graphiql/react'; +import type { Collection, CollectionItem } from '../types'; + +function inferOperationType(query: string): 'query' | 'mutation' | 'subscription' { + const match = /^\s*(query|mutation|subscription)/i.exec(query); + if (match) { + return match[1].toLowerCase() as 'query' | 'mutation' | 'subscription'; + } + return 'query'; +} + +type CollectionItemRowProps = { + item: CollectionItem; + collectionId: string; + index: number; + totalItems: number; + allCollections: Collection[]; + onOpen(item: CollectionItem): void; + onDelete(collectionId: string, itemId: string): void; + onMove(fromCollectionId: string, fromIndex: number, toCollectionId: string, toIndex: number): void; +}; + +export const CollectionItemRow: FC = ({ + item, + collectionId, + index, + totalItems, + allCollections, + onOpen, + onDelete, + onMove, +}) => { + const [isDragging, setIsDragging] = useState(false); + const [isDragOver, setIsDragOver] = useState(false); + const operationType = inferOperationType(item.query); + + return ( +
{ + setIsDragging(true); + e.dataTransfer.setData( + 'application/x-graphiql-item', + JSON.stringify({ collectionId, index }), + ); + e.dataTransfer.effectAllowed = 'move'; + }} + onDragEnd={() => setIsDragging(false)} + onDragOver={e => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + setIsDragOver(true); + }} + onDragLeave={() => setIsDragOver(false)} + onDrop={e => { + e.preventDefault(); + setIsDragOver(false); + try { + const data = JSON.parse(e.dataTransfer.getData('application/x-graphiql-item')); + onMove(data.collectionId, data.index, collectionId, index); + } catch { + // malformed drag data + } + }} + onClick={() => onOpen(item)} + > + + + {operationType.slice(0, 1).toUpperCase()} + + + {item.name} + + + e.stopPropagation()} + > + ··· + + + onOpen(item)}>Open + {index > 0 && ( + onMove(collectionId, index, collectionId, index - 1)}> + Move up + + )} + {index < totalItems - 1 && ( + onMove(collectionId, index, collectionId, index + 1)}> + Move down + + )} + {allCollections.filter(c => c.id !== collectionId).length > 0 && ( + <> + + {allCollections + .filter(c => c.id !== collectionId) + .map(c => ( + onMove(collectionId, index, c.id, c.items.length)} + > + Move to: {c.name} + + ))} + + )} + + onDelete(collectionId, item.id)}> + Delete + + + +
+ ); +}; diff --git a/packages/graphiql-plugin-collections/src/components/collection-row.tsx b/packages/graphiql-plugin-collections/src/components/collection-row.tsx new file mode 100644 index 0000000000..d1154eb558 --- /dev/null +++ b/packages/graphiql-plugin-collections/src/components/collection-row.tsx @@ -0,0 +1,114 @@ +import { FC, useState } from 'react'; +import { DropdownMenu } from '@graphiql/react'; +import type { Collection, CollectionItem } from '../types'; +import { CollectionItemRow } from './collection-item-row'; + +type CollectionRowProps = { + collection: Collection; + allCollections: Collection[]; + onRename(id: string, name: string): void; + onDelete(id: string): void; + onOpenItem(item: CollectionItem): void; + onDeleteItem(collectionId: string, itemId: string): void; + onMoveItem(fromCollectionId: string, fromIndex: number, toCollectionId: string, toIndex: number): void; + onAddItem(collectionId: string, item: Omit): CollectionItem; +}; + +export const CollectionRow: FC = ({ + collection, + allCollections, + onRename, + onDelete, + onOpenItem, + onDeleteItem, + onMoveItem, +}) => { + const [expanded, setExpanded] = useState(true); + const [isRenaming, setIsRenaming] = useState(false); + const [renameValue, setRenameValue] = useState(collection.name); + + const commitRename = () => { + if (renameValue.trim()) { + onRename(collection.id, renameValue.trim()); + } + setIsRenaming(false); + }; + + return ( +
+
!isRenaming && setExpanded(e => !e)} + role="button" + aria-expanded={expanded} + tabIndex={0} + onKeyDown={e => { + if (e.key === 'Enter' || e.key === ' ') setExpanded(ex => !ex); + }} + > + + {isRenaming ? ( + setRenameValue(e.target.value)} + onBlur={commitRename} + onKeyDown={e => { + if (e.key === 'Enter') commitRename(); + if (e.key === 'Escape') { + setRenameValue(collection.name); + setIsRenaming(false); + } + e.stopPropagation(); + }} + onClick={e => e.stopPropagation()} + /> + ) : ( + {collection.name} + )} + {collection.items.length} + + e.stopPropagation()} + > + ··· + + + { + setRenameValue(collection.name); + setIsRenaming(true); + }}> + Rename + + + onDelete(collection.id)}> + Delete + + + +
+ {expanded && collection.items.length > 0 && ( +
+ {collection.items.map((item, i) => ( + + ))} +
+ )} +
+ ); +}; diff --git a/packages/graphiql-plugin-collections/src/components/collections-panel.tsx b/packages/graphiql-plugin-collections/src/components/collections-panel.tsx new file mode 100644 index 0000000000..9a27943746 --- /dev/null +++ b/packages/graphiql-plugin-collections/src/components/collections-panel.tsx @@ -0,0 +1,98 @@ +import { FC, useEffect, useState } from 'react'; +import { PanelHeader, useGraphiQLActions } from '@graphiql/react'; +import { useCollectionsStore, collectionsStore } from '../store'; +import { CollectionRow } from './collection-row'; +import { SaveDialog } from './save-dialog'; +import { ImportExportDialog } from './import-export-dialog'; +import { localStorageAdapter } from '../storage/local-storage'; +import type { CollectionItem, CollectionsStorage } from '../types'; + +type CollectionsPanelProps = { + storage?: CollectionsStorage; +}; + +export const CollectionsPanel: FC = ({ storage }) => { + const actions = useCollectionsStore(s => s.actions); + const collections = useCollectionsStore(s => s.collections); + const loaded = useCollectionsStore(s => s.loaded); + const [showImportExport, setShowImportExport] = useState(false); + const [showSaveDialog, setShowSaveDialog] = useState(false); + + useEffect(() => { + void actions.init(storage ?? localStorageAdapter); + // storage is intentionally only read on mount + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const { addTab, updateActiveTabValues } = useGraphiQLActions(); + + const handleOpen = (item: CollectionItem) => { + addTab(); + updateActiveTabValues({ + query: item.query, + variables: item.variables ?? '', + headers: item.headers ?? '', + }); + }; + + return ( +
+ + + + + } + /> + {!loaded &&
Loading…
} + {loaded && collections.length === 0 && ( +
+ No collections yet. Save an operation to get started. +
+ )} +
+ {collections.map(collection => ( + + ))} +
+ setShowSaveDialog(false)} + initialQuery="" + initialName="Unnamed operation" + /> + setShowImportExport(false)} + /> +
+ ); +}; diff --git a/packages/graphiql-plugin-collections/src/components/import-export-dialog.tsx b/packages/graphiql-plugin-collections/src/components/import-export-dialog.tsx new file mode 100644 index 0000000000..0b426624d0 --- /dev/null +++ b/packages/graphiql-plugin-collections/src/components/import-export-dialog.tsx @@ -0,0 +1,120 @@ +import { FC, useRef, useState } from 'react'; +import { Dialog } from '@graphiql/react'; +import { collectionsStore } from '../store'; + +type ImportExportDialogProps = { + open: boolean; + onClose(): void; +}; + +export const ImportExportDialog: FC = ({ open, onClose }) => { + const [mode, setMode] = useState<'export' | 'import'>('export'); + const [importMode, setImportMode] = useState<'merge' | 'replace'>('merge'); + const [importError, setImportError] = useState(null); + const fileInputRef = useRef(null); + + const exported = collectionsStore.getState().actions.exportCollections(); + + const handleDownload = () => { + const blob = new Blob([exported], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'graphiql-collections.json'; + a.click(); + URL.revokeObjectURL(url); + }; + + const handleImport = () => { + const file = fileInputRef.current?.files?.[0]; + if (!file) { + setImportError('Please select a file.'); + return; + } + const reader = new FileReader(); + reader.onload = e => { + const text = e.target?.result as string; + try { + JSON.parse(text); + collectionsStore.getState().actions.importCollections(text, importMode); + onClose(); + } catch { + setImportError('Invalid JSON file. Please select a valid collections export.'); + } + }; + reader.readAsText(file); + }; + + return ( + !o && onClose()}> + Import / Export Collections +
+ + +
+ {mode === 'export' && ( +
+