diff --git a/packages/graphiql-console/src/App.tsx b/packages/graphiql-console/src/App.tsx index 0e6b1cb2542..c2a4746a640 100644 --- a/packages/graphiql-console/src/App.tsx +++ b/packages/graphiql-console/src/App.tsx @@ -1,12 +1,16 @@ +import {GraphiQLSection} from './sections/GraphiQL/index.ts' + import React from 'react' import {AppProvider} from '@shopify/polaris' import '@shopify/polaris/build/esm/styles.css' +import 'graphiql/style.css' +import 'graphiql/setup-workers/vite' function App() { return ( -
GraphiQL Console
+
) } diff --git a/packages/graphiql-console/src/sections/GraphiQL/GraphiQL.module.scss b/packages/graphiql-console/src/sections/GraphiQL/GraphiQL.module.scss new file mode 100644 index 00000000000..a2f5a09fd5a --- /dev/null +++ b/packages/graphiql-console/src/sections/GraphiQL/GraphiQL.module.scss @@ -0,0 +1,127 @@ +.Container { + display: flex; + flex-direction: column; + height: 100vh; + width: 100%; +} + +.ErrorBanner { + position: sticky; + top: 0; + z-index: 100; +} + +.Header { + display: grid; + grid-template-columns: 1fr; // Single column on narrow screens + align-items: center; + padding: 16px; + border-bottom: 1px solid var(--p-color-border, #e1e3e5); + background: #ffffff; + gap: 16px; +} + +.LeftSection { + display: flex; + align-items: center; + gap: 16px; + flex-wrap: wrap; +} + +.RightSection { + justify-self: start; // Left-align by default (single column on narrow screens) +} + +// Right-align only on wide screens with two-column layout +@media only screen and (min-width: 1081px) { + .RightSection { + justify-self: end; + } +} + +.StatusSection { + display: flex; + align-items: center; + gap: 8px; +} + +.ControlsSection { + display: flex; + align-items: center; + gap: 8px; +} + +.ScopesNote { + display: inline-flex; + align-items: center; + height: 100%; +} + +.LinksSection { + display: flex; + align-items: center; + gap: 8px; + + a { + line-height: 0; + + &:hover .Polaris-Text--root { + text-decoration: underline; + } + + span.Polaris-Text--root { + max-width: max(12vw, 150px); + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + } +} + +.GraphiQLContainer { + flex-grow: 1; + overflow: hidden; + + // Ensure GraphiQL component fills container + > div { + height: 100%; + } +} + +// Icon shrinking utility +.with-shrunk-icon .Polaris-Icon { + height: 1rem; + width: 1rem; + margin: 0.125rem; +} + +// Responsive design +@media only screen and (max-width: 1550px) { + .StatusSection, + .ControlsSection, + .LinksSection { + // Hide labels like "Status:", "API version:", "Store:", "App:" + :global(.top-bar-section-title) { + display: none; + } + } +} + +@media only screen and (max-width: 1150px) { + .LinksSection a span.Polaris-Text--root { + max-width: max(12vw, 140px); + } +} + +// Switch to two-column layout at wider screens +@media only screen and (min-width: 1081px) { + .Header { + grid-template-columns: 7fr 5fr; + } +} + +@media only screen and (max-width: 650px) { + .LinksSection a span.Polaris-Text--root { + max-width: 17vw; + } +} diff --git a/packages/graphiql-console/src/sections/GraphiQL/GraphiQL.test.tsx b/packages/graphiql-console/src/sections/GraphiQL/GraphiQL.test.tsx new file mode 100644 index 00000000000..2703fdaadd7 --- /dev/null +++ b/packages/graphiql-console/src/sections/GraphiQL/GraphiQL.test.tsx @@ -0,0 +1,219 @@ +import {GraphiQLSection} from './GraphiQL.tsx' +import React from 'react' +import {render, screen, fireEvent} from '@testing-library/react' +import {describe, test, expect, vi, beforeEach} from 'vitest' +import {AppProvider} from '@shopify/polaris' +import type {ServerStatus} from '@/hooks/useServerStatus' + +// Mock the hooks +const mockUseServerStatus = vi.fn() +vi.mock('@/hooks/useServerStatus', () => ({ + useServerStatus: (options: any) => mockUseServerStatus(options), +})) + +// Mock child components +vi.mock('@/components/StatusBadge/StatusBadge.tsx', () => ({ + StatusBadge: ({status}: {status: ServerStatus}) =>
{JSON.stringify(status)}
, +})) + +vi.mock('@/components/ErrorBanner/ErrorBanner.tsx', () => ({ + ErrorBanner: ({isVisible}: {isVisible: boolean}) => ( +
+ ErrorBanner +
+ ), +})) + +vi.mock('@/components/LinkPills/LinkPills.tsx', () => ({ + LinkPills: ({status}: {status: ServerStatus}) =>
{JSON.stringify(status)}
, +})) + +vi.mock('@/components/ApiVersionSelector/ApiVersionSelector.tsx', () => ({ + ApiVersionSelector: ({ + versions, + value, + onChange, + }: { + versions: string[] + value: string + onChange: (version: string) => void + }) => ( +
+ +
+ ), +})) + +vi.mock('@/components/GraphiQLEditor/GraphiQLEditor.tsx', () => ({ + GraphiQLEditor: ({config, apiVersion}: {config: any; apiVersion: string}) => ( +
+ {JSON.stringify(config)} +
+ ), +})) + +// Helper to wrap components in AppProvider +function renderWithProvider(element: React.ReactElement) { + return render({element}) +} + +describe('', () => { + beforeEach(() => { + // Reset mocks before each test + + // Default mock implementation + mockUseServerStatus.mockReturnValue({ + serverIsLive: true, + appIsInstalled: true, + storeFqdn: 'test-store.myshopify.com', + appName: 'Test App', + appUrl: 'http://localhost:3000', + }) + + // Mock window.__GRAPHIQL_CONFIG__ + ;(window as any).__GRAPHIQL_CONFIG__ = undefined + }) + + test('renders all child components', () => { + renderWithProvider() + + expect(screen.getByTestId('status-badge')).toBeDefined() + expect(screen.getByTestId('link-pills')).toBeDefined() + expect(screen.getByTestId('api-version-selector')).toBeDefined() + expect(screen.getByTestId('graphiql-editor')).toBeDefined() + }) + + test('ErrorBanner visible when serverIsLive=false', () => { + mockUseServerStatus.mockReturnValue({ + serverIsLive: false, + appIsInstalled: true, + }) + + renderWithProvider() + const errorBanner = screen.getByTestId('error-banner') + + expect(errorBanner).toBeDefined() + expect(errorBanner.getAttribute('data-visible')).toBe('true') + }) + + test('ErrorBanner not rendered when serverIsLive=true', () => { + mockUseServerStatus.mockReturnValue({ + serverIsLive: true, + appIsInstalled: true, + }) + + renderWithProvider() + + // ErrorBanner should not be in DOM when server is live + expect(screen.queryByTestId('error-banner')).toBeNull() + }) + + test('passes correct props to StatusBadge', () => { + const mockStatus: ServerStatus = { + serverIsLive: true, + appIsInstalled: true, + storeFqdn: 'test-store.myshopify.com', + appName: 'Test App', + appUrl: 'http://localhost:3000', + } + mockUseServerStatus.mockReturnValue(mockStatus) + + renderWithProvider() + const statusBadge = screen.getByTestId('status-badge') + + expect(statusBadge).toBeDefined() + expect(statusBadge.textContent).toContain('"serverIsLive":true') + expect(statusBadge.textContent).toContain('"appIsInstalled":true') + }) + + test('passes correct props to LinkPills', () => { + const mockStatus: ServerStatus = { + serverIsLive: true, + appIsInstalled: true, + storeFqdn: 'test-store.myshopify.com', + appName: 'Test App', + appUrl: 'http://localhost:3000', + } + mockUseServerStatus.mockReturnValue(mockStatus) + + renderWithProvider() + const linkPills = screen.getByTestId('link-pills') + + expect(linkPills).toBeDefined() + expect(linkPills.textContent).toContain('test-store.myshopify.com') + }) + + test('getConfig() reads window.__GRAPHIQL_CONFIG__', () => { + const customConfig = { + baseUrl: 'http://localhost:4000', + apiVersion: '2023-01', + apiVersions: ['2023-01'], + appName: 'Custom App', + appUrl: 'http://localhost:3000', + storeFqdn: 'custom.myshopify.com', + } + + ;(window as any).__GRAPHIQL_CONFIG__ = customConfig + + renderWithProvider() + const editor = screen.getByTestId('graphiql-editor') + + expect(editor).toBeDefined() + const editorConfig = JSON.parse(editor.textContent ?? '{}') + expect(editorConfig.baseUrl).toBe('http://localhost:4000') + expect(editorConfig.appName).toBe('Custom App') + + // Cleanup + ;(window as any).__GRAPHIQL_CONFIG__ = undefined + }) + + test('getConfig() falls back to defaults in development', () => { + // Ensure no global config + ;(window as any).__GRAPHIQL_CONFIG__ = undefined + + renderWithProvider() + const editor = screen.getByTestId('graphiql-editor') + + expect(editor).toBeDefined() + const editorConfig = JSON.parse(editor.textContent ?? '{}') + + // Should have default values + expect(editorConfig.apiVersion).toBe('2024-10') + expect(editorConfig.apiVersions).toEqual(['2024-01', '2024-04', '2024-07', '2024-10', 'unstable']) + }) + + test('ApiVersionSelector receives correct versions and value', () => { + renderWithProvider() + const selector = screen.getByTestId('api-version-selector') + + expect(selector).toBeDefined() + expect(selector.getAttribute('data-versions')).toBe('2024-01,2024-04,2024-07,2024-10,unstable') + expect(selector.getAttribute('data-value')).toBe('2024-10') + }) + + test('calls useServerStatus with correct baseUrl', () => { + renderWithProvider() + + expect(mockUseServerStatus).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: expect.any(String), + }), + ) + }) + + test('version selection updates GraphiQL editor', () => { + renderWithProvider() + + // Initial state + let editor = screen.getByTestId('graphiql-editor') + expect(editor.getAttribute('data-api-version')).toBe('2024-10') + + // Trigger version change + const button = screen.getByText('Change Version') + fireEvent.click(button) + + // Re-find after state update + editor = screen.getByTestId('graphiql-editor') + expect(editor.getAttribute('data-api-version')).toBe('new-version') + }) +}) diff --git a/packages/graphiql-console/src/sections/GraphiQL/GraphiQL.tsx b/packages/graphiql-console/src/sections/GraphiQL/GraphiQL.tsx new file mode 100644 index 00000000000..e655b16dc79 --- /dev/null +++ b/packages/graphiql-console/src/sections/GraphiQL/GraphiQL.tsx @@ -0,0 +1,92 @@ +import * as styles from './GraphiQL.module.scss' +import React, {useState, useMemo} from 'react' +import {Text} from '@shopify/polaris' +import type {GraphiQLConfig} from '@/types/config.ts' +import {useServerStatus} from '@/hooks/useServerStatus.ts' +import {StatusBadge} from '@/components/StatusBadge/StatusBadge.tsx' +import {ErrorBanner} from '@/components/ErrorBanner/ErrorBanner.tsx' +import {LinkPills} from '@/components/LinkPills/LinkPills.tsx' +import {ApiVersionSelector} from '@/components/ApiVersionSelector/ApiVersionSelector.tsx' +import {GraphiQLEditor} from '@/components/GraphiQLEditor/GraphiQLEditor.tsx' +import {validateConfig} from '@/utils/configValidation.ts' + +// Helper to get config from window or fallback to env/defaults +// Security: Validates window.__GRAPHIQL_CONFIG__ to prevent XSS attacks +function getConfig(): GraphiQLConfig { + // Fallback config for development + const fallbackConfig: GraphiQLConfig = { + baseUrl: import.meta.env.VITE_GRAPHIQL_BASE_URL ?? 'http://localhost:3457', + apiVersion: '2024-10', + apiVersions: ['2024-01', '2024-04', '2024-07', '2024-10', 'unstable'], + appName: 'Development App', + appUrl: 'http://localhost:3000', + storeFqdn: 'test-store.myshopify.com', + } + + if (typeof window !== 'undefined' && window.__GRAPHIQL_CONFIG__) { + // SECURITY: Validate and sanitize config before use + return validateConfig(window.__GRAPHIQL_CONFIG__, fallbackConfig) + } + + return fallbackConfig +} + +export function GraphiQLSection() { + const config = useMemo(() => getConfig(), []) + const [selectedVersion, setSelectedVersion] = useState(config.apiVersion) + + const status = useServerStatus({baseUrl: config.baseUrl}) + + const handleVersionChange = (version: string) => { + setSelectedVersion(version) + // GraphiQL component (Track 6) will use this version in fetcher URL + } + + return ( +
+ {/* Error banner - shown when server disconnects */} + {!status.serverIsLive && ( +
+ +
+ )} + +
+
+
+ +
+ +
+ +
+ +
+
+ +
+ +
+
+ +
+
+ + GraphiQL runs on the same access scopes you've defined in the TOML file for your app. + +
+
+
+ +
+ +
+
+ ) +} diff --git a/packages/graphiql-console/src/sections/GraphiQL/index.ts b/packages/graphiql-console/src/sections/GraphiQL/index.ts new file mode 100644 index 00000000000..66528cdf05f --- /dev/null +++ b/packages/graphiql-console/src/sections/GraphiQL/index.ts @@ -0,0 +1 @@ +export {GraphiQLSection} from './GraphiQL.tsx'