diff --git a/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx b/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx index cbe1a7e379bda..394a533578810 100644 --- a/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UsersV2.tsx @@ -14,6 +14,7 @@ import { FilterPopover } from 'components/ui/FilterPopover' import { FormHeader } from 'components/ui/Forms/FormHeader' import { authKeys } from 'data/auth/keys' import { useUserDeleteMutation } from 'data/auth/user-delete-mutation' +import { useUsersCountQuery } from 'data/auth/users-count-query' import { User, useUsersInfiniteQuery } from 'data/auth/users-infinite-query' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' @@ -54,6 +55,8 @@ import { formatUserColumns, formatUsersData } from './Users.utils' import { UsersFooter } from './UsersFooter' import { UsersSearch } from './UsersSearch' +const SORT_BY_VALUE_COUNT_THRESHOLD = 10_000 + export const UsersV2 = () => { const queryClient = useQueryClient() const { ref: projectRef } = useParams() @@ -97,7 +100,7 @@ export const UsersV2 = () => { parseAsStringEnum(['all', 'verified', 'unverified', 'anonymous']).withDefault('all') ) const [filterKeywords, setFilterKeywords] = useQueryState('keywords', { defaultValue: '' }) - const [sortByValue, setSortByValue] = useQueryState('id:asc', { defaultValue: 'id:asc' }) + const [sortByValue, setSortByValue] = useQueryState('sortBy', { defaultValue: 'id:asc' }) const [sortColumn, sortOrder] = sortByValue.split(':') const [selectedColumns, setSelectedColumns] = useQueryState( 'columns', @@ -116,6 +119,15 @@ export const UsersV2 = () => { 'id' ) + const [ + localStorageSortByValue, + setLocalStorageSortByValue, + { isSuccess: isLocalStorageSortByValueLoaded }, + ] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.AUTH_USERS_SORT_BY_VALUE(projectRef ?? ''), + 'id' + ) + const [ columnConfiguration, setColumnConfiguration, @@ -134,6 +146,23 @@ export const UsersV2 = () => { const [isDeletingUsers, setIsDeletingUsers] = useState(false) const [showFreeformWarning, setShowFreeformWarning] = useState(false) + const { data: totalUsersCountData, isSuccess: isCountLoaded } = useUsersCountQuery( + { + projectRef, + connectionString: project?.connectionString, + // [Joshen] Do not change the following, these are to match the count query in UsersFooter + // on initial load with no search configuration so that we only fire 1 count request at the + // beginning. The count value is for all users - should disregard any search configuration + keywords: '', + filter: undefined, + providers: [], + forceExactCount: false, + }, + { keepPreviousData: true } + ) + const totalUsers = totalUsersCountData?.count ?? 0 + const isCountWithinThresholdForSortBy = totalUsers <= SORT_BY_VALUE_COUNT_THRESHOLD + const { data, error, @@ -195,6 +224,19 @@ export const UsersV2 = () => { organization: selectedOrg?.slug ?? 'Unknown', } + const updateStorageFilter = (value: 'id' | 'email' | 'phone' | 'freeform') => { + setLocalStorageFilter(value) + setSpecificFilterColumn(value) + if (value !== 'freeform') { + updateSortByValue('id:asc') + } + } + + const updateSortByValue = (value: string) => { + if (isCountWithinThresholdForSortBy) setLocalStorageSortByValue(value) + setSortByValue(value) + } + const handleScroll = (event: UIEvent) => { const isScrollingHorizontally = xScroll.current !== event.currentTarget.scrollLeft xScroll.current = event.currentTarget.scrollLeft @@ -281,7 +323,7 @@ export const UsersV2 = () => { config: columnConfiguration ?? [], users: users ?? [], visibleColumns: selectedColumns, - setSortByValue, + setSortByValue: updateSortByValue, onSelectDeleteUser: setSelectedUserToDelete, }) setColumns(columns) @@ -301,16 +343,22 @@ export const UsersV2 = () => { specificFilterColumn, ]) - // [Joshen] Load URL state for filter column only once, if no filter column found in URL params + // [Joshen] Load URL state for filter column and sort by only once, if no respective values found in URL params useEffect(() => { - if (specificFilterColumn === 'id' && localStorageFilter !== 'id') { - setSpecificFilterColumn(localStorageFilter) + if ( + isLocalStorageFilterLoaded && + isLocalStorageSortByValueLoaded && + isCountLoaded && + isCountWithinThresholdForSortBy + ) { + if (specificFilterColumn === 'id' && localStorageFilter !== 'id') { + setSpecificFilterColumn(localStorageFilter) + } + if (sortByValue === 'id:asc' && localStorageSortByValue !== 'id:asc') { + setSortByValue(localStorageSortByValue) + } } - }, []) - - useEffect(() => { - setLocalStorageFilter(specificFilterColumn) - }, [specificFilterColumn]) + }, [isLocalStorageFilterLoaded, isLocalStorageSortByValueLoaded, isCountLoaded]) return ( <> @@ -353,9 +401,13 @@ export const UsersV2 = () => { }} setSpecificFilterColumn={(value) => { if (value === 'freeform') { - setShowFreeformWarning(true) + if (isCountWithinThresholdForSortBy) { + updateStorageFilter(value) + } else { + setShowFreeformWarning(true) + } } else { - setSpecificFilterColumn(value) + updateStorageFilter(value) } }} /> @@ -469,7 +521,7 @@ export const UsersV2 = () => { config: updatedConfig, users: users ?? [], visibleColumns: value, - setSortByValue, + setSortByValue: updateSortByValue, onSelectDeleteUser: setSelectedUserToDelete, }) @@ -486,7 +538,7 @@ export const UsersV2 = () => { sortByValue={sortByValue} setSortByValue={(value) => { const [sortColumn, sortOrder] = value.split(':') - setSortByValue(value) + updateSortByValue(value) sendEvent({ action: 'auth_users_search_submitted', properties: { @@ -669,15 +721,15 @@ export const UsersV2 = () => { confirmLabel="Confirm" title="Confirm to search across all columns" onConfirm={() => { - setSpecificFilterColumn('freeform') + updateStorageFilter('freeform') setShowFreeformWarning(false) }} onCancel={() => setShowFreeformWarning(false)} alert={{ base: { variant: 'warning' }, - title: 'Searching across all columns is not recommended', + title: 'Searching across all columns is not recommended with many users', description: - 'This may adversely impact your database, in particular if your project has a large number of users - use with caution.', + 'This may adversely impact your database, in particular if your project has a large number of users - use with caution. Search mode will not be persisted across browser sessions as a safeguard.', }} >

