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
<img width="265" height="740" alt="image"
src="https://github.com/user-attachments/assets/6ce1aa19-26c2-47c6-a9c4-595137266631"
/>

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
<img width="791" height="434" alt="image"
src="https://github.com/user-attachments/assets/e651d6d9-fed4-4da4-b552-c9f93f8d46d3"
/>

Added a dialog as well to further explain what this implies
<img width="610" height="312" alt="image"
src="https://github.com/user-attachments/assets/0aa957af-cb51-476f-aa79-8948a7cbe5ae"
/>

## 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)
This commit is contained in:
Joshen Lim authored and GitHub committed 2026-03-12 11:18:28 +07:00
1 parent a753a599ea
commit 0b97e0ff86
18 files changed
+596 -336

No files matched your search

@@ -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}
@@ -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
</UpgradePlanButton>
/>
<DocsButton href={`${DOCS_URL}/guides/platform/read-replicas#prerequisites`} />
</div>
</Admonition>
@@ -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<typeof formSchema>
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<DashboardPreference>(
LOCAL_STORAGE_KEYS.DASHBOARD_PREFERENCES(projectRef ?? '_'),
DEFAULT_PREFERENCE
)
const { isPending } = useReadReplicasQuery({ projectRef })
const form = useForm<z.infer<typeof formSchema>>({
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 (
<PageSection>
<PageSectionContent className="flex flex-col gap-y-4">
{/* [Joshen] Ideally we're able to persist this for all users in the project, but will need support in our middleware */}
<Admonition
type="note"
title="Preferences currently do not affect other members of this project"
/>
{isLoading ? (
<Card>
<CardContent>
<GenericSkeletonLoader />
</CardContent>
</Card>
) : (
<Form_Shadcn_ {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<Card>
<CardContent>
<FormField_Shadcn_
control={form.control}
name="defaultDatabase"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Preferred database for dashboard queries"
description={
<p>
All read queries from the dashboard will run against the selected
database by default
<DashboardQueriesDialog />
</p>
}
className="[&>div]:md:w-1/2"
>
{isPending ? (
<ShimmeringLoader />
) : (
<FormControl_Shadcn_>
{/* [Joshen] Need to disable unhealthy replicas */}
<DatabaseSelector
isForm
buttonProps={{ size: 'small', className: 'w-full' }}
selectedDatabaseId={field.value ?? projectRef}
onSelectId={(id) =>
field.onChange(id === projectRef ? undefined : id)
}
/>
</FormControl_Shadcn_>
)}
</FormItemLayout>
)}
/>
</CardContent>
<CardFooter className="justify-end space-x-2">
{form.formState.isDirty && (
<Button
type="default"
htmlType="button"
onClick={() => form.reset(dashboardPreferences)}
>
Cancel
</Button>
)}
<Button type="primary" htmlType="submit" disabled={!form.formState.isDirty}>
Save changes
</Button>
</CardFooter>
</Card>
</form>
</Form_Shadcn_>
)}
</PageSectionContent>
</PageSection>
)
}
const DashboardQueriesDialog = () => {
const { ref } = useParams()
return (
<Dialog>
<DialogTrigger className="ml-1 translate-y-0.5">
<HelpCircle size={14} className="hover:text-foreground transition" />
</DialogTrigger>
<DialogContent aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>How does the dashboard interact with your project's database?</DialogTitle>
</DialogHeader>
<DialogSectionSeparator />
<DialogSection className="flex flex-col gap-y-2">
<p className="text-sm">
The dashboard queries your project's database to display data across various interfaces,
such as the <InlineLink href={`/project/${ref}/editor`}>Table Editor</InlineLink>, the{' '}
<InlineLink href={`/project/${ref}/auth/users`}>Auth Users</InlineLink> page, and more.
</p>
<p className="text-sm">
You can route these queries to a read replica instead, which will help reduce load on
your primary database.
</p>
</DialogSection>
<DialogFooter>
<DialogClose asChild className="opacity-100">
<Button type="default">Understood</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -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 (
<>
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>General settings</PageSectionTitle>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
{isBranch && (
<Alert_Shadcn_ variant="default">
<WarningIcon />
<AlertTitle_Shadcn_>
You are currently on a preview branch of your project
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
Certain settings are not available while you're on a preview branch. To adjust your
project settings, you may return to your{' '}
<Link
href={`/project/${project?.parent_project_ref}/settings/general`}
className="text-brand"
>
main branch
</Link>
.
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)}
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>General settings</PageSectionTitle>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
{isBranch && (
<Alert_Shadcn_ variant="default">
<WarningIcon />
<AlertTitle_Shadcn_>
You are currently on a preview branch of your project
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
Certain settings are not available while you're on a preview branch. To adjust your
project settings, you may return to your{' '}
<Link
href={`/project/${project?.parent_project_ref}/settings/general`}
className="text-brand"
>
main branch
</Link>
.
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)}
{project === undefined ? (
<GenericSkeletonLoader />
) : (
<Form_Shadcn_ {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<Card>
<CardContent>
<FormField_Shadcn_
control={form.control}
name="name"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Project name"
description="Displayed throughout the dashboard."
className="[&>div]:md:w-1/2"
>
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
disabled={isBranch || !canUpdateProject}
autoComplete="off"
/>
</FormControl_Shadcn_>
<FormMessage_Shadcn_ />
</FormItemLayout>
)}
/>
</CardContent>
<CardContent>
<FormItemLayout
layout="flex-row-reverse"
label="Project ID"
description="Reference used in APIs and URLs."
className="[&>div]:md:w-1/2 [&>div>div]:md:w-full"
>
<FormControl_Shadcn_>
<Input copy readOnly size="small" value={project?.ref ?? ''} />
</FormControl_Shadcn_>
</FormItemLayout>
</CardContent>
<CardFooter className="justify-end space-x-2">
{form.formState.isDirty && (
<Button
type="default"
htmlType="button"
disabled={isUpdating}
onClick={() => form.reset({ name: project?.name ?? '' })}
>
Cancel
</Button>
)}
<Button
type="primary"
htmlType="submit"
disabled={
!form.formState.isDirty || isUpdating || !canUpdateProject || isBranch
}
loading={isUpdating}
>
Save changes
</Button>
</CardFooter>
</Card>
</form>
</Form_Shadcn_>
)}
</PageSectionContent>
</PageSection>
<PageSection id="restart-project">
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Project availability</PageSectionTitle>
<PageSectionDescription>
Restart or pause your project when performing maintenance
</PageSectionDescription>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
{project === undefined ? (
<Card>
<CardContent>
<div className="flex flex-col @lg:flex-row @lg:justify-between @lg:items-center gap-4">
<div>
<p className="text-sm">
{projectSettingsRestartProject ? 'Restart project' : 'Restart database'}
</p>
<div className="max-w-[420px]">
<p className="text-sm text-foreground-light">
Your project will not be available for a few minutes.
</p>
</div>
</div>
<RestartServerButton />
</div>
</CardContent>
<CardContent>
<div
className="flex w-full flex-col @lg:flex-row @lg:justify-between @lg:items-center gap-4"
id="pause-project"
>
<div>
<p className="text-sm">Pause project</p>
<div className="max-w-[420px]">
<p className="text-sm text-foreground-light">
Your project will not be accessible while it is paused.
</p>
</div>
</div>
<PauseProjectButton />
</div>
<GenericSkeletonLoader />
</CardContent>
</Card>
</PageSectionContent>
</PageSection>
{!isBranch && (
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Project usage</PageSectionTitle>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
<Card>
<CardContent>
<div className="flex flex-col @lg:flex-row @lg:justify-between @lg:items-center gap-4">
<div className="flex space-x-4">
<BarChart2 strokeWidth={2} />
<div>
<p className="text-sm">Project usage statistics have been moved</p>
<p className="text-foreground-light text-sm">
You may view your project's usage under your organization's settings
</p>
</div>
</div>
{!!organization && !!project && (
<Button asChild type="default">
<Link href={`/org/${organization.slug}/usage?projectRef=${project.ref}`}>
View project usage
</Link>
) : (
<Form_Shadcn_ {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<Card>
<CardContent>
<FormField_Shadcn_
control={form.control}
name="name"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Project name"
description="Displayed throughout the dashboard."
className="[&>div]:md:w-1/2"
>
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
disabled={isBranch || !canUpdateProject}
autoComplete="off"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</CardContent>
<CardContent>
<FormItemLayout
layout="flex-row-reverse"
label="Project ID"
description="Reference used in APIs and URLs."
className="[&>div]:md:w-1/2 [&>div>div]:md:w-full"
>
<FormControl_Shadcn_>
<Input copy readOnly size="small" value={project?.ref ?? ''} />
</FormControl_Shadcn_>
</FormItemLayout>
</CardContent>
<CardFooter className="justify-end space-x-2">
{form.formState.isDirty && (
<Button
type="default"
htmlType="button"
disabled={isUpdating}
onClick={() => form.reset({ name: project?.name ?? '' })}
>
Cancel
</Button>
)}
</div>
</CardContent>
</Card>
</PageSectionContent>
</PageSection>
)}
</>
<Button
type="primary"
htmlType="submit"
disabled={
!form.formState.isDirty || isUpdating || !canUpdateProject || isBranch
}
loading={isUpdating}
>
Save changes
</Button>
</CardFooter>
</Card>
</form>
</Form_Shadcn_>
)}
</PageSectionContent>
</PageSection>
)
}
@@ -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 (
<>
<PageSection id="restart-project">
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Project availability</PageSectionTitle>
<PageSectionDescription>
Restart or pause your project when performing maintenance
</PageSectionDescription>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
<Card>
<CardContent>
<div className="flex flex-col @lg:flex-row @lg:justify-between @lg:items-center gap-4">
<div>
<p className="text-sm">
{projectSettingsRestartProject ? 'Restart project' : 'Restart database'}
</p>
<div className="max-w-[420px]">
<p className="text-sm text-foreground-light">
Your project will not be available for a few minutes.
</p>
</div>
</div>
<RestartServerButton />
</div>
</CardContent>
<CardContent>
<div
className="flex w-full flex-col @lg:flex-row @lg:justify-between @lg:items-center gap-4"
id="pause-project"
>
<div>
<p className="text-sm">Pause project</p>
<div className="max-w-[420px]">
<p className="text-sm text-foreground-light">
Your project will not be accessible while it is paused.
</p>
</div>
</div>
<PauseProjectButton />
</div>
</CardContent>
</Card>
</PageSectionContent>
</PageSection>
{!isBranch && (
<PageSection>
<PageSectionMeta>
<PageSectionSummary>
<PageSectionTitle>Project usage</PageSectionTitle>
</PageSectionSummary>
</PageSectionMeta>
<PageSectionContent>
<Card>
<CardContent>
<div className="flex flex-col @lg:flex-row @lg:justify-between @lg:items-center gap-4">
<div className="flex space-x-4">
<BarChart2 strokeWidth={2} />
<div>
<p className="text-sm">Project usage statistics have been moved</p>
<p className="text-foreground-light text-sm">
You may view your project's usage under your organization's settings
</p>
</div>
</div>
{!!organization && !!project && (
<Button asChild type="default">
<Link href={`/org/${organization.slug}/usage?projectRef=${project.ref}`}>
View project usage
</Link>
</Button>
)}
</div>
</CardContent>
</Card>
</PageSectionContent>
</PageSection>
)}
</>
)
}
@@ -258,9 +258,7 @@ const ProjectLinks = () => {
showReports,
apiDocsSidePanel: isNewAPIDocsEnabled,
})
const settingsRoutes = generateSettingsRoutes(ref, project, {
platformWebhooks: platformWebhooksEnabled,
})
const settingsRoutes = generateSettingsRoutes(ref)
return (
<SidebarMenu>
@@ -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(
() => ({
@@ -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,
},
]
@@ -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 <ProductMenu page={page} menu={menuRoutes} />
return <ProductMenu page={page} menu={menu} />
}
interface SettingsLayoutProps {
@@ -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<Record<string, unknown>>('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()
})
})
@@ -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: [
+17 -10
View File
@@ -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 = ({
<Popover_Shadcn_ open={open} onOpenChange={setOpen} modal={false}>
<PopoverTrigger_Shadcn_ asChild>
<div className={cn('flex cursor-pointer', className)}>
<span className="flex items-center text-foreground-lighter px-3 rounded-lg rounded-r-none text-xs border border-button border-r-0">
Source
</span>
{!isForm && (
<span className="flex items-center text-foreground-lighter px-3 rounded-lg rounded-r-none text-xs border border-button border-r-0">
Source
</span>
)}
<Button
type="default"
icon={isLoading && <Loader2 className="animate-spin" />}
iconRight={<ChevronDown strokeWidth={1.5} size={12} />}
iconRight={
<ChevronDown strokeWidth={1.5} size={12} className="absolute right-2 top-2" />
}
{...buttonProps}
className={cn(
'pr-2 rounded-l-none',
'pr-2 justify-start',
!isForm && 'rounded-l-none',
variant === 'connected-on-right' && 'rounded-r-none',
variant === 'connected-on-left' && 'rounded-l-none border-l-0',
variant === 'connected-on-both' && 'rounded-none border-x-0',
@@ -127,12 +134,12 @@ export const DatabaseSelector = ({
value={option.id}
className="cursor-pointer w-full"
onSelect={() => {
state.setSelectedDatabaseId(option.id)
if (!isForm) state.setSelectedDatabaseId(option.id)
setOpen(false)
onSelectId(option.id)
}}
onClick={() => {
state.setSelectedDatabaseId(option.id)
if (!isForm) state.setSelectedDatabaseId(option.id)
setOpen(false)
onSelectId(option.id)
}}
@@ -184,12 +191,12 @@ export const DatabaseSelector = ({
value={database.identifier}
className="cursor-pointer w-full"
onSelect={() => {
state.setSelectedDatabaseId(database.identifier)
if (!isForm) state.setSelectedDatabaseId(database.identifier)
setOpen(false)
onSelectId(database.identifier)
}}
onClick={() => {
state.setSelectedDatabaseId(database.identifier)
if (!isForm) state.setSelectedDatabaseId(database.identifier)
setOpen(false)
onSelectId(database.identifier)
}}
@@ -73,7 +73,13 @@ export const UpgradePlanButton = ({
: `/project/${ref ?? '_'}/settings/addons?panel=${addon}&source=${source}`
: `/org/${slug ?? '_'}/billing?panel=subscriptionPlan&source=${source}`
const linkChildren = children || (!!addon ? 'Enable add-on' : `Upgrade to ${plan}`)
const linkChildren =
children ||
(isOnPaidPlanAndRequestingToPurchaseAddon
? addon === 'computeSize'
? 'Change compute size'
: 'Enable add-on'
: `Upgrade to ${plan}`)
const link = billingAll ? (
<Link href={href}>{linkChildren}</Link>
) : (
@@ -1,10 +1,12 @@
import { useQuery } from '@tanstack/react-query'
import { useFeatureFlags, useFlag } from 'common'
import { LOCAL_STORAGE_KEYS, useFeatureFlags, useFlag, useParams } from 'common'
import type { components } from 'data/api'
import { get, handleError } from 'data/fetchers'
import type { ResponseError, UseCustomQueryOptions } from 'types'
import { replicaKeys } from './keys'
import { DashboardPreference } from '@/components/interfaces/Settings/General/DashboardPreferences'
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
export const MAX_REPLICAS_BELOW_XL = 2
@@ -69,29 +71,36 @@ export const useConnectionStringForReadOps = (): {
identifier: string | undefined
connectionString: string | undefined | null
} => {
const { ref: projectRef } = useParams()
const { hasLoaded: flagsLoaded } = useFeatureFlags()
const defaultToReadReplicaConnectionString = useFlag('defaultToReadReplicaConnectionString')
const showDashboardPreferences = useFlag('dashboardPreferences')
const [dashboardPreferences, , { isLoading: isLoadingLocalStorage }] =
useLocalStorageQuery<DashboardPreference>(
LOCAL_STORAGE_KEYS.DASHBOARD_PREFERENCES(projectRef ?? '_'),
{}
)
const { data: project, isSuccess: isSuccessProject } = useSelectedProjectQuery()
const { data: databases = [], isLoading: isLoadingDatabases } = useReadReplicasQuery({
projectRef: project?.ref,
})
const readReplicas = databases.filter(
(x) => x.identifier !== project?.ref && x.status === 'ACTIVE_HEALTHY'
)
const readReplica = readReplicas.some((x) => x.region === project?.region)
? readReplicas.find((x) => x.region === project?.region)
: readReplicas[0]
if (!isSuccessProject || isLoadingDatabases || !flagsLoaded) {
if (!isSuccessProject || isLoadingDatabases || !flagsLoaded || isLoadingLocalStorage) {
return { connectionString: undefined, type: undefined, identifier: undefined }
}
if (!defaultToReadReplicaConnectionString) {
if (!showDashboardPreferences) {
return { type: 'primary', identifier: project.ref, connectionString: project.connectionString }
}
const readReplicas = databases.filter(
(x) => x.identifier !== project?.ref && x.status === 'ACTIVE_HEALTHY'
)
const readReplica = readReplicas.find(
(x) => x.identifier === dashboardPreferences.defaultDatabase
)
return {
type: !!readReplica ? 'replica' : 'primary',
identifier: !!readReplica ? readReplica.identifier : project.ref,
@@ -23,6 +23,8 @@ import {
PageHeaderTitle,
} from 'ui-patterns/PageHeader'
import { Project } from '@/components/interfaces/Settings/General/Project'
const ProjectSettings: NextPageWithLayout = () => {
const { data: project } = useSelectedProjectQuery()
const { data: selectedOrganization } = useSelectedOrganizationQuery()
@@ -55,7 +57,7 @@ const ProjectSettings: NextPageWithLayout = () => {
</PageHeader>
<PageContainer size="small">
<General />
<Project />
{/* this is only settable on compliance orgs, currently that means HIPAA orgs */}
{!isBranch && hasHipaaAddon && <ComplianceConfig />}
{projectSettingsCustomDomains && <CustomDomainConfig />}
@@ -1,15 +0,0 @@
import DefaultLayout from 'components/layouts/DefaultLayout'
import SettingsLayout from 'components/layouts/ProjectSettingsLayout/SettingsLayout'
import type { NextPageWithLayout } from 'types'
const Settings: NextPageWithLayout = () => {
return <>{/* <h1>Use this as a page template for settings pages</h1> */}</>
}
Settings.getLayout = (page) => (
<DefaultLayout>
<SettingsLayout>{page}</SettingsLayout>
</DefaultLayout>
)
export default Settings
@@ -0,0 +1,40 @@
import DefaultLayout from 'components/layouts/DefaultLayout'
import SettingsLayout from 'components/layouts/ProjectSettingsLayout/SettingsLayout'
import type { NextPageWithLayout } from 'types'
import { PageContainer } from 'ui-patterns/PageContainer'
import {
PageHeader,
PageHeaderDescription,
PageHeaderMeta,
PageHeaderSummary,
PageHeaderTitle,
} from 'ui-patterns/PageHeader'
import { DashboardPreferences } from '@/components/interfaces/Settings/General/DashboardPreferences'
const Preferences: NextPageWithLayout = () => {
return (
<>
<PageHeader size="small">
<PageHeaderMeta>
<PageHeaderSummary>
<PageHeaderTitle>Dashboard Preferences</PageHeaderTitle>
<PageHeaderDescription>
Configure how the dashboard interacts with your project
</PageHeaderDescription>
</PageHeaderSummary>
</PageHeaderMeta>
</PageHeader>
<PageContainer size="small">
<DashboardPreferences />
</PageContainer>
</>
)
}
Preferences.getLayout = (page) => (
<DefaultLayout>
<SettingsLayout title="General">{page}</SettingsLayout>
</DefaultLayout>
)
export default Preferences
@@ -13,6 +13,7 @@ export const LOCAL_STORAGE_KEYS = {
FEEDBACK_WIDGET_SCREENSHOT: 'feedback-widget-screenshot',
INCIDENT_BANNER_DISMISSED_IDS: 'incident-banner-dismissed-ids',
MAINTENANCE_BANNER_DISMISSED: (id: string) => `maintenance-banner-dismissed-${id}`,
DASHBOARD_PREFERENCES: (ref: string) => `dashboard-preferences-${ref}`,
UI_PREVIEW_API_SIDE_PANEL: 'supabase-ui-api-side-panel',
UI_PREVIEW_CLS: 'supabase-ui-cls',