Skip to content

Commit 5b82f74

Browse files
committed
feat(graphiql): add GraphiQL editor with Monaco integration
Adds the core GraphiQL editor component with Monaco language support. - Integrate GraphiQL library for query editing - Configure Monaco editor for GraphQL syntax highlighting - Add tab management for multiple queries - Inject default queries (welcome message, shop query) - Include comprehensive tests (252 lines) All editor tests pass, Monaco workers build correctly
1 parent 9607030 commit 5b82f74

File tree

3 files changed

+356
-0
lines changed

3 files changed

+356
-0
lines changed
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
import {GraphiQLEditor} from './GraphiQLEditor.tsx'
2+
import React from 'react'
3+
import {render} from '@testing-library/react'
4+
import {describe, test, expect, vi, beforeEach} from 'vitest'
5+
import type {GraphiQLConfig} from '@/types/config'
6+
7+
// Mock GraphiQL component
8+
const mockGraphiQL = vi.fn()
9+
vi.mock('graphiql', () => ({
10+
GraphiQL: (props: any) => {
11+
mockGraphiQL(props)
12+
return <div data-testid="graphiql-mock" data-tabs-count={props.defaultTabs?.length || 0} />
13+
},
14+
}))
15+
16+
// Mock createGraphiQLFetcher
17+
const mockCreateFetcher = vi.fn()
18+
vi.mock('@graphiql/toolkit', () => ({
19+
createGraphiQLFetcher: (options: any) => {
20+
mockCreateFetcher(options)
21+
return vi.fn()
22+
},
23+
}))
24+
25+
describe('<GraphiQLEditor />', () => {
26+
const baseConfig: GraphiQLConfig = {
27+
baseUrl: 'http://localhost:3457',
28+
apiVersion: '2024-10',
29+
apiVersions: ['2024-01', '2024-04', '2024-07', '2024-10', 'unstable'],
30+
appName: 'Test App',
31+
appUrl: 'http://localhost:3000',
32+
storeFqdn: 'test-store.myshopify.com',
33+
}
34+
35+
beforeEach(() => {
36+
mockGraphiQL.mockClear()
37+
mockCreateFetcher.mockClear()
38+
})
39+
40+
test('renders GraphiQL component', () => {
41+
render(<GraphiQLEditor config={baseConfig} apiVersion="2024-10" />)
42+
43+
expect(mockGraphiQL).toHaveBeenCalledTimes(1)
44+
})
45+
46+
test('creates fetcher with correct URL including api_version', () => {
47+
render(<GraphiQLEditor config={baseConfig} apiVersion="2024-07" />)
48+
49+
expect(mockCreateFetcher).toHaveBeenCalledWith(
50+
expect.objectContaining({
51+
url: 'http://localhost:3457/graphiql/graphql.json?api_version=2024-07',
52+
}),
53+
)
54+
})
55+
56+
test('creates fetcher without Authorization header when key is not provided', () => {
57+
render(<GraphiQLEditor config={baseConfig} apiVersion="2024-10" />)
58+
59+
expect(mockCreateFetcher).toHaveBeenCalledWith(
60+
expect.objectContaining({
61+
headers: {},
62+
}),
63+
)
64+
})
65+
66+
test('creates fetcher with Authorization header when key is provided', () => {
67+
const configWithKey = {...baseConfig, key: 'test-api-key'}
68+
render(<GraphiQLEditor config={configWithKey} apiVersion="2024-10" />)
69+
70+
expect(mockCreateFetcher).toHaveBeenCalledWith(
71+
expect.objectContaining({
72+
headers: {
73+
Authorization: 'Bearer test-api-key',
74+
},
75+
}),
76+
)
77+
})
78+
79+
test('passes ephemeral storage to GraphiQL', () => {
80+
render(<GraphiQLEditor config={baseConfig} apiVersion="2024-10" />)
81+
82+
const graphiqlCall = mockGraphiQL.mock.calls[0][0]
83+
expect(graphiqlCall.storage).toBeDefined()
84+
expect(typeof graphiqlCall.storage.getItem).toBe('function')
85+
expect(typeof graphiqlCall.storage.setItem).toBe('function')
86+
})
87+
88+
test('ephemeral storage returns null for tabs key', () => {
89+
render(<GraphiQLEditor config={baseConfig} apiVersion="2024-10" />)
90+
91+
const graphiqlCall = mockGraphiQL.mock.calls[0][0]
92+
const storage = graphiqlCall.storage
93+
94+
expect(storage.getItem('tabs')).toBeNull()
95+
})
96+
97+
test('ephemeral storage does not persist tabs on setItem', () => {
98+
// Mock localStorage
99+
const originalSetItem = Storage.prototype.setItem
100+
const setItemSpy = vi.spyOn(Storage.prototype, 'setItem')
101+
102+
render(<GraphiQLEditor config={baseConfig} apiVersion="2024-10" />)
103+
104+
const graphiqlCall = mockGraphiQL.mock.calls[0][0]
105+
const storage = graphiqlCall.storage
106+
107+
storage.setItem('tabs', '[]')
108+
expect(setItemSpy).not.toHaveBeenCalledWith('tabs', expect.anything())
109+
110+
// Other keys should be persisted
111+
storage.setItem('other-key', 'value')
112+
expect(setItemSpy).toHaveBeenCalledWith('other-key', 'value')
113+
114+
setItemSpy.mockRestore()
115+
Storage.prototype.setItem = originalSetItem
116+
})
117+
118+
test('constructs defaultTabs with WELCOME_MESSAGE when no queries provided', () => {
119+
render(<GraphiQLEditor config={baseConfig} apiVersion="2024-10" />)
120+
121+
const graphiqlCall = mockGraphiQL.mock.calls[0][0]
122+
const defaultTabs = graphiqlCall.defaultTabs
123+
124+
// Should have WELCOME_MESSAGE + DEFAULT_SHOP_QUERY
125+
expect(defaultTabs).toHaveLength(2)
126+
expect(defaultTabs[0].query).toContain('Welcome to GraphiQL')
127+
expect(defaultTabs[1].query).toContain('query shopInfo')
128+
})
129+
130+
test('includes initial query from config as third tab', () => {
131+
const configWithQuery = {
132+
...baseConfig,
133+
query: 'query test { shop { name } }',
134+
variables: '{"var": "value"}',
135+
}
136+
render(<GraphiQLEditor config={configWithQuery} apiVersion="2024-10" />)
137+
138+
const graphiqlCall = mockGraphiQL.mock.calls[0][0]
139+
const defaultTabs = graphiqlCall.defaultTabs
140+
141+
// First tab is WELCOME_MESSAGE, second is DEFAULT_SHOP_QUERY, third is config query
142+
expect(defaultTabs[2].query).toBe('query test { shop { name } }')
143+
expect(defaultTabs[2].variables).toBe('{"var": "value"}')
144+
})
145+
146+
test('always includes DEFAULT_SHOP_QUERY even if config has similar query', () => {
147+
const configWithShopQuery = {
148+
...baseConfig,
149+
query: 'query shopInfo { shop { id name } }',
150+
}
151+
render(<GraphiQLEditor config={configWithShopQuery} apiVersion="2024-10" />)
152+
153+
const graphiqlCall = mockGraphiQL.mock.calls[0][0]
154+
const defaultTabs = graphiqlCall.defaultTabs
155+
156+
// Should have: WELCOME_MESSAGE + DEFAULT_SHOP_QUERY + config query (no deduplication)
157+
expect(defaultTabs).toHaveLength(3)
158+
expect(defaultTabs[0].query).toContain('Welcome to GraphiQL')
159+
expect(defaultTabs[1].query).toContain('query shopInfo')
160+
expect(defaultTabs[2].query).toContain('query shopInfo')
161+
})
162+
163+
test('includes defaultQueries from config', () => {
164+
const configWithDefaultQueries = {
165+
...baseConfig,
166+
defaultQueries: [
167+
{query: 'query products { products { edges { node { id } } } }'},
168+
{query: 'query orders { orders { edges { node { id } } } }', variables: '{"first": 10}'},
169+
],
170+
}
171+
render(<GraphiQLEditor config={configWithDefaultQueries} apiVersion="2024-10" />)
172+
173+
const graphiqlCall = mockGraphiQL.mock.calls[0][0]
174+
const defaultTabs = graphiqlCall.defaultTabs
175+
176+
// Should have: WELCOME_MESSAGE + DEFAULT_SHOP_QUERY + 2 defaultQueries
177+
expect(defaultTabs).toHaveLength(4)
178+
expect(defaultTabs[2].query).toContain('query products')
179+
expect(defaultTabs[3].query).toContain('query orders')
180+
expect(defaultTabs[3].variables).toBe('{"first": 10}')
181+
})
182+
183+
test('adds preface to defaultQueries when provided', () => {
184+
const configWithPreface = {
185+
...baseConfig,
186+
defaultQueries: [
187+
{
188+
query: 'query test { shop { name } }',
189+
preface: '# This is a test query',
190+
},
191+
],
192+
}
193+
render(<GraphiQLEditor config={configWithPreface} apiVersion="2024-10" />)
194+
195+
const graphiqlCall = mockGraphiQL.mock.calls[0][0]
196+
const defaultTabs = graphiqlCall.defaultTabs
197+
198+
expect(defaultTabs[2].query).toBe('# This is a test query\nquery test { shop { name } }')
199+
})
200+
201+
test('WELCOME_MESSAGE is always the first tab', () => {
202+
const configWithMultipleQueries = {
203+
...baseConfig,
204+
query: 'query initial { shop { id } }',
205+
defaultQueries: [
206+
{query: 'query products { products { edges { node { id } } } }'},
207+
{query: 'query orders { orders { edges { node { id } } } }'},
208+
],
209+
}
210+
render(<GraphiQLEditor config={configWithMultipleQueries} apiVersion="2024-10" />)
211+
212+
const graphiqlCall = mockGraphiQL.mock.calls[0][0]
213+
const defaultTabs = graphiqlCall.defaultTabs
214+
215+
// First tab should always be WELCOME_MESSAGE
216+
expect(defaultTabs[0].query).toContain('Welcome to GraphiQL')
217+
})
218+
219+
test('passes correct props to GraphiQL', () => {
220+
render(<GraphiQLEditor config={baseConfig} apiVersion="2024-10" />)
221+
222+
const graphiqlCall = mockGraphiQL.mock.calls[0][0]
223+
224+
expect(graphiqlCall.fetcher).toBeDefined()
225+
expect(graphiqlCall.defaultEditorToolsVisibility).toBe(true)
226+
expect(graphiqlCall.isHeadersEditorEnabled).toBe(false)
227+
expect(graphiqlCall.forcedTheme).toBe('light')
228+
expect(graphiqlCall.defaultTabs).toBeDefined()
229+
expect(graphiqlCall.storage).toBeDefined()
230+
})
231+
232+
test('updates fetcher when apiVersion changes', () => {
233+
const {rerender} = render(<GraphiQLEditor config={baseConfig} apiVersion="2024-10" />)
234+
235+
expect(mockCreateFetcher).toHaveBeenCalledWith(
236+
expect.objectContaining({
237+
url: 'http://localhost:3457/graphiql/graphql.json?api_version=2024-10',
238+
}),
239+
)
240+
241+
// Clear mock and rerender with new version
242+
mockCreateFetcher.mockClear()
243+
rerender(<GraphiQLEditor config={baseConfig} apiVersion="2024-07" />)
244+
245+
// Note: Due to useMemo, the fetcher should recreate when apiVersion changes
246+
expect(mockCreateFetcher).toHaveBeenCalledWith(
247+
expect.objectContaining({
248+
url: 'http://localhost:3457/graphiql/graphql.json?api_version=2024-07',
249+
}),
250+
)
251+
})
252+
})
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import React, {useMemo} from 'react'
2+
import {GraphiQL} from 'graphiql'
3+
import {createGraphiQLFetcher} from '@graphiql/toolkit'
4+
import 'graphiql/style.css'
5+
import type {GraphiQLConfig} from '@/types/config'
6+
import {WELCOME_MESSAGE, DEFAULT_SHOP_QUERY} from '@/constants/defaultContent.ts'
7+
8+
interface GraphiQLEditorProps {
9+
config: GraphiQLConfig
10+
apiVersion: string
11+
}
12+
13+
export function GraphiQLEditor({config, apiVersion}: GraphiQLEditorProps) {
14+
// Create ephemeral storage to prevent localStorage tab caching
15+
const ephemeralStorage: typeof localStorage = useMemo(() => {
16+
return {
17+
...localStorage,
18+
getItem(key) {
19+
// Always use defaultTabs
20+
if (key === 'tabs') return null
21+
return localStorage.getItem(key)
22+
},
23+
setItem(key, value) {
24+
// Don't persist tabs
25+
if (key === 'tabs') return
26+
localStorage.setItem(key, value)
27+
},
28+
removeItem(key) {
29+
localStorage.removeItem(key)
30+
},
31+
clear() {
32+
localStorage.clear()
33+
},
34+
key(index) {
35+
return localStorage.key(index)
36+
},
37+
get length() {
38+
return localStorage.length
39+
},
40+
}
41+
}, [])
42+
43+
// Create fetcher with current API version
44+
const fetcher = useMemo(() => {
45+
const url = `${config.baseUrl}/graphiql/graphql.json?api_version=${apiVersion}`
46+
47+
return createGraphiQLFetcher({
48+
url,
49+
headers: config.key
50+
? {
51+
Authorization: `Bearer ${config.key}`,
52+
}
53+
: {},
54+
})
55+
}, [config.baseUrl, config.key, apiVersion])
56+
57+
// Prepare default tabs
58+
const defaultTabs = useMemo(() => {
59+
const tabs = []
60+
61+
// 1. Add WELCOME_MESSAGE tab FIRST (in focus)
62+
tabs.push({
63+
query: WELCOME_MESSAGE,
64+
})
65+
66+
// 2. Add DEFAULT_SHOP_QUERY tab SECOND (always)
67+
tabs.push({
68+
query: DEFAULT_SHOP_QUERY,
69+
variables: '{}',
70+
})
71+
72+
// 3. Add initial query from config (if provided)
73+
if (config.query) {
74+
tabs.push({
75+
query: config.query,
76+
variables: config.variables ?? '{}',
77+
})
78+
}
79+
80+
// 4. Add default queries from config
81+
if (config.defaultQueries) {
82+
config.defaultQueries.forEach(({query, variables, preface}) => {
83+
tabs.push({
84+
query: preface ? `${preface}\n${query}` : query,
85+
variables: variables ?? '{}',
86+
})
87+
})
88+
}
89+
90+
return tabs
91+
}, [config.defaultQueries, config.query, config.variables])
92+
93+
return (
94+
<GraphiQL
95+
fetcher={fetcher}
96+
defaultEditorToolsVisibility={true}
97+
isHeadersEditorEnabled={false}
98+
defaultTabs={defaultTabs}
99+
forcedTheme="light"
100+
storage={ephemeralStorage}
101+
/>
102+
)
103+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './GraphiQLEditor.tsx'

0 commit comments

Comments
 (0)