diff --git a/apps/studio/components/interfaces/Support/MessageField.tsx b/apps/studio/components/interfaces/Support/MessageField.tsx index 1a0196f3aba00..7fb4208442c1e 100644 --- a/apps/studio/components/interfaces/Support/MessageField.tsx +++ b/apps/studio/components/interfaces/Support/MessageField.tsx @@ -41,7 +41,7 @@ export function MessageField({ form, originalError }: MessageFieldProps) { diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx index 9e5083fd63326..4678cf0a7af9b 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx @@ -28,7 +28,7 @@ import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2' import { Button, cn, KeyboardShortcut } from 'ui' import { Admonition } from 'ui-patterns' import { ButtonTooltip } from '../ButtonTooltip' -import { ErrorBoundary } from '../ErrorBoundary' +import { ErrorBoundary } from '../ErrorBoundary/ErrorBoundary' import type { SqlSnippet } from './AIAssistant.types' import { onErrorChat } from './AIAssistant.utils' import { AIAssistantHeader } from './AIAssistantHeader' diff --git a/apps/studio/components/ui/ErrorBoundary/ClientSideExceptionHandler.tsx b/apps/studio/components/ui/ErrorBoundary/ClientSideExceptionHandler.tsx new file mode 100644 index 0000000000000..ebfaeaa55f9a5 --- /dev/null +++ b/apps/studio/components/ui/ErrorBoundary/ClientSideExceptionHandler.tsx @@ -0,0 +1,115 @@ +import { ExternalLink } from 'lucide-react' + +import { SupportCategories } from '@supabase/shared-types/out/constants' +import { SupportLink } from 'components/interfaces/Support/SupportLink' +import { useRouter } from 'next/router' +import { Button, cn } from 'ui' +import { Admonition } from 'ui-patterns' +import CopyButton from '../CopyButton' +import { InlineLinkClassName } from '../InlineLink' + +interface ClientSideExceptionHandlerProps { + message: string + sentryIssueId: string + urlMessage: string + resetErrorBoundary: () => void +} + +export const ClientSideExceptionHandler = ({ + message, + sentryIssueId, + urlMessage, + resetErrorBoundary, +}: ClientSideExceptionHandlerProps) => { + const router = useRouter() + + const isProduction = process.env.NEXT_PUBLIC_ENVIRONMENT !== 'prod' + + const handleClearStorage = () => { + try { + localStorage.clear() + sessionStorage.clear() + } catch (e) { + // ignore + } + window.location.reload() + } + + return ( + <> +

+
+

Sorry! An unexpected error occurred.

+ +
+

+ Application error: a client-side exception has occurred (see browser console for more + information) +

+

{message}

+
+ +
    +
  • + window.location.reload()} + > + Refresh + {' '} + the page +
  • +
  • + router.push('/logout')} + > + Sign out + {' '} + and sign back in +
  • +
  • + + Clear your browser storage + {' '} + to clean potentially outdated data +
  • +
  • + Disable browser extensions that might modify page content (e.g., Google Translate) +
  • +
  • If the problem persists, please contact support for assistance
  • +
