diff --git a/.github/workflows/update-js-libs.yml b/.github/workflows/update-js-libs.yml index aa41efd4db685..fb5e6f6705988 100644 --- a/.github/workflows/update-js-libs.yml +++ b/.github/workflows/update-js-libs.yml @@ -48,6 +48,9 @@ jobs: # Update @supabase/realtime-js sed -i "s/'@supabase\/realtime-js': .*/'@supabase\/realtime-js': ${{ github.event.inputs.version }}/" pnpm-workspace.yaml + # Update @supabase/postgrest-js + sed -i "s/'@supabase\/postgrest-js': .*/'@supabase\/postgrest-js': ${{ github.event.inputs.version }}/" pnpm-workspace.yaml + echo "Updated pnpm-workspace.yaml:" cat pnpm-workspace.yaml @@ -69,6 +72,7 @@ jobs: - Updated @supabase/supabase-js to ${{ github.event.inputs.version }} - Updated @supabase/auth-js to ${{ github.event.inputs.version }} - Updated @supabase/realtime-js to ${{ github.event.inputs.version }} + - Updated @supabase/postgest-js to ${{ github.event.inputs.version }} - Refreshed pnpm-lock.yaml This PR was created automatically. diff --git a/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx b/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx index b74d5b6c45a8c..c56f34d0fe9dd 100644 --- a/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx +++ b/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx @@ -9,7 +9,7 @@ import { memo, useState } from 'react' import { useIsLoggedIn, useIsUserLoading, useUser } from 'common' import { isFeatureEnabled } from 'common/enabled-features' import { Button, buttonVariants, cn } from 'ui' -import { AuthenticatedDropdownMenu, CommandMenuTrigger } from 'ui-patterns' +import { AuthenticatedDropdownMenu, CommandMenuTriggerInput } from 'ui-patterns' import { getCustomContent } from '../../../lib/custom-content/getCustomContent' import GlobalNavigationMenu from './GlobalNavigationMenu' import useDropdownMenu from './useDropdownMenu' @@ -43,37 +43,14 @@ const TopNavBar: FC = () => {
- - - + + Search + docs... + + } + /> - -

+ +

{x.argument_types || '-'}

-

{x.return_type}

+ {x.return_type === 'trigger' ? ( + + {x.return_type} + + ) : ( +

+ {x.return_type} +

+ )}
- {x.security_definer ? 'Definer' : 'Invoker'} +

+ {x.security_definer ? 'Definer' : 'Invoker'} +

