diff --git a/apps/docs/package.json b/apps/docs/package.json index 3e0ce1133a933..78f80fcb0e459 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -90,7 +90,7 @@ "mdast-util-to-string": "^3.1.1", "micromark-extension-gfm": "^2.0.3", "micromark-extension-mdxjs": "^1.0.0", - "next": "^15.5.15", + "next": "^15.5.18", "next-mdx-remote-client": "^1.1.7", "next-plugin-yaml": "^1.0.1", "next-themes": "catalog:", diff --git a/apps/studio/components/interfaces/DatabaseNavShortcuts.tsx b/apps/studio/components/interfaces/DatabaseNavShortcuts.tsx deleted file mode 100644 index 1b524aeaa7373..0000000000000 --- a/apps/studio/components/interfaces/DatabaseNavShortcuts.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { useRouter } from 'next/router' -import { useCallback, useMemo } from 'react' - -import { useGenerateDatabaseMenu } from '@/components/layouts/DatabaseLayout/DatabaseMenu.utils' -import { SHORTCUT_IDS, type ShortcutId } from '@/state/shortcuts/registry' -import { useShortcut } from '@/state/shortcuts/useShortcut' - -export const DatabaseNavShortcuts = () => { - const router = useRouter() - const groups = useGenerateDatabaseMenu() - - const urlByShortcut = useMemo(() => { - const map = new Map() - for (const group of groups) { - for (const item of group.items) { - if (item.shortcutId && item.url) map.set(item.shortcutId, item.url) - } - } - return map - }, [groups]) - - const navigate = useCallback( - (id: ShortcutId) => { - const url = urlByShortcut.get(id) - if (url) router.push(url) - }, - [router, urlByShortcut] - ) - - useShortcut(SHORTCUT_IDS.NAV_DATABASE_TABLES, () => navigate(SHORTCUT_IDS.NAV_DATABASE_TABLES), { - enabled: urlByShortcut.has(SHORTCUT_IDS.NAV_DATABASE_TABLES), - }) - useShortcut( - SHORTCUT_IDS.NAV_DATABASE_FUNCTIONS, - () => navigate(SHORTCUT_IDS.NAV_DATABASE_FUNCTIONS), - { enabled: urlByShortcut.has(SHORTCUT_IDS.NAV_DATABASE_FUNCTIONS) } - ) - useShortcut( - SHORTCUT_IDS.NAV_DATABASE_TRIGGERS, - () => navigate(SHORTCUT_IDS.NAV_DATABASE_TRIGGERS), - { enabled: urlByShortcut.has(SHORTCUT_IDS.NAV_DATABASE_TRIGGERS) } - ) - useShortcut( - SHORTCUT_IDS.NAV_DATABASE_INDEXES, - () => navigate(SHORTCUT_IDS.NAV_DATABASE_INDEXES), - { enabled: urlByShortcut.has(SHORTCUT_IDS.NAV_DATABASE_INDEXES) } - ) - useShortcut( - SHORTCUT_IDS.NAV_DATABASE_EXTENSIONS, - () => navigate(SHORTCUT_IDS.NAV_DATABASE_EXTENSIONS), - { enabled: urlByShortcut.has(SHORTCUT_IDS.NAV_DATABASE_EXTENSIONS) } - ) - useShortcut( - SHORTCUT_IDS.NAV_DATABASE_SCHEMA_VISUALIZER, - () => navigate(SHORTCUT_IDS.NAV_DATABASE_SCHEMA_VISUALIZER), - { enabled: urlByShortcut.has(SHORTCUT_IDS.NAV_DATABASE_SCHEMA_VISUALIZER) } - ) - useShortcut(SHORTCUT_IDS.NAV_DATABASE_ROLES, () => navigate(SHORTCUT_IDS.NAV_DATABASE_ROLES), { - enabled: urlByShortcut.has(SHORTCUT_IDS.NAV_DATABASE_ROLES), - }) - useShortcut( - SHORTCUT_IDS.NAV_DATABASE_BACKUPS, - () => navigate(SHORTCUT_IDS.NAV_DATABASE_BACKUPS), - { enabled: urlByShortcut.has(SHORTCUT_IDS.NAV_DATABASE_BACKUPS) } - ) - useShortcut( - SHORTCUT_IDS.NAV_DATABASE_MIGRATIONS, - () => navigate(SHORTCUT_IDS.NAV_DATABASE_MIGRATIONS), - { enabled: urlByShortcut.has(SHORTCUT_IDS.NAV_DATABASE_MIGRATIONS) } - ) - useShortcut(SHORTCUT_IDS.NAV_DATABASE_TYPES, () => navigate(SHORTCUT_IDS.NAV_DATABASE_TYPES), { - enabled: urlByShortcut.has(SHORTCUT_IDS.NAV_DATABASE_TYPES), - }) - useShortcut( - SHORTCUT_IDS.NAV_DATABASE_PUBLICATIONS, - () => navigate(SHORTCUT_IDS.NAV_DATABASE_PUBLICATIONS), - { enabled: urlByShortcut.has(SHORTCUT_IDS.NAV_DATABASE_PUBLICATIONS) } - ) - useShortcut( - SHORTCUT_IDS.NAV_DATABASE_COLUMN_PRIVILEGES, - () => navigate(SHORTCUT_IDS.NAV_DATABASE_COLUMN_PRIVILEGES), - { enabled: urlByShortcut.has(SHORTCUT_IDS.NAV_DATABASE_COLUMN_PRIVILEGES) } - ) - useShortcut( - SHORTCUT_IDS.NAV_DATABASE_SETTINGS, - () => navigate(SHORTCUT_IDS.NAV_DATABASE_SETTINGS), - { enabled: urlByShortcut.has(SHORTCUT_IDS.NAV_DATABASE_SETTINGS) } - ) - useShortcut( - SHORTCUT_IDS.NAV_DATABASE_REPLICATION, - () => navigate(SHORTCUT_IDS.NAV_DATABASE_REPLICATION), - { enabled: urlByShortcut.has(SHORTCUT_IDS.NAV_DATABASE_REPLICATION) } - ) - - return null -} diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Restriction.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Restriction.tsx index bea16a8cbd6da..fbc8331f34e34 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Restriction.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Restriction.tsx @@ -107,7 +107,7 @@ export const Restriction = () => { Your grace period has started.

- Your organization is over its quota + Your organization went over its quota in the previous billing cycle {violationLabels && ` ${violationLabels}`}. You can continue with your projects until your grace period ends on{' '} diff --git a/apps/studio/components/interfaces/Organization/Usage/TotalUsage.tsx b/apps/studio/components/interfaces/Organization/Usage/TotalUsage.tsx index f3f3f826af903..ee56a6066752d 100644 --- a/apps/studio/components/interfaces/Organization/Usage/TotalUsage.tsx +++ b/apps/studio/components/interfaces/Organization/Usage/TotalUsage.tsx @@ -16,6 +16,7 @@ import { import type { OrgSubscription } from '@/data/subscriptions/types' import { useOrgUsageQuery } from '@/data/usage/org-usage-query' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' +import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { DOCS_URL } from '@/lib/constants' export interface ComputeProps { @@ -50,6 +51,8 @@ export const TotalUsage = ({ const isMobile = useBreakpoint('md') const isUsageBillingEnabled = subscription?.usage_billing_enabled const { billingAll } = useIsFeatureEnabled(['billing:all']) + const { data: org } = useSelectedOrganizationQuery() + const hasActiveRestriction = Boolean(org?.restriction_status) const { data: usage, @@ -154,7 +157,7 @@ export const TotalUsage = ({ {isSuccessUsage && subscription && (

- {showRelationToSubscription && !isOnHigherPlan && ( + {showRelationToSubscription && !isOnHigherPlan && !hasActiveRestriction && (

{!hasExceededAnyLimits ? ( diff --git a/apps/studio/components/interfaces/Organization/restriction.constants.tsx b/apps/studio/components/interfaces/Organization/restriction.constants.tsx index a6b86cbaaf52b..3e917be78740d 100644 --- a/apps/studio/components/interfaces/Organization/restriction.constants.tsx +++ b/apps/studio/components/interfaces/Organization/restriction.constants.tsx @@ -1,16 +1,22 @@ +import dayjs from 'dayjs' import { type ReactNode } from 'react' +import { TimestampInfo } from 'ui-patterns' import { InlineLink } from '@/components/ui/InlineLink' export const RESTRICTION_MESSAGES = { GRACE_PERIOD: { - title: 'Organization plan has exceeded its quota', - description: (date: string, slug: string): ReactNode => ( - <> - You have been given a grace period until {date}.{' '} - Review usage - - ), + title: 'Organization exceeded its quota in the previous billing cycle', + description: (date: string, slug: string): ReactNode => { + const label = dayjs(date).format('DD MMM, YYYY') + return ( + <> + You have a grace period until{' '} + to bring usage + back under quota. Review usage + + ) + }, }, GRACE_PERIOD_OVER: { title: 'Grace period is over', diff --git a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.schema.ts b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.schema.ts index fe59e5b5d79db..ee5782b85a46a 100644 --- a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.schema.ts +++ b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.schema.ts @@ -34,6 +34,9 @@ export const FormSchema = z dbPassStrengthMessage: z.string().default(''), dbPassStrengthWarning: z.string().default(''), instanceSize: z.string().optional(), + githubRepositoryId: z.string().optional().default(''), + githubInstallationId: z.number().optional(), + githubRepositoryName: z.string().optional().default(''), dataApi: z.boolean(), dataApiDefaultPrivileges: z.boolean(), enableRlsEventTrigger: z.boolean(), diff --git a/apps/studio/components/interfaces/ProjectHome/ActivityStats.tsx b/apps/studio/components/interfaces/ProjectHome/ActivityStats.tsx index d668179863539..f2308ef0ddb41 100644 --- a/apps/studio/components/interfaces/ProjectHome/ActivityStats.tsx +++ b/apps/studio/components/interfaces/ProjectHome/ActivityStats.tsx @@ -1,24 +1,34 @@ import { useParams } from 'common' import dayjs from 'dayjs' -import { Archive, Database, GitBranch } from 'lucide-react' +import { Archive, Cpu, Database, GitBranch, Github } from 'lucide-react' import { useMemo } from 'react' import { cn, Skeleton } from 'ui' import { TimestampInfo } from 'ui-patterns' +import { HighAvailabilityBadge } from './HighAvailabilityBadge' import { ServiceStatus } from './ServiceStatus' +import { ComputeBadgeWrapper } from '@/components/ui/ComputeBadgeWrapper' import { SingleStat } from '@/components/ui/SingleStat' import { useBranchesQuery } from '@/data/branches/branches-query' import { useBackupsQuery } from '@/data/database/backups-query' import { DatabaseMigration, useMigrationsQuery } from '@/data/database/migrations-query' +import { useGitHubConnectionsQuery } from '@/data/integrations/github-connections-query' +import { useResourceWarningsQuery } from '@/data/usage/resource-warnings-query' +import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { PROJECT_STATUS } from '@/lib/constants' import { EMPTY_ARR } from '@/lib/void' export const ActivityStats = () => { const { ref } = useParams() const { data: project } = useSelectedProjectQuery() + const { data: organization } = useSelectedOrganizationQuery() + const { data: resourceWarnings } = useResourceWarningsQuery({ slug: organization?.slug }) + const projectResourceWarnings = resourceWarnings?.find((warning) => warning.project === ref) + const parentProjectRef = project?.parent_project_ref ?? project?.ref const { data: branchesData, isPending: isLoadingBranches } = useBranchesQuery({ - projectRef: project?.parent_project_ref ?? project?.ref, + projectRef: parentProjectRef, }) const isDefaultProject = project?.parent_project_ref === undefined const currentBranch = useMemo( @@ -61,52 +71,63 @@ export const ActivityStats = () => { .sort((a, b) => new Date(b.inserted_at).valueOf() - new Date(a.inserted_at).valueOf())[0] }, [backupsData]) + const { data: githubConnections, isPending: isLoadingGithubConnections } = + useGitHubConnectionsQuery({ organizationId: organization?.id }, { enabled: !!organization?.id }) + const githubConnection = githubConnections?.find( + (connection) => connection.project.ref === parentProjectRef + ) + const isProjectComingUp = [PROJECT_STATUS.COMING_UP, PROJECT_STATUS.UNKNOWN].includes( + project?.status ?? PROJECT_STATUS.UNKNOWN + ) + const githubLabelText = githubConnection?.repository.name + ? githubConnection.repository.name + : isProjectComingUp + ? 'Waiting for project...' + : 'No repository connected' + const integrationsPath = parentProjectRef + ? `/project/${parentProjectRef}/settings/integrations` + : undefined + return (

} - label={Last migration} - trackingProperties={{ - stat_type: 'migrations', - stat_value: migrationsData?.length ?? 0, - }} + icon={} + label={Compute} value={ - isLoadingMigrations ? ( - - ) : ( -

- {migrationLabelText} -

- ) +
+ {project?.infra_compute_size ? ( + + ) : ( +

Unknown

+ )} + {project?.high_availability && } +
} /> } - label={Last backup} - trackingProperties={{ - stat_type: 'backups', - stat_value: backupsData?.backups?.length ?? 0, - }} + href={integrationsPath} + icon={} + label={GitHub} value={ - isLoadingBackups ? ( + isLoadingGithubConnections ? ( - ) : backupsData?.pitr_enabled ? ( -

PITR enabled

- ) : latestBackup ? ( - ) : ( -

No backups

+

+ {githubLabelText} +

) } /> @@ -143,6 +164,51 @@ export const ActivityStats = () => { ) } /> + + } + label={Last migration} + trackingProperties={{ + stat_type: 'migrations', + stat_value: migrationsData?.length ?? 0, + }} + value={ + isLoadingMigrations ? ( + + ) : ( +

+ {migrationLabelText} +

+ ) + } + /> + + } + label={Last backup} + trackingProperties={{ + stat_type: 'backups', + stat_value: backupsData?.backups?.length ?? 0, + }} + value={ + isLoadingBackups ? ( + + ) : backupsData?.pitr_enabled ? ( +

PITR enabled

+ ) : latestBackup ? ( + + ) : ( +

No backups

+ ) + } + />
) diff --git a/apps/studio/components/interfaces/ProjectHome/HighAvailabilityBadge.tsx b/apps/studio/components/interfaces/ProjectHome/HighAvailabilityBadge.tsx index 913a7ac349c77..48c1d50d7f325 100644 --- a/apps/studio/components/interfaces/ProjectHome/HighAvailabilityBadge.tsx +++ b/apps/studio/components/interfaces/ProjectHome/HighAvailabilityBadge.tsx @@ -15,7 +15,7 @@ export function HighAvailabilityBadge({ size = 'default' }: HighAvailabilityBadg
{size === 'small' ? 'HA' : 'High Availability'} +
-
Multigres
-

- A horizontally scalable Postgres architecture that supports highly-available and - globally distributed deployments. + Driven by Multigres, a horizontally scalable + Postgres architecture that supports highly-available and globally distributed + deployments.

{ const isOrioleDb = useIsOrioleDb() const { data: project } = useSelectedProjectQuery() - const { data: organization } = useSelectedOrganizationQuery() - const { data: resourceWarnings } = useResourceWarningsQuery({ slug: organization?.slug }) - const projectResourceWarnings = resourceWarnings?.find((w) => w.project === project?.ref) const { data: parentProject } = useProjectDetailQuery({ ref: project?.parent_project_ref }) const { data: branches } = useBranchesQuery({ @@ -61,31 +54,21 @@ export const TopSection = () => { )}

{projectName}

-
- {isOrioleDb && ( - - - OrioleDB - - - This project is using Postgres with OrioleDB which is currently in preview - and not suitable for production workloads. View our{' '} - - documentation - {' '} - for all limitations. - - - )} - - {project?.high_availability && } -
+ {isOrioleDb && ( + + + OrioleDB + + + This project is using Postgres with OrioleDB which is currently in preview and + not suitable for production workloads. View our{' '} + + documentation + {' '} + for all limitations. + + + )}
diff --git a/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx b/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx index 851dab9a85c12..72e326beee5e7 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GitHubIntegrationConnectionForm.tsx @@ -1,8 +1,8 @@ import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' import { AnimatePresence, motion } from 'framer-motion' -import { ChevronDown, Loader2, PlusIcon, RefreshCw } from 'lucide-react' -import { useEffect, useMemo, useState } from 'react' +import { Loader2 } from 'lucide-react' +import { useEffect, useState } from 'react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' import { @@ -11,20 +11,10 @@ import { CardContent, CardFooter, cn, - Command_Shadcn_, - CommandEmpty_Shadcn_, - CommandGroup_Shadcn_, - CommandInput_Shadcn_, - CommandItem_Shadcn_, - CommandList_Shadcn_, - CommandSeparator_Shadcn_, Form, FormControl, FormField, Input_Shadcn_, - Popover_Shadcn_, - PopoverContent_Shadcn_, - PopoverTrigger_Shadcn_, Switch, } from 'ui' import { Admonition } from 'ui-patterns/admonition' @@ -32,37 +22,25 @@ import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import * as z from 'zod' +import { + GitHubRepositoryField, + useGitHubRepositoryOptions, +} from '@/components/interfaces/Settings/Integrations/GithubIntegration/GitHubRepositoryField' import { InlineLink } from '@/components/ui/InlineLink' import { UpgradeToPro } from '@/components/ui/UpgradeToPro' import { useBranchCreateMutation } from '@/data/branches/branch-create-mutation' import { useBranchUpdateMutation } from '@/data/branches/branch-update-mutation' import { useBranchesQuery } from '@/data/branches/branches-query' -import { useGitHubAuthorizationQuery } from '@/data/integrations/github-authorization-query' import { useCheckGithubBranchValidity } from '@/data/integrations/github-branch-check-query' import { useGitHubConnectionCreateMutation } from '@/data/integrations/github-connection-create-mutation' import { useGitHubConnectionDeleteMutation } from '@/data/integrations/github-connection-delete-mutation' import { useGitHubConnectionUpdateMutation } from '@/data/integrations/github-connection-update-mutation' -import { useGitHubRepositoriesQuery } from '@/data/integrations/github-repositories-query' import type { GitHubConnection } from '@/data/integrations/integrations.types' import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { DOCS_URL } from '@/lib/constants' -import { openInstallGitHubIntegrationWindow } from '@/lib/github' -import { EMPTY_ARR } from '@/lib/void' - -const GITHUB_ICON = ( - - GitHub icon - - -) interface GitHubIntegrationConnectionFormProps { connection?: GitHubConnection @@ -75,7 +53,6 @@ export const GitHubIntegrationConnectionForm = ({ const { data: selectedOrganization } = useSelectedOrganizationQuery() const [isConfirmingBranchChange, setIsConfirmingBranchChange] = useState(false) const [isConfirmingRepoChange, setIsConfirmingRepoChange] = useState(false) - const [repoComboBoxOpen, setRepoComboboxOpen] = useState(false) const isParentProject = !selectedProject?.parent_project_ref const { hasAccess: hasAccessToGitHubIntegration, isLoading: isLoadingEntitlements } = @@ -92,23 +69,13 @@ export const GitHubIntegrationConnectionForm = ({ 'integrations.github_connections' ) - const { data: gitHubAuthorization, refetch: refetchGitHubAuthorization } = - useGitHubAuthorizationQuery() - const { - data: githubReposData, - isPending: isLoadingGitHubRepos, - refetch: refetchGitHubRepositories, - } = useGitHubRepositoriesQuery({ - enabled: Boolean(gitHubAuthorization), - }) - - const refetchGitHubAuthorizationAndRepositories = () => { - setTimeout(() => { - refetchGitHubAuthorization() - refetchGitHubRepositories() - }, 2000) // 2 second to delay to let github authorization and repositories to be updated - } + gitHubAuthorization, + githubRepos, + hasPartialResponseDueToSSO, + isLoading: isLoadingRepositoryOptions, + refetch: refetchRepositoryOptions, + } = useGitHubRepositoryOptions() const { mutate: updateBranch } = useBranchUpdateMutation({ onSuccess: () => { @@ -156,19 +123,6 @@ export const GitHubIntegrationConnectionForm = ({ const { mutate: updateConnectionSettings, isPending: isUpdatingConnection } = useGitHubConnectionUpdateMutation() - const githubRepos = useMemo( - () => - githubReposData?.repositories?.map((repo) => ({ - id: repo.id.toString(), - name: repo.name, - installation_id: repo.installation_id, - default_branch: repo.default_branch || 'main', - })) ?? EMPTY_ARR, - [githubReposData] - ) - - const hasPartialResponseDueToSSO = githubReposData?.partial_response_due_to_sso ?? false - const prodBranch = existingBranches?.find((branch) => branch.is_default) // Combined GitHub Settings Form @@ -224,9 +178,6 @@ export const GitHubIntegrationConnectionForm = ({ const newBranchPerPr = githubSettingsForm.watch('new_branch_per_pr') const currentRepositoryId = githubSettingsForm.watch('repositoryId') - // Calculate selected repository based on current form value - const selectedRepository = githubRepos.find((repo) => repo.id === currentRepositoryId) - const handleCreateOrUpdateConnection = async (data: z.infer) => { if (!selectedProject?.ref || !selectedOrganization?.id) return @@ -417,32 +368,12 @@ export const GitHubIntegrationConnectionForm = ({ } }, [enableProductionSync, githubSettingsForm]) - // Show authorization prompt if not authorized - if (gitHubAuthorization === null) { - return ( - - -

Authorize with GitHub

-

- Connect your GitHub account to access and select repositories for integration. -

- -
-
- ) - } - const isLoading = - isLoadingEntitlements || isCreatingConnection || isUpdatingConnection || isDeletingConnection + isLoadingEntitlements || + isCreatingConnection || + isUpdatingConnection || + isDeletingConnection || + isLoadingRepositoryOptions return ( <> @@ -453,122 +384,34 @@ export const GitHubIntegrationConnectionForm = ({ > - {/* Repository Selection */} - ( - - - - -
- } - iconRight={ - - - - } - > - {selectedRepository || connection - ? selectedRepository?.name || connection?.repository.name - : 'Choose GitHub repository'} - - - - - - - - No repositories found. - {githubRepos.length > 0 ? ( - - {githubRepos.map((repo, i) => ( - { - field.onChange(repo.id) - setRepoComboboxOpen(false) - githubSettingsForm.setValue( - 'branchName', - repo.default_branch || 'main' - ) - }} - > -
- {GITHUB_ICON} -
- - {repo.name} - -
- ))} -
- ) : null} - - - openInstallGitHubIntegrationWindow( - 'install', - refetchGitHubAuthorizationAndRepositories - ) - } - > - - Add GitHub Repositories - - - {hasPartialResponseDueToSSO && ( - <> - - - { - openInstallGitHubIntegrationWindow( - 'authorize', - refetchGitHubAuthorizationAndRepositories - ) - }} - > - -
- Re-authorize GitHub with SSO to show all repositories -
-
-
- - )} -
-
-
- - - )} + label="GitHub Repository" + layout="flex-row-reverse" + description={ + connection + ? 'Change the connected repository' + : 'Select the repository to connect to your project' + } + disabled={ + (!connection && !canCreateGitHubConnection) || + (connection && !canUpdateGitHubConnection) + } + selectedRepositoryName={connection?.repository.name} + repositories={githubRepos} + gitHubAuthorization={gitHubAuthorization} + hasPartialResponseDueToSSO={hasPartialResponseDueToSSO} + isLoading={isLoadingRepositoryOptions} + refetch={refetchRepositoryOptions} + onRepositorySelect={(repo) => { + githubSettingsForm.setValue('branchName', repo.default_branch || 'main') + }} /> - {!!currentRepositoryId && ( + {gitHubAuthorization !== null && !!currentRepositoryId && ( + GitHub icon + + +) + +export const useGitHubRepositoryOptions = () => { + const { + data: gitHubAuthorization, + isPending: isLoadingGitHubAuthorization, + refetch: refetchGitHubAuthorization, + } = useGitHubAuthorizationQuery() + + const { + data: githubReposData, + isPending: isLoadingGitHubRepos, + refetch: refetchGitHubRepositories, + } = useGitHubRepositoriesQuery({ + enabled: Boolean(gitHubAuthorization), + }) + + const githubRepos = useMemo( + () => + githubReposData?.repositories?.map((repo) => ({ + id: repo.id.toString(), + name: repo.name, + installation_id: repo.installation_id, + default_branch: repo.default_branch || 'main', + })) ?? EMPTY_ARR, + [githubReposData] + ) + + const refetchGitHubAuthorizationAndRepositories = () => { + setTimeout(() => { + refetchGitHubAuthorization() + refetchGitHubRepositories() + }, 2000) + } + + return { + gitHubAuthorization, + githubRepos, + hasPartialResponseDueToSSO: githubReposData?.partial_response_due_to_sso ?? false, + isLoading: isLoadingGitHubAuthorization || isLoadingGitHubRepos, + refetch: refetchGitHubAuthorizationAndRepositories, + } +} + +interface GitHubRepositoryFieldProps { + form: UseFormReturn + name: Path + label: string + description?: ReactNode + layout?: ComponentProps['layout'] + disabled?: boolean + selectedRepositoryName?: string + installationIdField?: Path + repositoryNameField?: Path + repositories: GitHubRepository[] + gitHubAuthorization: unknown | null + hasPartialResponseDueToSSO?: boolean + isLoading?: boolean + placeholder?: string + refetch: () => void + onConnectClick?: () => void + onRepositorySelect?: (repo: GitHubRepository) => void +} + +export const GitHubRepositoryField = ({ + form, + name, + label, + description, + layout = 'horizontal', + disabled = false, + selectedRepositoryName, + installationIdField, + repositoryNameField, + repositories, + gitHubAuthorization, + hasPartialResponseDueToSSO = false, + isLoading = false, + placeholder = 'Choose GitHub repository', + refetch, + onConnectClick, + onRepositorySelect, +}: GitHubRepositoryFieldProps) => { + const [isRepoSelectorOpen, setIsRepoSelectorOpen] = useState(false) + + const currentRepositoryId = form.watch(name) as string | undefined + const selectedRepository = repositories.find((repo) => repo.id === currentRepositoryId) + + return ( + ( + + {gitHubAuthorization === null ? ( + + + + ) : ( + + + + + + + + + + + No repositories found. + {repositories.length > 0 ? ( + + {repositories.map((repo) => ( + { + field.onChange(repo.id) + + if (installationIdField !== undefined) { + form.setValue(installationIdField, repo.installation_id as never, { + shouldDirty: true, + }) + } + + if (repositoryNameField !== undefined) { + form.setValue(repositoryNameField, repo.name as never, { + shouldDirty: true, + }) + } + + onRepositorySelect?.(repo) + setIsRepoSelectorOpen(false) + }} + > + {GITHUB_ICON} + + {repo.name} + + + ))} + + ) : null} + + { + setIsRepoSelectorOpen(false) + openInstallGitHubIntegrationWindow('install', refetch) + }} + > + + Add GitHub Repositories + + + {hasPartialResponseDueToSSO && ( + <> + + + { + setIsRepoSelectorOpen(false) + openInstallGitHubIntegrationWindow('authorize', refetch) + }} + > + +
+ Re-authorize GitHub with SSO to show all repositories +
+
+
+ + )} +
+
+
+
+ )} +
+ )} + /> + ) +} diff --git a/apps/studio/components/interfaces/Support/AIAssistantOption.tsx b/apps/studio/components/interfaces/Support/AIAssistantOption.tsx index ca547a2598891..a8b11ed3fc67d 100644 --- a/apps/studio/components/interfaces/Support/AIAssistantOption.tsx +++ b/apps/studio/components/interfaces/Support/AIAssistantOption.tsx @@ -10,9 +10,14 @@ import { useSendEventMutation } from '@/data/telemetry/send-event-mutation' interface AIAssistantOptionProps { projectRef?: string | null organizationSlug?: string | null + onClick?: () => void } -export const AIAssistantOption = ({ projectRef, organizationSlug }: AIAssistantOptionProps) => { +export const AIAssistantOption = ({ + projectRef, + organizationSlug, + onClick, +}: AIAssistantOptionProps) => { const { mutate: sendEvent } = useSendEventMutation() const [isVisible, setIsVisible] = useState(false) @@ -32,7 +37,9 @@ export const AIAssistantOption = ({ projectRef, organizationSlug }: AIAssistantO : organizationSlug, }, }) - }, [projectRef, organizationSlug, sendEvent]) + + onClick?.() + }, [onClick, projectRef, organizationSlug, sendEvent]) // If no specific project selected, use the wildcard route const aiLink = `/project/${projectRef !== NO_PROJECT_MARKER ? projectRef : '_'}?sidebar=ai-assistant&slug=${organizationSlug}` @@ -57,11 +64,22 @@ export const AIAssistantOption = ({ projectRef, organizationSlug }: AIAssistantO

- - - + ) : ( + + + + )}
{/* Decorative background */} diff --git a/apps/studio/components/interfaces/Support/AttachmentUpload.tsx b/apps/studio/components/interfaces/Support/AttachmentUpload.tsx index 50d8f04e00003..35680f0c8c0bc 100644 --- a/apps/studio/components/interfaces/Support/AttachmentUpload.tsx +++ b/apps/studio/components/interfaces/Support/AttachmentUpload.tsx @@ -112,9 +112,14 @@ export function useAttachmentUpload() { if (uploadedFiles.length === 0) return - const filenames = await uploadAttachments({ userId: profile.gotrue_id, files: uploadedFiles }) - const urls = await generateAttachmentURLs({ bucket: 'support-attachments', filenames }) - return urls + try { + const filenames = await uploadAttachments({ userId: profile.gotrue_id, files: uploadedFiles }) + const urls = await generateAttachmentURLs({ bucket: 'support-attachments', filenames }) + return urls + } catch { + // Ignore attachments upload errors, images are additional context and support can ask for more if needed + return + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [profile, uploadedFiles]) diff --git a/apps/studio/components/interfaces/Support/DashboardLogsToggle.tsx b/apps/studio/components/interfaces/Support/DashboardLogsToggle.tsx index 5144716dbc5d7..764384b269c52 100644 --- a/apps/studio/components/interfaces/Support/DashboardLogsToggle.tsx +++ b/apps/studio/components/interfaces/Support/DashboardLogsToggle.tsx @@ -16,9 +16,16 @@ import type { SupportFormValues } from './SupportForm.schema' interface DashboardLogsToggleProps { form: UseFormReturn sanitizedLog: unknown[] + align?: 'left' | 'right' + className?: string } -export function DashboardLogsToggle({ form, sanitizedLog }: DashboardLogsToggleProps) { +export function DashboardLogsToggle({ + form, + sanitizedLog, + align = 'left', + className, +}: DashboardLogsToggleProps) { const sanitizedLogJson = useMemo(() => JSON.stringify(sanitizedLog, null, 2), [sanitizedLog]) const [isPreviewOpen, setIsPreviewOpen] = useState(false) @@ -33,25 +40,34 @@ export function DashboardLogsToggle({ form, sanitizedLog }: DashboardLogsToggleP Include dashboard activity log } description={ -
+
Share sanitized logs of recent dashboard actions to help reproduce the issue. - - + + - Preview log + Preview log
diff --git a/apps/studio/components/interfaces/Support/IncidentAdmonition.tsx b/apps/studio/components/interfaces/Support/IncidentAdmonition.tsx
index 247f924a602db..a5e88c04735c2 100644
--- a/apps/studio/components/interfaces/Support/IncidentAdmonition.tsx
+++ b/apps/studio/components/interfaces/Support/IncidentAdmonition.tsx
@@ -9,6 +9,7 @@ import { processIncidentData } from '@/data/platform/incident-status-utils'
 
 interface IncidentAdmonitionProps {
   isActive: boolean
+  className?: string
 }
 
 const STATUS_DESCRIPTION_SIGN_OFF = 'Follow the status page for updates.'
@@ -53,7 +54,7 @@ const getStatusDescription = (
   }
 }
 
-export function IncidentAdmonition({ isActive }: IncidentAdmonitionProps) {
+export function IncidentAdmonition({ isActive, className }: IncidentAdmonitionProps) {
   const { data: allStatusPageEvents, isLoading, isError } = useIncidentStatusQuery()
   const { incidents = [] } = allStatusPageEvents ?? {}
 
@@ -83,6 +84,7 @@ export function IncidentAdmonition({ isActive }: IncidentAdmonitionProps) {
           
             )}
 
diff --git a/apps/studio/components/interfaces/Support/ProjectAndPlanInfo.tsx b/apps/studio/components/interfaces/Support/ProjectAndPlanInfo.tsx
index cd1799e1bd6ba..5afc2f7a769f1 100644
--- a/apps/studio/components/interfaces/Support/ProjectAndPlanInfo.tsx
+++ b/apps/studio/components/interfaces/Support/ProjectAndPlanInfo.tsx
@@ -24,16 +24,14 @@ interface ProjectAndPlanProps {
   projectRef: string | null
   category: ExtendedSupportCategories
   subscriptionPlanId: string | undefined
-  showPlanExpectationInfo?: boolean
 }
 
 export function ProjectAndPlanInfo({
   form,
   orgSlug,
   projectRef,
-  category,
-  subscriptionPlanId,
-  showPlanExpectationInfo = true,
+  category: _category,
+  subscriptionPlanId: _subscriptionPlanId,
 }: ProjectAndPlanProps) {
   const hasProjectSelected = projectRef && projectRef !== NO_PROJECT_MARKER
 
@@ -43,14 +41,6 @@ export function ProjectAndPlanInfo({
       
 
       {!hasProjectSelected && }
-
-      {showPlanExpectationInfo &&
-        orgSlug &&
-        subscriptionPlanId !== 'enterprise' &&
-        subscriptionPlanId !== 'platform' &&
-        category !== 'Login_issues' && (
-          
-        )}
     
) } @@ -163,66 +153,60 @@ function ProjectRefHighlighted({ projectRef }: ProjectRefHighlightedProps) { ) } -interface PlanExpectationInfoBoxProps { +interface PlanExpectationInfoContentProps { orgSlug: string planId?: string } -const PlanExpectationInfoBox = ({ orgSlug, planId }: PlanExpectationInfoBoxProps) => { +export const PlanExpectationInfoContent = ({ + orgSlug, + planId, +}: PlanExpectationInfoContentProps) => { const { billingAll } = useIsFeatureEnabled(['billing:all']) const shouldShowUpgradeActions = billingAll && planId !== 'enterprise' return ( - - {planId === 'free' && ( -

- Support on the Free plan is provided through the community and by the team on a - best-effort basis. For a guaranteed response time, we recommend upgrading to the Pro - plan. Enhanced support SLAs are available on the Enterprise plan. -

- )} - - {planId === 'pro' && ( -

- The Pro plan includes email support. In most cases, you can expect a response within 1 - business day for all severities. For prioritized ticketing on all issues and - prioritized escalation to product engineering, we recommend upgrading to the Team - plan. Enhanced support SLAs are available on the Enterprise plan. -

- )} - - {planId === 'team' && ( -

- The Team plan includes email support with prioritized ticketing and escalation to - product engineering. Low, normal, and high-severity tickets are typically handled - within 1 business day. Urgent issues are handled within 1 day, 365 days a year. - Enhanced support SLAs are available on the Enterprise plan. -

- )} - - } - actions={ - shouldShowUpgradeActions && ( - <> - - - - ) - } - /> +
+ {planId === 'free' && ( +

+ Support on the Free plan is provided through the community and by the team on a + best-effort basis. For a guaranteed response time, we recommend upgrading to the Pro plan. + Enhanced support SLAs are available on the Enterprise plan. +

+ )} + + {planId === 'pro' && ( +

+ Pro includes email support with typical 1-business-day responses; upgrade to Team for + prioritized ticketing and engineering escalation, or Enterprise for enhanced SLAs. +

+ )} + + {planId === 'team' && ( +

+ The Team plan includes email support with prioritized ticketing and escalation to product + engineering. Low, normal, and high-severity tickets are typically handled within 1 + business day. Urgent issues are handled within 1 day, 365 days a year. Enhanced support + SLAs are available on the Enterprise plan. +

+ )} + + {shouldShowUpgradeActions && ( +
+ + +
+ )} +
) } diff --git a/apps/studio/components/interfaces/Support/SubmitButton.tsx b/apps/studio/components/interfaces/Support/SubmitButton.tsx index f7d654be9bc68..5dfb36923dd5a 100644 --- a/apps/studio/components/interfaces/Support/SubmitButton.tsx +++ b/apps/studio/components/interfaces/Support/SubmitButton.tsx @@ -1,17 +1,25 @@ import type { MouseEventHandler } from 'react' // End of third-party imports -import { Button } from 'ui' +import { Button, cn } from 'ui' interface SubmitButtonProps { isSubmitting: boolean userEmail: string onClick?: MouseEventHandler + className?: string + descriptionClassName?: string } -export function SubmitButton({ isSubmitting, userEmail, onClick }: SubmitButtonProps) { +export function SubmitButton({ + isSubmitting, + userEmail, + onClick, + className, + descriptionClassName, +}: SubmitButtonProps) { return ( -
+
-

+

We will contact you at {userEmail}. Please ensure emails from supabase.com are allowed.

diff --git a/apps/studio/components/interfaces/Support/Success.test.tsx b/apps/studio/components/interfaces/Support/Success.test.tsx new file mode 100644 index 0000000000000..9fca65cc8a997 --- /dev/null +++ b/apps/studio/components/interfaces/Support/Success.test.tsx @@ -0,0 +1,20 @@ +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' + +import { Success } from './Success' +import { customRender } from '@/tests/lib/custom-render' + +describe('Success', () => { + it('renders a local finish action when provided', async () => { + const onFinish = vi.fn() + + customRender() + + expect(screen.queryByRole('link', { name: 'Done' })).not.toBeInTheDocument() + + await userEvent.click(screen.getByRole('button', { name: 'Done' })) + + expect(onFinish).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/studio/components/interfaces/Support/Success.tsx b/apps/studio/components/interfaces/Support/Success.tsx index b00e83e320b26..2ee26a9ef1303 100644 --- a/apps/studio/components/interfaces/Support/Success.tsx +++ b/apps/studio/components/interfaces/Support/Success.tsx @@ -9,11 +9,15 @@ import { useProfile } from '@/lib/profile' interface SuccessProps { sentCategory?: string selectedProject?: string + onFinish?: () => void + finishLabel?: string } export const Success = ({ sentCategory = '', selectedProject = NO_PROJECT_MARKER, + onFinish, + finishLabel = 'Finish', }: SuccessProps) => { const { profile } = useProfile() const respondToEmail = profile?.primary_email ?? 'your email' @@ -29,8 +33,8 @@ export const Success = ({ return (
- -
+ +
@@ -76,9 +80,15 @@ export const Success = ({
- + {onFinish ? ( + + ) : ( + + )}
) diff --git a/apps/studio/components/interfaces/Support/SupportAccessToggle.tsx b/apps/studio/components/interfaces/Support/SupportAccessToggle.tsx index 535aeb70d3ee5..a809814a2115c 100644 --- a/apps/studio/components/interfaces/Support/SupportAccessToggle.tsx +++ b/apps/studio/components/interfaces/Support/SupportAccessToggle.tsx @@ -25,9 +25,11 @@ export const DISABLE_SUPPORT_ACCESS_CATEGORIES: ExtendedSupportCategories[] = [ interface SupportAccessToggleProps { form: UseFormReturn + align?: 'left' | 'right' + className?: string } -export function SupportAccessToggle({ form }: SupportAccessToggleProps) { +export function SupportAccessToggle({ form, align = 'left', className }: SupportAccessToggleProps) { return ( Allow support access to your project diff --git a/apps/studio/components/interfaces/Support/SupportForm.utils.tsx b/apps/studio/components/interfaces/Support/SupportForm.utils.tsx index 9db7dd67cc238..d88874ffb46c0 100644 --- a/apps/studio/components/interfaces/Support/SupportForm.utils.tsx +++ b/apps/studio/components/interfaces/Support/SupportForm.utils.tsx @@ -139,6 +139,18 @@ export type SupportFormUrlKeys = inferParserType export const loadSupportFormInitialParams = createLoader(supportFormUrlState) +export function loadSupportFormInitialParamsFromObject( + initialParams: Partial +): SupportFormUrlKeys { + const normalizedParams = Object.fromEntries( + Object.entries(initialParams).flatMap(([key, value]) => + value == null ? [] : [[key, String(value)]] + ) + ) + + return loadSupportFormInitialParams(normalizedParams) +} + const serializeSupportFormInitialParams = createSerializer(supportFormUrlState) export function createSupportFormUrl(initialParams: Partial) { diff --git a/apps/studio/components/interfaces/Support/SupportFormDirectEmailInfo.tsx b/apps/studio/components/interfaces/Support/SupportFormDirectEmailInfo.tsx new file mode 100644 index 0000000000000..e6f9d8d4eeed2 --- /dev/null +++ b/apps/studio/components/interfaces/Support/SupportFormDirectEmailInfo.tsx @@ -0,0 +1,52 @@ +import { toast } from 'sonner' + +import { NO_PROJECT_MARKER } from './SupportForm.utils' +import CopyButton from '@/components/ui/CopyButton' + +interface SupportFormDirectEmailContentProps { + projectRef: string | null +} + +export function SupportFormDirectEmailContent({ projectRef }: SupportFormDirectEmailContentProps) { + const hasProjectRef = projectRef && projectRef !== NO_PROJECT_MARKER + + return ( +
+

+ Please email us directly at + + + + support@supabase.com + + + toast.success('Copied email address to clipboard')} + /> + {' '} + and include as much information as possible + {hasProjectRef && ( + <> + , along with project ID + + {projectRef} + toast.success('Copied project ID to clipboard')} + /> + + + )} + . +

+
+ ) +} diff --git a/apps/studio/components/interfaces/Support/SupportFormPage.tsx b/apps/studio/components/interfaces/Support/SupportFormPage.tsx index 8c66fe3de7211..0606331122d07 100644 --- a/apps/studio/components/interfaces/Support/SupportFormPage.tsx +++ b/apps/studio/components/interfaces/Support/SupportFormPage.tsx @@ -29,6 +29,8 @@ import { useSendEventMutation } from '@/data/telemetry/send-event-mutation' import { useStateTransition } from '@/hooks/misc/useStateTransition' import { BASE_PATH, DOCS_URL } from '@/lib/constants' +export { SupportForm, SupportFormStatusButton } from './SupportSidebarForm' + function useSupportFormTelemetry() { const { mutate: sendEvent } = useSendEventMutation() @@ -97,7 +99,6 @@ function SupportFormPageContent() { - {/* Only show AI Assistant and Discord CTAs if there are no active incidents and the user is still filling out the support form*/} {!isSuccess && !hasActiveIncidents && (
@@ -134,7 +135,7 @@ function SupportFormHeader() { const isIncident = incidents.length > 0 return ( -
+

Supabase support

diff --git a/apps/studio/components/interfaces/Support/SupportFormV2.tsx b/apps/studio/components/interfaces/Support/SupportFormV2.tsx index a0c9d60b1f09c..a81cd52d0c408 100644 --- a/apps/studio/components/interfaces/Support/SupportFormV2.tsx +++ b/apps/studio/components/interfaces/Support/SupportFormV2.tsx @@ -236,14 +236,14 @@ export const SupportFormV2 = ({ form, initialError, state, dispatch }: SupportFo {DASHBOARD_LOG_CATEGORIES.includes(category) && ( <> - + )} {!!category && !DISABLE_SUPPORT_ACCESS_CATEGORIES.includes(category) && ( <> - + )} diff --git a/apps/studio/components/interfaces/Support/SupportFormV3.tsx b/apps/studio/components/interfaces/Support/SupportFormV3.tsx new file mode 100644 index 0000000000000..095f2fe293377 --- /dev/null +++ b/apps/studio/components/interfaces/Support/SupportFormV3.tsx @@ -0,0 +1,316 @@ +// End of third-party imports +import { SupportCategories } from '@supabase/shared-types/out/constants' +import { useConstant, useFlag } from 'common' +import { CLIENT_LIBRARIES } from 'common/constants' +import { type Dispatch, type MouseEventHandler } from 'react' +import type { SubmitHandler, UseFormReturn } from 'react-hook-form' +import { Form, Separator } from 'ui' + +import { + AffectedServicesSelector, + CATEGORIES_WITHOUT_AFFECTED_SERVICES, +} from './AffectedServicesSelector' +import { AttachmentUploadDisplay, useAttachmentUpload } from './AttachmentUpload' +import { CategoryAndSeverityInfo } from './CategoryAndSeverityInfo' +import { ClientLibraryInfo } from './ClientLibraryInfo' +import { + DASHBOARD_LOG_CATEGORIES, + getSanitizedBreadcrumbs, + uploadDashboardLog, +} from './dashboard-logs' +import { DashboardLogsToggle } from './DashboardLogsToggle' +import { MessageField } from './MessageField' +import { OrganizationSelector } from './OrganizationSelector' +import { PlanExpectationInfoContent, ProjectAndPlanInfo } from './ProjectAndPlanInfo' +import { SubjectAndSuggestionsInfo } from './SubjectAndSuggestionsInfo' +import { SubmitButton } from './SubmitButton' +import { DISABLE_SUPPORT_ACCESS_CATEGORIES, SupportAccessToggle } from './SupportAccessToggle' +import type { SupportFormValues } from './SupportForm.schema' +import type { SupportFormActions, SupportFormState } from './SupportForm.state' +import { + formatMessage, + formatStudioVersion, + getOrgSubscriptionPlan, + NO_ORG_MARKER, + NO_PROJECT_MARKER, +} from './SupportForm.utils' +import { SupportFormDirectEmailContent } from './SupportFormDirectEmailInfo' +import { getProjectAuthConfig } from '@/data/auth/auth-config-query' +import { useSendSupportTicketMutation } from '@/data/feedback/support-ticket-send' +import { type OrganizationPlanID } from '@/data/organizations/organization-query' +import { useOrganizationsQuery } from '@/data/organizations/organizations-query' +import { useGenerateAttachmentURLsMutation } from '@/data/support/generate-attachment-urls-mutation' +import { useDeploymentCommitQuery } from '@/data/utils/deployment-commit-query' +import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' +import { detectBrowser } from '@/lib/helpers' +import { useProfile } from '@/lib/profile' + +const useIsSimplifiedForm = (slug: string, subscriptionPlanId?: OrganizationPlanID) => { + const simplifiedSupportForm = useFlag('simplifiedSupportForm') + + if (subscriptionPlanId === 'platform') { + return true + } + + if (typeof simplifiedSupportForm === 'string') { + const slugs = (simplifiedSupportForm as string).split(',').map((x) => x.trim()) + return slugs.includes(slug) + } + + return false +} + +interface SupportFormV3Props { + form: UseFormReturn + initialError: string | null + state: SupportFormState + dispatch: Dispatch + selectedProjectRef?: string | null +} + +export const SupportFormV3 = ({ + form, + initialError, + state, + dispatch, + selectedProjectRef, +}: SupportFormV3Props) => { + const { profile } = useProfile() + const respondToEmail = profile?.primary_email ?? 'your email' + + const { organizationSlug, projectRef, category, severity, subject, library } = form.watch() + + const selectedOrgSlug = organizationSlug === NO_ORG_MARKER ? null : organizationSlug + const currentProjectRef = projectRef === NO_PROJECT_MARKER ? null : projectRef + + const { data: organizations } = useOrganizationsQuery() + const subscriptionPlanId = getOrgSubscriptionPlan(organizations, selectedOrgSlug) + const simplifiedSupportForm = useIsSimplifiedForm(organizationSlug, subscriptionPlanId) + const showClientLibraries = useIsFeatureEnabled('support:show_client_libraries') + + const attachmentUpload = useAttachmentUpload() + const { mutateAsync: uploadDashboardLogFn } = useGenerateAttachmentURLsMutation() + + const sanitizedLogSnapshot = useConstant(getSanitizedBreadcrumbs) + + const { data: commit } = useDeploymentCommitQuery({ + staleTime: 1000 * 60 * 10, + }) + + const { mutate: submitSupportTicket } = useSendSupportTicketMutation({ + onSuccess: (_, variables) => { + dispatch({ + type: 'SUCCESS', + sentProjectRef: variables.projectRef, + sentOrgSlug: variables.organizationSlug, + sentCategory: variables.category, + }) + }, + onError: (error) => { + dispatch({ + type: 'ERROR', + message: error.message, + }) + }, + }) + + const onSubmit: SubmitHandler = async (formValues) => { + if ( + !simplifiedSupportForm && + showClientLibraries && + formValues.category === SupportCategories.PROBLEM && + !formValues.library + ) { + form.setError('library', { + type: 'manual', + message: "Please select the library that you're facing issues with", + }) + return + } + + dispatch({ type: 'SUBMIT' }) + + const { attachDashboardLogs: formAttachDashboardLogs, ...values } = formValues + const attachDashboardLogs = + formAttachDashboardLogs && DASHBOARD_LOG_CATEGORIES.includes(values.category) + + const [attachments, dashboardLogUrl] = await Promise.all([ + attachmentUpload.createAttachments(), + attachDashboardLogs + ? uploadDashboardLog({ + userId: profile?.gotrue_id, + sanitizedLogs: sanitizedLogSnapshot, + uploadDashboardLogFn, + }) + : undefined, + ]) + + const selectedLibrary = values.library + ? CLIENT_LIBRARIES.find((library) => library.language === values.library) + : undefined + + const payload = { + ...values, + organizationSlug: values.organizationSlug ?? NO_ORG_MARKER, + projectRef: values.projectRef ?? NO_PROJECT_MARKER, + allowSupportAccess: + values.category && !DISABLE_SUPPORT_ACCESS_CATEGORIES.includes(values.category) + ? values.allowSupportAccess + : false, + library: + values.category === SupportCategories.PROBLEM && selectedLibrary !== undefined + ? selectedLibrary.key + : '', + message: formatMessage({ + message: values.message, + attachments, + error: initialError, + }), + verified: true, + tags: ['dashboard-support-form'], + siteUrl: '', + additionalRedirectUrls: '', + affectedServices: CATEGORIES_WITHOUT_AFFECTED_SERVICES.includes(values.category) + ? '' + : values.affectedServices + .split(',') + .map((x) => x.trim().replace(/ /g, '_').toLowerCase()) + .join(';'), + browserInformation: detectBrowser(), + dashboardLogs: dashboardLogUrl?.[0], + dashboardStudioVersion: commit ? formatStudioVersion(commit) : undefined, + } + + if (values.projectRef !== NO_PROJECT_MARKER) { + try { + const authConfig = await getProjectAuthConfig({ + projectRef: values.projectRef, + }) + payload.siteUrl = authConfig.SITE_URL + payload.additionalRedirectUrls = authConfig.URI_ALLOW_LIST + } catch { + // Nice-to-have only + } + } + + submitSupportTicket(payload) + } + + const handleFormSubmit = form.handleSubmit(onSubmit) + + const handleSubmitButtonClick: MouseEventHandler = (event) => { + handleFormSubmit(event) + } + + const showPlanExpectationInfo = + !!selectedOrgSlug && + subscriptionPlanId !== 'enterprise' && + subscriptionPlanId !== 'platform' && + category !== 'Login_issues' + const showDirectEmailInfo = state.type !== 'success' && selectedProjectRef !== undefined + + return ( +
+ +
+ + + +
+ +
+ + {!simplifiedSupportForm && ( + <> + + + + )} + + +
+ + {(DASHBOARD_LOG_CATEGORIES.includes(category) || + (!!category && !DISABLE_SUPPORT_ACCESS_CATEGORIES.includes(category)) || + showPlanExpectationInfo || + showDirectEmailInfo) && ( +
+ + + {DASHBOARD_LOG_CATEGORIES.includes(category) && ( + + )} + + {!!category && !DISABLE_SUPPORT_ACCESS_CATEGORIES.includes(category) && ( + + )} + + {(showPlanExpectationInfo || showDirectEmailInfo) && ( + + )} +
+ )} + +
+ +
+
+ + ) +} + +interface SupportFormV3AdditionalInfoSectionProps { + orgSlug: string | null + subscriptionPlanId?: OrganizationPlanID + projectRef: string | null + showPlanExpectationInfo: boolean + showDirectEmailInfo: boolean +} + +function SupportFormV3AdditionalInfoSection({ + orgSlug, + subscriptionPlanId, + projectRef, + showPlanExpectationInfo, + showDirectEmailInfo, +}: SupportFormV3AdditionalInfoSectionProps) { + return ( +
+ {showPlanExpectationInfo && orgSlug && ( +
+
Support varies by plan
+ +
+ )} + + {showDirectEmailInfo && ( +
+
Having trouble submitting the form?
+ +
+ )} +
+ ) +} diff --git a/apps/studio/components/interfaces/Support/SupportSidebarForm.tsx b/apps/studio/components/interfaces/Support/SupportSidebarForm.tsx new file mode 100644 index 0000000000000..4d45bc006b260 --- /dev/null +++ b/apps/studio/components/interfaces/Support/SupportSidebarForm.tsx @@ -0,0 +1,153 @@ +import * as Sentry from '@sentry/nextjs' +import { Loader2 } from 'lucide-react' +import Link from 'next/link' +import { useCallback, useReducer } from 'react' +import { toast } from 'sonner' +import { Button, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' + +import { IncidentAdmonition } from './IncidentAdmonition' +import { Success } from './Success' +import type { ExtendedSupportCategories } from './Support.constants' +import { createInitialSupportFormState, supportFormReducer } from './SupportForm.state' +import type { SupportFormUrlKeys } from './SupportForm.utils' +import { SupportFormV3 } from './SupportFormV3' +import { useSupportForm } from './useSupportForm' +import { useIncidentStatusQuery } from '@/data/platform/incident-status-query' +import { useSendEventMutation } from '@/data/telemetry/send-event-mutation' +import { useStateTransition } from '@/hooks/misc/useStateTransition' + +function useSupportFormTelemetry() { + const { mutate: sendEvent } = useSendEventMutation() + + return useCallback( + ({ + projectRef, + orgSlug, + category, + }: { + projectRef: string | undefined + orgSlug: string | undefined + category: ExtendedSupportCategories + }) => + sendEvent({ + action: 'support_ticket_submitted', + properties: { + ticketCategory: category, + }, + groups: { + project: projectRef, + organization: orgSlug, + }, + }), + [sendEvent] + ) +} + +interface SupportFormProps { + initialParams?: Partial + onFinish?: () => void +} + +export function SupportForm({ initialParams, onFinish }: SupportFormProps) { + const [state, dispatch] = useReducer(supportFormReducer, undefined, createInitialSupportFormState) + const { form, initialError, projectRef } = useSupportForm(dispatch, initialParams) + + const { + data: allStatusPageEvents, + isPending: isIncidentsPending, + isError: isIncidentsError, + } = useIncidentStatusQuery() + const { incidents = [] } = allStatusPageEvents ?? {} + const hasActiveIncidents = + !isIncidentsPending && !isIncidentsError && incidents && incidents.length > 0 + + const sendTelemetry = useSupportFormTelemetry() + useStateTransition(state, 'submitting', 'success', (_, curr) => { + toast.success('Support request sent. Thank you!') + sendTelemetry({ + projectRef: curr.sentProjectRef, + orgSlug: curr.sentOrgSlug, + category: curr.sentCategory, + }) + }) + + useStateTransition(state, 'submitting', 'error', (_, curr) => { + toast.error(`Failed to submit support ticket: ${curr.message}`) + Sentry.captureMessage(`Failed to submit Support Form: ${curr.message}`) + dispatch({ type: 'RETURN_TO_EDITING' }) + }) + + const isSuccess = state.type === 'success' + + return ( +
+ +
+
+ {isSuccess ? ( +
+ +
+ ) : ( + + )} +
+
+
+ ) +} + +export function SupportFormStatusButton() { + const { data: allStatusPageEvents, isPending: isLoading, isError } = useIncidentStatusQuery() + const { incidents = [], maintenanceEvents = [] } = allStatusPageEvents ?? {} + const isMaintenance = maintenanceEvents.length > 0 + const isIncident = incidents.length > 0 + + return ( + + + + + + Check the Supabase status page + + + ) +} diff --git a/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx b/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx index 7f23f15cedcce..a76533ad5af55 100644 --- a/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx +++ b/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx @@ -5,7 +5,7 @@ import { http, HttpResponse } from 'msw' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { NO_ORG_MARKER, NO_PROJECT_MARKER } from '../SupportForm.utils' -import { SupportFormPage } from '../SupportFormPage' +import { SupportForm, SupportFormPage, SupportFormStatusButton } from '../SupportFormPage' // End of third-party imports import { API_URL, BASE_PATH } from '@/lib/constants' @@ -188,6 +188,21 @@ const renderSupportFormPage = (options?: Parameters[1]) => ...options, }) +const renderSupportForm = ( + props?: Parameters[0], + options?: Parameters[1] +) => + customRender(, { + profileContext: createMockProfileContext(), + ...options, + }) + +const renderSupportFormStatusButton = (options?: Parameters[1]) => + customRender(, { + profileContext: createMockProfileContext(), + ...options, + }) + const getStatusLink = (screen: Screen) => { const statusLink = screen .getAllByRole('link') @@ -458,7 +473,7 @@ describe('SupportFormPage', () => { }) test('shows system status: healthy', async () => { - renderSupportFormPage() + renderSupportFormStatusButton() await waitFor(() => { expect(getStatusLink(screen)).toHaveTextContent('All systems operational') @@ -483,7 +498,7 @@ describe('SupportFormPage', () => { ) ) - renderSupportFormPage() + renderSupportFormStatusButton() await waitFor(() => { expect(getStatusLink(screen)).toHaveTextContent('Active incident ongoing') @@ -497,13 +512,24 @@ describe('SupportFormPage', () => { ) ) - renderSupportFormPage() + renderSupportFormStatusButton() await waitFor(() => { expect(getStatusLink(screen)).toHaveTextContent('Failed to check status') }) }) + test('loading with initial params prefills the organization and project', async () => { + renderSupportForm({ initialParams: { projectRef: 'project-3' } }) + + await waitFor(() => { + expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') + expect(screen.getByRole('combobox', { name: 'Select a project' })).toHaveTextContent( + 'Project 3' + ) + }) + }) + test('loading a URL with a valid project slug prefills the organization and project', async () => { Object.defineProperty(window, 'location', { value: createMockLocation('?projectRef=project-3'), diff --git a/apps/studio/components/interfaces/Support/useSupportForm.ts b/apps/studio/components/interfaces/Support/useSupportForm.ts index e224d02956fcd..a821910b2509e 100644 --- a/apps/studio/components/interfaces/Support/useSupportForm.ts +++ b/apps/studio/components/interfaces/Support/useSupportForm.ts @@ -6,6 +6,7 @@ import { SupportFormSchema, type SupportFormValues } from './SupportForm.schema' import type { SupportFormActions } from './SupportForm.state' import { loadSupportFormInitialParams, + loadSupportFormInitialParamsFromObject, NO_ORG_MARKER, NO_PROJECT_MARKER, selectInitialOrgAndProject, @@ -36,7 +37,10 @@ interface UseSupportFormResult { orgSlug: string | null } -export function useSupportForm(dispatch: Dispatch): UseSupportFormResult { +export function useSupportForm( + dispatch: Dispatch, + initialParams?: Partial +): UseSupportFormResult { const form = useForm({ mode: 'onBlur', reValidateMode: 'onBlur', @@ -45,11 +49,15 @@ export function useSupportForm(dispatch: Dispatch): UseSuppo }) const urlParamsRef = useRef(null) + const providedInitialParamsRef = useRef(initialParams) const [initialError, setInitialError] = useState(null) // Load initial values from URL params useEffect(() => { - const params = loadSupportFormInitialParams(window.location.search) + const params = + providedInitialParamsRef.current !== undefined + ? loadSupportFormInitialParamsFromObject(providedInitialParamsRef.current) + : loadSupportFormInitialParams(window.location.search) urlParamsRef.current = params setInitialError(params.error ?? null) diff --git a/apps/studio/components/layouts/DatabaseLayout/DatabaseLayout.tsx b/apps/studio/components/layouts/DatabaseLayout/DatabaseLayout.tsx index 7365cd2c3c375..ccdb26b4e5dd0 100644 --- a/apps/studio/components/layouts/DatabaseLayout/DatabaseLayout.tsx +++ b/apps/studio/components/layouts/DatabaseLayout/DatabaseLayout.tsx @@ -3,8 +3,8 @@ import type { PropsWithChildren } from 'react' import { ProjectLayout } from '../ProjectLayout' import { useGenerateDatabaseMenu } from './DatabaseMenu.utils' -import { DatabaseNavShortcuts } from '@/components/interfaces/DatabaseNavShortcuts' import { ProductMenu } from '@/components/ui/ProductMenu' +import { ProductMenuShortcuts } from '@/components/ui/ProductMenu/ProductMenuShortcuts' import { withAuth } from '@/hooks/misc/withAuth' export interface DatabaseLayoutProps { @@ -20,14 +20,18 @@ export const DatabaseProductMenu = () => { } const DatabaseLayout = ({ children, title }: PropsWithChildren) => { + const router = useRouter() + const page = router.pathname.split('/')[4] + const menu = useGenerateDatabaseMenu() + return ( } + productMenu={} isBlocking={false} > - + {children} ) diff --git a/apps/studio/components/ui/GlobalShortcuts/ShortcutsReferenceSheet.test.tsx b/apps/studio/components/ui/GlobalShortcuts/ShortcutsReferenceSheet.test.tsx index 9f52cba6611b8..433a5a3cd4d90 100644 --- a/apps/studio/components/ui/GlobalShortcuts/ShortcutsReferenceSheet.test.tsx +++ b/apps/studio/components/ui/GlobalShortcuts/ShortcutsReferenceSheet.test.tsx @@ -1,16 +1,71 @@ +import type { HotkeyRegistrationView, SequenceRegistrationView } from '@tanstack/react-hotkeys' import { screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { ShortcutsReferenceSheet } from './ShortcutsReferenceSheet' -import { SHORTCUT_DEFINITIONS } from '@/state/shortcuts/registry' +import { SHORTCUT_DEFINITIONS, SHORTCUT_IDS, type ShortcutId } from '@/state/shortcuts/registry' +import type { ShortcutHotkeyMeta } from '@/state/shortcuts/useShortcut' 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 { mockUseHotkeyRegistrations } = vi.hoisted(() => ({ + mockUseHotkeyRegistrations: + vi.fn<() => { hotkeys: HotkeyRegistrationView[]; sequences: SequenceRegistrationView[] }>(), +})) + +vi.mock('@tanstack/react-hotkeys', async () => { + const actual = + await vi.importActual('@tanstack/react-hotkeys') + return { + ...actual, + useHotkeyRegistrations: mockUseHotkeyRegistrations, + } +}) + +const ACTIVE_SHORTCUT_IDS = [ + SHORTCUT_IDS.COMMAND_MENU_OPEN, + SHORTCUT_IDS.NAV_HOME, +] satisfies ShortcutId[] + +const ACTIVE_DATABASE_SHORTCUT_IDS = [ + ...ACTIVE_SHORTCUT_IDS, + SHORTCUT_IDS.NAV_DATABASE_TABLES, +] satisfies ShortcutId[] + +let sequenceIdCounter = 0 + +const buildSequenceRegistration = (id: ShortcutId): SequenceRegistrationView => { + const definition = SHORTCUT_DEFINITIONS[id] + const meta: ShortcutHotkeyMeta = { + id: definition.id, + name: definition.label, + referenceGroup: definition.referenceGroup, + } + + return { + id: `sequence_${++sequenceIdCounter}`, + sequence: definition.sequence, + options: { + enabled: true, + meta, + }, + target: document, + triggerCount: 0, + hasFired: false, + matchedStepCount: 0, + partialMatchLastKeyTime: 0, + } +} -const renderShortcutsReferenceSheet = () => { +const seedRegistrations = (ids: ShortcutId[]) => { + mockUseHotkeyRegistrations.mockReturnValue({ + hotkeys: [], + sequences: ids.map(buildSequenceRegistration), + }) +} + +const renderShortcutsReferenceSheet = (ids: ShortcutId[] = ACTIVE_SHORTCUT_IDS) => { + seedRegistrations(ids) const onOpenChange = vi.fn() customRender() @@ -19,6 +74,12 @@ const renderShortcutsReferenceSheet = () => { } describe('ShortcutsReferenceSheet', () => { + beforeEach(() => { + sequenceIdCounter = 0 + mockUseHotkeyRegistrations.mockReset() + mockUseHotkeyRegistrations.mockReturnValue({ hotkeys: [], sequences: [] }) + }) + it('renders the grouped shortcut list by default', async () => { renderShortcutsReferenceSheet() @@ -26,11 +87,13 @@ describe('ShortcutsReferenceSheet', () => { expect(screen.getByLabelText('Search shortcuts')).toBeInTheDocument() expect(screen.getByText('Command Menu')).toBeInTheDocument() expect(screen.getByText('Navigation')).toBeInTheDocument() + expect(screen.queryByText('Global Navigation')).not.toBeInTheDocument() + expect(screen.queryByText('Database Navigation')).not.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 () => { + it('shows only active shortcuts in a group when the group label matches the search', async () => { const user = userEvent.setup() renderShortcutsReferenceSheet() @@ -38,11 +101,9 @@ describe('ShortcutsReferenceSheet', () => { await user.type(screen.getByLabelText('Search shortcuts'), 'navigation') expect(screen.getByText('Navigation')).toBeInTheDocument() + expect(screen.getByText('Go to Project Overview')).toBeInTheDocument() expect(screen.queryByText('Command Menu')).not.toBeInTheDocument() - - for (const label of NAVIGATION_LABELS) { - expect(screen.getByText(label)).toBeInTheDocument() - } + expect(screen.queryByText('Go to Database')).not.toBeInTheDocument() }) it('keeps the parent group header when only an item label matches', async () => { @@ -50,14 +111,50 @@ describe('ShortcutsReferenceSheet', () => { renderShortcutsReferenceSheet() - await user.type(screen.getByLabelText('Search shortcuts'), 'Go to Organization Integrations') + await user.type(screen.getByLabelText('Search shortcuts'), 'Go to Project Overview') expect(screen.getByText('Navigation')).toBeInTheDocument() - expect(screen.getByText('Go to Organization Integrations')).toBeInTheDocument() - expect(screen.queryByText('Go to Logs')).not.toBeInTheDocument() + expect(screen.getByText('Go to Project Overview')).toBeInTheDocument() + expect(screen.queryByText('Open command menu')).not.toBeInTheDocument() expect(screen.queryByText('Command Menu')).not.toBeInTheDocument() }) + it('shows the database navigation section when database shortcuts are active', async () => { + renderShortcutsReferenceSheet(ACTIVE_DATABASE_SHORTCUT_IDS) + + expect(await screen.findByText('Global Navigation')).toBeInTheDocument() + expect(screen.getByText('Database Navigation')).toBeInTheDocument() + expect(screen.queryByText(/^Navigation$/)).not.toBeInTheDocument() + expect(screen.getByText('Go to Tables')).toBeInTheDocument() + }) + + it('does not show inactive database shortcuts in search results', async () => { + const user = userEvent.setup() + + renderShortcutsReferenceSheet() + + await user.type(screen.getByLabelText('Search shortcuts'), 'Go to Tables') + + expect(screen.getByText('No matching shortcuts found')).toBeInTheDocument() + expect(screen.queryByText('Database Navigation')).not.toBeInTheDocument() + }) + + it('hides shortcuts whose registration is soft-disabled', async () => { + sequenceIdCounter = 0 + const enabled = buildSequenceRegistration(SHORTCUT_IDS.COMMAND_MENU_OPEN) + const disabled = buildSequenceRegistration(SHORTCUT_IDS.NAV_HOME) + disabled.options = { ...disabled.options, enabled: false } + mockUseHotkeyRegistrations.mockReturnValue({ + hotkeys: [], + sequences: [enabled, disabled], + }) + + customRender() + + expect(await screen.findByText('Open command menu')).toBeInTheDocument() + expect(screen.queryByText('Go to Project Overview')).not.toBeInTheDocument() + }) + it('shows a clear button when searching and resets the list when clicked', async () => { const user = userEvent.setup() diff --git a/apps/studio/components/ui/GlobalShortcuts/ShortcutsReferenceSheet.tsx b/apps/studio/components/ui/GlobalShortcuts/ShortcutsReferenceSheet.tsx index 3e965208fa4a1..3eb2dbffe203c 100644 --- a/apps/studio/components/ui/GlobalShortcuts/ShortcutsReferenceSheet.tsx +++ b/apps/studio/components/ui/GlobalShortcuts/ShortcutsReferenceSheet.tsx @@ -1,5 +1,6 @@ +import { useHotkeyRegistrations, type SequenceRegistrationView } from '@tanstack/react-hotkeys' import { CircleX } from 'lucide-react' -import { Fragment, useEffect, useState } from 'react' +import { Fragment, useMemo, useState } from 'react' import { Button, KeyboardShortcut, @@ -13,21 +14,33 @@ import { import { Input } from 'ui-patterns/DataInputs/Input' import { hotkeyToKeys } from '@/state/shortcuts/formatShortcut' -import { SHORTCUT_DEFINITIONS } from '@/state/shortcuts/registry' -import type { ShortcutDefinition } from '@/state/shortcuts/types' +import { + SHORTCUT_REFERENCE_GROUP_LABELS, + SHORTCUT_REFERENCE_GROUP_ORDER, + SHORTCUT_REFERENCE_GROUPS, +} from '@/state/shortcuts/referenceGroups' +import type { ShortcutHotkeyMeta } from '@/state/shortcuts/useShortcut' interface ShortcutsReferenceSheetProps { open: boolean onOpenChange: (open: boolean) => void } +interface ActiveShortcutDefinition { + id: string + label: string + sequence: string[] + referenceGroup?: string +} + interface ShortcutGroup { group: string label: string - definitions: ShortcutDefinition[] + definitions: ActiveShortcutDefinition[] } const GROUP_LABELS: Record = { + ...SHORTCUT_REFERENCE_GROUP_LABELS, 'action-bar': 'Actions', 'ai-assistant': 'AI Assistant', 'command-menu': 'Command Menu', @@ -43,35 +56,55 @@ const GROUP_LABELS: Record = { 'unified-logs': 'Logs', } -const GROUP_ORDER = [ - 'command-menu', - 'shortcuts', - 'nav', - 'ai-assistant', - 'inline-editor', - 'results', - 'data-table', - 'table-editor', - 'schema-visualizer', - 'list-page', - 'action-bar', - 'operation-queue', - 'unified-logs', -] - const getGroupOrder = (group: string) => { - const index = GROUP_ORDER.indexOf(group) - return index === -1 ? GROUP_ORDER.length : index + const index = SHORTCUT_REFERENCE_GROUP_ORDER.indexOf(group) + return index === -1 ? SHORTCUT_REFERENCE_GROUP_ORDER.length : index } const getGroupLabel = (group: string) => GROUP_LABELS[group] ?? group +const isScopedNavigationGroup = (group: string) => + group.startsWith('navigation.') && group !== SHORTCUT_REFERENCE_GROUPS.NAVIGATION_GLOBAL + const normalizeSearchValue = (value: string) => value.trim().toLowerCase() -const groupDefinitions = (): ShortcutGroup[] => { - const grouped = Object.values(SHORTCUT_DEFINITIONS).reduce>( +const toActiveDefinition = ( + registration: SequenceRegistrationView +): ActiveShortcutDefinition | null => { + const meta = registration.options.meta as ShortcutHotkeyMeta | undefined + if (!meta?.id || !meta.name) return null + return { + id: meta.id, + label: meta.name, + sequence: registration.sequence, + referenceGroup: meta.referenceGroup, + } +} + +const useActiveShortcuts = (): ActiveShortcutDefinition[] => { + const { sequences } = useHotkeyRegistrations() + + return useMemo(() => { + const definitions: ActiveShortcutDefinition[] = [] + const seen = new Set() + + for (const registration of sequences) { + if (registration.options.enabled === false) continue + const definition = toActiveDefinition(registration) + if (!definition) continue + if (seen.has(definition.id)) continue + seen.add(definition.id) + definitions.push(definition) + } + + return definitions + }, [sequences]) +} + +const groupDefinitions = (activeShortcuts: ActiveShortcutDefinition[]): ShortcutGroup[] => { + const grouped = activeShortcuts.reduce>( (acc, definition) => { - const prefix = definition.id.split('.')[0] + const prefix = definition.referenceGroup ?? definition.id.split('.')[0] acc[prefix] = acc[prefix] ?? [] acc[prefix].push(definition) return acc @@ -79,12 +112,21 @@ const groupDefinitions = (): ShortcutGroup[] => { {} ) + const hasScopedNavigationGroup = Object.keys(grouped).some(isScopedNavigationGroup) + return Object.entries(grouped) - .map(([group, definitions]) => ({ - group, - label: getGroupLabel(group), - definitions, - })) + .map(([group, definitions]) => { + const label = + group === SHORTCUT_REFERENCE_GROUPS.NAVIGATION_GLOBAL && !hasScopedNavigationGroup + ? 'Navigation' + : getGroupLabel(group) + + return { + group, + label, + definitions, + } + }) .sort((a, b) => getGroupOrder(a.group) - getGroupOrder(b.group)) } @@ -111,7 +153,7 @@ const filterGroups = (groups: ShortcutGroup[], search: string) => { }, []) } -const ShortcutSequence = ({ sequence }: Pick) => ( +const ShortcutSequence = ({ sequence }: Pick) => (
{sequence.map((step, index) => ( @@ -122,69 +164,72 @@ const ShortcutSequence = ({ sequence }: Pick) =>
) -export function ShortcutsReferenceSheet({ open, onOpenChange }: ShortcutsReferenceSheetProps) { +function ShortcutsReferenceSheetContent() { const [search, setSearch] = useState('') - const groups = filterGroups(groupDefinitions(), search) + const activeShortcuts = useActiveShortcuts() + const groups = filterGroups(groupDefinitions(activeShortcuts), 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.length === 0 ? ( +

No matching shortcuts found

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

{label}

+
    + {definitions.map((definition) => ( +
  • + {definition.label} + +
  • + ))} +
+
+ )) + )} +
+ + ) +} +export function ShortcutsReferenceSheet({ open, onOpenChange }: ShortcutsReferenceSheetProps) { return ( - - Keyboard shortcuts - - Browse and search available keyboard shortcuts. - - -
- setSearch(event.target.value)} - placeholder="Search shortcuts..." - value={search} - actions={ - search ? ( -
- - {groups.length === 0 ? ( -

No matching shortcuts found

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

- {label} -

-
    - {definitions.map((definition) => ( -
  • - {definition.label} - -
  • - ))} -
-
- )) - )} -
+ {open && }
) diff --git a/apps/studio/components/ui/HelpPanel/HelpOptionsList.test.tsx b/apps/studio/components/ui/HelpPanel/HelpOptionsList.test.tsx new file mode 100644 index 0000000000000..e7ca56ec3c351 --- /dev/null +++ b/apps/studio/components/ui/HelpPanel/HelpOptionsList.test.tsx @@ -0,0 +1,80 @@ +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { HelpOptionsList } from './HelpOptionsList' +import { DOCS_URL } from '@/lib/constants' +import { customRender } from '@/tests/lib/custom-render' +import { routerMock } from '@/tests/lib/route-mock' + +const { takeBreadcrumbSnapshotMock } = vi.hoisted(() => ({ + takeBreadcrumbSnapshotMock: vi.fn(), +})) + +vi.mock('react-inlinesvg', () => ({ + __esModule: true, + default: () => null, +})) + +vi.mock('@/lib/breadcrumbs', () => ({ + takeBreadcrumbSnapshot: takeBreadcrumbSnapshotMock, +})) + +describe('HelpOptionsList', () => { + beforeEach(() => { + routerMock.setCurrentUrl('/') + takeBreadcrumbSnapshotMock.mockReset() + vi.restoreAllMocks() + }) + + it('navigates to support after invoking the click callback by default', async () => { + const onSupportClick = vi.fn() + + customRender( + + ) + + await userEvent.click(screen.getByRole('button', { name: /contact support/i })) + + expect(onSupportClick).toHaveBeenCalledTimes(1) + expect(takeBreadcrumbSnapshotMock).toHaveBeenCalledTimes(1) + expect(routerMock.asPath).toBe('/support/new?projectRef=project-1') + }) + + it('allows the support callback to suppress navigation', async () => { + customRender( + false} + /> + ) + + await userEvent.click(screen.getByRole('button', { name: /contact support/i })) + + expect(takeBreadcrumbSnapshotMock).not.toHaveBeenCalled() + expect(routerMock.asPath).toBe('/') + }) + + it('renders docs as an external link', () => { + customRender( + + ) + + const docsOption = screen.getByRole('link', { name: /docs/i }) + + expect(docsOption).toHaveAttribute('href', `${DOCS_URL}/`) + expect(docsOption).toHaveAttribute('target', '_blank') + expect(docsOption).toHaveAttribute('rel', 'noreferrer noopener') + }) +}) diff --git a/apps/studio/components/ui/HelpPanel/HelpOptionsList.tsx b/apps/studio/components/ui/HelpPanel/HelpOptionsList.tsx index 3e8bab6ec1b17..cdf9d48dfaae8 100644 --- a/apps/studio/components/ui/HelpPanel/HelpOptionsList.tsx +++ b/apps/studio/components/ui/HelpPanel/HelpOptionsList.tsx @@ -1,12 +1,16 @@ -import { Activity, BookOpen, Mail, Wrench } from 'lucide-react' +import { Activity, BookOpen, ChevronRight, Mail, Wrench } from 'lucide-react' import { useRouter } from 'next/router' +import type { ReactNode } from 'react' import SVG from 'react-inlinesvg' -import { AiIconAnimation, ButtonGroup, ButtonGroupItem } from 'ui' +import { AiIconAnimation } from 'ui' import type { HelpOptionId } from './HelpPanel.constants' import { HELP_OPTION_IDS } from './HelpPanel.constants' import type { SupportFormUrlKeys } from '@/components/interfaces/Support/SupportForm.utils' -import { SupportLink } from '@/components/interfaces/Support/SupportLink' +import { createSupportFormUrl } from '@/components/interfaces/Support/SupportForm.utils' +import { ResourceItem } from '@/components/ui/Resource/ResourceItem' +import { ResourceList } from '@/components/ui/Resource/ResourceList' +import { takeBreadcrumbSnapshot } from '@/lib/breadcrumbs' import { DOCS_URL } from '@/lib/constants' const DISCORD_URL = 'https://discord.supabase.com' @@ -18,8 +22,15 @@ type HelpOptionsListProps = { projectRef: string | undefined supportLinkQueryParams: Partial | undefined onAssistantClick?: () => void - onSupportClick?: () => void - size?: 'tiny' | 'small' + onSupportClick?: () => boolean | void +} + +type HelpOption = { + media: ReactNode + title: string + description: string + href?: string + onClick?: () => void } export const HelpOptionsList = ({ @@ -29,7 +40,6 @@ export const HelpOptionsList = ({ supportLinkQueryParams, onAssistantClick, onSupportClick, - size = 'tiny', }: HelpOptionsListProps) => { const router = useRouter() const basePath = router.basePath ?? '' @@ -44,96 +54,94 @@ export const HelpOptionsList = ({ const filteredIds = ids.filter(include) + const handleSupportClick = () => { + const shouldNavigate = onSupportClick?.() + if (shouldNavigate === false) { + return + } + + takeBreadcrumbSnapshot() + router.push(createSupportFormUrl(supportLinkQueryParams ?? {})) + } + + const renderOption = (title: string, description: string) => ( +
+

{title}

+

{description}

+
+ ) + + const options: Record = { + assistant: { + media: , + title: 'Supabase Assistant', + description: 'Get guided help with your project directly in Studio.', + onClick: onAssistantClick, + }, + docs: { + media: , + title: 'Docs', + description: 'Browse guides, references, and product documentation.', + href: `${DOCS_URL}/`, + }, + troubleshooting: { + media: , + title: 'Troubleshooting', + description: 'Find fixes for common platform issues and errors.', + href: `${DOCS_URL}/guides/troubleshooting?products=platform`, + }, + discord: { + media: , + title: 'Ask on Discord', + description: 'Get help from the community on code-related questions.', + href: DISCORD_URL, + }, + status: { + media: , + title: 'Supabase status', + description: 'Check incidents, maintenance, and uptime updates.', + href: STATUS_URL, + }, + support: { + media: , + title: 'Contact support', + description: 'Reach support for account and platform issues.', + onClick: handleSupportClick, + }, + } + return ( - + {filteredIds.map((id) => { - switch (id) { - case 'assistant': - return ( - } - onClick={onAssistantClick} - > - Supabase Assistant - - ) - case 'docs': - return ( - } - asChild - > - - Docs - - - ) - case 'troubleshooting': - return ( - } - asChild - > - - Troubleshooting - - - ) - case 'discord': - return ( - } - asChild - > - - Ask on Discord - - - ) - case 'status': - return ( - } - asChild - > - - Supabase status - - - ) - case 'support': - return ( - } - asChild - > - - Contact support - - - ) - default: { - void (id as never) - return null - } + const option = options[id] + + if (option.href) { + return ( + } + href={option.href} + target="_blank" + rel="noreferrer noopener" + > + {renderOption(option.title, option.description)} + + ) } + + return ( + + {renderOption(option.title, option.description)} + + ) })} - + ) } diff --git a/apps/studio/components/ui/HelpPanel/HelpPanel.tsx b/apps/studio/components/ui/HelpPanel/HelpPanel.tsx index b38bedef22a00..3da35806c1ee9 100644 --- a/apps/studio/components/ui/HelpPanel/HelpPanel.tsx +++ b/apps/studio/components/ui/HelpPanel/HelpPanel.tsx @@ -1,14 +1,18 @@ import { IS_PLATFORM } from 'common' -import { X } from 'lucide-react' +import { ChevronLeft, X } from 'lucide-react' import Image from 'next/image' import { useRouter } from 'next/router' +import { useState } from 'react' import SVG from 'react-inlinesvg' -import { Button, cn, Separator } from 'ui' -import styleHandler from 'ui/src/lib/theme/styleHandler' +import { Button } from 'ui' import { ASSISTANT_SUGGESTIONS } from './HelpPanel.constants' import { HelpSection } from './HelpSection' import type { SupportFormUrlKeys } from '@/components/interfaces/Support/SupportForm.utils' +import { + SupportForm, + SupportFormStatusButton, +} from '@/components/interfaces/Support/SupportSidebarForm' import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state' @@ -26,72 +30,103 @@ export const HelpPanel = ({ const snap = useAiAssistantStateSnapshot() const { openSidebar, closeSidebar } = useSidebarManagerSnapshot() const router = useRouter() - - const __styles = styleHandler('popover') + const [view, setView] = useState<'home' | 'support'>('home') + const isSupportView = view === 'support' + const openAssistant = () => { + onClose() + openSidebar(SIDEBAR_KEYS.AI_ASSISTANT) + snap.newChat(ASSISTANT_SUGGESTIONS) + } return ( -
-
- Help & Support - closeSidebar(SIDEBAR_KEYS.HELP_PANEL)} - icon={} - tooltip={{ content: { side: 'bottom', text: 'Close' } }} - /> -
- { - onClose() - openSidebar(SIDEBAR_KEYS.AI_ASSISTANT) - snap.newChat(ASSISTANT_SUGGESTIONS) - }} - onSupportClick={onClose} - /> - -
-
-
Community support
-

- Our Discord community can help with code-related issues. Many questions are answered in - minutes. -

+
+
+
+ {isSupportView && ( + setView('home')} + icon={} + tooltip={{ content: { side: 'bottom', text: 'Back' } }} + /> + )} + {isSupportView ? 'Contact support' : 'Help & Support'}
-
- +
+ + closeSidebar(SIDEBAR_KEYS.HELP_PANEL)} + icon={} + tooltip={{ content: { side: 'bottom', text: 'Close' } }} + />
+
+ {isSupportView ? ( + { + setView('home') + }} + /> + ) : ( +
+ { + setView('support') + return false + }} + /> +
+
+
Community support
+

+ Our Discord community can help with code-related issues. Many questions are + answered in minutes. +

+
+ +
+
+ )} +
) } diff --git a/apps/studio/components/ui/HelpPanel/HelpSection.tsx b/apps/studio/components/ui/HelpPanel/HelpSection.tsx index 7aff02ec0492d..26a762c88c133 100644 --- a/apps/studio/components/ui/HelpPanel/HelpSection.tsx +++ b/apps/studio/components/ui/HelpPanel/HelpSection.tsx @@ -10,7 +10,7 @@ type HelpSectionProps = { projectRef: string | undefined supportLinkQueryParams: Partial | undefined onAssistantClick?: () => void - onSupportClick?: () => void + onSupportClick?: () => boolean | void className?: string } @@ -23,16 +23,8 @@ export const HelpSection = ({ onSupportClick, className, }: HelpSectionProps) => { - const description = projectRef - ? 'Start with our Assistant, docs, or community.' - : 'Start with our docs or community.' - return ( -
-
-
Need help with your project?
-

{description}

-
+
) diff --git a/apps/studio/components/ui/ProductMenu/ProductMenuShortcuts.test.tsx b/apps/studio/components/ui/ProductMenu/ProductMenuShortcuts.test.tsx new file mode 100644 index 0000000000000..b142c33e1854d --- /dev/null +++ b/apps/studio/components/ui/ProductMenu/ProductMenuShortcuts.test.tsx @@ -0,0 +1,111 @@ +import { render } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import type { ProductMenuGroup } from './ProductMenu.types' +import { ProductMenuShortcuts } from './ProductMenuShortcuts' +import { SHORTCUT_IDS } from '@/state/shortcuts/registry' + +const { mockPush, mockUseShortcut } = vi.hoisted(() => ({ + mockPush: vi.fn(), + mockUseShortcut: vi.fn(), +})) + +vi.mock('next/router', () => ({ + useRouter: () => ({ + push: mockPush, + }), +})) + +vi.mock('@/state/shortcuts/useShortcut', () => ({ + useShortcut: mockUseShortcut, +})) + +const menu: ProductMenuGroup[] = [ + { + title: 'Visible', + items: [ + { + name: 'Tables', + key: 'tables', + url: '/project/ref/database/tables', + shortcutId: SHORTCUT_IDS.NAV_DATABASE_TABLES, + }, + { + name: 'Functions', + key: 'functions', + url: '/project/ref/database/functions', + shortcutId: SHORTCUT_IDS.NAV_DATABASE_FUNCTIONS, + disabled: true, + }, + { + name: 'External', + key: 'external', + url: 'https://example.com', + shortcutId: SHORTCUT_IDS.NAV_DATABASE_EXTENSIONS, + isExternal: true, + }, + { + name: 'No shortcut', + key: 'no-shortcut', + url: '/project/ref/database/triggers', + }, + ], + }, +] + +describe('ProductMenuShortcuts', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('registers visible enabled internal shortcut items', () => { + render() + + expect(mockUseShortcut).toHaveBeenCalledTimes(1) + expect(mockUseShortcut).toHaveBeenCalledWith( + SHORTCUT_IDS.NAV_DATABASE_TABLES, + expect.any(Function) + ) + }) + + it('navigates to the item url when the shortcut fires', () => { + render() + + const callback = mockUseShortcut.mock.calls[0][1] + callback() + + expect(mockPush).toHaveBeenCalledWith('/project/ref/database/tables') + }) + + it('registers shortcut items nested under childItems', () => { + render( + + ) + + expect(mockUseShortcut).toHaveBeenCalledWith( + SHORTCUT_IDS.NAV_DATABASE_INDEXES, + expect.any(Function) + ) + }) +}) diff --git a/apps/studio/components/ui/ProductMenu/ProductMenuShortcuts.tsx b/apps/studio/components/ui/ProductMenu/ProductMenuShortcuts.tsx new file mode 100644 index 0000000000000..582805ae32cc2 --- /dev/null +++ b/apps/studio/components/ui/ProductMenu/ProductMenuShortcuts.tsx @@ -0,0 +1,50 @@ +import { useRouter } from 'next/router' +import { useCallback } from 'react' + +import type { ProductMenuGroup, ProductMenuGroupItem } from './ProductMenu.types' +import { useShortcut } from '@/state/shortcuts/useShortcut' + +interface ProductMenuShortcutsProps { + menu: ProductMenuGroup[] +} + +type ProductMenuShortcutItem = ProductMenuGroupItem & { + shortcutId: NonNullable +} + +const getShortcutItems = (items: ProductMenuGroupItem[]): ProductMenuShortcutItem[] => { + return items.flatMap((item) => { + const childItems = item.childItems ? getShortcutItems(item.childItems) : [] + + if (!item.shortcutId || !item.url || item.disabled || item.isExternal) { + return childItems + } + + return [item as ProductMenuShortcutItem, ...childItems] + }) +} + +const ProductMenuShortcut = ({ item }: { item: ProductMenuShortcutItem }) => { + const router = useRouter() + const { shortcutId, url } = item + + const navigate = useCallback(() => { + router.push(url) + }, [router, url]) + + useShortcut(shortcutId, navigate) + + return null +} + +export const ProductMenuShortcuts = ({ menu }: ProductMenuShortcutsProps) => { + const shortcutItems = menu.flatMap((group) => getShortcutItems(group.items)) + + return ( + <> + {shortcutItems.map((item) => ( + + ))} + + ) +} diff --git a/apps/studio/components/ui/Resource/ResourceItem.tsx b/apps/studio/components/ui/Resource/ResourceItem.tsx index 73bddce051d96..0f7b76a315a64 100644 --- a/apps/studio/components/ui/Resource/ResourceItem.tsx +++ b/apps/studio/components/ui/Resource/ResourceItem.tsx @@ -1,5 +1,6 @@ import { ChevronRight, MoreVertical } from 'lucide-react' -import { forwardRef, HTMLAttributes, ReactNode } from 'react' +import Link from 'next/link' +import { forwardRef, HTMLAttributes, KeyboardEvent, ReactNode } from 'react' import { Button, CardContent, @@ -21,22 +22,43 @@ export interface ResourceItemProps extends HTMLAttributes { onClick?: () => void children?: ReactNode actions?: ResourceAction[] + href?: string + target?: string + rel?: string } export const ResourceItem = forwardRef( - ({ media, meta, onClick, children, className, actions, ...props }, ref) => { - return ( - + ( + { + media, + meta, + onClick, + children, + className, + actions, + href, + target, + rel, + onKeyDown, + role, + tabIndex, + ...props + }, + ref + ) => { + const handleKeyDown = (event: KeyboardEvent) => { + onKeyDown?.(event) + + if (event.defaultPrevented || !onClick || event.target !== event.currentTarget) return + + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + onClick() + } + } + + const content = ( + <> {media && (
{media}
)} @@ -71,6 +93,40 @@ export const ResourceItem = forwardRef( ) : ( onClick && )} + + ) + + const rootClassName = cn( + 'flex items-center justify-between text-sm gap-4', + 'border-b-0!', + (onClick || href) && 'cursor-pointer transition-colors duration-150 hover:bg-surface-200', + className + ) + + if (href) { + return ( + + {content} + + ) + } + + return ( + + {content} ) } diff --git a/apps/studio/components/ui/Resource/ResourceList.tsx b/apps/studio/components/ui/Resource/ResourceList.tsx index 9bd90cdc76c7d..2acff847137a3 100644 --- a/apps/studio/components/ui/Resource/ResourceList.tsx +++ b/apps/studio/components/ui/Resource/ResourceList.tsx @@ -8,8 +8,12 @@ export interface ResourceListProps extends HTMLAttributes { export const ResourceList = forwardRef( ({ children, className, ...props }, ref) => { return ( - -
{children}
+ + {children} ) } diff --git a/apps/studio/data/projects/project-create-mutation.test.ts b/apps/studio/data/projects/project-create-mutation.test.ts new file mode 100644 index 0000000000000..7b9cff6d66b23 --- /dev/null +++ b/apps/studio/data/projects/project-create-mutation.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/data/fetchers', () => ({ + post: vi.fn(), + handleError: vi.fn((error) => { + throw error + }), +})) + +describe('project-create-mutation', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('createProject', () => { + it('sends GitHub connection fields when provided', async () => { + const { post } = await import('@/data/fetchers') + const { createProject } = await import('./project-create-mutation') + + const mockPost = post as unknown as ReturnType + mockPost.mockResolvedValueOnce({ + data: { ref: 'project-ref', organization_slug: 'test-org' }, + error: null, + }) + + await createProject({ + name: 'test-project', + organizationSlug: 'test-org', + dbPass: 'secret-password', + dbRegion: 'West US (North California)', + githubInstallationId: 1234, + githubRepositoryId: 5678, + }) + + expect(mockPost).toHaveBeenCalledWith('/platform/projects', { + body: expect.objectContaining({ + name: 'test-project', + organization_slug: 'test-org', + db_pass: 'secret-password', + db_region: 'West US (North California)', + github_installation_id: 1234, + github_repository_id: 5678, + }), + }) + }) + + it('does not populate GitHub connection fields when omitted', async () => { + const { post } = await import('@/data/fetchers') + const { createProject } = await import('./project-create-mutation') + + const mockPost = post as unknown as ReturnType + mockPost.mockResolvedValueOnce({ + data: { ref: 'project-ref', organization_slug: 'test-org' }, + error: null, + }) + + await createProject({ + name: 'test-project', + organizationSlug: 'test-org', + dbPass: 'secret-password', + dbRegion: 'West US (North California)', + }) + + expect(mockPost).toHaveBeenCalledWith('/platform/projects', { + body: expect.objectContaining({ + github_installation_id: undefined, + github_repository_id: undefined, + }), + }) + }) + }) +}) diff --git a/apps/studio/data/projects/project-create-mutation.ts b/apps/studio/data/projects/project-create-mutation.ts index 3bd702726de03..c4bc3b04b6a03 100644 --- a/apps/studio/data/projects/project-create-mutation.ts +++ b/apps/studio/data/projects/project-create-mutation.ts @@ -29,6 +29,8 @@ export type ProjectCreateVariables = { postgresEngine?: PostgresEngine releaseChannel?: ReleaseChannel highAvailability?: boolean + githubInstallationId?: CreateProjectBody['github_installation_id'] + githubRepositoryId?: CreateProjectBody['github_repository_id'] } export async function createProject({ @@ -47,6 +49,8 @@ export async function createProject({ postgresEngine, releaseChannel, highAvailability, + githubInstallationId, + githubRepositoryId, }: ProjectCreateVariables) { const body: CreateProjectBody = { cloud_provider: cloudProvider as CloudProvider, @@ -66,6 +70,8 @@ export async function createProject({ postgres_engine: postgresEngine, release_channel: releaseChannel, high_availability: highAvailability, + github_installation_id: githubInstallationId, + github_repository_id: githubRepositoryId, } const { data, error } = await post(`/platform/projects`, { diff --git a/apps/studio/hooks/misc/useOrganizationRestrictions.ts b/apps/studio/hooks/misc/useOrganizationRestrictions.ts index af1d892af2032..f18c02ab265dc 100644 --- a/apps/studio/hooks/misc/useOrganizationRestrictions.ts +++ b/apps/studio/hooks/misc/useOrganizationRestrictions.ts @@ -1,4 +1,3 @@ -import dayjs from 'dayjs' import type { ReactNode } from 'react' import { useIsFeatureEnabled } from './useIsFeatureEnabled' @@ -69,7 +68,7 @@ export function useOrganizationRestrictions() { variant: 'warning', title: RESTRICTION_MESSAGES.GRACE_PERIOD.title, description: RESTRICTION_MESSAGES.GRACE_PERIOD.description( - dayjs(org?.restriction_data?.['grace_period_end']).format('DD MMM, YYYY'), + org?.restriction_data?.['grace_period_end'] ?? '', org.slug ), }) diff --git a/apps/studio/hooks/misc/useStateTransition.ts b/apps/studio/hooks/misc/useStateTransition.ts index 08235b095b999..13a6b24c73b99 100644 --- a/apps/studio/hooks/misc/useStateTransition.ts +++ b/apps/studio/hooks/misc/useStateTransition.ts @@ -1,4 +1,4 @@ -import { useRef } from 'react' +import { useEffect, useRef } from 'react' export function useStateTransition< State extends { type: string }, @@ -14,14 +14,17 @@ export function useStateTransition< ) => void ): void { const prevState = useRef(state) - const savedPrevState = prevState.current - const shouldRunCallback = savedPrevState.type === prevTest && state.type === newTest - prevState.current = state - if (shouldRunCallback) { - cb( - savedPrevState as Extract, - state as Extract - ) - } + useEffect(() => { + const savedPrevState = prevState.current + + if (savedPrevState.type === prevTest && state.type === newTest) { + cb( + savedPrevState as Extract, + state as Extract + ) + } + + prevState.current = state + }, [cb, newTest, prevTest, state]) } diff --git a/apps/studio/pages/new/[slug].tsx b/apps/studio/pages/new/[slug].tsx index b3606be43bbda..6feae7993ddd1 100644 --- a/apps/studio/pages/new/[slug].tsx +++ b/apps/studio/pages/new/[slug].tsx @@ -38,6 +38,10 @@ import { ProjectCreationFooter } from '@/components/interfaces/ProjectCreation/P import { ProjectNameInput } from '@/components/interfaces/ProjectCreation/ProjectNameInput' import { RegionSelector } from '@/components/interfaces/ProjectCreation/RegionSelector' import { SecurityOptions } from '@/components/interfaces/ProjectCreation/SecurityOptions' +import { + GitHubRepositoryField, + useGitHubRepositoryOptions, +} from '@/components/interfaces/Settings/Integrations/GithubIntegration/GitHubRepositoryField' import DefaultLayout from '@/components/layouts/DefaultLayout' import { WizardLayoutWithoutAuth } from '@/components/layouts/WizardLayout' import Panel from '@/components/ui/Panel' @@ -58,6 +62,7 @@ import { useProjectCreateMutation, } from '@/data/projects/project-create-mutation' import { useCustomContent } from '@/hooks/custom-content/useCustomContent' +import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { useDataApiRevokeOnCreateDefaultEnabled } from '@/hooks/misc/useDataApiRevokeOnCreateDefault' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' @@ -94,7 +99,14 @@ const Wizard: NextPageWithLayout = () => { '' ) const { can: isAdmin } = useAsyncCheckPermissions(PermissionAction.CREATE, 'projects') + const { can: canCreateGitHubConnection } = useAsyncCheckPermissions( + PermissionAction.CREATE, + 'integrations.github_connections' + ) const showAdvancedConfig = useIsFeatureEnabled('project_creation:show_advanced_config') + const { hasAccess: hasAccessToGitHubIntegration } = useCheckEntitlements( + 'integrations.github_connections' + ) const smartRegionEnabled = useFlag('enableSmartRegion') const projectCreationDisabled = useFlag('disableProjectCreationAndUpdate') @@ -137,6 +149,9 @@ const Wizard: NextPageWithLayout = () => { dbPassStrength: 0, dbPassStrengthMessage: '', dbRegion: undefined, + githubRepositoryId: '', + githubInstallationId: undefined, + githubRepositoryName: '', instanceSize: canChooseInstanceSize ? sizes[0] : undefined, dataApi: true, dataApiDefaultPrivileges: !isDataApiRevokeOnCreateDefault, @@ -150,7 +165,9 @@ const Wizard: NextPageWithLayout = () => { instanceSize: watchedInstanceSize, cloudProvider, dbRegion, + githubRepositoryName, organization, + projectName: watchedProjectName, highAvailability, } = useWatch({ control: form.control }) @@ -239,6 +256,8 @@ const Wizard: NextPageWithLayout = () => { : _defaultRegion const canCreateProject = isAdmin && !freePlanWithExceedingLimits && !hasOutstandingInvoices + const canConfigureGitHubOnCreate = + canCreateProject && hasAccessToGitHubIntegration && canCreateGitHubConnection const dbRegionExact = smartRegionToExactRegion(dbRegion ?? '') @@ -258,6 +277,13 @@ const Wizard: NextPageWithLayout = () => { ) : false const shouldShowFreeProjectInfo = !!currentOrg && !isFreePlan && !isUserAtFreeProjectLimit + const { + gitHubAuthorization, + githubRepos, + hasPartialResponseDueToSSO, + isLoading: isLoadingRepositoryOptions, + refetch: refetchRepositoryOptions, + } = useGitHubRepositoryOptions() const { mutate: createProject, @@ -319,6 +345,8 @@ const Wizard: NextPageWithLayout = () => { enableRlsEventTrigger, postgresVersionSelection, useOrioleDb, + githubInstallationId, + githubRepositoryId, } = values if (useOrioleDb && !availableOrioleVersion) { @@ -332,6 +360,10 @@ const Wizard: NextPageWithLayout = () => { const selectedRegion = smartRegionEnabled ? (smartGroup.find((x) => x.name === dbRegion) ?? specific.find((x) => x.name === dbRegion)) : undefined + const parsedGitHubRepositoryId = + githubRepositoryId.length > 0 ? Number(githubRepositoryId) : undefined + const shouldIncludeGitHubFields = + githubInstallationId !== undefined && Number.isFinite(parsedGitHubRepositoryId) const data: ProjectCreateVariables = { dbPass, @@ -355,6 +387,12 @@ const Wizard: NextPageWithLayout = () => { ] .filter(Boolean) .join('\n') || undefined, + ...(shouldIncludeGitHubFields + ? { + githubInstallationId, + githubRepositoryId: parsedGitHubRepositoryId, + } + : {}), } if (postgresVersion && !postgresVersion.match(/1[2-9]\..*/)) { @@ -435,6 +473,16 @@ const Wizard: NextPageWithLayout = () => { } }, [instanceSize, watchedInstanceSize, setValue]) + useEffect(() => { + if (!githubRepositoryName) return + if ((watchedProjectName ?? '').trim().length > 0) return + + const repoName = githubRepositoryName.split('/').at(-1) ?? githubRepositoryName + setValue('projectName', repoName.trim(), { + shouldValidate: true, + }) + }, [githubRepositoryName, watchedProjectName, setValue]) + return ( <> {/* Wizard layouts set the visual header but not the browser tab title. */} @@ -475,6 +523,38 @@ const Wizard: NextPageWithLayout = () => { {canCreateProject && ( <> + {canConfigureGitHubOnCreate && ( + + + Ideal for agent-first workflows: update your schema in code, push it + to GitHub, and Supabase deploys the changes automatically.{' '} + + Learn more + + + } + disabled={isCreatingNewProject} + repositories={githubRepos} + gitHubAuthorization={gitHubAuthorization} + hasPartialResponseDueToSSO={hasPartialResponseDueToSSO} + isLoading={isLoadingRepositoryOptions} + refetch={refetchRepositoryOptions} + onConnectClick={() => track('project_creation_github_connect_clicked')} + /> + + )} diff --git a/apps/studio/state/shortcuts/referenceGroups.ts b/apps/studio/state/shortcuts/referenceGroups.ts new file mode 100644 index 0000000000000..224fe209e15dc --- /dev/null +++ b/apps/studio/state/shortcuts/referenceGroups.ts @@ -0,0 +1,27 @@ +export const SHORTCUT_REFERENCE_GROUPS = { + NAVIGATION_GLOBAL: 'navigation.global', + NAVIGATION_DATABASE: 'navigation.database', +} as const + +export const SHORTCUT_REFERENCE_GROUP_LABELS: Record = { + [SHORTCUT_REFERENCE_GROUPS.NAVIGATION_GLOBAL]: 'Global Navigation', + [SHORTCUT_REFERENCE_GROUPS.NAVIGATION_DATABASE]: 'Database Navigation', +} + +export const SHORTCUT_REFERENCE_GROUP_ORDER = [ + 'command-menu', + 'shortcuts', + SHORTCUT_REFERENCE_GROUPS.NAVIGATION_GLOBAL, + SHORTCUT_REFERENCE_GROUPS.NAVIGATION_DATABASE, + 'nav', + 'ai-assistant', + 'inline-editor', + 'results', + 'data-table', + 'table-editor', + 'schema-visualizer', + 'list-page', + 'action-bar', + 'operation-queue', + 'unified-logs', +] diff --git a/apps/studio/state/shortcuts/registry.ts b/apps/studio/state/shortcuts/registry.ts index 425bb75bd6246..120eaf267c5a0 100644 --- a/apps/studio/state/shortcuts/registry.ts +++ b/apps/studio/state/shortcuts/registry.ts @@ -1,3 +1,4 @@ +import { SHORTCUT_REFERENCE_GROUPS } from './referenceGroups' import { DATABASE_NAV_SHORTCUT_IDS, databaseNavRegistry } from './registry/database-nav' import { LIST_PAGE_SHORTCUT_IDS, listPageRegistry } from './registry/list-page' import { @@ -197,114 +198,133 @@ export const SHORTCUT_DEFINITIONS: Record = { label: 'Go to Project Overview', sequence: ['G', 'H'], showInSettings: false, + referenceGroup: SHORTCUT_REFERENCE_GROUPS.NAVIGATION_GLOBAL, }, [SHORTCUT_IDS.NAV_TABLE_EDITOR]: { id: SHORTCUT_IDS.NAV_TABLE_EDITOR, label: 'Go to Table Editor', sequence: ['G', 'T'], showInSettings: false, + referenceGroup: SHORTCUT_REFERENCE_GROUPS.NAVIGATION_GLOBAL, }, [SHORTCUT_IDS.NAV_SQL_EDITOR]: { id: SHORTCUT_IDS.NAV_SQL_EDITOR, label: 'Go to SQL Editor', sequence: ['G', 'S'], showInSettings: false, + referenceGroup: SHORTCUT_REFERENCE_GROUPS.NAVIGATION_GLOBAL, }, [SHORTCUT_IDS.NAV_DATABASE]: { id: SHORTCUT_IDS.NAV_DATABASE, label: 'Go to Database', sequence: ['G', 'D'], showInSettings: false, + referenceGroup: SHORTCUT_REFERENCE_GROUPS.NAVIGATION_GLOBAL, }, [SHORTCUT_IDS.NAV_AUTH]: { id: SHORTCUT_IDS.NAV_AUTH, label: 'Go to Authentication', sequence: ['G', 'A'], showInSettings: false, + referenceGroup: SHORTCUT_REFERENCE_GROUPS.NAVIGATION_GLOBAL, }, [SHORTCUT_IDS.NAV_STORAGE]: { id: SHORTCUT_IDS.NAV_STORAGE, label: 'Go to Storage', sequence: ['G', 'B'], showInSettings: false, + referenceGroup: SHORTCUT_REFERENCE_GROUPS.NAVIGATION_GLOBAL, }, [SHORTCUT_IDS.NAV_FUNCTIONS]: { id: SHORTCUT_IDS.NAV_FUNCTIONS, label: 'Go to Edge Functions', sequence: ['G', 'F'], showInSettings: false, + referenceGroup: SHORTCUT_REFERENCE_GROUPS.NAVIGATION_GLOBAL, }, [SHORTCUT_IDS.NAV_REALTIME]: { id: SHORTCUT_IDS.NAV_REALTIME, label: 'Go to Realtime', sequence: ['G', 'R'], showInSettings: false, + referenceGroup: SHORTCUT_REFERENCE_GROUPS.NAVIGATION_GLOBAL, }, [SHORTCUT_IDS.NAV_ADVISORS]: { id: SHORTCUT_IDS.NAV_ADVISORS, label: 'Go to Advisors', sequence: ['G', 'V'], showInSettings: false, + referenceGroup: SHORTCUT_REFERENCE_GROUPS.NAVIGATION_GLOBAL, }, [SHORTCUT_IDS.NAV_OBSERVABILITY]: { id: SHORTCUT_IDS.NAV_OBSERVABILITY, label: 'Go to Observability', sequence: ['G', 'U'], showInSettings: false, + referenceGroup: SHORTCUT_REFERENCE_GROUPS.NAVIGATION_GLOBAL, }, [SHORTCUT_IDS.NAV_LOGS]: { id: SHORTCUT_IDS.NAV_LOGS, label: 'Go to Logs', sequence: ['G', 'L'], showInSettings: false, + referenceGroup: SHORTCUT_REFERENCE_GROUPS.NAVIGATION_GLOBAL, }, [SHORTCUT_IDS.NAV_INTEGRATIONS]: { id: SHORTCUT_IDS.NAV_INTEGRATIONS, label: 'Go to Integrations', sequence: ['G', 'I'], showInSettings: false, + referenceGroup: SHORTCUT_REFERENCE_GROUPS.NAVIGATION_GLOBAL, }, [SHORTCUT_IDS.NAV_SETTINGS]: { id: SHORTCUT_IDS.NAV_SETTINGS, label: 'Go to Project Settings', sequence: ['G', ','], showInSettings: false, + referenceGroup: SHORTCUT_REFERENCE_GROUPS.NAVIGATION_GLOBAL, }, [SHORTCUT_IDS.NAV_ORG_PROJECTS]: { id: SHORTCUT_IDS.NAV_ORG_PROJECTS, label: 'Go to Projects', sequence: ['G', 'P'], showInSettings: false, + referenceGroup: SHORTCUT_REFERENCE_GROUPS.NAVIGATION_GLOBAL, }, [SHORTCUT_IDS.NAV_ORG_TEAM]: { id: SHORTCUT_IDS.NAV_ORG_TEAM, label: 'Go to Team', sequence: ['G', 'M'], showInSettings: false, + referenceGroup: SHORTCUT_REFERENCE_GROUPS.NAVIGATION_GLOBAL, }, [SHORTCUT_IDS.NAV_ORG_INTEGRATIONS]: { id: SHORTCUT_IDS.NAV_ORG_INTEGRATIONS, label: 'Go to Organization Integrations', sequence: ['G', 'I'], showInSettings: false, + referenceGroup: SHORTCUT_REFERENCE_GROUPS.NAVIGATION_GLOBAL, }, [SHORTCUT_IDS.NAV_ORG_USAGE]: { id: SHORTCUT_IDS.NAV_ORG_USAGE, label: 'Go to Usage', sequence: ['G', 'U'], showInSettings: false, + referenceGroup: SHORTCUT_REFERENCE_GROUPS.NAVIGATION_GLOBAL, }, [SHORTCUT_IDS.NAV_ORG_BILLING]: { id: SHORTCUT_IDS.NAV_ORG_BILLING, label: 'Go to Billing', sequence: ['G', 'B'], showInSettings: false, + referenceGroup: SHORTCUT_REFERENCE_GROUPS.NAVIGATION_GLOBAL, }, [SHORTCUT_IDS.NAV_ORG_SETTINGS]: { id: SHORTCUT_IDS.NAV_ORG_SETTINGS, label: 'Go to Organization Settings', sequence: ['G', 'O'], showInSettings: false, + referenceGroup: SHORTCUT_REFERENCE_GROUPS.NAVIGATION_GLOBAL, }, [SHORTCUT_IDS.SHORTCUTS_OPEN_REFERENCE]: { id: SHORTCUT_IDS.SHORTCUTS_OPEN_REFERENCE, diff --git a/apps/studio/state/shortcuts/registry/database-nav.ts b/apps/studio/state/shortcuts/registry/database-nav.ts index 9a437c3b390d0..58ce0b116d67b 100644 --- a/apps/studio/state/shortcuts/registry/database-nav.ts +++ b/apps/studio/state/shortcuts/registry/database-nav.ts @@ -1,3 +1,4 @@ +import { SHORTCUT_REFERENCE_GROUPS } from '../referenceGroups' import { RegistryDefinations } from '../types' /** @@ -35,83 +36,97 @@ export const databaseNavRegistry: RegistryDefinations = { label: 'Go to Tables', sequence: ['D', 'T'], showInSettings: false, + referenceGroup: SHORTCUT_REFERENCE_GROUPS.NAVIGATION_DATABASE, }, [DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_FUNCTIONS]: { id: DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_FUNCTIONS, label: 'Go to Functions', sequence: ['D', 'F'], showInSettings: false, + referenceGroup: SHORTCUT_REFERENCE_GROUPS.NAVIGATION_DATABASE, }, [DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_TRIGGERS]: { id: DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_TRIGGERS, label: 'Go to Triggers', sequence: ['D', 'R'], showInSettings: false, + referenceGroup: SHORTCUT_REFERENCE_GROUPS.NAVIGATION_DATABASE, }, [DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_INDEXES]: { id: DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_INDEXES, label: 'Go to Indexes', sequence: ['D', 'I'], showInSettings: false, + referenceGroup: SHORTCUT_REFERENCE_GROUPS.NAVIGATION_DATABASE, }, [DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_EXTENSIONS]: { id: DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_EXTENSIONS, label: 'Go to Extensions', sequence: ['D', 'X'], showInSettings: false, + referenceGroup: SHORTCUT_REFERENCE_GROUPS.NAVIGATION_DATABASE, }, [DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_SCHEMA_VISUALIZER]: { id: DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_SCHEMA_VISUALIZER, label: 'Go to Schema Visualizer', sequence: ['D', 'V'], showInSettings: false, + referenceGroup: SHORTCUT_REFERENCE_GROUPS.NAVIGATION_DATABASE, }, [DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_ROLES]: { id: DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_ROLES, label: 'Go to Roles', sequence: ['D', 'O'], showInSettings: false, + referenceGroup: SHORTCUT_REFERENCE_GROUPS.NAVIGATION_DATABASE, }, [DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_BACKUPS]: { id: DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_BACKUPS, label: 'Go to Backups', sequence: ['D', 'B'], showInSettings: false, + referenceGroup: SHORTCUT_REFERENCE_GROUPS.NAVIGATION_DATABASE, }, [DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_MIGRATIONS]: { id: DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_MIGRATIONS, label: 'Go to Migrations', sequence: ['D', 'M'], showInSettings: false, + referenceGroup: SHORTCUT_REFERENCE_GROUPS.NAVIGATION_DATABASE, }, [DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_TYPES]: { id: DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_TYPES, label: 'Go to Enumerated Types', sequence: ['D', 'E'], showInSettings: false, + referenceGroup: SHORTCUT_REFERENCE_GROUPS.NAVIGATION_DATABASE, }, [DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_PUBLICATIONS]: { id: DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_PUBLICATIONS, label: 'Go to Publications', sequence: ['D', 'U'], showInSettings: false, + referenceGroup: SHORTCUT_REFERENCE_GROUPS.NAVIGATION_DATABASE, }, [DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_COLUMN_PRIVILEGES]: { id: DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_COLUMN_PRIVILEGES, label: 'Go to Column Privileges', sequence: ['D', 'C'], showInSettings: false, + referenceGroup: SHORTCUT_REFERENCE_GROUPS.NAVIGATION_DATABASE, }, [DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_SETTINGS]: { id: DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_SETTINGS, label: 'Go to Database Settings', sequence: ['D', ','], showInSettings: false, + referenceGroup: SHORTCUT_REFERENCE_GROUPS.NAVIGATION_DATABASE, }, [DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_REPLICATION]: { id: DATABASE_NAV_SHORTCUT_IDS.NAV_DATABASE_REPLICATION, label: 'Go to Replication', sequence: ['D', 'L'], showInSettings: false, + referenceGroup: SHORTCUT_REFERENCE_GROUPS.NAVIGATION_DATABASE, }, } diff --git a/apps/studio/state/shortcuts/types.ts b/apps/studio/state/shortcuts/types.ts index ab2b4fcb4b08f..1bed754b97f71 100644 --- a/apps/studio/state/shortcuts/types.ts +++ b/apps/studio/state/shortcuts/types.ts @@ -108,6 +108,12 @@ export interface ShortcutDefinition { * standalone user preference. */ showInSettings?: boolean + + /** + * Optional grouping override for the Keyboard shortcuts reference sheet. + * Falls back to the shortcut id prefix when omitted. + */ + referenceGroup?: string } export type RegistryDefinations = Record diff --git a/apps/studio/state/shortcuts/useShortcut.test.tsx b/apps/studio/state/shortcuts/useShortcut.test.tsx index a54844305773e..448445b5cb2bd 100644 --- a/apps/studio/state/shortcuts/useShortcut.test.tsx +++ b/apps/studio/state/shortcuts/useShortcut.test.tsx @@ -33,7 +33,12 @@ vi.mock('./useIsShortcutEnabled', () => ({ const getLastHotkeyOptions = () => { const call = mockUseHotkeySequence.mock.calls.at(-1) if (!call) throw new Error('useHotkeySequence was not called') - return call[2] as { enabled: boolean; timeout: number | undefined; ignoreInputs?: boolean } + return call[2] as { + enabled: boolean + timeout: number | undefined + ignoreInputs?: boolean + meta?: { id?: string; name?: string; referenceGroup?: string } + } } const getLastRegisterCall = () => { @@ -308,4 +313,33 @@ describe('useShortcut', () => { }) }) }) + + describe('reference-sheet metadata', () => { + it('forwards id, label, and referenceGroup as registration meta', () => { + renderHook(() => useShortcut(SHORTCUT_IDS.NAV_HOME, vi.fn())) + expect(getLastHotkeyOptions().meta).toEqual({ + id: SHORTCUT_IDS.NAV_HOME, + name: SHORTCUT_DEFINITIONS[SHORTCUT_IDS.NAV_HOME].label, + referenceGroup: SHORTCUT_DEFINITIONS[SHORTCUT_IDS.NAV_HOME].referenceGroup, + }) + }) + + it('uses the caller label override in meta.name', () => { + renderHook(() => useShortcut(SHORTCUT_IDS.NAV_HOME, vi.fn(), { label: 'Go home' })) + expect(getLastHotkeyOptions().meta?.name).toBe('Go home') + }) + + it('keeps a stable meta reference when inputs do not change', () => { + const { rerender } = renderHook( + ({ cb }: { cb: () => void }) => useShortcut(SHORTCUT_IDS.NAV_HOME, cb), + { initialProps: { cb: vi.fn() } } + ) + + const first = getLastHotkeyOptions().meta + + rerender({ cb: vi.fn() }) + + expect(getLastHotkeyOptions().meta).toBe(first) + }) + }) }) diff --git a/apps/studio/state/shortcuts/useShortcut.tsx b/apps/studio/state/shortcuts/useShortcut.tsx index fc16bd4c139d1..698c787425e13 100644 --- a/apps/studio/state/shortcuts/useShortcut.tsx +++ b/apps/studio/state/shortcuts/useShortcut.tsx @@ -1,5 +1,5 @@ -import { useHotkeySequence } from '@tanstack/react-hotkeys' -import { Fragment, useCallback } from 'react' +import { useHotkeySequence, type HotkeyMeta } from '@tanstack/react-hotkeys' +import { Fragment, useCallback, useMemo } from 'react' import { KeyboardShortcut } from 'ui' import { useRegisterCommands, useSetCommandMenuOpen } from 'ui-patterns/CommandMenu' import type { ICommand } from 'ui-patterns/CommandMenu/api/types' @@ -10,6 +10,17 @@ import { useIsShortcutEnabled } from './useIsShortcutEnabled' import { COMMAND_MENU_SECTIONS } from '@/components/interfaces/App/CommandMenu/CommandMenu.utils' import useLatest from '@/hooks/misc/useLatest' +/** + * Shape we store on each registration's `options.meta` so the Keyboard + * shortcuts reference sheet can read it back via `useHotkeyRegistrations()`. + * The library's `HotkeyMeta` is open for declaration merging, but we don't + * own a direct dep on `@tanstack/hotkeys`, so we keep the extension local. + */ +export interface ShortcutHotkeyMeta extends HotkeyMeta { + id: ShortcutId + referenceGroup?: string +} + const hotkeyToKeys = (hotkey: string): string[] => hotkey.split('+').map((part) => (part === 'Mod' ? 'Meta' : part)) @@ -74,6 +85,14 @@ export function useShortcut(id: ShortcutId, callback: () => void, options?: Shor options?.registerInCommandMenu ?? def.options?.registerInCommandMenu ?? false const label = options?.label ?? def.label + // Stable identity so we don't churn the registration store on every render. + // setOptions in @tanstack/hotkeys notifies subscribers each call, which + // would cascade to every component using useHotkeyRegistrations(). + const meta = useMemo( + () => ({ id, name: label, referenceGroup: def.referenceGroup }), + [def.referenceGroup, id, label] + ) + // Only include `ignoreInputs` when set. The library resolves it to a concrete // boolean at register time (false for Meta/Ctrl/Escape, true otherwise), but // its setOptions does an object spread on every re-render — passing @@ -82,6 +101,7 @@ export function useShortcut(id: ShortcutId, callback: () => void, options?: Shor useHotkeySequence(def.sequence, callback, { enabled, timeout, + meta, ...(ignoreInputs !== undefined && { ignoreInputs }), }) diff --git a/apps/www/_blog/2026-05-06-introducing-supabase-server.mdx b/apps/www/_blog/2026-05-06-introducing-supabase-server.mdx index d423b5e668429..fba60a131a6e7 100644 --- a/apps/www/_blog/2026-05-06-introducing-supabase-server.mdx +++ b/apps/www/_blog/2026-05-06-introducing-supabase-server.mdx @@ -17,7 +17,7 @@ toc_depth: 2 Today we're releasing `@supabase/server` in public beta. -This is a new package that handles auth verification, client setup, request context, and common server-side boilerplate for you. It works across Edge Functions, Cloudflare Workers, Hono and Bun. +This is a new package that handles auth verification, client setup, request context, and common server-side boilerplate for you. It works across Edge Functions, Vercel Functions, Cloudflare Workers, Hono and Bun. We anonymously analyzed 25,000 deployed Edge Functions and saw the same pattern everywhere: developers were rebuilding the same setup code over and over just to get to their actual business logic. @@ -199,16 +199,18 @@ Now you get support for the new auth keys without manual JWT verification. Delet `withSupabase` returns a standard `(Request) => Promise` handler. It works with any runtime that supports the Web API pattern. -Edge Functions and Cloudflare Workers: +Edge Functions, Vercel Functions, and Cloudflare Workers: ```typescript -import { withSupabase } from 'npm:@supabase/server' +import { withSupabase } from '@supabase/server' export default { fetch: withSupabase({ auth: 'user' }, handler), } ``` +> On Edge Functions, declare the dependency in `deno.json` to import `@supabase/server` from `npm:@supabase/server`. + Hono (with the included adapter): ```typescript @@ -286,9 +288,15 @@ No. `@supabase/ssr` handles cookie-based session management for frameworks like If you would like to adopt the DX that this package provides, check our [SSR frameworks](https://github.com/supabase/server/blob/main/docs/ssr-frameworks.md) documentation for implementation references. -**Does this only support Hono?** +**Which runtimes does this support?** + +Any runtime or platform that supports the standard `Request`/`Response` Web API. `withSupabase` returns a standard `(Request) => Promise` handler, so it works on Supabase Edge Functions, Vercel Functions, Cloudflare Workers, Bun, Deno and more. -No. `withSupabase` works with any runtime that supports the standard `Request`/`Response` Web API: Edge Functions, Cloudflare Workers, Bun, and more. Hono was the first framework adapter we shipped, and we have already merged a community PR for the H3 adapter. We expect to accept more community-contributed adapters. +**Is Hono the only supported framework?** + +No. Hono was the first framework adapter we shipped, and we have already merged a community PR for the H3 adapter. We expect to accept more community-contributed adapters. + +See more in our adapters [documentation](https://github.com/supabase/server/tree/main/src/adapters). **Where is the documentation?** @@ -315,9 +323,7 @@ npm install @supabase/server@latest npx skills add supabase/server ``` -The skill gives Claude Code and Cursor full context about the API surface, patterns, and migration paths. From there, you can prompt your way through most tasks. - -Migrate existing Edge Functions to the new API keys: +The skill gives Claude Code, Codex, Cursor and any agentic coding tool full context about the API surface, patterns, and migration paths. From there, you can prompt your way through most tasks. ``` Analyze all Edge Functions, and plan a full migration to use diff --git a/apps/www/_go/lead-gen/aws-activate-offer.tsx b/apps/www/_go/lead-gen/aws-activate-offer.tsx index e00782d7494dd..1c141c569096c 100644 --- a/apps/www/_go/lead-gen/aws-activate-offer.tsx +++ b/apps/www/_go/lead-gen/aws-activate-offer.tsx @@ -1,7 +1,6 @@ +import { HubSpotFormEmbed } from 'marketing' import type { GoPageInput } from 'marketing' -import HubSpotFormEmbed from './components/HubSpotFormEmbed' - const page: GoPageInput = { template: 'lead-gen', slug: 'aws-activate-offer', @@ -110,7 +109,7 @@ const page: GoPageInput = { id: 'form', title: 'Apply for your credits', children: ( -
+
), diff --git a/apps/www/_go/lead-gen/components/HubSpotFormEmbed.tsx b/apps/www/_go/lead-gen/components/HubSpotFormEmbed.tsx deleted file mode 100644 index 159cb31ebc291..0000000000000 --- a/apps/www/_go/lead-gen/components/HubSpotFormEmbed.tsx +++ /dev/null @@ -1,92 +0,0 @@ -'use client' - -import { useEffect, useId } from 'react' - -declare global { - interface Window { - hbspt?: { - forms: { - create: (config: { portalId: string; formId: string; target: string }) => void - } - } - } -} - -const HUBSPOT_SCRIPT_SRC = 'https://js.hsforms.net/forms/embed/v2.js' - -function loadHubSpotScript(): Promise { - return new Promise((resolve, reject) => { - if (window.hbspt) { - resolve() - return - } - - const existing = document.querySelector( - `script[src="${HUBSPOT_SCRIPT_SRC}"]` - ) - if (existing) { - existing.addEventListener('load', () => resolve(), { once: true }) - existing.addEventListener( - 'error', - () => reject(new Error('Failed to load HubSpot form script')), - { - once: true, - } - ) - return - } - - const script = document.createElement('script') - script.src = HUBSPOT_SCRIPT_SRC - script.async = true - script.defer = true - script.onload = () => resolve() - script.onerror = () => reject(new Error('Failed to load HubSpot form script')) - document.body.appendChild(script) - }) -} - -export default function HubSpotFormEmbed({ - portalId, - formId, -}: { - portalId: string - formId: string -}) { - const targetId = `hubspot-form-${useId().replace(/:/g, '-')}` - - useEffect(() => { - let cancelled = false - - const mountForm = async () => { - try { - await loadHubSpotScript() - if (cancelled || !window.hbspt) return - - const target = document.getElementById(targetId) - if (!target) return - - // Reset target to avoid duplicate forms on remounts. - while (target.firstChild) { - target.removeChild(target.firstChild) - } - - window.hbspt.forms.create({ - portalId, - formId, - target: `#${targetId}`, - }) - } catch (error) { - console.error('[go/hubspot] Failed to initialize HubSpot form embed', error) - } - } - - mountForm() - - return () => { - cancelled = true - } - }, [formId, portalId, targetId]) - - return
-} diff --git a/apps/www/package.json b/apps/www/package.json index abc51b21f518e..262d7d7992b0a 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -64,7 +64,7 @@ "mdast-util-to-markdown": "^1.5.0", "micromark-extension-gfm": "^2.0.3", "micromark-extension-mdxjs": "^1.0.1", - "next": "^15.5.15", + "next": "^15.5.18", "next-mdx-remote-client": "^1.1.7", "next-seo": "^6.5.0", "next-themes": "catalog:", diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index 827883491f9e5..7f2fe2d91b372 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -400,6 +400,18 @@ export interface ProjectCreationSimpleVersionSubmittedEvent { groups: TelemetryGroups } +/** + * User clicked to connect GitHub during project creation. + * + * @group Events + * @source studio + * @page new/{slug} + */ +export interface ProjectCreationGithubConnectClickedEvent { + action: 'project_creation_github_connect_clicked' + groups: Omit +} + /** * Existing project creation form confirm modal was triggered and opened. * @@ -3418,6 +3430,7 @@ export type TelemetryEvent = | TimezonePickerClickedEvent | ProjectCreationRlsOptionExperimentExposedEvent | ProjectCreationDefaultPrivilegesExposedEvent + | ProjectCreationGithubConnectClickedEvent | ProjectCreationSimpleVersionSubmittedEvent | ProjectCreationSimpleVersionConfirmModalOpenedEvent | TableApiAccessToggleClickedEvent diff --git a/packages/marketing/index.ts b/packages/marketing/index.ts index 8f286a4718521..7d1cd84017dc8 100644 --- a/packages/marketing/index.ts +++ b/packages/marketing/index.ts @@ -1 +1,2 @@ export * from './src/go' +export * from './src/forms' diff --git a/packages/marketing/src/forms/HubSpotFormEmbed.tsx b/packages/marketing/src/forms/HubSpotFormEmbed.tsx new file mode 100644 index 0000000000000..d6fd62eec76ea --- /dev/null +++ b/packages/marketing/src/forms/HubSpotFormEmbed.tsx @@ -0,0 +1,164 @@ +'use client' + +import { useEffect, useId, useRef, useState } from 'react' + +interface HubSpotFormCreateConfig { + portalId: string + formId: string + region?: string + target: string + cssClass?: string +} + +declare global { + interface Window { + hbspt?: { + forms: { + create: (config: HubSpotFormCreateConfig) => void + } + } + } +} + +const HUBSPOT_SCRIPT_SRC = 'https://js.hsforms.net/forms/embed/v2.js' + +let scriptPromise: Promise | null = null + +function loadHubSpotScript(): Promise { + if (typeof window === 'undefined') { + return Promise.reject(new Error('HubSpot script can only load in the browser')) + } + if (window.hbspt) return Promise.resolve() + if (scriptPromise) return scriptPromise + + scriptPromise = new Promise((resolve, reject) => { + const existing = document.querySelector( + `script[src="${HUBSPOT_SCRIPT_SRC}"]` + ) + if (existing) { + existing.addEventListener('load', () => resolve(), { once: true }) + existing.addEventListener('error', () => reject(new Error('HubSpot script failed to load')), { + once: true, + }) + return + } + + const script = document.createElement('script') + script.src = HUBSPOT_SCRIPT_SRC + script.async = true + script.defer = true + script.onload = () => resolve() + script.onerror = () => reject(new Error('HubSpot script failed to load')) + document.body.appendChild(script) + }) + + // Reset the cached promise on failure so a retry can re-attempt the load. + scriptPromise.catch(() => { + scriptPromise = null + }) + + return scriptPromise +} + +export interface HubSpotFormEmbedProps { + /** HubSpot portal (hub) ID. */ + portalId: string + /** HubSpot form GUID. */ + formId: string + /** + * HubSpot region — required for EU-hosted portals (e.g. `'eu1'`). Omit for + * the default North America region. + */ + region?: string + /** Class name applied to the wrapper element. */ + className?: string +} + +type LoadState = 'loading' | 'ready' | 'error' + +/** + * Embeds a HubSpot-hosted form by loading their `forms/embed/v2.js` script + * and mounting the form into a container managed by this component. Use this + * when the form is managed in HubSpot (e.g. with conditional fields, GDPR + * notices, or workflow integrations) and you don't want to re-implement that + * logic natively. + * + * Note: HubSpot renders the form inside a same-origin iframe styled by their + * own stylesheet. The iframe's appearance is driven by the form's settings in + * HubSpot — adjust visual styling there. + * + * For native-rendered forms with multi-channel fan-out (HubSpot + Customer.io + * + Notion), use `MarketingForm` instead. + */ +export default function HubSpotFormEmbed({ + portalId, + formId, + region, + className, +}: HubSpotFormEmbedProps) { + const targetId = `hubspot-form-${useId().replace(/:/g, '-')}` + const containerRef = useRef(null) + const [state, setState] = useState('loading') + + useEffect(() => { + let cancelled = false + setState('loading') + + loadHubSpotScript() + .then(() => { + if (cancelled) return + if (!window.hbspt || !containerRef.current) { + setState('error') + return + } + + // Clear any previously rendered form (e.g. on prop change or remount). + while (containerRef.current.firstChild) { + containerRef.current.removeChild(containerRef.current.firstChild) + } + + window.hbspt.forms.create({ + portalId, + formId, + ...(region ? { region } : {}), + target: `#${targetId}`, + }) + setState('ready') + }) + .catch((error) => { + if (cancelled) return + console.error('[marketing/hubspot] Failed to initialize HubSpot form embed', error) + setState('error') + }) + + return () => { + cancelled = true + } + }, [portalId, formId, region, targetId]) + + return ( +
+
+ + {state === 'loading' && ( +
+
+
+
+
+
+
+ )} + + {state === 'error' && ( +

+ We couldn't load the form. Please refresh the page or try again later. +

+ )} +
+ ) +} diff --git a/packages/marketing/src/forms/MarketingForm.tsx b/packages/marketing/src/forms/MarketingForm.tsx new file mode 100644 index 0000000000000..f91327e3ea76e --- /dev/null +++ b/packages/marketing/src/forms/MarketingForm.tsx @@ -0,0 +1,355 @@ +'use client' + +import { useState } from 'react' +import ReactMarkdown from 'react-markdown' +import { + Button, + Checkbox, + Input_Shadcn_, + Select_Shadcn_, + SelectContent_Shadcn_, + SelectItem_Shadcn_, + SelectTrigger_Shadcn_, + SelectValue_Shadcn_, + TextArea_Shadcn_, +} from 'ui' +import type { z } from 'zod' + +import { submitFormAction } from '../go/actions/submitForm' +import { formCrmConfigSchema, formFieldSchema, type GoFormFieldShowWhen } from '../go/schemas' + +/** Input-shape field type — fields with Zod defaults (`half`, `required`) are optional here. */ +export type MarketingFormField = z.input +export type MarketingFormCrmConfig = z.input + +/** + * Evaluate a `showWhen` rule against the current form values. All supplied + * criteria must pass (AND). Missing values are treated as the empty string. + */ +function evaluateShowWhen(showWhen: GoFormFieldShowWhen, values: Record): boolean { + const value = values[showWhen.field] ?? '' + if (showWhen.equals !== undefined && value !== showWhen.equals) return false + if (showWhen.notEquals !== undefined && value === showWhen.notEquals) return false + if (showWhen.in !== undefined && !showWhen.in.includes(value)) return false + if (showWhen.notIn !== undefined && showWhen.notIn.includes(value)) return false + if (showWhen.truthy === true && value === '') return false + if (showWhen.truthy === false && value !== '') return false + return true +} + +export interface MarketingFormProps { + /** Form fields. The submit handler builds the payload from these by `name`. */ + fields: MarketingFormField[] + /** Submit button label. */ + submitLabel: string + /** Optional title shown above the form. */ + title?: string + /** Optional description shown above the form. */ + description?: string + /** Optional markdown disclaimer shown beneath the submit button. */ + disclaimer?: string + /** Message shown after a successful submission. Ignored when `successRedirect` is set. */ + successMessage?: string + /** URL to redirect the user to after a successful submission. Overrides `successMessage`. */ + successRedirect?: string + /** CRM fan-out config — submits to HubSpot, Customer.io, and/or Notion in parallel. */ + crm?: MarketingFormCrmConfig + /** Wraps the form in a styled card (border + padding). Defaults to `true`. */ + card?: boolean + /** Extra class names applied to the outer wrapper. */ + className?: string +} + +type SubmitState = 'idle' | 'loading' | 'success' | 'error' + +function FieldInput({ + field, + value, + onChange, +}: { + field: MarketingFormField + value: string + onChange: (value: string) => void +}) { + switch (field.type) { + case 'text': + case 'email': + case 'url': + return ( + onChange(e.target.value)} + /> + ) + case 'textarea': + return ( + onChange(e.target.value)} + /> + ) + case 'select': + return ( + + + + + + {field.options.map((opt) => ( + + {opt.label} + + ))} + + + ) + case 'checkbox': + return null + default: { + const _exhaustive: never = field + return null + } + } +} + +function Field({ + field, + value, + onChange, +}: { + field: MarketingFormField + value: string + onChange: (value: string) => void +}) { + if (field.type === 'checkbox') { + return ( + + ) + } + + return ( +
+ + + {field.description && ( +

{field.description}

+ )} +
+ ) +} + +export default function MarketingForm({ + fields, + submitLabel, + title, + description, + disclaimer, + successMessage, + successRedirect, + crm, + card = true, + className, +}: MarketingFormProps) { + const [values, setValues] = useState>(() => + Object.fromEntries(fields.map((f) => [f.name, ''])) + ) + const [submitState, setSubmitState] = useState('idle') + const [errorMessages, setErrorMessages] = useState([]) + + const handleChange = (name: string, value: string) => { + setValues((prev) => ({ ...prev, [name]: value })) + } + + // Only fields whose `showWhen` (if any) currently passes are rendered or submitted. + const visibleFields = fields.filter((f) => !f.showWhen || evaluateShowWhen(f.showWhen, values)) + const visibleFieldNames = new Set(visibleFields.map((f) => f.name)) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + // Required checkboxes aren't covered by HTML5 validation; check them manually. + const uncheckedRequired = visibleFields.filter( + (f) => f.type === 'checkbox' && f.required && values[f.name] !== 'true' + ) + if (uncheckedRequired.length > 0) { + setSubmitState('error') + setErrorMessages( + uncheckedRequired.map((f) => `Please confirm: ${f.label.replace(/\*$/, '').trim()}`) + ) + return + } + + // Strip values for fields that are currently hidden so stale data doesn't leak. + const submittedValues = Object.fromEntries( + Object.entries(values).filter(([name]) => visibleFieldNames.has(name)) + ) + + if (!crm) { + if (process.env.NODE_ENV === 'development') { + console.log('[marketing/form] No CRM configured — form values:', submittedValues) + } + return + } + + setSubmitState('loading') + setErrorMessages([]) + + const pageUri = typeof window !== 'undefined' ? window.location.href : undefined + const pageName = typeof document !== 'undefined' ? document.title : undefined + + try { + const result = await submitFormAction(crm, submittedValues, { pageUri, pageName }) + + if (result.success) { + if (successRedirect) { + window.location.href = successRedirect + } else { + setSubmitState('success') + } + } else { + setSubmitState('error') + setErrorMessages(result.errors) + } + } catch (err: any) { + console.error('[marketing/form] Form submission failed:', err) + setSubmitState('error') + setErrorMessages(['Something went wrong. Please try again.']) + } + } + + // Group fields into rows: half-width fields pair up, full-width fields get their own row. + // Checkbox fields always take a full row regardless of their `half` flag. + const rows: MarketingFormField[][] = [] + let pendingHalf: MarketingFormField | null = null + + for (const field of visibleFields) { + const isHalf = field.half && field.type !== 'checkbox' + if (isHalf) { + if (pendingHalf) { + rows.push([pendingHalf, field]) + pendingHalf = null + } else { + pendingHalf = field + } + } else { + if (pendingHalf) { + rows.push([pendingHalf]) + pendingHalf = null + } + rows.push([field]) + } + } + if (pendingHalf) { + rows.push([pendingHalf]) + } + + if (submitState === 'success') { + return ( +
+
+

Thank you!

+

+ {successMessage ?? "We've received your submission and will be in touch soon."} +

+
+
+ ) + } + + return ( +
+ {(title || description) && ( +
+ {title && ( +

+ {title} +

+ )} + {description &&

{description}

} +
+ )} +
+ {rows.map((row, rowIndex) => ( +
1 ? 'grid grid-cols-1 sm:grid-cols-2 gap-4' : undefined} + > + {row.map((field) => ( + handleChange(field.name, v)} + /> + ))} +
+ ))} + + {submitState === 'error' && errorMessages.length > 0 && ( +
+ {errorMessages.map((msg, i) => ( +

+ {msg} +

+ ))} +
+ )} + +
+ + + + {disclaimer && ( +
+

{children}

, + a: ({ href, children }) => ( + + {children} + + ), + }} + > + {disclaimer} +
+
+ )} +
+
+ ) +} diff --git a/packages/marketing/src/forms/index.ts b/packages/marketing/src/forms/index.ts new file mode 100644 index 0000000000000..974e7091b4bae --- /dev/null +++ b/packages/marketing/src/forms/index.ts @@ -0,0 +1,9 @@ +export { default as MarketingForm } from './MarketingForm' +export type { + MarketingFormCrmConfig, + MarketingFormField, + MarketingFormProps, +} from './MarketingForm' + +export { default as HubSpotFormEmbed } from './HubSpotFormEmbed' +export type { HubSpotFormEmbedProps } from './HubSpotFormEmbed' diff --git a/packages/marketing/src/go/schemas.ts b/packages/marketing/src/go/schemas.ts index d0b73469e2608..d9bec8effedc0 100644 --- a/packages/marketing/src/go/schemas.ts +++ b/packages/marketing/src/go/schemas.ts @@ -101,18 +101,55 @@ export const threeColumnSectionSchema = z.object({ // ----- Form field schemas ----- +/** + * Conditional visibility rule: a field is visible only when the referenced + * field's current value satisfies all of the supplied criteria. + * + * - `equals` / `notEquals` — strict string compare against the live value. + * - `in` / `notIn` — membership check against a list of values. + * - `truthy` — when `true`, requires a non-empty value; when `false`, requires empty. + * + * Multiple criteria within the same `showWhen` are combined with AND. Hidden + * fields are excluded from the submitted payload. + */ +export const showWhenSchema = z + .object({ + field: z.string().min(1), + equals: z.string().optional(), + notEquals: z.string().optional(), + in: z.array(z.string()).optional(), + notIn: z.array(z.string()).optional(), + truthy: z.boolean().optional(), + }) + .refine( + (v) => + v.equals !== undefined || + v.notEquals !== undefined || + v.in !== undefined || + v.notIn !== undefined || + v.truthy !== undefined, + { message: 'showWhen must include at least one of: equals, notEquals, in, notIn, truthy' } + ) + const formFieldBase = z.object({ name: z.string().min(1), label: z.string().min(1), + /** Helper text rendered beneath the input. */ + description: z.string().optional(), placeholder: z.string().optional(), required: z.boolean().optional().default(false), half: z.boolean().optional().default(false), + showWhen: showWhenSchema.optional(), }) export const textFieldSchema = formFieldBase.extend({ type: z.literal('text'), }) +export const urlFieldSchema = formFieldBase.extend({ + type: z.literal('url'), +}) + export const emailFieldSchema = formFieldBase.extend({ type: z.literal('email'), }) @@ -127,11 +164,17 @@ export const selectFieldSchema = formFieldBase.extend({ options: z.array(z.object({ label: z.string(), value: z.string() })).min(1), }) +export const checkboxFieldSchema = formFieldBase.extend({ + type: z.literal('checkbox'), +}) + export const formFieldSchema = z.discriminatedUnion('type', [ textFieldSchema, + urlFieldSchema, emailFieldSchema, textareaFieldSchema, selectFieldSchema, + checkboxFieldSchema, ]) // ----- Form CRM config schemas ----- @@ -401,6 +444,7 @@ export type GoSingleColumnSection = z.infer export type GoTwoColumnSection = z.infer export type GoThreeColumnSection = z.infer export type GoFormField = z.infer +export type GoFormFieldShowWhen = z.infer export type GoFormSection = z.infer export type GoHubSpotFormConfig = z.infer export type GoCustomerIOFormConfig = z.infer diff --git a/packages/marketing/src/go/sections/FormSection.tsx b/packages/marketing/src/go/sections/FormSection.tsx index 0939d15e86153..c4147eb12519a 100644 --- a/packages/marketing/src/go/sections/FormSection.tsx +++ b/packages/marketing/src/go/sections/FormSection.tsx @@ -1,240 +1,22 @@ 'use client' -import { useState } from 'react' -import ReactMarkdown from 'react-markdown' -import { - Button, - Input_Shadcn_, - Select_Shadcn_, - SelectContent_Shadcn_, - SelectItem_Shadcn_, - SelectTrigger_Shadcn_, - SelectValue_Shadcn_, - TextArea_Shadcn_, -} from 'ui' - -import { submitFormAction } from '../actions/submitForm' -import type { GoFormField, GoFormSection } from '../schemas' - -function FormField({ - field, - value, - onChange, -}: { - field: GoFormField - value: string - onChange: (value: string) => void -}) { - switch (field.type) { - case 'text': - case 'email': - return ( - onChange(e.target.value)} - /> - ) - case 'textarea': - return ( - onChange(e.target.value)} - /> - ) - case 'select': - return ( - - - - - - {field.options.map((opt) => ( - - {opt.label} - - ))} - - - ) - default: { - const _exhaustive: never = field - return null - } - } -} - -type SubmitState = 'idle' | 'loading' | 'success' | 'error' +import MarketingForm from '../../forms/MarketingForm' +import type { GoFormSection } from '../schemas' export default function FormSection({ section }: { section: GoFormSection }) { - const [values, setValues] = useState>(() => - Object.fromEntries(section.fields.map((f) => [f.name, ''])) - ) - const [submitState, setSubmitState] = useState('idle') - const [errorMessages, setErrorMessages] = useState([]) - - const handleChange = (name: string, value: string) => { - setValues((prev) => ({ ...prev, [name]: value })) - } - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - - if (!section.crm) { - if (process.env.NODE_ENV === 'development') { - console.log('[go/form] No CRM configured — form values:', values) - } - return - } - - setSubmitState('loading') - setErrorMessages([]) - - const pageUri = typeof window !== 'undefined' ? window.location.href : undefined - const pageName = typeof document !== 'undefined' ? document.title : undefined - - try { - const result = await submitFormAction(section.crm, values, { pageUri, pageName }) - - if (result.success) { - if (section.successRedirect) { - window.location.href = section.successRedirect - } else { - setSubmitState('success') - } - } else { - setSubmitState('error') - setErrorMessages(result.errors) - } - } catch (err: any) { - // Unexpected client-side error (network failure, server action crash, etc.) - console.error('[go/form] Form submission failed:', err) - setSubmitState('error') - setErrorMessages(['Something went wrong. Please try again.']) - } - } - - // Group fields into rows: half-width fields pair up, full-width fields get their own row - const rows: GoFormField[][] = [] - let pendingHalf: GoFormField | null = null - - for (const field of section.fields) { - if (field.half) { - if (pendingHalf) { - rows.push([pendingHalf, field]) - pendingHalf = null - } else { - pendingHalf = field - } - } else { - if (pendingHalf) { - rows.push([pendingHalf]) - pendingHalf = null - } - rows.push([field]) - } - } - if (pendingHalf) { - rows.push([pendingHalf]) - } - - if (submitState === 'success') { - return ( -
-
-
-

Thank you!

-

- {section.successMessage ?? - "We've received your submission and will be in touch soon."} -

-
-
-
- ) - } - return (
- {(section.title || section.description) && ( -
- {section.title && ( -

- {section.title} -

- )} - {section.description && ( -

{section.description}

- )} -
- )} -
- {rows.map((row, rowIndex) => ( -
1 ? 'grid grid-cols-1 sm:grid-cols-2 gap-4' : undefined} - > - {row.map((field) => ( -
- - handleChange(field.name, v)} - /> -
- ))} -
- ))} - - {submitState === 'error' && errorMessages.length > 0 && ( -
- {errorMessages.map((msg, i) => ( -

- {msg} -

- ))} -
- )} - -
- - - - {section.disclaimer && ( -
-

{children}

, - a: ({ href, children }) => ( - - {children} - - ), - }} - > - {section.disclaimer} -
-
- )} -
+
) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2a19ed61498d4..ad32cbf228b4c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,8 +43,8 @@ catalogs: specifier: ^4.1.4 version: 4.1.4 next: - specifier: 16.2.3 - version: 16.2.3 + specifier: 16.2.6 + version: 16.2.6 next-themes: specifier: ^0.4.6 version: 0.4.6 @@ -201,10 +201,10 @@ importers: version: 1.2.0 next: specifier: 'catalog:' - version: 16.2.3(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) + version: 16.2.6(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) next-contentlayer2: specifier: 0.4.6 - version: 0.4.6(contentlayer2@0.4.6(esbuild@0.25.2)(markdown-wasm@1.2.0)(supports-color@8.1.1))(esbuild@0.25.2)(markdown-wasm@1.2.0)(next@16.2.3(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1) + version: 0.4.6(contentlayer2@0.4.6(esbuild@0.25.2)(markdown-wasm@1.2.0)(supports-color@8.1.1))(esbuild@0.25.2)(markdown-wasm@1.2.0)(next@16.2.6(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1) next-themes: specifier: 'catalog:' version: 0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -352,7 +352,7 @@ importers: version: 8.1.0(@octokit/core@7.0.6) '@sentry/nextjs': specifier: 'catalog:' - version: 10.27.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.15(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.105.4(esbuild@0.25.2)) + version: 10.27.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.18(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.105.4(esbuild@0.25.2)) '@supabase/supabase-js': specifier: 'catalog:' version: 2.105.3 @@ -459,8 +459,8 @@ importers: specifier: ^1.0.0 version: 1.0.1 next: - specifier: ^15.5.15 - version: 15.5.15(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) + specifier: ^15.5.18 + version: 15.5.18(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) next-mdx-remote-client: specifier: ^1.1.7 version: 1.1.7(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)(unified@11.0.5) @@ -472,7 +472,7 @@ importers: version: 0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) nuqs: specifier: ^1.19.1 - version: 1.19.1(next@15.5.15(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4)) + version: 1.19.1(next@15.5.18(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4)) openai: specifier: ^4.75.1 version: 4.104.0(encoding@0.1.13)(ws@8.19.0)(zod@3.25.76) @@ -716,10 +716,10 @@ importers: version: 0.511.0(react@18.3.1) next: specifier: 'catalog:' - version: 16.2.3(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) + version: 16.2.6(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) next-contentlayer2: specifier: 0.4.6 - version: 0.4.6(contentlayer2@0.4.6(esbuild@0.25.2)(markdown-wasm@1.2.0)(supports-color@8.1.1))(esbuild@0.25.2)(markdown-wasm@1.2.0)(next@16.2.3(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1) + version: 0.4.6(contentlayer2@0.4.6(esbuild@0.25.2)(markdown-wasm@1.2.0)(supports-color@8.1.1))(esbuild@0.25.2)(markdown-wasm@1.2.0)(next@16.2.6(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1) next-themes: specifier: 'catalog:' version: 0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -919,7 +919,7 @@ importers: version: 0.3.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@sentry/nextjs': specifier: 'catalog:' - version: 10.27.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.105.4(esbuild@0.25.2)) + version: 10.27.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.105.4(esbuild@0.25.2)) '@std/path': specifier: npm:@jsr/std__path@^1.0.8 version: '@jsr/std__path@1.0.8' @@ -1069,13 +1069,13 @@ importers: version: 0.52.2 next: specifier: 'catalog:' - version: 16.2.3(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) + version: 16.2.6(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) next-themes: specifier: 'catalog:' version: 0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) nuqs: specifier: 2.7.1 - version: 2.7.1(@tanstack/react-router@1.168.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(next@16.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-router@7.13.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + version: 2.7.1(@tanstack/react-router@1.168.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-router@7.13.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) openai: specifier: ^4.104.0 version: 4.104.0(encoding@0.1.13)(ws@8.19.0)(zod@3.25.76) @@ -1325,7 +1325,7 @@ importers: version: 2.11.3(@types/node@22.13.14)(typescript@6.0.2) next-router-mock: specifier: ^0.9.13 - version: 0.9.13(next@16.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1) + version: 0.9.13(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1) node-mocks-http: specifier: ^1.17.2 version: 1.17.2(@types/node@22.13.14) @@ -1427,10 +1427,10 @@ importers: version: 0.55.1 next: specifier: 'catalog:' - version: 16.2.3(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) + version: 16.2.6(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) next-contentlayer2: specifier: 0.4.6 - version: 0.4.6(contentlayer2@0.4.6(esbuild@0.25.2)(markdown-wasm@1.2.0)(supports-color@8.1.1))(esbuild@0.25.2)(markdown-wasm@1.2.0)(next@16.2.3(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1) + version: 0.4.6(contentlayer2@0.4.6(esbuild@0.25.2)(markdown-wasm@1.2.0)(supports-color@8.1.1))(esbuild@0.25.2)(markdown-wasm@1.2.0)(next@16.2.6(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1) next-themes: specifier: 'catalog:' version: 0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1629,7 +1629,7 @@ importers: version: 21.1.1 '@sentry/nextjs': specifier: 'catalog:' - version: 10.27.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.15(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.105.4(esbuild@0.25.2)) + version: 10.27.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.18(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.105.4(esbuild@0.25.2)) '@supabase/ssr': specifier: 'catalog:' version: 0.10.2(@supabase/supabase-js@2.105.3) @@ -1721,20 +1721,20 @@ importers: specifier: ^1.0.1 version: 1.0.1 next: - specifier: ^15.5.15 - version: 15.5.15(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) + specifier: ^15.5.18 + version: 15.5.18(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) next-mdx-remote-client: specifier: ^1.1.7 version: 1.1.7(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1)(unified@11.0.5) next-seo: specifier: ^6.5.0 - version: 6.5.0(next@15.5.15(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 6.5.0(next@15.5.18(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-themes: specifier: 'catalog:' version: 0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) nuqs: specifier: ^2.8.1 - version: 2.8.1(@tanstack/react-router@1.168.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(next@15.5.15(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-router@7.13.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + version: 2.8.1(@tanstack/react-router@1.168.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(next@15.5.18(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-router@7.13.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) openai: specifier: ^4.75.1 version: 4.104.0(encoding@0.1.13)(ws@8.19.0)(zod@3.25.76) @@ -1800,7 +1800,7 @@ importers: version: link:../../packages/ui-patterns unicornstudio-react: specifier: 2.0.1-1 - version: 2.0.1-1(next@15.5.15(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 2.0.1-1(next@15.5.18(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) use-debounce: specifier: ^7.0.1 version: 7.0.1(react@18.3.1) @@ -2080,13 +2080,13 @@ importers: version: 0.7.9 flags: specifier: ^4.0.0 - version: 4.0.1(@opentelemetry/api@1.9.0)(next@16.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 4.0.1(@opentelemetry/api@1.9.0)(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) lodash: specifier: ^4.18.1 version: 4.18.1 next: specifier: 'catalog:' - version: 16.2.3(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) + version: 16.2.6(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) next-themes: specifier: 'catalog:' version: 0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -2181,7 +2181,7 @@ importers: version: 0.511.0(react@18.3.1) next: specifier: 'catalog:' - version: 16.2.3(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) + version: 16.2.6(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) react: specifier: 'catalog:' version: 18.3.1 @@ -2212,7 +2212,7 @@ importers: version: link:../config next-router-mock: specifier: ^0.9.13 - version: 0.9.13(next@16.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1) + version: 0.9.13(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1) tailwindcss: specifier: 'catalog:' version: 4.2.4 @@ -2567,7 +2567,7 @@ importers: version: 0.52.2 next: specifier: 'catalog:' - version: 16.2.3(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) + version: 16.2.6(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) next-themes: specifier: 'catalog:' version: 0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -2694,7 +2694,7 @@ importers: version: link:../config next-router-mock: specifier: ^0.9.13 - version: 0.9.13(next@16.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1) + version: 0.9.13(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1) tailwindcss: specifier: ^4.2.4 version: 4.2.4 @@ -4587,11 +4587,11 @@ packages: '@next/env@14.2.35': resolution: {integrity: sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==} - '@next/env@15.5.15': - resolution: {integrity: sha512-vcmyu5/MyFzN7CdqRHO3uHO44p/QPCZkuTUXroeUmhNP8bL5PHFEhik22JUazt+CDDoD6EpBYRCaS2pISL+/hg==} + '@next/env@15.5.18': + resolution: {integrity: sha512-hAV85Ckd9QR6RvH04MEKwsfLTksvFpO47j9xwtoIuvuPnlwecpSi+uZTtm8HirVbtlI2Fnz//xpcSTjFdyJk+g==} - '@next/env@16.2.3': - resolution: {integrity: sha512-ZWXyj4uNu4GCWQw9cjRxWlbD+33mcDszIo9iQxFnBX3Wmgq9ulaSJcl6VhuWx5pCWqqD+9W6Wfz7N0lM5lYPMA==} + '@next/env@16.2.6': + resolution: {integrity: sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==} '@next/eslint-plugin-next@15.5.4': resolution: {integrity: sha512-SR1vhXNNg16T4zffhJ4TS7Xn7eq4NfKfcOsRwea7RIAHrjRpI9ALYbamqIJqkAhowLlERffiwk0FMvTLNdnVtw==} @@ -4607,106 +4607,106 @@ packages: '@mdx-js/react': optional: true - '@next/swc-darwin-arm64@15.5.15': - resolution: {integrity: sha512-6PvFO2Tzt10GFK2Ro9tAVEtacMqRmTarYMFKAnV2vYMdwWc73xzmDQyAV7SwEdMhzmiRoo7+m88DuiXlJlGeaw==} + '@next/swc-darwin-arm64@15.5.18': + resolution: {integrity: sha512-w0WvQf1n+txiwns/9pwIQteCJpZTbxzO2SE0FLcwuD4v0WEh1JPOjdyxWL21XwJsdpx8cFRjyzxzCS/siP7HcQ==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-arm64@16.2.3': - resolution: {integrity: sha512-u37KDKTKQ+OQLvY+z7SNXixwo4Q2/IAJFDzU1fYe66IbCE51aDSAzkNDkWmLN0yjTUh4BKBd+hb69jYn6qqqSg==} + '@next/swc-darwin-arm64@16.2.6': + resolution: {integrity: sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@15.5.15': - resolution: {integrity: sha512-G+YNV+z6FDZTp/+IdGyIMFqalBTaQSnvAA+X/hrt+eaTRFSznRMz9K7rTmzvM6tDmKegNtyzgufZW0HwVzEqaQ==} + '@next/swc-darwin-x64@15.5.18': + resolution: {integrity: sha512-znn71QmDuxm+BOaglihMZfvyySMnNljkVIY5Z2TCssBmm+WqL6c19VhtH5ktFkHa8EZ2bnTUpcNcmNSQsg67og==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-darwin-x64@16.2.3': - resolution: {integrity: sha512-gHjL/qy6Q6CG3176FWbAKyKh9IfntKZTB3RY/YOJdDFpHGsUDXVH38U4mMNpHVGXmeYW4wj22dMp1lTfmu/bTQ==} + '@next/swc-darwin-x64@16.2.6': + resolution: {integrity: sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@15.5.15': - resolution: {integrity: sha512-eVkrMcVIBqGfXB+QUC7jjZ94Z6uX/dNStbQFabewAnk13Uy18Igd1YZ/GtPRzdhtm7QwC0e6o7zOQecul4iC1w==} + '@next/swc-linux-arm64-gnu@15.5.18': + resolution: {integrity: sha512-yPPe5MNL+igZUa+OsqQJisqSfh6oarIuA1Q0BDxljGJhRQyZeP+WRHh7rs/jZUGMh5aY0YdIjXZG0VohkKkUdw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-gnu@16.2.3': - resolution: {integrity: sha512-U6vtblPtU/P14Y/b/n9ZY0GOxbbIhTFuaFR7F4/uMBidCi2nSdaOFhA0Go81L61Zd6527+yvuX44T4ksnf8T+Q==} + '@next/swc-linux-arm64-gnu@16.2.6': + resolution: {integrity: sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@15.5.15': - resolution: {integrity: sha512-RwSHKMQ7InLy5GfkY2/n5PcFycKA08qI1VST78n09nN36nUPqCvGSMiLXlfUmzmpQpF6XeBYP2KRWHi0UW3uNg==} + '@next/swc-linux-arm64-musl@15.5.18': + resolution: {integrity: sha512-glaCczEWIrHsokFZ3pP08U4BpKxwIdnT+txdOM32OBgpL9Yw4aqx8NejmgtZQZOdstQ5f0L3CasIZudzCuD+nw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-arm64-musl@16.2.3': - resolution: {integrity: sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw==} + '@next/swc-linux-arm64-musl@16.2.6': + resolution: {integrity: sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@15.5.15': - resolution: {integrity: sha512-nplqvY86LakS+eeiuWsNWvfmK8pFcOEW7ZtVRt4QH70lL+0x6LG/m1OpJ/tvrbwjmR8HH9/fH2jzW1GlL03TIg==} + '@next/swc-linux-x64-gnu@15.5.18': + resolution: {integrity: sha512-oUfg2EgJmU3R0OCOWiokGFUTvZiPfXtriXiuF3YNxRoROCdgvTedHIzYoeKH34gsZxS/V7mHbfq2hpAHwhH1/A==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-gnu@16.2.3': - resolution: {integrity: sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ==} + '@next/swc-linux-x64-gnu@16.2.6': + resolution: {integrity: sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@15.5.15': - resolution: {integrity: sha512-eAgl9NKQ84/sww0v81DQINl/vL2IBxD7sMybd0cWRw6wqgouVI53brVRBrggqBRP/NWeIAE1dm5cbKYoiMlqDQ==} + '@next/swc-linux-x64-musl@15.5.18': + resolution: {integrity: sha512-JLxSP3KTd9iu/bvUMQxH7RJo9xKSHf55/6RPE4a6FTSZygGn7uvZbCej0AHXydwkggQGSD9UddSjwv6Xz5ESfA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-linux-x64-musl@16.2.3': - resolution: {integrity: sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw==} + '@next/swc-linux-x64-musl@16.2.6': + resolution: {integrity: sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@15.5.15': - resolution: {integrity: sha512-GJVZC86lzSquh0MtvZT+L7G8+jMnJcldloOjA8Kf3wXvBrvb6OGe2MzPuALxFshSm/IpwUtD2mIoof39ymf52A==} + '@next/swc-win32-arm64-msvc@15.5.18': + resolution: {integrity: sha512-ir1v7enP52K2HNz3tQQvwF+x7VNxBk1ciiZ18WBPvxf4C59IqdfmHPJYK3vH7rSxpuCVw/8C712wTXNAtEp+NA==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-arm64-msvc@16.2.3': - resolution: {integrity: sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw==} + '@next/swc-win32-arm64-msvc@16.2.6': + resolution: {integrity: sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@15.5.15': - resolution: {integrity: sha512-nFucjVdwlFqxh/JG3hWSJ4p8+YJV7Ii8aPDuBQULB6DzUF4UNZETXLfEUk+oI2zEznWWULPt7MeuTE6xtK1HSA==} + '@next/swc-win32-x64-msvc@15.5.18': + resolution: {integrity: sha512-LIu5me6QTANCd25E7I5uIEfvgQ06RK7tvHAbYo3zCb3VpxQEPvMcSpd87NwUABDT6MbGPdEGR5VRiK4PPTJhQg==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@next/swc-win32-x64-msvc@16.2.3': - resolution: {integrity: sha512-Ibm29/GgB/ab5n7XKqlStkm54qqZE8v2FnijUPBgrd67FWrac45o/RsNlaOWjme/B5UqeWt/8KM4aWBwA1D2Kw==} + '@next/swc-win32-x64-msvc@16.2.6': + resolution: {integrity: sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -13843,8 +13843,8 @@ packages: next-tick@1.1.0: resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==} - next@15.5.15: - resolution: {integrity: sha512-VSqCrJwtLVGwAVE0Sb/yikrQfkwkZW9p+lL/J4+xe+G3ZA+QnWPqgcfH1tDUEuk9y+pthzzVFp4L/U8JerMfMQ==} + next@15.5.18: + resolution: {integrity: sha512-eKL8zUJkX9Y5lE+RX/2YJoItVdGlIscyVyboeD9wSpp0PaGqjoA4tTpT2qPqz9ax+5IzGESyLSeZ/RCwbSZ2uQ==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} hasBin: true peerDependencies: @@ -13864,8 +13864,8 @@ packages: sass: optional: true - next@16.2.3: - resolution: {integrity: sha512-9V3zV4oZFza3PVev5/poB9g0dEafVcgNyQ8eTRop8GvxZjV2G15FC5ARuG1eFD42QgeYkzJBJzHghNP8Ad9xtA==} + next@16.2.6: + resolution: {integrity: sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -20525,9 +20525,9 @@ snapshots: '@next/env@14.2.35': {} - '@next/env@15.5.15': {} + '@next/env@15.5.18': {} - '@next/env@16.2.3': {} + '@next/env@16.2.6': {} '@next/eslint-plugin-next@15.5.4': dependencies: @@ -20540,52 +20540,52 @@ snapshots: '@mdx-js/loader': 3.1.1(supports-color@8.1.1)(webpack@5.105.4(esbuild@0.25.2)) '@mdx-js/react': 3.1.1(@types/react@18.3.3)(react@18.3.1) - '@next/swc-darwin-arm64@15.5.15': + '@next/swc-darwin-arm64@15.5.18': optional: true - '@next/swc-darwin-arm64@16.2.3': + '@next/swc-darwin-arm64@16.2.6': optional: true - '@next/swc-darwin-x64@15.5.15': + '@next/swc-darwin-x64@15.5.18': optional: true - '@next/swc-darwin-x64@16.2.3': + '@next/swc-darwin-x64@16.2.6': optional: true - '@next/swc-linux-arm64-gnu@15.5.15': + '@next/swc-linux-arm64-gnu@15.5.18': optional: true - '@next/swc-linux-arm64-gnu@16.2.3': + '@next/swc-linux-arm64-gnu@16.2.6': optional: true - '@next/swc-linux-arm64-musl@15.5.15': + '@next/swc-linux-arm64-musl@15.5.18': optional: true - '@next/swc-linux-arm64-musl@16.2.3': + '@next/swc-linux-arm64-musl@16.2.6': optional: true - '@next/swc-linux-x64-gnu@15.5.15': + '@next/swc-linux-x64-gnu@15.5.18': optional: true - '@next/swc-linux-x64-gnu@16.2.3': + '@next/swc-linux-x64-gnu@16.2.6': optional: true - '@next/swc-linux-x64-musl@15.5.15': + '@next/swc-linux-x64-musl@15.5.18': optional: true - '@next/swc-linux-x64-musl@16.2.3': + '@next/swc-linux-x64-musl@16.2.6': optional: true - '@next/swc-win32-arm64-msvc@15.5.15': + '@next/swc-win32-arm64-msvc@15.5.18': optional: true - '@next/swc-win32-arm64-msvc@16.2.3': + '@next/swc-win32-arm64-msvc@16.2.6': optional: true - '@next/swc-win32-x64-msvc@15.5.15': + '@next/swc-win32-x64-msvc@15.5.18': optional: true - '@next/swc-win32-x64-msvc@16.2.3': + '@next/swc-win32-x64-msvc@16.2.6': optional: true '@ngrok/ngrok-android-arm64@1.6.0': @@ -23319,7 +23319,7 @@ snapshots: '@sentry/core@10.27.0': {} - '@sentry/nextjs@10.27.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.15(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.105.4(esbuild@0.25.2))': + '@sentry/nextjs@10.27.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.18(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.105.4(esbuild@0.25.2))': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/semantic-conventions': 1.38.0 @@ -23332,7 +23332,7 @@ snapshots: '@sentry/react': 10.27.0(react@18.3.1) '@sentry/vercel-edge': 10.27.0 '@sentry/webpack-plugin': 4.6.1(encoding@0.1.13)(supports-color@8.1.1)(webpack@5.105.4(esbuild@0.25.2)) - next: 15.5.15(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) + next: 15.5.18(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) resolve: 1.22.8 rollup: 4.59.0 stacktrace-parser: 0.1.10 @@ -23345,7 +23345,7 @@ snapshots: - supports-color - webpack - '@sentry/nextjs@10.27.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.105.4(esbuild@0.25.2))': + '@sentry/nextjs@10.27.0(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1)(supports-color@8.1.1)(webpack@5.105.4(esbuild@0.25.2))': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/semantic-conventions': 1.38.0 @@ -23358,7 +23358,7 @@ snapshots: '@sentry/react': 10.27.0(react@18.3.1) '@sentry/vercel-edge': 10.27.0 '@sentry/webpack-plugin': 4.6.1(encoding@0.1.13)(supports-color@8.1.1)(webpack@5.105.4(esbuild@0.25.2)) - next: 16.2.3(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) + next: 16.2.6(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) resolve: 1.22.8 rollup: 4.59.0 stacktrace-parser: 0.1.10 @@ -28177,13 +28177,13 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 - flags@4.0.1(@opentelemetry/api@1.9.0)(next@16.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + flags@4.0.1(@opentelemetry/api@1.9.0)(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@edge-runtime/cookies': 5.0.2 jose: 5.9.6 optionalDependencies: '@opentelemetry/api': 1.9.0 - next: 16.2.3(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) + next: 16.2.6(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -31337,12 +31337,12 @@ snapshots: neo-async@2.6.2: {} - next-contentlayer2@0.4.6(contentlayer2@0.4.6(esbuild@0.25.2)(markdown-wasm@1.2.0)(supports-color@8.1.1))(esbuild@0.25.2)(markdown-wasm@1.2.0)(next@16.2.3(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1): + next-contentlayer2@0.4.6(contentlayer2@0.4.6(esbuild@0.25.2)(markdown-wasm@1.2.0)(supports-color@8.1.1))(esbuild@0.25.2)(markdown-wasm@1.2.0)(next@16.2.6(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(supports-color@8.1.1): dependencies: '@contentlayer2/core': 0.4.3(esbuild@0.25.2)(markdown-wasm@1.2.0)(supports-color@8.1.1) '@contentlayer2/utils': 0.4.3 contentlayer2: 0.4.6(esbuild@0.25.2)(markdown-wasm@1.2.0)(supports-color@8.1.1) - next: 16.2.3(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) + next: 16.2.6(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) transitivePeerDependencies: @@ -31371,14 +31371,14 @@ snapshots: dependencies: js-yaml-loader: 1.2.2 - next-router-mock@0.9.13(next@16.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1): + next-router-mock@0.9.13(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react@18.3.1): dependencies: - next: 16.2.3(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) + next: 16.2.6(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) react: 18.3.1 - next-seo@6.5.0(next@15.5.15(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + next-seo@6.5.0(next@15.5.18(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - next: 15.5.15(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) + next: 15.5.18(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -31389,9 +31389,9 @@ snapshots: next-tick@1.1.0: {} - next@15.5.15(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4): + next@15.5.18(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4): dependencies: - '@next/env': 15.5.15 + '@next/env': 15.5.18 '@swc/helpers': 0.5.15 caniuse-lite: 1.0.30001780 postcss: 8.5.10 @@ -31399,14 +31399,14 @@ snapshots: react-dom: 18.3.1(react@18.3.1) styled-jsx: 5.1.6(@babel/core@7.29.0(supports-color@8.1.1))(babel-plugin-macros@3.1.0)(react@18.3.1) optionalDependencies: - '@next/swc-darwin-arm64': 15.5.15 - '@next/swc-darwin-x64': 15.5.15 - '@next/swc-linux-arm64-gnu': 15.5.15 - '@next/swc-linux-arm64-musl': 15.5.15 - '@next/swc-linux-x64-gnu': 15.5.15 - '@next/swc-linux-x64-musl': 15.5.15 - '@next/swc-win32-arm64-msvc': 15.5.15 - '@next/swc-win32-x64-msvc': 15.5.15 + '@next/swc-darwin-arm64': 15.5.18 + '@next/swc-darwin-x64': 15.5.18 + '@next/swc-linux-arm64-gnu': 15.5.18 + '@next/swc-linux-arm64-musl': 15.5.18 + '@next/swc-linux-x64-gnu': 15.5.18 + '@next/swc-linux-x64-musl': 15.5.18 + '@next/swc-win32-arm64-msvc': 15.5.18 + '@next/swc-win32-x64-msvc': 15.5.18 '@opentelemetry/api': 1.9.0 '@playwright/test': 1.59.1 sass: 1.77.4 @@ -31415,9 +31415,9 @@ snapshots: - '@babel/core' - babel-plugin-macros - next@16.2.3(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4): + next@16.2.6(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4): dependencies: - '@next/env': 16.2.3 + '@next/env': 16.2.6 '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.10.0 caniuse-lite: 1.0.30001780 @@ -31426,14 +31426,14 @@ snapshots: react-dom: 18.3.1(react@18.3.1) styled-jsx: 5.1.6(@babel/core@7.29.0(supports-color@8.1.1))(babel-plugin-macros@3.1.0)(react@18.3.1) optionalDependencies: - '@next/swc-darwin-arm64': 16.2.3 - '@next/swc-darwin-x64': 16.2.3 - '@next/swc-linux-arm64-gnu': 16.2.3 - '@next/swc-linux-arm64-musl': 16.2.3 - '@next/swc-linux-x64-gnu': 16.2.3 - '@next/swc-linux-x64-musl': 16.2.3 - '@next/swc-win32-arm64-msvc': 16.2.3 - '@next/swc-win32-x64-msvc': 16.2.3 + '@next/swc-darwin-arm64': 16.2.6 + '@next/swc-darwin-x64': 16.2.6 + '@next/swc-linux-arm64-gnu': 16.2.6 + '@next/swc-linux-arm64-musl': 16.2.6 + '@next/swc-linux-x64-gnu': 16.2.6 + '@next/swc-linux-x64-musl': 16.2.6 + '@next/swc-win32-arm64-msvc': 16.2.6 + '@next/swc-win32-x64-msvc': 16.2.6 '@opentelemetry/api': 1.9.0 '@playwright/test': 1.59.1 sass: 1.77.4 @@ -31701,27 +31701,27 @@ snapshots: number-flow@0.3.7: {} - nuqs@1.19.1(next@15.5.15(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4)): + nuqs@1.19.1(next@15.5.18(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4)): dependencies: mitt: 3.0.1 - next: 15.5.15(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) + next: 15.5.18(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) - nuqs@2.7.1(@tanstack/react-router@1.168.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(next@16.2.3(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-router@7.13.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1): + nuqs@2.7.1(@tanstack/react-router@1.168.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(next@16.2.6(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-router@7.13.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1): dependencies: '@standard-schema/spec': 1.0.0 react: 18.3.1 optionalDependencies: '@tanstack/react-router': 1.168.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - next: 16.2.3(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) + next: 16.2.6(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) react-router: 7.13.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - nuqs@2.8.1(@tanstack/react-router@1.168.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(next@15.5.15(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-router@7.13.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1): + nuqs@2.8.1(@tanstack/react-router@1.168.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(next@15.5.18(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-router@7.13.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1): dependencies: '@standard-schema/spec': 1.0.0 react: 18.3.1 optionalDependencies: '@tanstack/react-router': 1.168.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - next: 15.5.15(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) + next: 15.5.18(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) react-router: 7.13.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) nuxt@4.4.2(@babel/core@7.29.0(supports-color@8.1.1))(@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.29.0(supports-color@8.1.1)))(@electric-sql/pglite@0.2.15)(@parcel/watcher@2.5.1)(@types/node@22.13.14)(@vue/compiler-sfc@3.5.30)(aws4fetch@1.0.20)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.2.15))(encoding@0.1.13)(eslint@9.37.0(jiti@2.6.1)(supports-color@8.1.1))(ioredis@5.10.0(supports-color@8.1.1))(lightningcss@1.32.0)(magicast@0.5.2)(rolldown@1.0.0-rc.15)(rollup-plugin-visualizer@6.0.11(rolldown@1.0.0-rc.15)(rollup@4.59.0))(rollup@4.59.0)(sass@1.77.4)(supports-color@8.1.1)(terser@5.39.0)(tsx@4.20.3)(typescript@6.0.2)(vite@7.3.2(@types/node@22.13.14)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.77.4)(terser@5.39.0)(tsx@4.20.3)(yaml@2.8.3))(yaml@2.8.3): @@ -35114,12 +35114,12 @@ snapshots: unicorn-magic@0.4.0: {} - unicornstudio-react@2.0.1-1(next@15.5.15(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + unicornstudio-react@2.0.1-1(next@15.5.18(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4))(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) optionalDependencies: - next: 15.5.15(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) + next: 15.5.18(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4) unified@10.1.2: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e6ec05a7bf7b3..c6fa3d8807088 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -19,7 +19,7 @@ catalog: '@vitest/ui': ^4.1.4 lodash: ^4.18.1 lodash-es: ^4.18.1 - next: 16.2.3 + next: 16.2.6 next-themes: ^0.4.6 postcss: ^8.5.10 radix-ui: ^1.4.3 @@ -54,9 +54,8 @@ minimumReleaseAgeExclude: - '@supabase/*' - '@supabase-labs/*' - braintrust # Included for bugfix in v3.9.0 - # The following packages are excluded from the minimum release age check because they had a vulneralibity - # Delete them the next time you update this list - - follow-redirects + - '@next/*' + - next onlyBuiltDependencies: - node-pty