From 50eea124e7220f689294751de65955038ed16ceb Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Fri, 1 May 2026 10:18:20 +0800 Subject: [PATCH 001/172] Small tweaks to feature preview badge (#45409) ## Context Small improvements from this PR: https://github.com/supabase/supabase/pull/45373 - Fix feature preview badge alignment - Before: image - After: image - Shift feature preview badge for policies into tester side panel image - Realised that advisor settings wasn't set up to be behind the feature preview - Fixing that in this PR ## Summary by CodeRabbit * **New Features** * Added preview badge indicator to the RLS Tester feature * **Style** * Improved spacing and layout alignment across authentication, database access, webhook, logging, and advisor interface components * Enhanced badge component styling for better vertical alignment --- .../interfaces/Auth/RLSTester/RLSTesterSheet.tsx | 7 ++++++- .../Platform/Webhooks/PlatformWebhooksHeader.tsx | 2 +- .../JitDatabaseAccess/JitDbAccessConfiguration.tsx | 2 +- .../components/interfaces/UnifiedLogs/UnifiedLogs.tsx | 2 +- .../layouts/AdvisorsLayout/AdvisorRulesLayout.tsx | 2 +- .../layouts/AdvisorsLayout/AdvisorsMenu.utils.tsx | 10 ++++++---- .../layouts/AdvisorsLayout/AdvisorsSidebarMenu.tsx | 7 +++---- apps/studio/components/ui/FeaturePreviewBadge.tsx | 2 +- apps/studio/pages/project/[ref]/auth/policies.tsx | 10 +--------- .../pages/project/[ref]/database/column-privileges.tsx | 2 +- apps/studio/pages/project/[ref]/merge.tsx | 2 +- 11 files changed, 23 insertions(+), 25 deletions(-) diff --git a/apps/studio/components/interfaces/Auth/RLSTester/RLSTesterSheet.tsx b/apps/studio/components/interfaces/Auth/RLSTester/RLSTesterSheet.tsx index cf9a96e6769..81b64d27ec9 100644 --- a/apps/studio/components/interfaces/Auth/RLSTester/RLSTesterSheet.tsx +++ b/apps/studio/components/interfaces/Auth/RLSTester/RLSTesterSheet.tsx @@ -8,6 +8,7 @@ import { SelectTrigger, SelectValue, } from '@ui/components/shadcn/ui/select' +import { LOCAL_STORAGE_KEYS } from 'common' import { Code } from 'lucide-react' import { useEffect, useState } from 'react' import { @@ -32,6 +33,7 @@ import { RoleSelector } from './RoleSelector' import { UserSelector } from './UserSelector' import { useTestQueryRLS } from './useTestQueryRLS' import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor' +import { FeaturePreviewBadge } from '@/components/ui/FeaturePreviewBadge' import { useRoleImpersonationStateSnapshot } from '@/state/role-impersonation-state' interface RLSTesterSheetProps { @@ -87,7 +89,10 @@ export const RLSTesterSheet = ({ handleSelectEditPolicy }: RLSTesterSheetProps) - What data can my users see? + + What data can my users see? + + See what data a user is allowed to read based on your RLS policies diff --git a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksHeader.tsx b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksHeader.tsx index 6af68fbd45d..24306c1ef42 100644 --- a/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksHeader.tsx +++ b/apps/studio/components/interfaces/Platform/Webhooks/PlatformWebhooksHeader.tsx @@ -62,7 +62,7 @@ export const PlatformWebhooksHeader = ({ - + {headerTitle} {hasSelectedEndpoint && endpointStatus && ( { - + Temporary access diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx index ebcce0e9a5f..4bcacd95016 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx @@ -409,7 +409,7 @@ export const UnifiedLogs = () => {
-
+
diff --git a/apps/studio/components/layouts/AdvisorsLayout/AdvisorRulesLayout.tsx b/apps/studio/components/layouts/AdvisorsLayout/AdvisorRulesLayout.tsx index 288b7e86524..a1132003e60 100644 --- a/apps/studio/components/layouts/AdvisorsLayout/AdvisorRulesLayout.tsx +++ b/apps/studio/components/layouts/AdvisorsLayout/AdvisorRulesLayout.tsx @@ -15,7 +15,7 @@ export const AdvisorRulesLayout = ({ children }: PropsWithChildren<{}>) => { + Advisor Settings {isAdvisorRulesEnabled && ( diff --git a/apps/studio/components/layouts/AdvisorsLayout/AdvisorsMenu.utils.tsx b/apps/studio/components/layouts/AdvisorsLayout/AdvisorsMenu.utils.tsx index 3e968a14780..d99e24ff76b 100644 --- a/apps/studio/components/layouts/AdvisorsLayout/AdvisorsMenu.utils.tsx +++ b/apps/studio/components/layouts/AdvisorsLayout/AdvisorsMenu.utils.tsx @@ -1,11 +1,13 @@ +import { useParams } from 'common' import { ArrowUpRight } from 'lucide-react' +import { useIsAdvisorRulesEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' import type { ProductMenuGroup } from '@/components/ui/ProductMenu/ProductMenu.types' -import type { Project } from '@/data/projects/project-detail-query' import { IS_PLATFORM } from '@/lib/constants' -export const generateAdvisorsMenu = (project?: Project): ProductMenuGroup[] => { - const ref = project?.ref ?? 'default' +export const useGenerateAdvisorsMenu = (): ProductMenuGroup[] => { + const { ref } = useParams() + const isAdvisorRulesEnabled = useIsAdvisorRulesEnabled() return [ { @@ -32,7 +34,7 @@ export const generateAdvisorsMenu = (project?: Project): ProductMenuGroup[] => { }, ], }, - ...(IS_PLATFORM + ...(IS_PLATFORM && isAdvisorRulesEnabled ? [ { title: 'Configuration', diff --git a/apps/studio/components/layouts/AdvisorsLayout/AdvisorsSidebarMenu.tsx b/apps/studio/components/layouts/AdvisorsLayout/AdvisorsSidebarMenu.tsx index 0d735950391..e5334a9f415 100644 --- a/apps/studio/components/layouts/AdvisorsLayout/AdvisorsSidebarMenu.tsx +++ b/apps/studio/components/layouts/AdvisorsLayout/AdvisorsSidebarMenu.tsx @@ -1,10 +1,9 @@ import { Badge, Button } from 'ui' import { FeaturePreviewSidebarPanel } from '../../ui/FeaturePreviewSidebarPanel' -import { generateAdvisorsMenu } from './AdvisorsMenu.utils' +import { useGenerateAdvisorsMenu } from './AdvisorsMenu.utils' import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' import { ProductMenu } from '@/components/ui/ProductMenu' -import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' interface AdvisorsSidebarMenuProps { @@ -12,7 +11,7 @@ interface AdvisorsSidebarMenuProps { } export function AdvisorsSidebarMenu({ page }: AdvisorsSidebarMenuProps) { - const { data: project } = useSelectedProjectQuery() + const menu = useGenerateAdvisorsMenu() const { toggleSidebar } = useSidebarManagerSnapshot() const handleOpenAdvisor = () => { @@ -33,7 +32,7 @@ export function AdvisorsSidebarMenu({ page }: AdvisorsSidebarMenuProps) { } /> - +
) } diff --git a/apps/studio/components/ui/FeaturePreviewBadge.tsx b/apps/studio/components/ui/FeaturePreviewBadge.tsx index 45a0c454b13..89118121c90 100644 --- a/apps/studio/components/ui/FeaturePreviewBadge.tsx +++ b/apps/studio/components/ui/FeaturePreviewBadge.tsx @@ -15,7 +15,7 @@ export const FeaturePreviewBadge = ({ featureKey, className }: FeaturePreviewBad ) : ( - - Create ensure_rls trigger - + setHasCreated(true)} /> )}
) } + +const CreateEnsureRLSTriggerDialog = ({ onCreateSuccess }: { onCreateSuccess: () => void }) => { + const track = useTrack() + const { data: project } = useSelectedProjectQuery() + + const [open, setOpen] = useState(false) + + const { can: canCreateTriggers } = useAsyncCheckPermissions( + PermissionAction.TENANT_SQL_ADMIN_WRITE, + 'triggers' + ) + + const { mutate: createEventTrigger, isPending: isCreating } = + useDatabaseEventTriggerCreateMutation({ + onSuccess: () => { + toast.success( + 'Successfully set up database trigger to automatically enable RLS on all new tables' + ) + onCreateSuccess() + setOpen(false) + }, + }) + + const handleCreateTrigger = () => { + if (!project) return + track('rls_event_trigger_banner_create_button_clicked') + createEventTrigger({ + projectRef: project.ref, + connectionString: project.connectionString, + sql: AUTO_ENABLE_RLS_EVENT_TRIGGER_SQL, + }) + } + + return ( + + + + Learn more + + + + + Automatically enable RLS for newly created tables + Secure your data using Postgres Row Level Security + + + + + +

+ Tables in exposed schemas (default being the{' '} + public schema) are accessible to anyone. + Hence, we highly recommend enabling RLS on all such tables. +

+

+ You can set up a database trigger to enable RLS automatically on all new tables with the + following SQL: +

+
+ + + {AUTO_ENABLE_RLS_EVENT_TRIGGER_SQL.trim()} + + + + + + +
+
+ ) +} From 61c6c51b81bd625d38bb4cff4e3c99179430eba3 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Fri, 1 May 2026 14:56:19 +1000 Subject: [PATCH 003/172] feat(studio): improve keyboard shortcuts reference (#45352) ## What kind of change does this PR introduce? Feature improvement to the Studio keyboard shortcuts reference and command palette behaviour. ## What is the current behavior? The keyboard shortcuts sheet does not support filtering, some shortcut labels are harder to scan at a glance, and the command palette shows "Show all keyboard shortcuts" before the more contextual shortcuts in the `Shortcuts` section. ## What is the new behavior? Adds live filtering to the keyboard shortcuts sheet, keeps the sheet width stable on small breakpoints, renders arrow-based shortcuts more compactly, and moves "Show all keyboard shortcuts" to the end of the `Shortcuts` section so contextual actions appear first. https://github.com/user-attachments/assets/315a1a36-0cfb-4a0d-b6de-ef3c86aa9a05 ## Summary by CodeRabbit * **New Features** * Added search for keyboard shortcuts with live filtering, group-aware results, clear-search action, and empty-state handling * Added arrow key symbols for clearer shortcut visuals * **Improvements** * Updated shortcut visuals and typography for a tighter, pill-style presentation * Improved command menu ordering so shortcut-related entries appear in a logical sequence * **Tests** * Added tests covering shortcut search behavior, display formatting, and platform-specific key rendering --- .../ShortcutsReferenceSheet.test.tsx | 97 +++++++++++++ .../ShortcutsReferenceSheet.tsx | 127 ++++++++++++++---- .../state/shortcuts/useShortcut.test.tsx | 29 +++- apps/studio/state/shortcuts/useShortcut.tsx | 14 +- .../KeyboardShortcut.test.tsx | 48 +++++++ .../KeyboardShortcut/KeyboardShortcut.tsx | 14 +- 6 files changed, 301 insertions(+), 28 deletions(-) create mode 100644 apps/studio/components/ui/GlobalShortcuts/ShortcutsReferenceSheet.test.tsx create mode 100644 packages/ui/src/components/KeyboardShortcut/KeyboardShortcut.test.tsx diff --git a/apps/studio/components/ui/GlobalShortcuts/ShortcutsReferenceSheet.test.tsx b/apps/studio/components/ui/GlobalShortcuts/ShortcutsReferenceSheet.test.tsx new file mode 100644 index 00000000000..9f52cba6611 --- /dev/null +++ b/apps/studio/components/ui/GlobalShortcuts/ShortcutsReferenceSheet.test.tsx @@ -0,0 +1,97 @@ +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' + +import { ShortcutsReferenceSheet } from './ShortcutsReferenceSheet' +import { SHORTCUT_DEFINITIONS } from '@/state/shortcuts/registry' +import { customRender } from '@/tests/lib/custom-render' + +const NAVIGATION_LABELS = Object.values(SHORTCUT_DEFINITIONS) + .filter((definition) => definition.id.startsWith('nav.')) + .map((definition) => definition.label) + +const renderShortcutsReferenceSheet = () => { + const onOpenChange = vi.fn() + + customRender() + + return { onOpenChange } +} + +describe('ShortcutsReferenceSheet', () => { + it('renders the grouped shortcut list by default', async () => { + renderShortcutsReferenceSheet() + + expect(await screen.findByText('Keyboard shortcuts')).toBeInTheDocument() + expect(screen.getByLabelText('Search shortcuts')).toBeInTheDocument() + expect(screen.getByText('Command Menu')).toBeInTheDocument() + expect(screen.getByText('Navigation')).toBeInTheDocument() + expect(screen.getByText('Open command menu')).toBeInTheDocument() + expect(screen.getByText('Go to Project Overview')).toBeInTheDocument() + }) + + it('shows every shortcut in a group when the group label matches the search', async () => { + const user = userEvent.setup() + + renderShortcutsReferenceSheet() + + await user.type(screen.getByLabelText('Search shortcuts'), 'navigation') + + expect(screen.getByText('Navigation')).toBeInTheDocument() + expect(screen.queryByText('Command Menu')).not.toBeInTheDocument() + + for (const label of NAVIGATION_LABELS) { + expect(screen.getByText(label)).toBeInTheDocument() + } + }) + + it('keeps the parent group header when only an item label matches', async () => { + const user = userEvent.setup() + + renderShortcutsReferenceSheet() + + await user.type(screen.getByLabelText('Search shortcuts'), 'Go to Organization Integrations') + + expect(screen.getByText('Navigation')).toBeInTheDocument() + expect(screen.getByText('Go to Organization Integrations')).toBeInTheDocument() + expect(screen.queryByText('Go to Logs')).not.toBeInTheDocument() + expect(screen.queryByText('Command Menu')).not.toBeInTheDocument() + }) + + it('shows a clear button when searching and resets the list when clicked', async () => { + const user = userEvent.setup() + + renderShortcutsReferenceSheet() + + await user.type(screen.getByLabelText('Search shortcuts'), 'navigation') + + expect(screen.getByRole('button', { name: 'Clear search' })).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Clear search' })) + + expect(screen.getByLabelText('Search shortcuts')).toHaveValue('') + expect(screen.getByText('Command Menu')).toBeInTheDocument() + expect(screen.getByText('Navigation')).toBeInTheDocument() + }) + + it.each(['⌘Esc', 'Mod+/'])('does not search shortcut values like %s', async (query) => { + const user = userEvent.setup() + + renderShortcutsReferenceSheet() + + await user.type(screen.getByLabelText('Search shortcuts'), query) + + expect(screen.getByText('No matching shortcuts found')).toBeInTheDocument() + expect(screen.queryByText('Navigation')).not.toBeInTheDocument() + }) + + it('shows an empty state when nothing matches', async () => { + const user = userEvent.setup() + + renderShortcutsReferenceSheet() + + await user.type(screen.getByLabelText('Search shortcuts'), 'totally missing') + + expect(screen.getByText('No matching shortcuts found')).toBeInTheDocument() + }) +}) diff --git a/apps/studio/components/ui/GlobalShortcuts/ShortcutsReferenceSheet.tsx b/apps/studio/components/ui/GlobalShortcuts/ShortcutsReferenceSheet.tsx index c6be87f40f7..9eadf66acb2 100644 --- a/apps/studio/components/ui/GlobalShortcuts/ShortcutsReferenceSheet.tsx +++ b/apps/studio/components/ui/GlobalShortcuts/ShortcutsReferenceSheet.tsx @@ -1,5 +1,16 @@ -import { Fragment } from 'react' -import { KeyboardShortcut, Sheet, SheetContent, SheetHeader, SheetSection, SheetTitle } from 'ui' +import { CircleX } from 'lucide-react' +import { Fragment, useEffect, useState } from 'react' +import { + Button, + KeyboardShortcut, + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetSection, + SheetTitle, +} from 'ui' +import { Input } from 'ui-patterns/DataInputs/Input' import { hotkeyToKeys } from '@/state/shortcuts/formatShortcut' import { SHORTCUT_DEFINITIONS } from '@/state/shortcuts/registry' @@ -10,6 +21,12 @@ interface ShortcutsReferenceSheetProps { onOpenChange: (open: boolean) => void } +interface ShortcutGroup { + group: string + label: string + definitions: ShortcutDefinition[] +} + const GROUP_LABELS: Record = { 'action-bar': 'Actions', 'ai-assistant': 'AI Assistant', @@ -43,7 +60,11 @@ const getGroupOrder = (group: string) => { return index === -1 ? GROUP_ORDER.length : index } -const groupDefinitions = (): Array<{ group: string; definitions: ShortcutDefinition[] }> => { +const getGroupLabel = (group: string) => GROUP_LABELS[group] ?? group + +const normalizeSearchValue = (value: string) => value.trim().toLowerCase() + +const groupDefinitions = (): ShortcutGroup[] => { const grouped = Object.values(SHORTCUT_DEFINITIONS).reduce>( (acc, definition) => { const prefix = definition.id.split('.')[0] @@ -57,50 +78,108 @@ const groupDefinitions = (): Array<{ group: string; definitions: ShortcutDefinit return Object.entries(grouped) .map(([group, definitions]) => ({ group, + label: getGroupLabel(group), definitions, })) .sort((a, b) => getGroupOrder(a.group) - getGroupOrder(b.group)) } +const filterGroups = (groups: ShortcutGroup[], search: string) => { + const normalizedSearch = normalizeSearchValue(search) + + if (normalizedSearch.length === 0) return groups + + return groups.reduce((acc, group) => { + if (normalizeSearchValue(group.label).includes(normalizedSearch)) { + acc.push(group) + return acc + } + + const definitions = group.definitions.filter((definition) => + normalizeSearchValue(definition.label).includes(normalizedSearch) + ) + + if (definitions.length > 0) { + acc.push({ ...group, definitions }) + } + + return acc + }, []) +} + const ShortcutSequence = ({ sequence }: Pick) => (
{sequence.map((step, index) => ( {index > 0 && then} - + ))}
) export function ShortcutsReferenceSheet({ open, onOpenChange }: ShortcutsReferenceSheetProps) { - const groups = groupDefinitions() + const [search, setSearch] = useState('') + const groups = filterGroups(groupDefinitions(), search) + + useEffect(() => { + if (!open) setSearch('') + }, [open]) return ( - + Keyboard shortcuts + + Browse and search available keyboard shortcuts. + +
+ setSearch(event.target.value)} + placeholder="Search shortcuts..." + value={search} + actions={ + search ? ( +
- {groups.map(({ group, definitions }) => ( -
-

- {GROUP_LABELS[group] ?? group} -

-
    - {definitions.map((definition) => ( -
  • - {definition.label} - -
  • - ))} -
-
- ))} + {groups.length === 0 ? ( +

No matching shortcuts found

+ ) : ( + groups.map(({ group, label, definitions }) => ( +
+

+ {label} +

+
    + {definitions.map((definition) => ( +
  • + {definition.label} + +
  • + ))} +
+
+ )) + )}
diff --git a/apps/studio/state/shortcuts/useShortcut.test.tsx b/apps/studio/state/shortcuts/useShortcut.test.tsx index 5ae39cba95d..a5484430577 100644 --- a/apps/studio/state/shortcuts/useShortcut.test.tsx +++ b/apps/studio/state/shortcuts/useShortcut.test.tsx @@ -1,4 +1,5 @@ import { render, renderHook } from '@testing-library/react' +import type { ICommand } from 'ui-patterns/CommandMenu/api/types' import { beforeEach, describe, expect, it, vi } from 'vitest' import { SHORTCUT_DEFINITIONS, SHORTCUT_IDS } from './registry' @@ -41,7 +42,7 @@ const getLastRegisterCall = () => { return call as [ string, Array<{ id: string; name: string; action: () => void; badge: () => any }>, - { enabled: boolean; deps: unknown[] }, + { enabled: boolean; deps: unknown[]; orderCommands?: unknown }, ] } @@ -247,6 +248,32 @@ describe('useShortcut', () => { ]) }) + it('orders "Show all keyboard shortcuts" last within the Shortcuts section', () => { + renderHook(() => + useShortcut(SHORTCUT_IDS.SHORTCUTS_OPEN_REFERENCE, vi.fn(), { registerInCommandMenu: true }) + ) + + const [, commands, options] = getLastRegisterCall() + const orderCommands = options.orderCommands as ( + existing: ICommand[], + commandsToInsert: ICommand[] + ) => ICommand[] + + const ordered = orderCommands( + [ + { id: SHORTCUT_IDS.TABLE_EDITOR_INSERT_ROW, name: 'Insert row', action: vi.fn() }, + { id: SHORTCUT_IDS.TABLE_EDITOR_INSERT_COLUMN, name: 'Insert column', action: vi.fn() }, + ], + commands + ) + + expect(ordered.map((command) => command.id)).toEqual([ + SHORTCUT_IDS.TABLE_EDITOR_INSERT_ROW, + SHORTCUT_IDS.TABLE_EDITOR_INSERT_COLUMN, + SHORTCUT_IDS.SHORTCUTS_OPEN_REFERENCE, + ]) + }) + describe('badge rendering', () => { it('renders a single KeyboardShortcut pill for single-step sequences (no "then")', () => { renderHook(() => diff --git a/apps/studio/state/shortcuts/useShortcut.tsx b/apps/studio/state/shortcuts/useShortcut.tsx index 3ea2fa5e5e6..a7942d1186a 100644 --- a/apps/studio/state/shortcuts/useShortcut.tsx +++ b/apps/studio/state/shortcuts/useShortcut.tsx @@ -2,8 +2,9 @@ import { useHotkeySequence } from '@tanstack/react-hotkeys' import { Fragment, useCallback } from 'react' import { KeyboardShortcut } from 'ui' import { useRegisterCommands, useSetCommandMenuOpen } from 'ui-patterns/CommandMenu' +import type { ICommand } from 'ui-patterns/CommandMenu/api/types' -import { SHORTCUT_DEFINITIONS, type ShortcutId } from './registry' +import { SHORTCUT_DEFINITIONS, SHORTCUT_IDS, type ShortcutId } from './registry' import type { ShortcutOptions } from './types' import { useIsShortcutEnabled } from './useIsShortcutEnabled' import { COMMAND_MENU_SECTIONS } from '@/components/interfaces/App/CommandMenu/CommandMenu.utils' @@ -12,6 +13,16 @@ import useLatest from '@/hooks/misc/useLatest' const hotkeyToKeys = (hotkey: string): string[] => hotkey.split('+').map((part) => (part === 'Mod' ? 'Meta' : part)) +const orderShortcutCommands = (commands: ICommand[], commandsToInsert: ICommand[]): ICommand[] => { + const mergedCommands = [...commands, ...commandsToInsert] + + return mergedCommands.sort((a, b) => { + if (a.id === SHORTCUT_IDS.SHORTCUTS_OPEN_REFERENCE) return 1 + if (b.id === SHORTCUT_IDS.SHORTCUTS_OPEN_REFERENCE) return -1 + return 0 + }) +} + /** * Subscribe to a registered keyboard shortcut. * @@ -103,6 +114,7 @@ export function useShortcut(id: ShortcutId, callback: () => void, options?: Shor { enabled: enabledInCommandMenu, deps: depsInCommandMenu, + orderCommands: orderShortcutCommands, sectionMeta: { priority: 1 }, } ) diff --git a/packages/ui/src/components/KeyboardShortcut/KeyboardShortcut.test.tsx b/packages/ui/src/components/KeyboardShortcut/KeyboardShortcut.test.tsx new file mode 100644 index 00000000000..aee8a743777 --- /dev/null +++ b/packages/ui/src/components/KeyboardShortcut/KeyboardShortcut.test.tsx @@ -0,0 +1,48 @@ +import { render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' + +import { KeyboardShortcut } from './KeyboardShortcut' + +const originalNavigator = global.navigator + +const setNavigator = (platform: string, userAgent: string) => { + Object.defineProperty(global, 'navigator', { + configurable: true, + value: { + ...originalNavigator, + platform, + userAgent, + }, + }) +} + +describe('KeyboardShortcut', () => { + afterEach(() => { + Object.defineProperty(global, 'navigator', { + configurable: true, + value: originalNavigator, + }) + }) + + it('renders arrow keys as symbols', () => { + render() + + expect(screen.getByText('↑')).toBeInTheDocument() + }) + + it('renders compact mac-style shortcuts for symbol and single-character keys', () => { + setNavigator('MacIntel', 'Mozilla/5.0 (Macintosh; Intel Mac OS X)') + + render() + + expect(screen.getByText('⌘↑')).toBeInTheDocument() + }) + + it('keeps word-style non-mac shortcuts readable', () => { + setNavigator('Linux x86_64', 'Mozilla/5.0 (X11; Linux x86_64)') + + render() + + expect(screen.getByText('Ctrl ↑')).toBeInTheDocument() + }) +}) diff --git a/packages/ui/src/components/KeyboardShortcut/KeyboardShortcut.tsx b/packages/ui/src/components/KeyboardShortcut/KeyboardShortcut.tsx index 4ca49292b9c..dbf9e1216ce 100644 --- a/packages/ui/src/components/KeyboardShortcut/KeyboardShortcut.tsx +++ b/packages/ui/src/components/KeyboardShortcut/KeyboardShortcut.tsx @@ -18,6 +18,10 @@ const KEY_SYMBOLS: Record string)> = { Alt: (isMac) => (isMac ? '⌥' : 'Alt'), Shift: '⇧', Enter: '↵', + ArrowUp: '↑', + ArrowDown: '↓', + ArrowLeft: '←', + ArrowRight: '→', Esc: 'Esc', // ⎋ symbol not recognisable enough Escape: 'Esc', // Match above Tab: 'Tab', // ⇥ symbol not recognisable enough @@ -36,17 +40,23 @@ const resolveKeyLabel = (key: string, isMac: boolean) => { return resolvedKey.length === 1 ? resolvedKey.toUpperCase() : resolvedKey } +const formatShortcutLabel = (keys: string[]) => { + if (keys.length <= 1) return keys.join('') + + return keys.every((key) => key.length === 1) ? keys.join('') : keys.join(' ') +} + export const KeyboardShortcut = ({ keys, variant = 'pill', className }: KeyboardShortcutProps) => { const isMac = getIsMac() const resolvedKeys = keys.map((key) => resolveKeyLabel(key, isMac)) - const shortcutLabel = resolvedKeys.join(' ') + const shortcutLabel = formatShortcutLabel(resolvedKeys) return ( Date: Fri, 1 May 2026 14:47:33 +0900 Subject: [PATCH 004/172] fix(www): respect Accept q-values and 406 unsupported types (#45394) --- apps/www/middleware.test.ts | 109 ++++++++++++++++++++++++++++++++++++ apps/www/middleware.ts | 99 +++++++++++++++++++++++++------- 2 files changed, 188 insertions(+), 20 deletions(-) diff --git a/apps/www/middleware.test.ts b/apps/www/middleware.test.ts index 53310810985..dbeed5d70ec 100644 --- a/apps/www/middleware.test.ts +++ b/apps/www/middleware.test.ts @@ -155,6 +155,115 @@ describe('www middleware', () => { }) }) + describe('Accept header q-value parsing', () => { + it('serves markdown for Cursor-style Accept (markdown preferred, plain fallback)', () => { + const req = makeRequest('/auth', { + accept: 'text/markdown, text/plain;q=0.9, */*;q=0.8', + }) + const res = middleware(req) + + expect(res.headers.get('x-middleware-rewrite')).toBe('https://supabase.com/api-v2/md/auth') + }) + + it('serves markdown when md and html have equal q-values', () => { + const req = makeRequest('/auth', { accept: 'text/markdown, text/html, */*' }) + const res = middleware(req) + + expect(res.headers.get('x-middleware-rewrite')).toBe('https://supabase.com/api-v2/md/auth') + }) + + it('serves HTML when html q-value beats markdown q-value', () => { + const req = makeRequest('/auth', { accept: 'text/html;q=1.0, text/markdown;q=0.5' }) + const res = middleware(req) + + expect(res.headers.get('x-middleware-rewrite')).toBeNull() + }) + + it('serves HTML for browser-style Accept (html with */* fallback)', () => { + const req = makeRequest('/auth', { + accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', + }) + const res = middleware(req) + + expect(res.headers.get('x-middleware-rewrite')).toBeNull() + }) + + it('serves markdown when md q-value beats html q-value', () => { + const req = makeRequest('/auth', { accept: 'text/html;q=0.5, text/markdown;q=1.0' }) + const res = middleware(req) + + expect(res.headers.get('x-middleware-rewrite')).toBe('https://supabase.com/api-v2/md/auth') + }) + + it('tolerates OWS around the q parameter (per RFC 9110)', () => { + const req = makeRequest('/auth', { accept: 'text/html ; q = 1.0, text/markdown ; q = 0.5' }) + const res = middleware(req) + + expect(res.headers.get('x-middleware-rewrite')).toBeNull() + }) + + it('ignores out-of-range q-values rather than treating them as preference', () => { + const req = makeRequest('/auth', { accept: 'text/html;q=2.0, text/markdown;q=1.0' }) + const res = middleware(req) + + // text/html's q=2.0 is invalid and falls back to default 1.0; tie -> markdown. + expect(res.headers.get('x-middleware-rewrite')).toBe('https://supabase.com/api-v2/md/auth') + }) + }) + + describe('406 Not Acceptable', () => { + it('returns 406 on MD-eligible page when Accept excludes every type we serve', () => { + const req = makeRequest('/pricing', { accept: 'application/x-content-negotiation-probe' }) + const res = middleware(req) + + expect(res.status).toBe(406) + expect(res.headers.get('x-middleware-rewrite')).toBeNull() + }) + + it('does not return 406 on non-MD pages (no negotiation contract there)', () => { + const req = makeRequest('/not-a-page', { accept: 'application/x-content-negotiation-probe' }) + const res = middleware(req) + + expect(res.status).not.toBe(406) + }) + + it('does not return 406 when Accept includes */*', () => { + const req = makeRequest('/pricing', { accept: '*/*' }) + const res = middleware(req) + + expect(res.status).not.toBe(406) + }) + + it('does not return 406 for LLM UAs even with a probe Accept header', () => { + const req = makeRequest('/pricing', { + accept: 'application/x-content-negotiation-probe', + userAgent: 'Claude-User/1.0', + }) + const res = middleware(req) + + expect(res.status).not.toBe(406) + expect(res.headers.get('x-middleware-rewrite')).toBe('https://supabase.com/api-v2/md/pricing') + }) + + it('returns 406 on changelog entries when Accept excludes every type', () => { + const req = makeRequest('/changelog/100', { + accept: 'application/x-content-negotiation-probe', + }) + const res = middleware(req) + + expect(res.status).toBe(406) + }) + + it('sets Cache-Control: no-store and Vary: Accept on 406 responses', () => { + const req = makeRequest('/pricing', { accept: 'application/x-content-negotiation-probe' }) + const res = middleware(req) + + expect(res.status).toBe(406) + expect(res.headers.get('Cache-Control')).toBe('no-store') + expect(res.headers.get('Vary')).toBe('Accept') + }) + }) + describe('LLM user-agent routing', () => { it('rewrites for Claude-User', () => { const req = makeRequest('/pricing', { diff --git a/apps/www/middleware.ts b/apps/www/middleware.ts index e83020bf965..095fa5f3a67 100644 --- a/apps/www/middleware.ts +++ b/apps/www/middleware.ts @@ -3,40 +3,100 @@ import { NextResponse, type NextRequest } from 'next/server' import { MD_PAGES } from './app/api-v2/md/content.generated' -// Live-fetch LLM agents that retrieve pages on behalf of a user prompt. -// Training crawlers (GPTBot, CCBot, ClaudeBot, Anthropic-AI) are intentionally -// excluded; they are governed by robots.txt and serving them content that -// differs from the human HTML page would risk SEO and cloaking penalties. +// Live-fetch agents only. Training crawlers (GPTBot, ClaudeBot, CCBot) are +// governed by robots.txt; serving them content that differs from the HTML +// page risks SEO and cloaking penalties. const LLM_USER_AGENT = /\bClaude-User\b|\bClaude-Web\b|\bChatGPT-User\b|\bPerplexityBot\b/i +// Media ranges (RFC 9110 §5.3.2) ordered most to least specific. +const RANGES = ['text/markdown', 'text/html', 'text/*', '*/*'] as const +type Range = (typeof RANGES)[number] + +const Q_PARAM = /^\s*q\s*=\s*([\d.]+)\s*$/i + +function isRange(s: string): s is Range { + return (RANGES as readonly string[]).includes(s) +} + +function parseQ(params: string[]): number { + for (const p of params) { + const q = parseFloat(p.match(Q_PARAM)?.[1] ?? '') + if (Number.isFinite(q) && q >= 0 && q <= 1) return q + } + return 1 +} + +// `markdownExplicit` lets the caller avoid flipping a bare `Accept: */*` to +// markdown — generic clients sending */* aren't expressing a preference. +function parseAccept(header: string) { + const seen = new Map() + + for (const entry of header.toLowerCase().split(',')) { + const [rawType, ...params] = entry.trim().split(';') + const range = rawType.trim() + if (!isRange(range)) continue + seen.set(range, Math.max(seen.get(range) ?? -1, parseQ(params))) + } + + return { + html: seen.get('text/html') ?? seen.get('text/*') ?? seen.get('*/*') ?? 0, + markdown: seen.get('text/markdown') ?? seen.get('text/*') ?? seen.get('*/*') ?? 0, + markdownExplicit: seen.has('text/markdown') || seen.has('text/*'), + } +} + +function shouldServeMarkdown(accept: ReturnType): boolean { + if (accept.markdown === 0) return false + if (accept.markdown > accept.html) return true + return accept.markdown === accept.html && accept.markdownExplicit +} + export function middleware(request: NextRequest) { const { pathname } = request.nextUrl - // Handle /.md suffix: /pricing.md -> /api-v2/md/pricing if (pathname.endsWith('.md')) { - const slug = pathname.slice(1, -3) // strip leading / and trailing .md + const slug = pathname.slice(1, -3) if (MD_PAGES.has(slug)) { return NextResponse.rewrite(new URL(`/api-v2/md/${slug}`, request.nextUrl)) } } - // Serve markdown to known LLM clients (Accept header or UA match). - // Cache-key safety: rewriting to /api-v2/md/ partitions the response - // by path, so no Vary: User-Agent is needed. - const accept = (request.headers.get('accept') ?? '').toLowerCase() + const acceptHeader = request.headers.get('accept') ?? '' // Cap UA length before regex test to bound CPU on the edge hot path. const userAgent = (request.headers.get('user-agent') ?? '').slice(0, 512) - if (accept.includes('text/markdown') || LLM_USER_AGENT.test(userAgent)) { - // Strip trailing slash so /auth/ and /auth resolve to the same allowlist entry. - // (NextURL's pathname setter preserves the trailing-slash style of the cloned - // origin, which would otherwise leak through to the rewrite target.) - const slug = (pathname === '/' ? 'homepage' : pathname.slice(1)).replace(/\/$/, '') - if (MD_PAGES.has(slug)) { + const isLlmAgent = LLM_USER_AGENT.test(userAgent) + const accept = acceptHeader ? parseAccept(acceptHeader) : null + + // Strip trailing slash so /auth/ and /auth resolve to the same allowlist + // entry — NextURL preserves trailing-slash style on rewrite targets. + const slug = (pathname === '/' ? 'homepage' : pathname.slice(1)).replace(/\/$/, '') + const isMdEligible = MD_PAGES.has(slug) + const isChangelogEntry = slug === 'changelog' || /^changelog\/\d+/.test(slug) + const hasMdVariant = isMdEligible || isChangelogEntry + + // 406 when Accept rejects every type we can produce. Skip for LLM UAs + // (always served markdown) and clients with no Accept (browser default). + if ( + hasMdVariant && + !isLlmAgent && + accept !== null && + accept.markdown === 0 && + accept.html === 0 + ) { + return new NextResponse('Not Acceptable', { + status: 406, + headers: { 'Cache-Control': 'no-store', Vary: 'Accept' }, + }) + } + + const wantsMarkdown = isLlmAgent || (accept !== null && shouldServeMarkdown(accept)) + + if (wantsMarkdown) { + if (isMdEligible) { return NextResponse.rewrite(new URL(`/api-v2/md/${slug}`, request.nextUrl)) } - // Individual changelog entries are served as static .md files from public/; - // rewrite directly to the static path. The slug always starts with the number. - if (slug === 'changelog' || /^changelog\/\d+/.test(slug)) { + // Changelog entries are static .md files in public/, not API routes. + if (isChangelogEntry) { return NextResponse.rewrite(new URL(`/${slug}.md`, request.nextUrl)) } } @@ -48,7 +108,6 @@ export function middleware(request: NextRequest) { export const config = { matcher: [ - // Match all paths except Next.js internals and static files. // MUST exclude _next/data to prevent full page reloads in multi-zone apps. '/((?!api|_next/static|_next/image|_next/data|favicon.ico|__nextjs).*)', ], From baabcb189c5f619d7657bd2d16c225b55e27d947 Mon Sep 17 00:00:00 2001 From: Pamela Chia Date: Fri, 1 May 2026 14:57:48 +0900 Subject: [PATCH 005/172] feat(www): serve blog, customers, events as .md for AI agents (#45403) --- apps/www/app/blog/[slug]/page.tsx | 7 +- apps/www/package.json | 2 + apps/www/scripts/generateMdContent.mjs | 205 ++++++++++++++---- .../lib/__tests__/mdxToMarkdown.test.mjs | 89 ++++++++ apps/www/scripts/lib/mdxToMarkdown.mjs | 114 ++++++++++ pnpm-lock.yaml | 10 +- 6 files changed, 382 insertions(+), 45 deletions(-) create mode 100644 apps/www/scripts/lib/__tests__/mdxToMarkdown.test.mjs create mode 100644 apps/www/scripts/lib/mdxToMarkdown.mjs diff --git a/apps/www/app/blog/[slug]/page.tsx b/apps/www/app/blog/[slug]/page.tsx index e2f68cd513b..6e6fc8fb88c 100644 --- a/apps/www/app/blog/[slug]/page.tsx +++ b/apps/www/app/blog/[slug]/page.tsx @@ -2,8 +2,8 @@ import type { Metadata } from 'next' import { draftMode } from 'next/headers' import BlogPostClient from './BlogPostClient' -import { SITE_ORIGIN } from '@/lib/constants' import { getAbsoluteBlogSocialImage } from '@/lib/blog-images' +import { SITE_ORIGIN } from '@/lib/constants' import { getAllPostSlugs, getPostdata, getSortedPosts } from '@/lib/posts' import type { Blog, BlogData, PostReturnType } from '@/types/post' @@ -53,6 +53,11 @@ export async function generateMetadata({ params }: { params: Promise }): return { title: blogPost.title, description: blogPost.description, + alternates: { + types: { + 'text/markdown': `/blog/${slug}.md`, + }, + }, openGraph: { title: blogPost.title, description: blogPost.description, diff --git a/apps/www/package.json b/apps/www/package.json index 219f472836a..30503d95897 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -111,8 +111,10 @@ "api-types": "workspace:*", "postcss": "catalog:", "react-hook-form": "^7.71.2", + "remark-mdx": "^3.0.1", "tailwindcss": "catalog:", "tsconfig": "workspace:*", + "unist-util-visit": "^5.1.0", "unist-util-visit-parents": "5.1.3", "uuid": "^9.0.1", "vite-tsconfig-paths": "catalog:", diff --git a/apps/www/scripts/generateMdContent.mjs b/apps/www/scripts/generateMdContent.mjs index 053408a6d26..378b18cefbf 100644 --- a/apps/www/scripts/generateMdContent.mjs +++ b/apps/www/scripts/generateMdContent.mjs @@ -1,31 +1,91 @@ // @ts-check /** - * Scans content/md/ and emits a TypeScript module exporting the markdown - * content as a Map plus the slug allowlist as a Set. Static imports of the - * generated file make the content traceable by @vercel/nft so it ends up in - * the serverless bundle without runtime fs.readFile. - * - * Drop a new .md file in content/md/ and the route handler picks it up on - * the next content:build — no constant to edit. + * Scans content/md/ + _blog/ + _customers/ + _events/ and emits a TypeScript + * module exporting MD_CONTENT (slug → markdown) and MD_PAGES (allowlist Set). + * The static import keeps content traceable by @vercel/nft, no runtime fs reads. */ import { promises as fs } from 'fs' import path from 'path' import { fileURLToPath } from 'url' +import matter from 'gray-matter' + +import { mdxBodyToMarkdown } from './lib/mdxToMarkdown.mjs' const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const contentDir = path.join(__dirname, '../content/md') -const outputPath = path.join(__dirname, '../app/api-v2/md/content.generated.ts') +const wwwDir = path.join(__dirname, '..') +const contentDir = path.join(wwwDir, 'content/md') +const outputPath = path.join(wwwDir, 'app/api-v2/md/content.generated.ts') -// 'pricing' is served dynamically via generatePricingContent() instead of -// from a .md file; it still needs to be in MD_PAGES so middleware rewrites -// /pricing.md to the API route. +// Matches lib/posts.tsx FILENAME_SUBSTRING — strips YYYY-MM-DD- (11 chars). +const DATE_PREFIX = 11 + +// Slugs handled by a dynamic generator in the route handler. Listed here so +// MD_PAGES still includes them (middleware relies on the allowlist). const DYNAMIC_SLUGS = ['pricing'] +function pickFields(data, fields) { + const picked = {} + for (const field of fields) { + const value = data[field] + if (value == null || value === '' || (Array.isArray(value) && value.length === 0)) continue + picked[field] = value + } + return picked +} + +const MDX_SECTIONS = [ + { + dir: '_blog', + urlPrefix: 'blog', + stripDatePrefix: true, + frontmatterFields: ['title', 'description', 'author', 'date', 'tags', 'categories'], + }, + { + dir: '_customers', + urlPrefix: 'customers', + stripDatePrefix: false, + frontmatterFields: [ + 'name', + 'title', + 'description', + 'company_url', + 'industry', + 'region', + 'company_size', + 'supabase_products', + 'date', + ], + }, + { + dir: '_events', + urlPrefix: 'events', + stripDatePrefix: true, + frontmatterFields: [ + 'title', + 'subtitle', + 'description', + 'type', + 'date', + 'end_date', + 'timezone', + 'duration', + 'onDemand', + ], + skipIf: (data) => data.disable_page_build === true, + }, +] + async function collectMdFiles(dir, prefix = '') { const results = [] - const dirents = await fs.readdir(dir, { withFileTypes: true }) + let dirents + try { + dirents = await fs.readdir(dir, { withFileTypes: true }) + } catch (err) { + if (err.code === 'ENOENT') return results + throw err + } for (const dirent of dirents) { const slug = prefix ? `${prefix}/${dirent.name}` : dirent.name if (dirent.isDirectory()) { @@ -37,56 +97,117 @@ async function collectMdFiles(dir, prefix = '') { return results } -// homepage first (it's the site overview), then everything else alphabetical. +// _events double-underscore filenames (`2024-08-30__launch-...`) produce slugs +// with a leading underscore. The HTML page is at `/events/_launch-...`, so the +// .md slug must keep the underscore to match. +function deriveSlug(filename, stripDatePrefix) { + const base = filename.replace(/\.mdx$/, '') + return stripDatePrefix ? base.substring(DATE_PREFIX) : base +} + +async function ingestMdxSection(section) { + const dir = path.join(wwwDir, section.dir) + let filenames + try { + filenames = await fs.readdir(dir) + } catch (err) { + if (err.code === 'ENOENT') { + console.warn(` ⚠ Section directory missing: ${section.dir}`) + return [] + } + throw err + } + + const mdxFilenames = filenames.filter((f) => f.endsWith('.mdx')) + const results = await Promise.all( + mdxFilenames.map(async (filename) => { + const fullPath = path.join(dir, filename) + try { + const raw = await fs.readFile(fullPath, 'utf-8') + const parsed = matter(raw) + const data = parsed.data ?? {} + if (section.skipIf?.(data)) return null + + const slug = `${section.urlPrefix}/${deriveSlug(filename, section.stripDatePrefix)}` + const body = await mdxBodyToMarkdown(parsed.content) + const content = matter.stringify(body, pickFields(data, section.frontmatterFields)) + return { slug, content } + } catch (err) { + throw new Error(`${section.dir}/${filename}: ${err.message}`, { cause: err }) + } + }) + ) + return results.filter((entry) => entry !== null) +} + function sortSlugs(a, b) { if (a === 'homepage') return -1 if (b === 'homepage') return 1 return a.localeCompare(b) } -const slugs = (await collectMdFiles(contentDir)).sort(sortSlugs) +const staticSlugs = (await collectMdFiles(contentDir)).sort(sortSlugs) -if (slugs.length === 0) { +if (staticSlugs.length === 0) { console.error('❌ No .md files found in content/md/') process.exit(1) } -// A static file with the same slug as a dynamic generator would land in both -// MD_CONTENT and the dynamic append path, so /llms-full.txt would emit it -// twice. Fail the build instead of shipping a duplicate. -const collisions = slugs.filter((s) => DYNAMIC_SLUGS.includes(s)) -if (collisions.length > 0) { +const staticEntries = await Promise.all( + staticSlugs.map(async (slug) => ({ + slug, + content: await fs.readFile(path.join(contentDir, `${slug}.md`), 'utf-8'), + })) +) + +const mdxEntries = [] +for (const section of MDX_SECTIONS) { + console.log(`📚 Ingesting ${section.dir}...`) + const sectionEntries = await ingestMdxSection(section) + console.log(` ${sectionEntries.length} entries`) + mdxEntries.push(...sectionEntries) +} + +const allEntries = [...staticEntries, ...mdxEntries] + +const dynamicCollisions = staticSlugs.filter((s) => DYNAMIC_SLUGS.includes(s)) +if (dynamicCollisions.length > 0) { console.error( - `❌ Slug collision: [${collisions.join(', ')}] is reserved for a dynamic generator. ` + - `Remove the corresponding file from content/md/ or update DYNAMIC_SLUGS.` + `❌ Slug collision: [${dynamicCollisions.join(', ')}] reserved for a dynamic generator.` ) process.exit(1) } -const entries = [] -const errors = [] -for (const slug of slugs) { - const filePath = path.join(contentDir, `${slug}.md`) - try { - const content = await fs.readFile(filePath, 'utf-8') - entries.push(` [${JSON.stringify(slug)}, ${JSON.stringify(content)}]`) - } catch (err) { - errors.push(`${slug}.md: ${err.message}`) - } -} - -if (errors.length > 0) { - console.error('❌ Failed to read .md files:') - errors.forEach((e) => console.error(` ${e}`)) +const sectionRoots = MDX_SECTIONS.map((s) => s.urlPrefix) +const sectionRootCollisions = [...staticSlugs, ...DYNAMIC_SLUGS].filter((s) => + sectionRoots.includes(s) +) +if (sectionRootCollisions.length > 0) { + console.error( + `❌ Section-root collision: [${sectionRootCollisions.join(', ')}] shadows an MDX section.` + ) process.exit(1) } -const allSlugs = [...slugs, ...DYNAMIC_SLUGS] -const pageEntries = allSlugs.map((s) => ` ${JSON.stringify(s)}`).join(',\n') +const seen = new Set() +for (const entry of allEntries) { + if (seen.has(entry.slug)) { + console.error(`❌ Duplicate slug emitted: ${entry.slug}`) + process.exit(1) + } + seen.add(entry.slug) +} + +const contentEntries = allEntries + .map((e) => ` [${JSON.stringify(e.slug)}, ${JSON.stringify(e.content)}]`) + .join(',\n') + +const allPageSlugs = [...allEntries.map((e) => e.slug), ...DYNAMIC_SLUGS] +const pageEntries = allPageSlugs.map((s) => ` ${JSON.stringify(s)}`).join(',\n') const output = `// AUTO-GENERATED by scripts/generateMdContent.mjs — do not edit export const MD_CONTENT = new Map([ -${entries.join(',\n')}, +${contentEntries}, ]) export const MD_PAGES = new Set([ @@ -95,4 +216,4 @@ ${pageEntries}, ` await fs.writeFile(outputPath, output, 'utf-8') -console.log(`✅ Generated ${outputPath} (${entries.length} files, ${allSlugs.length} pages)`) +console.log(`✅ Generated ${outputPath} (${allEntries.length} files, ${allPageSlugs.length} pages)`) diff --git a/apps/www/scripts/lib/__tests__/mdxToMarkdown.test.mjs b/apps/www/scripts/lib/__tests__/mdxToMarkdown.test.mjs new file mode 100644 index 00000000000..76ff2c551e5 --- /dev/null +++ b/apps/www/scripts/lib/__tests__/mdxToMarkdown.test.mjs @@ -0,0 +1,89 @@ +// @ts-check +import { describe, expect, it } from 'vitest' + +import { mdxBodyToMarkdown } from '../mdxToMarkdown.mjs' + +describe('mdxBodyToMarkdown', () => { + it('preserves plain markdown unchanged', async () => { + const out = await mdxBodyToMarkdown('# Title\n\nA **bold** word.') + expect(out).toContain('# Title') + expect(out).toContain('**bold**') + }) + + it('preserves code fences containing JSX literally', async () => { + const src = [ + '```tsx', + 'function App() {', + ' return hi', + '}', + '```', + ].join('\n') + const out = await mdxBodyToMarkdown(src) + expect(out).toContain('hi') + expect(out).toContain('```tsx') + }) + + it('rewrites to a markdown image', async () => { + const out = await mdxBodyToMarkdown('X') + expect(out.trim()).toBe('![X](/x.png)') + }) + + it('rewrites without alt', async () => { + const out = await mdxBodyToMarkdown('') + expect(out.trim()).toBe('![](/x.png)') + }) + + it('rewrites to a labeled blockquote', async () => { + const out = await mdxBodyToMarkdown('\n\nHeads up.\n\n') + expect(out).toMatch(/^>\s+\*\*NOTE\*\*/m) + expect(out).toContain('Heads up.') + }) + + it('rewrites with caption to a blockquote with em-dash attribution', async () => { + const out = await mdxBodyToMarkdown( + '\n\nIt was great.\n\n' + ) + expect(out).toContain('> It was great.') + expect(out).toContain('*— Jane Doe, CEO*') + }) + + it('rewrites to a markdown link', async () => { + const out = await mdxBodyToMarkdown('click') + expect(out.trim()).toBe('[click](/x)') + }) + + it('rewrites and to bold', async () => { + const out = await mdxBodyToMarkdown('foo bar') + expect(out).toContain('**foo**') + expect(out).toContain('**bar**') + }) + + it('drops entirely', async () => { + const out = await mdxBodyToMarkdown(' rest') + expect(out).not.toContain('Avatar') + expect(out).toContain('rest') + }) + + it('flattens unknown inline JSX to text, preserving inner content', async () => { + const out = await mdxBodyToMarkdown('Some text with kept inline.') + expect(out).toContain('kept') + expect(out).toContain('Some text with') + expect(out).toContain('inline.') + expect(out).not.toContain(' { + const out = await mdxBodyToMarkdown('hello {/* internal note */} world') + expect(out).toContain('hello') + expect(out).toContain('world') + expect(out).not.toContain('internal') + }) + + it('strips orphan mdxjsEsm imports at the top level', async () => { + const src = "import X from 'y'\n\n# Title" + const out = await mdxBodyToMarkdown(src) + expect(out).toContain('# Title') + expect(out).not.toContain('import') + }) +}) diff --git a/apps/www/scripts/lib/mdxToMarkdown.mjs b/apps/www/scripts/lib/mdxToMarkdown.mjs new file mode 100644 index 00000000000..c17e844be7c --- /dev/null +++ b/apps/www/scripts/lib/mdxToMarkdown.mjs @@ -0,0 +1,114 @@ +// @ts-check + +import { remark } from 'remark' +import remarkGfm from 'remark-gfm' +import remarkMdx from 'remark-mdx' +import { SKIP, visit } from 'unist-util-visit' + +function getJsxAttr(node, name) { + const attr = (node.attributes ?? []).find((a) => a.type === 'mdxJsxAttribute' && a.name === name) + return typeof attr?.value === 'string' ? attr.value : '' +} + +function flattenChildText(nodes) { + const parts = [] + for (const node of nodes) { + if (node.type === 'text' || node.type === 'inlineCode') parts.push(node.value) + else if (node.children) parts.push(flattenChildText(node.children)) + } + return parts.join('') +} + +const text = (value) => ({ type: 'text', value }) +const para = (children) => ({ type: 'paragraph', children }) +const strong = (children) => ({ type: 'strong', children }) +const emph = (children) => ({ type: 'emphasis', children }) +const blockquote = (children) => ({ type: 'blockquote', children }) + +// Unknown JSX must not become a `paragraph` (block) inside an inline parent. +function unwrap(node) { + const children = node.children ?? [] + if (node.type === 'mdxJsxTextElement') return text(flattenChildText(children)) + return children.length > 0 ? para(children) : text('') +} + +const REWRITES = { + Img: (node) => { + const src = getJsxAttr(node, 'src') + return src ? { type: 'image', url: src, alt: getJsxAttr(node, 'alt'), title: null } : text('') + }, + img: (node) => REWRITES.Img(node), + + Admonition: (node) => { + const label = (getJsxAttr(node, 'type') || 'note').toUpperCase() + return blockquote([para([strong([text(label)])]), ...(node.children ?? [])]) + }, + + Quote: (node) => { + const caption = getJsxAttr(node, 'caption') + const attribution = caption ? [para([emph([text(`— ${caption}`)])])] : [] + return blockquote([...(node.children ?? []), ...attribution]) + }, + + BlogCollapsible: (node) => { + const title = getJsxAttr(node, 'title') + const heading = title ? [para([strong([text(title)])])] : [] + return blockquote([...heading, ...(node.children ?? [])]) + }, + + Link: (node) => { + const href = getJsxAttr(node, 'href') + if (!href) return unwrap(node) + return { type: 'link', url: href, title: null, children: node.children ?? [] } + }, + a: (node) => REWRITES.Link(node), + + Subtitle: (node) => strong(node.children ?? []), + Badge: (node) => strong(node.children ?? []), + + Avatar: (node) => (node.type === 'mdxJsxTextElement' ? text('') : para([])), +} + +function rewriteJsxNode(node) { + return REWRITES[node.name]?.(node) ?? unwrap(node) +} + +function transformMdxNodes() { + return (tree, file) => { + const unknownNames = new Set() + visit(tree, (node, index, parent) => { + if (parent == null || index == null) return + + if ( + node.type === 'mdxFlowExpression' || + node.type === 'mdxTextExpression' || + node.type === 'mdxjsEsm' + ) { + parent.children.splice(index, 1) + return [SKIP, index] + } + + if (node.type !== 'mdxJsxFlowElement' && node.type !== 'mdxJsxTextElement') return + + if (node.name && !(node.name in REWRITES)) unknownNames.add(node.name) + parent.children[index] = rewriteJsxNode(node) + return [SKIP, index] + }) + if (unknownNames.size > 0) { + file.message(`Unknown JSX components stripped: ${[...unknownNames].sort().join(', ')}`) + } + } +} + +const processor = remark().use(remarkMdx).use(remarkGfm).use(transformMdxNodes).data('settings', { + bullet: '-', + fences: true, + incrementListMarker: false, + rule: '-', +}) + +export async function mdxBodyToMarkdown(rawBody) { + const file = await processor.process(rawBody) + for (const msg of file.messages) console.warn(` ⚠ ${msg.message}`) + return String(file) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 10af73a9147..184a2b27ed6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1855,12 +1855,18 @@ importers: react-hook-form: specifier: ^7.71.2 version: 7.72.1(react@18.3.1) + remark-mdx: + specifier: ^3.0.1 + version: 3.0.1(supports-color@8.1.1) tailwindcss: specifier: 'catalog:' version: 4.2.4 tsconfig: specifier: workspace:* version: link:../../packages/tsconfig + unist-util-visit: + specifier: ^5.1.0 + version: 5.1.0 unist-util-visit-parents: specifier: 5.1.3 version: 5.1.3 @@ -34099,7 +34105,7 @@ snapshots: hast-util-heading-rank: 3.0.0 hast-util-is-element: 3.0.0 unified: 11.0.5 - unist-util-visit: 5.0.0 + unist-util-visit: 5.1.0 rehype-harden@1.1.2: {} @@ -34142,7 +34148,7 @@ snapshots: github-slugger: 2.0.0 hast-util-heading-rank: 3.0.0 hast-util-to-string: 3.0.0 - unist-util-visit: 5.0.0 + unist-util-visit: 5.1.0 rehype-stringify@10.0.0: dependencies: From 09be492bbf8d1e36da32231cec5a848154442787 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Fri, 1 May 2026 14:05:42 +0800 Subject: [PATCH 006/172] Shift auto enable rls to a minimisable banner instead (#45410) ## Context Continuing off from this PR: https://github.com/supabase/supabase/pull/45407 Shifting the "Auto enable RLS" toast into a banner that's dismissible, only can be minimised until action is taken Given that this is a security measure that we highly advise - otherwise there's no way for users to revisit this after dismissing We'll use the existing local storage key so it doesn't affect the behaviour for users who already dismissed the banner Starting state: (Not dismissed / not minimised) 1 If dismissed already / minimised: Shift CTA into a button tooltip 2 Button will disappear entirely once the trigger is created Also added this to the database tables page image ## Summary by CodeRabbit * **New Features** * Redesigned the Row Level Security (RLS) notice with a new card-based layout and minimize button to reduce visual clutter while keeping the feature accessible. * Added a compact icon-only mode for the RLS notice in page headers, with tooltip descriptions for clarity. * RLS notice minimization state is now persisted locally per project, improving user experience across page navigation. --- ...entTrigger.tsx => AutoEnableRLSNotice.tsx} | 134 ++++++++---------- .../pages/project/[ref]/auth/policies.tsx | 36 +---- .../project/[ref]/database/tables/index.tsx | 17 ++- 3 files changed, 83 insertions(+), 104 deletions(-) rename apps/studio/components/ui/{BannerStack/Banners/BannerRlsEventTrigger.tsx => AutoEnableRLSNotice.tsx} (59%) diff --git a/apps/studio/components/ui/BannerStack/Banners/BannerRlsEventTrigger.tsx b/apps/studio/components/ui/AutoEnableRLSNotice.tsx similarity index 59% rename from apps/studio/components/ui/BannerStack/Banners/BannerRlsEventTrigger.tsx rename to apps/studio/components/ui/AutoEnableRLSNotice.tsx index 6bd05b6eb88..e739e8bdc7e 100644 --- a/apps/studio/components/ui/BannerStack/Banners/BannerRlsEventTrigger.tsx +++ b/apps/studio/components/ui/AutoEnableRLSNotice.tsx @@ -1,13 +1,12 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { LOCAL_STORAGE_KEYS } from 'common' -import { useParams } from 'common/hooks' -import { ShieldCheck } from 'lucide-react' -import Link from 'next/link' -import { useEffect, useMemo, useState } from 'react' +import { LOCAL_STORAGE_KEYS, useParams } from 'common' +import { ShieldCheck, X } from 'lucide-react' +import { useMemo, useState } from 'react' import { toast } from 'sonner' import { Button, - cn, + Card, + CardContent, Dialog, DialogContent, DialogDescription, @@ -20,8 +19,6 @@ import { } from 'ui' import { CodeBlock } from 'ui-patterns/CodeBlock' -import { BannerCard } from '../BannerCard' -import { useBannerStack } from '../BannerStackProvider' import { AUTO_ENABLE_RLS_EVENT_TRIGGER_SQL } from '@/components/interfaces/Database/Triggers/EventTriggersList/EventTriggers.constants' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { useDatabaseEventTriggerCreateMutation } from '@/data/database-event-triggers/database-event-trigger-create-mutation' @@ -31,14 +28,14 @@ import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { useTrack } from '@/lib/telemetry/track' -export const BannerRlsEventTrigger = () => { +export const AutoEnableRLSNotice = ({ iconOnly }: { iconOnly?: boolean }) => { const { ref } = useParams() - const { dismissBanner } = useBannerStack() const { data: project } = useSelectedProjectQuery() const projectRef = ref ?? project?.ref - const [hasCreated, setHasCreated] = useState(false) - const [, setIsDismissed] = useLocalStorageQuery( + // [Joshen] Changing the behaviour of this to not be dismissible, only minimized + // Given that its a security measure that we highly advise. Otherwise there's no way for users to revisit this + const [, setIsMinimized] = useLocalStorageQuery( LOCAL_STORAGE_KEYS.RLS_EVENT_TRIGGER_BANNER_DISMISSED(projectRef ?? 'unknown'), false ) @@ -48,7 +45,6 @@ export const BannerRlsEventTrigger = () => { projectRef: project?.ref, connectionString: project?.connectionString, }) - const hasDefaultTrigger = useMemo( () => eventTriggers.some( @@ -57,57 +53,43 @@ export const BannerRlsEventTrigger = () => { [eventTriggers] ) - useEffect(() => { - if (hasDefaultTrigger) { - setHasCreated(true) - } - }, [hasDefaultTrigger]) + if (!projectRef || isLoadingEventTriggers || hasDefaultTrigger) return null - if (!projectRef || isLoadingEventTriggers) return null + if (iconOnly) { + return + } return ( - { - setIsDismissed(true) - dismissBanner('rls-event-trigger-banner') - }} - > -
-
-
- + + +
+
+ +
+
+

Auto-enable RLS for new tables

+

+ Create an event trigger that enables Row Level Security on all new tables +

-
-

- {hasCreated ? 'RLS auto-enable trigger is active' : 'Auto-enable RLS for new tables'} -

-

- {hasCreated - ? 'New tables will have Row Level Security enabled automatically.' - : 'Create an event trigger that enables Row Level Security on all new tables'} -

+ +
+ + } + type="text" + className="w-7" + tooltip={{ content: { side: 'bottom', text: 'Minimize' } }} + onClick={() => setIsMinimized(true)} + />
-
- {hasCreated ? ( - - ) : ( - setHasCreated(true)} /> - )} -
-
- +
+
) } -const CreateEnsureRLSTriggerDialog = ({ onCreateSuccess }: { onCreateSuccess: () => void }) => { +const CreateEnsureRLSTriggerDialog = ({ iconOnly }: { iconOnly?: boolean }) => { const track = useTrack() const { data: project } = useSelectedProjectQuery() @@ -124,7 +106,6 @@ const CreateEnsureRLSTriggerDialog = ({ onCreateSuccess }: { onCreateSuccess: () toast.success( 'Successfully set up database trigger to automatically enable RLS on all new tables' ) - onCreateSuccess() setOpen(false) }, }) @@ -142,21 +123,16 @@ const CreateEnsureRLSTriggerDialog = ({ onCreateSuccess }: { onCreateSuccess: () return ( - - Learn more - + {iconOnly ? ( + } + className="w-7" + tooltip={{ content: { side: 'bottom', text: 'Auto-enable RLS for new tables' } }} + /> + ) : ( + + )} @@ -186,9 +162,21 @@ const CreateEnsureRLSTriggerDialog = ({ onCreateSuccess }: { onCreateSuccess: () - + diff --git a/apps/studio/pages/project/[ref]/auth/policies.tsx b/apps/studio/pages/project/[ref]/auth/policies.tsx index c9850e38bb3..5d2802cafde 100644 --- a/apps/studio/pages/project/[ref]/auth/policies.tsx +++ b/apps/studio/pages/project/[ref]/auth/policies.tsx @@ -31,7 +31,7 @@ import AuthLayout from '@/components/layouts/AuthLayout/AuthLayout' import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' import { AlertError } from '@/components/ui/AlertError' -import { BannerRlsEventTrigger } from '@/components/ui/BannerStack/Banners/BannerRlsEventTrigger' +import { AutoEnableRLSNotice } from '@/components/ui/AutoEnableRLSNotice' import { BannerRlsTester } from '@/components/ui/BannerStack/Banners/BannerRlsTester' import { useBannerStack } from '@/components/ui/BannerStack/BannerStackProvider' import { DocsButton } from '@/components/ui/DocsButton' @@ -132,7 +132,7 @@ const AuthPoliciesPage: NextPageWithLayout = () => { const { isSchemaLocked } = useIsProtectedSchema({ schema: schema, excludedSchemas: ['realtime'] }) const { addBanner, dismissBanner } = useBannerStack() - const [isRlsBannerDismissed] = useLocalStorageQuery( + const [isAutoEnableRLSMinimized] = useLocalStorageQuery( LOCAL_STORAGE_KEYS.RLS_EVENT_TRIGGER_BANNER_DISMISSED(projectRef ?? ''), false ) @@ -177,12 +177,11 @@ const AuthPoliciesPage: NextPageWithLayout = () => { .map((schema) => schema.trim()) .filter((schema) => schema.length > 0) }, [postgrestConfig?.db_schema]) + const { can: canReadPolicies, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_READ, 'policies' ) - const { can: canCreateTriggers, isSuccess: isTriggerPermissionsLoaded } = - useAsyncCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'triggers') const handleSelectCreatePolicy = useCallback( (table: string) => { @@ -254,31 +253,6 @@ const AuthPoliciesPage: NextPageWithLayout = () => { } }, [addBanner, dismissBanner, isRlsTesterBannerDismissed, rlsTesterEnabled]) - useEffect(() => { - if (!isTriggerPermissionsLoaded) return - - if (canCreateTriggers && !isRlsBannerDismissed) { - addBanner({ - id: 'rls-event-trigger-banner', - isDismissed: false, - content: , - priority: 2, - }) - } else { - dismissBanner('rls-event-trigger-banner') - } - - return () => { - dismissBanner('rls-event-trigger-banner') - } - }, [ - addBanner, - dismissBanner, - canCreateTriggers, - isTriggerPermissionsLoaded, - isRlsBannerDismissed, - ]) - useEffect(() => { if (selectedIdToEdit && isPoliciesSuccess && !selectedPolicyToEdit) { toast(`Policy ID ${selectedIdToEdit} cannot be found`) @@ -303,13 +277,17 @@ const AuthPoliciesPage: NextPageWithLayout = () => { + {isAutoEnableRLSMinimized && } {rlsTesterEnabled && } + + {!isAutoEnableRLSMinimized && } +
{ const snap = useTableEditorStateSnapshot() const [selectedTableToEdit, setSelectedTableToEdit] = useState() + const [isAutoEnableRLSMinimized] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.RLS_EVENT_TRIGGER_BANNER_DISMISSED(projectRef ?? ''), + false + ) + return ( <> - + } + > + {!isAutoEnableRLSMinimized && } + Date: Fri, 1 May 2026 15:46:05 +0800 Subject: [PATCH 007/172] Fix FormItemLayout incorrect min-w-100 on FlexContainer for flex-row-reverse (#45447) ## Context As per PR title - saw that there's a `md:min-w-100` on `FlexContainer` which overrides `md:w-1/2`. Removing the former resolves this image ## Summary by CodeRabbit * **Style** * Improved form layout responsiveness on medium-sized screens by adjusting width constraints for better flexibility. --- packages/ui-patterns/src/form/Layout/FormLayout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui-patterns/src/form/Layout/FormLayout.tsx b/packages/ui-patterns/src/form/Layout/FormLayout.tsx index 3cb8191b25a..044ac568049 100644 --- a/packages/ui-patterns/src/form/Layout/FormLayout.tsx +++ b/packages/ui-patterns/src/form/Layout/FormLayout.tsx @@ -227,7 +227,7 @@ const FlexContainer = cva('', { { layout: 'flex-row-reverse', className: - 'flex flex-col justify-center items-start md:items-end shrink-0 md:w-1/2 xl:w-2/5 md:min-w-100 [&>div]:md:w-full', + 'flex flex-col justify-center items-start md:items-end shrink-0 md:w-1/2 xl:w-2/5 [&>div]:md:w-full', }, ], }) From f8cc6c21bd987724a0169a52a162438c57ea8ae0 Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Fri, 1 May 2026 16:16:26 +0800 Subject: [PATCH 008/172] [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 (