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 (