From 0b97e0ff86aaac4fc73dce80ec9ff0be2f3087df Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Thu, 12 Mar 2026 11:18:28 +0700 Subject: [PATCH] Joshen/fe 2642 project settings allow to select a replica to power (#43626) ## Context Related to dashboard scalability Previous PR: https://github.com/supabase/supabase/pull/42856 Note: Changes are all feature flagged still and I'm still not entirely convinced with the current UX Will iterate as as go along, and only make this publicly available when we're satisfied with the behaviour Adds a "Dashboard preference" section to the project settings image In which users can then select which database they'd like to use for read queries run from the dashboard Note: Everything is local storage for now, but we'd need middleware support if we want to make this setting persist for all users on the project image Added a dialog as well to further explain what this implies image ## To test - Choosing a replica in dashboard preferences will only affect the table editor as thats the only place that's set up so far to use a replica for read queries (I'll need to follow up for other parts of the dashboard in subsequent PRs) --- .../Connect/DatabaseConnectionString.tsx | 2 +- .../ReadReplicaEligibilityWarnings.tsx | 12 +- .../Settings/General/DashboardPreferences.tsx | 194 ++++++++++++ .../interfaces/Settings/General/General.tsx | 290 ++++++------------ .../interfaces/Settings/General/Project.tsx | 111 +++++++ apps/studio/components/interfaces/Sidebar.tsx | 4 +- .../MobileMenuContent/MobileMenuContent.tsx | 2 +- .../NavigationBar/NavigationBar.utils.tsx | 12 +- .../ProjectSettingsLayout/SettingsLayout.tsx | 45 +-- .../SettingsMenu.utils.test.tsx | 75 ++++- .../SettingsMenu.utils.tsx | 59 ++-- .../studio/components/ui/DatabaseSelector.tsx | 27 +- .../components/ui/UpgradePlanButton.tsx | 8 +- .../data/read-replicas/replicas-query.ts | 31 +- .../pages/project/[ref]/settings/general.tsx | 4 +- .../pages/project/[ref]/settings/index.tsx | 15 - .../project/[ref]/settings/preferences.tsx | 40 +++ packages/common/constants/local-storage.ts | 1 + 18 files changed, 596 insertions(+), 336 deletions(-) create mode 100644 apps/studio/components/interfaces/Settings/General/DashboardPreferences.tsx create mode 100644 apps/studio/components/interfaces/Settings/General/Project.tsx delete mode 100644 apps/studio/pages/project/[ref]/settings/index.tsx create mode 100644 apps/studio/pages/project/[ref]/settings/preferences.tsx diff --git a/apps/studio/components/interfaces/Connect/DatabaseConnectionString.tsx b/apps/studio/components/interfaces/Connect/DatabaseConnectionString.tsx index 9000583ef7f..63f703d7325 100644 --- a/apps/studio/components/interfaces/Connect/DatabaseConnectionString.tsx +++ b/apps/studio/components/interfaces/Connect/DatabaseConnectionString.tsx @@ -306,7 +306,7 @@ export const DatabaseConnectionString = () => { align="start" buttonProps={{ size: 'small', - className: 'w-full justify-between pr-2.5 [&_svg]:h-4', + className: 'w-full pr-2.5 [&_svg]:h-4', }} className="w-full md:w-auto [&>span]:w-1/2 [&>span]:md:w-auto" onSelectId={handleDatabaseChange} diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/ReadReplicaForm/ReadReplicaEligibilityWarnings.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/ReadReplicaForm/ReadReplicaEligibilityWarnings.tsx index 62da219a858..0417a681631 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/ReadReplicaForm/ReadReplicaEligibilityWarnings.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/ReadReplicaForm/ReadReplicaEligibilityWarnings.tsx @@ -1,8 +1,4 @@ import { SupportCategories } from '@supabase/shared-types/out/constants' -import Link from 'next/link' -import { useEffect, useState } from 'react' -import { toast } from 'sonner' - import { useParams } from 'common' import { SupportLink } from 'components/interfaces/Support/SupportLink' import { DocsButton } from 'components/ui/DocsButton' @@ -13,8 +9,12 @@ import { MAX_REPLICAS_ABOVE_XL, MAX_REPLICAS_BELOW_XL } from 'data/read-replicas import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { DOCS_URL } from 'lib/constants' +import Link from 'next/link' +import { useEffect, useState } from 'react' +import { toast } from 'sonner' import { Button } from 'ui' import { Admonition } from 'ui-patterns' + import { useCheckEligibilityDeployReplica } from './useCheckEligibilityDeployReplica' export const ReadReplicaEligibilityWarnings = () => { @@ -138,9 +138,7 @@ export const ReadReplicaEligibilityWarnings = () => { addon="computeSize" source="read-replicas" featureProposition="deploy Read Replicas" - > - Change compute size - + /> diff --git a/apps/studio/components/interfaces/Settings/General/DashboardPreferences.tsx b/apps/studio/components/interfaces/Settings/General/DashboardPreferences.tsx new file mode 100644 index 00000000000..050d49db8ab --- /dev/null +++ b/apps/studio/components/interfaces/Settings/General/DashboardPreferences.tsx @@ -0,0 +1,194 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { LOCAL_STORAGE_KEYS, useParams } from 'common' +import { HelpCircle } from 'lucide-react' +import { useForm } from 'react-hook-form' +import { toast } from 'sonner' +import { + Button, + Card, + CardContent, + CardFooter, + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogSection, + DialogSectionSeparator, + DialogTitle, + DialogTrigger, + Form_Shadcn_, + FormControl_Shadcn_, + FormField_Shadcn_, +} from 'ui' +import { Admonition } from 'ui-patterns' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' +import ShimmeringLoader, { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' +import * as z from 'zod' + +import { DatabaseSelector } from '@/components/ui/DatabaseSelector' +import { InlineLink } from '@/components/ui/InlineLink' +import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query' +import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' + +const formSchema = z.object({ + defaultDatabase: z.string().optional(), +}) + +export type DashboardPreference = z.infer + +const DEFAULT_PREFERENCE: DashboardPreference = { + defaultDatabase: undefined, +} + +/** + * [Joshen] JFYI am not convinced about the UX of this, will iterate over time + * and only release to public when we're satisfied with how it behaves + * - Where should "Dashboard preferences" live + * - Preferences currently only apply to the user via local storage until we have middleware support that will persist the setting on the project for all users + * - Should selecting which database to run queries for dashboard be an option for users to configure (or for us to just default to) + * - I'd love for this to work seamlessly, but main concern atm is latency which is region dependent + * - Also, current database logic only applies to Table Editor atm, will need to extend it further to other pages + */ + +export const DashboardPreferences = () => { + const { ref: projectRef } = useParams() + + const [dashboardPreferences, setDashboardPreferences, { isLoading }] = + useLocalStorageQuery( + LOCAL_STORAGE_KEYS.DASHBOARD_PREFERENCES(projectRef ?? '_'), + DEFAULT_PREFERENCE + ) + + const { isPending } = useReadReplicasQuery({ projectRef }) + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: dashboardPreferences, + values: dashboardPreferences, + mode: 'onSubmit', + reValidateMode: 'onBlur', + }) + + const onSubmit = async (values: DashboardPreference) => { + if (!projectRef) return console.error('Ref is required') + setDashboardPreferences(values) + form.reset(values) + toast.success('Successfully saved dashboard preferences!') + } + + return ( + + + {/* [Joshen] Ideally we're able to persist this for all users in the project, but will need support in our middleware */} + + + {isLoading ? ( + + + + + + ) : ( + +
+ + + ( + + All read queries from the dashboard will run against the selected + database by default + +

+ } + className="[&>div]:md:w-1/2" + > + {isPending ? ( + + ) : ( + + {/* [Joshen] Need to disable unhealthy replicas */} + + field.onChange(id === projectRef ? undefined : id) + } + /> + + )} +
+ )} + /> +
+ + {form.formState.isDirty && ( + + )} + + +
+
+
+ )} +
+
+ ) +} + +const DashboardQueriesDialog = () => { + const { ref } = useParams() + return ( + + + + + + + How does the dashboard interact with your project's database? + + + + + +

+ The dashboard queries your project's database to display data across various interfaces, + such as the Table Editor, the{' '} + Auth Users page, and more. +

+ +

+ You can route these queries to a read replica instead, which will help reduce load on + your primary database. +

+
+ + + + + + +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Settings/General/General.tsx b/apps/studio/components/interfaces/Settings/General/General.tsx index 4544eecd26a..03c373f2a3b 100644 --- a/apps/studio/components/interfaces/Settings/General/General.tsx +++ b/apps/studio/components/interfaces/Settings/General/General.tsx @@ -1,28 +1,22 @@ import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { BarChart2 } from 'lucide-react' +import { useProjectUpdateMutation } from 'data/projects/project-update-mutation' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import Link from 'next/link' import { useForm } from 'react-hook-form' import { toast } from 'sonner' -import * as z from 'zod' - -import { useProjectUpdateMutation } from 'data/projects/project-update-mutation' -import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' -import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' -import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { + Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, - Alert_Shadcn_, Button, Card, CardContent, CardFooter, + Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, - FormMessage_Shadcn_, - Form_Shadcn_, Input_Shadcn_, WarningIcon, } from 'ui' @@ -31,25 +25,17 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { PageSection, PageSectionContent, - PageSectionDescription, PageSectionMeta, PageSectionSummary, PageSectionTitle, } from 'ui-patterns/PageSection' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' -import PauseProjectButton from './Infrastructure/PauseProjectButton' -import RestartServerButton from './Infrastructure/RestartServerButton' +import * as z from 'zod' export const General = () => { const { data: project } = useSelectedProjectQuery() - const { data: organization } = useSelectedOrganizationQuery() - const isBranch = Boolean(project?.parent_project_ref) - const { projectSettingsRestartProject } = useIsFeatureEnabled([ - 'project_settings:restart_project', - ]) - const { can: canUpdateProject } = useAsyncCheckPermissions(PermissionAction.UPDATE, 'projects', { resource: { project_id: project?.id, @@ -86,184 +72,104 @@ export const General = () => { } return ( - <> - - - - General settings - - - - {isBranch && ( - - - - You are currently on a preview branch of your project - - - Certain settings are not available while you're on a preview branch. To adjust your - project settings, you may return to your{' '} - - main branch - - . - - - )} + + + + General settings + + + + {isBranch && ( + + + + You are currently on a preview branch of your project + + + Certain settings are not available while you're on a preview branch. To adjust your + project settings, you may return to your{' '} + + main branch + + . + + + )} - {project === undefined ? ( - - ) : ( - -
- - - ( - - - - - - - )} - /> - - - - - - - - - - {form.formState.isDirty && ( - - )} - - - -
-
- )} -
-
- - - - - Project availability - - Restart or pause your project when performing maintenance - - - - + {project === undefined ? ( -
-
-

- {projectSettingsRestartProject ? 'Restart project' : 'Restart database'} -

-
-

- Your project will not be available for a few minutes. -

-
-
- -
-
- -
-
-

Pause project

-
-

- Your project will not be accessible while it is paused. -

-
-
- -
+
-
-
- - {!isBranch && ( - - - - Project usage - - - - - -
-
- -
-

Project usage statistics have been moved

-

- You may view your project's usage under your organization's settings -

-
-
- - {!!organization && !!project && ( - )} -
-
-
-
-
- )} - + + + + + + )} +
+
) } diff --git a/apps/studio/components/interfaces/Settings/General/Project.tsx b/apps/studio/components/interfaces/Settings/General/Project.tsx new file mode 100644 index 00000000000..26d40787504 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/General/Project.tsx @@ -0,0 +1,111 @@ +import { BarChart2 } from 'lucide-react' +import Link from 'next/link' +import { Button, Card, CardContent } from 'ui' +import { + PageSection, + PageSectionContent, + PageSectionDescription, + PageSectionMeta, + PageSectionSummary, + PageSectionTitle, +} from 'ui-patterns/PageSection' + +import PauseProjectButton from './Infrastructure/PauseProjectButton' +import RestartServerButton from './Infrastructure/RestartServerButton' +import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' +import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' + +export const Project = () => { + const { data: project } = useSelectedProjectQuery() + const { data: organization } = useSelectedOrganizationQuery() + const isBranch = Boolean(project?.parent_project_ref) + const { projectSettingsRestartProject } = useIsFeatureEnabled([ + 'project_settings:restart_project', + ]) + + return ( + <> + + + + Project availability + + Restart or pause your project when performing maintenance + + + + + + +
+
+

+ {projectSettingsRestartProject ? 'Restart project' : 'Restart database'} +

+
+

+ Your project will not be available for a few minutes. +

+
+
+ +
+
+ +
+
+

Pause project

+
+

+ Your project will not be accessible while it is paused. +

+
+
+ +
+
+
+
+
+ + {!isBranch && ( + + + + Project usage + + + + + +
+
+ +
+

Project usage statistics have been moved

+

+ You may view your project's usage under your organization's settings +

+
+
+ + {!!organization && !!project && ( + + )} +
+
+
+
+
+ )} + + ) +} diff --git a/apps/studio/components/interfaces/Sidebar.tsx b/apps/studio/components/interfaces/Sidebar.tsx index 7b37b7926ad..fa68d58cd64 100644 --- a/apps/studio/components/interfaces/Sidebar.tsx +++ b/apps/studio/components/interfaces/Sidebar.tsx @@ -258,9 +258,7 @@ const ProjectLinks = () => { showReports, apiDocsSidePanel: isNewAPIDocsEnabled, }) - const settingsRoutes = generateSettingsRoutes(ref, project, { - platformWebhooks: platformWebhooksEnabled, - }) + const settingsRoutes = generateSettingsRoutes(ref) return ( diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/MobileMenuContent.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/MobileMenuContent.tsx index a320c023817..37cb8e129bb 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/MobileMenuContent.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/MobileMenuContent/MobileMenuContent.tsx @@ -99,7 +99,7 @@ export function MobileMenuContent({ }), [ref, project, isUnifiedLogsEnabled, showReports, isNewAPIDocsEnabled] ) - const settingsRoutes = useMemo(() => generateSettingsRoutes(ref, project), [ref, project]) + const settingsRoutes = useMemo(() => generateSettingsRoutes(ref), [ref]) const homeRoute: Route = useMemo( () => ({ diff --git a/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx b/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx index 9b7262cadf0..0bab5712ebf 100644 --- a/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx +++ b/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx @@ -1,5 +1,5 @@ import { ICON_SIZE, ICON_STROKE_WIDTH } from 'components/interfaces/Sidebar' -import { generateSettingsMenu } from 'components/layouts/ProjectSettingsLayout/SettingsMenu.utils' +import { useGenerateSettingsMenu } from 'components/layouts/ProjectSettingsLayout/SettingsMenu.utils' import type { Route } from 'components/ui/ui.types' import { EditorIndexPageLink } from 'data/prefetchers/project.$ref.editor' import type { Project } from 'data/projects/project-detail-query' @@ -182,14 +182,7 @@ export const generateOtherRoutes = ( ] } -export const generateSettingsRoutes = ( - ref?: string, - project?: Project, - features?: { platformWebhooks?: boolean } -): Route[] => { - const settingsMenu = generateSettingsMenu(ref as string, undefined, undefined, { - platformWebhooks: features?.platformWebhooks, - }) +export const generateSettingsRoutes = (ref?: string): Route[] => { return [ { key: 'settings', @@ -198,7 +191,6 @@ export const generateSettingsRoutes = ( link: ref && (IS_PLATFORM ? `/project/${ref}/settings/general` : `/project/${ref}/settings/log-drains`), - items: settingsMenu, disabled: false, }, ] diff --git a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsLayout.tsx b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsLayout.tsx index 03b8c1ccce3..50ddecf86c9 100644 --- a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsLayout.tsx +++ b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsLayout.tsx @@ -1,15 +1,10 @@ -import { useParams } from 'common' -import { useIsPlatformWebhooksEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { ProductMenu } from 'components/ui/ProductMenu' -import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' -import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' -import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { withAuth } from 'hooks/misc/withAuth' import { useRouter } from 'next/router' import type { PropsWithChildren } from 'react' import { ProjectLayout } from '../ProjectLayout' -import { generateSettingsMenu } from './SettingsMenu.utils' +import { useGenerateSettingsMenu } from './SettingsMenu.utils' /** * Menu-only component for the settings section. Used by the desktop sidebar and by the @@ -18,48 +13,14 @@ import { generateSettingsMenu } from './SettingsMenu.utils' */ export const SettingsProductMenu = () => { const router = useRouter() - const { ref } = useParams() - const { data: project } = useSelectedProjectQuery() - const { data: organization } = useSelectedOrganizationQuery() - const platformWebhooksEnabled = useIsPlatformWebhooksEnabled() const page = router.pathname.includes('billing') ? router.pathname.split('/')[5] : router.pathname.split('/')[4] - const { - projectAuthAll: authEnabled, - authenticationShowProviders: authProvidersEnabled, - projectEdgeFunctionAll: edgeFunctionsEnabled, - projectStorageAll: storageEnabled, - billingInvoices: invoicesEnabled, - projectSettingsLegacyJwtKeys: legacyJWTKeysEnabled, - projectSettingsLogDrains, - billingAll, - } = useIsFeatureEnabled([ - 'project_auth:all', - 'authentication:show_providers', - 'project_edge_function:all', - 'project_storage:all', - 'billing:invoices', - 'project_settings:legacy_jwt_keys', - 'project_settings:log_drains', - 'billing:all', - ]) + const menu = useGenerateSettingsMenu() - const menuRoutes = generateSettingsMenu(ref, project, organization, { - auth: authEnabled, - authProviders: authProvidersEnabled, - edgeFunctions: edgeFunctionsEnabled, - storage: storageEnabled, - invoices: invoicesEnabled, - legacyJwtKeys: legacyJWTKeysEnabled, - logDrains: projectSettingsLogDrains, - billing: billingAll, - platformWebhooks: platformWebhooksEnabled, - }) - - return + return } interface SettingsLayoutProps { diff --git a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.test.tsx b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.test.tsx index 7ed196c96dc..001e5ff6012 100644 --- a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.test.tsx +++ b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.test.tsx @@ -1,5 +1,10 @@ +import { renderHook } from '@testing-library/react' +import { useFlag } from 'common' import { describe, expect, it, vi } from 'vitest' +import { useGenerateSettingsMenu } from './SettingsMenu.utils' +import { useIsPlatformWebhooksEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' + vi.mock('lib/constants', async () => { const actual = await vi.importActual>('lib/constants') return { @@ -8,18 +13,35 @@ vi.mock('lib/constants', async () => { } }) -import { generateSettingsMenu } from './SettingsMenu.utils' +vi.mock('common', () => ({ + useFlag: vi.fn().mockReturnValue(false), + useParams: vi.fn().mockReturnValue({ ref: 'project-ref' }), +})) -describe('generateSettingsMenu', () => { - it('includes webhooks in project settings items', () => { - const menu = generateSettingsMenu( - 'project-ref', - { status: 'ACTIVE_HEALTHY' } as any, - { slug: 'my-org' } as any, - { platformWebhooks: true } - ) +vi.mock('@/hooks/misc/useSelectedOrganization', () => ({ + useSelectedOrganizationQuery: vi.fn().mockReturnValue({ data: { slug: 'my-org' } }), +})) - const configurationGroup = menu.find((group) => group.title === 'Configuration') +vi.mock('@/hooks/misc/useSelectedProject', () => ({ + useSelectedProjectQuery: vi.fn().mockReturnValue({ data: { status: 'ACTIVE_HEALTHY' } }), +})) + +vi.mock('@/hooks/misc/useIsFeatureEnabled', () => ({ + useIsFeatureEnabled: vi + .fn() + .mockReturnValue({ projectSettingsLegacyJwtKeys: false, billingAll: true }), +})) + +vi.mock('@/components/interfaces/App/FeaturePreview/FeaturePreviewContext', () => ({ + useIsPlatformWebhooksEnabled: vi.fn().mockReturnValue(true), +})) + +describe('useGenerateSettingsMenu', () => { + it('includes webhooks when platformWebhooks feature is enabled', () => { + vi.mocked(useIsPlatformWebhooksEnabled).mockReturnValue(true) + + const { result } = renderHook(() => useGenerateSettingsMenu()) + const configurationGroup = result.current.find((group) => group.title === 'Configuration') const hasWebhooks = configurationGroup?.items.some( (item) => item.name === 'Webhooks' && item.url === '/project/project-ref/settings/webhooks' ) @@ -28,16 +50,35 @@ describe('generateSettingsMenu', () => { }) it('hides webhooks when platformWebhooks feature is disabled', () => { - const menu = generateSettingsMenu( - 'project-ref', - { status: 'ACTIVE_HEALTHY' } as any, - { slug: 'my-org' } as any, - { platformWebhooks: false } - ) + vi.mocked(useIsPlatformWebhooksEnabled).mockReturnValue(false) - const configurationGroup = menu.find((group) => group.title === 'Configuration') + const { result } = renderHook(() => useGenerateSettingsMenu()) + const configurationGroup = result.current.find((group) => group.title === 'Configuration') const hasWebhooks = configurationGroup?.items.some((item) => item.name === 'Webhooks') expect(hasWebhooks).toBe(false) }) + + it('includes dashboard preferences when flag is enabled', () => { + vi.mocked(useFlag).mockReturnValue(true) + + const { result } = renderHook(() => useGenerateSettingsMenu()) + const preferencesGroup = result.current.find((group) => group.title === 'Preferences') + const hasDashboardPreferences = preferencesGroup?.items.some( + (item) => + item.name === 'Dashboard preferences' && + item.url === '/project/project-ref/settings/preferences' + ) + + expect(hasDashboardPreferences).toBe(true) + }) + + it('hides dashboard preferences when flag is disabled', () => { + vi.mocked(useFlag).mockReturnValue(false) + + const { result } = renderHook(() => useGenerateSettingsMenu()) + const preferencesGroup = result.current.find((group) => group.title === 'Preferences') + + expect(preferencesGroup).toBeUndefined() + }) }) diff --git a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx index 7b6e2aec480..da1ce946586 100644 --- a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx +++ b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx @@ -1,25 +1,25 @@ -import type { ProductMenuGroup } from 'components/ui/ProductMenu/ProductMenu.types' -import type { Project } from 'data/projects/project-detail-query' +import { useFlag, useParams } from 'common' import { IS_PLATFORM, PROJECT_STATUS } from 'lib/constants' import { ArrowUpRight } from 'lucide-react' -import type { Organization } from 'types' -export const generateSettingsMenu = ( - ref?: string, - project?: Project, - organization?: Organization, - features?: { - auth?: boolean - authProviders?: boolean - edgeFunctions?: boolean - storage?: boolean - invoices?: boolean - legacyJwtKeys?: boolean - logDrains?: boolean - billing?: boolean - platformWebhooks?: boolean - } -): ProductMenuGroup[] => { +import { useIsPlatformWebhooksEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' +import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' +import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' +import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' + +export const useGenerateSettingsMenu = () => { + const { ref } = useParams() + const { data: project } = useSelectedProjectQuery() + const { data: organization } = useSelectedOrganizationQuery() + + const showDashboardPreferences = useFlag('dashboardPreferences') + const platformWebhooksEnabled = useIsPlatformWebhooksEnabled() + + const { projectSettingsLegacyJwtKeys: legacyJwtKeysEnabled, billingAll: billingEnabled } = + useIsFeatureEnabled(['project_settings:legacy_jwt_keys', 'billing:all']) + + const isProjectActive = project?.status === PROJECT_STATUS.ACTIVE_HEALTHY + if (!IS_PLATFORM) { return [ { @@ -36,12 +36,6 @@ export const generateSettingsMenu = ( ] } - const isProjectActive = project?.status === PROJECT_STATUS.ACTIVE_HEALTHY - - const legacyJwtKeysEnabled = features?.legacyJwtKeys ?? true - const billingEnabled = features?.billing ?? true - const platformWebhooksEnabled = features?.platformWebhooks ?? false - return [ { title: 'Configuration', @@ -118,6 +112,21 @@ export const generateSettingsMenu = ( }, ], }, + ...(IS_PLATFORM && showDashboardPreferences + ? [ + { + title: 'Preferences', + items: [ + { + name: 'Dashboard preferences', + key: 'preferences', + url: `/project/${ref}/settings/preferences`, + items: [], + }, + ], + }, + ] + : []), { title: 'Integrations', items: [ diff --git a/apps/studio/components/ui/DatabaseSelector.tsx b/apps/studio/components/ui/DatabaseSelector.tsx index 23143762b62..bf73976adab 100644 --- a/apps/studio/components/ui/DatabaseSelector.tsx +++ b/apps/studio/components/ui/DatabaseSelector.tsx @@ -37,6 +37,7 @@ interface DatabaseSelectorProps { onSelectId?: (id: string) => void // Optional callback className?: string align?: 'start' | 'end' + isForm?: boolean } export const DatabaseSelector = ({ @@ -47,6 +48,7 @@ export const DatabaseSelector = ({ buttonProps, align = 'end', className, + isForm = false, }: DatabaseSelectorProps) => { const router = useRouter() const { ref: projectRef } = useParams() @@ -73,7 +75,7 @@ export const DatabaseSelector = ({ const newReplicaURL = `/project/${projectRef}/database/replication?type=Read+Replica` useEffect(() => { - if (_selectedDatabaseId) state.setSelectedDatabaseId(_selectedDatabaseId) + if (_selectedDatabaseId && !isForm) state.setSelectedDatabaseId(_selectedDatabaseId) // eslint-disable-next-line react-hooks/exhaustive-deps }, [_selectedDatabaseId]) @@ -81,16 +83,21 @@ export const DatabaseSelector = ({
- - Source - + {!isForm && ( + + Source + + )}