+
+ +
+ + + {/* [Joshen] For local and staging, allow us to escape the error boundary */} + {/* We could actually investigate how to make this available on prod, but without being able to reliably test this, I'm not keen to do it now */} + {isProduction ? ( + + ) : ( + + )} +
+ + ) +} diff --git a/apps/studio/components/ui/ErrorBoundary.tsx b/apps/studio/components/ui/ErrorBoundary/ErrorBoundary.tsx similarity index 100% rename from apps/studio/components/ui/ErrorBoundary.tsx rename to apps/studio/components/ui/ErrorBoundary/ErrorBoundary.tsx diff --git a/apps/studio/components/ui/ErrorBoundary/GlobalErrorBoundaryState.tsx b/apps/studio/components/ui/ErrorBoundary/GlobalErrorBoundaryState.tsx new file mode 100644 index 0000000000000..4efebda3cb894 --- /dev/null +++ b/apps/studio/components/ui/ErrorBoundary/GlobalErrorBoundaryState.tsx @@ -0,0 +1,65 @@ +import { isError } from 'lodash' +import Link from 'next/link' +import { useRouter } from 'next/router' + +import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' +import { ClientSideExceptionHandler } from './ClientSideExceptionHandler' +import { InsertBeforeRemoveChildErrorHandler } from './InsertBeforeRemoveChildErrorHandler' + +export type FallbackProps = { + error: unknown + resetErrorBoundary: (...args: unknown[]) => void +} + +export const GlobalErrorBoundaryState = ({ error, resetErrorBoundary }: FallbackProps) => { + const router = useRouter() + const checkIsError = isError(error) + + const largeLogo = useIsFeatureEnabled('branding:large_logo') + + const errorMessage = checkIsError ? error.message : '' + const urlMessage = checkIsError ? `Path name: ${router.pathname}\n\n${error?.stack}` : '' + + const isRemoveChildError = checkIsError + ? errorMessage.includes("Failed to execute 'removeChild' on 'Node'") + : false + const isInsertBeforeError = checkIsError + ? errorMessage.includes("Failed to execute 'insertBefore' on 'Node'") + : false + + // Get Sentry issue ID from error if available + const sentryIssueId = (!!error && typeof error === 'object' && (error as any).sentryId) ?? '' + + return ( +
+
+ + Supabase + +
+ +
+ {isRemoveChildError || isInsertBeforeError ? ( + + ) : ( + + )} +
+
+ ) +} diff --git a/apps/studio/components/ui/ErrorBoundary/InsertBeforeRemoveChildErrorHandler.tsx b/apps/studio/components/ui/ErrorBoundary/InsertBeforeRemoveChildErrorHandler.tsx new file mode 100644 index 0000000000000..77501d94a1b36 --- /dev/null +++ b/apps/studio/components/ui/ErrorBoundary/InsertBeforeRemoveChildErrorHandler.tsx @@ -0,0 +1,89 @@ +import { SupportCategories } from '@supabase/shared-types/out/constants' +import { Blocks, ExternalLink } from 'lucide-react' +import { useRouter } from 'next/router' + +import { SupportLink } from 'components/interfaces/Support/SupportLink' +import { detectBrowser } from 'lib/helpers' +import { Button } from 'ui' + +interface InsertBeforeRemoveChildErrorHandlerProps { + message: string + sentryIssueId: string + urlMessage: string + isRemoveChildError: boolean + isInsertBeforeError?: boolean +} + +export const InsertBeforeRemoveChildErrorHandler = ({ + message, + sentryIssueId, + urlMessage, + isRemoveChildError, + isInsertBeforeError, +}: InsertBeforeRemoveChildErrorHandlerProps) => { + const router = useRouter() + const browser = detectBrowser() + + return ( + <> +
+
+

Sorry! A browser extension may have caused an error.

+ +
+ +
+

+ Browser translation tools (like Chrome's built-in Translate) or some third-party browser + extensions are known to cause errors when using the Supabase Dashboard. +

+ +

+ We highly recommend{' '} + + {browser === 'Chrome' + ? 'disabling Chrome Translate or certain browser extensions' + : 'avoiding the use of browser translation tools or disabling certain extensions'} + {' '} + while using the Supabase Dashboard to avoid running into this error. Try to refresh the + browser to see if it occurs again. +

+
+ +

Error: {message}

+
+ +
+ + +
+ + Still stuck? + + + ) +} diff --git a/apps/studio/components/ui/GlobalErrorBoundaryState.tsx b/apps/studio/components/ui/GlobalErrorBoundaryState.tsx deleted file mode 100644 index 6747f5717f491..0000000000000 --- a/apps/studio/components/ui/GlobalErrorBoundaryState.tsx +++ /dev/null @@ -1,200 +0,0 @@ -import { isError } from 'lodash' -import { ExternalLink } from 'lucide-react' -import Link from 'next/link' -import { useRouter } from 'next/router' - -import { SupportCategories } from '@supabase/shared-types/out/constants' -import { SupportLink } from 'components/interfaces/Support/SupportLink' -import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' -import { Button, cn } from 'ui' -import { Admonition } from 'ui-patterns' -import CopyButton from './CopyButton' -import { InlineLinkClassName } from './InlineLink' - -export type FallbackProps = { - error: unknown - resetErrorBoundary: (...args: any[]) => void -} - -export const GlobalErrorBoundaryState = ({ error, resetErrorBoundary }: FallbackProps) => { - const router = useRouter() - const checkIsError = isError(error) - - const largeLogo = useIsFeatureEnabled('branding:large_logo') - - const errorMessage = checkIsError ? error.message : '' - const urlMessage = checkIsError ? `Path name: ${router.pathname}\n\n${error?.stack}` : '' - const isRemoveChildError = checkIsError - ? errorMessage.includes("Failed to execute 'removeChild' on 'Node'") - : false - const isInsertBeforeError = checkIsError - ? errorMessage.includes("Failed to execute 'insertBefore' on 'Node'") - : false - - // Get Sentry issue ID from error if available - const sentryIssueId = (!!error && typeof error === 'object' && (error as any).sentryId) ?? '' - - const handleClearStorage = () => { - try { - localStorage.clear() - sessionStorage.clear() - } catch (e) { - // ignore - } - window.location.reload() - } - - return ( -
-
- - Supabase - -
- -
-
-
-

Sorry! An unexpected error occurred.

- -
-

- Application error: a client-side exception has occurred (see browser console for more - information) -

-

{errorMessage}

-
- {isRemoveChildError || isInsertBeforeError ? ( - -

- Try to avoid using Google translate or disable certain browser extensions to avoid - running into the{' '} - - {isRemoveChildError - ? `'removeChild' on 'Node'` - : isInsertBeforeError - ? `'insertBefore' on 'Node'` - : ''} - {' '} - error.{' '} - window.location.reload()} - > - Refresh - {' '} - the browser to see if occurs again. -

- -
- ) : ( - -
    -
  • - window.location.reload()} - > - Refresh - {' '} - the page -
  • -
  • - router.push('/logout')} - > - Sign out - {' '} - and sign back in -
  • -
  • - - Clear your browser storage - {' '} - to clean potentially outdated data -
  • -
  • - Disable browser extensions that might modify page content (e.g., Google Translate) -
  • -
  • If the problem persists, please contact support for assistance
  • -