{!isLocked && ( diff --git a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx index 5d8476f3998f8..7dbaaaae4bdc6 100644 --- a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx +++ b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx @@ -3,6 +3,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { noop } from 'lodash' import { Search } from 'lucide-react' import { useRouter } from 'next/router' +import { parseAsJson, useQueryState } from 'nuqs' import { useParams } from 'common' import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState' @@ -27,6 +28,10 @@ import { TableHeader, TableRow, } from 'ui' +import { + ReportsSelectFilter, + selectFilterSchema, +} from 'components/interfaces/Reports/v2/ReportsSelectFilter' import { ProtectedSchemaWarning } from '../../ProtectedSchemaWarning' import FunctionList from './FunctionList' @@ -51,6 +56,16 @@ const FunctionsList = ({ const filterString = search ?? '' + // Filters + const [returnTypeFilter, setReturnTypeFilter] = useQueryState( + 'return_type', + parseAsJson(selectFilterSchema.parse) + ) + const [securityFilter, setSecurityFilter] = useQueryState( + 'security', + parseAsJson(selectFilterSchema.parse) + ) + const setFilterString = (str: string) => { const url = new URL(document.URL) if (str === '') { @@ -84,6 +99,18 @@ const FunctionsList = ({ connectionString: project?.connectionString, }) + // Get unique return types from functions in the selected schema + const schemaFunctions = (functions ?? []).filter((fn) => fn.schema === selectedSchema) + const uniqueReturnTypes = Array.from(new Set(schemaFunctions.map((fn) => fn.return_type))).sort() + + // Get security options based on what exists in the selected schema + const hasDefiner = schemaFunctions.some((fn) => fn.security_definer) + const hasInvoker = schemaFunctions.some((fn) => !fn.security_definer) + const securityOptions = [ + ...(hasDefiner ? [{ label: 'Definer', value: 'definer' }] : []), + ...(hasInvoker ? [{ label: 'Invoker', value: 'invoker' }] : []), + ] + if (isLoading) return if (isError) return @@ -132,6 +159,22 @@ const FunctionsList = ({ className="w-full lg:w-52" onChange={(e) => setFilterString(e.target.value)} /> + ({ + label: type, + value: type, + }))} + value={returnTypeFilter ?? []} + onChange={setReturnTypeFilter} + showSearch + /> +
@@ -201,6 +244,8 @@ const FunctionsList = ({ schema={selectedSchema} filterString={filterString} isLocked={isSchemaLocked} + returnTypeFilter={returnTypeFilter ?? []} + securityFilter={securityFilter ?? []} duplicateFunction={duplicateFunction} editFunction={editFunction} deleteFunction={deleteFunction} diff --git a/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx b/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx index bb2fede7e02d0..9e0862014fea8 100644 --- a/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx +++ b/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx @@ -44,8 +44,10 @@ const TriggerList = ({ projectRef: project?.ref, connectionString: project?.connectionString, }) - const filteredTriggers = (triggers ?? []).filter((x) => - includes(x.name.toLowerCase(), filterString.toLowerCase()) + const filteredTriggers = (triggers ?? []).filter( + (x) => + includes(x.name.toLowerCase(), filterString.toLowerCase()) || + (x.function_name && includes(x.function_name.toLowerCase(), filterString.toLowerCase())) ) const _triggers = sortBy( diff --git a/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggersList.tsx b/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggersList.tsx index 9160a211ca5d4..30b651ba93887 100644 --- a/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggersList.tsx +++ b/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggersList.tsx @@ -2,7 +2,7 @@ import { PostgresTrigger } from '@supabase/postgres-meta' import { PermissionAction } from '@supabase/shared-types/out/constants' import { noop } from 'lodash' import { DatabaseZap, FunctionSquare, Plus, Search, Shield } from 'lucide-react' -import { useState } from 'react' +import { parseAsString, useQueryState } from 'nuqs' import AlphaPreview from 'components/to-be-cleaned/AlphaPreview' import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState' @@ -48,7 +48,11 @@ const TriggersList = ({ const { data: project } = useSelectedProjectQuery() const aiSnap = useAiAssistantStateSnapshot() const { selectedSchema, setSelectedSchema } = useQuerySchemaState() - const [filterString, setFilterString] = useState('') + + const [filterString, setFilterString] = useQueryState( + 'search', + parseAsString.withDefault('').withOptions({ history: 'replace', clearOnDefault: true }) + ) const { data: protectedSchemas } = useProtectedSchemas() const { isSchemaLocked } = useIsProtectedSchema({ schema: selectedSchema }) diff --git a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.ts b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.ts index 1c2da89ce34b9..2db5478777f72 100644 --- a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.ts +++ b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.ts @@ -12,7 +12,14 @@ export function getAvailableRegions(cloudProvider: CloudProvider): Region { case 'AWS_K8S': return AWS_REGIONS case 'AWS_NIMBUS': - // Only allow US East for Nimbus + if (process.env.NEXT_PUBLIC_ENVIRONMENT !== 'prod') { + // Only allow Southeast Asia for Nimbus (local/staging) + return { + SOUTHEAST_ASIA: AWS_REGIONS.SOUTHEAST_ASIA, + } + } + + // Only allow US East for Nimbus (prod) return { EAST_US: AWS_REGIONS.EAST_US, } diff --git a/apps/studio/components/interfaces/UserDropdown.tsx b/apps/studio/components/interfaces/UserDropdown.tsx index 1fe53c346dc1e..02d3d4a046302 100644 --- a/apps/studio/components/interfaces/UserDropdown.tsx +++ b/apps/studio/components/interfaces/UserDropdown.tsx @@ -23,7 +23,7 @@ import { Theme, singleThemes, } from 'ui' -import { useSetCommandMenuOpen } from 'ui-patterns/CommandMenu' +import { useCommandMenuOpenedTelemetry, useSetCommandMenuOpen } from 'ui-patterns/CommandMenu' import { useFeaturePreviewModal } from './App/FeaturePreview/FeaturePreviewContext' export function UserDropdown() { @@ -35,8 +35,14 @@ export function UserDropdown() { const signOut = useSignOut() const setCommandMenuOpen = useSetCommandMenuOpen() + const sendTelemetry = useCommandMenuOpenedTelemetry() const { openFeaturePreviewModal } = useFeaturePreviewModal() + const handleCommandMenuOpen = () => { + setCommandMenuOpen(true) + sendTelemetry() + } + return ( @@ -97,7 +103,7 @@ export function UserDropdown() { Feature previews - setCommandMenuOpen(true)}> + Command menu diff --git a/apps/studio/data/subscriptions/org-subscription-confirm-pending-change.ts b/apps/studio/data/subscriptions/org-subscription-confirm-pending-change.ts index fc660901b76c1..7aad279c93cce 100644 --- a/apps/studio/data/subscriptions/org-subscription-confirm-pending-change.ts +++ b/apps/studio/data/subscriptions/org-subscription-confirm-pending-change.ts @@ -3,6 +3,7 @@ import { toast } from 'sonner' import { handleError, post } from 'data/fetchers' import type { ResponseError } from 'types' +import type { components } from 'api-types' import { organizationKeys } from 'data/organizations/keys' import { subscriptionKeys } from './keys' import { usageKeys } from 'data/usage/keys' @@ -63,6 +64,17 @@ export const useConfirmPendingSubscriptionChangeMutation = ({ async onSuccess(data, variables, context) { const { slug } = variables + // Handle 202 Accepted - show toast and skip query invalidation + // The 200 success response returns void, so if data exists it must be 202 + if (data && 'message' in data) { + const pendingResponse = data as components['schemas']['PendingConfirmationResponse'] + toast.success(pendingResponse.message, { + dismissible: true, + duration: 10_000, + }) + return + } + // [Kevin] Backend can return stale data as it's waiting for the Stripe-sync to complete. Until that's solved in the backend // we are going back to monkey here and delay the invalidation await new Promise((resolve) => setTimeout(resolve, 2000)) diff --git a/apps/studio/data/subscriptions/org-subscription-confirm-pending-create.ts b/apps/studio/data/subscriptions/org-subscription-confirm-pending-create.ts index 8d60276bb9447..dc41f110121ec 100644 --- a/apps/studio/data/subscriptions/org-subscription-confirm-pending-create.ts +++ b/apps/studio/data/subscriptions/org-subscription-confirm-pending-create.ts @@ -3,10 +3,10 @@ import { toast } from 'sonner' import { handleError, post } from 'data/fetchers' import type { ResponseError } from 'types' +import type { components } from 'api-types' import { organizationKeys } from 'data/organizations/keys' import { permissionKeys } from 'data/permissions/keys' import { castOrganizationResponseToOrganization } from 'data/organizations/organizations-query' -import type { components } from 'api-types' export type PendingSubscriptionCreateVariables = { payment_intent_id: string @@ -56,6 +56,16 @@ export const useConfirmPendingSubscriptionCreateMutation = ({ PendingSubscriptionCreateVariables >((vars) => confirmPendingSubscriptionCreate(vars), { async onSuccess(data, variables, context) { + // Handle 202 Accepted - show toast and skip query updates + if (data && 'message' in data && !('slug' in data)) { + const pendingResponse = data as components['schemas']['PendingConfirmationResponse'] + toast.success(pendingResponse.message, { + dismissible: true, + duration: 10_000, + }) + return + } + // [Joshen] We're manually updating the query client here as the org's subscription is // created async, and the invalidation will happen too quick where the GET organizations // endpoint will error out with a 500 since the subscription isn't created yet. diff --git a/apps/studio/hooks/misc/useStudioCommandMenuTelemetry.ts b/apps/studio/hooks/misc/useStudioCommandMenuTelemetry.ts new file mode 100644 index 0000000000000..925c9c600a861 --- /dev/null +++ b/apps/studio/hooks/misc/useStudioCommandMenuTelemetry.ts @@ -0,0 +1,40 @@ +import { useCallback } from 'react' + +import { useParams } from 'common' +import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' +import { useSendEventMutation } from 'data/telemetry/send-event-mutation' +import type { + CommandMenuOpenedEvent, + CommandMenuCommandSelectedEvent, + CommandMenuSearchSubmittedEvent, +} from 'common/telemetry-constants' + +export function useStudioCommandMenuTelemetry() { + const { ref: projectRef } = useParams() + const { data: organization } = useSelectedOrganizationQuery() + const { mutate: sendEvent } = useSendEventMutation() + + const onTelemetry = useCallback( + ( + event: + | CommandMenuOpenedEvent + | CommandMenuCommandSelectedEvent + | CommandMenuSearchSubmittedEvent + ) => { + // Add studio-specific groups (project and organization) + const eventWithGroups = { + ...event, + groups: { + ...event.groups, + ...(projectRef && { project: projectRef }), + ...(organization?.slug && { organization: organization.slug }), + }, + } + + sendEvent(eventWithGroups) + }, + [projectRef, organization?.slug, sendEvent] + ) + + return { onTelemetry } +} diff --git a/apps/studio/pages/_app.tsx b/apps/studio/pages/_app.tsx index fb4ae48c14c00..f6816f5ed7968 100644 --- a/apps/studio/pages/_app.tsx +++ b/apps/studio/pages/_app.tsx @@ -30,13 +30,12 @@ import timezone from 'dayjs/plugin/timezone' import utc from 'dayjs/plugin/utc' import Head from 'next/head' import { NuqsAdapter } from 'nuqs/adapters/next/pages' -import { ErrorInfo, PropsWithChildren, useCallback } from 'react' +import { ErrorInfo, useCallback } from 'react' import { ErrorBoundary } from 'react-error-boundary' import { FeatureFlagProvider, getFlags, - LOCAL_STORAGE_KEYS, TelemetryTagManager, ThemeProvider, useThemeSandbox, @@ -52,14 +51,13 @@ import { GlobalErrorBoundaryState } from 'components/ui/ErrorBoundary/GlobalErro import { useRootQueryClient } from 'data/query-client' import { customFont, sourceCodePro } from 'fonts' import { useCustomContent } from 'hooks/custom-content/useCustomContent' -import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { AuthProvider } from 'lib/auth' import { API_URL, BASE_PATH, IS_PLATFORM, useDefaultProvider } from 'lib/constants' import { ProfileProvider } from 'lib/profile' import { Telemetry } from 'lib/telemetry' import { AppPropsWithLayout } from 'types' import { SonnerToaster, TooltipProvider } from 'ui' -import { CommandProvider } from 'ui-patterns/CommandMenu' +import { StudioCommandProvider as CommandProvider } from 'components/interfaces/App/CommandMenu/StudioCommandProvider' dayjs.extend(customParseFormat) dayjs.extend(utc) @@ -80,15 +78,6 @@ loader.config({ }, }) -const CommandProviderWithPreferences = ({ children }: PropsWithChildren) => { - const [commandMenuHotkeyEnabled] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.HOTKEY_COMMAND_MENU, - true - ) - - return {children} -} - // [Joshen TODO] Once we settle on the new nav layout - we'll need a lot of clean up in terms of our layout components // a lot of them are unnecessary and introduce way too many cluttered CSS especially with the height styles that make // debugging way too difficult. Ideal scenario is we just have one AppLayout to control the height and scroll areas of @@ -161,7 +150,7 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) { disableTransitionOnChange > - + {getLayout()} @@ -169,7 +158,7 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) { - + diff --git a/apps/ui-library/package.json b/apps/ui-library/package.json index 05c09a053f4b6..50d4e479fb592 100644 --- a/apps/ui-library/package.json +++ b/apps/ui-library/package.json @@ -47,7 +47,7 @@ "@radix-ui/react-toggle-group": "*", "@radix-ui/react-tooltip": "*", "@react-router/fs-routes": "^7.4.0", - "@supabase/postgrest-js": "*", + "@supabase/postgrest-js": "catalog:", "@supabase/supa-mdx-lint": "0.2.6-alpha", "@tanstack/react-query": "^5.83.0", "@supabase/vue-blocks": "workspace:*", diff --git a/apps/ui-library/registry/default/blocks/infinite-query-hook/hooks/use-infinite-query.ts b/apps/ui-library/registry/default/blocks/infinite-query-hook/hooks/use-infinite-query.ts index 93a8fe00f8b72..8ca64107484a0 100644 --- a/apps/ui-library/registry/default/blocks/infinite-query-hook/hooks/use-infinite-query.ts +++ b/apps/ui-library/registry/default/blocks/infinite-query-hook/hooks/use-infinite-query.ts @@ -1,7 +1,7 @@ 'use client' import { createClient } from '@/registry/default/fixtures/lib/supabase/client' -import { PostgrestQueryBuilder } from '@supabase/postgrest-js' +import { PostgrestQueryBuilder, type PostgrestClientOptions } from '@supabase/postgrest-js' import { type SupabaseClient } from '@supabase/supabase-js' import { useEffect, useRef, useSyncExternalStore } from 'react' @@ -44,8 +44,16 @@ type SupabaseTableName = keyof DatabaseSchema['Tables'] // Extracts the table definition from the database type type SupabaseTableData = DatabaseSchema['Tables'][T]['Row'] +// Default client options for PostgrestQueryBuilder +type DefaultClientOptions = PostgrestClientOptions + type SupabaseSelectBuilder = ReturnType< - PostgrestQueryBuilder['select'] + PostgrestQueryBuilder< + DefaultClientOptions, + DatabaseSchema, + DatabaseSchema['Tables'][T], + T + >['select'] > // A function that modifies the query. Can be used to sort, filter, etc. If .range is used, it will be overwritten. diff --git a/apps/www/hooks/useWwwCommandMenuTelemetry.ts b/apps/www/hooks/useWwwCommandMenuTelemetry.ts new file mode 100644 index 0000000000000..cf7a6037efff7 --- /dev/null +++ b/apps/www/hooks/useWwwCommandMenuTelemetry.ts @@ -0,0 +1,26 @@ +import { useCallback } from 'react' + +import type { + CommandMenuOpenedEvent, + CommandMenuCommandSelectedEvent, + CommandMenuSearchSubmittedEvent, +} from 'common/telemetry-constants' +import { useSendTelemetryEvent } from 'lib/telemetry' + +export function useWwwCommandMenuTelemetry() { + const sendTelemetryEvent = useSendTelemetryEvent() + + const onTelemetry = useCallback( + ( + event: + | CommandMenuOpenedEvent + | CommandMenuCommandSelectedEvent + | CommandMenuSearchSubmittedEvent + ) => { + sendTelemetryEvent(event) + }, + [sendTelemetryEvent] + ) + + return { onTelemetry } +} diff --git a/apps/www/lib/telemetry.ts b/apps/www/lib/telemetry.ts index 0e781bd5eed0a..686ed8482fe92 100644 --- a/apps/www/lib/telemetry.ts +++ b/apps/www/lib/telemetry.ts @@ -4,16 +4,20 @@ import { sendTelemetryEvent } from 'common' import type { TelemetryEvent } from 'common/telemetry-constants' import { API_URL } from 'lib/constants' import { usePathname, useSearchParams } from 'next/navigation' +import { useCallback } from 'react' export function useSendTelemetryEvent() { const pathname = usePathname() const searchParams = useSearchParams() - return (event: TelemetryEvent) => { - const url = new URL(API_URL ?? 'http://localhost:3000') - url.pathname = pathname ?? '' - url.search = searchParams?.toString() ?? '' + return useCallback( + (event: TelemetryEvent) => { + const url = new URL(API_URL ?? 'http://localhost:3000') + url.pathname = pathname ?? '' + url.search = searchParams?.toString() ?? '' - return sendTelemetryEvent(API_URL, event, url.toString()) - } + return sendTelemetryEvent(API_URL, event, url.toString()) + }, + [pathname, searchParams] + ) } diff --git a/apps/www/pages/_app.tsx b/apps/www/pages/_app.tsx index f839f5cbd4042..55a845f09b0bb 100644 --- a/apps/www/pages/_app.tsx +++ b/apps/www/pages/_app.tsx @@ -12,7 +12,7 @@ import { useThemeSandbox, } from 'common' import { DefaultSeo } from 'next-seo' -import { AppProps } from 'next/app' +import type { AppProps } from 'next/app' import Head from 'next/head' import { useRouter } from 'next/router' import { SonnerToaster, themes, TooltipProvider } from 'ui' @@ -26,10 +26,12 @@ import MetaFaviconsPagesRouter, { import { WwwCommandMenu } from '~/components/CommandMenu' import { API_URL, APP_NAME, DEFAULT_META_DESCRIPTION } from '~/lib/constants' import useDarkLaunchWeeks from '../hooks/useDarkLaunchWeeks' +import { useWwwCommandMenuTelemetry } from '../hooks/useWwwCommandMenuTelemetry' export default function App({ Component, pageProps }: AppProps) { const router = useRouter() const { hasAcceptedConsent } = useConsentToast() + const { onTelemetry } = useWwwCommandMenuTelemetry() useThemeSandbox() @@ -43,7 +45,7 @@ export default function App({ Component, pageProps }: AppProps) { let faviconRoute = DEFAULT_FAVICON_ROUTE let themeColor = DEFAULT_FAVICON_THEME_COLOR - if (router.asPath && router.asPath.includes('/launch-week/x')) { + if (router.asPath?.includes('/launch-week/x')) { applicationName = 'Supabase LWX' faviconRoute = 'images/launchweek/lwx/favicon' themeColor = 'FFFFFF' @@ -95,7 +97,7 @@ export default function App({ Component, pageProps }: AppProps) { forcedTheme={forceDarkMode ? 'dark' : undefined} > - + diff --git a/packages/api-types/types/platform.d.ts b/packages/api-types/types/platform.d.ts index 1dd23516f7797..d8a7dbb1c9009 100644 --- a/packages/api-types/types/platform.d.ts +++ b/packages/api-types/types/platform.d.ts @@ -7612,6 +7612,9 @@ export interface components { }[] defaultPaymentMethodId: string | null } + PendingConfirmationResponse: { + message: string + } PgbouncerConfigResponse: { connection_string: string db_dns_name: string @@ -13831,6 +13834,14 @@ export interface operations { } content?: never } + 202: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['PendingConfirmationResponse'] + } + } /** @description Unauthorized */ 401: { headers: { @@ -16169,6 +16180,14 @@ export interface operations { 'application/json': components['schemas']['CreateOrganizationResponse'] } } + 202: { + headers: { + [name: string]: unknown + } + content: { + 'application/json': components['schemas']['PendingConfirmationResponse'] + } + } /** @description Failed to confirm subscription changes */ 500: { headers: { diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index 5d699a3d4c884..0d370b71ea45d 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -1949,6 +1949,78 @@ export interface AuthUsersSearchSubmittedEvent { groups: TelemetryGroups } +/** + * User opened the command menu. + * + * @group Events + * @source ui-patterns + * @page any + */ +export interface CommandMenuOpenedEvent { + action: 'command_menu_opened' + properties: { + /** + * The trigger that opened the command menu + */ + trigger_type: 'keyboard_shortcut' | 'search_input' + /** + * The location where the command menu was opened + */ + trigger_location?: string + /** + * In which app the command input was typed + */ + app: 'studio' | 'docs' | 'www' + } + groups: Partial +} + +/** + * User typed a search term in the command menu input. + * + * @group Events + * @source ui-patterns + * @page any + */ +export interface CommandMenuSearchSubmittedEvent { + action: 'command_menu_search_submitted' + properties: { + /** + * Search term typed into the command menu input + */ + value: string + /** + * In which app the command input was typed + */ + app: 'studio' | 'docs' | 'www' + } + groups: Partial +} + +/** + * User selected a command from the command menu. + * + * @group Events + * @source ui-patterns + * @page any + */ +export interface CommandMenuCommandSelectedEvent { + action: 'command_menu_command_selected' + properties: { + /** + * The selected command + */ + command_name: string + command_value?: string + command_type: 'action' | 'route' + /** + * In which app the command input was typed + */ + app: 'studio' | 'docs' | 'www' + } + groups: Partial +} + /** * @hidden */ @@ -2063,3 +2135,6 @@ export type TelemetryEvent = | TableDataAddedEvent | TableRLSEnabledEvent | AuthUsersSearchSubmittedEvent + | CommandMenuOpenedEvent + | CommandMenuSearchSubmittedEvent + | CommandMenuCommandSelectedEvent diff --git a/packages/ui-patterns/src/CommandMenu/api/CommandInput.tsx b/packages/ui-patterns/src/CommandMenu/api/CommandInput.tsx index a2386f484af12..afa27b8361c5a 100644 --- a/packages/ui-patterns/src/CommandMenu/api/CommandInput.tsx +++ b/packages/ui-patterns/src/CommandMenu/api/CommandInput.tsx @@ -1,11 +1,15 @@ 'use client' -import React, { forwardRef, useEffect, useRef, useState } from 'react' +import { forwardRef, useCallback, useEffect, useRef, useState } from 'react' +import type React from 'react' -import { useBreakpoint } from 'common' +import { useBreakpoint, useDebounce } from 'common' import { CommandInput_Shadcn_, cn } from 'ui' import { useQuery, useSetQuery } from './hooks/queryHooks' +import { useCommandMenuTelemetryContext } from './hooks/useCommandMenuTelemetryContext' + +const INPUT_TYPED_EVENT_DEBOUNCE_TIME = 2000 // 2s function useFocusInputOnWiderScreens(ref: React.ForwardedRef) { const isBelowSm = useBreakpoint('sm') @@ -50,8 +54,56 @@ const CommandInput = forwardRef< const [inputValue, setInputValue] = useState(query) useEffect(() => { setInputValue(query) + previousValueRef.current = query }, [query]) + // Get telemetry context + const telemetryContext = useCommandMenuTelemetryContext() + const previousValueRef = useRef(inputValue) + + const inputTelemetryEvent = useCallback( + (value: string) => { + if (telemetryContext?.onTelemetry) { + const event = { + action: 'command_menu_search_submitted' as const, + properties: { + value: value, + app: telemetryContext.app, + }, + groups: {}, + } + telemetryContext.onTelemetry(event) + } + }, + [telemetryContext] + ) + + const debouncedTelemetry = useDebounce( + useCallback(() => { + inputTelemetryEvent(inputValue) + previousValueRef.current = inputValue + }, [inputTelemetryEvent, inputValue]), + INPUT_TYPED_EVENT_DEBOUNCE_TIME + ) + + const handleValueChange = useCallback( + (value: string) => { + setInputValue(value) + + // Only trigger telemetry if the user is adding characters (not removing with backspace) + const isAddingCharacters = value.length > previousValueRef.current.length + + if (!isAddingCharacters) { + previousValueRef.current = value + return + } + + // Trigger debounced telemetry + debouncedTelemetry() + }, + [debouncedTelemetry] + ) + // To handle CJK input const [imeComposing, setImeComposing] = useState(false) useEffect(() => { @@ -67,8 +119,8 @@ const CommandInput = forwardRef< autoFocus={false} ref={inputRef} value={inputValue} - onValueChange={setInputValue} - placeholder="Type a command or search..." + onValueChange={handleValueChange} + placeholder="Run a command or search..." onCompositionStart={() => setImeComposing(true)} onCompositionEnd={() => setImeComposing(false)} className={cn( diff --git a/packages/ui-patterns/src/CommandMenu/api/CommandMenu.tsx b/packages/ui-patterns/src/CommandMenu/api/CommandMenu.tsx index 205d8f1f6f808..1c1238310ef2a 100644 --- a/packages/ui-patterns/src/CommandMenu/api/CommandMenu.tsx +++ b/packages/ui-patterns/src/CommandMenu/api/CommandMenu.tsx @@ -1,6 +1,6 @@ 'use client' -import { AlertTriangle, ArrowLeft } from 'lucide-react' +import { AlertTriangle, ArrowLeft, Command, Search } from 'lucide-react' import type { HTMLAttributes, MouseEvent, PropsWithChildren, ReactElement, ReactNode } from 'react' import { Children, cloneElement, forwardRef, isValidElement, useEffect, useMemo } from 'react' import { ErrorBoundary } from 'react-error-boundary' @@ -11,6 +11,7 @@ import { Button, Command_Shadcn_, Dialog, DialogContent, cn } from 'ui' import { useCurrentPage, usePageComponent, usePopPage } from './hooks/pagesHooks' import { useQuery, useSetQuery } from './hooks/queryHooks' +import { useCommandMenuTelemetryContext } from './hooks/useCommandMenuTelemetryContext' import { useCommandMenuSize, useCommandMenuOpen, @@ -26,6 +27,7 @@ function Breadcrumb({ className }: { className?: string }) { return ( + + ) +} + interface CommandMenuProps extends PropsWithChildren { trigger?: ReactNode } @@ -202,4 +260,4 @@ function CommandMenu({ children, trigger }: CommandMenuProps) { ) } -export { Breadcrumb, CommandMenu, CommandMenuTrigger, CommandWrapper } +export { Breadcrumb, CommandMenu, CommandMenuTrigger, CommandMenuTriggerInput, CommandWrapper } diff --git a/packages/ui-patterns/src/CommandMenu/api/CommandProvider.tsx b/packages/ui-patterns/src/CommandMenu/api/CommandProvider.tsx index 8cb7079a737b5..1bdd0e3041baa 100644 --- a/packages/ui-patterns/src/CommandMenu/api/CommandProvider.tsx +++ b/packages/ui-patterns/src/CommandMenu/api/CommandProvider.tsx @@ -11,7 +11,12 @@ import { initPagesState } from '../internal/state/pagesState' import { initQueryState } from '../internal/state/queryState' import { initViewState } from '../internal/state/viewState' import { CrossCompatRouterContext } from './hooks/useCrossCompatRouter' -import { useSetCommandMenuOpen, useToggleCommandMenu } from './hooks/viewHooks' +import { + useCommandMenuTelemetry, + type CommandMenuTelemetryCallback, +} from './hooks/useCommandMenuTelemetry' +import { CommandMenuTelemetryContext } from './hooks/useCommandMenuTelemetryContext' +import { useCommandMenuOpen, useSetCommandMenuOpen, useToggleCommandMenu } from './hooks/viewHooks' const CommandProviderInternal = ({ children }: PropsWithChildren) => { const combinedState = useConstant(() => ({ @@ -25,8 +30,21 @@ const CommandProviderInternal = ({ children }: PropsWithChildren) => { } // This is a component not a hook so it can access the wrapping context. -const CommandShortcut = ({ openKey }: { openKey: string }) => { +const CommandShortcut = ({ + openKey, + app, + onTelemetry, +}: { + openKey: string + app?: 'studio' | 'docs' | 'www' + onTelemetry?: CommandMenuTelemetryCallback +}) => { const toggleOpen = useToggleCommandMenu() + const isOpen = useCommandMenuOpen() + const { sendTelemetry } = useCommandMenuTelemetry({ + app: app ?? 'studio', + onTelemetry, + }) useEffect(() => { if (openKey === '') return @@ -37,13 +55,14 @@ const CommandShortcut = ({ openKey }: { openKey: string }) => { if (evt.key === openKey && usesPrimaryModifier && !otherModifiersActive) { evt.preventDefault() toggleOpen() + !isOpen && sendTelemetry('keyboard_shortcut') } } document.addEventListener('keydown', handleKeydown) return () => document.removeEventListener('keydown', handleKeydown) - }, [openKey, toggleOpen]) + }, [isOpen, openKey, sendTelemetry, toggleOpen]) return null } @@ -85,12 +104,22 @@ interface CommandProviderProps extends PropsWithChildren { * Defaults to `k`. Pass an empty string to disable the keyboard shortcut. */ openKey?: string + /** + * The app where the command menu is being used + */ + app?: 'studio' | 'docs' | 'www' + /** + * Optional callback to send telemetry events + */ + onTelemetry?: CommandMenuTelemetryCallback } -const CommandProvider = ({ children, openKey }: CommandProviderProps) => ( +const CommandProvider = ({ children, openKey, app, onTelemetry }: CommandProviderProps) => ( - - {children} + + + {children} + ) diff --git a/packages/ui-patterns/src/CommandMenu/api/hooks/useCommandMenuTelemetry.ts b/packages/ui-patterns/src/CommandMenu/api/hooks/useCommandMenuTelemetry.ts new file mode 100644 index 0000000000000..1ec7f51d8a7b6 --- /dev/null +++ b/packages/ui-patterns/src/CommandMenu/api/hooks/useCommandMenuTelemetry.ts @@ -0,0 +1,78 @@ +'use client' + +import { useCallback } from 'react' +import { useCommandMenuTelemetryContext } from './useCommandMenuTelemetryContext' +import { useCommandMenuOpen } from './viewHooks' + +import type { + CommandMenuOpenedEvent, + CommandMenuCommandSelectedEvent, + CommandMenuSearchSubmittedEvent, +} from 'common/telemetry-constants' + +export type CommandMenuTelemetryCallback = ( + event: CommandMenuOpenedEvent | CommandMenuCommandSelectedEvent | CommandMenuSearchSubmittedEvent +) => void + +export interface UseCommandMenuTelemetryOptions { + /** + * The app where the command menu is being used + */ + app: 'studio' | 'docs' | 'www' + /** + * Optional callback to send telemetry events + */ + onTelemetry?: CommandMenuTelemetryCallback +} + +export function useCommandMenuTelemetry({ app, onTelemetry }: UseCommandMenuTelemetryOptions) { + const sendTelemetry = useCallback( + ( + triggerType: 'keyboard_shortcut' | 'search_input' = 'search_input', + groups: Partial = {}, + triggerLocation?: string + ) => { + if (!onTelemetry) return + + const event: CommandMenuOpenedEvent = { + action: 'command_menu_opened', + properties: { + trigger_type: triggerType, + trigger_location: triggerLocation, + app, + }, + groups: groups as CommandMenuOpenedEvent['groups'], + } + + onTelemetry(event) + }, + [app, onTelemetry] + ) + + return { sendTelemetry } +} + +export const useCommandMenuOpenedTelemetry: ( + trigger?: 'keyboard_shortcut' | 'search_input' +) => () => void = (trigger = 'search_input') => { + const telemetryContext = useCommandMenuTelemetryContext() + const open = useCommandMenuOpen() + + const sendTelemetry = useCallback(() => { + if (!open && telemetryContext?.onTelemetry) { + const event = { + action: 'command_menu_opened' as const, + properties: { + trigger_type: trigger, + location: 'user_dropdown_menu', + app: telemetryContext.app, + }, + groups: {}, + } + + telemetryContext.onTelemetry(event) + } + }, [open, trigger, telemetryContext]) + + return sendTelemetry +} diff --git a/packages/ui-patterns/src/CommandMenu/api/hooks/useCommandMenuTelemetryContext.tsx b/packages/ui-patterns/src/CommandMenu/api/hooks/useCommandMenuTelemetryContext.tsx new file mode 100644 index 0000000000000..bf39002f05dbf --- /dev/null +++ b/packages/ui-patterns/src/CommandMenu/api/hooks/useCommandMenuTelemetryContext.tsx @@ -0,0 +1,18 @@ +'use client' + +import { createContext, useContext } from 'react' +import type { CommandMenuTelemetryCallback } from './useCommandMenuTelemetry' + +interface CommandMenuTelemetryContextValue { + app: 'studio' | 'docs' | 'www' + onTelemetry?: CommandMenuTelemetryCallback +} + +const CommandMenuTelemetryContext = createContext(null) + +export function useCommandMenuTelemetryContext() { + const context = useContext(CommandMenuTelemetryContext) + return context +} + +export { CommandMenuTelemetryContext } diff --git a/packages/ui-patterns/src/CommandMenu/index.tsx b/packages/ui-patterns/src/CommandMenu/index.tsx index 82a0d154f220b..e41f375511dc9 100644 --- a/packages/ui-patterns/src/CommandMenu/index.tsx +++ b/packages/ui-patterns/src/CommandMenu/index.tsx @@ -2,7 +2,13 @@ export * from './api/Badges' export { CommandHeader } from './api/CommandHeader' export { CommandInput } from './api/CommandInput' export { CommandList } from './api/CommandList' -export { Breadcrumb, CommandMenu, CommandMenuTrigger, CommandWrapper } from './api/CommandMenu' +export { + Breadcrumb, + CommandMenu, + CommandMenuTrigger, + CommandMenuTriggerInput, + CommandWrapper, +} from './api/CommandMenu' export { CommandProvider } from './api/CommandProvider' export { TextHighlighter, TextHighlighterBase } from './api/TextHighlighter' export * from './api/hooks/commandsHooks' @@ -10,6 +16,10 @@ export * from './api/hooks/pagesHooks' export * from './api/hooks/queryHooks' export { useCommandFilterState } from './api/hooks/useCommandFilterState' export { useCrossCompatRouter } from './api/hooks/useCrossCompatRouter' +export { + useCommandMenuOpenedTelemetry, + useCommandMenuTelemetry, +} from './api/hooks/useCommandMenuTelemetry' export { useHistoryKeys } from './api/hooks/useHistoryKeys' export * from './api/hooks/viewHooks' export * from './api/utils' diff --git a/packages/ui-patterns/src/CommandMenu/internal/Command.tsx b/packages/ui-patterns/src/CommandMenu/internal/Command.tsx index e07c1a287cab6..9fa962fa79c95 100644 --- a/packages/ui-patterns/src/CommandMenu/internal/Command.tsx +++ b/packages/ui-patterns/src/CommandMenu/internal/Command.tsx @@ -3,8 +3,9 @@ import { type PropsWithChildren, forwardRef } from 'react' import { CommandItem_Shadcn_, cn } from 'ui' import { useCrossCompatRouter } from '../api/hooks/useCrossCompatRouter' +import { useCommandMenuTelemetryContext } from '../api/hooks/useCommandMenuTelemetryContext' import { useSetCommandMenuOpen } from '../api/hooks/viewHooks' -import { type ICommand, type IActionCommand, type IRouteCommand } from './types' +import type { ICommand, IActionCommand, IRouteCommand } from './types' const isActionCommand = (command: ICommand): command is IActionCommand => 'action' in command const isRouteCommand = (command: ICommand): command is IRouteCommand => 'route' in command @@ -52,23 +53,44 @@ const CommandItem = forwardRef< >(({ children, className, command: _command, ...props }, ref) => { const router = useCrossCompatRouter() const setIsOpen = useSetCommandMenuOpen() + const telemetryContext = useCommandMenuTelemetryContext() const command = _command as ICommand // strip the readonly applied from the proxy + const handleCommandSelect = () => { + // Send telemetry event + if (telemetryContext?.onTelemetry) { + const event = { + action: 'command_menu_command_selected' as const, + properties: { + command_name: command.name, + command_value: command.value, + command_type: isActionCommand(command) ? ('action' as const) : ('route' as const), + app: telemetryContext.app, + }, + groups: {}, + } + + telemetryContext.onTelemetry(event) + } + + // Execute the original command logic + if (isActionCommand(command)) { + command.action() + } else if (isRouteCommand(command)) { + if (command.route.startsWith('http')) { + setIsOpen(false) + window.open(command.route, '_blank', 'noreferrer,noopener') + } else { + router.push(command.route) + } + } + } + return ( { - command.route.startsWith('http') - ? (setIsOpen(false), window.open(command.route, '_blank', 'noreferrer,noopener')) - : router.push(command.route) - } - : () => {} - } + onSelect={handleCommandSelect} value={command.value ?? command.name} forceMount={command.forceMount} className={cn( diff --git a/packages/ui/src/components/shadcn/ui/command.tsx b/packages/ui/src/components/shadcn/ui/command.tsx index 2b2b82b895171..84cb0361bab9a 100644 --- a/packages/ui/src/components/shadcn/ui/command.tsx +++ b/packages/ui/src/components/shadcn/ui/command.tsx @@ -62,7 +62,7 @@ const CommandInput = React.forwardRef< =16', npm: '>=8'} - '@supabase/postgrest-js@1.19.2': - resolution: {integrity: sha512-MXRbk4wpwhWl9IN6rIY1mR8uZCCG4MZAEji942ve6nMwIqnBgBnZhZlON6zTTs6fgveMnoCILpZv1+K91jN+ow==} - '@supabase/postgrest-js@2.75.1': resolution: {integrity: sha512-FiYBD0MaKqGW8eo4Xqu7/100Xm3ddgh+3qHtqS18yQRoglJTFRQCJzY1xkrGS0JFHE2YnbjL6XCiOBXiG8DK4Q==} @@ -28636,10 +28636,6 @@ snapshots: - pg-native - supports-color - '@supabase/postgrest-js@1.19.2': - dependencies: - '@supabase/node-fetch': 2.6.15 - '@supabase/postgrest-js@2.75.1': dependencies: '@supabase/node-fetch': 2.6.15 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ca580b63050f4..aeebeababa2ba 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,6 +8,7 @@ catalog: '@supabase/auth-js': 2.75.1 '@supabase/realtime-js': 2.75.1 '@supabase/supabase-js': 2.75.1 + '@supabase/postgrest-js': 2.75.1 '@types/node': ^22.0.0 '@types/react': ^18.3.0 '@types/react-dom': ^18.3.0