diff --git a/.changeset/collections-plugin.md b/.changeset/collections-plugin.md new file mode 100644 index 0000000000..6df54688d0 --- /dev/null +++ b/.changeset/collections-plugin.md @@ -0,0 +1,5 @@ +--- +'@graphiql/plugin-collections': minor +--- + +New `@graphiql/plugin-collections` plugin. Save and organize named operations into folder collections. Includes a collapsible tree UI, save-current-operation dialog, open-into-new-tab, drag-and-drop reorder with keyboard fallback, and JSON import/export. Persistence is pluggable via the `storage` option (defaults to `localStorage`). diff --git a/.changeset/graphiql-collections-default-install.md b/.changeset/graphiql-collections-default-install.md new file mode 100644 index 0000000000..6bd37240cc --- /dev/null +++ b/.changeset/graphiql-collections-default-install.md @@ -0,0 +1,5 @@ +--- +'graphiql': minor +--- + +The collections plugin (`@graphiql/plugin-collections`) is now included by default. A new "Collections" rail icon appears out of the box for saving and organizing operations. Passing the `plugins` prop opts out of the default set as before. 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..9a0def3a48 --- /dev/null +++ b/packages/graphiql-plugin-collections/src/__mocks__/@graphiql/react.ts @@ -0,0 +1,107 @@ +// Minimal stub of @graphiql/react for unit/integration tests. +import type { ReactNode } from 'react'; + +export type Operation = 'query' | 'mutation' | 'subscription'; +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) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + return useStore(store, selector ? useShallow(selector) : (s: unknown) => s); + }) 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/components/collection-item-row.tsx b/packages/graphiql-plugin-collections/src/components/collection-item-row.tsx new file mode 100644 index 0000000000..3bcd03a0b6 --- /dev/null +++ b/packages/graphiql-plugin-collections/src/components/collection-item-row.tsx @@ -0,0 +1,141 @@ +import { FC, useState } from 'react'; +import { DropdownMenu, MethodPill } from '@graphiql/react'; +import type { Operation } from '@graphiql/react'; +import type { Collection, CollectionItem } from '../types'; + +function inferOperationType(query: string): Operation { + const match = /^\s*(query|mutation|subscription)/i.exec(query); + const keyword = match?.[1]?.toLowerCase(); + if (keyword === 'mutation' || keyword === 'subscription') { + return keyword; + } + 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)} + > + + + + {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.some(c => c.id !== collectionId) && ( + <> + + {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..bf4a4a41a5 --- /dev/null +++ b/packages/graphiql-plugin-collections/src/components/collection-row.tsx @@ -0,0 +1,130 @@ +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..6688104222 --- /dev/null +++ b/packages/graphiql-plugin-collections/src/components/collections-panel.tsx @@ -0,0 +1,108 @@ +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 ?? '', + }); + }; + + const handleRename = actions.renameCollection; + const handleDelete = actions.deleteCollection; + const handleDeleteItem = actions.deleteItem; + const handleMoveItem = actions.moveItem; + const handleAddItem = actions.addItem; + + 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..cf62487ea9 --- /dev/null +++ b/packages/graphiql-plugin-collections/src/components/import-export-dialog.tsx @@ -0,0 +1,135 @@ +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' && ( +
+