-
- )} -
- {!isRemoveChildError && !isInsertBeforeError && ( - - )} - - {/* [Joshen] For local and staging, allow us to escape the error boundary */} - {/* We could actually investigate how to make this available on prod, but without being able to reliably test this, I'm not keen to do it now */} - {process.env.NEXT_PUBLIC_ENVIRONMENT !== 'prod' ? ( - - ) : ( - - )} - - {(isRemoveChildError || isInsertBeforeError) && ( - - Still stuck? - - )} -
-
-
- ) -} diff --git a/apps/studio/pages/_app.tsx b/apps/studio/pages/_app.tsx index 04aac87207aa9..fb4ae48c14c00 100644 --- a/apps/studio/pages/_app.tsx +++ b/apps/studio/pages/_app.tsx @@ -48,7 +48,7 @@ import { StudioCommandMenu } from 'components/interfaces/App/CommandMenu' import { FeaturePreviewContextProvider } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import FeaturePreviewModal from 'components/interfaces/App/FeaturePreview/FeaturePreviewModal' import { MonacoThemeProvider } from 'components/interfaces/App/MonacoThemeProvider' -import { GlobalErrorBoundaryState } from 'components/ui/GlobalErrorBoundaryState' +import { GlobalErrorBoundaryState } from 'components/ui/ErrorBoundary/GlobalErrorBoundaryState' import { useRootQueryClient } from 'data/query-client' import { customFont, sourceCodePro } from 'fonts' import { useCustomContent } from 'hooks/custom-content/useCustomContent' @@ -143,6 +143,7 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) { {appTitle ?? 'Supabase'} + {/* [Alaister]: This has to be an inline style tag here and not a separate component due to next/font */}