From f8cc6c21bd987724a0169a52a162438c57ea8ae0 Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Fri, 1 May 2026 16:16:26 +0800 Subject: [PATCH] [FE-2075] feat(studio): bump graphiql to v5 and use prebuilt component (#45404) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `graphiql@5.2.2` and switches from our heavily-customised rebuild (which used `@graphiql/react` + `@graphiql/toolkit` directly) to the prebuilt component, restyled to match the dashboard. Role impersonation re-added as a sidebar plugin. This is a deliberately simpler setup than what we had – we lose some layout customisation (sidebar is forced to the left, role impersonation moves into the sidebar) but future upgrades become much easier since we're no longer maintaining a fork-by-rewrite. **Removed:** - `apps/studio/components/interfaces/GraphQL/GraphiQL.tsx` – custom rebuild - `apps/studio/components/interfaces/GraphQL/graphiql.module.css` – custom styles **Changed:** - Added `graphiql` ^5.2.2 (we previously didn't have the top-level package, just the subpackages) - `@graphiql/react` ^0.19.4 → ^0.37.3 (now Monaco-based; v0.19 was still on CodeMirror 5) - `@graphiql/toolkit` ^0.9.1 → ^0.11.3 - `GraphiQLTab.tsx` now wires up the prebuilt `` with worker setup, theme bridge, and plugins - New `graphiql.module.css` scopes restyling via `:global(...)` since we can't add hashed classes to the library's DOM - `RoleImpersonationSelector` gained an `orientation: 'horizontal' | 'vertical'` prop (default `horizontal`) so it fits in the sidebar pane – all existing call sites unchanged - `MonacoThemeProvider` exports `getTheme` so the GraphQL Monaco instance can reuse Studio's theme **Added:** - Theme bridge: `supabase-graphql-dark` / `supabase-graphql-light` Monaco themes synced with `next-themes` via `forcedTheme` - Role impersonation sidebar plugin (gated on `field.jwt_secret` read permission, same as before) ### Notes / tradeoffs - We don't share Studio's monaco instance – Studio loads it via AMD/CDN, GraphiQL bundles it as ESM. Both end up on `monaco-editor@0.52.2` but in different module systems. Sharing would require ripping out Studio's CDN loader (Studio-wide refactor, out of scope). GraphiQL's monaco is dynamically imported and only loads when the GraphQL tab opens. - The dark/light response panel uses different `--graphiql-response-bg` tokens because the editor sits at very different baseline lightness in each theme; a single token can't lift it meaningfully in both directions. - Session header (tabs row) is hidden – we don't expose multi-tab workflows. ## To test - Open `/project//api/graphiql` in both light and dark themes – editor + response panel backgrounds, sidebar borders, button radii should all match the dashboard - Run a query and confirm syntax highlighting works (GraphQL-specific token `argument.identifier.gql` is purple) - Open the doc explorer and history sidebar plugins - As a user with `field.jwt_secret` read permission: open the Role Impersonation sidebar plugin, pick a role, confirm subsequent queries hit the API with the impersonated JWT - As a user without that permission: confirm the Role Impersonation plugin doesn't appear, history still does - Toggle theme while GraphiQL is open – Monaco theme should swap without a reload ## Summary by CodeRabbit * **New Features** * Vertical layout option for the role impersonation selector; radios can expand to full width. * **Improvements** * Revamped GraphiQL integration with updated upstream package, plugins, and editor theming for improved consistency and UX. * New GraphiQL styling and layout for clearer pane separation and polished controls. * Role selector radios now support a full-width mode for improved responsiveness. * **Chores** * Updated GraphiQL-related dependencies. --------- Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com> --- .../interfaces/App/MonacoThemeProvider.tsx | 2 +- .../interfaces/GraphQL/GraphiQL.tsx | 489 ------------------ .../interfaces/GraphQL/graphiql.module.css | 65 --- .../Integrations/GraphQL/GraphiQLTab.tsx | 69 ++- .../Integrations/GraphQL/graphiql.module.css | 104 ++++ .../RoleImpersonationRadio.tsx | 5 +- .../RoleImpersonationSelector/index.tsx | 10 +- apps/studio/package.json | 5 +- pnpm-lock.yaml | 464 ++++++++--------- 9 files changed, 413 insertions(+), 800 deletions(-) delete mode 100644 apps/studio/components/interfaces/GraphQL/GraphiQL.tsx delete mode 100644 apps/studio/components/interfaces/GraphQL/graphiql.module.css create mode 100644 apps/studio/components/interfaces/Integrations/GraphQL/graphiql.module.css diff --git a/apps/studio/components/interfaces/App/MonacoThemeProvider.tsx b/apps/studio/components/interfaces/App/MonacoThemeProvider.tsx index 92b9fb6e0f8..5c693fc449e 100644 --- a/apps/studio/components/interfaces/App/MonacoThemeProvider.tsx +++ b/apps/studio/components/interfaces/App/MonacoThemeProvider.tsx @@ -2,7 +2,7 @@ import { useMonaco } from '@monaco-editor/react' import { useTheme } from 'next-themes' import { useMemo } from 'react' -const getTheme = (theme: string) => { +export const getTheme = (theme: string) => { const isDarkMode = theme.includes('dark') // [TODO] Probably need better theming for light mode return { diff --git a/apps/studio/components/interfaces/GraphQL/GraphiQL.tsx b/apps/studio/components/interfaces/GraphQL/GraphiQL.tsx deleted file mode 100644 index 5039cb49713..00000000000 --- a/apps/studio/components/interfaces/GraphQL/GraphiQL.tsx +++ /dev/null @@ -1,489 +0,0 @@ -/* Based on https://github.com/graphql/graphiql/blob/main/packages/graphiql/src/components/GraphiQL.tsx */ - -import { - ChevronDownIcon, - ChevronUpIcon, - CopyIcon, - ExecuteButton, - GraphiQLProvider, - HeaderEditor, - MergeIcon, - PlusIcon, - PrettifyIcon, - QueryEditor, - ReloadIcon, - ResponseEditor, - Spinner, - Tab, - Tabs, - ToolbarButton, - Tooltip, - UnStyledButton, - useCopyQuery, - useDragResize, - useEditorContext, - useExecutionContext, - useMergeQuery, - usePluginContext, - usePrettifyEditors, - useSchemaContext, - useTheme, - VariableEditor, -} from '@graphiql/react' -import { Fetcher } from '@graphiql/toolkit' -import { PermissionAction } from '@supabase/shared-types/out/constants' -import { LOCAL_STORAGE_KEYS } from 'common' -import { AlertTriangle, XIcon } from 'lucide-react' -import { MouseEventHandler, useCallback, useEffect, useState } from 'react' -import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, Button, cn } from 'ui' - -import { RoleImpersonationSelector } from '../RoleImpersonationSelector' -import styles from './graphiql.module.css' -import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' -import { useLocalStorage } from '@/hooks/misc/useLocalStorage' - -export interface GraphiQLProps { - fetcher: Fetcher - theme?: 'dark' | 'light' -} - -export default function GraphiQL({ fetcher, theme = 'dark' }: GraphiQLProps) { - // Ensure props are correct - if (typeof fetcher !== 'function') { - throw new TypeError( - 'The `GraphiQL` component requires a `fetcher` function to be passed as prop.' - ) - } - - return ( - - - - ) -} - -interface GraphiQLInterfaceProps { - theme: 'dark' | 'light' -} - -const GraphiQLInterface = ({ theme }: GraphiQLInterfaceProps) => { - const editorContext = useEditorContext({ nonNull: true }) - const executionContext = useExecutionContext({ nonNull: true }) - const schemaContext = useSchemaContext({ nonNull: true }) - const pluginContext = usePluginContext() - - const copy = useCopyQuery() - const merge = useMergeQuery() - const prettify = usePrettifyEditors() - - const { can: canReadJWTSecret } = useAsyncCheckPermissions( - PermissionAction.READ, - 'field.jwt_secret' - ) - - const [rlsBypassedWarningDismissed, setRlsBypassedWarningDismissed] = useLocalStorage( - LOCAL_STORAGE_KEYS.GRAPHIQL_RLS_BYPASS_WARNING, - false - ) - - const { setTheme } = useTheme() - useEffect(() => { - setTheme(theme) - }, [theme]) - - const PluginContent = pluginContext?.visiblePlugin?.content - - const pluginResize = useDragResize({ - defaultSizeRelation: 1 / 3, - direction: 'horizontal', - initiallyHidden: pluginContext?.visiblePlugin ? undefined : 'second', - onHiddenElementChange: (resizableElement) => { - if (resizableElement === 'second') { - pluginContext?.setVisiblePlugin(null) - } - }, - sizeThresholdSecond: 200, - storageKey: 'docExplorerFlex', - }) - const editorResize = useDragResize({ - direction: 'horizontal', - storageKey: 'editorFlex', - }) - const editorToolsResize = useDragResize({ - defaultSizeRelation: 3, - direction: 'vertical', - initiallyHidden: (() => { - return editorContext.initialVariables || editorContext.initialHeaders ? undefined : 'second' - })(), - sizeThresholdSecond: 60, - storageKey: 'secondaryEditorFlex', - }) - - const [activeSecondaryEditor, setActiveSecondaryEditor] = useState< - 'variables' | 'headers' | 'role-impersonation' - >(() => { - return !editorContext.initialVariables && editorContext.initialHeaders ? 'headers' : 'variables' - }) - - const toolbar = ( - <> - - - - - - - - ) - - const onClickReference = useCallback(() => { - if (pluginResize.hiddenElement === 'second') { - pluginResize.setHiddenElement(null) - } - }, [pluginResize]) - - const handleAddTab = editorContext.addTab - const handleRefetchSchema = schemaContext.introspect - const handleReorder = editorContext.moveTab - - const handlePluginClick: MouseEventHandler = useCallback( - (e) => { - const context = pluginContext! - const pluginIndex = Number(e.currentTarget.dataset.index!) - const plugin = context.plugins.find((_, index) => pluginIndex === index)! - const isVisible = plugin === context.visiblePlugin - if (isVisible) { - context.setVisiblePlugin(null) - pluginResize.setHiddenElement('second') - } else { - context.setVisiblePlugin(plugin) - pluginResize.setHiddenElement(null) - } - }, - [pluginContext, pluginResize] - ) - - const handleToolsTabClick: MouseEventHandler = useCallback( - (event) => { - if (editorToolsResize.hiddenElement === 'second') { - editorToolsResize.setHiddenElement(null) - } - setActiveSecondaryEditor( - event.currentTarget.dataset.name as 'variables' | 'headers' | 'role-impersonation' - ) - }, - [editorToolsResize] - ) - - const toggleEditorTools: MouseEventHandler = useCallback(() => { - editorToolsResize.setHiddenElement( - editorToolsResize.hiddenElement === 'second' ? null : 'second' - ) - }, [editorToolsResize]) - - const addTab = ( - - - - - ) - - const hasSingleTab = editorContext.tabs.length === 1 - - return ( - -
-
-
-
- - {editorContext.tabs.length > 1 && ( - <> - {editorContext.tabs.map((tab, index) => ( - - { - executionContext.stop() - editorContext.changeTab(index) - }} - > - {tab.title} - - { - if (editorContext.activeTabIndex === index) { - executionContext.stop() - } - editorContext.closeTab(index) - }} - /> - - ))} - {addTab} - - )} - -
- {hasSingleTab &&
{addTab}
} -
-
-
-
-
-
-
- -
- - {toolbar} -
-
-
- -
-
- - Variables - - - - Headers - - - {canReadJWTSecret && ( - - Role Impersonation - - )} - - - - {editorToolsResize.hiddenElement === 'second' ? ( - - -
-
- -
-
- - - - - {canReadJWTSecret && ( -
- -
- )} -
-
-
-
- -
- -
-
- {executionContext.isFetching ? : null} - - - {!rlsBypassedWarningDismissed && ( - - - - Please note that queries and mutations run in GraphiQL now use the service - role key by default. -
- RLS will be bypassed. -
- - You can send queries as a specific role/user by using the role impersonation - tab. - - -
- )} -
-
-
-
- {pluginContext?.visiblePlugin && ( -
- )} -
-
{PluginContent ? : null}
-
-
-
-
- {pluginContext?.plugins.map((plugin, index) => { - const isVisible = plugin === pluginContext.visiblePlugin - const label = `${isVisible ? 'Hide' : 'Show'} ${plugin.title}` - const Icon = plugin.icon - return ( - - - - - ) - })} -
-
- - - - -
-
-
- - ) -} diff --git a/apps/studio/components/interfaces/GraphQL/graphiql.module.css b/apps/studio/components/interfaces/GraphQL/graphiql.module.css deleted file mode 100644 index 11a08708240..00000000000 --- a/apps/studio/components/interfaces/GraphQL/graphiql.module.css +++ /dev/null @@ -1,65 +0,0 @@ -@reference "../../../styles/main.css"; - -.graphiqlContainer .graphiqlSessions { - margin: 0; - border-radius: 0; -} - -.graphiqlContainer .graphiqlSession { - padding: 0; -} - -.graphiqlContainer .graphiqlEditors { - border-radius: 0; -} - -.graphiqlContainer .graphiqlEditors:global(.full-height) { - margin-top: calc(0px - var(--session-header-height)); -} - -.graphiqlContainer .graphiqlQueryEditor { - @apply border-b border-default; -} - -.graphiqlContainer .graphiqlSessionHeader { - background-color: hsl(var(--color-base)); - @apply border-b border-default; -} - -.graphiqlContainer .graphiqlResponseSingleTab { - margin-top: calc(24px - var(--session-header-height)); -} - -.graphiqlContainer .graphiqlResponseMultiTab { - padding-top: 16px; -} - -.graphiqlContainer .graphiqlSidebar { - @apply border-l border-default; -} - -.graphiqlHorizontalDragBar { - @apply border-l border-default; -} - -.graphiqlAddTabWrapper { - padding: var(--px-12); - @apply z-10; -} - -/* Variables */ - -:global(body.graphiql-dark) .graphiqlContainer { - --color-base: 0, 0%, 11%; - --color-primary: 153, 50%, 50%; -} - -:global(body.graphiql-light) .graphiqlContainer { - --color-base: 210, 17%, 98%; - --color-primary: 153, 50%, 50%; -} - -.graphiqlContainer { - --font-family: theme(fontFamily.sans); - --font-family-mono: theme(fontFamily.mono); -} diff --git a/apps/studio/components/interfaces/Integrations/GraphQL/GraphiQLTab.tsx b/apps/studio/components/interfaces/Integrations/GraphQL/GraphiQLTab.tsx index 882d7b9afbf..4f353b8700b 100644 --- a/apps/studio/components/interfaces/Integrations/GraphQL/GraphiQLTab.tsx +++ b/apps/studio/components/interfaces/Integrations/GraphQL/GraphiQLTab.tsx @@ -1,19 +1,59 @@ -import '@graphiql/react/dist/style.css' +import 'graphiql/style.css' +import 'graphiql/setup-workers/webpack' +import { useMonaco, type GraphiQLPlugin } from '@graphiql/react' import { createGraphiQLFetcher, Fetcher } from '@graphiql/toolkit' +import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' +import { GraphiQL, HISTORY_PLUGIN } from 'graphiql' +import { User as IconUser } from 'lucide-react' import { useTheme } from 'next-themes' -import { useMemo } from 'react' +import { useEffect, useMemo } from 'react' import { toast } from 'sonner' import { LogoLoader } from 'ui' -import GraphiQL from '@/components/interfaces/GraphQL/GraphiQL' +import styles from './graphiql.module.css' +import { getTheme } from '@/components/interfaces/App/MonacoThemeProvider' +import { RoleImpersonationSelector } from '@/components/interfaces/RoleImpersonationSelector' import { useSessionAccessTokenQuery } from '@/data/auth/session-access-token-query' import { useProjectPostgrestConfigQuery } from '@/data/config/project-postgrest-config-query' +import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { API_URL, IS_PLATFORM } from '@/lib/constants' import { getRoleImpersonationJWT } from '@/lib/role-impersonation' import { useGetImpersonatedRoleState } from '@/state/role-impersonation-state' +const ROLE_IMPERSONATION_PLUGIN: GraphiQLPlugin = { + title: 'Role Impersonation', + icon: () => , + content: () => , +} + +const MONACO_THEME = { dark: 'supabase-graphql-dark', light: 'supabase-graphql-light' } + +const GraphiQLMonacoTheme = ({ resolvedTheme }: { resolvedTheme: 'dark' | 'light' }) => { + const { monaco } = useMonaco() + + useEffect(() => { + if (!monaco) return + const dark = getTheme('dark') + const light = getTheme('light') + monaco.editor.defineTheme(MONACO_THEME.dark, { + ...dark, + rules: [...dark.rules, { token: 'argument.identifier.gql', foreground: '908aff' }], + }) + monaco.editor.defineTheme(MONACO_THEME.light, { + ...light, + rules: [...light.rules, { token: 'argument.identifier.gql', foreground: '6c69ce' }], + // Match the dashboard's bg-default in light mode so the editor doesn't read + // as a darker square against the surrounding UI. + colors: { ...light.colors, 'editor.background': '#fcfcfc' }, + }) + monaco.editor.setTheme(MONACO_THEME[resolvedTheme]) + }, [monaco, resolvedTheme]) + + return null +} + export const GraphiQLTab = () => { const { resolvedTheme } = useTheme() const { ref: projectRef } = useParams() @@ -25,6 +65,16 @@ export const GraphiQLTab = () => { const getImpersonatedRoleState = useGetImpersonatedRoleState() + const { can: canReadJWTSecret } = useAsyncCheckPermissions( + PermissionAction.READ, + 'field.jwt_secret' + ) + + const plugins = useMemo( + () => (canReadJWTSecret ? [HISTORY_PLUGIN, ROLE_IMPERSONATION_PLUGIN] : [HISTORY_PLUGIN]), + [canReadJWTSecret] + ) + const fetcher = useMemo(() => { const fetcherFn = createGraphiQLFetcher({ // [Joshen] Opting to hard code /platform for local to match the routes, so that it's clear what's happening @@ -72,5 +122,16 @@ export const GraphiQLTab = () => { return } - return + return ( + <> + + + + ) } diff --git a/apps/studio/components/interfaces/Integrations/GraphQL/graphiql.module.css b/apps/studio/components/interfaces/Integrations/GraphQL/graphiql.module.css new file mode 100644 index 00000000000..e6c351cf871 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/GraphQL/graphiql.module.css @@ -0,0 +1,104 @@ +@reference "../../../../styles/main.css"; + +.root :global(.graphiql-sidebar) { + @apply border-r border-default; + overflow-x: hidden; +} + +.root :global(.graphiql-sidebar [data-value='settings']), +.root :global(.graphiql-sidebar [data-value='short-keys']) { + display: none; +} + +.root :global(.graphiql-sessions) { + @apply m-0; +} + +.root :global(#graphiql-session) { + @apply p-0; +} + +.root :global(.graphiql-session-header) { + display: none; +} + +.root :global(.graphiql-horizontal-drag-bar) { + @apply border-l border-default; +} + +.root :global(.graphiql-horizontal-drag-bar:hover::after) { + @apply rounded-full; +} + +.root :global(.graphiql-plugin) { + border-left: 0; +} + +/* Lift the response panel above the editor surface so the two read as separate panes. + * The token differs per theme because the editor sits at a different baseline lightness + * in each (~12% L in dark, ~94% L in light), so a single surface token can't lift it + * meaningfully in both directions. */ +.root :global(.graphiql-response), +.root :global(.graphiql-response .monaco-editor), +.root :global(.graphiql-response .monaco-editor .margin), +.root :global(.graphiql-response .monaco-editor .monaco-editor-background) { + background-color: var(--graphiql-response-bg) !important; +} + +/* Editor-to-response drag bar: blend it into the response side so it doesn't read as + * a third color sandwiched between the two panes. The plugin-to-main drag bar is left + * alone — it sits next to the editor surface and our default border there is fine. */ +.root :global(.graphiql-editors + .graphiql-horizontal-drag-bar) { + background-color: var(--graphiql-response-bg); + border-left: 0; +} + +.root :global(.graphiql-button), +.root :global(button.graphiql-button), +.root :global(.graphiql-un-styled), +.root :global(button.graphiql-un-styled), +.root :global(button.graphiql-execute-button) { + @apply rounded-md; +} + +.root :global(.graphiql-button-group) { + @apply rounded-lg; +} + +.root :global(.graphiql-doc-explorer-search), +.root :global(.graphiql-doc-explorer-search-input) { + @apply rounded-md; +} + +.root :global(.graphiql-doc-explorer-search [role='listbox']) { + @apply rounded-b-md; +} + +/* Theme variable overrides */ + +.root { + --font-family: theme(fontFamily.sans); + --font-family-mono: theme(fontFamily.mono); + --border-radius-2: 0; + --border-radius-4: 0; + --border-radius-8: 0; + --border-radius-12: 0; + --popover-box-shadow: none; +} + +:global(body.graphiql-dark) .root { + /* HSL form of #1f1f1f, matches the editor.background returned by getTheme('dark') */ + --color-base: 0, 0%, 12%; + --color-primary: 153, 50%, 50%; + --graphiql-response-bg: hsl(var(--background-surface-300)); +} + +:global(body.graphiql-light) .root { + /* HSL form of #fcfcfc, matches the dashboard bg-default and the editor.background + * we override to in GraphiQLTab.tsx so the editor reads as part of the surrounding UI. */ + --color-base: 0, 0%, 98.8%; + --color-primary: 153, 50%, 50%; + /* Drop the response below the editor in light mode (inverse of dark) so it reads as + * the lower / "data" surface rather than the lifted one. */ + --graphiql-response-bg: hsl(var(--background-surface-300)); +} diff --git a/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationRadio.tsx b/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationRadio.tsx index e8ba1f0fb31..e176e4ccfb9 100644 --- a/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationRadio.tsx +++ b/apps/studio/components/interfaces/RoleImpersonationSelector/RoleImpersonationRadio.tsx @@ -8,6 +8,7 @@ export interface RoleImpersonationRadioProps { isSelected: boolean | 'partially' onSelectedChange: (value: T) => void icon?: React.ReactNode + fullWidth?: boolean } export function RoleImpersonationRadio({ @@ -17,11 +18,13 @@ export function RoleImpersonationRadio({ isSelected, onSelectedChange, icon, + fullWidth = false, }: RoleImpersonationRadioProps) { return (