Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/collections-plugin.md
Original file line number Diff line number Diff line change
@@ -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`).
5 changes: 5 additions & 0 deletions .changeset/graphiql-collections-default-install.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions packages/graphiql-plugin-collections/README.md
Original file line number Diff line number Diff line change
@@ -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
```
62 changes: 62 additions & 0 deletions packages/graphiql-plugin-collections/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
107 changes: 107 additions & 0 deletions packages/graphiql-plugin-collections/src/__mocks__/@graphiql/react.ts
Original file line number Diff line number Diff line change
@@ -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<T> = (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<T>(selector: Selector<T>): 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<unknown>) =>
(selector?: (state: unknown) => unknown) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
return useStore(store, selector ? useShallow(selector) : (s: unknown) => s);
}) as <S extends StoreApi<unknown>>(
store: S,
) => {
(): ExtractState<S>;
<T>(selector: (state: ExtractState<S>) => 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;
Original file line number Diff line number Diff line change
@@ -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<CollectionItemRowProps> = ({
item,
collectionId,
index,
totalItems,
allCollections,
onOpen,
onDelete,
onMove,
}) => {
const [isDragging, setIsDragging] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const operationType = inferOperationType(item.query);

return (
<div
className={`graphiql-collection-item-row${isDragOver ? ' graphiql-collection-drop-target' : ''}`}
aria-grabbed={isDragging || undefined}
draggable
onDragStart={e => {
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)}
>
<span className="graphiql-collection-drag-handle" aria-hidden="true">
</span>
<MethodPill operation={operationType} aria-hidden />
<span className="graphiql-collection-item-name" title={item.name}>
{item.name}
</span>
<DropdownMenu>
<DropdownMenu.Button
className="graphiql-collection-item-menu"
aria-label={`Actions for ${item.name}`}
onClick={e => e.stopPropagation()}
>
···
</DropdownMenu.Button>
<DropdownMenu.Content>
<DropdownMenu.Item onSelect={() => onOpen(item)}>
Open
</DropdownMenu.Item>
{index > 0 && (
<DropdownMenu.Item
onSelect={() =>
onMove(collectionId, index, collectionId, index - 1)
}
>
Move up
</DropdownMenu.Item>
)}
{index < totalItems - 1 && (
<DropdownMenu.Item
onSelect={() =>
onMove(collectionId, index, collectionId, index + 1)
}
>
Move down
</DropdownMenu.Item>
)}
{allCollections.some(c => c.id !== collectionId) && (
<>
<DropdownMenu.Separator />
{allCollections
.filter(c => c.id !== collectionId)
.map(c => (
<DropdownMenu.Item
key={c.id}
onSelect={() =>
onMove(collectionId, index, c.id, c.items.length)
}
>
Move to: {c.name}
</DropdownMenu.Item>
))}
</>
)}
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={() => onDelete(collectionId, item.id)}>
Delete
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
</div>
);
};
Loading
Loading