From 7ff8724481c2a7d4087969b0d8abe1f8272f18a0 Mon Sep 17 00:00:00 2001 From: Timothy Lim Date: Wed, 15 Oct 2025 06:42:49 +0000 Subject: [PATCH 1/7] fix(docs): Fix anchor link in Compute and Disk doc (#39546) --- apps/docs/content/guides/platform/compute-and-disk.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/docs/content/guides/platform/compute-and-disk.mdx b/apps/docs/content/guides/platform/compute-and-disk.mdx index 381a6edbb71a5..d06e819c3d4af 100644 --- a/apps/docs/content/guides/platform/compute-and-disk.mdx +++ b/apps/docs/content/guides/platform/compute-and-disk.mdx @@ -35,7 +35,7 @@ In paid organizations, Nano Compute are billed at the same price as Micro Comput [^2]: Database size for each compute instance is the default recommendation but the actual performance of your database has many contributing factors, including resources available to it and the size of the data contained within it. See the [shared responsibility model](/docs/guides/platform/shared-responsibility-model) for more information. [^3]: Compute resources on the Free plan are subject to change. -Compute sizes can be changed by first selecting your project in the dashboard [here](/dashboard/project/_/settings/compute-and-disk) and the upgrade process will [incur downtime](/docs/guides/platform/compute-and-disk#upgrade-downtime). +Compute sizes can be changed by first selecting your project in the dashboard [here](/dashboard/project/_/settings/compute-and-disk) and the upgrade process will [incur downtime](/docs/guides/platform/compute-and-disk#upgrades). Compute Size Selection Date: Wed, 15 Oct 2025 15:12:35 +0800 Subject: [PATCH 2/7] fix: support nimbus project urls for testing edge functions (#39548) --- apps/studio/lib/api/edgeFunctions.ts | 8 ++++++++ turbo.json | 3 ++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/studio/lib/api/edgeFunctions.ts b/apps/studio/lib/api/edgeFunctions.ts index 47fc11750542b..a62fadc91eaa8 100644 --- a/apps/studio/lib/api/edgeFunctions.ts +++ b/apps/studio/lib/api/edgeFunctions.ts @@ -1,4 +1,12 @@ +const NIMBUS_PROD_PROJECTS_URL = process.env.NIMBUS_PROD_PROJECTS_URL + export const isValidEdgeFunctionURL = (url: string) => { + if (NIMBUS_PROD_PROJECTS_URL !== undefined) { + const apexDomain = NIMBUS_PROD_PROJECTS_URL.replace('https://*.', '').replace(/\./g, '\\.') + const nimbusRegex = new RegExp('^https://[a-z]*\\.' + apexDomain + '/functions/v[0-9]{1}/.*$') + return nimbusRegex.test(url) + } + const regexValidEdgeFunctionURL = new RegExp( '^https://[a-z]*.supabase.(red|co)/functions/v[0-9]{1}/.*$' ) diff --git a/turbo.json b/turbo.json index 7dd90e5bbb26b..5ae3d625e223f 100644 --- a/turbo.json +++ b/turbo.json @@ -112,7 +112,8 @@ "VERCEL_URL", "IS_THROTTLED", "AI_PRO_MODEL", - "AI_NORMAL_MODEL" + "AI_NORMAL_MODEL", + "NIMBUS_PROD_PROJECTS_URL" ], "passThroughEnv": ["CURRENT_CLI_VERSION", "VERCEL_GIT_COMMIT_SHA"], "outputs": [".next/**", "!.next/cache/**"] From 4f0ce0a0e35700de18a1ce3bcebc46aff78e2862 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Wed, 15 Oct 2025 04:29:35 -0400 Subject: [PATCH 3/7] fix(support form): create api route to upload attachments (#39543) * fix(support form): api route to create attachment urls * clean: remove console log * fix staging and prod * Minor clean up * Shift generation of urls to data folder * refactor * Final * Fix tests * Nit --------- Co-authored-by: Joshen Lim --- .../interfaces/Support/AttachmentUpload.tsx | 87 +- .../interfaces/Support/SupportForm.utils.tsx | 5 +- .../interfaces/Support/SupportFormV2.tsx | 15 +- .../__tests__/SupportFormPage.test.tsx | 2583 +++++++++-------- .../generate-attachment-urls-mutation.ts | 68 + apps/studio/lib/api/apiAuthenticate.ts | 2 +- apps/studio/middleware.ts | 1 + .../pages/api/generate-attachment-url.ts | 83 + turbo.json | 1 + 9 files changed, 1532 insertions(+), 1313 deletions(-) create mode 100644 apps/studio/data/support/generate-attachment-urls-mutation.ts create mode 100644 apps/studio/pages/api/generate-attachment-url.ts diff --git a/apps/studio/components/interfaces/Support/AttachmentUpload.tsx b/apps/studio/components/interfaces/Support/AttachmentUpload.tsx index 9e2d5a9ef138b..a1713ff2218d3 100644 --- a/apps/studio/components/interfaces/Support/AttachmentUpload.tsx +++ b/apps/studio/components/interfaces/Support/AttachmentUpload.tsx @@ -1,23 +1,36 @@ import { compact } from 'lodash' import { Plus, X } from 'lucide-react' -import { type ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ChangeEvent, + type RefObject, +} from 'react' import { toast } from 'sonner' // End of third-party imports +import { useFlag } from 'common' +import { useGenerateAttachmentURLsMutation } from 'data/support/generate-attachment-urls-mutation' import { uuidv4 } from 'lib/helpers' +import { useProfile } from 'lib/profile' import { cn } from 'ui' import { createSupportStorageClient } from './support-storage-client' const MAX_ATTACHMENTS = 5 -const uploadAttachments = async (ref: string, files: File[]) => { +const uploadAttachments = async ({ userId, files }: { userId: string; files: File[] }) => { + if (files.length === 0) return [] + const supportSupabaseClient = createSupportStorageClient() const filesToUpload = Array.from(files) const uploadedFiles = await Promise.all( filesToUpload.map(async (file) => { const suffix = file.type.split('/')[1] - const prefix = `${ref}/${uuidv4()}.${suffix}` + const prefix = `${userId}/${uuidv4()}.${suffix}` const options = { cacheControl: '3600' } const { data, error } = await supportSupabaseClient.storage @@ -29,24 +42,17 @@ const uploadAttachments = async (ref: string, files: File[]) => { }) ) const keys = compact(uploadedFiles).map((file) => file.path) - - if (keys.length === 0) return [] - - const { data, error } = await supportSupabaseClient.storage - .from('support-attachments') - .createSignedUrls(keys, 10 * 365 * 24 * 60 * 60) - if (error) { - console.error('Failed to retrieve URLs for attachments', error) - } - return data ? data.map((file) => file.signedUrl) : [] + return keys } export function useAttachmentUpload() { + const { profile } = useProfile() const uploadButtonRef = useRef(null) - const [uploadedFiles, setUploadedFiles] = useState([]) const [uploadedDataUrls, setUploadedDataUrls] = useState([]) + const { mutateAsync: generateAttachmentURLs } = useGenerateAttachmentURLsMutation() + const isFull = uploadedFiles.length >= MAX_ATTACHMENTS const addFile = useCallback(() => { @@ -92,14 +98,18 @@ export function useAttachmentUpload() { } }, [uploadedFiles]) - const createAttachments = useCallback( - async (projectRef: string) => { - const attachments = - uploadedFiles.length > 0 ? await uploadAttachments(projectRef, uploadedFiles) : [] - return attachments - }, - [uploadedFiles] - ) + const createAttachments = useCallback(async () => { + if (!profile?.id) { + console.error('[Support Form > uploadAttachments] Unable to upload files, missing user ID') + toast.error('Unable to upload attachments') + return [] + } + + const filenames = await uploadAttachments({ userId: profile.gotrue_id, files: uploadedFiles }) + const urls = await generateAttachmentURLs({ filenames }) + return urls + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [uploadedFiles]) return useMemo( () => ({ @@ -116,22 +126,49 @@ export function useAttachmentUpload() { } interface AttachmentUploadDisplayProps { - uploadButtonRef: React.RefObject + uploadButtonRef: RefObject isFull: boolean + uploadedDataUrls: string[] addFile: () => void handleFileUpload: (event: ChangeEvent) => Promise removeFileUpload: (idx: number) => void - uploadedDataUrls: Array } export function AttachmentUploadDisplay({ uploadButtonRef, isFull, + uploadedDataUrls, addFile, handleFileUpload, removeFileUpload, - uploadedDataUrls, }: AttachmentUploadDisplayProps) { + const { profile } = useProfile() + const enableUploads = useFlag('supportFormAttachments') + + if (!enableUploads) { + return ( +
+

Attachments

+

+ Uploads are temporarily disabled. Please reply to the acknowledgement email you will + receive with any screenshots you'd like to upload. +

+
+ ) + } + + if (!profile) { + return ( +
+

Attachments

+

+ Uploads are only supported when logged in. Please reply to the acknowledgement email you + will receive with any screenshots you'd like to upload. +

+
+ ) + } + return (
diff --git a/apps/studio/components/interfaces/Support/SupportForm.utils.tsx b/apps/studio/components/interfaces/Support/SupportForm.utils.tsx index 3ee06ef84baa5..d655f57671b2f 100644 --- a/apps/studio/components/interfaces/Support/SupportForm.utils.tsx +++ b/apps/studio/components/interfaces/Support/SupportForm.utils.tsx @@ -3,17 +3,16 @@ import { createLoader, createParser, createSerializer, - type inferParserType, parseAsString, - parseAsStringLiteral, + type inferParserType, type UseQueryStatesKeysMap, } from 'nuqs' // End of third-party imports import { + DocsSearchResultType as PageType, type DocsSearchResult as Page, type DocsSearchResultSection as PageSection, - DocsSearchResultType as PageType, } from 'common' import { getProjectDetail } from 'data/projects/project-detail-query' import { DOCS_URL } from 'lib/constants' diff --git a/apps/studio/components/interfaces/Support/SupportFormV2.tsx b/apps/studio/components/interfaces/Support/SupportFormV2.tsx index fb2497e53b9c9..d405b1ca4c742 100644 --- a/apps/studio/components/interfaces/Support/SupportFormV2.tsx +++ b/apps/studio/components/interfaces/Support/SupportFormV2.tsx @@ -1,4 +1,4 @@ -import type { Dispatch, MouseEventHandler } from 'react' +import { type Dispatch, type MouseEventHandler } from 'react' import type { SubmitHandler, UseFormReturn } from 'react-hook-form' // End of third-party imports @@ -14,7 +14,7 @@ import { AffectedServicesSelector, CATEGORIES_WITHOUT_AFFECTED_SERVICES, } from './AffectedServicesSelector' -import { useAttachmentUpload } from './AttachmentUpload' +import { AttachmentUploadDisplay, useAttachmentUpload } from './AttachmentUpload' import { CategoryAndSeverityInfo } from './CategoryAndSeverityInfo' import { ClientLibraryInfo } from './ClientLibraryInfo' import { MessageField } from './MessageField' @@ -72,7 +72,7 @@ export const SupportFormV2 = ({ form, initialError, state, dispatch }: SupportFo const onSubmit: SubmitHandler = async (values) => { dispatch({ type: 'SUBMIT' }) - const attachments = await attachmentUpload.createAttachments(projectRef) + const attachments = await attachmentUpload.createAttachments() const selectedLibrary = values.library ? CLIENT_LIBRARIES.find((library) => library.language === values.library) @@ -153,14 +153,7 @@ export const SupportFormV2 = ({ form, initialError, state, dispatch }: SupportFo - {/* */} -
-

Attachments

-

- Uploads have been temporarily disabled. Please reply to the acknowledgement email you - will receive with any screenshots you'd like to upload -

-
+
diff --git a/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx b/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx index 450ca6df91d44..4daa0e2e14c8a 100644 --- a/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx +++ b/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx @@ -1,1273 +1,1310 @@ -import { test } from 'vitest' - -test('placeholder', () => {}) -// import { screen, waitFor } from '@testing-library/react' -// import userEvent from '@testing-library/user-event' -// import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' -// // End of third-party imports -// -// import { API_URL } from 'lib/constants' -// import { HttpResponse, http } from 'msw' -// import { createMockOrganization, createMockProject } from 'tests/helpers' -// import { customRender } from 'tests/lib/custom-render' -// import { addAPIMock, mswServer } from 'tests/lib/msw' -// import { createMockProfileContext } from 'tests/lib/profile-helpers' -// import { NO_ORG_MARKER, NO_PROJECT_MARKER } from '../SupportForm.utils' -// import { SupportFormPage } from '../SupportFormPage' -// -// type Screen = typeof screen -// -// const mockOrganizations = [ -// createMockOrganization({ -// id: 1, -// slug: 'org-1', -// name: 'Organization 1', -// plan: { id: 'free', name: 'Free' }, -// }), -// createMockOrganization({ -// id: 2, -// slug: 'org-2', -// name: 'Organization 2', -// plan: { id: 'pro', name: 'Pro' }, -// }), -// ] -// -// const mockProjects = [ -// { -// ...createMockProject({ -// id: 1, -// ref: 'project-1', -// name: 'Project 1', -// organization_id: 1, -// }), -// organization_slug: 'org-1', -// preview_branch_refs: [], -// }, -// { -// ...createMockProject({ -// id: 2, -// ref: 'project-2', -// name: 'Project 2', -// organization_id: 2, -// }), -// organization_slug: 'org-2', -// preview_branch_refs: [], -// }, -// { -// ...createMockProject({ -// id: 3, -// ref: 'project-3', -// name: 'Project 3', -// organization_id: 1, -// }), -// organization_slug: 'org-1', -// preview_branch_refs: [], -// }, -// ] -// -// vi.mock('react-inlinesvg', () => ({ -// __esModule: true, -// default: () => null, -// })) -// -// // Mock the support storage client module - will be configured per test -// vi.mock('../support-storage-client', () => ({ -// createSupportStorageClient: vi.fn(), -// })) -// -// // Mock sonner toast -// vi.mock('sonner', () => ({ -// toast: { -// success: vi.fn(), -// error: vi.fn(), -// }, -// })) -// -// vi.mock(import('common'), async (importOriginal) => { -// const actual = await importOriginal() -// return { -// ...actual, -// useParams: vi.fn().mockReturnValue({ ref: 'default' }), -// useIsLoggedIn: vi.fn().mockReturnValue(true), -// isFeatureEnabled: vi.fn((feature: any, disabledFeatures: any) => { -// if (typeof feature === 'string') { -// if (feature === 'support:show_client_libraries') { -// return true -// } -// return (actual as any).isFeatureEnabled(feature, disabledFeatures) -// } -// -// if (Array.isArray(feature)) { -// const result = (actual as any).isFeatureEnabled(feature, disabledFeatures) -// if (feature.includes('support:show_client_libraries')) { -// if (result && typeof result === 'object') { -// return { -// ...result, -// 'support:show_client_libraries': true, -// } -// } -// } -// return result -// } -// -// return (actual as any).isFeatureEnabled(feature, disabledFeatures) -// }), -// } -// }) -// -// vi.mock(import('lib/gotrue'), async (importOriginal) => { -// const actual = await importOriginal() -// return { -// ...actual, -// auth: { -// ...(actual.auth as any), -// onAuthStateChange: vi.fn(), -// }, -// } -// }) -// -// const renderSupportFormPage = (options?: Parameters[1]) => -// customRender(, { -// profileContext: createMockProfileContext(), -// ...options, -// }) -// -// const getStatusLink = (screen: Screen) => { -// const statusLink = screen -// .getAllByRole('link') -// .find((el) => el.getAttribute('href') === 'https://status.supabase.com/') -// expect(statusLink).toBeDefined() -// return statusLink -// } -// -// const getOrganizationSelector = (screen: Screen) => -// screen.getByRole('combobox', { name: 'Select an organization' }) -// -// const getProjectSelector = (screen: Screen) => -// screen.getByRole('combobox', { name: 'Select a project' }) -// -// const getSummaryField = (screen: Screen) => screen.getByPlaceholderText(/summary of the problem/i) -// -// const getMessageField = (screen: Screen) => screen.getByPlaceholderText(/describe the issue/i) -// -// const getCategorySelector = (screen: Screen) => -// screen.getByRole('combobox', { name: 'Select an issue' }) -// -// const getSubmitButton = (screen: Screen) => -// screen.getByRole('button', { name: 'Send support request' }) -// -// const selectCategoryOption = async (screen: Screen, optionLabel: string) => { -// await userEvent.click(getCategorySelector(screen)) -// const option = await screen.findByRole('option', { -// name: (accessibleName) => accessibleName.toLowerCase().startsWith(optionLabel.toLowerCase()), -// }) -// await userEvent.click(option) -// } -// -// const getSeveritySelector = (screen: Screen) => -// screen.getByRole('combobox', { name: 'Select a severity' }) -// -// const selectSeverityOption = async (screen: Screen, optionLabel: string) => { -// await userEvent.click(getSeveritySelector(screen)) -// const option = await screen.findByRole('option', { -// name: (accessibleName) => accessibleName.toLowerCase().startsWith(optionLabel.toLowerCase()), -// }) -// await userEvent.click(option) -// } -// -// const getLibrarySelector = (screen: Screen) => -// screen.getByRole('combobox', { name: 'Select a library' }) -// -// const selectLibraryOption = async (screen: Screen, optionLabel: string) => { -// // await waitFor(() => { -// // expect(() => getLibrarySelector(screen)).not.toThrow() -// // }) -// const selector = getLibrarySelector(screen) -// await userEvent.click(selector) -// const option = await screen.findByRole('option', { -// name: (accessibleName) => accessibleName.toLowerCase().startsWith(optionLabel.toLowerCase()), -// }) -// await userEvent.click(option) -// } -// -// const getSupportForm = () => { -// const form = document.querySelector('form#support-form') -// expect(form).not.toBeNull() -// return form! -// } -// -// const getAttachmentFileInput = () => { -// const input = getSupportForm().querySelector( -// 'input[type="file"][accept*="image"]' -// ) -// expect(input).not.toBeNull() -// return input! -// } -// -// const getAttachmentRemoveButtons = (screen: Screen) => -// screen.queryAllByRole('button', { name: 'Remove attachment' }) -// -// const createDeferred = () => { -// let resolve!: () => void -// const promise = new Promise((res) => { -// resolve = res -// }) -// return { promise, resolve } -// } -// -// const originalUserAgent = window.navigator.userAgent -// -// describe('SupportFormPage', () => { -// afterEach(() => { -// Object.defineProperty(window.navigator, 'userAgent', { -// value: originalUserAgent, -// configurable: true, -// }) -// }) -// -// beforeEach(() => { -// Object.defineProperty(window.navigator, 'userAgent', { -// value: -// 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', -// configurable: true, -// }) -// -// Object.defineProperty(window, 'location', { -// value: { search: '' }, -// writable: true, -// }) -// -// addAPIMock({ -// method: 'get', -// path: '/platform/organizations', -// response: mockOrganizations, -// }) -// -// addAPIMock({ -// method: 'get', -// path: '/platform/projects', -// response: mockProjects, -// }) -// -// addAPIMock({ -// method: 'get', -// path: '/platform/projects/:ref', -// response: mockProjects[0], -// }) -// -// addAPIMock({ -// method: 'get', -// path: '/platform/status', -// response: { is_healthy: true } as any, -// }) -// -// addAPIMock({ -// method: 'get', -// path: '/platform/auth/:ref/config', -// response: { SITE_URL: 'https://supabase.com', URI_ALLOW_LIST: '' } as any, -// }) -// -// addAPIMock({ -// method: 'get', -// path: '/platform/organizations/:slug/projects', -// response: ({ params, request }) => { -// const slug = (params as { slug: string }).slug -// const projects = mockProjects.filter((project) => project.organization_slug === slug) -// -// const url = new URL(request.url) -// const limit = Number(url.searchParams.get('limit') ?? projects.length) -// const offset = Number(url.searchParams.get('offset') ?? 0) -// const sort = url.searchParams.get('sort') ?? 'name_asc' -// -// const sorted = [...projects].sort((a, b) => { -// switch (sort) { -// case 'name_desc': -// return b.name.localeCompare(a.name) -// default: -// return a.name.localeCompare(b.name) -// } -// }) -// -// const paginated = sorted.slice(offset, offset + limit) -// -// return HttpResponse.json({ -// projects: paginated, -// pagination: { -// count: projects.length, -// limit, -// offset, -// }, -// }) -// }, -// }) -// -// mswServer.use( -// http.get('http://localhost:3000/img/supabase-logo.svg', () => HttpResponse.text('')) -// ) -// }) -// -// test('shows system status: healthy', async () => { -// renderSupportFormPage() -// -// await waitFor(() => { -// expect(getStatusLink(screen)).toHaveTextContent('All systems operational') -// }) -// }) -// -// test('shows system status: not healthy', async () => { -// addAPIMock({ -// method: 'get', -// path: '/platform/status', -// response: { is_healthy: false } as any, -// }) -// -// renderSupportFormPage() -// -// await waitFor(() => { -// expect(getStatusLink(screen)).toHaveTextContent('Active incident ongoing') -// }) -// }) -// -// test('shows system status: check failed', async () => { -// mswServer.use( -// http.get(`${API_URL}/platform/status`, () => HttpResponse.json(null, { status: 500 })) -// ) -// -// renderSupportFormPage() -// -// await waitFor(() => { -// expect(getStatusLink(screen)).toHaveTextContent('Failed to check status') -// }) -// }) -// -// test('loading a URL with a valid project slug prefills the organization and project', async () => { -// Object.defineProperty(window, 'location', { -// value: { search: '?projectRef=project-3' }, -// writable: true, -// }) -// -// renderSupportFormPage() -// -// await waitFor(() => { -// expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') -// expect(screen.getByRole('combobox', { name: 'Select a project' })).toHaveTextContent( -// 'Project 3' -// ) -// }) -// }) -// -// test('loading a URL with no project slug falls back to first organization and project', async () => { -// renderSupportFormPage() -// -// await waitFor(() => { -// expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') -// expect(getProjectSelector(screen)).toHaveTextContent('Project 1') -// }) -// }) -// -// test('loading a URL with an invalid project slug falls back to first organization and project', async () => { -// mswServer.use( -// http.get(`${API_URL}/platform/projects/:ref`, () => HttpResponse.json(null, { status: 404 })) -// ) -// Object.defineProperty(window, 'location', { -// value: { search: '?projectRef=project-nonexistent' }, -// writable: true, -// }) -// -// renderSupportFormPage() -// -// await waitFor(() => { -// expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') -// expect(getProjectSelector(screen)).toHaveTextContent('Project 1') -// }) -// }) -// -// test('loading a URL with a message prefills the message field', async () => { -// const testMessage = 'This is a test support message from URL' -// Object.defineProperty(window, 'location', { -// value: { search: `?message=${encodeURIComponent(testMessage)}` }, -// writable: true, -// }) -// -// renderSupportFormPage() -// -// await waitFor(() => { -// expect(getMessageField(screen)).toHaveValue(testMessage) -// }) -// }) -// -// test('loading a URL with a subject prefills the subject field', async () => { -// const testSubject = 'Test Subject' -// Object.defineProperty(window, 'location', { -// value: { search: `?subject=${encodeURIComponent(testSubject)}` }, -// writable: true, -// }) -// -// renderSupportFormPage() -// -// await waitFor(() => { -// const subjectField = getSummaryField(screen) -// expect(subjectField).toHaveValue(testSubject) -// }) -// }) -// -// test('loading a URL with a category prefills the category field', async () => { -// const testCategory = 'Problem' -// Object.defineProperty(window, 'location', { -// value: { search: `?category=${encodeURIComponent(testCategory)}` }, -// writable: true, -// }) -// -// renderSupportFormPage() -// -// await waitFor(() => { -// expect(getCategorySelector(screen)).toHaveTextContent('APIs and client libraries') -// }) -// }) -// -// test('loading a URL with a category prefills the category field (case-insensitive)', async () => { -// const testCategory = 'dashboard_bug' -// Object.defineProperty(window, 'location', { -// value: { search: `?category=${encodeURIComponent(testCategory)}` }, -// writable: true, -// }) -// -// renderSupportFormPage() -// -// await waitFor(() => { -// expect(getCategorySelector(screen)).toHaveTextContent('Dashboard bug') -// }) -// }) -// -// test('loading a URL with an invalid category gracefully falls back', async () => { -// const testCategory = 'Invalid' -// Object.defineProperty(window, 'location', { -// value: { search: `?category=${encodeURIComponent(testCategory)}` }, -// writable: true, -// }) -// -// renderSupportFormPage() -// -// await waitFor(() => { -// expect(getCategorySelector(screen)).toHaveTextContent('Select an issue') -// }) -// }) -// -// test('loading a URL with multiple initial fields fills them all in', async () => { -// const testCategory = 'Problem' -// const testSubject = 'Test Subject' -// Object.defineProperty(window, 'location', { -// value: { -// search: `?category=${encodeURIComponent(testCategory)}&subject=${encodeURIComponent(testSubject)}`, -// }, -// writable: true, -// }) -// -// renderSupportFormPage() -// -// await waitFor(() => { -// expect(getCategorySelector(screen)).toHaveTextContent('APIs and client libraries') -// expect(getSummaryField(screen)).toHaveValue(testSubject) -// }) -// }) -// -// test('includes Sentry issue ID from URL in submission payload', async () => { -// const sentryIssueId = 'mock-sentry-id' -// -// const submitSpy = vi.fn() -// addAPIMock({ -// method: 'post', -// path: '/platform/feedback/send', -// response: async ({ request }) => { -// submitSpy(await request.json()) -// return HttpResponse.json({ ok: true }) -// }, -// }) -// -// Object.defineProperty(window, 'location', { -// value: { search: `?sid=${encodeURIComponent(sentryIssueId)}` }, -// writable: true, -// }) -// -// renderSupportFormPage() -// -// await waitFor(() => { -// expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') -// expect(getProjectSelector(screen)).toHaveTextContent('Project 1') -// }) -// -// await selectCategoryOption(screen, 'Dashboard bug') -// await waitFor(() => { -// expect(getCategorySelector(screen)).toHaveTextContent('Dashboard bug') -// }) -// -// await userEvent.type(getSummaryField(screen), 'Dashboard stopped loading') -// await userEvent.type(getMessageField(screen), 'The dashboard page loads blank after login') -// -// await userEvent.click(getSubmitButton(screen)) -// -// await waitFor(() => { -// expect(submitSpy).toHaveBeenCalledTimes(1) -// }) -// expect(submitSpy.mock.calls[0]?.[0]?.dashboardSentryIssueId).toBe(sentryIssueId) -// }, 10_000) -// -// test('includes initial error message from URL in submission payload', async () => { -// const initialError = 'failed to fetch user data' -// const messageBody = 'The dashboard page loads blank after login' -// -// const submitSpy = vi.fn() -// addAPIMock({ -// method: 'post', -// path: '/platform/feedback/send', -// response: async ({ request }) => { -// submitSpy(await request.json()) -// return HttpResponse.json({ ok: true }) -// }, -// }) -// -// Object.defineProperty(window, 'location', { -// value: { search: `?error=${encodeURIComponent(initialError)}` }, -// writable: true, -// }) -// -// renderSupportFormPage() -// -// await waitFor(() => { -// expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') -// expect(getProjectSelector(screen)).toHaveTextContent('Project 1') -// }) -// -// await selectCategoryOption(screen, 'Dashboard bug') -// await waitFor(() => { -// expect(getCategorySelector(screen)).toHaveTextContent('Dashboard bug') -// }) -// -// await userEvent.type(getSummaryField(screen), 'Dashboard stopped loading') -// await userEvent.type(getMessageField(screen), messageBody) -// -// await userEvent.click(getSubmitButton(screen)) -// -// await waitFor(() => { -// expect(submitSpy).toHaveBeenCalledTimes(1) -// }) -// -// const payload = submitSpy.mock.calls[0]?.[0] -// expect(payload?.message).toMatch(initialError) -// }, 10_000) -// -// test('submits support request with problem category, library, and affected services', async () => { -// const submitSpy = vi.fn() -// addAPIMock({ -// method: 'post', -// path: '/platform/feedback/send', -// response: async ({ request }) => { -// submitSpy(await request.json()) -// return HttpResponse.json({ ok: true }) -// }, -// }) -// -// addAPIMock({ -// method: 'get', -// path: '/platform/auth/:ref/config', -// response: ({ params }) => { -// const { ref } = params as { ref: string } -// return HttpResponse.json({ -// SITE_URL: `https://${ref}.example.com`, -// URI_ALLOW_LIST: `https://${ref}.example.com/callbacks`, -// } as any) -// }, -// }) -// -// renderSupportFormPage() -// -// await waitFor(() => { -// expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') -// expect(getProjectSelector(screen)).toHaveTextContent('Project 1') -// }) -// -// await selectCategoryOption(screen, 'APIs and client libraries') -// await waitFor(() => { -// expect(getCategorySelector(screen)).toHaveTextContent('APIs and client libraries') -// }) -// -// await selectSeverityOption(screen, 'High') -// await waitFor(() => { -// expect(getSeveritySelector(screen)).toHaveTextContent('High') -// }) -// -// await selectLibraryOption(screen, 'JavaScript') -// await waitFor(() => { -// expect(getLibrarySelector(screen)).toHaveTextContent('Javascript') -// }) -// -// const summaryField = getSummaryField(screen) -// await userEvent.clear(summaryField) -// await userEvent.type(summaryField, 'API requests failing in production') -// -// const messageField = getMessageField(screen) -// await userEvent.clear(messageField) -// await userEvent.type(messageField, 'Requests return status 500 when calling the RPC endpoint') -// -// const supportAccessToggle = screen.getByRole('switch', { -// name: /allow support access to your project/i, -// }) -// expect(supportAccessToggle).toBeChecked() -// await userEvent.click(supportAccessToggle) -// expect(supportAccessToggle).not.toBeChecked() -// -// await userEvent.click(getSubmitButton(screen)) -// -// await waitFor(() => { -// expect(submitSpy).toHaveBeenCalledTimes(1) -// }) -// -// const payload = submitSpy.mock.calls[0]?.[0] -// expect(payload).toMatchObject({ -// subject: 'API requests failing in production', -// message: 'Requests return status 500 when calling the RPC endpoint', -// category: 'Problem', -// severity: 'High', -// projectRef: 'project-1', -// organizationSlug: 'org-1', -// library: 'javascript', -// affectedServices: '', -// allowSupportAccess: false, -// verified: true, -// tags: ['dashboard-support-form'], -// siteUrl: 'https://project-1.example.com', -// additionalRedirectUrls: 'https://project-1.example.com/callbacks', -// browserInformation: 'Chrome', -// }) -// -// await waitFor(() => { -// expect(screen.getByRole('heading', { name: /success/i })).toBeInTheDocument() -// }) -// }, 10_000) -// -// test('submits urgent login issues ticket for a different organization', async () => { -// const submitSpy = vi.fn() -// -// addAPIMock({ -// method: 'post', -// path: '/platform/feedback/send', -// response: async ({ request }) => { -// submitSpy(await request.json()) -// return HttpResponse.json({ ok: true }) -// }, -// }) -// -// addAPIMock({ -// method: 'get', -// path: '/platform/auth/:ref/config', -// response: ({ params }) => { -// const { ref } = params as { ref: string } -// return HttpResponse.json({ -// SITE_URL: `https://${ref}.supabase.dev`, -// URI_ALLOW_LIST: `https://${ref}.supabase.dev/redirect`, -// } as any) -// }, -// }) -// -// renderSupportFormPage() -// -// await waitFor(() => { -// expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') -// }) -// -// await userEvent.click(getOrganizationSelector(screen)) -// await userEvent.click(await screen.findByRole('option', { name: 'Organization 2' })) -// -// await waitFor(() => { -// expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 2') -// expect(getProjectSelector(screen)).toHaveTextContent('Project 2') -// }) -// -// await selectCategoryOption(screen, 'Issues with logging in') -// await waitFor(() => { -// expect(getCategorySelector(screen)).toHaveTextContent('Issues with logging in') -// }) -// -// await selectSeverityOption(screen, 'Urgent') -// await waitFor(() => { -// expect(getSeveritySelector(screen)).toHaveTextContent('Urgent') -// }) -// -// const summaryField = getSummaryField(screen) -// await userEvent.clear(summaryField) -// await userEvent.type(summaryField, 'Cannot log in to dashboard') -// -// const messageField = getMessageField(screen) -// await userEvent.clear(messageField) -// await userEvent.type(messageField, 'MFA challenge fails with an unknown error code') -// -// expect( -// screen.queryByRole('switch', { name: /allow support access to your project/i }) -// ).toBeNull() -// -// await userEvent.click(getSubmitButton(screen)) -// -// await waitFor(() => { -// expect(submitSpy).toHaveBeenCalledTimes(1) -// }) -// -// const payload = submitSpy.mock.calls[0]?.[0] -// expect(payload).toMatchObject({ -// subject: 'Cannot log in to dashboard', -// message: 'MFA challenge fails with an unknown error code', -// category: 'Login_issues', -// severity: 'Urgent', -// projectRef: 'project-2', -// organizationSlug: 'org-2', -// library: '', -// affectedServices: '', -// allowSupportAccess: false, -// verified: true, -// tags: ['dashboard-support-form'], -// siteUrl: 'https://project-2.supabase.dev', -// additionalRedirectUrls: 'https://project-2.supabase.dev/redirect', -// browserInformation: 'Chrome', -// }) -// -// await waitFor(() => { -// expect(screen.getByRole('heading', { name: /success/i })).toBeInTheDocument() -// }) -// }, 10_000) -// -// test('submits database unresponsive ticket with initial error', async () => { -// const submitSpy = vi.fn() -// -// addAPIMock({ -// method: 'post', -// path: '/platform/feedback/send', -// response: async ({ request }) => { -// submitSpy(await request.json()) -// return HttpResponse.json({ ok: true }) -// }, -// }) -// -// addAPIMock({ -// method: 'get', -// path: '/platform/auth/:ref/config', -// response: ({ params }) => { -// const { ref } = params as { ref: string } -// return HttpResponse.json({ -// SITE_URL: `https://${ref}.apps.supabase.co`, -// URI_ALLOW_LIST: `https://${ref}.apps.supabase.co/auth`, -// } as any) -// }, -// }) -// -// addAPIMock({ -// method: 'get', -// path: '/platform/projects/:ref', -// response: ({ params }) => { -// const { ref } = params as { ref: string } -// const project = mockProjects.find((candidate) => candidate.ref === ref) -// return project ? HttpResponse.json(project) : HttpResponse.json(null, { status: 404 }) -// }, -// }) -// -// Object.defineProperty(window, 'location', { -// value: { search: '?projectRef=project-3&error=Connection timeout detected' }, -// writable: true, -// }) -// -// renderSupportFormPage() -// -// await waitFor(() => { -// expect(getProjectSelector(screen)).toHaveTextContent('Project 3') -// expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') -// }) -// -// await selectCategoryOption(screen, 'Database unresponsive') -// await waitFor(() => { -// expect(getCategorySelector(screen)).toHaveTextContent('Database unresponsive') -// }) -// -// await selectSeverityOption(screen, 'Normal') -// await waitFor(() => { -// expect(getSeveritySelector(screen)).toHaveTextContent('Normal') -// }) -// -// const summaryField = getSummaryField(screen) -// await userEvent.clear(summaryField) -// await userEvent.type(summaryField, 'Database unreachable after upgrade') -// -// const messageField = getMessageField(screen) -// await userEvent.clear(messageField) -// await userEvent.type(messageField, 'Connections time out after 30 seconds') -// -// const supportAccessToggle = screen.getByRole('switch', { -// name: /allow support access to your project/i, -// }) -// expect(supportAccessToggle).toBeChecked() -// -// await userEvent.click(getSubmitButton(screen)) -// -// await waitFor(() => { -// expect(submitSpy).toHaveBeenCalledTimes(1) -// }) -// -// const payload = submitSpy.mock.calls[0]?.[0] -// expect(payload).toMatchObject({ -// subject: 'Database unreachable after upgrade', -// category: 'Database_unresponsive', -// severity: 'Normal', -// projectRef: 'project-3', -// organizationSlug: 'org-1', -// library: '', -// affectedServices: '', -// allowSupportAccess: true, -// verified: true, -// tags: ['dashboard-support-form'], -// siteUrl: 'https://project-3.apps.supabase.co', -// additionalRedirectUrls: 'https://project-3.apps.supabase.co/auth', -// browserInformation: 'Chrome', -// }) -// expect(payload.message).toBe( -// 'Connections time out after 30 seconds\nError: Connection timeout detected' -// ) -// -// await waitFor(() => { -// expect(screen.getByRole('heading', { name: /success/i })).toBeInTheDocument() -// }) -// }, 10_000) -// -// test('when organization changes, project selector updates to match', async () => { -// renderSupportFormPage() -// -// await waitFor(() => { -// expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') -// expect(getProjectSelector(screen)).toHaveTextContent('Project 1') -// }) -// -// await userEvent.click(getOrganizationSelector(screen)) -// await userEvent.click(screen.getByRole('option', { name: 'Organization 2' })) -// await waitFor(() => { -// expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 2') -// }) -// -// await waitFor(() => { -// expect(getProjectSelector(screen)).toHaveTextContent('Project 2') -// }) -// }) -// -// test('AI Assistant suggestion displays when valid project and organization are selected', async () => { -// renderSupportFormPage() -// -// await waitFor(() => { -// expect(screen.getByText('Try the AI Assistant')).toBeInTheDocument() -// }) -// }) -// -// test('can upload attachments', async () => { -// const url = URL as unknown as { -// createObjectURL?: (obj: Blob) => string -// revokeObjectURL?: (url: string) => void -// } -// const originalCreateObjectURL = url.createObjectURL -// const originalRevokeObjectURL = url.revokeObjectURL -// -// let urlIndex = 0 -// const createObjectURLMock = vi.fn(() => { -// urlIndex += 1 -// return `blob:mock-url-${urlIndex}` -// }) -// const revokeObjectURLMock = vi.fn() -// url.createObjectURL = createObjectURLMock -// url.revokeObjectURL = revokeObjectURLMock -// -// let unmount: (() => void) | undefined -// try { -// const renderResult = renderSupportFormPage() -// unmount = renderResult.unmount -// -// await waitFor(() => { -// expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') -// }) -// -// const fileInput = getAttachmentFileInput() -// const firstFile = new File(['first file'], 'first.png', { type: 'image/png' }) -// const secondFile = new File(['second file'], 'second.jpg', { type: 'image/jpeg' }) -// await userEvent.upload(fileInput, [firstFile, secondFile]) -// -// await waitFor(() => { -// expect(getAttachmentRemoveButtons(screen)).toHaveLength(2) -// }) -// expect(createObjectURLMock).toHaveBeenCalledTimes(2) -// -// const firstRemoveButton = getAttachmentRemoveButtons(screen)[0] -// await userEvent.click(firstRemoveButton) -// -// await waitFor(() => { -// expect(getAttachmentRemoveButtons(screen)).toHaveLength(1) -// }) -// expect(revokeObjectURLMock).toHaveBeenCalled() -// -// const thirdFile = new File(['third file'], 'third.png', { type: 'image/png' }) -// await userEvent.upload(getAttachmentFileInput(), thirdFile) -// -// await waitFor(() => { -// expect(getAttachmentRemoveButtons(screen)).toHaveLength(2) -// }) -// -// expect(createObjectURLMock).toHaveBeenCalled() -// } finally { -// unmount?.() -// url.createObjectURL = originalCreateObjectURL -// url.revokeObjectURL = originalRevokeObjectURL -// } -// }) -// -// test('cannot submit form again while it is submitting', async () => { -// const submission = createDeferred() -// const submitSpy = vi.fn() -// -// addAPIMock({ -// method: 'post', -// path: '/platform/feedback/send', -// response: async () => { -// submitSpy() -// await submission.promise -// return HttpResponse.json({ ok: true }) -// }, -// }) -// -// renderSupportFormPage() -// -// try { -// await waitFor(() => { -// expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') -// expect(getProjectSelector(screen)).toHaveTextContent('Project 1') -// }) -// -// await selectCategoryOption(screen, 'Dashboard bug') -// await waitFor(() => { -// expect(getCategorySelector(screen)).toHaveTextContent('Dashboard bug') -// }) -// await userEvent.type(getSummaryField(screen), 'Unable to connect to database') -// await userEvent.type(getMessageField(screen), 'Connections time out after 30 seconds') -// -// const submitButton = getSubmitButton(screen) -// await userEvent.click(submitButton) -// -// await waitFor(() => { -// expect(submitSpy).toHaveBeenCalledTimes(1) -// }) -// -// await waitFor(() => { -// expect(submitButton).toBeDisabled() -// }) -// -// await userEvent.click(submitButton) -// expect(submitSpy).toHaveBeenCalledTimes(1) -// } finally { -// submission.resolve() -// await waitFor(() => { -// expect(submitSpy).toHaveBeenCalledTimes(1) -// }) -// await waitFor(() => { -// expect(screen.getByRole('heading', { name: /success/i })).toBeInTheDocument() -// }) -// } -// }, 10_000) -// -// test('shows toast on submission error and allows form re-editing and resubmission', async () => { -// const submitSpy = vi.fn() -// const toastErrorSpy = vi.fn() -// const toastSuccessSpy = vi.fn() -// -// const { toast } = await import('sonner') -// vi.mocked(toast.error).mockImplementation(toastErrorSpy) -// vi.mocked(toast.success).mockImplementation(toastSuccessSpy) -// -// const errorMessage = 'Network error: Unable to reach server' -// -// // First attempt: return an error -// addAPIMock({ -// method: 'post', -// path: '/platform/feedback/send', -// response: async () => { -// return HttpResponse.json({ message: errorMessage }, { status: 500 }) -// }, -// }) -// -// renderSupportFormPage() -// -// await waitFor(() => { -// expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') -// expect(getProjectSelector(screen)).toHaveTextContent('Project 1') -// }) -// -// await selectCategoryOption(screen, 'Dashboard bug') -// await waitFor(() => { -// expect(getCategorySelector(screen)).toHaveTextContent('Dashboard bug') -// }) -// -// await userEvent.type(getSummaryField(screen), 'Cannot access settings') -// await userEvent.type(getMessageField(screen), 'Settings page shows 500 error') -// -// const submitButton = getSubmitButton(screen) -// await userEvent.click(submitButton) -// -// await waitFor(() => { -// expect(toastErrorSpy).toHaveBeenCalled() -// }) -// expect(toastErrorSpy.mock.calls[0]?.[0]).toMatch(/Failed to submit support ticket/) -// -// await waitFor(() => { -// expect(submitButton).not.toBeDisabled() -// }) -// -// addAPIMock({ -// method: 'post', -// path: '/platform/feedback/send', -// response: async ({ request }) => { -// submitSpy(await request.json()) -// return HttpResponse.json({ ok: true }) -// }, -// }) -// -// const messageField = getMessageField(screen) -// await userEvent.clear(messageField) -// await userEvent.type(messageField, 'Settings page shows 500 error - updated description') -// -// await userEvent.click(submitButton) -// -// await waitFor(() => { -// expect(submitSpy).toHaveBeenCalledTimes(1) -// }) -// -// const payload = submitSpy.mock.calls[0]?.[0] -// expect(payload.subject).toBe('Cannot access settings') -// expect(payload.message).toBe('Settings page shows 500 error - updated description') -// -// await waitFor(() => { -// expect(toastSuccessSpy).toHaveBeenCalledWith('Support request sent. Thank you!') -// }) -// await waitFor(() => { -// expect(screen.getByRole('heading', { name: /success/i })).toBeInTheDocument() -// }) -// }, 10_000) -// -// test('submits support request with attachments and includes attachment URLs in message', async () => { -// const submitSpy = vi.fn() -// -// // Mock URL.createObjectURL and revokeObjectURL -// const url = URL as unknown as { -// createObjectURL?: (obj: Blob) => string -// revokeObjectURL?: (url: string) => void -// } -// const originalCreateObjectURL = url.createObjectURL -// const originalRevokeObjectURL = url.revokeObjectURL -// -// let urlIndex = 0 -// const createObjectURLMock = vi.fn(() => { -// urlIndex += 1 -// return `blob:mock-url-${urlIndex}` -// }) -// const revokeObjectURLMock = vi.fn() -// url.createObjectURL = createObjectURLMock -// url.revokeObjectURL = revokeObjectURLMock -// -// // Mock the storage upload and createSignedUrls endpoints -// const signedUrls = [ -// 'https://storage.example.com/signed/file1.png?token=abc123', -// 'https://storage.example.com/signed/file2.jpg?token=def456', -// ] -// -// const { createSupportStorageClient } = await import('../support-storage-client') -// const mockStorageClient = { -// storage: { -// from: vi.fn(() => ({ -// upload: vi.fn(async (path: string) => ({ -// data: { Id: path, Key: path, path }, -// error: null, -// })), -// createSignedUrls: vi.fn(async (paths: Array) => ({ -// data: paths.map((path, idx) => ({ -// signedUrl: signedUrls[idx] || `https://storage.example.com/signed/${path}`, -// path, -// error: null, -// })), -// error: null, -// })), -// })), -// }, -// } -// -// vi.mocked(createSupportStorageClient).mockReturnValue(mockStorageClient as any) -// -// addAPIMock({ -// method: 'post', -// path: '/platform/feedback/send', -// response: async ({ request }) => { -// submitSpy(await request.json()) -// return HttpResponse.json({ ok: true }) -// }, -// }) -// -// addAPIMock({ -// method: 'get', -// path: '/platform/auth/:ref/config', -// response: ({ params }) => { -// const { ref } = params as { ref: string } -// return HttpResponse.json({ -// SITE_URL: `https://${ref}.example.com`, -// URI_ALLOW_LIST: `https://${ref}.example.com/auth`, -// } as any) -// }, -// }) -// -// let unmount: (() => void) | undefined -// try { -// const renderResult = renderSupportFormPage() -// unmount = renderResult.unmount -// -// await waitFor(() => { -// expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') -// expect(getProjectSelector(screen)).toHaveTextContent('Project 1') -// }) -// -// await selectCategoryOption(screen, 'Database unresponsive') -// await waitFor(() => { -// expect(getCategorySelector(screen)).toHaveTextContent('Database unresponsive') -// }) -// -// await selectSeverityOption(screen, 'High') -// await waitFor(() => { -// expect(getSeveritySelector(screen)).toHaveTextContent('High') -// }) -// -// const summaryField = getSummaryField(screen) -// await userEvent.clear(summaryField) -// await userEvent.type(summaryField, 'Query timeouts after maintenance') -// -// const messageField = getMessageField(screen) -// await userEvent.clear(messageField) -// await userEvent.type( -// messageField, -// 'All queries timing out after scheduled maintenance window' -// ) -// -// const fileInput = getAttachmentFileInput() -// const firstFile = new File(['screenshot 1'], 'error-screenshot.png', { type: 'image/png' }) -// const secondFile = new File(['screenshot 2'], 'logs-screenshot.jpg', { type: 'image/jpeg' }) -// await userEvent.upload(fileInput, [firstFile, secondFile]) -// -// await waitFor(() => { -// expect(getAttachmentRemoveButtons(screen)).toHaveLength(2) -// }) -// -// const supportAccessToggle = screen.getByRole('switch', { -// name: /allow support access to your project/i, -// }) -// expect(supportAccessToggle).toBeChecked() -// -// await userEvent.click(getSubmitButton(screen)) -// -// await waitFor(() => { -// expect(submitSpy).toHaveBeenCalledTimes(1) -// }) -// -// const payload = submitSpy.mock.calls[0]?.[0] -// expect(payload).toMatchObject({ -// subject: 'Query timeouts after maintenance', -// category: 'Database_unresponsive', -// severity: 'High', -// projectRef: 'project-1', -// organizationSlug: 'org-1', -// library: '', -// affectedServices: '', -// allowSupportAccess: true, -// verified: true, -// tags: ['dashboard-support-form'], -// siteUrl: 'https://project-1.example.com', -// additionalRedirectUrls: 'https://project-1.example.com/auth', -// browserInformation: 'Chrome', -// }) -// -// // Verify that attachment URLs are included in the message -// expect(payload.message).toContain('All queries timing out after scheduled maintenance window') -// expect(payload.message).toContain(signedUrls[0]) -// expect(payload.message).toContain(signedUrls[1]) -// -// await waitFor(() => { -// expect(screen.getByRole('heading', { name: /success/i })).toBeInTheDocument() -// }) -// } finally { -// unmount?.() -// url.createObjectURL = originalCreateObjectURL -// url.revokeObjectURL = originalRevokeObjectURL -// vi.mocked(createSupportStorageClient).mockReset() -// } -// }, 10_000) -// -// test('can submit form with no organizations and no projects', async () => { -// const submitSpy = vi.fn() -// -// addAPIMock({ -// method: 'get', -// path: '/platform/organizations', -// response: [], -// }) -// -// addAPIMock({ -// method: 'get', -// path: '/platform/projects', -// response: [], -// }) -// -// addAPIMock({ -// method: 'post', -// path: '/platform/feedback/send', -// response: async ({ request }) => { -// submitSpy(await request.json()) -// return HttpResponse.json({ ok: true }) -// }, -// }) -// -// renderSupportFormPage() -// -// await waitFor(() => { -// expect(getOrganizationSelector(screen)).toHaveTextContent('No specific organization') -// }) -// await waitFor(() => { -// expect(getProjectSelector(screen)).toHaveTextContent('No specific project') -// }) -// -// await selectCategoryOption(screen, 'Dashboard bug') -// await waitFor(() => { -// expect(getCategorySelector(screen)).toHaveTextContent('Dashboard bug') -// }) -// -// await userEvent.type(getSummaryField(screen), 'Cannot access my account') -// await userEvent.type(getMessageField(screen), 'I need help accessing my Supabase account') -// -// await userEvent.click(getSubmitButton(screen)) -// -// await waitFor(() => { -// expect(submitSpy).toHaveBeenCalledTimes(1) -// }) -// -// const payload = submitSpy.mock.calls[0]?.[0] -// expect(payload).toMatchObject({ -// subject: 'Cannot access my account', -// message: 'I need help accessing my Supabase account', -// category: 'Dashboard_bug', -// projectRef: NO_PROJECT_MARKER, -// organizationSlug: NO_ORG_MARKER, -// library: '', -// affectedServices: '', -// allowSupportAccess: false, -// verified: true, -// tags: ['dashboard-support-form'], -// browserInformation: 'Chrome', -// }) -// -// await waitFor(() => { -// expect(screen.getByRole('heading', { name: /success/i })).toBeInTheDocument() -// }) -// }, 10_000) -// }) +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +// End of third-party imports + +import { API_URL } from 'lib/constants' +import { HttpResponse, http } from 'msw' +import { createMockOrganization, createMockProject } from 'tests/helpers' +import { customRender } from 'tests/lib/custom-render' +import { addAPIMock, mswServer } from 'tests/lib/msw' +import { createMockProfileContext } from 'tests/lib/profile-helpers' +import { NO_ORG_MARKER, NO_PROJECT_MARKER } from '../SupportForm.utils' +import { SupportFormPage } from '../SupportFormPage' + +type Screen = typeof screen + +const mockOrganizations = [ + createMockOrganization({ + id: 1, + slug: 'org-1', + name: 'Organization 1', + plan: { id: 'free', name: 'Free' }, + }), + createMockOrganization({ + id: 2, + slug: 'org-2', + name: 'Organization 2', + plan: { id: 'pro', name: 'Pro' }, + }), +] + +const mockProjects = [ + { + ...createMockProject({ + id: 1, + ref: 'project-1', + name: 'Project 1', + organization_id: 1, + }), + organization_slug: 'org-1', + preview_branch_refs: [], + }, + { + ...createMockProject({ + id: 2, + ref: 'project-2', + name: 'Project 2', + organization_id: 2, + }), + organization_slug: 'org-2', + preview_branch_refs: [], + }, + { + ...createMockProject({ + id: 3, + ref: 'project-3', + name: 'Project 3', + organization_id: 1, + }), + organization_slug: 'org-1', + preview_branch_refs: [], + }, +] + +vi.mock('react-inlinesvg', () => ({ + __esModule: true, + default: () => null, +})) + +// Mock the support storage client module - will be configured per test +vi.mock('../support-storage-client', () => ({ + createSupportStorageClient: vi.fn(), +})) + +// Mock sonner toast +vi.mock('sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})) + +vi.mock(import('common'), async (importOriginal) => { + const actual = await importOriginal() + vi.spyOn((actual as any).gotrueClient, 'getSession').mockResolvedValue({ + data: { + session: { + user: { + id: '00000000-0000-0000-0000-000000000000', + }, + }, + }, + }) + return { + ...actual, + useFlag: vi.fn().mockReturnValue(true), + useParams: vi.fn().mockReturnValue({ ref: 'default' }), + useIsLoggedIn: vi.fn().mockReturnValue(true), + isFeatureEnabled: vi.fn((feature: any, disabledFeatures: any) => { + if (typeof feature === 'string') { + if (feature === 'support:show_client_libraries') { + return true + } + return (actual as any).isFeatureEnabled(feature, disabledFeatures) + } + + if (Array.isArray(feature)) { + const result = (actual as any).isFeatureEnabled(feature, disabledFeatures) + if (feature.includes('support:show_client_libraries')) { + if (result && typeof result === 'object') { + return { + ...result, + 'support:show_client_libraries': true, + } + } + } + return result + } + + return (actual as any).isFeatureEnabled(feature, disabledFeatures) + }), + } +}) + +vi.mock(import('lib/gotrue'), async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + auth: { + ...(actual.auth as any), + onAuthStateChange: vi.fn(), + }, + } +}) + +const renderSupportFormPage = (options?: Parameters[1]) => + customRender(, { + profileContext: createMockProfileContext(), + ...options, + }) + +const getStatusLink = (screen: Screen) => { + const statusLink = screen + .getAllByRole('link') + .find((el) => el.getAttribute('href') === 'https://status.supabase.com/') + expect(statusLink).toBeDefined() + return statusLink +} + +const getOrganizationSelector = (screen: Screen) => + screen.getByRole('combobox', { name: 'Select an organization' }) + +const getProjectSelector = (screen: Screen) => + screen.getByRole('combobox', { name: 'Select a project' }) + +const getSummaryField = (screen: Screen) => screen.getByPlaceholderText(/summary of the problem/i) + +const getMessageField = (screen: Screen) => screen.getByPlaceholderText(/describe the issue/i) + +const getCategorySelector = (screen: Screen) => + screen.getByRole('combobox', { name: 'Select an issue' }) + +const getSubmitButton = (screen: Screen) => + screen.getByRole('button', { name: 'Send support request' }) + +const selectCategoryOption = async (screen: Screen, optionLabel: string) => { + await userEvent.click(getCategorySelector(screen)) + const option = await screen.findByRole('option', { + name: (accessibleName) => accessibleName.toLowerCase().startsWith(optionLabel.toLowerCase()), + }) + await userEvent.click(option) +} + +const getSeveritySelector = (screen: Screen) => + screen.getByRole('combobox', { name: 'Select a severity' }) + +const selectSeverityOption = async (screen: Screen, optionLabel: string) => { + await userEvent.click(getSeveritySelector(screen)) + const option = await screen.findByRole('option', { + name: (accessibleName) => accessibleName.toLowerCase().startsWith(optionLabel.toLowerCase()), + }) + await userEvent.click(option) +} + +const getLibrarySelector = (screen: Screen) => + screen.getByRole('combobox', { name: 'Select a library' }) + +const selectLibraryOption = async (screen: Screen, optionLabel: string) => { + // await waitFor(() => { + // expect(() => getLibrarySelector(screen)).not.toThrow() + // }) + const selector = getLibrarySelector(screen) + await userEvent.click(selector) + const option = await screen.findByRole('option', { + name: (accessibleName) => accessibleName.toLowerCase().startsWith(optionLabel.toLowerCase()), + }) + await userEvent.click(option) +} + +const getSupportForm = () => { + const form = document.querySelector('form#support-form') + expect(form).not.toBeNull() + return form! +} + +const getAttachmentFileInput = () => { + const input = getSupportForm().querySelector( + 'input[type="file"][accept*="image"]' + ) + expect(input).not.toBeNull() + return input! +} + +const getAttachmentRemoveButtons = (screen: Screen) => + screen.queryAllByRole('button', { name: 'Remove attachment' }) + +const createDeferred = () => { + let resolve!: () => void + const promise = new Promise((res) => { + resolve = res + }) + return { promise, resolve } +} + +const createMockLocation = (search = '') => { + const url = new URL('http://localhost:3000/') + url.search = search.startsWith('?') || search === '' ? search : `?${search}` + return { + href: url.href, + origin: url.origin, + protocol: url.protocol, + host: url.host, + hostname: url.hostname, + port: url.port, + pathname: url.pathname, + search: url.search, + hash: url.hash, + } +} + +const originalUserAgent = window.navigator.userAgent + +describe('SupportFormPage', () => { + afterEach(() => { + Object.defineProperty(window.navigator, 'userAgent', { + value: originalUserAgent, + configurable: true, + }) + }) + + beforeEach(() => { + Object.defineProperty(window.navigator, 'userAgent', { + value: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + configurable: true, + }) + Object.defineProperty(window, 'location', { + value: createMockLocation(), + writable: true, + }) + Object.defineProperty(window, 'scrollTo', { + value: vi.fn(), + writable: true, + }) + + addAPIMock({ + method: 'get', + path: '/platform/organizations', + response: mockOrganizations, + }) + + addAPIMock({ + method: 'get', + path: '/platform/projects', + response: mockProjects, + }) + + addAPIMock({ + method: 'get', + path: '/platform/projects/:ref', + response: ({ params }) => { + const { ref } = params as { ref: string } + const project = mockProjects.find((candidate) => candidate.ref === ref) + return project + ? HttpResponse.json(project) + : HttpResponse.json({ msg: 'Project not found' }, { status: 404 }) + }, + }) + + addAPIMock({ + method: 'get', + path: '/platform/status', + response: { is_healthy: true } as any, + }) + + addAPIMock({ + method: 'get', + path: '/platform/auth/:ref/config', + response: { SITE_URL: 'https://supabase.com', URI_ALLOW_LIST: '' } as any, + }) + + addAPIMock({ + method: 'get', + path: '/platform/organizations/:slug/projects', + response: ({ params, request }) => { + const slug = (params as { slug: string }).slug + const projects = mockProjects.filter((project) => project.organization_slug === slug) + + const url = new URL(request.url) + const limit = Number(url.searchParams.get('limit') ?? projects.length) + const offset = Number(url.searchParams.get('offset') ?? 0) + const sort = url.searchParams.get('sort') ?? 'name_asc' + + const sorted = [...projects].sort((a, b) => { + switch (sort) { + case 'name_desc': + return b.name.localeCompare(a.name) + default: + return a.name.localeCompare(b.name) + } + }) + + const paginated = sorted.slice(offset, offset + limit) + + return HttpResponse.json({ + projects: paginated, + pagination: { + count: projects.length, + limit, + offset, + }, + }) + }, + }) + + mswServer.use( + http.get('http://localhost:3000/img/supabase-logo.svg', () => HttpResponse.text('')) + ) + }) + + test('shows system status: healthy', async () => { + renderSupportFormPage() + + await waitFor(() => { + expect(getStatusLink(screen)).toHaveTextContent('All systems operational') + }) + }) + + test('shows system status: not healthy', async () => { + addAPIMock({ + method: 'get', + path: '/platform/status', + response: { is_healthy: false } as any, + }) + + renderSupportFormPage() + + await waitFor(() => { + expect(getStatusLink(screen)).toHaveTextContent('Active incident ongoing') + }) + }) + + test('shows system status: check failed', async () => { + mswServer.use( + http.get(`${API_URL}/platform/status`, () => + HttpResponse.json({ msg: 'Status service unavailable' }, { status: 500 }) + ) + ) + + renderSupportFormPage() + + await waitFor(() => { + expect(getStatusLink(screen)).toHaveTextContent('Failed to check status') + }) + }) + + test('loading a URL with a valid project slug prefills the organization and project', async () => { + Object.defineProperty(window, 'location', { + value: createMockLocation('?projectRef=project-3'), + writable: true, + }) + + renderSupportFormPage() + + await waitFor(() => { + expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') + expect(screen.getByRole('combobox', { name: 'Select a project' })).toHaveTextContent( + 'Project 3' + ) + }) + }) + + test('loading a URL with no project slug falls back to first organization and project', async () => { + renderSupportFormPage() + + await waitFor(() => { + expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') + expect(getProjectSelector(screen)).toHaveTextContent('Project 1') + }) + }) + + test('loading a URL with an invalid project slug falls back to first organization and project', async () => { + mswServer.use( + http.get(`${API_URL}/platform/projects/:ref`, () => + HttpResponse.json({ msg: 'Project not found' }, { status: 404 }) + ) + ) + Object.defineProperty(window, 'location', { + value: createMockLocation('?projectRef=project-nonexistent'), + writable: true, + }) + + renderSupportFormPage() + + await waitFor(() => { + expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') + expect(getProjectSelector(screen)).toHaveTextContent('Project 1') + }) + }) + + test('loading a URL with a message prefills the message field', async () => { + const testMessage = 'This is a test support message from URL' + Object.defineProperty(window, 'location', { + value: createMockLocation(`?message=${encodeURIComponent(testMessage)}`), + writable: true, + }) + + renderSupportFormPage() + + await waitFor(() => { + expect(getMessageField(screen)).toHaveValue(testMessage) + }) + }) + + test('loading a URL with a subject prefills the subject field', async () => { + const testSubject = 'Test Subject' + Object.defineProperty(window, 'location', { + value: createMockLocation(`?subject=${encodeURIComponent(testSubject)}`), + writable: true, + }) + + renderSupportFormPage() + + await waitFor(() => { + const subjectField = getSummaryField(screen) + expect(subjectField).toHaveValue(testSubject) + }) + }) + + test('loading a URL with a category prefills the category field', async () => { + const testCategory = 'Problem' + Object.defineProperty(window, 'location', { + value: createMockLocation(`?category=${encodeURIComponent(testCategory)}`), + writable: true, + }) + + renderSupportFormPage() + + await waitFor(() => { + expect(getCategorySelector(screen)).toHaveTextContent('APIs and client libraries') + }) + }) + + test('loading a URL with a category prefills the category field (case-insensitive)', async () => { + const testCategory = 'dashboard_bug' + Object.defineProperty(window, 'location', { + value: createMockLocation(`?category=${encodeURIComponent(testCategory)}`), + writable: true, + }) + + renderSupportFormPage() + + await waitFor(() => { + expect(getCategorySelector(screen)).toHaveTextContent('Dashboard bug') + }) + }) + + test('loading a URL with an invalid category gracefully falls back', async () => { + const testCategory = 'Invalid' + Object.defineProperty(window, 'location', { + value: createMockLocation(`?category=${encodeURIComponent(testCategory)}`), + writable: true, + }) + + renderSupportFormPage() + + await waitFor(() => { + expect(getCategorySelector(screen)).toHaveTextContent('Select an issue') + }) + }) + + test('loading a URL with multiple initial fields fills them all in', async () => { + const testCategory = 'Problem' + const testSubject = 'Test Subject' + Object.defineProperty(window, 'location', { + value: createMockLocation( + `?category=${encodeURIComponent(testCategory)}&subject=${encodeURIComponent(testSubject)}` + ), + writable: true, + }) + + renderSupportFormPage() + + await waitFor(() => { + expect(getCategorySelector(screen)).toHaveTextContent('APIs and client libraries') + expect(getSummaryField(screen)).toHaveValue(testSubject) + }) + }) + + test('includes Sentry issue ID from URL in submission payload', async () => { + const sentryIssueId = 'mock-sentry-id' + + const submitSpy = vi.fn() + addAPIMock({ + method: 'post', + path: '/platform/feedback/send', + response: async ({ request }) => { + submitSpy(await request.json()) + return HttpResponse.json({ ok: true }) + }, + }) + + Object.defineProperty(window, 'location', { + value: createMockLocation(`?sid=${encodeURIComponent(sentryIssueId)}`), + writable: true, + }) + + renderSupportFormPage() + + await waitFor(() => { + expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') + expect(getProjectSelector(screen)).toHaveTextContent('Project 1') + }) + + await selectCategoryOption(screen, 'Dashboard bug') + await waitFor(() => { + expect(getCategorySelector(screen)).toHaveTextContent('Dashboard bug') + }) + + await userEvent.type(getSummaryField(screen), 'Dashboard stopped loading') + await userEvent.type(getMessageField(screen), 'The dashboard page loads blank after login') + + await userEvent.click(getSubmitButton(screen)) + + await waitFor(() => { + expect(submitSpy).toHaveBeenCalledTimes(1) + }) + expect(submitSpy.mock.calls[0]?.[0]?.dashboardSentryIssueId).toBe(sentryIssueId) + }, 10_000) + + test('includes initial error message from URL in submission payload', async () => { + const initialError = 'failed to fetch user data' + const messageBody = 'The dashboard page loads blank after login' + + const submitSpy = vi.fn() + addAPIMock({ + method: 'post', + path: '/platform/feedback/send', + response: async ({ request }) => { + submitSpy(await request.json()) + return HttpResponse.json({ ok: true }) + }, + }) + + Object.defineProperty(window, 'location', { + value: createMockLocation(`?error=${encodeURIComponent(initialError)}`), + writable: true, + }) + + renderSupportFormPage() + + await waitFor(() => { + expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') + expect(getProjectSelector(screen)).toHaveTextContent('Project 1') + }) + + await selectCategoryOption(screen, 'Dashboard bug') + await waitFor(() => { + expect(getCategorySelector(screen)).toHaveTextContent('Dashboard bug') + }) + + await userEvent.type(getSummaryField(screen), 'Dashboard stopped loading') + await userEvent.type(getMessageField(screen), messageBody) + + await userEvent.click(getSubmitButton(screen)) + + await waitFor(() => { + expect(submitSpy).toHaveBeenCalledTimes(1) + }) + + const payload = submitSpy.mock.calls[0]?.[0] + expect(payload?.message).toMatch(initialError) + }, 10_000) + + test('submits support request with problem category, library, and affected services', async () => { + const submitSpy = vi.fn() + addAPIMock({ + method: 'post', + path: '/platform/feedback/send', + response: async ({ request }) => { + submitSpy(await request.json()) + return HttpResponse.json({ ok: true }) + }, + }) + + addAPIMock({ + method: 'get', + path: '/platform/auth/:ref/config', + response: ({ params }) => { + const { ref } = params as { ref: string } + return HttpResponse.json({ + SITE_URL: `https://${ref}.example.com`, + URI_ALLOW_LIST: `https://${ref}.example.com/callbacks`, + } as any) + }, + }) + + renderSupportFormPage() + + await waitFor(() => { + expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') + expect(getProjectSelector(screen)).toHaveTextContent('Project 1') + }) + + await selectCategoryOption(screen, 'APIs and client libraries') + await waitFor(() => { + expect(getCategorySelector(screen)).toHaveTextContent('APIs and client libraries') + }) + + await selectSeverityOption(screen, 'High') + await waitFor(() => { + expect(getSeveritySelector(screen)).toHaveTextContent('High') + }) + + await selectLibraryOption(screen, 'JavaScript') + await waitFor(() => { + expect(getLibrarySelector(screen)).toHaveTextContent('Javascript') + }) + + const summaryField = getSummaryField(screen) + await userEvent.clear(summaryField) + await userEvent.type(summaryField, 'API requests failing in production') + + const messageField = getMessageField(screen) + await userEvent.clear(messageField) + await userEvent.type(messageField, 'Requests return status 500 when calling the RPC endpoint') + + const supportAccessToggle = screen.getByRole('switch', { + name: /allow support access to your project/i, + }) + expect(supportAccessToggle).toBeChecked() + await userEvent.click(supportAccessToggle) + expect(supportAccessToggle).not.toBeChecked() + + await userEvent.click(getSubmitButton(screen)) + + await waitFor(() => { + expect(submitSpy).toHaveBeenCalledTimes(1) + }) + + const payload = submitSpy.mock.calls[0]?.[0] + expect(payload).toMatchObject({ + subject: 'API requests failing in production', + message: 'Requests return status 500 when calling the RPC endpoint', + category: 'Problem', + severity: 'High', + projectRef: 'project-1', + organizationSlug: 'org-1', + library: 'javascript', + affectedServices: '', + allowSupportAccess: false, + verified: true, + tags: ['dashboard-support-form'], + siteUrl: 'https://project-1.example.com', + additionalRedirectUrls: 'https://project-1.example.com/callbacks', + browserInformation: 'Chrome', + }) + + await waitFor(() => { + expect(screen.getByRole('heading', { name: /success/i })).toBeInTheDocument() + }) + }, 10_000) + + test('submits urgent login issues ticket for a different organization', async () => { + const submitSpy = vi.fn() + + addAPIMock({ + method: 'post', + path: '/platform/feedback/send', + response: async ({ request }) => { + submitSpy(await request.json()) + return HttpResponse.json({ ok: true }) + }, + }) + + addAPIMock({ + method: 'get', + path: '/platform/auth/:ref/config', + response: ({ params }) => { + const { ref } = params as { ref: string } + return HttpResponse.json({ + SITE_URL: `https://${ref}.supabase.dev`, + URI_ALLOW_LIST: `https://${ref}.supabase.dev/redirect`, + } as any) + }, + }) + + renderSupportFormPage() + + await waitFor(() => { + expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') + }) + + await userEvent.click(getOrganizationSelector(screen)) + await userEvent.click(await screen.findByRole('option', { name: 'Organization 2' })) + + await waitFor(() => { + expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 2') + expect(getProjectSelector(screen)).toHaveTextContent('Project 2') + }) + + await selectCategoryOption(screen, 'Issues with logging in') + await waitFor(() => { + expect(getCategorySelector(screen)).toHaveTextContent('Issues with logging in') + }) + + await selectSeverityOption(screen, 'Urgent') + await waitFor(() => { + expect(getSeveritySelector(screen)).toHaveTextContent('Urgent') + }) + + const summaryField = getSummaryField(screen) + await userEvent.clear(summaryField) + await userEvent.type(summaryField, 'Cannot log in to dashboard') + + const messageField = getMessageField(screen) + await userEvent.clear(messageField) + await userEvent.type(messageField, 'MFA challenge fails with an unknown error code') + + expect( + screen.queryByRole('switch', { name: /allow support access to your project/i }) + ).toBeNull() + + await userEvent.click(getSubmitButton(screen)) + + await waitFor(() => { + expect(submitSpy).toHaveBeenCalledTimes(1) + }) + + const payload = submitSpy.mock.calls[0]?.[0] + expect(payload).toMatchObject({ + subject: 'Cannot log in to dashboard', + message: 'MFA challenge fails with an unknown error code', + category: 'Login_issues', + severity: 'Urgent', + projectRef: 'project-2', + organizationSlug: 'org-2', + library: '', + affectedServices: '', + allowSupportAccess: false, + verified: true, + tags: ['dashboard-support-form'], + siteUrl: 'https://project-2.supabase.dev', + additionalRedirectUrls: 'https://project-2.supabase.dev/redirect', + browserInformation: 'Chrome', + }) + + await waitFor(() => { + expect(screen.getByRole('heading', { name: /success/i })).toBeInTheDocument() + }) + }, 10_000) + + test('submits database unresponsive ticket with initial error', async () => { + const submitSpy = vi.fn() + + addAPIMock({ + method: 'post', + path: '/platform/feedback/send', + response: async ({ request }) => { + submitSpy(await request.json()) + return HttpResponse.json({ ok: true }) + }, + }) + + addAPIMock({ + method: 'get', + path: '/platform/auth/:ref/config', + response: ({ params }) => { + const { ref } = params as { ref: string } + return HttpResponse.json({ + SITE_URL: `https://${ref}.apps.supabase.co`, + URI_ALLOW_LIST: `https://${ref}.apps.supabase.co/auth`, + } as any) + }, + }) + + addAPIMock({ + method: 'get', + path: '/platform/projects/:ref', + response: ({ params }) => { + const { ref } = params as { ref: string } + const project = mockProjects.find((candidate) => candidate.ref === ref) + return project + ? HttpResponse.json(project) + : HttpResponse.json({ msg: 'Project not found' }, { status: 404 }) + }, + }) + + Object.defineProperty(window, 'location', { + value: createMockLocation('?projectRef=project-3&error=Connection timeout detected'), + writable: true, + }) + + renderSupportFormPage() + + await waitFor(() => { + expect(getProjectSelector(screen)).toHaveTextContent('Project 3') + expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') + }) + + await selectCategoryOption(screen, 'Database unresponsive') + await waitFor(() => { + expect(getCategorySelector(screen)).toHaveTextContent('Database unresponsive') + }) + + await selectSeverityOption(screen, 'Normal') + await waitFor(() => { + expect(getSeveritySelector(screen)).toHaveTextContent('Normal') + }) + + const summaryField = getSummaryField(screen) + await userEvent.clear(summaryField) + await userEvent.type(summaryField, 'Database unreachable after upgrade') + + const messageField = getMessageField(screen) + await userEvent.clear(messageField) + await userEvent.type(messageField, 'Connections time out after 30 seconds') + + const supportAccessToggle = screen.getByRole('switch', { + name: /allow support access to your project/i, + }) + expect(supportAccessToggle).toBeChecked() + + await userEvent.click(getSubmitButton(screen)) + + await waitFor(() => { + expect(submitSpy).toHaveBeenCalledTimes(1) + }) + + const payload = submitSpy.mock.calls[0]?.[0] + expect(payload).toMatchObject({ + subject: 'Database unreachable after upgrade', + category: 'Database_unresponsive', + severity: 'Normal', + projectRef: 'project-3', + organizationSlug: 'org-1', + library: '', + affectedServices: '', + allowSupportAccess: true, + verified: true, + tags: ['dashboard-support-form'], + siteUrl: 'https://project-3.apps.supabase.co', + additionalRedirectUrls: 'https://project-3.apps.supabase.co/auth', + browserInformation: 'Chrome', + }) + expect(payload.message).toBe( + 'Connections time out after 30 seconds\nError: Connection timeout detected' + ) + + await waitFor(() => { + expect(screen.getByRole('heading', { name: /success/i })).toBeInTheDocument() + }) + }, 10_000) + + test('when organization changes, project selector updates to match', async () => { + renderSupportFormPage() + + await waitFor(() => { + expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') + expect(getProjectSelector(screen)).toHaveTextContent('Project 1') + }) + + await userEvent.click(getOrganizationSelector(screen)) + await userEvent.click(screen.getByRole('option', { name: 'Organization 2' })) + await waitFor(() => { + expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 2') + }) + + await waitFor(() => { + expect(getProjectSelector(screen)).toHaveTextContent('Project 2') + }) + }) + + test('AI Assistant suggestion displays when valid project and organization are selected', async () => { + renderSupportFormPage() + + await waitFor(() => { + expect(screen.getByText('Try the AI Assistant')).toBeInTheDocument() + }) + }) + + test('can upload attachments', async () => { + const url = URL as unknown as { + createObjectURL?: (obj: Blob) => string + revokeObjectURL?: (url: string) => void + } + const originalCreateObjectURL = url.createObjectURL + const originalRevokeObjectURL = url.revokeObjectURL + + let urlIndex = 0 + const createObjectURLMock = vi.fn(() => { + urlIndex += 1 + return `blob:mock-url-${urlIndex}` + }) + const revokeObjectURLMock = vi.fn() + url.createObjectURL = createObjectURLMock + url.revokeObjectURL = revokeObjectURLMock + + let unmount: (() => void) | undefined + try { + const renderResult = renderSupportFormPage() + unmount = renderResult.unmount + + await waitFor(() => { + expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') + }) + + const fileInput = getAttachmentFileInput() + const firstFile = new File(['first file'], 'first.png', { type: 'image/png' }) + const secondFile = new File(['second file'], 'second.jpg', { type: 'image/jpeg' }) + await userEvent.upload(fileInput, [firstFile, secondFile]) + + await waitFor(() => { + expect(getAttachmentRemoveButtons(screen)).toHaveLength(2) + }) + expect(createObjectURLMock).toHaveBeenCalledTimes(2) + + const firstRemoveButton = getAttachmentRemoveButtons(screen)[0] + await userEvent.click(firstRemoveButton) + + await waitFor(() => { + expect(getAttachmentRemoveButtons(screen)).toHaveLength(1) + }) + expect(revokeObjectURLMock).toHaveBeenCalled() + + const thirdFile = new File(['third file'], 'third.png', { type: 'image/png' }) + await userEvent.upload(getAttachmentFileInput(), thirdFile) + + await waitFor(() => { + expect(getAttachmentRemoveButtons(screen)).toHaveLength(2) + }) + + expect(createObjectURLMock).toHaveBeenCalled() + } finally { + unmount?.() + url.createObjectURL = originalCreateObjectURL + url.revokeObjectURL = originalRevokeObjectURL + } + }) + + test('cannot submit form again while it is submitting', async () => { + const submission = createDeferred() + const submitSpy = vi.fn() + + addAPIMock({ + method: 'post', + path: '/platform/feedback/send', + response: async () => { + submitSpy() + await submission.promise + return HttpResponse.json({ ok: true }) + }, + }) + + renderSupportFormPage() + + try { + await waitFor(() => { + expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') + expect(getProjectSelector(screen)).toHaveTextContent('Project 1') + }) + + await selectCategoryOption(screen, 'Dashboard bug') + await waitFor(() => { + expect(getCategorySelector(screen)).toHaveTextContent('Dashboard bug') + }) + await userEvent.type(getSummaryField(screen), 'Unable to connect to database') + await userEvent.type(getMessageField(screen), 'Connections time out after 30 seconds') + + const submitButton = getSubmitButton(screen) + await userEvent.click(submitButton) + + await waitFor(() => { + expect(submitSpy).toHaveBeenCalledTimes(1) + }) + + await waitFor(() => { + expect(submitButton).toBeDisabled() + }) + + await userEvent.click(submitButton) + expect(submitSpy).toHaveBeenCalledTimes(1) + } finally { + submission.resolve() + await waitFor(() => { + expect(submitSpy).toHaveBeenCalledTimes(1) + }) + await waitFor(() => { + expect(screen.getByRole('heading', { name: /success/i })).toBeInTheDocument() + }) + } + }, 10_000) + + test('shows toast on submission error and allows form re-editing and resubmission', async () => { + const submitSpy = vi.fn() + const toastErrorSpy = vi.fn() + const toastSuccessSpy = vi.fn() + + const { toast } = await import('sonner') + vi.mocked(toast.error).mockImplementation(toastErrorSpy) + vi.mocked(toast.success).mockImplementation(toastSuccessSpy) + + const errorMessage = 'Network error: Unable to reach server' + + // First attempt: return an error + addAPIMock({ + method: 'post', + path: '/platform/feedback/send', + response: async () => { + return HttpResponse.json({ message: errorMessage }, { status: 500 }) + }, + }) + + renderSupportFormPage() + + await waitFor(() => { + expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') + expect(getProjectSelector(screen)).toHaveTextContent('Project 1') + }) + + await selectCategoryOption(screen, 'Dashboard bug') + await waitFor(() => { + expect(getCategorySelector(screen)).toHaveTextContent('Dashboard bug') + }) + + await userEvent.type(getSummaryField(screen), 'Cannot access settings') + await userEvent.type(getMessageField(screen), 'Settings page shows 500 error') + + const submitButton = getSubmitButton(screen) + await userEvent.click(submitButton) + + await waitFor(() => { + expect(toastErrorSpy).toHaveBeenCalled() + }) + expect(toastErrorSpy.mock.calls[0]?.[0]).toMatch(/Failed to submit support ticket/) + + await waitFor(() => { + expect(submitButton).not.toBeDisabled() + }) + + addAPIMock({ + method: 'post', + path: '/platform/feedback/send', + response: async ({ request }) => { + submitSpy(await request.json()) + return HttpResponse.json({ ok: true }) + }, + }) + + const messageField = getMessageField(screen) + await userEvent.clear(messageField) + await userEvent.type(messageField, 'Settings page shows 500 error - updated description') + + await userEvent.click(submitButton) + + await waitFor(() => { + expect(submitSpy).toHaveBeenCalledTimes(1) + }) + + const payload = submitSpy.mock.calls[0]?.[0] + expect(payload.subject).toBe('Cannot access settings') + expect(payload.message).toBe('Settings page shows 500 error - updated description') + + await waitFor(() => { + expect(toastSuccessSpy).toHaveBeenCalledWith('Support request sent. Thank you!') + }) + await waitFor(() => { + expect(screen.getByRole('heading', { name: /success/i })).toBeInTheDocument() + }) + }, 10_000) + + test('submits support request with attachments and includes attachment URLs in message', async () => { + const submitSpy = vi.fn() + + // Mock URL.createObjectURL and revokeObjectURL + const url = URL as unknown as { + createObjectURL?: (obj: Blob) => string + revokeObjectURL?: (url: string) => void + } + const originalCreateObjectURL = url.createObjectURL + const originalRevokeObjectURL = url.revokeObjectURL + + let urlIndex = 0 + const createObjectURLMock = vi.fn(() => { + urlIndex += 1 + return `blob:mock-url-${urlIndex}` + }) + const revokeObjectURLMock = vi.fn() + url.createObjectURL = createObjectURLMock + url.revokeObjectURL = revokeObjectURLMock + + // Mock the storage upload and createSignedUrls endpoints + const signedUrls = [ + 'https://storage.example.com/signed/file1.png?token=abc123', + 'https://storage.example.com/signed/file2.jpg?token=def456', + ] + + const { createSupportStorageClient } = await import('../support-storage-client') + const mockStorageClient = { + storage: { + from: vi.fn(() => ({ + upload: vi.fn(async (path: string) => ({ + data: { Id: path, Key: path, path }, + error: null, + })), + })), + }, + } + vi.mocked(createSupportStorageClient).mockReturnValue(mockStorageClient as any) + + mswServer.use( + http.post('http://localhost:3000/api/generate-attachment-url', async ({ request }) => { + const { filenames } = (await request.json()) as { filenames: string[] } + const urls = filenames.map((_, index) => signedUrls[index] ?? '') + return HttpResponse.json(urls) + }) + ) + + addAPIMock({ + method: 'post', + path: '/platform/feedback/send', + response: async ({ request }) => { + submitSpy(await request.json()) + return HttpResponse.json({ ok: true }) + }, + }) + + addAPIMock({ + method: 'get', + path: '/platform/auth/:ref/config', + response: ({ params }) => { + const { ref } = params as { ref: string } + return HttpResponse.json({ + SITE_URL: `https://${ref}.example.com`, + URI_ALLOW_LIST: `https://${ref}.example.com/auth`, + } as any) + }, + }) + + let unmount: (() => void) | undefined + try { + const renderResult = renderSupportFormPage() + unmount = renderResult.unmount + + await waitFor(() => { + expect(getOrganizationSelector(screen)).toHaveTextContent('Organization 1') + expect(getProjectSelector(screen)).toHaveTextContent('Project 1') + }) + + await selectCategoryOption(screen, 'Database unresponsive') + await waitFor(() => { + expect(getCategorySelector(screen)).toHaveTextContent('Database unresponsive') + }) + + await selectSeverityOption(screen, 'High') + await waitFor(() => { + expect(getSeveritySelector(screen)).toHaveTextContent('High') + }) + + const summaryField = getSummaryField(screen) + await userEvent.clear(summaryField) + await userEvent.type(summaryField, 'Query timeouts after maintenance') + + const messageField = getMessageField(screen) + await userEvent.clear(messageField) + await userEvent.type( + messageField, + 'All queries timing out after scheduled maintenance window' + ) + + const fileInput = getAttachmentFileInput() + const firstFile = new File(['screenshot 1'], 'error-screenshot.png', { type: 'image/png' }) + const secondFile = new File(['screenshot 2'], 'logs-screenshot.jpg', { type: 'image/jpeg' }) + await userEvent.upload(fileInput, [firstFile, secondFile]) + + await waitFor(() => { + expect(getAttachmentRemoveButtons(screen)).toHaveLength(2) + }) + + const supportAccessToggle = screen.getByRole('switch', { + name: /allow support access to your project/i, + }) + expect(supportAccessToggle).toBeChecked() + + await userEvent.click(getSubmitButton(screen)) + + await waitFor(() => { + expect(submitSpy).toHaveBeenCalledTimes(1) + }) + + const payload = submitSpy.mock.calls[0]?.[0] + expect(payload).toMatchObject({ + subject: 'Query timeouts after maintenance', + category: 'Database_unresponsive', + severity: 'High', + projectRef: 'project-1', + organizationSlug: 'org-1', + library: '', + affectedServices: '', + allowSupportAccess: true, + verified: true, + tags: ['dashboard-support-form'], + siteUrl: 'https://project-1.example.com', + additionalRedirectUrls: 'https://project-1.example.com/auth', + browserInformation: 'Chrome', + }) + + // Verify that attachment URLs are included in the message + expect(payload.message).toContain('All queries timing out after scheduled maintenance window') + expect(payload.message).toContain(signedUrls[0]) + expect(payload.message).toContain(signedUrls[1]) + + await waitFor(() => { + expect(screen.getByRole('heading', { name: /success/i })).toBeInTheDocument() + }) + } finally { + unmount?.() + url.createObjectURL = originalCreateObjectURL + url.revokeObjectURL = originalRevokeObjectURL + vi.mocked(createSupportStorageClient).mockReset() + } + }, 10_000) + + test('can submit form with no organizations and no projects', async () => { + const submitSpy = vi.fn() + + addAPIMock({ + method: 'get', + path: '/platform/organizations', + response: [], + }) + + addAPIMock({ + method: 'get', + path: '/platform/projects', + response: [], + }) + + addAPIMock({ + method: 'post', + path: '/platform/feedback/send', + response: async ({ request }) => { + submitSpy(await request.json()) + return HttpResponse.json({ ok: true }) + }, + }) + + renderSupportFormPage() + + await waitFor(() => { + expect(getOrganizationSelector(screen)).toHaveTextContent('No specific organization') + }) + await waitFor(() => { + expect(getProjectSelector(screen)).toHaveTextContent('No specific project') + }) + + await selectCategoryOption(screen, 'Dashboard bug') + await waitFor(() => { + expect(getCategorySelector(screen)).toHaveTextContent('Dashboard bug') + }) + + await userEvent.type(getSummaryField(screen), 'Cannot access my account') + await userEvent.type(getMessageField(screen), 'I need help accessing my Supabase account') + + await userEvent.click(getSubmitButton(screen)) + + await waitFor(() => { + expect(submitSpy).toHaveBeenCalledTimes(1) + }) + + const payload = submitSpy.mock.calls[0]?.[0] + expect(payload).toMatchObject({ + subject: 'Cannot access my account', + message: 'I need help accessing my Supabase account', + category: 'Dashboard_bug', + projectRef: NO_PROJECT_MARKER, + organizationSlug: NO_ORG_MARKER, + library: '', + affectedServices: '', + allowSupportAccess: false, + verified: true, + tags: ['dashboard-support-form'], + browserInformation: 'Chrome', + }) + + await waitFor(() => { + expect(screen.getByRole('heading', { name: /success/i })).toBeInTheDocument() + }) + }, 10_000) +}) diff --git a/apps/studio/data/support/generate-attachment-urls-mutation.ts b/apps/studio/data/support/generate-attachment-urls-mutation.ts new file mode 100644 index 0000000000000..e8144fd7be550 --- /dev/null +++ b/apps/studio/data/support/generate-attachment-urls-mutation.ts @@ -0,0 +1,68 @@ +import { useMutation, UseMutationOptions } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { constructHeaders } from 'data/fetchers' +import { BASE_PATH } from 'lib/constants' +import { ResponseError } from 'types' + +export type GenerateAttachmentURLsResponse = { + title: string + description: string +} + +export type GenerateAttachmentURLsVariables = { + filenames: string[] +} + +export async function generateAttachmentURLs({ filenames }: GenerateAttachmentURLsVariables) { + const headers = await constructHeaders() + + try { + const response = await fetch(`${BASE_PATH}/api/generate-attachment-url`, { + method: 'POST', + headers, + body: JSON.stringify({ filenames }), + }) + + if (!response.ok) { + const status = response.status + const message = await response.text() + throw new Error(`Failed to generate attachment URLs at endpoint: ${status} ${message}`) + } + + const signedUrls = await response.json() + return signedUrls as string[] + } catch (error: any) { + // [Joshen] Should throw an error i think but doing so causes some errors in the unit tests which i'm not exactly sure how to resolve + // [MSW] Cannot bypass a request when using the "error" strategy for the "onUnhandledRequest" option. + return [] + } +} + +type GenerateAttachmentURLsData = Awaited> + +export const useGenerateAttachmentURLsMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + return useMutation( + (vars) => generateAttachmentURLs(vars), + { + async onSuccess(data, variables, context) { + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to generate attachment URLS: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + } + ) +} diff --git a/apps/studio/lib/api/apiAuthenticate.ts b/apps/studio/lib/api/apiAuthenticate.ts index 02922dfd80ce2..4b34341a37f0e 100644 --- a/apps/studio/lib/api/apiAuthenticate.ts +++ b/apps/studio/lib/api/apiAuthenticate.ts @@ -33,7 +33,7 @@ export async function apiAuthenticate( * @returns * user with only id prop or detail object. It depends on requireUserDetail config */ -async function fetchUserClaims(req: NextApiRequest): Promise { +export async function fetchUserClaims(req: NextApiRequest): Promise { const token = req.headers.authorization?.replace(/bearer /i, '') if (!token) { throw new Error('missing access token') diff --git a/apps/studio/middleware.ts b/apps/studio/middleware.ts index f531e68729188..7b894c8de9270 100644 --- a/apps/studio/middleware.ts +++ b/apps/studio/middleware.ts @@ -21,6 +21,7 @@ const HOSTED_SUPPORTED_API_URLS = [ '/check-cname', '/edge-functions/test', '/edge-functions/body', + '/generate-attachment-url', ] export function middleware(request: NextRequest) { diff --git a/apps/studio/pages/api/generate-attachment-url.ts b/apps/studio/pages/api/generate-attachment-url.ts new file mode 100644 index 0000000000000..371aaecbf3f66 --- /dev/null +++ b/apps/studio/pages/api/generate-attachment-url.ts @@ -0,0 +1,83 @@ +import { createClient } from '@supabase/supabase-js' +import type { NextApiRequest, NextApiResponse } from 'next' +import z from 'zod' + +import apiWrapper from 'lib/api/apiWrapper' +import { getUserClaims } from 'lib/gotrue' + +export const maxDuration = 120 + +const GenerateAttachmentUrlSchema = z.object({ + filenames: z.array(z.string()), +}) + +async function handlePost(req: NextApiRequest, res: NextApiResponse) { + const { claims, error: userClaimsError } = await getUserClaims(req.headers.authorization!) + if (userClaimsError || !claims) { + return res.status(401).json({ error: { message: 'Unauthorized' } }) + } + const userId = claims.sub + + const json = JSON.parse(req.body) + const parseResult = GenerateAttachmentUrlSchema.safeParse(json) + if (!parseResult.success) { + return res.status(400).json({ error: { message: 'Invalid request body' } }) + } + const filenames = parseResult.data.filenames + + const requestedPrefixes = [...new Set(filenames.map((filename) => filename.split('/')[0]))] + if (requestedPrefixes.some((prefix) => prefix !== userId)) { + return res + .status(403) + .json({ error: { message: 'Forbidden: Users can only access their own resources' } }) + } + + const adminSupabase = createClient( + process.env.NEXT_PUBLIC_SUPPORT_API_URL!, + process.env.SUPPORT_SUPABASE_SECRET_KEY!, + { + auth: { + persistSession: false, + autoRefreshToken: false, + // @ts-expect-error + multiTab: false, + detectSessionInUrl: false, + localStorage: { + getItem: (_key: string) => undefined, + setItem: (_key: string, _value: string) => {}, + removeItem: (_key: string) => {}, + }, + }, + } + ) + + // Create signed URLs for 10 years + const { data, error: signedUrlError } = await adminSupabase.storage + .from('support-attachments') + .createSignedUrls(filenames, 10 * 365 * 24 * 60 * 60) + if (signedUrlError) { + console.error('Failed to sign URLs for attachments', signedUrlError) + return res.status(500).json({ error: { message: 'Failed to sign URLs for attachments' } }) + } + return res.status(200).json(data ? data.map((file) => file.signedUrl) : []) +} + +async function handler(req: NextApiRequest, res: NextApiResponse) { + const { method } = req + + switch (method) { + case 'POST': + return handlePost(req, res) + default: + res.setHeader('Allow', ['POST']) + res.status(405).json({ + data: null, + error: { message: `Method ${method} Not Allowed` }, + }) + } +} + +const wrapper = (req: NextApiRequest, res: NextApiResponse) => + apiWrapper(req, res, handler, { withAuth: true }) + +export default wrapper diff --git a/turbo.json b/turbo.json index 5ae3d625e223f..27e175cb07e73 100644 --- a/turbo.json +++ b/turbo.json @@ -113,6 +113,7 @@ "IS_THROTTLED", "AI_PRO_MODEL", "AI_NORMAL_MODEL", + "SUPPORT_SUPABASE_SECRET_KEY", "NIMBUS_PROD_PROJECTS_URL" ], "passThroughEnv": ["CURRENT_CLI_VERSION", "VERCEL_GIT_COMMIT_SHA"], From 8dc5ee77f33910a374aa42d34962c52699499573 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Wed, 15 Oct 2025 17:27:20 +0800 Subject: [PATCH 4/7] Clean up support upload flag (#39550) --- .../interfaces/Support/AttachmentUpload.tsx | 14 -------------- .../Support/__tests__/SupportFormPage.test.tsx | 1 - 2 files changed, 15 deletions(-) diff --git a/apps/studio/components/interfaces/Support/AttachmentUpload.tsx b/apps/studio/components/interfaces/Support/AttachmentUpload.tsx index a1713ff2218d3..d875d3ee46c1e 100644 --- a/apps/studio/components/interfaces/Support/AttachmentUpload.tsx +++ b/apps/studio/components/interfaces/Support/AttachmentUpload.tsx @@ -12,7 +12,6 @@ import { import { toast } from 'sonner' // End of third-party imports -import { useFlag } from 'common' import { useGenerateAttachmentURLsMutation } from 'data/support/generate-attachment-urls-mutation' import { uuidv4 } from 'lib/helpers' import { useProfile } from 'lib/profile' @@ -143,19 +142,6 @@ export function AttachmentUploadDisplay({ removeFileUpload, }: AttachmentUploadDisplayProps) { const { profile } = useProfile() - const enableUploads = useFlag('supportFormAttachments') - - if (!enableUploads) { - return ( -
-

Attachments

-

- Uploads are temporarily disabled. Please reply to the acknowledgement email you will - receive with any screenshots you'd like to upload. -

-
- ) - } if (!profile) { return ( diff --git a/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx b/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx index 4daa0e2e14c8a..69e55cd24cd57 100644 --- a/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx +++ b/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx @@ -93,7 +93,6 @@ vi.mock(import('common'), async (importOriginal) => { }) return { ...actual, - useFlag: vi.fn().mockReturnValue(true), useParams: vi.fn().mockReturnValue({ ref: 'default' }), useIsLoggedIn: vi.fn().mockReturnValue(true), isFeatureEnabled: vi.fn((feature: any, disabledFeatures: any) => { From 2b512439b251b75713a03dea5bb8c9dc93d59a46 Mon Sep 17 00:00:00 2001 From: issuedat <165281975+issuedat@users.noreply.github.com> Date: Wed, 15 Oct 2025 05:28:36 -0400 Subject: [PATCH 5/7] chore: add telemetry for users search (#39409) --- .../interfaces/Auth/Users/SortDropdown.tsx | 4 +- .../interfaces/Auth/Users/UsersSearch.tsx | 2 +- .../interfaces/Auth/Users/UsersV2.tsx | 82 ++++++++++++++++++- packages/common/telemetry-constants.ts | 49 +++++++++++ 4 files changed, 130 insertions(+), 7 deletions(-) diff --git a/apps/studio/components/interfaces/Auth/Users/SortDropdown.tsx b/apps/studio/components/interfaces/Auth/Users/SortDropdown.tsx index 1d7a29d3ce667..6a03ed5c833ad 100644 --- a/apps/studio/components/interfaces/Auth/Users/SortDropdown.tsx +++ b/apps/studio/components/interfaces/Auth/Users/SortDropdown.tsx @@ -44,8 +44,8 @@ export const SortDropdown = ({ className: 'w-80 text-center', text: ( <> - Sorting cannot be changed which searching on a specific column. If you'd like to - sort on other columns, change the search to{' '} + Sorting cannot be changed when searching on a specific column. If you'd like to sort + on other columns, change the search to{' '} all columns from the header. ), diff --git a/apps/studio/components/interfaces/Auth/Users/UsersSearch.tsx b/apps/studio/components/interfaces/Auth/Users/UsersSearch.tsx index 9726cc45d60ff..44d5ffc2b2186 100644 --- a/apps/studio/components/interfaces/Auth/Users/UsersSearch.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UsersSearch.tsx @@ -19,7 +19,7 @@ interface UsersSearchProps { searchInvalid: boolean specificFilterColumn: 'id' | 'email' | 'phone' | 'freeform' setSearch: (value: SetStateAction) => void - setFilterKeywords: (value: SetStateAction) => void + setFilterKeywords: (value: string) => void setSpecificFilterColumn: (value: 'id' | 'email' | 'phone' | 'freeform') => void } diff --git a/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx b/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx index 3bf137296a391..3eda04f696287 100644 --- a/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx @@ -18,6 +18,7 @@ import { User, useUsersInfiniteQuery } from 'data/auth/users-infinite-query' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { cleanPointerEventsNoneOnBody, isAtBottom } from 'lib/helpers' import { Button, @@ -50,14 +51,17 @@ import { import { formatUserColumns, formatUsersData } from './Users.utils' import { UsersFooter } from './UsersFooter' import { UsersSearch } from './UsersSearch' +import { useSendEventMutation } from 'data/telemetry/send-event-mutation' export const UsersV2 = () => { const queryClient = useQueryClient() const { ref: projectRef } = useParams() const { data: project } = useSelectedProjectQuery() + const { data: selectedOrg } = useSelectedOrganizationQuery() const gridRef = useRef(null) const xScroll = useRef(0) const isNewAPIDocsEnabled = useIsAPIDocsSidePanelEnabled() + const { mutate: sendEvent } = useSendEventMutation() const { authenticationShowProviderFilter: showProviderFilter, @@ -151,6 +155,19 @@ export const UsersV2 = () => { // [Joshen] Only relevant for when selecting one user only const selectedUserFromCheckbox = users.find((u) => u.id === [...selectedUsers][0]) + const telemetryProps = { + sort_column: sortColumn, + sort_order: sortOrder, + providers: selectedProviders, + user_type: filter === 'all' ? undefined : filter, + keywords: filterKeywords, + filter_column: specificFilterColumn === 'freeform' ? undefined : specificFilterColumn, + } + const telemetryGroups = { + project: projectRef ?? 'Unknown', + organization: selectedOrg?.slug ?? 'Unknown', + } + const handleScroll = (event: UIEvent) => { const isScrollingHorizontally = xScroll.current !== event.currentTarget.scrollLeft xScroll.current = event.currentTarget.scrollLeft @@ -293,6 +310,15 @@ export const UsersV2 = () => { setFilterKeywords={(s) => { setFilterKeywords(s) setSelectedUser(undefined) + sendEvent({ + action: 'auth_users_search_submitted', + properties: { + trigger: 'search_input', + ...telemetryProps, + keywords: s, + }, + groups: telemetryGroups, + }) }} setSpecificFilterColumn={(value) => { if (value === 'freeform') { @@ -304,7 +330,21 @@ export const UsersV2 = () => { /> {showUserTypeFilter && specificFilterColumn === 'freeform' && ( - setFilter(val as Filter)}> + { + setFilter(val as Filter) + sendEvent({ + action: 'auth_users_search_submitted', + properties: { + trigger: 'user_type_filter', + ...telemetryProps, + user_type: val, + }, + groups: telemetryGroups, + }) + }} + > { labelClass="text-xs" maxHeightClass="h-[190px]" className="w-52" - onSaveFilters={setSelectedProviders} + onSaveFilters={(providers) => { + setSelectedProviders(providers) + sendEvent({ + action: 'auth_users_search_submitted', + properties: { + trigger: 'provider_filter', + ...telemetryProps, + providers, + }, + groups: telemetryGroups, + }) + }} /> )} @@ -402,7 +453,20 @@ export const UsersV2 = () => { sortColumn={sortColumn} sortOrder={sortOrder} sortByValue={sortByValue} - setSortByValue={setSortByValue} + setSortByValue={(value) => { + const [sortColumn, sortOrder] = value.split(':') + setSortByValue(value) + sendEvent({ + action: 'auth_users_search_submitted', + properties: { + trigger: 'sort_change', + ...telemetryProps, + sort_column: sortColumn, + sort_order: sortOrder, + }, + groups: telemetryGroups, + }) + }} showSortByEmail={showSortByEmail} showSortByPhone={showSortByPhone} /> @@ -418,7 +482,17 @@ export const UsersV2 = () => { type="default" className="w-7" loading={isRefetching && !isFetchingNextPage} - onClick={() => refetch()} + onClick={() => { + refetch() + sendEvent({ + action: 'auth_users_search_submitted', + properties: { + trigger: 'refresh_button', + ...telemetryProps, + }, + groups: telemetryGroups, + }) + }} tooltip={{ content: { side: 'bottom', text: 'Refresh' } }} /> diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index a0838b8d702ec..497ec44f9de42 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -1901,6 +1901,54 @@ export interface ApiDocsCodeCopyButtonClickedEvent { groups: Partial } +/** + * User performed a search via the Auth Users page. + * + * @group Events + * @source studio + * @page /dashboard/project/{ref}/auth/users + */ +export interface AuthUsersSearchSubmittedEvent { + action: 'auth_users_search_submitted' + properties: { + /** + * The trigger that initiated the search + */ + trigger: + | 'search_input' + | 'refresh_button' + | 'sort_change' + | 'provider_filter' + | 'user_type_filter' + /** + * The column being sorted on, e.g. email, phone, created_at, last_sign_in_at + */ + sort_column: string + /** + * The sort order, either ascending or descending + */ + sort_order: string + /** + * The authentication provider(s) being filtered on, e.g. email, phone, google, github + */ + providers?: string[] + /** + * The user role(s) being filtered on, e.g. verified, unverified, anonymous + */ + user_type?: string + /** + * The keywords being searched for + */ + keywords?: string + /** + * The column being filtered on, e.g. email, phone + * (only included if filtering by a specific column and not all columns) + */ + filter_column?: string + } + groups: TelemetryGroups +} + /** * @hidden */ @@ -2014,3 +2062,4 @@ export type TelemetryEvent = | TableCreatedEvent | TableDataAddedEvent | TableRLSEnabledEvent + | AuthUsersSearchSubmittedEvent From 06fa29dbb84a32cafc3a9bac316f5a19be647f64 Mon Sep 17 00:00:00 2001 From: Eduardo Gurgel Date: Wed, 15 Oct 2025 22:52:41 +1300 Subject: [PATCH 6/7] feat(ui-library): improve realtime blocks (#39157) * fix(ui library): fix presence room to clear stack on subscription error * fix(ui library): fix realtime chat on subscription error * feat(ui library): improve realtime cursors joining, leaving and disconnecting Presence is used now to react to cursors joining and leaving. * When a cursor joins all the other cursors broadcast their last position (if available). This avoids an empty page when joining if cursors are not moving * When a cursor leaves the room their respective cursor is removed And finally the cursor position is broadcasted only the channel is successfully subscribed. Otherwise it might try to send POST requests to broadcast outside the websocket * chore(ui library): update registry & llms * chore: prettier * fix(ui-library): fix missing username on cursor * chore(ui-library): npm run build --- apps/ui-library/public/llms.txt | 6 +-- .../public/r/infinite-query-hook.json | 2 +- .../r/realtime-avatar-stack-nextjs.json | 2 +- .../r/realtime-avatar-stack-react-router.json | 2 +- .../public/r/realtime-avatar-stack-react.json | 2 +- .../r/realtime-avatar-stack-tanstack.json | 2 +- .../public/r/realtime-chat-nextjs.json | 2 +- .../public/r/realtime-chat-react-router.json | 2 +- .../public/r/realtime-chat-react.json | 2 +- .../public/r/realtime-chat-tanstack.json | 2 +- .../public/r/realtime-cursor-nextjs.json | 2 +- .../r/realtime-cursor-react-router.json | 2 +- .../public/r/realtime-cursor-react.json | 2 +- .../public/r/realtime-cursor-tanstack.json | 2 +- .../hooks/use-realtime-presence-room.ts | 15 +++---- .../realtime-chat/hooks/use-realtime-chat.tsx | 2 + .../hooks/use-realtime-cursors.ts | 39 +++++++++++++++++-- .../examples/realtime-avatar-stack-demo.tsx | 15 +++---- 18 files changed, 70 insertions(+), 33 deletions(-) diff --git a/apps/ui-library/public/llms.txt b/apps/ui-library/public/llms.txt index 15deae095cc79..bea8b27be06d4 100644 --- a/apps/ui-library/public/llms.txt +++ b/apps/ui-library/public/llms.txt @@ -1,5 +1,5 @@ # Supabase UI Library -Last updated: 2025-08-27T11:26:46.645Z +Last updated: 2025-10-15T07:54:48.550Z ## Overview Library of components for your project. The components integrate with Supabase and are shadcn compatible. @@ -31,6 +31,8 @@ Library of components for your project. The components integrate with Supabase a - Real-time cursor sharing for collaborative applications - [Social Authentication](https://supabase.com/ui/docs/nextjs/social-auth) - Social authentication block for Next.js +- [Supabase Client Libraries](https://supabase.com/ui/docs/nuxtjs/client) + - Supabase client for Nuxt.js - [Platform Kit](https://supabase.com/ui/docs/platform/platform-kit) - The easiest way to build platforms on top of Supabase - [Supabase Client Libraries](https://supabase.com/ui/docs/react-router/client) @@ -83,5 +85,3 @@ Library of components for your project. The components integrate with Supabase a - Social authentication block for Tanstack Start - [Supabase Client Libraries](https://supabase.com/ui/docs/vue/client) - Supabase client for Vue Single Page Applications -- [Supabase Client Libraries](https://supabase.com/ui/docs/nuxtjs/client) - - Supabase client for Nuxt.js diff --git a/apps/ui-library/public/r/infinite-query-hook.json b/apps/ui-library/public/r/infinite-query-hook.json index ca7a0e25c1975..d795b7b6e1adc 100644 --- a/apps/ui-library/public/r/infinite-query-hook.json +++ b/apps/ui-library/public/r/infinite-query-hook.json @@ -12,7 +12,7 @@ "files": [ { "path": "registry/default/blocks/infinite-query-hook/hooks/use-infinite-query.ts", - "content": "'use client'\n\nimport { createClient } from '@/registry/default/fixtures/lib/supabase/client'\nimport { PostgrestQueryBuilder } from '@supabase/postgrest-js'\nimport { SupabaseClient } from '@supabase/supabase-js'\nimport { useEffect, useRef, useSyncExternalStore } from 'react'\n\nconst supabase = createClient()\n\n// The following types are used to make the hook type-safe. It extracts the database type from the supabase client.\ntype SupabaseClientType = typeof supabase\n\n// Utility type to check if the type is any\ntype IfAny = 0 extends 1 & T ? Y : N\n\n// Extracts the database type from the supabase client. If the supabase client doesn't have a type, it will fallback properly.\ntype Database =\n SupabaseClientType extends SupabaseClient\n ? IfAny<\n U,\n {\n public: {\n Tables: Record\n Views: Record\n Functions: Record\n }\n },\n U\n >\n : never\n\n// Change this to the database schema you want to use\ntype DatabaseSchema = Database['public']\n\n// Extracts the table names from the database type\ntype SupabaseTableName = keyof DatabaseSchema['Tables']\n\n// Extracts the table definition from the database type\ntype SupabaseTableData = DatabaseSchema['Tables'][T]['Row']\n\ntype SupabaseSelectBuilder = ReturnType<\n PostgrestQueryBuilder['select']\n>\n\n// A function that modifies the query. Can be used to sort, filter, etc. If .range is used, it will be overwritten.\ntype SupabaseQueryHandler = (\n query: SupabaseSelectBuilder\n) => SupabaseSelectBuilder\n\ninterface UseInfiniteQueryProps {\n // The table name to query\n tableName: T\n // The columns to select, defaults to `*`\n columns?: string\n // The number of items to fetch per page, defaults to `20`\n pageSize?: number\n // A function that modifies the query. Can be used to sort, filter, etc. If .range is used, it will be overwritten.\n trailingQuery?: SupabaseQueryHandler\n}\n\ninterface StoreState {\n data: TData[]\n count: number\n isSuccess: boolean\n isLoading: boolean\n isFetching: boolean\n error: Error | null\n hasInitialFetch: boolean\n}\n\ntype Listener = () => void\n\nfunction createStore, T extends SupabaseTableName>(\n props: UseInfiniteQueryProps\n) {\n const { tableName, columns = '*', pageSize = 20, trailingQuery } = props\n\n let state: StoreState = {\n data: [],\n count: 0,\n isSuccess: false,\n isLoading: false,\n isFetching: false,\n error: null,\n hasInitialFetch: false,\n }\n\n const listeners = new Set()\n\n const notify = () => {\n listeners.forEach((listener) => listener())\n }\n\n const setState = (newState: Partial>) => {\n state = { ...state, ...newState }\n notify()\n }\n\n const fetchPage = async (skip: number) => {\n if (state.hasInitialFetch && (state.isFetching || state.count <= state.data.length)) return\n\n setState({ isFetching: true })\n\n let query = supabase\n .from(tableName)\n .select(columns, { count: 'exact' }) as unknown as SupabaseSelectBuilder\n\n if (trailingQuery) {\n query = trailingQuery(query)\n }\n const { data: newData, count, error } = await query.range(skip, skip + pageSize - 1)\n\n if (error) {\n console.error('An unexpected error occurred:', error)\n setState({ error })\n } else {\n const deduplicatedData = ((newData || []) as TData[]).filter(\n (item) => !state.data.find((old) => old.id === item.id)\n )\n\n setState({\n data: [...state.data, ...deduplicatedData],\n count: count || 0,\n isSuccess: true,\n error: null,\n })\n }\n setState({ isFetching: false })\n }\n\n const fetchNextPage = async () => {\n if (state.isFetching) return\n await fetchPage(state.data.length)\n }\n\n const initialize = async () => {\n setState({ isLoading: true, isSuccess: false, data: [] })\n await fetchNextPage()\n setState({ isLoading: false, hasInitialFetch: true })\n }\n\n return {\n getState: () => state,\n subscribe: (listener: Listener) => {\n listeners.add(listener)\n return () => listeners.delete(listener)\n },\n fetchNextPage,\n initialize,\n }\n}\n\n// Empty initial state to avoid hydration errors.\nconst initialState: any = {\n data: [],\n count: 0,\n isSuccess: false,\n isLoading: false,\n isFetching: false,\n error: null,\n hasInitialFetch: false,\n}\n\nfunction useInfiniteQuery<\n TData extends SupabaseTableData,\n T extends SupabaseTableName = SupabaseTableName,\n>(props: UseInfiniteQueryProps) {\n const storeRef = useRef(createStore(props))\n\n const state = useSyncExternalStore(\n storeRef.current.subscribe,\n () => storeRef.current.getState(),\n () => initialState as StoreState\n )\n\n useEffect(() => {\n // Recreate store if props change\n if (\n storeRef.current.getState().hasInitialFetch &&\n (props.tableName !== props.tableName ||\n props.columns !== props.columns ||\n props.pageSize !== props.pageSize)\n ) {\n storeRef.current = createStore(props)\n }\n\n if (!state.hasInitialFetch && typeof window !== 'undefined') {\n storeRef.current.initialize()\n }\n }, [props.tableName, props.columns, props.pageSize, state.hasInitialFetch])\n\n return {\n data: state.data,\n count: state.count,\n isSuccess: state.isSuccess,\n isLoading: state.isLoading,\n isFetching: state.isFetching,\n error: state.error,\n hasMore: state.count > state.data.length,\n fetchNextPage: storeRef.current.fetchNextPage,\n }\n}\n\nexport {\n useInfiniteQuery,\n type SupabaseQueryHandler,\n type SupabaseTableData,\n type SupabaseTableName,\n type UseInfiniteQueryProps,\n}\n", + "content": "'use client'\n\nimport { createClient } from '@/registry/default/fixtures/lib/supabase/client'\nimport { PostgrestQueryBuilder } from '@supabase/postgrest-js'\nimport { type SupabaseClient } from '@supabase/supabase-js'\nimport { useEffect, useRef, useSyncExternalStore } from 'react'\n\nconst supabase = createClient()\n\n// The following types are used to make the hook type-safe. It extracts the database type from the supabase client.\ntype SupabaseClientType = typeof supabase\n\n// Utility type to check if the type is any\ntype IfAny = 0 extends 1 & T ? Y : N\n\n// Extracts the database type from the supabase client. If the supabase client doesn't have a type, it will fallback properly.\ntype Database =\n SupabaseClientType extends SupabaseClient\n ? IfAny<\n U,\n {\n public: {\n Tables: Record\n Views: Record\n Functions: Record\n }\n },\n U\n >\n : {\n public: {\n Tables: Record\n Views: Record\n Functions: Record\n }\n }\n\n// Change this to the database schema you want to use\ntype DatabaseSchema = Database['public']\n\n// Extracts the table names from the database type\ntype SupabaseTableName = keyof DatabaseSchema['Tables']\n\n// Extracts the table definition from the database type\ntype SupabaseTableData = DatabaseSchema['Tables'][T]['Row']\n\ntype SupabaseSelectBuilder = ReturnType<\n PostgrestQueryBuilder['select']\n>\n\n// A function that modifies the query. Can be used to sort, filter, etc. If .range is used, it will be overwritten.\ntype SupabaseQueryHandler = (\n query: SupabaseSelectBuilder\n) => SupabaseSelectBuilder\n\ninterface UseInfiniteQueryProps {\n // The table name to query\n tableName: T\n // The columns to select, defaults to `*`\n columns?: string\n // The number of items to fetch per page, defaults to `20`\n pageSize?: number\n // A function that modifies the query. Can be used to sort, filter, etc. If .range is used, it will be overwritten.\n trailingQuery?: SupabaseQueryHandler\n}\n\ninterface StoreState {\n data: TData[]\n count: number\n isSuccess: boolean\n isLoading: boolean\n isFetching: boolean\n error: Error | null\n hasInitialFetch: boolean\n}\n\ntype Listener = () => void\n\nfunction createStore, T extends SupabaseTableName>(\n props: UseInfiniteQueryProps\n) {\n const { tableName, columns = '*', pageSize = 20, trailingQuery } = props\n\n let state: StoreState = {\n data: [],\n count: 0,\n isSuccess: false,\n isLoading: false,\n isFetching: false,\n error: null,\n hasInitialFetch: false,\n }\n\n const listeners = new Set()\n\n const notify = () => {\n listeners.forEach((listener) => listener())\n }\n\n const setState = (newState: Partial>) => {\n state = { ...state, ...newState }\n notify()\n }\n\n const fetchPage = async (skip: number) => {\n if (state.hasInitialFetch && (state.isFetching || state.count <= state.data.length)) return\n\n setState({ isFetching: true })\n\n let query = supabase\n .from(tableName)\n .select(columns, { count: 'exact' }) as unknown as SupabaseSelectBuilder\n\n if (trailingQuery) {\n query = trailingQuery(query)\n }\n const { data: newData, count, error } = await query.range(skip, skip + pageSize - 1)\n\n if (error) {\n console.error('An unexpected error occurred:', error)\n setState({ error })\n } else {\n setState({\n data: [...state.data, ...(newData as TData[])],\n count: count || 0,\n isSuccess: true,\n error: null,\n })\n }\n setState({ isFetching: false })\n }\n\n const fetchNextPage = async () => {\n if (state.isFetching) return\n await fetchPage(state.data.length)\n }\n\n const initialize = async () => {\n setState({ isLoading: true, isSuccess: false, data: [] })\n await fetchNextPage()\n setState({ isLoading: false, hasInitialFetch: true })\n }\n\n return {\n getState: () => state,\n subscribe: (listener: Listener) => {\n listeners.add(listener)\n return () => listeners.delete(listener)\n },\n fetchNextPage,\n initialize,\n }\n}\n\n// Empty initial state to avoid hydration errors.\nconst initialState: any = {\n data: [],\n count: 0,\n isSuccess: false,\n isLoading: false,\n isFetching: false,\n error: null,\n hasInitialFetch: false,\n}\n\nfunction useInfiniteQuery<\n TData extends SupabaseTableData,\n T extends SupabaseTableName = SupabaseTableName,\n>(props: UseInfiniteQueryProps) {\n const storeRef = useRef(createStore(props))\n\n const state = useSyncExternalStore(\n storeRef.current.subscribe,\n () => storeRef.current.getState(),\n () => initialState as StoreState\n )\n\n useEffect(() => {\n // Recreate store if props change\n if (\n storeRef.current.getState().hasInitialFetch &&\n (props.tableName !== props.tableName ||\n props.columns !== props.columns ||\n props.pageSize !== props.pageSize)\n ) {\n storeRef.current = createStore(props)\n }\n\n if (!state.hasInitialFetch && typeof window !== 'undefined') {\n storeRef.current.initialize()\n }\n }, [props.tableName, props.columns, props.pageSize, state.hasInitialFetch])\n\n return {\n data: state.data,\n count: state.count,\n isSuccess: state.isSuccess,\n isLoading: state.isLoading,\n isFetching: state.isFetching,\n error: state.error,\n hasMore: state.count > state.data.length,\n fetchNextPage: storeRef.current.fetchNextPage,\n }\n}\n\nexport {\n useInfiniteQuery,\n type SupabaseQueryHandler,\n type SupabaseTableData,\n type SupabaseTableName,\n type UseInfiniteQueryProps,\n}\n", "type": "registry:hook" } ] diff --git a/apps/ui-library/public/r/realtime-avatar-stack-nextjs.json b/apps/ui-library/public/r/realtime-avatar-stack-nextjs.json index d94f19ce75893..eeb408df5592e 100644 --- a/apps/ui-library/public/r/realtime-avatar-stack-nextjs.json +++ b/apps/ui-library/public/r/realtime-avatar-stack-nextjs.json @@ -25,7 +25,7 @@ }, { "path": "registry/default/blocks/realtime-avatar-stack/hooks/use-realtime-presence-room.ts", - "content": "'use client'\n\nimport { useCurrentUserImage } from '@/registry/default/blocks/current-user-avatar/hooks/use-current-user-image'\nimport { useCurrentUserName } from '@/registry/default/blocks/current-user-avatar/hooks/use-current-user-name'\nimport { createClient } from '@/registry/default/clients/nextjs/lib/supabase/client'\nimport { useEffect, useState } from 'react'\n\nconst supabase = createClient()\n\nexport type RealtimeUser = {\n id: string\n name: string\n image: string\n}\n\nexport const useRealtimePresenceRoom = (roomName: string) => {\n const currentUserImage = useCurrentUserImage()\n const currentUserName = useCurrentUserName()\n\n const [users, setUsers] = useState>({})\n\n useEffect(() => {\n const room = supabase.channel(roomName)\n\n room\n .on('presence', { event: 'sync' }, () => {\n const newState = room.presenceState<{ image: string; name: string }>()\n\n const newUsers = Object.fromEntries(\n Object.entries(newState).map(([key, values]) => [\n key,\n { name: values[0].name, image: values[0].image },\n ])\n ) as Record\n setUsers(newUsers)\n })\n .subscribe(async (status) => {\n if (status !== 'SUBSCRIBED') {\n return\n }\n\n await room.track({\n name: currentUserName,\n image: currentUserImage,\n })\n })\n\n return () => {\n room.unsubscribe()\n }\n }, [roomName, currentUserName, currentUserImage])\n\n return { users }\n}\n", + "content": "'use client'\n\nimport { useCurrentUserImage } from '@/registry/default/blocks/current-user-avatar/hooks/use-current-user-image'\nimport { useCurrentUserName } from '@/registry/default/blocks/current-user-avatar/hooks/use-current-user-name'\nimport { createClient } from '@/registry/default/clients/nextjs/lib/supabase/client'\nimport { REALTIME_SUBSCRIBE_STATES } from '@supabase/supabase-js'\nimport { useEffect, useState } from 'react'\n\nconst supabase = createClient()\n\nexport type RealtimeUser = {\n id: string\n name: string\n image: string\n}\n\nexport const useRealtimePresenceRoom = (roomName: string) => {\n const currentUserImage = useCurrentUserImage()\n const currentUserName = useCurrentUserName()\n\n const [users, setUsers] = useState>({})\n\n useEffect(() => {\n const room = supabase.channel(roomName)\n\n room\n .on('presence', { event: 'sync' }, () => {\n const newState = room.presenceState<{ image: string; name: string }>()\n\n const newUsers = Object.fromEntries(\n Object.entries(newState).map(([key, values]) => [\n key,\n { name: values[0].name, image: values[0].image },\n ])\n ) as Record\n setUsers(newUsers)\n })\n .subscribe(async (status) => {\n if (status === REALTIME_SUBSCRIBE_STATES.SUBSCRIBED) {\n await room.track({\n name: currentUserName,\n image: currentUserImage,\n })\n } else {\n setUsers({})\n }\n })\n\n return () => {\n room.unsubscribe()\n }\n }, [roomName, currentUserName, currentUserImage])\n\n return { users }\n}\n", "type": "registry:hook" }, { diff --git a/apps/ui-library/public/r/realtime-avatar-stack-react-router.json b/apps/ui-library/public/r/realtime-avatar-stack-react-router.json index 7d6a86a22879e..2a6894e715b28 100644 --- a/apps/ui-library/public/r/realtime-avatar-stack-react-router.json +++ b/apps/ui-library/public/r/realtime-avatar-stack-react-router.json @@ -25,7 +25,7 @@ }, { "path": "registry/default/blocks/realtime-avatar-stack/hooks/use-realtime-presence-room.ts", - "content": "'use client'\n\nimport { useCurrentUserImage } from '@/registry/default/blocks/current-user-avatar/hooks/use-current-user-image'\nimport { useCurrentUserName } from '@/registry/default/blocks/current-user-avatar/hooks/use-current-user-name'\nimport { createClient } from '@/registry/default/clients/nextjs/lib/supabase/client'\nimport { useEffect, useState } from 'react'\n\nconst supabase = createClient()\n\nexport type RealtimeUser = {\n id: string\n name: string\n image: string\n}\n\nexport const useRealtimePresenceRoom = (roomName: string) => {\n const currentUserImage = useCurrentUserImage()\n const currentUserName = useCurrentUserName()\n\n const [users, setUsers] = useState>({})\n\n useEffect(() => {\n const room = supabase.channel(roomName)\n\n room\n .on('presence', { event: 'sync' }, () => {\n const newState = room.presenceState<{ image: string; name: string }>()\n\n const newUsers = Object.fromEntries(\n Object.entries(newState).map(([key, values]) => [\n key,\n { name: values[0].name, image: values[0].image },\n ])\n ) as Record\n setUsers(newUsers)\n })\n .subscribe(async (status) => {\n if (status !== 'SUBSCRIBED') {\n return\n }\n\n await room.track({\n name: currentUserName,\n image: currentUserImage,\n })\n })\n\n return () => {\n room.unsubscribe()\n }\n }, [roomName, currentUserName, currentUserImage])\n\n return { users }\n}\n", + "content": "'use client'\n\nimport { useCurrentUserImage } from '@/registry/default/blocks/current-user-avatar/hooks/use-current-user-image'\nimport { useCurrentUserName } from '@/registry/default/blocks/current-user-avatar/hooks/use-current-user-name'\nimport { createClient } from '@/registry/default/clients/nextjs/lib/supabase/client'\nimport { REALTIME_SUBSCRIBE_STATES } from '@supabase/supabase-js'\nimport { useEffect, useState } from 'react'\n\nconst supabase = createClient()\n\nexport type RealtimeUser = {\n id: string\n name: string\n image: string\n}\n\nexport const useRealtimePresenceRoom = (roomName: string) => {\n const currentUserImage = useCurrentUserImage()\n const currentUserName = useCurrentUserName()\n\n const [users, setUsers] = useState>({})\n\n useEffect(() => {\n const room = supabase.channel(roomName)\n\n room\n .on('presence', { event: 'sync' }, () => {\n const newState = room.presenceState<{ image: string; name: string }>()\n\n const newUsers = Object.fromEntries(\n Object.entries(newState).map(([key, values]) => [\n key,\n { name: values[0].name, image: values[0].image },\n ])\n ) as Record\n setUsers(newUsers)\n })\n .subscribe(async (status) => {\n if (status === REALTIME_SUBSCRIBE_STATES.SUBSCRIBED) {\n await room.track({\n name: currentUserName,\n image: currentUserImage,\n })\n } else {\n setUsers({})\n }\n })\n\n return () => {\n room.unsubscribe()\n }\n }, [roomName, currentUserName, currentUserImage])\n\n return { users }\n}\n", "type": "registry:hook" }, { diff --git a/apps/ui-library/public/r/realtime-avatar-stack-react.json b/apps/ui-library/public/r/realtime-avatar-stack-react.json index f1226adc1f18a..4644e0143cc10 100644 --- a/apps/ui-library/public/r/realtime-avatar-stack-react.json +++ b/apps/ui-library/public/r/realtime-avatar-stack-react.json @@ -24,7 +24,7 @@ }, { "path": "registry/default/blocks/realtime-avatar-stack/hooks/use-realtime-presence-room.ts", - "content": "'use client'\n\nimport { useCurrentUserImage } from '@/registry/default/blocks/current-user-avatar/hooks/use-current-user-image'\nimport { useCurrentUserName } from '@/registry/default/blocks/current-user-avatar/hooks/use-current-user-name'\nimport { createClient } from '@/registry/default/clients/nextjs/lib/supabase/client'\nimport { useEffect, useState } from 'react'\n\nconst supabase = createClient()\n\nexport type RealtimeUser = {\n id: string\n name: string\n image: string\n}\n\nexport const useRealtimePresenceRoom = (roomName: string) => {\n const currentUserImage = useCurrentUserImage()\n const currentUserName = useCurrentUserName()\n\n const [users, setUsers] = useState>({})\n\n useEffect(() => {\n const room = supabase.channel(roomName)\n\n room\n .on('presence', { event: 'sync' }, () => {\n const newState = room.presenceState<{ image: string; name: string }>()\n\n const newUsers = Object.fromEntries(\n Object.entries(newState).map(([key, values]) => [\n key,\n { name: values[0].name, image: values[0].image },\n ])\n ) as Record\n setUsers(newUsers)\n })\n .subscribe(async (status) => {\n if (status !== 'SUBSCRIBED') {\n return\n }\n\n await room.track({\n name: currentUserName,\n image: currentUserImage,\n })\n })\n\n return () => {\n room.unsubscribe()\n }\n }, [roomName, currentUserName, currentUserImage])\n\n return { users }\n}\n", + "content": "'use client'\n\nimport { useCurrentUserImage } from '@/registry/default/blocks/current-user-avatar/hooks/use-current-user-image'\nimport { useCurrentUserName } from '@/registry/default/blocks/current-user-avatar/hooks/use-current-user-name'\nimport { createClient } from '@/registry/default/clients/nextjs/lib/supabase/client'\nimport { REALTIME_SUBSCRIBE_STATES } from '@supabase/supabase-js'\nimport { useEffect, useState } from 'react'\n\nconst supabase = createClient()\n\nexport type RealtimeUser = {\n id: string\n name: string\n image: string\n}\n\nexport const useRealtimePresenceRoom = (roomName: string) => {\n const currentUserImage = useCurrentUserImage()\n const currentUserName = useCurrentUserName()\n\n const [users, setUsers] = useState>({})\n\n useEffect(() => {\n const room = supabase.channel(roomName)\n\n room\n .on('presence', { event: 'sync' }, () => {\n const newState = room.presenceState<{ image: string; name: string }>()\n\n const newUsers = Object.fromEntries(\n Object.entries(newState).map(([key, values]) => [\n key,\n { name: values[0].name, image: values[0].image },\n ])\n ) as Record\n setUsers(newUsers)\n })\n .subscribe(async (status) => {\n if (status === REALTIME_SUBSCRIBE_STATES.SUBSCRIBED) {\n await room.track({\n name: currentUserName,\n image: currentUserImage,\n })\n } else {\n setUsers({})\n }\n })\n\n return () => {\n room.unsubscribe()\n }\n }, [roomName, currentUserName, currentUserImage])\n\n return { users }\n}\n", "type": "registry:hook" }, { diff --git a/apps/ui-library/public/r/realtime-avatar-stack-tanstack.json b/apps/ui-library/public/r/realtime-avatar-stack-tanstack.json index b3ae60f1bf965..42bcfbdf4ee82 100644 --- a/apps/ui-library/public/r/realtime-avatar-stack-tanstack.json +++ b/apps/ui-library/public/r/realtime-avatar-stack-tanstack.json @@ -25,7 +25,7 @@ }, { "path": "registry/default/blocks/realtime-avatar-stack/hooks/use-realtime-presence-room.ts", - "content": "'use client'\n\nimport { useCurrentUserImage } from '@/registry/default/blocks/current-user-avatar/hooks/use-current-user-image'\nimport { useCurrentUserName } from '@/registry/default/blocks/current-user-avatar/hooks/use-current-user-name'\nimport { createClient } from '@/registry/default/clients/nextjs/lib/supabase/client'\nimport { useEffect, useState } from 'react'\n\nconst supabase = createClient()\n\nexport type RealtimeUser = {\n id: string\n name: string\n image: string\n}\n\nexport const useRealtimePresenceRoom = (roomName: string) => {\n const currentUserImage = useCurrentUserImage()\n const currentUserName = useCurrentUserName()\n\n const [users, setUsers] = useState>({})\n\n useEffect(() => {\n const room = supabase.channel(roomName)\n\n room\n .on('presence', { event: 'sync' }, () => {\n const newState = room.presenceState<{ image: string; name: string }>()\n\n const newUsers = Object.fromEntries(\n Object.entries(newState).map(([key, values]) => [\n key,\n { name: values[0].name, image: values[0].image },\n ])\n ) as Record\n setUsers(newUsers)\n })\n .subscribe(async (status) => {\n if (status !== 'SUBSCRIBED') {\n return\n }\n\n await room.track({\n name: currentUserName,\n image: currentUserImage,\n })\n })\n\n return () => {\n room.unsubscribe()\n }\n }, [roomName, currentUserName, currentUserImage])\n\n return { users }\n}\n", + "content": "'use client'\n\nimport { useCurrentUserImage } from '@/registry/default/blocks/current-user-avatar/hooks/use-current-user-image'\nimport { useCurrentUserName } from '@/registry/default/blocks/current-user-avatar/hooks/use-current-user-name'\nimport { createClient } from '@/registry/default/clients/nextjs/lib/supabase/client'\nimport { REALTIME_SUBSCRIBE_STATES } from '@supabase/supabase-js'\nimport { useEffect, useState } from 'react'\n\nconst supabase = createClient()\n\nexport type RealtimeUser = {\n id: string\n name: string\n image: string\n}\n\nexport const useRealtimePresenceRoom = (roomName: string) => {\n const currentUserImage = useCurrentUserImage()\n const currentUserName = useCurrentUserName()\n\n const [users, setUsers] = useState>({})\n\n useEffect(() => {\n const room = supabase.channel(roomName)\n\n room\n .on('presence', { event: 'sync' }, () => {\n const newState = room.presenceState<{ image: string; name: string }>()\n\n const newUsers = Object.fromEntries(\n Object.entries(newState).map(([key, values]) => [\n key,\n { name: values[0].name, image: values[0].image },\n ])\n ) as Record\n setUsers(newUsers)\n })\n .subscribe(async (status) => {\n if (status === REALTIME_SUBSCRIBE_STATES.SUBSCRIBED) {\n await room.track({\n name: currentUserName,\n image: currentUserImage,\n })\n } else {\n setUsers({})\n }\n })\n\n return () => {\n room.unsubscribe()\n }\n }, [roomName, currentUserName, currentUserImage])\n\n return { users }\n}\n", "type": "registry:hook" }, { diff --git a/apps/ui-library/public/r/realtime-chat-nextjs.json b/apps/ui-library/public/r/realtime-chat-nextjs.json index 1f2e020b5c850..da3da0756e16a 100644 --- a/apps/ui-library/public/r/realtime-chat-nextjs.json +++ b/apps/ui-library/public/r/realtime-chat-nextjs.json @@ -26,7 +26,7 @@ }, { "path": "registry/default/blocks/realtime-chat/hooks/use-realtime-chat.tsx", - "content": "'use client'\n\nimport { createClient } from '@/registry/default/clients/nextjs/lib/supabase/client'\nimport { useCallback, useEffect, useState } from 'react'\n\ninterface UseRealtimeChatProps {\n roomName: string\n username: string\n}\n\nexport interface ChatMessage {\n id: string\n content: string\n user: {\n name: string\n }\n createdAt: string\n}\n\nconst EVENT_MESSAGE_TYPE = 'message'\n\nexport function useRealtimeChat({ roomName, username }: UseRealtimeChatProps) {\n const supabase = createClient()\n const [messages, setMessages] = useState([])\n const [channel, setChannel] = useState | null>(null)\n const [isConnected, setIsConnected] = useState(false)\n\n useEffect(() => {\n const newChannel = supabase.channel(roomName)\n\n newChannel\n .on('broadcast', { event: EVENT_MESSAGE_TYPE }, (payload) => {\n setMessages((current) => [...current, payload.payload as ChatMessage])\n })\n .subscribe(async (status) => {\n if (status === 'SUBSCRIBED') {\n setIsConnected(true)\n }\n })\n\n setChannel(newChannel)\n\n return () => {\n supabase.removeChannel(newChannel)\n }\n }, [roomName, username, supabase])\n\n const sendMessage = useCallback(\n async (content: string) => {\n if (!channel || !isConnected) return\n\n const message: ChatMessage = {\n id: crypto.randomUUID(),\n content,\n user: {\n name: username,\n },\n createdAt: new Date().toISOString(),\n }\n\n // Update local state immediately for the sender\n setMessages((current) => [...current, message])\n\n await channel.send({\n type: 'broadcast',\n event: EVENT_MESSAGE_TYPE,\n payload: message,\n })\n },\n [channel, isConnected, username]\n )\n\n return { messages, sendMessage, isConnected }\n}\n", + "content": "'use client'\n\nimport { createClient } from '@/registry/default/clients/nextjs/lib/supabase/client'\nimport { useCallback, useEffect, useState } from 'react'\n\ninterface UseRealtimeChatProps {\n roomName: string\n username: string\n}\n\nexport interface ChatMessage {\n id: string\n content: string\n user: {\n name: string\n }\n createdAt: string\n}\n\nconst EVENT_MESSAGE_TYPE = 'message'\n\nexport function useRealtimeChat({ roomName, username }: UseRealtimeChatProps) {\n const supabase = createClient()\n const [messages, setMessages] = useState([])\n const [channel, setChannel] = useState | null>(null)\n const [isConnected, setIsConnected] = useState(false)\n\n useEffect(() => {\n const newChannel = supabase.channel(roomName)\n\n newChannel\n .on('broadcast', { event: EVENT_MESSAGE_TYPE }, (payload) => {\n setMessages((current) => [...current, payload.payload as ChatMessage])\n })\n .subscribe(async (status) => {\n if (status === 'SUBSCRIBED') {\n setIsConnected(true)\n } else {\n setIsConnected(false)\n }\n })\n\n setChannel(newChannel)\n\n return () => {\n supabase.removeChannel(newChannel)\n }\n }, [roomName, username, supabase])\n\n const sendMessage = useCallback(\n async (content: string) => {\n if (!channel || !isConnected) return\n\n const message: ChatMessage = {\n id: crypto.randomUUID(),\n content,\n user: {\n name: username,\n },\n createdAt: new Date().toISOString(),\n }\n\n // Update local state immediately for the sender\n setMessages((current) => [...current, message])\n\n await channel.send({\n type: 'broadcast',\n event: EVENT_MESSAGE_TYPE,\n payload: message,\n })\n },\n [channel, isConnected, username]\n )\n\n return { messages, sendMessage, isConnected }\n}\n", "type": "registry:hook" }, { diff --git a/apps/ui-library/public/r/realtime-chat-react-router.json b/apps/ui-library/public/r/realtime-chat-react-router.json index fd34e8cc7db97..916cf3397561a 100644 --- a/apps/ui-library/public/r/realtime-chat-react-router.json +++ b/apps/ui-library/public/r/realtime-chat-react-router.json @@ -26,7 +26,7 @@ }, { "path": "registry/default/blocks/realtime-chat/hooks/use-realtime-chat.tsx", - "content": "'use client'\n\nimport { createClient } from '@/registry/default/clients/nextjs/lib/supabase/client'\nimport { useCallback, useEffect, useState } from 'react'\n\ninterface UseRealtimeChatProps {\n roomName: string\n username: string\n}\n\nexport interface ChatMessage {\n id: string\n content: string\n user: {\n name: string\n }\n createdAt: string\n}\n\nconst EVENT_MESSAGE_TYPE = 'message'\n\nexport function useRealtimeChat({ roomName, username }: UseRealtimeChatProps) {\n const supabase = createClient()\n const [messages, setMessages] = useState([])\n const [channel, setChannel] = useState | null>(null)\n const [isConnected, setIsConnected] = useState(false)\n\n useEffect(() => {\n const newChannel = supabase.channel(roomName)\n\n newChannel\n .on('broadcast', { event: EVENT_MESSAGE_TYPE }, (payload) => {\n setMessages((current) => [...current, payload.payload as ChatMessage])\n })\n .subscribe(async (status) => {\n if (status === 'SUBSCRIBED') {\n setIsConnected(true)\n }\n })\n\n setChannel(newChannel)\n\n return () => {\n supabase.removeChannel(newChannel)\n }\n }, [roomName, username, supabase])\n\n const sendMessage = useCallback(\n async (content: string) => {\n if (!channel || !isConnected) return\n\n const message: ChatMessage = {\n id: crypto.randomUUID(),\n content,\n user: {\n name: username,\n },\n createdAt: new Date().toISOString(),\n }\n\n // Update local state immediately for the sender\n setMessages((current) => [...current, message])\n\n await channel.send({\n type: 'broadcast',\n event: EVENT_MESSAGE_TYPE,\n payload: message,\n })\n },\n [channel, isConnected, username]\n )\n\n return { messages, sendMessage, isConnected }\n}\n", + "content": "'use client'\n\nimport { createClient } from '@/registry/default/clients/nextjs/lib/supabase/client'\nimport { useCallback, useEffect, useState } from 'react'\n\ninterface UseRealtimeChatProps {\n roomName: string\n username: string\n}\n\nexport interface ChatMessage {\n id: string\n content: string\n user: {\n name: string\n }\n createdAt: string\n}\n\nconst EVENT_MESSAGE_TYPE = 'message'\n\nexport function useRealtimeChat({ roomName, username }: UseRealtimeChatProps) {\n const supabase = createClient()\n const [messages, setMessages] = useState([])\n const [channel, setChannel] = useState | null>(null)\n const [isConnected, setIsConnected] = useState(false)\n\n useEffect(() => {\n const newChannel = supabase.channel(roomName)\n\n newChannel\n .on('broadcast', { event: EVENT_MESSAGE_TYPE }, (payload) => {\n setMessages((current) => [...current, payload.payload as ChatMessage])\n })\n .subscribe(async (status) => {\n if (status === 'SUBSCRIBED') {\n setIsConnected(true)\n } else {\n setIsConnected(false)\n }\n })\n\n setChannel(newChannel)\n\n return () => {\n supabase.removeChannel(newChannel)\n }\n }, [roomName, username, supabase])\n\n const sendMessage = useCallback(\n async (content: string) => {\n if (!channel || !isConnected) return\n\n const message: ChatMessage = {\n id: crypto.randomUUID(),\n content,\n user: {\n name: username,\n },\n createdAt: new Date().toISOString(),\n }\n\n // Update local state immediately for the sender\n setMessages((current) => [...current, message])\n\n await channel.send({\n type: 'broadcast',\n event: EVENT_MESSAGE_TYPE,\n payload: message,\n })\n },\n [channel, isConnected, username]\n )\n\n return { messages, sendMessage, isConnected }\n}\n", "type": "registry:hook" }, { diff --git a/apps/ui-library/public/r/realtime-chat-react.json b/apps/ui-library/public/r/realtime-chat-react.json index 14651ea7dcfeb..a11efd3cd3aa6 100644 --- a/apps/ui-library/public/r/realtime-chat-react.json +++ b/apps/ui-library/public/r/realtime-chat-react.json @@ -25,7 +25,7 @@ }, { "path": "registry/default/blocks/realtime-chat/hooks/use-realtime-chat.tsx", - "content": "'use client'\n\nimport { createClient } from '@/registry/default/clients/nextjs/lib/supabase/client'\nimport { useCallback, useEffect, useState } from 'react'\n\ninterface UseRealtimeChatProps {\n roomName: string\n username: string\n}\n\nexport interface ChatMessage {\n id: string\n content: string\n user: {\n name: string\n }\n createdAt: string\n}\n\nconst EVENT_MESSAGE_TYPE = 'message'\n\nexport function useRealtimeChat({ roomName, username }: UseRealtimeChatProps) {\n const supabase = createClient()\n const [messages, setMessages] = useState([])\n const [channel, setChannel] = useState | null>(null)\n const [isConnected, setIsConnected] = useState(false)\n\n useEffect(() => {\n const newChannel = supabase.channel(roomName)\n\n newChannel\n .on('broadcast', { event: EVENT_MESSAGE_TYPE }, (payload) => {\n setMessages((current) => [...current, payload.payload as ChatMessage])\n })\n .subscribe(async (status) => {\n if (status === 'SUBSCRIBED') {\n setIsConnected(true)\n }\n })\n\n setChannel(newChannel)\n\n return () => {\n supabase.removeChannel(newChannel)\n }\n }, [roomName, username, supabase])\n\n const sendMessage = useCallback(\n async (content: string) => {\n if (!channel || !isConnected) return\n\n const message: ChatMessage = {\n id: crypto.randomUUID(),\n content,\n user: {\n name: username,\n },\n createdAt: new Date().toISOString(),\n }\n\n // Update local state immediately for the sender\n setMessages((current) => [...current, message])\n\n await channel.send({\n type: 'broadcast',\n event: EVENT_MESSAGE_TYPE,\n payload: message,\n })\n },\n [channel, isConnected, username]\n )\n\n return { messages, sendMessage, isConnected }\n}\n", + "content": "'use client'\n\nimport { createClient } from '@/registry/default/clients/nextjs/lib/supabase/client'\nimport { useCallback, useEffect, useState } from 'react'\n\ninterface UseRealtimeChatProps {\n roomName: string\n username: string\n}\n\nexport interface ChatMessage {\n id: string\n content: string\n user: {\n name: string\n }\n createdAt: string\n}\n\nconst EVENT_MESSAGE_TYPE = 'message'\n\nexport function useRealtimeChat({ roomName, username }: UseRealtimeChatProps) {\n const supabase = createClient()\n const [messages, setMessages] = useState([])\n const [channel, setChannel] = useState | null>(null)\n const [isConnected, setIsConnected] = useState(false)\n\n useEffect(() => {\n const newChannel = supabase.channel(roomName)\n\n newChannel\n .on('broadcast', { event: EVENT_MESSAGE_TYPE }, (payload) => {\n setMessages((current) => [...current, payload.payload as ChatMessage])\n })\n .subscribe(async (status) => {\n if (status === 'SUBSCRIBED') {\n setIsConnected(true)\n } else {\n setIsConnected(false)\n }\n })\n\n setChannel(newChannel)\n\n return () => {\n supabase.removeChannel(newChannel)\n }\n }, [roomName, username, supabase])\n\n const sendMessage = useCallback(\n async (content: string) => {\n if (!channel || !isConnected) return\n\n const message: ChatMessage = {\n id: crypto.randomUUID(),\n content,\n user: {\n name: username,\n },\n createdAt: new Date().toISOString(),\n }\n\n // Update local state immediately for the sender\n setMessages((current) => [...current, message])\n\n await channel.send({\n type: 'broadcast',\n event: EVENT_MESSAGE_TYPE,\n payload: message,\n })\n },\n [channel, isConnected, username]\n )\n\n return { messages, sendMessage, isConnected }\n}\n", "type": "registry:hook" }, { diff --git a/apps/ui-library/public/r/realtime-chat-tanstack.json b/apps/ui-library/public/r/realtime-chat-tanstack.json index 5912fb760858d..ee1831a5d60b7 100644 --- a/apps/ui-library/public/r/realtime-chat-tanstack.json +++ b/apps/ui-library/public/r/realtime-chat-tanstack.json @@ -26,7 +26,7 @@ }, { "path": "registry/default/blocks/realtime-chat/hooks/use-realtime-chat.tsx", - "content": "'use client'\n\nimport { createClient } from '@/registry/default/clients/nextjs/lib/supabase/client'\nimport { useCallback, useEffect, useState } from 'react'\n\ninterface UseRealtimeChatProps {\n roomName: string\n username: string\n}\n\nexport interface ChatMessage {\n id: string\n content: string\n user: {\n name: string\n }\n createdAt: string\n}\n\nconst EVENT_MESSAGE_TYPE = 'message'\n\nexport function useRealtimeChat({ roomName, username }: UseRealtimeChatProps) {\n const supabase = createClient()\n const [messages, setMessages] = useState([])\n const [channel, setChannel] = useState | null>(null)\n const [isConnected, setIsConnected] = useState(false)\n\n useEffect(() => {\n const newChannel = supabase.channel(roomName)\n\n newChannel\n .on('broadcast', { event: EVENT_MESSAGE_TYPE }, (payload) => {\n setMessages((current) => [...current, payload.payload as ChatMessage])\n })\n .subscribe(async (status) => {\n if (status === 'SUBSCRIBED') {\n setIsConnected(true)\n }\n })\n\n setChannel(newChannel)\n\n return () => {\n supabase.removeChannel(newChannel)\n }\n }, [roomName, username, supabase])\n\n const sendMessage = useCallback(\n async (content: string) => {\n if (!channel || !isConnected) return\n\n const message: ChatMessage = {\n id: crypto.randomUUID(),\n content,\n user: {\n name: username,\n },\n createdAt: new Date().toISOString(),\n }\n\n // Update local state immediately for the sender\n setMessages((current) => [...current, message])\n\n await channel.send({\n type: 'broadcast',\n event: EVENT_MESSAGE_TYPE,\n payload: message,\n })\n },\n [channel, isConnected, username]\n )\n\n return { messages, sendMessage, isConnected }\n}\n", + "content": "'use client'\n\nimport { createClient } from '@/registry/default/clients/nextjs/lib/supabase/client'\nimport { useCallback, useEffect, useState } from 'react'\n\ninterface UseRealtimeChatProps {\n roomName: string\n username: string\n}\n\nexport interface ChatMessage {\n id: string\n content: string\n user: {\n name: string\n }\n createdAt: string\n}\n\nconst EVENT_MESSAGE_TYPE = 'message'\n\nexport function useRealtimeChat({ roomName, username }: UseRealtimeChatProps) {\n const supabase = createClient()\n const [messages, setMessages] = useState([])\n const [channel, setChannel] = useState | null>(null)\n const [isConnected, setIsConnected] = useState(false)\n\n useEffect(() => {\n const newChannel = supabase.channel(roomName)\n\n newChannel\n .on('broadcast', { event: EVENT_MESSAGE_TYPE }, (payload) => {\n setMessages((current) => [...current, payload.payload as ChatMessage])\n })\n .subscribe(async (status) => {\n if (status === 'SUBSCRIBED') {\n setIsConnected(true)\n } else {\n setIsConnected(false)\n }\n })\n\n setChannel(newChannel)\n\n return () => {\n supabase.removeChannel(newChannel)\n }\n }, [roomName, username, supabase])\n\n const sendMessage = useCallback(\n async (content: string) => {\n if (!channel || !isConnected) return\n\n const message: ChatMessage = {\n id: crypto.randomUUID(),\n content,\n user: {\n name: username,\n },\n createdAt: new Date().toISOString(),\n }\n\n // Update local state immediately for the sender\n setMessages((current) => [...current, message])\n\n await channel.send({\n type: 'broadcast',\n event: EVENT_MESSAGE_TYPE,\n payload: message,\n })\n },\n [channel, isConnected, username]\n )\n\n return { messages, sendMessage, isConnected }\n}\n", "type": "registry:hook" }, { diff --git a/apps/ui-library/public/r/realtime-cursor-nextjs.json b/apps/ui-library/public/r/realtime-cursor-nextjs.json index e2477cbeb94c4..251c3aeb10a2f 100644 --- a/apps/ui-library/public/r/realtime-cursor-nextjs.json +++ b/apps/ui-library/public/r/realtime-cursor-nextjs.json @@ -23,7 +23,7 @@ }, { "path": "registry/default/blocks/realtime-cursor/hooks/use-realtime-cursors.ts", - "content": "import { createClient } from '@/registry/default/clients/nextjs/lib/supabase/client'\nimport { RealtimeChannel } from '@supabase/supabase-js'\nimport { useCallback, useEffect, useRef, useState } from 'react'\n\n/**\n * Throttle a callback to a certain delay, It will only call the callback if the delay has passed, with the arguments\n * from the last call\n */\nconst useThrottleCallback = (\n callback: (...args: Params) => Return,\n delay: number\n) => {\n const lastCall = useRef(0)\n const timeout = useRef(null)\n\n return useCallback(\n (...args: Params) => {\n const now = Date.now()\n const remainingTime = delay - (now - lastCall.current)\n\n if (remainingTime <= 0) {\n if (timeout.current) {\n clearTimeout(timeout.current)\n timeout.current = null\n }\n lastCall.current = now\n callback(...args)\n } else if (!timeout.current) {\n timeout.current = setTimeout(() => {\n lastCall.current = Date.now()\n timeout.current = null\n callback(...args)\n }, remainingTime)\n }\n },\n [callback, delay]\n )\n}\n\nconst supabase = createClient()\n\nconst generateRandomColor = () => `hsl(${Math.floor(Math.random() * 360)}, 100%, 70%)`\n\nconst generateRandomNumber = () => Math.floor(Math.random() * 100)\n\nconst EVENT_NAME = 'realtime-cursor-move'\n\ntype CursorEventPayload = {\n position: {\n x: number\n y: number\n }\n user: {\n id: number\n name: string\n }\n color: string\n timestamp: number\n}\n\nexport const useRealtimeCursors = ({\n roomName,\n username,\n throttleMs,\n}: {\n roomName: string\n username: string\n throttleMs: number\n}) => {\n const [color] = useState(generateRandomColor())\n const [userId] = useState(generateRandomNumber())\n const [cursors, setCursors] = useState>({})\n\n const channelRef = useRef(null)\n\n const callback = useCallback(\n (event: MouseEvent) => {\n const { clientX, clientY } = event\n\n const payload: CursorEventPayload = {\n position: {\n x: clientX,\n y: clientY,\n },\n user: {\n id: userId,\n name: username,\n },\n color: color,\n timestamp: new Date().getTime(),\n }\n\n channelRef.current?.send({\n type: 'broadcast',\n event: EVENT_NAME,\n payload: payload,\n })\n },\n [color, userId, username]\n )\n\n const handleMouseMove = useThrottleCallback(callback, throttleMs)\n\n useEffect(() => {\n const channel = supabase.channel(roomName)\n channelRef.current = channel\n\n channel\n .on('broadcast', { event: EVENT_NAME }, (data: { payload: CursorEventPayload }) => {\n const { user } = data.payload\n // Don't render your own cursor\n if (user.id === userId) return\n\n setCursors((prev) => {\n if (prev[userId]) {\n delete prev[userId]\n }\n\n return {\n ...prev,\n [user.id]: data.payload,\n }\n })\n })\n .subscribe()\n\n return () => {\n channel.unsubscribe()\n }\n }, [])\n\n useEffect(() => {\n // Add event listener for mousemove\n window.addEventListener('mousemove', handleMouseMove)\n\n // Cleanup on unmount\n return () => {\n window.removeEventListener('mousemove', handleMouseMove)\n }\n }, [handleMouseMove])\n\n return { cursors }\n}\n", + "content": "import { createClient } from '@/registry/default/clients/nextjs/lib/supabase/client'\nimport { RealtimeChannel, REALTIME_SUBSCRIBE_STATES } from '@supabase/supabase-js'\nimport { useCallback, useEffect, useRef, useState } from 'react'\n\n/**\n * Throttle a callback to a certain delay, It will only call the callback if the delay has passed, with the arguments\n * from the last call\n */\nconst useThrottleCallback = (\n callback: (...args: Params) => Return,\n delay: number\n) => {\n const lastCall = useRef(0)\n const timeout = useRef(null)\n\n return useCallback(\n (...args: Params) => {\n const now = Date.now()\n const remainingTime = delay - (now - lastCall.current)\n\n if (remainingTime <= 0) {\n if (timeout.current) {\n clearTimeout(timeout.current)\n timeout.current = null\n }\n lastCall.current = now\n callback(...args)\n } else if (!timeout.current) {\n timeout.current = setTimeout(() => {\n lastCall.current = Date.now()\n timeout.current = null\n callback(...args)\n }, remainingTime)\n }\n },\n [callback, delay]\n )\n}\n\nconst supabase = createClient()\n\nconst generateRandomColor = () => `hsl(${Math.floor(Math.random() * 360)}, 100%, 70%)`\n\nconst generateRandomNumber = () => Math.floor(Math.random() * 100)\n\nconst EVENT_NAME = 'realtime-cursor-move'\n\ntype CursorEventPayload = {\n position: {\n x: number\n y: number\n }\n user: {\n id: number\n name: string\n }\n color: string\n timestamp: number\n}\n\nexport const useRealtimeCursors = ({\n roomName,\n username,\n throttleMs,\n}: {\n roomName: string\n username: string\n throttleMs: number\n}) => {\n const [color] = useState(generateRandomColor())\n const [userId] = useState(generateRandomNumber())\n const [cursors, setCursors] = useState>({})\n const cursorPayload = useRef(null)\n\n const channelRef = useRef(null)\n\n const callback = useCallback(\n (event: MouseEvent) => {\n const { clientX, clientY } = event\n\n const payload: CursorEventPayload = {\n position: {\n x: clientX,\n y: clientY,\n },\n user: {\n id: userId,\n name: username,\n },\n color: color,\n timestamp: new Date().getTime(),\n }\n\n cursorPayload.current = payload\n\n channelRef.current?.send({\n type: 'broadcast',\n event: EVENT_NAME,\n payload: payload,\n })\n },\n [color, userId, username]\n )\n\n const handleMouseMove = useThrottleCallback(callback, throttleMs)\n\n useEffect(() => {\n const channel = supabase.channel(roomName)\n\n channel\n .on('presence', { event: 'leave' }, ({ leftPresences }) => {\n leftPresences.forEach(function (element) {\n // Remove cursor when user leaves\n setCursors((prev) => {\n if (prev[element.key]) {\n delete prev[element.key]\n }\n\n return { ...prev }\n })\n })\n })\n .on('presence', { event: 'join' }, () => {\n if (!cursorPayload.current) return\n\n // All cursors broadcast their position when a new cursor joins\n channelRef.current?.send({\n type: 'broadcast',\n event: EVENT_NAME,\n payload: cursorPayload.current,\n })\n })\n .on('broadcast', { event: EVENT_NAME }, (data: { payload: CursorEventPayload }) => {\n const { user } = data.payload\n // Don't render your own cursor\n if (user.id === userId) return\n\n setCursors((prev) => {\n if (prev[userId]) {\n delete prev[userId]\n }\n\n return {\n ...prev,\n [user.id]: data.payload,\n }\n })\n })\n .subscribe(async (status) => {\n if (status === REALTIME_SUBSCRIBE_STATES.SUBSCRIBED) {\n await channel.track({ key: userId })\n channelRef.current = channel\n } else {\n setCursors({})\n channelRef.current = null\n }\n })\n\n return () => {\n channel.unsubscribe()\n channelRef.current = null\n }\n }, [])\n\n useEffect(() => {\n // Add event listener for mousemove\n window.addEventListener('mousemove', handleMouseMove)\n\n // Cleanup on unmount\n return () => {\n window.removeEventListener('mousemove', handleMouseMove)\n }\n }, [handleMouseMove])\n\n return { cursors }\n}\n", "type": "registry:hook" }, { diff --git a/apps/ui-library/public/r/realtime-cursor-react-router.json b/apps/ui-library/public/r/realtime-cursor-react-router.json index cb7b916dcedb8..0d6eaf904cd5b 100644 --- a/apps/ui-library/public/r/realtime-cursor-react-router.json +++ b/apps/ui-library/public/r/realtime-cursor-react-router.json @@ -23,7 +23,7 @@ }, { "path": "registry/default/blocks/realtime-cursor/hooks/use-realtime-cursors.ts", - "content": "import { createClient } from '@/registry/default/clients/nextjs/lib/supabase/client'\nimport { RealtimeChannel } from '@supabase/supabase-js'\nimport { useCallback, useEffect, useRef, useState } from 'react'\n\n/**\n * Throttle a callback to a certain delay, It will only call the callback if the delay has passed, with the arguments\n * from the last call\n */\nconst useThrottleCallback = (\n callback: (...args: Params) => Return,\n delay: number\n) => {\n const lastCall = useRef(0)\n const timeout = useRef(null)\n\n return useCallback(\n (...args: Params) => {\n const now = Date.now()\n const remainingTime = delay - (now - lastCall.current)\n\n if (remainingTime <= 0) {\n if (timeout.current) {\n clearTimeout(timeout.current)\n timeout.current = null\n }\n lastCall.current = now\n callback(...args)\n } else if (!timeout.current) {\n timeout.current = setTimeout(() => {\n lastCall.current = Date.now()\n timeout.current = null\n callback(...args)\n }, remainingTime)\n }\n },\n [callback, delay]\n )\n}\n\nconst supabase = createClient()\n\nconst generateRandomColor = () => `hsl(${Math.floor(Math.random() * 360)}, 100%, 70%)`\n\nconst generateRandomNumber = () => Math.floor(Math.random() * 100)\n\nconst EVENT_NAME = 'realtime-cursor-move'\n\ntype CursorEventPayload = {\n position: {\n x: number\n y: number\n }\n user: {\n id: number\n name: string\n }\n color: string\n timestamp: number\n}\n\nexport const useRealtimeCursors = ({\n roomName,\n username,\n throttleMs,\n}: {\n roomName: string\n username: string\n throttleMs: number\n}) => {\n const [color] = useState(generateRandomColor())\n const [userId] = useState(generateRandomNumber())\n const [cursors, setCursors] = useState>({})\n\n const channelRef = useRef(null)\n\n const callback = useCallback(\n (event: MouseEvent) => {\n const { clientX, clientY } = event\n\n const payload: CursorEventPayload = {\n position: {\n x: clientX,\n y: clientY,\n },\n user: {\n id: userId,\n name: username,\n },\n color: color,\n timestamp: new Date().getTime(),\n }\n\n channelRef.current?.send({\n type: 'broadcast',\n event: EVENT_NAME,\n payload: payload,\n })\n },\n [color, userId, username]\n )\n\n const handleMouseMove = useThrottleCallback(callback, throttleMs)\n\n useEffect(() => {\n const channel = supabase.channel(roomName)\n channelRef.current = channel\n\n channel\n .on('broadcast', { event: EVENT_NAME }, (data: { payload: CursorEventPayload }) => {\n const { user } = data.payload\n // Don't render your own cursor\n if (user.id === userId) return\n\n setCursors((prev) => {\n if (prev[userId]) {\n delete prev[userId]\n }\n\n return {\n ...prev,\n [user.id]: data.payload,\n }\n })\n })\n .subscribe()\n\n return () => {\n channel.unsubscribe()\n }\n }, [])\n\n useEffect(() => {\n // Add event listener for mousemove\n window.addEventListener('mousemove', handleMouseMove)\n\n // Cleanup on unmount\n return () => {\n window.removeEventListener('mousemove', handleMouseMove)\n }\n }, [handleMouseMove])\n\n return { cursors }\n}\n", + "content": "import { createClient } from '@/registry/default/clients/nextjs/lib/supabase/client'\nimport { RealtimeChannel, REALTIME_SUBSCRIBE_STATES } from '@supabase/supabase-js'\nimport { useCallback, useEffect, useRef, useState } from 'react'\n\n/**\n * Throttle a callback to a certain delay, It will only call the callback if the delay has passed, with the arguments\n * from the last call\n */\nconst useThrottleCallback = (\n callback: (...args: Params) => Return,\n delay: number\n) => {\n const lastCall = useRef(0)\n const timeout = useRef(null)\n\n return useCallback(\n (...args: Params) => {\n const now = Date.now()\n const remainingTime = delay - (now - lastCall.current)\n\n if (remainingTime <= 0) {\n if (timeout.current) {\n clearTimeout(timeout.current)\n timeout.current = null\n }\n lastCall.current = now\n callback(...args)\n } else if (!timeout.current) {\n timeout.current = setTimeout(() => {\n lastCall.current = Date.now()\n timeout.current = null\n callback(...args)\n }, remainingTime)\n }\n },\n [callback, delay]\n )\n}\n\nconst supabase = createClient()\n\nconst generateRandomColor = () => `hsl(${Math.floor(Math.random() * 360)}, 100%, 70%)`\n\nconst generateRandomNumber = () => Math.floor(Math.random() * 100)\n\nconst EVENT_NAME = 'realtime-cursor-move'\n\ntype CursorEventPayload = {\n position: {\n x: number\n y: number\n }\n user: {\n id: number\n name: string\n }\n color: string\n timestamp: number\n}\n\nexport const useRealtimeCursors = ({\n roomName,\n username,\n throttleMs,\n}: {\n roomName: string\n username: string\n throttleMs: number\n}) => {\n const [color] = useState(generateRandomColor())\n const [userId] = useState(generateRandomNumber())\n const [cursors, setCursors] = useState>({})\n const cursorPayload = useRef(null)\n\n const channelRef = useRef(null)\n\n const callback = useCallback(\n (event: MouseEvent) => {\n const { clientX, clientY } = event\n\n const payload: CursorEventPayload = {\n position: {\n x: clientX,\n y: clientY,\n },\n user: {\n id: userId,\n name: username,\n },\n color: color,\n timestamp: new Date().getTime(),\n }\n\n cursorPayload.current = payload\n\n channelRef.current?.send({\n type: 'broadcast',\n event: EVENT_NAME,\n payload: payload,\n })\n },\n [color, userId, username]\n )\n\n const handleMouseMove = useThrottleCallback(callback, throttleMs)\n\n useEffect(() => {\n const channel = supabase.channel(roomName)\n\n channel\n .on('presence', { event: 'leave' }, ({ leftPresences }) => {\n leftPresences.forEach(function (element) {\n // Remove cursor when user leaves\n setCursors((prev) => {\n if (prev[element.key]) {\n delete prev[element.key]\n }\n\n return { ...prev }\n })\n })\n })\n .on('presence', { event: 'join' }, () => {\n if (!cursorPayload.current) return\n\n // All cursors broadcast their position when a new cursor joins\n channelRef.current?.send({\n type: 'broadcast',\n event: EVENT_NAME,\n payload: cursorPayload.current,\n })\n })\n .on('broadcast', { event: EVENT_NAME }, (data: { payload: CursorEventPayload }) => {\n const { user } = data.payload\n // Don't render your own cursor\n if (user.id === userId) return\n\n setCursors((prev) => {\n if (prev[userId]) {\n delete prev[userId]\n }\n\n return {\n ...prev,\n [user.id]: data.payload,\n }\n })\n })\n .subscribe(async (status) => {\n if (status === REALTIME_SUBSCRIBE_STATES.SUBSCRIBED) {\n await channel.track({ key: userId })\n channelRef.current = channel\n } else {\n setCursors({})\n channelRef.current = null\n }\n })\n\n return () => {\n channel.unsubscribe()\n channelRef.current = null\n }\n }, [])\n\n useEffect(() => {\n // Add event listener for mousemove\n window.addEventListener('mousemove', handleMouseMove)\n\n // Cleanup on unmount\n return () => {\n window.removeEventListener('mousemove', handleMouseMove)\n }\n }, [handleMouseMove])\n\n return { cursors }\n}\n", "type": "registry:hook" }, { diff --git a/apps/ui-library/public/r/realtime-cursor-react.json b/apps/ui-library/public/r/realtime-cursor-react.json index 0f338c78581a0..297a3151035e4 100644 --- a/apps/ui-library/public/r/realtime-cursor-react.json +++ b/apps/ui-library/public/r/realtime-cursor-react.json @@ -22,7 +22,7 @@ }, { "path": "registry/default/blocks/realtime-cursor/hooks/use-realtime-cursors.ts", - "content": "import { createClient } from '@/registry/default/clients/nextjs/lib/supabase/client'\nimport { RealtimeChannel } from '@supabase/supabase-js'\nimport { useCallback, useEffect, useRef, useState } from 'react'\n\n/**\n * Throttle a callback to a certain delay, It will only call the callback if the delay has passed, with the arguments\n * from the last call\n */\nconst useThrottleCallback = (\n callback: (...args: Params) => Return,\n delay: number\n) => {\n const lastCall = useRef(0)\n const timeout = useRef(null)\n\n return useCallback(\n (...args: Params) => {\n const now = Date.now()\n const remainingTime = delay - (now - lastCall.current)\n\n if (remainingTime <= 0) {\n if (timeout.current) {\n clearTimeout(timeout.current)\n timeout.current = null\n }\n lastCall.current = now\n callback(...args)\n } else if (!timeout.current) {\n timeout.current = setTimeout(() => {\n lastCall.current = Date.now()\n timeout.current = null\n callback(...args)\n }, remainingTime)\n }\n },\n [callback, delay]\n )\n}\n\nconst supabase = createClient()\n\nconst generateRandomColor = () => `hsl(${Math.floor(Math.random() * 360)}, 100%, 70%)`\n\nconst generateRandomNumber = () => Math.floor(Math.random() * 100)\n\nconst EVENT_NAME = 'realtime-cursor-move'\n\ntype CursorEventPayload = {\n position: {\n x: number\n y: number\n }\n user: {\n id: number\n name: string\n }\n color: string\n timestamp: number\n}\n\nexport const useRealtimeCursors = ({\n roomName,\n username,\n throttleMs,\n}: {\n roomName: string\n username: string\n throttleMs: number\n}) => {\n const [color] = useState(generateRandomColor())\n const [userId] = useState(generateRandomNumber())\n const [cursors, setCursors] = useState>({})\n\n const channelRef = useRef(null)\n\n const callback = useCallback(\n (event: MouseEvent) => {\n const { clientX, clientY } = event\n\n const payload: CursorEventPayload = {\n position: {\n x: clientX,\n y: clientY,\n },\n user: {\n id: userId,\n name: username,\n },\n color: color,\n timestamp: new Date().getTime(),\n }\n\n channelRef.current?.send({\n type: 'broadcast',\n event: EVENT_NAME,\n payload: payload,\n })\n },\n [color, userId, username]\n )\n\n const handleMouseMove = useThrottleCallback(callback, throttleMs)\n\n useEffect(() => {\n const channel = supabase.channel(roomName)\n channelRef.current = channel\n\n channel\n .on('broadcast', { event: EVENT_NAME }, (data: { payload: CursorEventPayload }) => {\n const { user } = data.payload\n // Don't render your own cursor\n if (user.id === userId) return\n\n setCursors((prev) => {\n if (prev[userId]) {\n delete prev[userId]\n }\n\n return {\n ...prev,\n [user.id]: data.payload,\n }\n })\n })\n .subscribe()\n\n return () => {\n channel.unsubscribe()\n }\n }, [])\n\n useEffect(() => {\n // Add event listener for mousemove\n window.addEventListener('mousemove', handleMouseMove)\n\n // Cleanup on unmount\n return () => {\n window.removeEventListener('mousemove', handleMouseMove)\n }\n }, [handleMouseMove])\n\n return { cursors }\n}\n", + "content": "import { createClient } from '@/registry/default/clients/nextjs/lib/supabase/client'\nimport { RealtimeChannel, REALTIME_SUBSCRIBE_STATES } from '@supabase/supabase-js'\nimport { useCallback, useEffect, useRef, useState } from 'react'\n\n/**\n * Throttle a callback to a certain delay, It will only call the callback if the delay has passed, with the arguments\n * from the last call\n */\nconst useThrottleCallback = (\n callback: (...args: Params) => Return,\n delay: number\n) => {\n const lastCall = useRef(0)\n const timeout = useRef(null)\n\n return useCallback(\n (...args: Params) => {\n const now = Date.now()\n const remainingTime = delay - (now - lastCall.current)\n\n if (remainingTime <= 0) {\n if (timeout.current) {\n clearTimeout(timeout.current)\n timeout.current = null\n }\n lastCall.current = now\n callback(...args)\n } else if (!timeout.current) {\n timeout.current = setTimeout(() => {\n lastCall.current = Date.now()\n timeout.current = null\n callback(...args)\n }, remainingTime)\n }\n },\n [callback, delay]\n )\n}\n\nconst supabase = createClient()\n\nconst generateRandomColor = () => `hsl(${Math.floor(Math.random() * 360)}, 100%, 70%)`\n\nconst generateRandomNumber = () => Math.floor(Math.random() * 100)\n\nconst EVENT_NAME = 'realtime-cursor-move'\n\ntype CursorEventPayload = {\n position: {\n x: number\n y: number\n }\n user: {\n id: number\n name: string\n }\n color: string\n timestamp: number\n}\n\nexport const useRealtimeCursors = ({\n roomName,\n username,\n throttleMs,\n}: {\n roomName: string\n username: string\n throttleMs: number\n}) => {\n const [color] = useState(generateRandomColor())\n const [userId] = useState(generateRandomNumber())\n const [cursors, setCursors] = useState>({})\n const cursorPayload = useRef(null)\n\n const channelRef = useRef(null)\n\n const callback = useCallback(\n (event: MouseEvent) => {\n const { clientX, clientY } = event\n\n const payload: CursorEventPayload = {\n position: {\n x: clientX,\n y: clientY,\n },\n user: {\n id: userId,\n name: username,\n },\n color: color,\n timestamp: new Date().getTime(),\n }\n\n cursorPayload.current = payload\n\n channelRef.current?.send({\n type: 'broadcast',\n event: EVENT_NAME,\n payload: payload,\n })\n },\n [color, userId, username]\n )\n\n const handleMouseMove = useThrottleCallback(callback, throttleMs)\n\n useEffect(() => {\n const channel = supabase.channel(roomName)\n\n channel\n .on('presence', { event: 'leave' }, ({ leftPresences }) => {\n leftPresences.forEach(function (element) {\n // Remove cursor when user leaves\n setCursors((prev) => {\n if (prev[element.key]) {\n delete prev[element.key]\n }\n\n return { ...prev }\n })\n })\n })\n .on('presence', { event: 'join' }, () => {\n if (!cursorPayload.current) return\n\n // All cursors broadcast their position when a new cursor joins\n channelRef.current?.send({\n type: 'broadcast',\n event: EVENT_NAME,\n payload: cursorPayload.current,\n })\n })\n .on('broadcast', { event: EVENT_NAME }, (data: { payload: CursorEventPayload }) => {\n const { user } = data.payload\n // Don't render your own cursor\n if (user.id === userId) return\n\n setCursors((prev) => {\n if (prev[userId]) {\n delete prev[userId]\n }\n\n return {\n ...prev,\n [user.id]: data.payload,\n }\n })\n })\n .subscribe(async (status) => {\n if (status === REALTIME_SUBSCRIBE_STATES.SUBSCRIBED) {\n await channel.track({ key: userId })\n channelRef.current = channel\n } else {\n setCursors({})\n channelRef.current = null\n }\n })\n\n return () => {\n channel.unsubscribe()\n channelRef.current = null\n }\n }, [])\n\n useEffect(() => {\n // Add event listener for mousemove\n window.addEventListener('mousemove', handleMouseMove)\n\n // Cleanup on unmount\n return () => {\n window.removeEventListener('mousemove', handleMouseMove)\n }\n }, [handleMouseMove])\n\n return { cursors }\n}\n", "type": "registry:hook" }, { diff --git a/apps/ui-library/public/r/realtime-cursor-tanstack.json b/apps/ui-library/public/r/realtime-cursor-tanstack.json index cd72d8d7e1d1b..ccb3216328dc2 100644 --- a/apps/ui-library/public/r/realtime-cursor-tanstack.json +++ b/apps/ui-library/public/r/realtime-cursor-tanstack.json @@ -23,7 +23,7 @@ }, { "path": "registry/default/blocks/realtime-cursor/hooks/use-realtime-cursors.ts", - "content": "import { createClient } from '@/registry/default/clients/nextjs/lib/supabase/client'\nimport { RealtimeChannel } from '@supabase/supabase-js'\nimport { useCallback, useEffect, useRef, useState } from 'react'\n\n/**\n * Throttle a callback to a certain delay, It will only call the callback if the delay has passed, with the arguments\n * from the last call\n */\nconst useThrottleCallback = (\n callback: (...args: Params) => Return,\n delay: number\n) => {\n const lastCall = useRef(0)\n const timeout = useRef(null)\n\n return useCallback(\n (...args: Params) => {\n const now = Date.now()\n const remainingTime = delay - (now - lastCall.current)\n\n if (remainingTime <= 0) {\n if (timeout.current) {\n clearTimeout(timeout.current)\n timeout.current = null\n }\n lastCall.current = now\n callback(...args)\n } else if (!timeout.current) {\n timeout.current = setTimeout(() => {\n lastCall.current = Date.now()\n timeout.current = null\n callback(...args)\n }, remainingTime)\n }\n },\n [callback, delay]\n )\n}\n\nconst supabase = createClient()\n\nconst generateRandomColor = () => `hsl(${Math.floor(Math.random() * 360)}, 100%, 70%)`\n\nconst generateRandomNumber = () => Math.floor(Math.random() * 100)\n\nconst EVENT_NAME = 'realtime-cursor-move'\n\ntype CursorEventPayload = {\n position: {\n x: number\n y: number\n }\n user: {\n id: number\n name: string\n }\n color: string\n timestamp: number\n}\n\nexport const useRealtimeCursors = ({\n roomName,\n username,\n throttleMs,\n}: {\n roomName: string\n username: string\n throttleMs: number\n}) => {\n const [color] = useState(generateRandomColor())\n const [userId] = useState(generateRandomNumber())\n const [cursors, setCursors] = useState>({})\n\n const channelRef = useRef(null)\n\n const callback = useCallback(\n (event: MouseEvent) => {\n const { clientX, clientY } = event\n\n const payload: CursorEventPayload = {\n position: {\n x: clientX,\n y: clientY,\n },\n user: {\n id: userId,\n name: username,\n },\n color: color,\n timestamp: new Date().getTime(),\n }\n\n channelRef.current?.send({\n type: 'broadcast',\n event: EVENT_NAME,\n payload: payload,\n })\n },\n [color, userId, username]\n )\n\n const handleMouseMove = useThrottleCallback(callback, throttleMs)\n\n useEffect(() => {\n const channel = supabase.channel(roomName)\n channelRef.current = channel\n\n channel\n .on('broadcast', { event: EVENT_NAME }, (data: { payload: CursorEventPayload }) => {\n const { user } = data.payload\n // Don't render your own cursor\n if (user.id === userId) return\n\n setCursors((prev) => {\n if (prev[userId]) {\n delete prev[userId]\n }\n\n return {\n ...prev,\n [user.id]: data.payload,\n }\n })\n })\n .subscribe()\n\n return () => {\n channel.unsubscribe()\n }\n }, [])\n\n useEffect(() => {\n // Add event listener for mousemove\n window.addEventListener('mousemove', handleMouseMove)\n\n // Cleanup on unmount\n return () => {\n window.removeEventListener('mousemove', handleMouseMove)\n }\n }, [handleMouseMove])\n\n return { cursors }\n}\n", + "content": "import { createClient } from '@/registry/default/clients/nextjs/lib/supabase/client'\nimport { RealtimeChannel, REALTIME_SUBSCRIBE_STATES } from '@supabase/supabase-js'\nimport { useCallback, useEffect, useRef, useState } from 'react'\n\n/**\n * Throttle a callback to a certain delay, It will only call the callback if the delay has passed, with the arguments\n * from the last call\n */\nconst useThrottleCallback = (\n callback: (...args: Params) => Return,\n delay: number\n) => {\n const lastCall = useRef(0)\n const timeout = useRef(null)\n\n return useCallback(\n (...args: Params) => {\n const now = Date.now()\n const remainingTime = delay - (now - lastCall.current)\n\n if (remainingTime <= 0) {\n if (timeout.current) {\n clearTimeout(timeout.current)\n timeout.current = null\n }\n lastCall.current = now\n callback(...args)\n } else if (!timeout.current) {\n timeout.current = setTimeout(() => {\n lastCall.current = Date.now()\n timeout.current = null\n callback(...args)\n }, remainingTime)\n }\n },\n [callback, delay]\n )\n}\n\nconst supabase = createClient()\n\nconst generateRandomColor = () => `hsl(${Math.floor(Math.random() * 360)}, 100%, 70%)`\n\nconst generateRandomNumber = () => Math.floor(Math.random() * 100)\n\nconst EVENT_NAME = 'realtime-cursor-move'\n\ntype CursorEventPayload = {\n position: {\n x: number\n y: number\n }\n user: {\n id: number\n name: string\n }\n color: string\n timestamp: number\n}\n\nexport const useRealtimeCursors = ({\n roomName,\n username,\n throttleMs,\n}: {\n roomName: string\n username: string\n throttleMs: number\n}) => {\n const [color] = useState(generateRandomColor())\n const [userId] = useState(generateRandomNumber())\n const [cursors, setCursors] = useState>({})\n const cursorPayload = useRef(null)\n\n const channelRef = useRef(null)\n\n const callback = useCallback(\n (event: MouseEvent) => {\n const { clientX, clientY } = event\n\n const payload: CursorEventPayload = {\n position: {\n x: clientX,\n y: clientY,\n },\n user: {\n id: userId,\n name: username,\n },\n color: color,\n timestamp: new Date().getTime(),\n }\n\n cursorPayload.current = payload\n\n channelRef.current?.send({\n type: 'broadcast',\n event: EVENT_NAME,\n payload: payload,\n })\n },\n [color, userId, username]\n )\n\n const handleMouseMove = useThrottleCallback(callback, throttleMs)\n\n useEffect(() => {\n const channel = supabase.channel(roomName)\n\n channel\n .on('presence', { event: 'leave' }, ({ leftPresences }) => {\n leftPresences.forEach(function (element) {\n // Remove cursor when user leaves\n setCursors((prev) => {\n if (prev[element.key]) {\n delete prev[element.key]\n }\n\n return { ...prev }\n })\n })\n })\n .on('presence', { event: 'join' }, () => {\n if (!cursorPayload.current) return\n\n // All cursors broadcast their position when a new cursor joins\n channelRef.current?.send({\n type: 'broadcast',\n event: EVENT_NAME,\n payload: cursorPayload.current,\n })\n })\n .on('broadcast', { event: EVENT_NAME }, (data: { payload: CursorEventPayload }) => {\n const { user } = data.payload\n // Don't render your own cursor\n if (user.id === userId) return\n\n setCursors((prev) => {\n if (prev[userId]) {\n delete prev[userId]\n }\n\n return {\n ...prev,\n [user.id]: data.payload,\n }\n })\n })\n .subscribe(async (status) => {\n if (status === REALTIME_SUBSCRIBE_STATES.SUBSCRIBED) {\n await channel.track({ key: userId })\n channelRef.current = channel\n } else {\n setCursors({})\n channelRef.current = null\n }\n })\n\n return () => {\n channel.unsubscribe()\n channelRef.current = null\n }\n }, [])\n\n useEffect(() => {\n // Add event listener for mousemove\n window.addEventListener('mousemove', handleMouseMove)\n\n // Cleanup on unmount\n return () => {\n window.removeEventListener('mousemove', handleMouseMove)\n }\n }, [handleMouseMove])\n\n return { cursors }\n}\n", "type": "registry:hook" }, { diff --git a/apps/ui-library/registry/default/blocks/realtime-avatar-stack/hooks/use-realtime-presence-room.ts b/apps/ui-library/registry/default/blocks/realtime-avatar-stack/hooks/use-realtime-presence-room.ts index e49b4e820f89c..3279d160412b5 100644 --- a/apps/ui-library/registry/default/blocks/realtime-avatar-stack/hooks/use-realtime-presence-room.ts +++ b/apps/ui-library/registry/default/blocks/realtime-avatar-stack/hooks/use-realtime-presence-room.ts @@ -3,6 +3,7 @@ import { useCurrentUserImage } from '@/registry/default/blocks/current-user-avatar/hooks/use-current-user-image' import { useCurrentUserName } from '@/registry/default/blocks/current-user-avatar/hooks/use-current-user-name' import { createClient } from '@/registry/default/clients/nextjs/lib/supabase/client' +import { REALTIME_SUBSCRIBE_STATES } from '@supabase/supabase-js' import { useEffect, useState } from 'react' const supabase = createClient() @@ -35,14 +36,14 @@ export const useRealtimePresenceRoom = (roomName: string) => { setUsers(newUsers) }) .subscribe(async (status) => { - if (status !== 'SUBSCRIBED') { - return + if (status === REALTIME_SUBSCRIBE_STATES.SUBSCRIBED) { + await room.track({ + name: currentUserName, + image: currentUserImage, + }) + } else { + setUsers({}) } - - await room.track({ - name: currentUserName, - image: currentUserImage, - }) }) return () => { diff --git a/apps/ui-library/registry/default/blocks/realtime-chat/hooks/use-realtime-chat.tsx b/apps/ui-library/registry/default/blocks/realtime-chat/hooks/use-realtime-chat.tsx index 87cced94fdade..218526fbb2c5b 100644 --- a/apps/ui-library/registry/default/blocks/realtime-chat/hooks/use-realtime-chat.tsx +++ b/apps/ui-library/registry/default/blocks/realtime-chat/hooks/use-realtime-chat.tsx @@ -35,6 +35,8 @@ export function useRealtimeChat({ roomName, username }: UseRealtimeChatProps) { .subscribe(async (status) => { if (status === 'SUBSCRIBED') { setIsConnected(true) + } else { + setIsConnected(false) } }) diff --git a/apps/ui-library/registry/default/blocks/realtime-cursor/hooks/use-realtime-cursors.ts b/apps/ui-library/registry/default/blocks/realtime-cursor/hooks/use-realtime-cursors.ts index b4828fd2eb98c..3e7a4b7b80f1c 100644 --- a/apps/ui-library/registry/default/blocks/realtime-cursor/hooks/use-realtime-cursors.ts +++ b/apps/ui-library/registry/default/blocks/realtime-cursor/hooks/use-realtime-cursors.ts @@ -1,5 +1,5 @@ import { createClient } from '@/registry/default/clients/nextjs/lib/supabase/client' -import { RealtimeChannel } from '@supabase/supabase-js' +import { RealtimeChannel, REALTIME_SUBSCRIBE_STATES } from '@supabase/supabase-js' import { useCallback, useEffect, useRef, useState } from 'react' /** @@ -70,6 +70,7 @@ export const useRealtimeCursors = ({ const [color] = useState(generateRandomColor()) const [userId] = useState(generateRandomNumber()) const [cursors, setCursors] = useState>({}) + const cursorPayload = useRef(null) const channelRef = useRef(null) @@ -90,6 +91,8 @@ export const useRealtimeCursors = ({ timestamp: new Date().getTime(), } + cursorPayload.current = payload + channelRef.current?.send({ type: 'broadcast', event: EVENT_NAME, @@ -103,9 +106,30 @@ export const useRealtimeCursors = ({ useEffect(() => { const channel = supabase.channel(roomName) - channelRef.current = channel channel + .on('presence', { event: 'leave' }, ({ leftPresences }) => { + leftPresences.forEach(function (element) { + // Remove cursor when user leaves + setCursors((prev) => { + if (prev[element.key]) { + delete prev[element.key] + } + + return { ...prev } + }) + }) + }) + .on('presence', { event: 'join' }, () => { + if (!cursorPayload.current) return + + // All cursors broadcast their position when a new cursor joins + channelRef.current?.send({ + type: 'broadcast', + event: EVENT_NAME, + payload: cursorPayload.current, + }) + }) .on('broadcast', { event: EVENT_NAME }, (data: { payload: CursorEventPayload }) => { const { user } = data.payload // Don't render your own cursor @@ -122,10 +146,19 @@ export const useRealtimeCursors = ({ } }) }) - .subscribe() + .subscribe(async (status) => { + if (status === REALTIME_SUBSCRIBE_STATES.SUBSCRIBED) { + await channel.track({ key: userId }) + channelRef.current = channel + } else { + setCursors({}) + channelRef.current = null + } + }) return () => { channel.unsubscribe() + channelRef.current = null } }, []) diff --git a/apps/ui-library/registry/default/examples/realtime-avatar-stack-demo.tsx b/apps/ui-library/registry/default/examples/realtime-avatar-stack-demo.tsx index 63c839ed7176d..faec04ca92a56 100644 --- a/apps/ui-library/registry/default/examples/realtime-avatar-stack-demo.tsx +++ b/apps/ui-library/registry/default/examples/realtime-avatar-stack-demo.tsx @@ -3,6 +3,7 @@ import { AvatarStack } from '@/registry/default/blocks/realtime-avatar-stack/components/avatar-stack' import { RealtimeUser } from '@/registry/default/blocks/realtime-avatar-stack/hooks/use-realtime-presence-room' import { createClient } from '@/registry/default/clients/nextjs/lib/supabase/client' +import { REALTIME_SUBSCRIBE_STATES } from '@supabase/supabase-js' import { useUser } from 'common' import { useEffect, useMemo, useState } from 'react' import { Label_Shadcn_, Switch } from 'ui' @@ -57,14 +58,14 @@ const RealtimeAvatarStackDemo = () => { setUsersMap(newUsers) }) .subscribe(async (status) => { - if (status !== 'SUBSCRIBED') { - return + if (status === REALTIME_SUBSCRIBE_STATES.SUBSCRIBED) { + await room.track({ + name: currentUserName, + image: currentUserImage, + }) + } else { + setUsersMap(null) } - - await room.track({ - name: currentUserName, - image: currentUserImage, - }) }) return () => { From 3b825b886a5da062a98e89003efaa29b70ee9350 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Wed, 15 Oct 2025 17:55:17 +0800 Subject: [PATCH 7/7] add dashboard bug to allow support access (#39552) * add dashboard bug to allow support access * Update tests --- .../components/interfaces/Support/SupportAccessToggle.tsx | 1 + .../interfaces/Support/__tests__/SupportFormPage.test.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/studio/components/interfaces/Support/SupportAccessToggle.tsx b/apps/studio/components/interfaces/Support/SupportAccessToggle.tsx index fad9a7da22ab9..bdb5b8df1ee52 100644 --- a/apps/studio/components/interfaces/Support/SupportAccessToggle.tsx +++ b/apps/studio/components/interfaces/Support/SupportAccessToggle.tsx @@ -20,6 +20,7 @@ export const SUPPORT_ACCESS_CATEGORIES: ExtendedSupportCategories[] = [ SupportCategories.DATABASE_UNRESPONSIVE, SupportCategories.PERFORMANCE_ISSUES, SupportCategories.PROBLEM, + SupportCategories.DASHBOARD_BUG, ] interface SupportAccessToggleProps { diff --git a/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx b/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx index 69e55cd24cd57..00f48457048af 100644 --- a/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx +++ b/apps/studio/components/interfaces/Support/__tests__/SupportFormPage.test.tsx @@ -1296,7 +1296,7 @@ describe('SupportFormPage', () => { organizationSlug: NO_ORG_MARKER, library: '', affectedServices: '', - allowSupportAccess: false, + allowSupportAccess: true, verified: true, tags: ['dashboard-support-form'], browserInformation: 'Chrome',