From cbf676c83e22d60d81631bef93de17d1d23bc747 Mon Sep 17 00:00:00 2001 From: Kanishk Dudeja Date: Fri, 24 Oct 2025 12:11:38 +0530 Subject: [PATCH 1/6] refactor(billing): show success toast for 202 accepted response from subscription confirm endpoints (#39823) * refactor(billing): show success toast if backend returns 202 Accepted status code * don't need to check slug for sub confirm endpoint --- ...org-subscription-confirm-pending-change.ts | 12 ++++++++++++ ...org-subscription-confirm-pending-create.ts | 12 +++++++++++- packages/api-types/types/platform.d.ts | 19 +++++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) 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/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: { From 7ade636a79a5aeb8f5b779c5808912c6c7ed5e6f Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Fri, 24 Oct 2025 17:18:59 +1000 Subject: [PATCH 2/6] Improve database functions list (#39835) --- .../Functions/FunctionsList/FunctionList.tsx | 40 ++++++++++++++--- .../Functions/FunctionsList/FunctionsList.tsx | 45 +++++++++++++++++++ .../Triggers/TriggersList/TriggerList.tsx | 6 ++- .../Triggers/TriggersList/TriggersList.tsx | 8 +++- 4 files changed, 88 insertions(+), 11 deletions(-) diff --git a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx index be7259728a941..8832787fa6563 100644 --- a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx +++ b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx @@ -1,6 +1,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { includes, noop, sortBy } from 'lodash' import { Copy, Edit, Edit2, FileText, MoreVertical, Trash } from 'lucide-react' +import Link from 'next/link' import { useRouter } from 'next/router' import { ButtonTooltip } from 'components/ui/ButtonTooltip' @@ -23,6 +24,8 @@ interface FunctionListProps { schema: string filterString: string isLocked: boolean + returnTypeFilter: string[] + securityFilter: string[] duplicateFunction: (fn: any) => void editFunction: (fn: any) => void deleteFunction: (fn: any) => void @@ -32,6 +35,8 @@ const FunctionList = ({ schema, filterString, isLocked, + returnTypeFilter, + securityFilter, duplicateFunction = noop, editFunction = noop, deleteFunction = noop, @@ -45,9 +50,16 @@ const FunctionList = ({ connectionString: selectedProject?.connectionString, }) - const filteredFunctions = (functions ?? []).filter((x) => - includes(x.name.toLowerCase(), filterString.toLowerCase()) - ) + const filteredFunctions = (functions ?? []).filter((x) => { + const matchesName = includes(x.name.toLowerCase(), filterString.toLowerCase()) + const matchesReturnType = + returnTypeFilter.length === 0 || returnTypeFilter.includes(x.return_type) + const matchesSecurity = + securityFilter.length === 0 || + (securityFilter.includes('definer') && x.security_definer) || + (securityFilter.includes('invoker') && !x.security_definer) + return matchesName && matchesReturnType && matchesSecurity + }) const _functions = sortBy( filteredFunctions.filter((x) => x.schema == schema), (func) => func.name.toLocaleLowerCase() @@ -100,16 +112,30 @@ const FunctionList = ({ {x.name} - -

+ +

{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 }) From c73296e5497192a0aaa62cf3d2a3e7ec85eaccf2 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Fri, 24 Oct 2025 05:26:04 -0300 Subject: [PATCH 3/6] docs: add missing Dart examples (#39815) --- apps/docs/content/guides/auth/passwords.mdx | 73 ++++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/apps/docs/content/guides/auth/passwords.mdx b/apps/docs/content/guides/auth/passwords.mdx index 363b86c640094..cfde4e71b7ce2 100644 --- a/apps/docs/content/guides/auth/passwords.mdx +++ b/apps/docs/content/guides/auth/passwords.mdx @@ -643,6 +643,18 @@ client.auth.reset_password_email( ) ``` + + +<$Show if="sdk:dart"> + + +```dart +await supabase.auth.resetPasswordForEmail( + 'valid.email@supabase.io', + redirectTo: 'http://example.com/account/update-password', +); +``` + @@ -937,6 +949,15 @@ supabase.gotrue.sendRecoveryEmail( supabase.auth.reset_password_email('valid.email@supabase.io') ``` + + +<$Show if="sdk:dart"> + + +```dart +await supabase.auth.resetPasswordForEmail('valid.email@supabase.io'); +``` + @@ -992,6 +1013,17 @@ supabase.auth.updateUser { supabase.auth.update_user({'password': 'new_password'}) ``` + + +<$Show if="sdk:dart"> + + +```dart +final UserResponse res = await supabase.auth.updateUser( + UserAttributes(password: 'new_password'), +); +``` + @@ -1098,6 +1130,18 @@ supabase.auth.sign_up({ }) ``` + + +<$Show if="sdk:dart"> + + +```dart +final AuthResponse res = await supabase.auth.signUp( + phone: '+13334445555', + password: 'some-password', +); +``` + @@ -1187,6 +1231,21 @@ supabase.auth.verify_otp({ }) ``` + + +<$Show if="sdk:dart"> + + +You should present a form to the user so they can input the 6 digit pin, then send it along with the phone number to `verifyOTP`: + +```dart +final AuthResponse res = await supabase.auth.verifyOTP( + phone: '+13334445555', + token: '123456', + type: OtpType.sms, +); +``` + @@ -1259,11 +1318,23 @@ supabase.auth.signInWith(Phone) { ```python supabase.auth.sign_in_with_password({ - 'phone': "+13334445555" + 'phone': "+13334445555", 'password': "some-password" }) ``` + + +<$Show if="sdk:dart"> + + +```dart +final AuthResponse res = await supabase.auth.signInWithPassword( + phone: '+13334445555', + password: 'some-password', +); +``` + From 45713e29104fd52a0f3091d385817a624c0a75e4 Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Fri, 24 Oct 2025 16:55:26 +0800 Subject: [PATCH 4/6] fix: nimbus local/staging region (#39841) --- .../interfaces/ProjectCreation/ProjectCreation.utils.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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, } From 416df4afa48eda8174ffc840b8837adcf26f448e Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Fri, 24 Oct 2025 11:58:56 +0300 Subject: [PATCH 5/6] chore: respect catalog version for postgrest-js (#39837) --- .github/workflows/update-js-libs.yml | 4 ++++ apps/ui-library/package.json | 2 +- .../hooks/use-infinite-query.ts | 12 ++++++++++-- pnpm-lock.yaml | 14 +++++--------- pnpm-workspace.yaml | 1 + 5 files changed, 21 insertions(+), 12 deletions(-) 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/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/pnpm-lock.yaml b/pnpm-lock.yaml index d54dc608eec45..0201d52bb40fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,6 +9,9 @@ catalogs: '@supabase/auth-js': specifier: 2.75.1 version: 2.75.1 + '@supabase/postgrest-js': + specifier: 2.75.1 + version: 2.75.1 '@supabase/realtime-js': specifier: 2.75.1 version: 2.75.1 @@ -1343,8 +1346,8 @@ importers: specifier: ^7.4.0 version: 7.4.0(@react-router/dev@7.4.0(@types/node@22.13.14)(babel-plugin-macros@3.1.0)(jiti@2.5.1)(react-router@7.5.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(sass@1.77.4)(supports-color@8.1.1)(terser@5.39.0)(tsx@4.19.3)(typescript@5.9.2)(vite@6.3.6(@types/node@22.13.14)(jiti@2.5.1)(sass@1.77.4)(terser@5.39.0)(tsx@4.19.3)(yaml@2.8.1))(yaml@2.8.1))(typescript@5.9.2) '@supabase/postgrest-js': - specifier: '*' - version: 1.19.2 + specifier: 'catalog:' + version: 2.75.1 '@supabase/supa-mdx-lint': specifier: 0.2.6-alpha version: 0.2.6-alpha @@ -8850,9 +8853,6 @@ packages: resolution: {integrity: sha512-vz5gc6RKNfDVnIfRUmH2ssTMYFI0U3MYOVyQ9R4YkzOS2dKSanjC4rTEDGjlMFwGTCUPW3N3pbY7HJIW81wMyg==} engines: {node: '>=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 From af43adb9a49711796e77064493ae992efe54e2e8 Mon Sep 17 00:00:00 2001 From: Francesco Sansalvadore Date: Fri, 24 Oct 2025 11:06:31 +0200 Subject: [PATCH 6/6] chore: command events (#39775) * add CommandMenuTriggerInput in ui-patterns * optimize search input responsiveness * add event on commandMenu opened * add event on debounced commandMenu input submission * add event on commandMenu command selection * add commandMenu telemetry to studio, www and docs --------- Co-authored-by: Charis <26616127+charislam@users.noreply.github.com> --- .../Navigation/NavigationMenu/TopNavBar.tsx | 41 +++------- apps/docs/features/app.providers.tsx | 9 +-- .../features/command/DocsCommandProvider.tsx | 16 ++++ apps/docs/features/command/index.tsx | 3 +- .../docs/hooks/useDocsCommandMenuTelemetry.ts | 28 +++++++ .../App/CommandMenu/StudioCommandProvider.tsx | 24 ++++++ .../components/interfaces/UserDropdown.tsx | 10 ++- .../misc/useStudioCommandMenuTelemetry.ts | 40 ++++++++++ apps/studio/pages/_app.tsx | 19 +---- apps/www/hooks/useWwwCommandMenuTelemetry.ts | 26 +++++++ apps/www/lib/telemetry.ts | 16 ++-- apps/www/pages/_app.tsx | 8 +- packages/common/telemetry-constants.ts | 75 ++++++++++++++++++ .../src/CommandMenu/api/CommandInput.tsx | 60 +++++++++++++- .../src/CommandMenu/api/CommandMenu.tsx | 62 ++++++++++++++- .../src/CommandMenu/api/CommandProvider.tsx | 41 ++++++++-- .../api/hooks/useCommandMenuTelemetry.ts | 78 +++++++++++++++++++ .../hooks/useCommandMenuTelemetryContext.tsx | 18 +++++ .../ui-patterns/src/CommandMenu/index.tsx | 12 ++- .../src/CommandMenu/internal/Command.tsx | 46 ++++++++--- .../ui/src/components/shadcn/ui/command.tsx | 2 +- 21 files changed, 544 insertions(+), 90 deletions(-) create mode 100644 apps/docs/features/command/DocsCommandProvider.tsx create mode 100644 apps/docs/hooks/useDocsCommandMenuTelemetry.ts create mode 100644 apps/studio/components/interfaces/App/CommandMenu/StudioCommandProvider.tsx create mode 100644 apps/studio/hooks/misc/useStudioCommandMenuTelemetry.ts create mode 100644 apps/www/hooks/useWwwCommandMenuTelemetry.ts create mode 100644 packages/ui-patterns/src/CommandMenu/api/hooks/useCommandMenuTelemetry.ts create mode 100644 packages/ui-patterns/src/CommandMenu/api/hooks/useCommandMenuTelemetryContext.tsx 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... + + } + /> + + ) +} + 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<