Merge branch 'master' into feat/update-auth-settings

This commit is contained in:
Saxon Fletcher committed 2025-02-28 11:13:38 +10:00
commit 68ddf3c144
82 files changed
+4067 -1127

No files matched your search

+61 -21
View File
@@ -5,7 +5,8 @@ import type { HTMLAttributes } from 'react'
import { useEffect, useState } from 'react'
import { useBreakpoint } from 'common'
import { cn, Popover_Shadcn_, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_ } from 'ui'
import { cn, Separator, Sheet, SheetContent, SheetHeader, SheetTrigger } from 'ui'
import { Feedback } from '~/components/Feedback'
interface TocItem extends HTMLAttributes<HTMLElement> {
label: string
@@ -18,7 +19,7 @@ export function ContributingToc({ className }: { className?: string }) {
useEffect(() => {
const headings = [
...document.querySelectorAll('article.prose > h2,h3'),
...document.querySelectorAll('article.prose > h2,h3:not(#feedback-title)'),
] as Array<HTMLHeadingElement>
const tocItems = headings
.filter((heading) => !!heading.id)
@@ -30,20 +31,16 @@ export function ContributingToc({ className }: { className?: string }) {
}, [])
return mobileToc ? (
<MobileToc
items={tocItems}
className={cn(
'[--local-top-spacing:2rem]',
'fixed top-[calc(var(--header-height)+var(--local-top-spacing))] right-8'
)}
/>
<MobileToc items={tocItems} />
) : (
<TocBase
items={tocItems}
className={cn(
'[--local-top-spacing:5rem]',
'fixed top-[calc(var(--header-height)+var(--local-top-spacing))] right-8 w-36',
'xl:border-l border-foreground-muted'
'border-l thin-scrollbar overflow-y-auto px-2 hidden lg:block',
'col-span-3 self-start sticky',
'top-[calc(var(--header-height)+1px+2rem)] max-h-[calc(100vh-var(--header-height)-3rem)]',
className
)}
/>
)
@@ -52,31 +49,74 @@ export function ContributingToc({ className }: { className?: string }) {
function MobileToc({ items, className }: { items: Array<TocItem>; className?: string }) {
const [open, setOpen] = useState(false)
useEffect(() => {
const onHashChanged = () => setOpen(false)
window.addEventListener('hashchange', onHashChanged)
return () => {
window.removeEventListener('hashchange', onHashChanged)
}
}, [])
return (
<Popover_Shadcn_ open={open} onOpenChange={setOpen}>
<PopoverTrigger_Shadcn_ className={cn('border rounded p-2', className)}>
<Menu />
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger
className={cn(
'fixed z-0 inset-0 top-auto w-full rounded-t-lg border border-b-0 p-4 bg-studio flex items-center gap-2 text-foreground-light text-sm',
className
)}
>
<Menu size={16} strokeWidth={1.5} className="text-foreground-light" />
<span className="sr-only">
{open ? 'Close table of contents' : 'Open table of contents'}
</span>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ align="end" className="w-48">
<TocBase items={items} />
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
<span>On this page</span>
</SheetTrigger>
<SheetContent
side="bottom"
size="lg"
className={cn(
'w-full flex flex-col gap-0 p-0 rounded-t-lg overflow-hidden',
!open && 'top-[calc(100vh-100px)]'
)}
>
<SheetHeader className="py-0 px-4">
<SheetTrigger
className={cn(
'w-full py-4 flex items-center gap-2 text-foreground-light text-sm',
className
)}
>
<Menu size={16} strokeWidth={1.5} className="text-foreground-light" />
<span className="sr-only">
{open ? 'Close table of contents' : 'Open table of contents'}
</span>
<span>On this page</span>
</SheetTrigger>
</SheetHeader>
<div className="w-full flex-1 p-4 pb-8 overflow-y-auto thin-scrollbar">
<TocBase items={items} />
</div>
</SheetContent>
</Sheet>
)
}
function TocBase({ items, className }: { items: Array<TocItem>; className?: string }) {
return (
<nav aria-label="Table of contents" className={cn('px-4 text-foreground-light', className)}>
<ul className="flex flex-col gap-1">
<nav aria-label="Table of contents" className={cn('text-foreground-lighter', className)}>
<span className="hidden lg:block font-mono text-xs uppercase text-foreground px-5 mb-6">
On this page
</span>
<ul className="toc-menu list-none lg:pl-5 text-[0.8rem] grid gap-2">
{items.map((item) => (
<li key={item.anchor} className="overflow-hidden truncate">
<a href={`#${item.anchor}`}>{item.label}</a>
</li>
))}
</ul>
<Separator className="lg:w-[calc(100%-2rem)] lg:ml-5 my-4 lg:my-8" />
<Feedback className="pl-0 lg:pl-5" />
</nav>
)
}
+16 -8
View File
@@ -5,22 +5,30 @@ import { fileURLToPath } from 'node:url'
import { ContributingToc } from '~/app/contributing/ContributingToC'
import { MDXProviderGuides } from '~/features/docs/GuidesMdx.client'
import { MDXRemoteBase } from '~/features/docs/MdxBase'
import { LayoutMainContent } from '~/layouts/DefaultLayout'
import { SidebarSkeleton } from '~/layouts/MainSkeleton'
import Breadcrumbs from '~/components/Breadcrumbs'
export default async function ContributingPage() {
const contentFile = join(dirname(fileURLToPath(import.meta.url)), 'content.mdx')
const content = await readFile(contentFile, 'utf-8')
return (
<SidebarSkeleton hideSideNav>
<div className="px-8 py-16">
<article className="prose mx-auto">
<MDXProviderGuides>
<MDXRemoteBase source={content} />
</MDXProviderGuides>
</article>
<SidebarSkeleton>
<LayoutMainContent className="pb-0 grid grid-cols-12 relative gap-4">
<div className="col-span-12 lg:col-span-9">
<Breadcrumbs className="mb-2 col-span-full" />
<article
id="contributing"
className="prose max-w-none relative transition-all ease-out duration-100"
>
<MDXProviderGuides>
<MDXRemoteBase source={content} />
</MDXProviderGuides>
</article>
</div>
<ContributingToc />
</div>
</LayoutMainContent>
</SidebarSkeleton>
)
}
+54 -43
View File
@@ -33,9 +33,9 @@ const FeedbackButton = forwardRef<
ref={ref}
className={cn(
'mt-0',
'flex items-center gap-2',
'flex items-center gap-1',
'text-xs text-foreground-lighter',
'hover:text-foreground',
'hover:text-foreground text-left',
!visible && 'opacity-0 invisible',
'[transition-property:opacity,color]',
'[transition-delay:700ms,0ms]'
@@ -141,72 +141,83 @@ function Feedback({ className }: { className?: string }) {
}
return (
<section className={cn('@container px-5 mb-6', className)} aria-labelledby="feedback-title">
<h3 id="feedback-title" className="block font-mono text-xs uppercase text-foreground mb-4">
<section className={cn('@container', className)} aria-labelledby="feedback-title">
<h3 id="feedback-title" className="block font-mono text-xs text-foreground-light mb-3">
Is this helpful?
</h3>
<div className="relative flex flex-col gap-y-4 mb-2 @[12rem]:flex-row @[12rem]:items-center">
<div className="relative flex flex-col gap-2 @[12rem]:gap-4 @[12rem]:flex-row @[12rem]:items-center">
<div
style={{ '--container-flex-gap': '0.5rem' } as CSSProperties}
className={`relative flex gap-[var(--container-flex-gap)] items-center`}
className="relative flex gap-2 items-center"
>
<Button
type="outline"
rounded
className={cn(
'px-1',
!isYes && 'hover:text-brand-600 hover:border-brand-500',
isYes && 'bg-brand text-brand-200 !border-brand disabled:opacity-100',
!showYes && 'opacity-0 invisible',
'[transition-property:opacity,transform,color] [transition-duration:150ms,300ms,300ms]',
'motion-reduce:[transition-duration:150ms,1ms,300ms]'
)}
onClick={() => handleVote('yes')}
disabled={state.type === StateType.Followup}
>
<Check size={14} strokeWidth={3} />
<span className="sr-only">Yes</span>
</Button>
<Button
type="outline"
rounded
className={cn(
'px-1',
'px-1 w-7 h-7',
'text-foreground-light',
'[transition-property:opacity,transform,color] [transition-duration:150ms,250ms,250ms]',
'motion-reduce:[transition-duration:150ms,1ms,300ms]',
'[transition-timing-function:cubic-bezier(.76,0,.23,1)]',
!isNo && 'hover:text-warning-600 hover:border-warning-500',
isNo &&
`bg-warning text-warning-200 !border-warning -translate-x-[calc(100%+var(--container-flex-gap,0.5rem))] disabled:opacity-100`,
!showNo && 'opacity-0 invisible',
'[transition-property:opacity,transform,color] [transition-duration:150ms,300ms,300ms]',
'motion-reduce:[transition-duration:150ms,1ms,300ms]'
isNo && `bg-warning text-warning-200 !border-warning disabled:opacity-100`,
!showNo && 'opacity-0 invisible'
)}
onClick={() => handleVote('no')}
disabled={state.type === StateType.Followup}
>
<X size={14} strokeWidth={3} />
<X size={14} strokeWidth={2} className="text-current" />
<span className="sr-only">No</span>
</Button>
<Button
type="outline"
rounded
className={cn(
'px-1 w-7 h-7',
'text-foreground-light',
'[transition-property:opacity,transform,color] [transition-duration:150ms,250ms,250ms]',
'motion-reduce:[transition-duration:150ms,1ms,300ms]',
'[transition-timing-function:cubic-bezier(.76,0,.23,1)]',
!isYes && 'hover:text-brand-600 hover:border-brand-500',
isYes &&
'bg-brand text-brand-200 !border-brand disabled:opacity-100 -translate-x-[calc(100%+var(--container-inline-flex-gap,0.5rem))]',
!showYes && 'opacity-0 invisible'
)}
onClick={() => handleVote('yes')}
disabled={state.type === StateType.Followup}
>
<Check size={14} strokeWidth={2} />
<span className="sr-only">Yes</span>
</Button>
</div>
<div
className={cn(
'flex flex-col gap-1',
'flex flex-col gap-0.5',
'@[12rem]:absolute @[12rem]:left-9',
'text-xs',
'opacity-0 invisible',
'text-left',
'-translate-x-[0.25rem] @[12rem]:-translate-x-[1.25rem]',
'[transition-property:opacity,transform] [transition-duration:150ms,300ms]',
'-translate-x-2',
'[transition-property:opacity,transform]',
'[transition-duration:450ms,300ms]',
'[transition-delay:200ms,0ms]',
'[transition-timing-function:cubic-bezier(.76,0,.23,1)]',
'motion-reduce:[transition-duration:150ms,1ms]',
'delay-300',
state.type === StateType.Followup &&
'opacity-100 visible -translate-x-0 @[12rem]:-translate-x-[1rem]'
'!ease-out',
state.type === StateType.Followup && 'opacity-100 visible -translate-x-0'
)}
>
<span className="text-foreground-light">Thanks for your feedback!</span>
<FeedbackButton
ref={feedbackButtonRef}
onClick={() => setModalOpen(true)}
isYes={isYes}
visible={!unanswered}
/>
{state.type === StateType.Followup && (
<>
<span className="text-foreground-light">Thanks for your feedback!</span>
<FeedbackButton
ref={feedbackButtonRef}
onClick={() => setModalOpen(true)}
isYes={isYes}
visible={true}
/>
</>
)}
</div>
</div>
<FeedbackModal
+15 -6
View File
@@ -131,16 +131,25 @@ const GuidesTableOfContents = ({
const tocVideoPreview = `https://img.youtube.com/vi/${video}/0.jpg`
return (
<div className={cn('border-l', 'thin-scrollbar overflow-y-auto', 'px-2', className)}>
<div
className={cn(
'border-l flex flex-col gap-6 lg:gap-8',
'thin-scrollbar overflow-y-auto',
'px-2',
className
)}
>
{video && (
<div className="relative mb-6 pl-5">
<div className="relative pl-5">
<ExpandableVideo imgUrl={tocVideoPreview} videoId={video} />
</div>
)}
<Feedback key={pathname} />
<div className="pl-5">
<Feedback key={pathname} />
</div>
{displayedList.length > 0 && (
<>
<span className="block font-mono text-xs uppercase text-foreground px-5 mb-6">
<div>
<span className="block font-mono text-xs uppercase text-foreground px-5 mb-3">
On this page
</span>
<ul className="toc-menu list-none pl-5 text-[0.8rem] grid gap-2">
@@ -163,7 +172,7 @@ const GuidesTableOfContents = ({
</li>
))}
</ul>
</>
</div>
)}
</div>
)
@@ -1953,6 +1953,13 @@ export const local_development: NavMenuConstant = {
],
}
export const contributing: NavMenuConstant = {
icon: 'contributing',
title: 'Contributing',
url: '/contributing',
items: [{ name: 'Overview', url: '/contributing' }],
}
export const MIGRATION_PAGES: Partial<NavMenuSection & ComponentProps<typeof IconPanel>>[] = [
{
name: 'Auth0',
@@ -24,6 +24,7 @@ enum MenuId {
SelfHosting = 'self_hosting',
Integrations = 'integrations',
LocalDevelopment = 'local_development',
Contributing = 'contributing',
RefJavaScriptV1 = 'reference_javascript_v1',
RefJavaScriptV2 = 'reference_javascript_v2',
RefDartV1 = 'reference_dart_v1',
@@ -128,6 +129,10 @@ const menus: Menu[] = [
id: MenuId.LocalDevelopment,
type: 'guide',
},
{
id: MenuId.Contributing,
type: 'guide',
},
{
id: MenuId.Deployment,
type: 'guide',
@@ -139,6 +139,8 @@ export const getMenuId = (pathname: string | null) => {
return MenuId.SelfHosting
case pathname.startsWith('storage'):
return MenuId.Storage
case pathname.startsWith('/contributing'):
return MenuId.Contributing
default:
return MenuId.GettingStarted
}
@@ -90,7 +90,7 @@ The session mode connection string connects to your Postgres instance via a prox
The connection string looks like this:
```
postgres://postgres.apbkobhfnmcqqzqeeqss:[YOUR-PASSWORD]@aws-0-ca-central-1.pooler.supabase.com:5432/postgres
postgres://postgres.apbkobhfnmcqqzqeeqss:[YOUR-PASSWORD]@aws-0-[REGION].pooler.supabase.com:5432/postgres
```
Get your project's session mode string from the [Database Settings](/dashboard/project/_/settings/database) page:
@@ -112,7 +112,7 @@ Transaction mode does not support [prepared statements](https://postgresql.org/d
The connection string looks like this:
```
postgres://postgres.apbkobhfnmcqqzqeeqss:[YOUR-PASSWORD]@aws-0-ca-central-1.pooler.supabase.com:6543/postgres
postgres://postgres.apbkobhfnmcqqzqeeqss:[YOUR-PASSWORD]@aws-0-[REGION].pooler.supabase.com:6543/postgres
```
Get your project's transaction mode string from the [Database Settings](/dashboard/project/_/settings/database) page:
@@ -24,7 +24,7 @@ enable row level security;
</Admonition>
RLS is incredibly powerful and flexible, allowing you to write complex SQL rules that fit your unique business needs. RLS can be [combined with Supabase Auth](/docs/guides/database/postgres/row-level-security) for end-to-end user security from the browser to the database.
RLS is incredibly powerful and flexible, allowing you to write complex SQL rules that fit your unique business needs. RLS can be combined with [Supabase Auth](/docs/guides/auth) for end-to-end user security from the browser to the database.
RLS is a Postgres primitive and can provide "[defense in depth](<https://en.wikipedia.org/wiki/Defense_in_depth_(computing)>)" to protect your data from malicious actors even when accessed through third-party tooling.
@@ -12,6 +12,10 @@ sdk = [ "auth-getuser" ]
[[errors]]
http_status_code = 401
message = "invalid claim: missing sub"
[[errors]]
http_status_code = 403
message = "invalid claim: missing sub"
---
The missing sub claim error is returned when `supabase.auth.getUser()` is called with an invalid JWT in the session or when the user attempts to register/sign in but hasn't completed the sign in when the `getUser` call is made.
@@ -115,7 +115,7 @@ const GuideTemplate = ({ meta, content, children, editLink, mdxOptions }: GuideT
<GuidesTableOfContents
video={meta?.tocVideo}
className={cn(
'hidden md:block',
'hidden md:flex',
'col-span-3 self-start',
'sticky',
/**
@@ -94,7 +94,7 @@ export default async function TroubleshootingPage({ entry }: { entry: ITroublesh
<hr className="my-6" aria-hidden />
</>
)}
<Feedback className="px-0" />
<Feedback className="px-0 mb-6 lg:mb-8" />
</aside>
</div>
</article>
@@ -158,7 +158,12 @@ export async function getAllTroubleshootingEntriesInternal() {
].includes(child.type)
)
}
if (node.type === 'link' || node.type === 'image') {
canonicalizeUrl(node)
}
})
const contentWithoutJsx = toMarkdown(mdxTree, {
extensions: [gfmToMarkdown()],
})
@@ -174,6 +179,16 @@ export async function getAllTroubleshootingEntriesInternal() {
return (await Promise.all(troubleshootingFiles)).filter((x) => x != null)
}
/**
*
* @param {import('mdast').Image | import('mdast').Link} node
*/
function canonicalizeUrl(node) {
if (node.url.startsWith('/')) {
node.url === 'https://supabase.com' + node.url
}
}
/**
* @param {TroubleshootingEntry} entry
*/
+6
View File
@@ -82,6 +82,10 @@ const levelsData = {
icon: 'platform',
name: 'Platform',
},
contributing: {
icon: 'contributing',
name: 'Contributing',
},
resources: {
icon: 'resources',
name: 'Resources',
@@ -377,6 +381,8 @@ function SidebarSkeleton({
const mobileMenuOpen = useMenuMobileOpen()
console.log('menuName', menuName, menuId)
return (
<div className={cn('flex flex-row h-full relative', className)}>
{!hideSideNav && (
+2 -2
View File
@@ -74,7 +74,7 @@ const nextConfig = {
headers: [
{
key: 'Strict-Transport-Security',
value: process.env.VERCEL === '1' ? 'max-age=31536000' : '',
value: process.env.VERCEL === '1' ? 'max-age=1200; includeSubDomains' : '',
},
{
key: 'X-Robots-Tag',
@@ -97,7 +97,7 @@ const nextConfig = {
headers: [
{
key: 'Strict-Transport-Security',
value: process.env.VERCEL === '1' ? 'max-age=31536000' : '',
value: process.env.VERCEL === '1' ? 'max-age=1200; includeSubDomains' : '',
},
{
key: 'X-Robots-Tag',
@@ -26,6 +26,8 @@ export const billingPartnerLabel = (billingPartner?: string) => {
return 'Fly.io'
case 'aws':
return 'AWS'
case 'vercel_marketplace':
return 'Vercel'
default:
return billingPartner
}
@@ -342,10 +342,31 @@ let { data: ${resourceId}, error } = await supabase
bash: {
language: 'bash',
code: `
curl '${endpoint}/rest/v1/${resourceId}?id=eq.1&select=*' \\
curl --get '${endpoint}/rest/v1/${resourceId}' \\
-H "apikey: ${apiKey}" \\
-H "Authorization: Bearer ${apiKey}" \\
-H "Range: 0-9"
-H "Range: 0-9" \\
-d "select=*" \\
\\
\`# Filters\` \\
-d "column=eq.Equal+to" \\
-d "column=gt.Greater+than" \\
-d "column=lt.Less+than" \\
-d "column=gte.Greater+than+or+equal+to" \\
-d "column=lte.Less+than+or+equal+to" \\
-d "column=like.*CaseSensitive*" \\
-d "column=ilike.*CaseInsensitive*" \\
-d "column=is.null" \\
-d "column=in.(Array,Values)" \\
-d "column=neq.Not+equal+to" \\
\\
\`# Arrays\` \\
-d "array_column=cs.{array,contains}" \\
-d "array_column=cd.{contained,by}" \\
\\
\`# Logical operators\` \\
-d "column=not.like.Negate+filter" \\
-d "or=(some_column.eq.Some+value,other_column.eq.Other+value)"
`,
},
js: {
@@ -354,6 +375,7 @@ curl '${endpoint}/rest/v1/${resourceId}?id=eq.1&select=*' \\
let { data: ${resourceId}, error } = await supabase
.from('${resourceId}')
.select("*")
// Filters
.eq('column', 'Equal to')
.gt('column', 'Greater than')
@@ -365,9 +387,14 @@ let { data: ${resourceId}, error } = await supabase
.is('column', null)
.in('column', ['Array', 'Values'])
.neq('column', 'Not equal to')
// Arrays
.contains('array_column', ['array', 'contains'])
.containedBy('array_column', ['contained', 'by'])
// Logical operators
.not('column', 'like', 'Negate filter')
.or('some_column.eq.Some value, other_column.eq.Other value')
`,
},
}),
@@ -0,0 +1,68 @@
interface InvocationTab {
id: string
label: string
language: 'bash' | 'js' | 'ts' | 'dart' | 'python'
hideLineNumbers?: boolean
code: (functionUrl: string, functionName: string, apiKey: string) => string
}
export const INVOCATION_TABS: InvocationTab[] = [
{
id: 'curl',
label: 'cURL',
language: 'bash',
code: (functionUrl, _, apiKey) => `curl -L -X POST '${functionUrl}' \\
-H 'Authorization: Bearer ${apiKey}' \\
-H 'Content-Type: application/json' \\
--data '{"name":"Functions"}'`,
},
{
id: 'supabase-js',
label: 'JavaScript',
language: 'js',
hideLineNumbers: true,
code: (_, functionName) => `import { createClient } from '@supabase/supabase-js'
const supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_ANON_KEY)
const { data, error } = await supabase.functions.invoke('${functionName}', {
body: { name: 'Functions' },
})`,
},
{
id: 'swift',
label: 'Swift',
language: 'ts',
hideLineNumbers: true,
code: (_, functionName) => `struct Response: Decodable {
// Expected response definition
}
let response: Response = try await supabase.functions
.invoke(
"${functionName}",
options: FunctionInvokeOptions(
body: ["name": "Functions"]
)
)`,
},
{
id: 'flutter',
label: 'Flutter',
language: 'dart',
hideLineNumbers: true,
code: (
_,
functionName
) => `final res = await supabase.functions.invoke('${functionName}', body: {'name': 'Functions'});
final data = res.data;`,
},
{
id: 'python',
label: 'Python',
language: 'python',
hideLineNumbers: true,
code: (_, functionName) => `response = supabase.functions.invoke(
"${functionName}",
invoke_options={"body": {"name": "Functions"}}
)`,
},
]
@@ -1,17 +1,16 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import dayjs from 'dayjs'
import { ExternalLink, Maximize2, Minimize2, Terminal } from 'lucide-react'
import { ExternalLink } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useEffect, useMemo, useState } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import z from 'zod'
import { useParams } from 'common'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { FormActions } from 'components/ui/Forms/FormActions'
import { FormHeader } from 'components/ui/Forms/FormHeader'
import { FormPanel } from 'components/ui/Forms/FormPanel'
import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection'
import { ScaffoldSection, ScaffoldSectionTitle } from 'components/layouts/Scaffold'
import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query'
import { useEdgeFunctionQuery } from 'data/edge-functions/edge-function-query'
@@ -19,29 +18,50 @@ import { useEdgeFunctionDeleteMutation } from 'data/edge-functions/edge-function
import { useEdgeFunctionUpdateMutation } from 'data/edge-functions/edge-functions-update-mutation'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import {
Alert_Shadcn_,
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
Alert_Shadcn_,
Button,
CriticalIcon,
Form,
Input,
Modal,
Toggle,
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
cn,
CodeBlock,
CriticalIcon,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
Input,
Input_Shadcn_,
Switch,
Tabs_Shadcn_ as Tabs,
TabsContent_Shadcn_ as TabsContent,
TabsList_Shadcn_ as TabsList,
TabsTrigger_Shadcn_ as TabsTrigger,
} from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import CommandRender from '../CommandRender'
import { INVOCATION_TABS } from './EdgeFunctionDetails.constants'
import { generateCLICommands } from './EdgeFunctionDetails.utils'
const EdgeFunctionDetails = () => {
const FormSchema = z.object({
name: z.string().min(0, 'Name is required'),
verify_jwt: z.boolean(),
})
export const EdgeFunctionDetails = () => {
const router = useRouter()
const { ref: projectRef, functionSlug } = useParams()
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [showInstructions, setShowInstructions] = useState(true)
const canUpdateEdgeFunction = useCheckPermissions(PermissionAction.FUNCTIONS_WRITE, '*')
const { data: settings } = useProjectSettingsV2Query({ projectRef })
const { data: customDomainData } = useCustomDomainsQuery({ projectRef })
const { data: selectedFunction } = useEdgeFunctionQuery({ projectRef, slug: functionSlug })
const { mutate: updateEdgeFunction, isLoading: isUpdating } = useEdgeFunctionUpdateMutation()
const { mutate: deleteEdgeFunction, isLoading: isDeleting } = useEdgeFunctionDeleteMutation({
onSuccess: () => {
@@ -50,8 +70,10 @@ const EdgeFunctionDetails = () => {
},
})
const formId = 'edge-function-update-form'
const canUpdateEdgeFunction = useCheckPermissions(PermissionAction.FUNCTIONS_WRITE, '*')
const form = useForm({
resolver: zodResolver(FormSchema),
defaultValues: { name: '', verify_jwt: false },
})
const { anonKey } = getAPIKeys(settings)
const apiKey = anonKey?.api_key ?? '[YOUR ANON KEY]'
@@ -62,14 +84,17 @@ const EdgeFunctionDetails = () => {
customDomainData?.customDomain?.status === 'active'
? `https://${customDomainData.customDomain.hostname}/functions/v1/${selectedFunction?.slug}`
: `${protocol}://${endpoint}/functions/v1/${selectedFunction?.slug}`
const { managementCommands, secretCommands, invokeCommands } = generateCLICommands({
const hasImportMap = useMemo(
() => selectedFunction?.import_map || selectedFunction?.import_map_path,
[selectedFunction]
)
const { managementCommands } = generateCLICommands({
selectedFunction,
functionUrl,
anonKey: apiKey,
})
const onUpdateFunction = async (values: any, { resetForm }: any) => {
const onUpdateFunction: SubmitHandler<z.infer<typeof FormSchema>> = async (values: any) => {
if (!projectRef) return console.error('Project ref is required')
if (selectedFunction === undefined) return console.error('No edge function selected')
@@ -81,7 +106,6 @@ const EdgeFunctionDetails = () => {
},
{
onSuccess: () => {
resetForm({ values, initialValues: values })
toast.success(`Successfully updated edge function`)
},
}
@@ -94,153 +118,138 @@ const EdgeFunctionDetails = () => {
deleteEdgeFunction({ projectRef, slug: selectedFunction.slug })
}
const hasImportMap = useMemo(
() => selectedFunction?.import_map || selectedFunction?.import_map_path,
[selectedFunction]
)
useEffect(() => {
if (selectedFunction) {
form.reset({
name: selectedFunction.name,
verify_jwt: selectedFunction.verify_jwt,
})
}
}, [selectedFunction])
return (
<>
<div className="space-y-4 pb-16 pt-3">
<Form id={formId} initialValues={{}} onSubmit={onUpdateFunction}>
{({ handleReset, values, initialValues, resetForm }: any) => {
const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
// [Alaister] although this "technically" is breaking the rules of React hooks
// it won't error because the hooks are always rendered in the same order
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (selectedFunction !== undefined) {
const formValues = {
name: selectedFunction?.name,
verify_jwt: selectedFunction?.verify_jwt,
}
resetForm({ values: formValues, initialValues: formValues })
}
}, [selectedFunction])
return (
<>
<FormPanel
disabled={!canUpdateEdgeFunction}
footer={
<div className="flex py-4 px-8">
<FormActions
form={formId}
isSubmitting={isUpdating}
hasChanges={hasChanges}
handleReset={handleReset}
helper={
!canUpdateEdgeFunction
? 'You need additional permissions to update this function'
: undefined
}
<div className="mx-auto flex flex-col-reverse 2xl:flex-row gap-8 pb-8">
<div className="flex-1 min-w-0 overflow-hidden">
<ScaffoldSection isFullWidth className="!pt-0 2xl:first:!pt-12">
<ScaffoldSectionTitle className="mb-4">Function Configuration</ScaffoldSectionTitle>
<Form_Shadcn_ {...form}>
<form onSubmit={form.handleSubmit(onUpdateFunction)}>
<Card>
<CardContent>
<FormField_Shadcn_
control={form.control}
name="name"
render={({ field }) => (
<FormItemLayout
label="Name"
layout="flex-row-reverse"
description="Your slug and endpoint URL will remain the same"
>
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
className="w-full"
disabled={!canUpdateEdgeFunction}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</CardContent>
<CardContent>
<FormField_Shadcn_
control={form.control}
name="verify_jwt"
render={({ field }) => (
<FormItemLayout
label="Enforce JWT Verification"
layout="flex-row-reverse"
description="Require a valid JWT in the authorization header when invoking the function"
>
<FormControl_Shadcn_>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={!canUpdateEdgeFunction}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</CardContent>
<CardFooter className="flex justify-end space-x-2">
{form.formState.isDirty && (
<Button type="default" onClick={() => form.reset()}>
Cancel
</Button>
)}
<Button
type="primary"
htmlType="submit"
loading={isUpdating}
disabled={!canUpdateEdgeFunction || !form.formState.isDirty}
>
Save changes
</Button>
</CardFooter>
</Card>
</form>
</Form_Shadcn_>
</ScaffoldSection>
<ScaffoldSection isFullWidth>
<ScaffoldSectionTitle className="mb-4">Invoke function</ScaffoldSectionTitle>
<Card>
<CardContent>
<Tabs defaultValue="curl" className="w-full">
<TabsList className="flex flex-wrap gap-4">
{INVOCATION_TABS.map((tab) => (
<TabsTrigger key={tab.id} value={tab.id}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
{INVOCATION_TABS.map((tab) => (
<TabsContent key={tab.id} value={tab.id} className="mt-4">
<div className="overflow-x-auto">
<CodeBlock
language={tab.language}
hideLineNumbers={tab.hideLineNumbers}
className="p-0 text-xs !mt-0 border-none"
value={tab.code(functionUrl, selectedFunction?.name ?? '', apiKey)}
/>
</div>
}
>
<FormSection header={<FormSectionLabel>Function Details</FormSectionLabel>}>
<FormSectionContent loading={selectedFunction === undefined}>
<Input id="name" name="name" label="Name" disabled={!canUpdateEdgeFunction} />
<Input
disabled
id="slug"
name="slug"
label="Slug"
value={selectedFunction?.slug}
/>
<Input disabled copy label="Endpoint URL" value={functionUrl} />
<Input disabled label="Region" value="All functions are deployed globally" />
<Input
disabled
label="Created at"
value={dayjs(selectedFunction?.created_at ?? 0).format(
'dddd, MMMM D, YYYY h:mm A'
)}
/>
<Input
disabled
label="Last updated at"
value={dayjs(selectedFunction?.updated_at ?? 0).format(
'dddd, MMMM D, YYYY h:mm A'
)}
/>
<Input disabled label="Deployments" value={selectedFunction?.version ?? 0} />
</FormSectionContent>
</FormSection>
<FormSection header={<FormSectionLabel>Function Configuration</FormSectionLabel>}>
<FormSectionContent loading={selectedFunction === undefined}>
<Toggle
id="verify_jwt"
name="verify_jwt"
disabled={!canUpdateEdgeFunction}
label="Enforce JWT Verification"
descriptionText="Require a valid JWT in the authorization header when invoking the function"
/>
<div className="space-y-1 border rounded border-default bg-surface-200 px-4 py-4">
<div className="flex items-center space-x-2">
<p className="text-sm">
Import maps are{' '}
<span className={cn(hasImportMap ? 'text-brand' : 'text-amber-900')}>
{hasImportMap ? 'used' : 'not used'}
</span>{' '}
for this function
</p>
</div>
<p className="text-sm text-foreground-light">
Import maps allow the use of bare specifiers in functions instead of
explicit import URLs
</p>
<div className="!mt-4">
<Button asChild type="default" icon={<ExternalLink strokeWidth={1.5} />}>
<Link
href="https://supabase.com/docs/guides/functions/dependencies"
target="_blank"
rel="noreferrer"
>
More about import maps
</Link>
</Button>
</div>
</div>
</FormSectionContent>
</FormSection>
</FormPanel>
</>
)
}}
</Form>
<div
className="space-y-6 rounded border bg-surface-100 px-6 py-4 drop-shadow-sm transition-all overflow-hidden"
style={{ maxHeight: showInstructions ? 800 : 66 }}
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="flex h-8 w-8 items-center justify-center rounded border bg-foreground p-2 text-background">
<Terminal size={18} strokeWidth={2} />
</div>
<h4>Deploy, invoke and manage secrets</h4>
</div>
<div className="cursor-pointer" onClick={() => setShowInstructions(!showInstructions)}>
{showInstructions ? (
<Minimize2 size={14} strokeWidth={1.5} />
) : (
<Maximize2 size={14} strokeWidth={1.5} />
)}
</TabsContent>
))}
</Tabs>
</CardContent>
</Card>
</ScaffoldSection>
<ScaffoldSection isFullWidth>
<ScaffoldSectionTitle className="mb-4">Develop locally</ScaffoldSectionTitle>
<div className="rounded border bg-surface-100 px-6 py-4 drop-shadow-sm">
<div className="space-y-6">
<CommandRender
commands={[
{
command: `supabase functions download ${selectedFunction?.slug}`,
description: 'Download the function to your local machine',
jsx: () => (
<>
<span className="text-brand-600">supabase</span> functions download{' '}
{selectedFunction?.slug}
</>
),
comment: '1. Download the function',
},
]}
/>
<CommandRender commands={[managementCommands[0]]} />
<CommandRender commands={[managementCommands[1]]} />
</div>
</div>
<h5 className="text-base">Deploy your function</h5>
<CommandRender commands={managementCommands} />
<h5 className="text-base">Invoke your function</h5>
<CommandRender commands={invokeCommands} />
<h5 className="text-base">Manage secrets</h5>
<CommandRender commands={secretCommands} />
</div>
<div className="!mt-8">
<FormHeader title="Delete Edge Function" description="" />
</ScaffoldSection>
<ScaffoldSection isFullWidth>
<ScaffoldSectionTitle className="mb-4">Delete function</ScaffoldSectionTitle>
<Alert_Shadcn_ variant="destructive">
<CriticalIcon />
<AlertTitle_Shadcn_>
@@ -250,48 +259,109 @@ const EdgeFunctionDetails = () => {
Make sure you have made a backup if you want to restore your edge function
</AlertDescription_Shadcn_>
<AlertDescription_Shadcn_ className="mt-3">
<ButtonTooltip
<Button
type="danger"
disabled={!canUpdateEdgeFunction}
loading={selectedFunction?.id === undefined}
onClick={() => setShowDeleteModal(true)}
tooltip={{
content: {
side: 'bottom',
text: !canUpdateEdgeFunction
? 'You need additional permissions to delete edge functions'
: undefined,
},
}}
>
Delete edge function
</ButtonTooltip>
</Button>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
</div>
</ScaffoldSection>
<ConfirmationModal
visible={showDeleteModal}
loading={isDeleting}
variant="destructive"
confirmLabel="Delete"
confirmLabelLoading="Deleting"
title={`Confirm to delete ${selectedFunction?.name}`}
onCancel={() => setShowDeleteModal(false)}
onConfirm={onConfirmDelete}
alert={{
base: { variant: 'destructive' },
title: 'This action cannot be undone',
description:
'Ensure that you have made a backup if you want to restore your edge function',
}}
/>
</div>
<Modal
size="small"
alignFooter="right"
header={<h3>Confirm to delete {selectedFunction?.name}</h3>}
visible={showDeleteModal}
loading={isDeleting}
onCancel={() => setShowDeleteModal(false)}
onConfirm={onConfirmDelete}
>
<Modal.Content>
<Alert_Shadcn_ variant="warning">
<CriticalIcon />
<AlertTitle_Shadcn_>This action cannot be undone</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
Ensure that you have made a backup if you want to restore your edge function
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
</Modal.Content>
</Modal>
</>
<div className="w-full 2xl:max-w-[600px] shrink-0">
<ScaffoldSection isFullWidth className="!pt-6 2xl:first:!pt-12">
<Card>
<CardHeader>
<CardTitle>Details</CardTitle>
</CardHeader>
<CardContent className="pt-6 space-y-6">
<dl className="grid grid-cols-1 xl:grid-cols-[auto_1fr] gap-y-4 xl:gap-y-6 gap-x-10">
<dt className="text-sm text-foreground-light">Slug</dt>
<dd className="text-sm lg:text-left">{selectedFunction?.slug}</dd>
<dt className="text-sm text-foreground-light">Endpoint URL</dt>
<dd className="text-sm lg:text-left">
<Input
className="font-mono input-mono"
disabled
copy
size="small"
value={functionUrl}
/>
</dd>
<dt className="text-sm text-foreground-light">Region</dt>
<dd className="text-sm lg:text-left">All functions are deployed globally</dd>
<dt className="text-sm text-foreground-light">Created at</dt>
<dd className="text-sm lg:text-left">
{dayjs(selectedFunction?.created_at ?? 0).format('dddd, MMMM D, YYYY h:mm A')}
</dd>
<dt className="text-sm text-foreground-light">Last updated at</dt>
<dd className="text-sm lg:text-left">
{dayjs(selectedFunction?.updated_at ?? 0).format('dddd, MMMM D, YYYY h:mm A')}
</dd>
<dt className="text-sm text-foreground-light">Deployments</dt>
<dd className="text-sm lg:text-left">{selectedFunction?.version ?? 0}</dd>
<dt className="text-sm text-foreground-light">Import Maps</dt>
<dd className="text-sm lg:text-left">
<p>
Import maps are{' '}
<span className={cn(hasImportMap ? 'text-brand' : 'text-amber-900')}>
{hasImportMap ? 'used' : 'not used'}
</span>{' '}
for this function
</p>
<p className="text-foreground-light mt-1">
Import maps allow the use of bare specifiers in functions instead of explicit
import URLs
</p>
<div className="mt-4">
<Button
asChild
type="default"
size="tiny"
icon={<ExternalLink strokeWidth={1.5} />}
>
<Link
href="https://supabase.com/docs/guides/functions/dependencies"
target="_blank"
rel="noreferrer"
>
More about import maps
</Link>
</Button>
</div>
</dd>
</dl>
</CardContent>
</Card>
</ScaffoldSection>
</div>
</div>
)
}
export default EdgeFunctionDetails
@@ -5,6 +5,7 @@ import Table from 'components/to-be-cleaned/Table'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import type { ProjectSecret } from 'data/secrets/secrets-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { TimestampInfo } from 'ui-patterns'
interface EdgeFunctionSecretProps {
secret: ProjectSecret
@@ -23,11 +24,21 @@ const EdgeFunctionSecret = ({ secret, onSelectDelete }: EdgeFunctionSecretProps)
<p className="truncate py-2">{secret.name}</p>
</Table.td>
<Table.td>
<div className="flex items-center space-x-2">
<p className="font-mono text-sm truncate" title={secret.value}>
{secret.value}
</p>
</div>
<p className="font-mono text-sm max-w-96 truncate" title={secret.value}>
{secret.value}
</p>
</Table.td>
<Table.td>
{!!secret.updated_at ? (
<TimestampInfo
displayAs="utc"
utcTimestamp={secret.updated_at}
labelFormat="DD MMM YYYY HH:mm:ss (ZZ)"
className="!text-sm"
/>
) : (
'-'
)}
</Table.td>
<Table.td>
<div className="flex items-center justify-end">
@@ -68,47 +68,51 @@ const EdgeFunctionSecrets = () => {
icon={<Search size={14} />}
/>
</div>
<Table
head={[
<Table.th key="secret-name">Name</Table.th>,
<Table.th key="secret-value" className="flex items-center gap-x-2">
Digest{' '}
<Badge color="scale" className="font-mono">
SHA256
</Badge>
</Table.th>,
<Table.th key="actions" />,
]}
body={
secrets.length > 0 ? (
secrets.map((secret) => (
<EdgeFunctionSecret
key={secret.name}
secret={secret}
onSelectDelete={() => setSelectedSecret(secret)}
/>
))
) : secrets.length === 0 && searchString.length > 0 ? (
<Table.tr>
<Table.td colSpan={3}>
<p className="text-sm text-foreground">No results found</p>
<p className="text-sm text-foreground-light">
Your search for "{searchString}" did not return any results
</p>
</Table.td>
</Table.tr>
) : (
<Table.tr>
<Table.td colSpan={3}>
<p className="text-sm text-foreground">No secrets created</p>
<p className="text-sm text-foreground-light">
There are no secrets associated with your project yet
</p>
</Table.td>
</Table.tr>
)
}
/>
<div className="w-full overflow-hidden overflow-x-auto">
<Table
head={[
<Table.th key="secret-name">Name</Table.th>,
<Table.th key="secret-value" className="flex items-center gap-x-2">
Digest{' '}
<Badge color="scale" className="font-mono">
SHA256
</Badge>
</Table.th>,
<Table.th key="secret-updated-at">Updated at</Table.th>,
<Table.th key="actions" />,
]}
body={
secrets.length > 0 ? (
secrets.map((secret) => (
<EdgeFunctionSecret
key={secret.name}
secret={secret}
onSelectDelete={() => setSelectedSecret(secret)}
/>
))
) : secrets.length === 0 && searchString.length > 0 ? (
<Table.tr>
<Table.td colSpan={3}>
<p className="text-sm text-foreground">No results found</p>
<p className="text-sm text-foreground-light">
Your search for "{searchString}" did not return any results
</p>
</Table.td>
</Table.tr>
) : (
<Table.tr>
<Table.td colSpan={3}>
<p className="text-sm text-foreground">No secrets created</p>
<p className="text-sm text-foreground-light">
There are no secrets associated with your project yet
</p>
</Table.td>
</Table.tr>
)
}
/>
</div>
</>
)}
</div>
@@ -14,7 +14,7 @@ interface EdgeFunctionsListItemProps {
function: EdgeFunctionsResponse
}
const EdgeFunctionsListItem = ({ function: item }: EdgeFunctionsListItemProps) => {
export const EdgeFunctionsListItem = ({ function: item }: EdgeFunctionsListItemProps) => {
const router = useRouter()
const { ref } = useParams()
const { project } = useProjectContext()
@@ -46,7 +46,9 @@ const EdgeFunctionsListItem = ({ function: item }: EdgeFunctionsListItemProps) =
</Table.td>
<Table.td>
<div className="text-xs text-foreground-light flex gap-2 items-center truncate">
<p className="font-mono truncate hidden md:inline">{endpoint}</p>
<p title={endpoint} className="font-mono truncate hidden md:inline max-w-[30rem]">
{endpoint}
</p>
<button
type="button"
className="text-foreground-lighter hover:text-foreground transition"
@@ -99,5 +101,3 @@ const EdgeFunctionsListItem = ({ function: item }: EdgeFunctionsListItemProps) =
</Table.tr>
)
}
export default EdgeFunctionsListItem
@@ -0,0 +1,302 @@
export const EDGE_FUNCTION_TEMPLATES = [
{
value: 'hello-world',
name: 'Simple Hello World',
description: 'Basic function that returns a JSON response',
content: `// Setup type definitions for built-in Supabase Runtime APIs
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
interface reqPayload {
name: string;
}
console.info('server started');
Deno.serve(async (req: Request) => {
const { name }: reqPayload = await req.json();
const data = {
message: \`Hello \${name} from foo!\`,
};
return new Response(
JSON.stringify(data),
{ headers: { 'Content-Type': 'application/json', 'Connection': 'keep-alive' }}
);
});`,
},
{
value: 'database-access',
name: 'Supabase Database Access',
description: 'Example using Supabase client to query your database',
content: `// Setup type definitions for built-in Supabase Runtime APIs
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from 'jsr:@supabase/supabase-js@2'
Deno.serve(async (req) => {
try {
const supabase = createClient(
Deno.env.get('SUPABASE_URL') ?? '',
Deno.env.get('SUPABASE_ANON_KEY') ?? '',
{ global: { headers: { Authorization: req.headers.get('Authorization')! } } }
)
const { data, error } = await supabase.from('table_name').select('*')
if (error) {
throw error
}
return new Response(JSON.stringify({ data }), {
headers: { 'Content-Type': 'application/json' },
status: 200,
})
} catch (err) {
return new Response(String(err?.message ?? err), { status: 500 })
}
})`,
},
{
value: 'storage-upload',
name: 'Supabase Storage Upload',
description: 'Upload files to Supabase Storage',
content: `// Setup type definitions for built-in Supabase Runtime APIs
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from 'jsr:@supabase/supabase-js@2'
const supabase = createClient(
Deno.env.get('SUPABASE_URL') ?? '',
Deno.env.get('SUPABASE_ANON_KEY') ?? ''
)
Deno.serve(async (req) => {
const formData = await req.formData()
const file = formData.get('file')
const { data, error } = await supabase
.storage
.from('your-bucket')
.upload(
\`files/\${crypto.randomUUID()}\`,
file,
{ contentType: file.type }
)
if (error) throw error
return new Response(
JSON.stringify({ data }),
{ headers: { 'Content-Type': 'application/json' }}
)
})`,
},
{
value: 'node-api',
name: 'Node Built-in API Example',
description: 'Example using Node.js built-in crypto and http modules',
content: `// Setup type definitions for built-in Supabase Runtime APIs
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { randomBytes } from "node:crypto";
import { createServer } from "node:http";
import process from "node:process";
const generateRandomString = (length) => {
const buffer = randomBytes(length);
return buffer.toString('hex');
};
const randomString = generateRandomString(10);
console.log(randomString);
const server = createServer((req, res) => {
const message = \`Hello\`;
res.end(message);
});
server.listen(9999);`,
},
{
value: 'express',
name: 'Express Server',
description: 'Example using Express.js for routing',
content: `// Setup type definitions for built-in Supabase Runtime APIs
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import express from "npm:express@4.18.2";
const app = express();
app.get(/(.*)/, (req, res) => {
res.send("Welcome to Supabase");
});
app.listen(8000);`,
},
{
value: 'openai-completion',
name: 'OpenAI Text Completion',
description: 'Generate text completions using OpenAI GPT-3',
content: `// Setup type definitions for built-in Supabase Runtime APIs
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { Configuration, OpenAIApi } from 'npm:openai@3.3.0'
const openAi = new OpenAIApi(
new Configuration({
apiKey: Deno.env.get('OPENAI_API_KEY')
})
)
Deno.serve(async (req) => {
const { prompt } = await req.json()
const completion = await openAi.createCompletion({
model: 'text-davinci-003',
prompt,
max_tokens: 200
})
return new Response(
JSON.stringify({ text: completion.data.choices[0].text }),
{ headers: { 'Content-Type': 'application/json' }}
)
})`,
},
{
value: 'stripe-webhook',
name: 'Stripe Webhook Example',
description: 'Handle Stripe webhook events securely',
content: `// Setup type definitions for built-in Supabase Runtime APIs
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import Stripe from 'npm:stripe@12.0.0'
const stripe = new Stripe(Deno.env.get('STRIPE_API_KEY')!)
const endpointSecret = Deno.env.get('STRIPE_WEBHOOK_SIGNING_SECRET')!
Deno.serve(async (req) => {
const signature = req.headers.get('stripe-signature')
try {
const event = stripe.webhooks.constructEvent(
await req.text(),
signature!,
endpointSecret
)
switch (event.type) {
case 'payment_intent.succeeded':
// Handle successful payment
break
}
return new Response(JSON.stringify({ received: true }), {
headers: { 'Content-Type': 'application/json' },
})
} catch (err) {
return new Response(
JSON.stringify({ error: err.message }),
{ status: 400 }
)
}
})`,
},
{
value: 'resend-email',
name: 'Send Emails',
description: 'Send emails using the Resend API',
content: `// Setup type definitions for built-in Supabase Runtime APIs
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
const RESEND_API_KEY = Deno.env.get('RESEND_API_KEY')!
Deno.serve(async (req) => {
const { to, subject, html } = await req.json()
const res = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: \`Bearer \${RESEND_API_KEY}\`,
},
body: JSON.stringify({
from: 'you@example.com',
to,
subject,
html,
}),
})
const data = await res.json()
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' },
})
})`,
},
{
value: 'image-transform',
name: 'Image Transformation',
description: 'Transform images using ImageMagick WASM',
content: `// Setup type definitions for built-in Supabase Runtime APIs
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import {
ImageMagick,
initializeImageMagick,
} from "npm:@imagemagick/magick-wasm@0.0.30"
await initializeImageMagick()
Deno.serve(async (req) => {
const formData = await req.formData()
const file = formData.get('file')
const content = await file.arrayBuffer()
const result = await ImageMagick.read(new Uint8Array(content), (img) => {
img.resize(500, 300)
img.blur(60, 5)
return img.write(data => data)
})
return new Response(
result,
{ headers: { 'Content-Type': 'image/png' }}
)
})`,
},
{
value: 'discord-bot',
name: 'Discord Bot Example',
description: 'Build a Slash Command Discord Bot',
content: `// Setup type definitions for built-in Supabase Runtime APIs
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { verifyDiscordRequest } from './_shared/discord.ts'
Deno.serve(async (req) => {
const { valid } = await verifyDiscordRequest(req)
if (!valid) {
return new Response('Invalid request', { status: 401 })
}
const message = await req.json()
if (message.type === 1) {
return new Response(
JSON.stringify({ type: 1 }),
{ headers: { 'Content-Type': 'application/json' }}
)
}
return new Response(
JSON.stringify({
type: 4,
data: { content: 'Hello from Supabase Edge Functions!' }
}),
{ headers: { 'Content-Type': 'application/json' }}
)
})`,
},
{
value: 'websocket-server',
name: 'Websocket Server Example',
description: 'Create a real-time WebSocket server',
content: `// Setup type definitions for built-in Supabase Runtime APIs
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
Deno.serve((req) => {
const upgrade = req.headers.get("upgrade") || ""
if (upgrade.toLowerCase() != "websocket") {
return new Response("request isn't trying to upgrade to websocket.")
}
const { socket, response } = Deno.upgradeWebSocket(req)
socket.onopen = () => {
console.log("client connected!")
socket.send('Welcome to Supabase Edge Functions!')
}
socket.onmessage = (e) => {
console.log("client sent message:", e.data)
socket.send(new Date().toString())
}
return response
})`,
},
]
@@ -1,41 +1,132 @@
import { ExternalLink } from 'lucide-react'
import { useParams } from 'common'
import { ScaffoldSectionTitle } from 'components/layouts/Scaffold'
import { ResourceItem } from 'components/ui/Resource/ResourceItem'
import { ResourceList } from 'components/ui/Resource/ResourceList'
import { useFlag } from 'hooks/ui/useFlag'
import { Code, Terminal } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useAppStateSnapshot } from 'state/app-state'
import {
AiIconAnimation,
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Dialog,
DialogContent,
DialogSection,
DialogTrigger,
} from 'ui'
import { EDGE_FUNCTION_TEMPLATES } from './Functions.templates'
import { TerminalInstructions } from './TerminalInstructions'
import { DocsButton } from 'components/ui/DocsButton'
import { Button } from 'ui'
import TerminalInstructions from './TerminalInstructions'
export const FunctionsEmptyState = () => {
const { ref } = useParams()
const router = useRouter()
const { setAiAssistantPanel } = useAppStateSnapshot()
const edgeFunctionCreate = useFlag('edgeFunctionCreate')
const FunctionsEmptyState = () => {
return (
<>
<div className="flex flex-col gap-8 w-full">
<div className="flex flex-col gap-4">
<p className="max-w-lg text-base text-foreground">
Scalable functions to run your code with no server management.
</p>
<p className="max-w-lg text-sm text-foreground-light">
Edge Functions are server-side Typescript functions, distributed globally at the edge -
close to your users. They can be used for listening to webhooks or integrating your
Supabase project with third-parties.
</p>
<div className="flex gap-x-2">
<DocsButton href="https://supabase.com/docs/guides/functions" />
<Button asChild type="default" icon={<ExternalLink />}>
<a
target="_blank"
rel="noreferrer"
href="https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions"
>
Examples
</a>
<Card>
<CardHeader>
<CardTitle>Create your first edge function</CardTitle>
</CardHeader>
<CardContent className="p-0 grid grid-cols-[repeat(auto-fit,minmax(240px,1fr))] divide-y md:divide-y-0 md:divide-x divide-default items-stretch">
{/* CLI Option */}
<div className="p-8">
<div className="flex items-center gap-2">
<Terminal strokeWidth={1.5} size={20} />
<h4 className="text-base text-foreground">Via CLI</h4>
</div>
<p className="text-sm text-foreground-light mb-4 mt-1">
Create and deploy functions using the Supabase CLI. Ideal for local development and
version control.
</p>
<Dialog>
<DialogTrigger asChild>
<Button type="default">View CLI Instructions</Button>
</DialogTrigger>
<DialogContent size="large">
<DialogSection padding="small">
<TerminalInstructions />
</DialogSection>
</DialogContent>
</Dialog>
</div>
{/* AI Assistant Option */}
<div className="p-8">
<div className="flex items-center gap-2">
<AiIconAnimation size={20} />
<h4 className="text-base text-foreground">AI Assistant</h4>
</div>
<p className="text-sm text-foreground-light mb-4 mt-1">
Let our AI assistant help you create functions. Perfect for kickstarting a function.
</p>
<Button
type="default"
onClick={() =>
setAiAssistantPanel({
open: true,
initialInput: 'Create a new edge function that ...',
suggestions: {
title:
'I can help you create a new edge function. Here are a few example prompts to get you started:',
prompts: [
'Create a new edge function that processes payments with Stripe',
'Create a new edge function that sends emails with Resend',
'Create a new edge function that generates PDFs from HTML templates',
],
},
})
}
>
Open Assistant
</Button>
</div>
</div>
<div className="col-span-8 bg-surface-100 px-5 py-4 border rounded-md">
<TerminalInstructions />
</div>
</div>
{/* Editor Option */}
{edgeFunctionCreate && (
<div className="p-8">
<div className="flex items-center gap-2">
<Code strokeWidth={1.5} size={20} />
<h4 className="text-base text-foreground">Via Editor</h4>
</div>
<p className="text-sm text-foreground-light mb-4 mt-1">
Create and edit functions directly in the browser. Download to local at any time.
</p>
<Button type="default" onClick={() => router.push(`/project/${ref}/functions/new`)}>
Open Editor
</Button>
</div>
)}
</CardContent>
</Card>
{edgeFunctionCreate && (
<>
<ScaffoldSectionTitle className="text-xl mb-4 mt-12">
Start with a template
</ScaffoldSectionTitle>
<ResourceList>
{EDGE_FUNCTION_TEMPLATES.map((template) => (
<ResourceItem
key={template.name}
media={<Code strokeWidth={1.5} size={16} className="-translate-y-[9px]" />}
onClick={() => {}}
>
<Link href={`/project/${ref}/functions/new?template=${template.value}`}>
<p>{template.name}</p>
<p className="text-sm text-foreground-lighter">{template.description}</p>
</Link>
</ResourceItem>
))}
</ResourceList>
</>
)}
</>
)
}
export default FunctionsEmptyState
@@ -21,7 +21,7 @@ interface TerminalInstructionsProps extends ComponentPropsWithoutRef<typeof Coll
removeBorder?: boolean
}
const TerminalInstructions = forwardRef<
export const TerminalInstructions = forwardRef<
ElementRef<typeof Collapsible_Shadcn_>,
TerminalInstructionsProps
>(({ closable = false, removeBorder = false, ...props }, ref) => {
@@ -158,5 +158,3 @@ const TerminalInstructions = forwardRef<
})
TerminalInstructions.displayName = 'TerminalInstructions'
export default TerminalInstructions
@@ -1,4 +0,0 @@
export { default as EdgeFunctionDetails } from './EdgeFunctionDetails/EdgeFunctionDetails'
export { default as EdgeFunctionsListItem } from './EdgeFunctionsListItem'
export { default as FunctionsEmptyState } from './FunctionsEmptyState'
export { default as TerminalInstructions } from './TerminalInstructions'
@@ -1,4 +1,15 @@
import { Box, Clock, Eye, Lock, Ruler, Table2, TextSearch, Unlock, User } from 'lucide-react'
import {
Box,
Clock,
Eye,
Lock,
LockIcon,
Ruler,
Table2,
TextSearch,
Unlock,
User,
} from 'lucide-react'
import Link from 'next/link'
import { LINTER_LEVELS, LintInfo } from 'components/interfaces/Linter/Linter.constants'
@@ -204,6 +215,14 @@ export const lintInfoMap: LintInfo[] = [
linkText: 'View settings',
docsLink: 'https://supabase.com/docs/guides/platform/network-restrictions',
},
{
name: 'password_requirements_min_length',
title: 'Minimum password length not set or inadequate',
icon: <Ruler className="text-foreground-muted" size={15} strokeWidth={1} />,
link: ({ projectRef }) => `/project/${projectRef}/settings/auth`,
linkText: 'View settings',
docsLink: 'https://supabase.com/docs/guides/platform/going-into-prod#security',
},
{
name: 'pitr_not_enabled',
title: 'PITR not enabled',
@@ -213,12 +232,45 @@ export const lintInfoMap: LintInfo[] = [
docsLink: 'https://supabase.com/docs/guides/platform/backups#point-in-time-recovery',
},
{
name: 'password_requirements_min_length',
title: 'Minimum password length not set or inadequate',
icon: <Ruler className="text-foreground-muted" size={15} strokeWidth={1} />,
name: 'auth_leaked_password_protection',
title: 'Leaked Password Protection Disabled',
icon: <LockIcon className="text-foreground-muted" size={15} strokeWidth={1} />,
link: ({ projectRef }) => `/project/${projectRef}/settings/auth`,
linkText: 'View settings',
docsLink: 'https://supabase.com/docs/guides/platform/going-into-prod#security',
docsLink:
'https://supabase.com/docs/guides/auth/password-security#password-strength-and-leaked-password-protection',
},
{
name: 'auth_insufficient_mfa_options',
title: 'Insufficient MFA Options',
icon: <LockIcon className="text-foreground-muted" size={15} strokeWidth={1} />,
link: ({ projectRef }) => `/project/${projectRef}/settings/auth`,
linkText: 'View settings',
docsLink: 'https://supabase.com/docs/guides/auth/auth-mfa',
},
{
name: 'auth_password_policy_missing',
title: 'Password Policy Missing',
icon: <LockIcon className="text-foreground-muted" size={15} strokeWidth={1} />,
link: ({ projectRef }) => `/project/${projectRef}/settings/auth`,
linkText: 'View settings',
docsLink: 'https://supabase.com/docs/guides/auth/password-security',
},
{
name: 'leaked_service_key',
title: 'Leaked Service Key Detected',
icon: <LockIcon className="text-foreground-muted" size={15} strokeWidth={1} />,
link: ({ projectRef }) => `/project/${projectRef}/settings/api`,
linkText: 'View settings',
docsLink: 'https://supabase.com/docs/guides/api/api-keys#the-servicerole-key',
},
{
name: 'no_backup_admin',
title: 'No Backup Admin Detected',
icon: <LockIcon className="text-foreground-muted" size={15} strokeWidth={1} />,
link: ({ projectRef }) => `/project/${projectRef}/settings/auth`,
linkText: 'View settings',
docsLink: 'https://supabase.com/docs/guides/auth/auth-mfa',
},
]
@@ -67,7 +67,7 @@ const PaymentMethodSelection = ({
<div className="flex items-center justify-between px-4 py-2 border border-dashed rounded-md bg-alternative">
<div className="flex items-center space-x-4 text-foreground-light">
<AlertCircle size={16} strokeWidth={1.5} />
<p className="text-sm">No saved payment methods</p>
<p className="text-sm">No payment methods</p>
</div>
<ButtonTooltip
@@ -26,7 +26,7 @@ import type { OrgPlan, SubscriptionTier } from 'data/subscriptions/types'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useFlag } from 'hooks/ui/useFlag'
import { useFlag, usePHFlag } from 'hooks/ui/useFlag'
import { PRICING_TIER_PRODUCT_IDS } from 'lib/constants'
import { formatCurrency } from 'lib/helpers'
import { pickFeatures, pickFooter, plans as subscriptionsPlans } from 'shared-data/plans'
@@ -36,6 +36,7 @@ import DowngradeModal from './DowngradeModal'
import { EnterpriseCard } from './EnterpriseCard'
import ExitSurveyModal from './ExitSurveyModal'
import MembersExceedLimitModal from './MembersExceedLimitModal'
import SubscriptionPlanUpdateDialog from './SubscriptionPlanUpdateDialog'
import PaymentMethodSelection from './PaymentMethodSelection'
import UpgradeSurveyModal from './UpgradeModal'
@@ -48,6 +49,7 @@ const PlanUpdateSidePanel = () => {
const queryClient = useQueryClient()
const originalPlanRef = useRef<string>()
const allowOrioleDB = useFlag('allowOrioleDb')
const newPlanUpdateDialog = usePHFlag('newPlanUpdateDialog')
const [showExitSurvey, setShowExitSurvey] = useState(false)
const [showUpgradeSurvey, setShowUpgradeSurvey] = useState(false)
@@ -100,7 +102,6 @@ const PlanUpdateSidePanel = () => {
const billingViaPartner = subscription?.billing_via_partner === true
const billingPartner = subscription?.billing_partner
const paymentViaInvoice = subscription?.payment_method_type === 'invoice'
const {
data: subscriptionPreview,
@@ -115,14 +116,6 @@ const PlanUpdateSidePanel = () => {
orgProjects.filter((it) => it.status !== 'INACTIVE' && it.status !== 'GOING_DOWN').length > 0
const subscriptionPlanMeta = subscriptionsPlans.find((tier) => tier.id === selectedTier)
const expandUsageFee = (fee: string) => {
setUsageFeesExpanded([...usageFeesExpanded, fee])
}
const collapseUsageFee = (fee: string) => {
setUsageFeesExpanded(usageFeesExpanded.filter((item) => item !== fee))
}
useEffect(() => {
if (visible) {
setSelectedTier(undefined)
@@ -159,10 +152,14 @@ const PlanUpdateSidePanel = () => {
}
}
const planMeta = selectedTier
? availablePlans.find((p) => p.id === selectedTier.split('tier_')[1])
: null
const onUpdateSubscription = async () => {
if (!slug) return console.error('org slug is required')
if (!selectedTier) return console.error('Selected plan is required')
if (!selectedPaymentMethod && !paymentViaInvoice) {
if (!selectedPaymentMethod && subscription?.payment_method_type !== 'invoice') {
return toast.error('Please select a payment method')
}
@@ -189,9 +186,13 @@ const PlanUpdateSidePanel = () => {
updateOrgSubscription({ slug, tier, paymentMethod: selectedPaymentMethod })
}
const planMeta = selectedTier
? availablePlans.find((p) => p.id === selectedTier.split('tier_')[1])
: null
const expandUsageFee = (fee: string) => {
setUsageFeesExpanded([...usageFeesExpanded, fee])
}
const collapseUsageFee = (fee: string) => {
setUsageFeesExpanded(usageFeesExpanded.filter((item) => item !== fee))
}
return (
<>
@@ -356,218 +357,242 @@ const PlanUpdateSidePanel = () => {
projects={orgProjects}
/>
<Modal
loading={isUpdating}
alignFooter="right"
size="xlarge"
visible={selectedTier !== undefined && selectedTier !== 'tier_free'}
onCancel={() => setSelectedTier(undefined)}
onConfirm={onUpdateSubscription}
dialogOverlayProps={{ className: 'pointer-events-none' }}
header={`Confirm ${planMeta?.change_type === 'downgrade' ? 'downgrade' : 'upgrade'} to ${subscriptionPlanMeta?.name}`}
>
<Modal.Content>
{subscriptionPreviewError && (
<AlertError
error={subscriptionPreviewError}
subject="Failed to preview subscription."
/>
)}
{subscriptionPreviewIsLoading && (
<div className="space-y-2">
<span className="text-sm">Estimating monthly costs...</span>
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
<ShimmeringLoader className="w-1/2" />
</div>
)}
{subscriptionPreviewInitialized && (
<div>
<Table
className="mt-2"
borderless={true}
head={[
<Table.th key="header-item">Item</Table.th>,
<Table.th key="header-count" className="text-right pr-4">
Usage
</Table.th>,
<Table.th key="header-unit-price">Unit Price</Table.th>,
<Table.th key="header-price" className="text-right">
Cost
</Table.th>,
]}
body={
<>
{subscriptionPreview.breakdown.map((item) => (
<>
<Table.tr key={item.description}>
<Table.td>
{item.breakdown && item.breakdown.length > 0 && (
<Button
type="text"
className="!pl-0 !pr-1"
icon={
<ChevronRight
className={cn(
'transition',
usageFeesExpanded.includes(item.description) && 'rotate-90'
)}
/>
}
onClick={() =>
usageFeesExpanded.includes(item.description)
? collapseUsageFee(item.description)
: expandUsageFee(item.description)
}
/>
)}
{item.description ?? 'Unknown'}{' '}
{item.description.endsWith('Compute') && ` (Hours)`}
</Table.td>
<Table.td className="text-right pr-4 tabular-nums">
{item.quantity?.toLocaleString()}
</Table.td>
<Table.td>
{item.unit_price_desc
? item.unit_price_desc
: item.unit_price === 0
? 'FREE'
: item.unit_price
? `${formatCurrency(item.unit_price)}`
: ''}
</Table.td>
<Table.td className="text-right">
{formatCurrency(item.total_price)}
</Table.td>
</Table.tr>
{newPlanUpdateDialog === 'variant-a' ? (
<SubscriptionPlanUpdateDialog
selectedTier={selectedTier}
selectedOrganization={selectedOrganization}
onClose={() => setSelectedTier(undefined)}
subscriptionPlanMeta={subscriptionPlanMeta}
planMeta={planMeta}
subscriptionPreviewError={subscriptionPreviewError}
subscriptionPreviewIsLoading={subscriptionPreviewIsLoading}
subscriptionPreviewInitialized={subscriptionPreviewInitialized}
subscriptionPreview={subscriptionPreview}
billingViaPartner={billingViaPartner}
billingPartner={billingPartner}
subscription={subscription}
slug={slug}
currentPlanMeta={{
...availablePlans.find((p) => p.id === subscription?.plan?.id),
features:
subscriptionsPlans.find((plan) => plan.id === `tier_${subscription?.plan?.id}`)
?.features || [],
}}
/>
) : (
<Modal
loading={isUpdating}
alignFooter="right"
size="xlarge"
visible={selectedTier !== undefined && selectedTier !== 'tier_free'}
onCancel={() => setSelectedTier(undefined)}
onConfirm={onUpdateSubscription}
dialogOverlayProps={{ className: 'pointer-events-none' }}
header={`Confirm ${planMeta?.change_type === 'downgrade' ? 'downgrade' : 'upgrade'} to ${subscriptionPlanMeta?.name}`}
>
<Modal.Content>
{subscriptionPreviewError && (
<AlertError
error={subscriptionPreviewError}
subject="Failed to preview subscription."
/>
)}
{subscriptionPreviewIsLoading && (
<div className="space-y-2">
<span className="text-sm">Estimating monthly costs...</span>
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
<ShimmeringLoader className="w-1/2" />
</div>
)}
{subscriptionPreviewInitialized && (
<div>
<Table
className="mt-2"
borderless={true}
head={[
<Table.th key="header-item">Item</Table.th>,
<Table.th key="header-count" className="text-right pr-4">
Usage
</Table.th>,
<Table.th key="header-unit-price">Unit Price</Table.th>,
<Table.th key="header-price" className="text-right">
Cost
</Table.th>,
]}
body={
<>
{subscriptionPreview.breakdown.map((item) => (
<>
<Table.tr key={item.description}>
<Table.td>
{item.breakdown && item.breakdown.length > 0 && (
<Button
type="text"
className="!pl-0 !pr-1"
icon={
<ChevronRight
className={cn(
'transition',
usageFeesExpanded.includes(item.description) && 'rotate-90'
)}
/>
}
onClick={() =>
usageFeesExpanded.includes(item.description)
? collapseUsageFee(item.description)
: expandUsageFee(item.description)
}
/>
)}
{item.description ?? 'Unknown'}{' '}
{item.description.endsWith('Compute') && ` (Hours)`}
</Table.td>
<Table.td className="text-right pr-4 tabular-nums">
{item.quantity?.toLocaleString()}
</Table.td>
<Table.td>
{item.unit_price_desc
? item.unit_price_desc
: item.unit_price === 0
? 'FREE'
: item.unit_price
? `${formatCurrency(item.unit_price)}`
: ''}
</Table.td>
<Table.td className="text-right">
{formatCurrency(item.total_price)}
</Table.td>
</Table.tr>
{usageFeesExpanded.includes(item.description) &&
item.breakdown &&
item.breakdown.length > 0 &&
item.breakdown.map((project) => (
<Table.tr key={project.project_ref}>
<Table.td className="!pl-12">{project.project_name}</Table.td>
<Table.td className="text-right pr-4 tabular-nums">
{project.usage}
</Table.td>
<Table.td />
<Table.td />
</Table.tr>
))}
</>
))}
{usageFeesExpanded.includes(item.description) &&
item.breakdown &&
item.breakdown.length > 0 &&
item.breakdown.map((project) => (
<Table.tr key={project.project_ref}>
<Table.td className="!pl-12">{project.project_name}</Table.td>
<Table.td className="text-right pr-4 tabular-nums">
{project.usage}
</Table.td>
<Table.td />
<Table.td />
</Table.tr>
))}
</>
))}
<Table.tr>
<Table.td className="font-medium">
Monthly Costs (excluding over-usage and credits)
</Table.td>
<Table.td />
<Table.td />
<Table.td className="text-right font-medium">
{formatCurrency(
Math.round(
subscriptionPreview.breakdown.reduce(
(prev, cur) => prev + cur.total_price,
0
)
) ?? 0
)}
</Table.td>
</Table.tr>
</>
}
></Table>
<Table.tr>
<Table.td className="font-medium">
Monthly Costs (excluding over-usage and credits)
</Table.td>
<Table.td />
<Table.td />
<Table.td className="text-right font-medium">
{formatCurrency(
Math.round(
subscriptionPreview.breakdown.reduce(
(prev, cur) => prev + cur.total_price,
0
)
) ?? 0
)}
</Table.td>
</Table.tr>
</>
}
></Table>
<InformationBox
className="mt-4"
title="Usage-billing for Compute"
icon={<Info />}
defaultVisibility={true}
hideCollapse={true}
description={
<div>
<p className="text-sm mt-2">
Each project is a dedicated server and database. Paid plans come with $10 of
Compute Credits to cover one project on the default Micro Compute size or
parts of any compute addon. Additional unpaused projects on paid plans will
incur compute usage costs starting at $10 per month, billed hourly.
</p>
{subscription?.plan?.id === 'free' && (
<InformationBox
className="mt-4"
title="Usage-billing for Compute"
icon={<Info />}
defaultVisibility={true}
hideCollapse={true}
description={
<div>
<p className="text-sm mt-2">
Mixing paid and non-paid projects in a single organization is not possible.
If you want projects to be on the Free Plan, use self-serve project
transfers.
Each project is a dedicated server and database. Paid plans come with $10 of
Compute Credits to cover one project on the default Micro Compute size or
parts of any compute addon. Additional unpaused projects on paid plans will
incur compute usage costs starting at $10 per month, billed hourly.
</p>
)}
<div className="space-x-3 mt-2">
<Button asChild type="default" icon={<ExternalLink strokeWidth={1.5} />}>
<Link
href="https://supabase.com/docs/guides/platform/manage-your-usage/compute"
target="_blank"
rel="noreferrer"
>
How billing for Compute works
</Link>
</Button>
{subscription?.plan?.id === 'free' && (
<p className="text-sm mt-2">
Mixing paid and non-paid projects in a single organization is not
possible. If you want projects to be on the Free Plan, use self-serve
project transfers.
</p>
)}
<div className="space-x-3 mt-2">
<Button asChild type="default" icon={<ExternalLink strokeWidth={1.5} />}>
<Link
href="https://supabase.com/docs/guides/platform/project-transfer"
href="https://supabase.com/docs/guides/platform/manage-your-usage/compute"
target="_blank"
rel="noreferrer"
>
Project transfers
How billing for Compute works
</Link>
</Button>
)}
{subscription?.plan?.id === 'free' && (
<Button asChild type="default" icon={<ExternalLink strokeWidth={1.5} />}>
<Link
href="https://supabase.com/docs/guides/platform/project-transfer"
target="_blank"
rel="noreferrer"
>
Project transfers
</Link>
</Button>
)}
</div>
</div>
</div>
}
/>
</div>
)}
</Modal.Content>
<Modal.Content>
{!billingViaPartner ? (
<div className="py-4 space-y-2">
<p className="text-sm">
Upon clicking confirm, your monthly invoice will be adjusted and your credit card
will be charged immediately. Changing the plan resets your billing cycle and may
result in a prorated charge for previous usage.
</p>
<div className="!mt-4">
<PaymentMethodSelection
selectedPaymentMethod={selectedPaymentMethod}
onSelectPaymentMethod={setSelectedPaymentMethod}
}
/>
</div>
</div>
) : (
<div className="py-4 space-y-2">
<p className="text-sm">
This organization is billed through our partner{' '}
{billingPartnerLabel(billingPartner)}.{' '}
{billingPartner === 'aws' ? (
<>The organization's credit balance will be decreased accordingly.</>
) : (
<>You will be charged by them directly.</>
)}
</p>
{billingViaPartner &&
billingPartner === 'fly' &&
subscriptionPreview?.plan_change_type === 'downgrade' && (
<p className="text-sm">
Your organization will be downgraded at the end of your current billing cycle.
</p>
)}
</div>
)}
</Modal.Content>
</Modal>
)}
</Modal.Content>
<Modal.Content>
{!billingViaPartner ? (
<div className="py-4 space-y-2">
<p className="text-sm">
Upon clicking confirm, your monthly invoice will be adjusted and your credit card
will be charged immediately. Changing the plan resets your billing cycle and may
result in a prorated charge for previous usage.
</p>
<div className="!mt-4">
<PaymentMethodSelection
selectedPaymentMethod={selectedPaymentMethod}
onSelectPaymentMethod={setSelectedPaymentMethod}
/>
</div>
</div>
) : (
<div className="py-4 space-y-2">
<p className="text-sm">
This organization is billed through our partner{' '}
{billingPartnerLabel(billingPartner)}.{' '}
{billingPartner === 'aws' ? (
<>The organization's credit balance will be decreased accordingly.</>
) : (
<>You will be charged by them directly.</>
)}
</p>
{billingViaPartner &&
billingPartner === 'fly' &&
subscriptionPreview?.plan_change_type === 'downgrade' && (
<p className="text-sm">
Your organization will be downgraded at the end of your current billing cycle.
</p>
)}
</div>
)}
</Modal.Content>
</Modal>
)}
<MembersExceedLimitModal
visible={showDowngradeError}
@@ -0,0 +1,652 @@
import { useQueryClient } from '@tanstack/react-query'
import { InfoIcon, Check, ExternalLink } from 'lucide-react'
import Link from 'next/link'
import { useState, useEffect } from 'react'
import { toast } from 'sonner'
import tweets from 'shared-data/tweets'
import AlertError from 'components/ui/AlertError'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
HoverCard,
HoverCardContent,
HoverCardTrigger,
Card,
CardContent,
Badge,
} from 'ui'
import { useOrgSubscriptionUpdateMutation } from 'data/subscriptions/org-subscription-update-mutation'
import { organizationKeys } from 'data/organizations/keys'
import { formatCurrency } from 'lib/helpers'
import { PRICING_TIER_PRODUCT_IDS } from 'lib/constants'
import { SubscriptionTier } from 'data/subscriptions/types'
import { billingPartnerLabel } from 'components/interfaces/Billing/Subscription/Subscription.utils'
import PaymentMethodSelection from './PaymentMethodSelection'
import { Button, Dialog, DialogContent } from 'ui'
import { InfoTooltip } from 'ui-patterns/info-tooltip'
import { OrganizationBillingSubscriptionPreviewResponse } from 'data/organizations/organization-billing-subscription-preview'
const getRandomTweet = () => {
const filteredTweets = tweets.filter((it) => it.text.length < 180)
const randomIndex = Math.floor(Math.random() * filteredTweets.length)
return filteredTweets[randomIndex]
}
const PLAN_HEADINGS = {
tier_pro:
'the Pro plan and create unlimited projects, daily backups and premium support when you need it',
tier_team: 'the Team plan for SOC2, SSO, priority support and greater data and log retention',
default: 'to a new plan',
} as const
type PlanHeadingKey = keyof typeof PLAN_HEADINGS
// Add downgrade headings
const DOWNGRADE_PLAN_HEADINGS = {
tier_free: 'the Free plan with limited resources and active projects',
tier_pro: 'the Pro plan',
default: 'to a lower plan',
} as const
type DowngradePlanHeadingKey = keyof typeof DOWNGRADE_PLAN_HEADINGS
interface Props {
selectedTier: 'tier_free' | 'tier_pro' | 'tier_team' | undefined
onClose: () => void
subscriptionPlanMeta: any
planMeta: any
subscriptionPreviewError: any
subscriptionPreviewIsLoading: boolean
subscriptionPreviewInitialized: boolean
subscriptionPreview: OrganizationBillingSubscriptionPreviewResponse | undefined
billingViaPartner: boolean
billingPartner?: string
selectedOrganization: any
subscription: any
slug?: string
currentPlanMeta: any
}
const SubscriptionPlanUpdateDialog = ({
selectedTier,
onClose,
subscriptionPlanMeta,
planMeta,
subscriptionPreviewError,
subscriptionPreviewIsLoading,
subscriptionPreviewInitialized,
subscriptionPreview,
billingViaPartner,
billingPartner,
selectedOrganization,
subscription,
slug,
currentPlanMeta,
}: Props) => {
const queryClient = useQueryClient()
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<string>()
const [testimonialTweet, setTestimonialTweet] = useState(getRandomTweet())
useEffect(() => {
if (selectedTier !== undefined && selectedTier !== 'tier_free') {
setTestimonialTweet(getRandomTweet())
}
}, [selectedTier])
const { mutate: updateOrgSubscription, isLoading: isUpdating } = useOrgSubscriptionUpdateMutation(
{
onSuccess: () => {
toast.success(
`Successfully ${planMeta?.change_type === 'downgrade' ? 'downgraded' : 'upgraded'} subscription to ${subscriptionPlanMeta?.name}!`
)
onClose()
window.scrollTo({ top: 0, left: 0, behavior: 'smooth' })
},
onError: (error) => {
toast.error(`Unable to update subscription: ${error.message}`)
},
}
)
const onUpdateSubscription = async () => {
if (!slug) return console.error('org slug is required')
if (!selectedTier) return console.error('Selected plan is required')
if (!selectedPaymentMethod && subscription?.payment_method_type !== 'invoice') {
return toast.error('Please select a payment method')
}
if (selectedPaymentMethod) {
queryClient.setQueriesData(organizationKeys.paymentMethods(slug), (prev: any) => {
if (!prev) return prev
return {
...prev,
defaultPaymentMethodId: selectedPaymentMethod,
data: prev.data.map((pm: any) => ({
...pm,
is_default: pm.id === selectedPaymentMethod,
})),
}
})
}
// If the user is downgrading from team, should have spend cap disabled by default
const tier =
subscription?.plan?.id === 'team' && selectedTier === PRICING_TIER_PRODUCT_IDS.PRO
? (PRICING_TIER_PRODUCT_IDS.PAYG as SubscriptionTier)
: selectedTier
updateOrgSubscription({ slug, tier, paymentMethod: selectedPaymentMethod })
}
const features = subscriptionPlanMeta?.features?.[0]?.features || []
const topFeatures = features
// Get current plan features for downgrade comparison
const currentPlanFeatures = currentPlanMeta?.features?.[0]?.features || []
// Features that will be lost when downgrading
const featuresToLose =
planMeta?.change_type === 'downgrade'
? currentPlanFeatures.filter((feature: string | [string, ...any[]]) => {
const featureStr = typeof feature === 'string' ? feature : feature[0]
// Check if this feature exists in the new plan
return !topFeatures.some((newFeature: string | [string, ...any[]]) => {
const newFeatureStr = typeof newFeature === 'string' ? newFeature : newFeature[0]
return newFeatureStr === featureStr
})
})
: []
return (
<Dialog
open={selectedTier !== undefined && selectedTier !== 'tier_free'}
onOpenChange={(open) => {
if (!open) onClose()
}}
>
<DialogContent
onOpenAutoFocus={(event) => event.preventDefault()}
size="xlarge"
className="p-0"
>
<div className="grid grid-cols-1 md:grid-cols-2 h-full items-stretch">
{/* Left Column */}
<div className="p-8 pb-8 flex flex-col">
<div className="flex-1">
<h3 className="text-lg font-medium mb-4">
{planMeta?.change_type === 'downgrade' ? 'Downgrade' : 'Upgrade'}{' '}
{selectedOrganization?.name} to{' '}
{planMeta?.change_type === 'downgrade'
? DOWNGRADE_PLAN_HEADINGS[(selectedTier as DowngradePlanHeadingKey) || 'default']
: PLAN_HEADINGS[(selectedTier as PlanHeadingKey) || 'default']}
</h3>
{subscriptionPreviewIsLoading && (
<div className="space-y-2 mb-4 mt-2">
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
<ShimmeringLoader className="w-1/2" />
</div>
)}
{subscriptionPreviewInitialized && (
<>
<Table className="mt-2 mb-4 text-foreground-light">
<TableBody>
{(() => {
// Calculate remaining days in current billing cycle
const now = Math.floor(Date.now() / 1000) // current time in seconds
const remainingSeconds = subscription?.current_period_end - now
const totalSeconds =
subscription?.current_period_end - subscription?.current_period_start
const remainingRatio = remainingSeconds / totalSeconds
// Calculate prorated credit for current plan
const currentPlanMonthlyPrice = currentPlanMeta?.price ?? 0
const proratedCredit = currentPlanMonthlyPrice * remainingRatio
// Calculate new plan cost
const newPlanCost = subscriptionPlanMeta?.priceMonthly ?? 0
const customerBalance = ((subscription?.customer_balance ?? 0) / 100) * -1
// Calculate total charge (new plan - prorated credit)
const totalCharge = Math.max(
0,
newPlanCost - proratedCredit - customerBalance
)
return (
<>
<TableRow>
<TableCell className="py-2 pl-0 flex items-center gap-1">
<span>{subscriptionPlanMeta?.name} Plan</span>
<Badge variant={'brand'} size={'small'} className="ml-1">
New
</Badge>
</TableCell>
<TableCell className="py-2 pr-0 text-right">
{formatCurrency(newPlanCost)}
</TableCell>
</TableRow>
{subscription?.plan?.id !== 'free' && (
<TableRow>
<TableCell className="py-2 pl-0 flex items-center gap-1">
<span>Unused Time on {subscription?.plan?.name} Plan</span>
<InfoTooltip className="max-w-sm">
Your previous plan was charged upfront, so a plan change will
prorate any unused time in credits. If the prorated credits
exceed the new plan charge, the excessive credits are added to
your organization for future use.
</InfoTooltip>
</TableCell>
<TableCell className="py-2 pr-0 text-right">
-{formatCurrency(proratedCredit)}
</TableCell>
</TableRow>
)}
{/* Ignore rare case with negative balance (debt) */}
{customerBalance > 0 && (
<TableRow>
<TableCell className="py-2 pl-0 flex items-center gap-1">
<span>Credits</span>
<InfoTooltip>
Credits will be used first before charging your card.
</InfoTooltip>
</TableCell>
<TableCell className="py-2 pr-0 text-right">
{formatCurrency(customerBalance)}
</TableCell>
</TableRow>
)}
<TableRow className="text-foreground">
<TableCell className="py-2 pl-0 border-t">Charge today</TableCell>
<TableCell className="py-2 pr-0 text-right border-t">
{formatCurrency(totalCharge)}
</TableCell>
</TableRow>
{subscription?.plan?.id !== 'free' && (
<TableRow>
<TableCell className="py-2 pl-0 flex items-center gap-1">
<span>+ current cycle usage</span>
<InfoTooltip className="max-w-sm">
Changing your plan resets your billing cycle. If your previous
billing cycle still has any outstanding usage charges, they will
be added to your total charge today.
</InfoTooltip>
</TableCell>
<TableCell className="py-2 pr-0 text-right">
<Link
href={`/org/${slug}/billing#breakdown`}
className="text-sm text-brand hover:text-brand-600 transition"
target="_blank"
>
View spend
</Link>
</TableCell>
</TableRow>
)}
</>
)
})()}
</TableBody>
</Table>
<HoverCard>
<HoverCardTrigger asChild>
<Card className="cursor-help text-sm">
<CardContent className="flex items-center gap-2 py-2 px-3 text-foreground-light hover:text-foreground">
<InfoIcon strokeWidth={1.5} size={16} className="text-foreground-light" />
Monthly invoice estimate is{' '}
{formatCurrency(
Math.round(
subscriptionPreview?.breakdown.reduce(
(prev: number, cur) => prev + cur.total_price,
0
) ?? 0
)
)}
</CardContent>
</Card>
</HoverCardTrigger>
<HoverCardContent className="w-[520px] p-6">
<h3 className="text-md font-medium mb-2">Your new monthly invoice</h3>
{subscriptionPreviewError && (
<AlertError
error={subscriptionPreviewError}
subject="Failed to preview subscription."
/>
)}
{subscriptionPreviewIsLoading && (
<div className="space-y-2 p-6">
<span className="text-sm">Estimating monthly costs...</span>
<ShimmeringLoader />
<ShimmeringLoader className="w-3/4" />
<ShimmeringLoader className="w-1/2" />
</div>
)}
{subscriptionPreviewInitialized && (
<>
<Table className="[&_tr:last-child]:border-t font-mono text-xs">
<TableBody>
{/* Non-compute items and Projects list */}
{(() => {
// Combine all compute-related projects
const computeItems =
subscriptionPreview?.breakdown?.filter(
(item) =>
item.description?.toLowerCase().includes('compute') &&
item.breakdown?.length > 0
) || []
const computeCreditsItem =
subscriptionPreview?.breakdown?.find((item) =>
item.description.startsWith('Compute Credits')
) ?? null
const planItem = subscriptionPreview?.breakdown?.find((item) =>
item.description?.toLowerCase().includes('plan')
)
const allProjects = computeItems.flatMap((item) =>
item.breakdown.map((project) => ({
...project,
computeType: item.description,
computeCosts: Math.round(
item.total_price / item.breakdown.length
),
}))
)
const otherItems =
subscriptionPreview?.breakdown?.filter(
(item) =>
!item.description?.toLowerCase().includes('compute') &&
!item.description?.toLowerCase().includes('plan')
) || []
const content = (
<>
{/* Combined projects section */}
{allProjects.length > 0 && (
<>
<TableRow className="text-foreground-light">
<TableCell className="!py-2 px-0">
{planItem?.description}
</TableCell>
<TableCell className="text-right py-2 px-0">
{formatCurrency(planItem?.total_price)}
</TableCell>
</TableRow>
<TableRow className="text-foreground-light">
<TableCell className="!py-2 px-0 flex items-center gap-1">
<span>Compute</span>
<InfoTooltip className="max-w-sm p-3">
<p className="prose text-xs mb-2">
Each project on a paid plan is a dedicated server
running 24/7 with no pausing. The first project is
covered by Compute Credits. Additional projects will
incur compute costs starting at $10/month,
independent of activity. See{' '}
<Link
href={
'/docs/guides/platform/manage-your-usage/compute'
}
target="_blank"
>
docs
</Link>
.
</p>
{subscription?.plan?.id === 'free' && (
<>
<p className="text-xs text-foreground-light mb-3">
Mixing paid and non-paid projects in a single
organization is not possible. If you want
projects to be on the Free Plan, use self-serve
project transfers.
</p>
<div className="space-x-3">
<Button
asChild
type="default"
icon={<ExternalLink strokeWidth={1.5} />}
>
<Link
href="/docs/guides/platform/manage-your-usage/compute"
target="_blank"
rel="noreferrer"
>
How billing for Compute works
</Link>
</Button>
<Button
asChild
type="default"
icon={<ExternalLink strokeWidth={1.5} />}
>
<Link
href="/docs/guides/platform/project-transfer"
target="_blank"
rel="noreferrer"
>
Project transfers
</Link>
</Button>
</div>
</>
)}
</InfoTooltip>
</TableCell>
<TableCell className="text-right py-2 px-0">
{formatCurrency(
computeItems.reduce(
(sum: number, item) => sum + item.total_price,
0
) + (computeCreditsItem?.total_price ?? 0)
)}
</TableCell>
</TableRow>
{/* Show first 3 projects */}
{allProjects.map((project) => (
<TableRow
key={project.project_ref}
className="text-foreground-light"
>
<TableCell className="!py-2 px-0 pl-6">
{project.project_name} ({project.computeType}) |{' '}
{formatCurrency(project.computeCosts)}
</TableCell>
</TableRow>
))}
{computeCreditsItem && (
<TableRow className="text-foreground-light">
<TableCell className="!py-2 px-0 pl-6">
Compute Credits |{' '}
{formatCurrency(computeCreditsItem.total_price)}
</TableCell>
</TableRow>
)}
</>
)}
{/* Non-compute items */}
{otherItems.map((item) => (
<TableRow
key={item.description}
className="text-foreground-light"
>
<TableCell className="text-xs py-2 px-0">
<div className="flex items-center gap-1 flex items-center gap-1">
<span>{item.description ?? 'Unknown'}</span>
{item.breakdown.length > 0 && (
<InfoTooltip className="max-w-sm">
<p>Projects using {item.description}:</p>
<ul className="ml-6 list-disc">
{item.breakdown.map((breakdown) => (
<li
key={`${item.description}-breakdown-${breakdown.project_ref}`}
>
{breakdown.project_name}
</li>
))}
</ul>
</InfoTooltip>
)}
</div>
</TableCell>
<TableCell className="text-right text-xs py-2 px-0">
{formatCurrency(item.total_price)}
</TableCell>
</TableRow>
))}
</>
)
return content
})()}
<TableRow>
<TableCell className="font-medium py-2 px-0">
Total per month (excluding other usage)
</TableCell>
<TableCell className="text-right font-medium py-2 px-0">
{formatCurrency(
Math.round(
subscriptionPreview?.breakdown?.reduce(
(prev, cur) => prev + cur.total_price,
0
) ?? 0
) ?? 0
)}
</TableCell>
</TableRow>
</TableBody>
</Table>
</>
)}
</HoverCardContent>
</HoverCard>
</>
)}
</div>
<div className="mt-4 pt-4">
{!billingViaPartner ? (
<div className="space-y-4">
<PaymentMethodSelection
selectedPaymentMethod={selectedPaymentMethod}
onSelectPaymentMethod={setSelectedPaymentMethod}
/>
</div>
) : (
<div className="mb-4">
<p className="text-sm">
This organization is billed through our partner{' '}
{billingPartnerLabel(billingPartner)}.{' '}
{billingPartner === 'aws' ? (
<>The organization's credit balance will be decreased accordingly.</>
) : (
<>You will be charged by them directly.</>
)}
</p>
{billingViaPartner &&
billingPartner === 'fly' &&
subscriptionPreview?.plan_change_type === 'downgrade' && (
<p className="text-sm">
Your organization will be downgraded at the end of your current billing
cycle.
</p>
)}
</div>
)}
<div className="flex space-x-4">
<Button type="default" onClick={onClose} className="flex-1">
Cancel
</Button>
<Button
loading={isUpdating}
type="primary"
onClick={onUpdateSubscription}
className="flex-1"
>
Confirm {planMeta?.change_type === 'downgrade' ? 'Downgrade' : 'Upgrade'}
</Button>
</div>
</div>
</div>
{/* Right Column */}
<div className="bg-surface-100 p-8 flex flex-col border-l">
{planMeta?.change_type === 'downgrade'
? featuresToLose.length > 0 && (
<div className="mb-4">
<h3 className="text-sm mb-1">Features you'll lose</h3>
<p className="text-xs text-foreground-light mb-4">
Please review this carefully before downgrading.
</p>
<div className="space-y-2 mb-4 text-foreground-light">
{featuresToLose.map((feature: string | [string, ...any[]]) => (
<div
key={typeof feature === 'string' ? feature : feature[0]}
className="flex items-center gap-2"
>
<div className="w-4">
<InfoIcon className="h-3 w-3 text-amber-900" strokeWidth={3} />
</div>
<p className="text-sm">
{typeof feature === 'string' ? feature : feature[0]}
</p>
</div>
))}
</div>
</div>
)
: topFeatures.length > 0 && (
<div className="mb-4">
<h3 className="text-sm mb-4">Upgrade features</h3>
<div className="space-y-2 mb-4 text-foreground-light">
{topFeatures.map((feature: string | [string, ...any[]]) => (
<div
key={typeof feature === 'string' ? feature : feature[0]}
className="flex items-center gap-2"
>
<div className="w-4">
<Check className="h-3 w-3 text-brand" strokeWidth={3} />
</div>
<div className="text-sm">
<p>{typeof feature === 'string' ? feature : feature[0]}</p>
{Array.isArray(feature) && feature.length > 1 && (
<p className="text-foreground-lighter text-xs">{feature[1]}</p>
)}
</div>
</div>
))}
</div>
</div>
)}
{planMeta?.change_type !== 'downgrade' && (
<div className="border-t pt-6">
<blockquote className="text-sm text-foreground-light italic">
{testimonialTweet.text}
<div className="mt-2 text-foreground">— @{testimonialTweet.handle}</div>
</blockquote>
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
)
}
export default SubscriptionPlanUpdateDialog
@@ -722,10 +722,31 @@ let { data: ${resourceId}, error } = await supabase
key: 'with-filtering',
title: 'With filtering',
bash: `
curl '${endpoint}/rest/v1/${resourceId}?id=eq.1&select=*' \\
curl --get '${endpoint}/rest/v1/${resourceId}' \\
-H "apikey: ${apikey}" \\
-H "Authorization: Bearer ${apikey}" \\
-H "Range: 0-9"
-H "Range: 0-9" \\
-d "select=*" \\
\\
\`# Filters\` \\
-d "column=eq.Equal+to" \\
-d "column=gt.Greater+than" \\
-d "column=lt.Less+than" \\
-d "column=gte.Greater+than+or+equal+to" \\
-d "column=lte.Less+than+or+equal+to" \\
-d "column=like.*CaseSensitive*" \\
-d "column=ilike.*CaseInsensitive*" \\
-d "column=is.null" \\
-d "column=in.(Array,Values)" \\
-d "column=neq.Not+equal+to" \\
\\
\`# Arrays\` \\
-d "array_column=cs.{array,contains}" \\
-d "array_column=cd.{contained,by}" \\
\\
\`# Logical operators\` \\
-d "column=not.like.Negate+filter" \\
-d "or=(some_column.eq.Some+value,other_column.eq.Other+value)"
`,
js: `
let { data: ${resourceId}, error } = await supabase
@@ -747,6 +768,10 @@ let { data: ${resourceId}, error } = await supabase
// Arrays
.contains('array_column', ['array', 'contains'])
.containedBy('array_column', ['contained', 'by'])
// Logical operators
.not('column', 'like', 'Negate filter')
.or('some_column.eq.Some value, other_column.eq.Other value')
`,
},
]
@@ -2,11 +2,13 @@ import type { CloudProvider, Region } from 'shared-data'
import { AWS_REGIONS, FLY_REGIONS } from 'shared-data'
export function getAvailableRegions(cloudProvider: CloudProvider): Region {
if (cloudProvider === 'AWS') {
return AWS_REGIONS
} else if (cloudProvider === 'FLY') {
return FLY_REGIONS
switch (cloudProvider) {
case 'AWS':
case 'AWS_NEW':
return AWS_REGIONS
case 'FLY':
return FLY_REGIONS
default:
throw new Error('Invalid cloud provider')
}
throw new Error('Invalid cloud provider')
}
+71 -7
View File
@@ -16,11 +16,15 @@ import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectConte
import { ProjectIndexPageLink } from 'data/prefetchers/project.$ref'
import { useHideSidebar } from 'hooks/misc/useHideSidebar'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useLints } from 'hooks/misc/useLints'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
import { useFlag } from 'hooks/ui/useFlag'
import { Home } from 'icons'
import { IS_PLATFORM, LOCAL_STORAGE_KEYS } from 'lib/constants'
import { useAppStateSnapshot } from 'state/app-state'
import {
Button,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
@@ -39,6 +43,7 @@ import {
useSidebar,
} from 'ui'
import { useSetCommandMenuOpen } from 'ui-patterns'
import { useIsAPIDocsSidePanelEnabled } from './App/FeaturePreview/FeaturePreviewContext'
import { ThemeDropdown } from './ThemeDropdown'
import { UserDropdown } from './UserDropdown'
@@ -237,10 +242,29 @@ export function SideBarNavLink({
)
}
const ActiveDot = (errorArray: any[], warningArray: any[]) => {
return (
<div
className={cn(
'absolute flex h-2 w-2 left-[18px] group-data-[state=expanded]:left-[20px] top-2 z-10 rounded-full',
errorArray.length > 0
? 'bg-destructive-600'
: warningArray.length > 0
? 'bg-warning-600'
: 'bg-transparent'
)}
/>
)
}
function ProjectLinks() {
const router = useRouter()
const { ref } = useParams()
const { project } = useProjectContext()
const snap = useAppStateSnapshot()
const isNewAPIDocsEnabled = useIsAPIDocsSidePanelEnabled()
const { securityLints, errorLints } = useLints()
const showWarehouse = useFlag('warehouse')
const activeRoute = router.pathname.split('/')[3]
@@ -302,13 +326,53 @@ function ProjectLinks() {
</SidebarGroup>
<Separator className="w-[calc(100%-1rem)] mx-auto" />
<SidebarGroup className="gap-0.5">
{otherRoutes.map((route, i) => (
<SideBarNavLink
key={`other-routes-${i}`}
route={route}
active={activeRoute === route.key}
/>
))}
{otherRoutes.map((route, i) => {
if (route.key === 'api' && isNewAPIDocsEnabled) {
return (
<SideBarNavLink
key={`other-routes-${i}`}
route={{
label: route.label,
icon: route.icon,
key: route.key,
}}
onClick={() => {
snap.setShowProjectApiDocs(true)
}}
/>
)
} else if (route.key === 'advisors') {
return (
<div className="relative" key={route.key}>
{ActiveDot(errorLints, securityLints)}
<SideBarNavLink
key={`other-routes-${i}`}
route={route}
active={activeRoute === route.key}
/>
</div>
)
} else if (route.key === 'logs') {
// TODO: Undo this when warehouse flag is removed
const label = showWarehouse ? 'Logs & Analytics' : route.label
const newRoute = { ...route, label }
return (
<SideBarNavLink
key={`other-routes-${i}`}
route={newRoute}
active={activeRoute === newRoute.key}
/>
)
} else {
return (
<SideBarNavLink
key={`other-routes-${i}`}
route={route}
active={activeRoute === route.key}
/>
)
}
})}
</SidebarGroup>
{/* Settings routes to be added in with project/org nav */}
{/* <SidebarGroup className="gap-0.5">
@@ -12,12 +12,11 @@ import ProjectLayout from '../ProjectLayout/ProjectLayout'
import { generateAuthMenu } from './AuthLayout.utils'
const AuthProductMenu = () => {
const router = useRouter()
const { ref: projectRef = 'default' } = useParams()
const columnLevelPrivileges = useIsColumnLevelPrivilegesEnabled()
useAuthConfigPrefetch({ projectRef })
const router = useRouter()
const page = router.pathname.split('/')[4]
return (
@@ -0,0 +1,113 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useEffect, type PropsWithChildren } from 'react'
import { useParams } from 'common'
import { useIsAPIDocsSidePanelEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
import APIDocsButton from 'components/ui/APIDocsButton'
import { DocsButton } from 'components/ui/DocsButton'
import NoPermission from 'components/ui/NoPermission'
import { useEdgeFunctionQuery } from 'data/edge-functions/edge-function-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { withAuth } from 'hooks/misc/withAuth'
import { useRouter } from 'next/router'
import { toast } from 'sonner'
import ProjectLayout from '../ProjectLayout/ProjectLayout'
import EdgeFunctionsLayout from './EdgeFunctionsLayout'
interface EdgeFunctionDetailsLayoutProps {
title?: string
}
const EdgeFunctionDetailsLayout = ({
title,
children,
}: PropsWithChildren<EdgeFunctionDetailsLayoutProps>) => {
const router = useRouter()
const { functionSlug, ref } = useParams()
const isNewAPIDocsEnabled = useIsAPIDocsSidePanelEnabled()
const canReadFunctions = useCheckPermissions(PermissionAction.FUNCTIONS_READ, '*')
const {
data: selectedFunction,
error,
isError,
} = useEdgeFunctionQuery({ projectRef: ref, slug: functionSlug })
const name = selectedFunction?.name || ''
const breadcrumbItems = [
{
label: 'Edge Functions',
href: `/project/${ref}/functions`,
},
]
const navigationItems = functionSlug
? [
{
label: 'Overview',
href: `/project/${ref}/functions/${functionSlug}`,
},
{
label: 'Invocations',
href: `/project/${ref}/functions/${functionSlug}/invocations`,
},
{
label: 'Logs',
href: `/project/${ref}/functions/${functionSlug}/logs`,
},
{
label: 'Details',
href: `/project/${ref}/functions/${functionSlug}/details`,
},
]
: []
useEffect(() => {
let cancel = false
if (!!functionSlug && isError && error.code === 404 && !cancel) {
toast('Edge function cannot be found in your project')
router.push(`/project/${ref}/functions`)
}
return () => {
cancel = true
}
}, [isError])
if (!canReadFunctions) {
return (
<ProjectLayout title={title || 'Edge Functions'} product="Edge Functions">
<NoPermission isFullPage resourceText="access your project's edge functions" />
</ProjectLayout>
)
}
return (
<EdgeFunctionsLayout>
<PageLayout
isCompact
size="full"
title={functionSlug ? name : 'Edge Functions'}
breadcrumbs={breadcrumbItems}
navigationItems={navigationItems}
primaryActions={
<div className="flex items-center space-x-2">
{isNewAPIDocsEnabled && (
<APIDocsButton
section={
functionSlug !== undefined ? ['edge-functions', functionSlug] : ['edge-functions']
}
/>
)}
<DocsButton href="https://supabase.com/docs/guides/functions" />
</div>
}
>
{children}
</PageLayout>
</EdgeFunctionsLayout>
)
}
export default withAuth(EdgeFunctionDetailsLayout)
@@ -0,0 +1,51 @@
import { useRouter } from 'next/router'
import { PropsWithChildren } from 'react'
import { useParams } from 'common'
import { ProductMenu } from 'components/ui/ProductMenu'
import { withAuth } from 'hooks/misc/withAuth'
import ProjectLayout from '../ProjectLayout/ProjectLayout'
const EdgeFunctionsProductMenu = () => {
const { ref: projectRef = 'default' } = useParams()
const router = useRouter()
const page = router.pathname.split('/')[4]
const menuItems = [
{
title: 'Manage',
items: [
{
name: 'Functions',
key: 'main',
pages: [undefined, '[functionSlug]', 'new'],
url: `/project/${projectRef}/functions`,
items: [],
},
{
name: 'Secrets',
key: 'secrets',
url: `/project/${projectRef}/functions/secrets`,
items: [],
},
],
},
]
return <ProductMenu page={page} menu={menuItems} />
}
const EdgeFunctionsLayout = ({ children }: PropsWithChildren<{}>) => {
return (
<ProjectLayout
title="Edge Functions"
product="Edge Functions"
productMenu={<EdgeFunctionsProductMenu />}
isBlocking={false}
>
{children}
</ProjectLayout>
)
}
export default withAuth(EdgeFunctionsLayout)
@@ -1,168 +0,0 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import Link from 'next/link'
import { useEffect, type PropsWithChildren } from 'react'
import { useParams } from 'common'
import { useIsAPIDocsSidePanelEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import APIDocsButton from 'components/ui/APIDocsButton'
import { DocsButton } from 'components/ui/DocsButton'
import NoPermission from 'components/ui/NoPermission'
import { useEdgeFunctionQuery } from 'data/edge-functions/edge-function-query'
import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { withAuth } from 'hooks/misc/withAuth'
import { Code } from 'lucide-react'
import { useRouter } from 'next/router'
import { toast } from 'sonner'
import { Button, cn } from 'ui'
import FunctionsNav from '../../interfaces/Functions/FunctionsNav'
import ProjectLayout from '../ProjectLayout/ProjectLayout'
interface FunctionsLayoutProps {
title?: string
}
const FunctionsLayout = ({ title, children }: PropsWithChildren<FunctionsLayoutProps>) => {
const router = useRouter()
const { functionSlug, ref } = useParams()
const isNewAPIDocsEnabled = useIsAPIDocsSidePanelEnabled()
const { data: functions, isLoading } = useEdgeFunctionsQuery({ projectRef: ref })
const {
data: selectedFunction,
error,
isError,
} = useEdgeFunctionQuery({ projectRef: ref, slug: functionSlug })
const canReadFunctions = useCheckPermissions(PermissionAction.FUNCTIONS_READ, '*')
const name = selectedFunction?.name || ''
const hasFunctions = (functions ?? []).length > 0
const centered = !hasFunctions
useEffect(() => {
let cancel = false
if (!!functionSlug && isError && error.code === 404 && !cancel) {
toast('Edge function cannot be found in your project')
router.push(`/project/${ref}/functions`)
}
return () => {
cancel = true
}
}, [isError])
if (!canReadFunctions) {
return (
<ProjectLayout title={title || 'Edge Functions'} product="Edge Functions">
<NoPermission isFullPage resourceText="access your project's edge functions" />
</ProjectLayout>
)
}
return (
<ProjectLayout isLoading={isLoading} title={title || 'Edge Functions'} product="Edge Functions">
{centered ? (
<>
<div className="mx-auto max-w-full md:max-w-5xl py-12 md:py-20 px-4 md:px-5">
<div
className="item-center
flex
flex-col
justify-between
gap-y-4
xl:flex-row"
>
<div className="flex items-center gap-3">
<div className="flex items-center space-x-4">
<h1 className="text-2xl text-foreground">Edge Functions</h1>
</div>
</div>
</div>
{children}
</div>
</>
) : (
<div className="flex h-full flex-grow flex-col py-6">
<div
className={cn(
'mx-auto flex w-full flex-col transition-all',
'1xl:px-28 gap-4 px-5 lg:px-16 xl:px-24 2xl:px-32'
)}
>
<div className="item-center flex flex-col justify-between gap-y-4 xl:flex-row">
<div className="flex items-center gap-3 w-full">
<div
className={cn(
'h-6 w-6 rounded border border-brand-600 bg-brand-300',
'flex items-center justify-center text-brand'
)}
>
<Code size={14} strokeWidth={3} />
</div>
<div className="flex items-center justify-between w-full">
<div className="flex items-center space-x-4">
<Link href={`/project/${ref}/functions`}>
<h1 className="cursor-pointer text-2xl text-foreground transition-colors hover:text-foreground-light">
Edge Functions
</h1>
</Link>
{name && (
<div className="flex items-center space-x-4">
<span className="text-foreground-light">
<svg
viewBox="0 0 24 24"
width="24"
height="24"
stroke="currentColor"
strokeWidth="1"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
shapeRendering="geometricPrecision"
>
<path d="M16 3.549L7.12 20.600"></path>
</svg>
</span>
<h1 className="text-2xl text-foreground">{name}</h1>
</div>
)}
</div>
<div className="flex items-center space-x-2">
<Button asChild type="default">
<Link href={`/project/${ref}/settings/functions`}>Manage secrets</Link>
</Button>
{isNewAPIDocsEnabled && (
<APIDocsButton
section={
functionSlug !== undefined
? ['edge-functions', functionSlug]
: ['edge-functions']
}
/>
)}
<DocsButton href="https://supabase.com/docs/guides/functions" />
</div>
</div>
</div>
</div>
{functionSlug !== undefined && <FunctionsNav item={selectedFunction} />}
</div>
<div
className={[
'mx-auto flex h-full w-full flex-grow flex-col transition-all',
'1xl:px-28 gap-4 px-5 lg:px-16 xl:px-24 2xl:px-32',
].join(' ')}
>
{children}
</div>
</div>
)}
</ProjectLayout>
)
}
export default withAuth(FunctionsLayout)
@@ -1,6 +1,8 @@
import { useParams } from 'common'
import { ChevronLeft } from 'lucide-react'
import { Fragment, ReactNode } from 'react'
import { useParams } from 'common'
import Link from 'next/link'
import { cn } from 'ui'
import {
Breadcrumb,
@@ -17,13 +19,15 @@ interface PageHeaderProps {
subtitle?: string
icon?: ReactNode
breadcrumbs?: Array<{
label: string
label?: string
href?: string
element?: ReactNode
}>
primaryActions?: ReactNode
secondaryActions?: ReactNode
className?: string
isCompact?: boolean
pageMeta?: ReactNode
}
export const PageHeader = ({
@@ -35,6 +39,7 @@ export const PageHeader = ({
secondaryActions,
className,
isCompact = false,
pageMeta,
}: PageHeaderProps) => {
const { ref } = useParams()
@@ -45,28 +50,50 @@ export const PageHeader = ({
{(displayBreadcrumbs.length > 0 ||
(isCompact && (title || primaryActions || secondaryActions))) && (
<div className={cn('flex items-center gap-4', isCompact ? 'justify-between' : 'mb-4')}>
{displayBreadcrumbs.length > 0 ? (
<Breadcrumb className={cn('text-foreground-muted', isCompact && 'text-base')}>
<BreadcrumbList className={isCompact ? 'text-base' : 'text-xs'}>
{displayBreadcrumbs.map((item, index) => (
<Fragment key={item.label}>
<BreadcrumbItem>
{item.href ? (
<BreadcrumbLink href={!!ref ? item.href.replace('[ref]', ref) : item.href}>
{item.label}
</BreadcrumbLink>
) : (
<BreadcrumbPageItem>{item.label}</BreadcrumbPageItem>
)}
</BreadcrumbItem>
{index < displayBreadcrumbs.length - 1 && <BreadcrumbSeparator />}
</Fragment>
))}
</BreadcrumbList>
</Breadcrumb>
) : isCompact ? (
title
) : null}
<div className="flex items-center gap-4">
{breadcrumbs.length > 0 ? (
<Breadcrumb className={cn('text-foreground-muted', isCompact && 'text-base')}>
<BreadcrumbList className={isCompact ? 'text-base' : 'text-xs'}>
{breadcrumbs.map((item, index) => (
<Fragment key={item.label || `breadcrumb-${index}`}>
<BreadcrumbItem>
{item.element ? (
item.element
) : item.href ? (
<BreadcrumbLink asChild className="flex items-center gap-2">
<Link href={!!ref ? item.href.replace('[ref]', ref) : item.href}>
{breadcrumbs.length === 1 && !isCompact && (
<ChevronLeft size={16} strokeWidth={1.5} />
)}
{item.label}
</Link>
</BreadcrumbLink>
) : (
<BreadcrumbPageItem className="flex items-center gap-2">
{breadcrumbs.length === 1 && (
<ChevronLeft size={16} strokeWidth={1.5} />
)}
{item.label}
</BreadcrumbPageItem>
)}
</BreadcrumbItem>
{index < breadcrumbs.length - 1 && <BreadcrumbSeparator />}
</Fragment>
))}
{isCompact && title && (
<>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPageItem>{title}</BreadcrumbPageItem>
</BreadcrumbItem>
</>
)}
</BreadcrumbList>
</Breadcrumb>
) : isCompact ? (
title
) : null}
</div>
{isCompact && (
<div className="flex items-center gap-2">
{secondaryActions && (
@@ -20,14 +20,15 @@ interface PageLayoutProps {
subtitle?: string
icon?: ReactNode
breadcrumbs?: Array<{
label: string
label?: string
href?: string
element?: ReactNode
}>
primaryActions?: ReactNode
secondaryActions?: ReactNode
navigationItems?: NavigationItem[]
className?: string
size?: 'default' | 'full'
size?: 'default' | 'full' | 'large' | 'small'
isCompact?: boolean
}
@@ -46,7 +47,7 @@ interface PageLayoutProps {
* @param title - Title rendered in page header
* @param subtitle - Subtitle rendered in page header, below title
* @param icon - Icon rendered in Page header, to the left of title and subtitle
* @param breadcrumbs - Breadcrumbs rendered in page header, above title
* @param breadcrumbs - Breadcrumbs rendered in page header, above title. Can be string labels with hrefs or custom elements
* @param primaryActions - TBD
* @param secondaryActions - TBD
* @param navigationItems - Tab navigation rendered below the page header
@@ -70,8 +71,9 @@ export const PageLayout = ({
const router = useRouter()
return (
<div className="w-full">
<div className="w-full min-h-full flex flex-col items-stretch">
<ScaffoldContainer
size={size}
className={cn(
'w-full mx-auto',
size === 'full' &&
@@ -96,7 +98,7 @@ export const PageLayout = ({
{/* Navigation section */}
{navigationItems.length > 0 && (
<NavMenu className={cn('mt-4', size === 'full' && 'border-none')}>
<NavMenu className={cn(isCompact ? 'mt-2' : 'mt-4', size === 'full' && 'border-none')}>
{navigationItems.map((item) => (
<NavMenuItem key={item.label} active={router.asPath === item.href}>
{item.href ? (
@@ -129,8 +129,9 @@ export const generateSettingsMenu = (
{
name: 'Edge Functions',
key: 'functions',
url: `/project/${ref}/settings/functions`,
url: `/project/${ref}/functions/secrets`,
items: [],
rightIcon: <ArrowUpRight strokeWidth={1} className="h-4 w-4" />,
},
]
: []),
+15 -3
View File
@@ -19,16 +19,28 @@ export const MAX_WIDTH_CLASSES_COLUMN = 'min-w-[420px]'
*/
export const ScaffoldContainer = forwardRef<
HTMLDivElement,
HTMLAttributes<HTMLDivElement> & { bottomPadding?: boolean }
>(({ className, bottomPadding, ...props }, ref) => {
HTMLAttributes<HTMLDivElement> & {
bottomPadding?: boolean
size?: 'small' | 'default' | 'large' | 'full'
}
>(({ className, bottomPadding, size = 'default', ...props }, ref) => {
const { aiAssistantPanel } = useAppStateSnapshot()
const { open } = aiAssistantPanel
const maxWidthClass = {
small: 'max-w-[768px]',
default: 'max-w-[1200px]',
large: 'max-w-[1600px]',
full: 'max-w-none',
}[size]
return (
<div
ref={ref}
{...props}
className={cn(
MAX_WIDTH_CLASSES,
'mx-auto w-full',
maxWidthClass,
PADDING_CLASSES,
bottomPadding && 'pb-16',
open ? 'xl:px-6' : '',
@@ -1,15 +1,15 @@
import { useIsLoggedIn } from 'common'
import { useApiDocsGotoCommands } from 'components/interfaces/ProjectAPIDocs/ProjectAPIDocs.Commands'
import { useAdvisorsGoToCommands } from './AdvisorsLayout/Advisors.Commands'
import { useAuthGotoCommands } from './AuthLayout/Auth.Commands'
import { useDatabaseGotoCommands } from './DatabaseLayout/Database.Commands'
import { useFunctionsGotoCommands } from './FunctionsLayout/Functions.Commands'
import { useFunctionsGotoCommands } from './EdgeFunctionsLayout/EdgeFunctions.Commands'
import { useLogsGotoCommands } from './LogsLayout/Logs.Commands'
import { useProjectSettingsGotoCommands } from './ProjectSettingsLayout/ProjectSettings.Commands'
import { useReportsGotoCommands } from './ReportsLayout/Reports.Commands'
import { useStorageGotoCommands } from './StorageLayout/Storage.Commands'
import { useSqlEditorGotoCommands } from './SQLEditorLayout/SqlEditor.Commands'
import { useStorageGotoCommands } from './StorageLayout/Storage.Commands'
import { useTableEditorGotoCommands } from './TableEditorLayout/TableEditor.Commands'
import { useIsLoggedIn } from 'common'
export function useLayoutNavCommands() {
const isLoggedIn = useIsLoggedIn()
+1 -1
View File
@@ -313,7 +313,7 @@ const AIEditor = ({
) : (
<div className="w-full h-full relative">
<Editor
theme="vs-dark"
theme="supabase"
language={language}
value={currentValue}
options={defaultOptions}
@@ -0,0 +1,265 @@
import { Edit, File, Plus, Trash } from 'lucide-react'
import { useEffect, useState } from 'react'
import AIEditor from 'components/ui/AIEditor'
import {
Button,
ContextMenu_Shadcn_,
ContextMenuContent_Shadcn_,
ContextMenuItem_Shadcn_,
ContextMenuSeparator_Shadcn_,
ContextMenuTrigger_Shadcn_,
flattenTree,
TreeView,
TreeViewItem,
} from 'ui'
interface FileData {
id: number
name: string
content: string
selected?: boolean
}
interface FileExplorerAndEditorProps {
files: FileData[]
onFilesChange: (files: FileData[]) => void
aiEndpoint?: string
aiMetadata?: {
projectRef?: string
connectionString?: string
includeSchemaMetadata?: boolean
}
}
const getLanguageFromFileName = (fileName: string): string => {
const extension = fileName.split('.').pop()?.toLowerCase()
switch (extension) {
case 'ts':
case 'tsx':
return 'typescript'
case 'js':
case 'jsx':
return 'javascript'
case 'json':
return 'json'
case 'html':
return 'html'
case 'css':
return 'css'
case 'md':
return 'markdown'
default:
return 'typescript' // Default to typescript
}
}
const FileExplorerAndEditor = ({
files,
onFilesChange,
aiEndpoint,
aiMetadata,
}: FileExplorerAndEditorProps) => {
const selectedFile = files.find((f) => f.selected) ?? files[0]
const [treeData, setTreeData] = useState({
name: '',
children: files.map((file) => ({
id: file.id.toString(),
name: file.name,
metadata: {
isEditing: false,
originalId: file.id,
},
})),
})
const handleChange = (value: string) => {
const updatedFiles = files.map((file) =>
file.id === selectedFile.id ? { ...file, content: value } : file
)
onFilesChange(updatedFiles)
}
const addNewFile = () => {
const newId = Math.max(...files.map((f) => f.id)) + 1
const updatedFiles = files.map((f) => ({ ...f, selected: false }))
onFilesChange([
...updatedFiles,
{
id: newId,
name: `file${newId}.ts`,
content: '',
selected: true,
},
])
}
const handleFileNameChange = (id: number, newName: string) => {
if (!newName.trim()) return // Don't allow empty names
const updatedFiles = files.map((file) => (file.id === id ? { ...file, name: newName } : file))
onFilesChange(updatedFiles)
}
const handleFileDelete = (id: number) => {
if (files.length <= 1) {
// Don't allow deleting the last file
return
}
const fileToDelete = files.find((f) => f.id === id)
const isSelected = fileToDelete?.selected
const updatedFiles = files.filter((file) => file.id !== id)
// If the deleted file was selected, select another file
if (isSelected && updatedFiles.length > 0) {
updatedFiles[0].selected = true
}
onFilesChange(updatedFiles)
}
const handleFileSelect = (id: number) => {
const updatedFiles = files.map((file) => ({
...file,
selected: file.id === id,
}))
onFilesChange(updatedFiles)
}
const handleStartRename = (id: number) => {
const updatedTreeData = {
name: '',
children: files.map((file) => ({
id: file.id.toString(),
name: file.name,
metadata: {
isEditing: file.id === id,
originalId: file.id,
},
})),
}
// Force re-render of the TreeView with the updated metadata
setTreeData(updatedTreeData)
}
// Update treeData when files change
useEffect(() => {
setTreeData({
name: '',
children: files.map((file) => ({
id: file.id.toString(),
name: file.name,
metadata: {
isEditing: false,
originalId: file.id,
},
})),
})
}, [files])
return (
<div className="flex-1 overflow-hidden flex h-full">
<div className="w-64 border-r bg-surface-200 flex flex-col">
<div className="py-4 px-6 border-b flex items-center justify-between">
<h3 className="text-sm font-normal font-mono uppercase text-lighter tracking-wide">
Files
</h3>
<Button size="tiny" type="default" icon={<Plus size={14} />} onClick={addNewFile}>
Add File
</Button>
</div>
<div className="flex-1 overflow-y-auto">
<TreeView
data={flattenTree(treeData)}
aria-label="files tree"
nodeRenderer={({ element, isBranch, isExpanded, getNodeProps, level }) => {
const nodeProps = getNodeProps()
const originalId =
typeof element.metadata?.originalId === 'number'
? element.metadata.originalId
: null
return (
<ContextMenu_Shadcn_ modal={false}>
<ContextMenuTrigger_Shadcn_ asChild>
<div>
<TreeViewItem
{...nodeProps}
isExpanded={isExpanded}
isBranch={isBranch}
isSelected={files.find((f) => f.id === originalId)?.selected}
level={level}
xPadding={16}
name={element.name}
icon={<File size={14} className="text-foreground-light" />}
isEditing={Boolean(element.metadata?.isEditing)}
onEditSubmit={(value) => {
if (originalId !== null) handleFileNameChange(originalId, value)
}}
onClick={() => {
if (originalId !== null) handleFileSelect(originalId)
}}
/>
</div>
</ContextMenuTrigger_Shadcn_>
<ContextMenuContent_Shadcn_ onCloseAutoFocus={(e) => e.stopPropagation()}>
<ContextMenuItem_Shadcn_
className="gap-x-2"
onSelect={() => {
if (originalId !== null) handleStartRename(originalId)
}}
onFocusCapture={(e) => e.stopPropagation()}
>
<Edit size={14} />
Rename file
</ContextMenuItem_Shadcn_>
{files.length > 1 && (
<>
<ContextMenuSeparator_Shadcn_ />
<ContextMenuItem_Shadcn_
className="gap-x-2"
onSelect={() => {
if (originalId !== null) handleFileDelete(originalId)
}}
onFocusCapture={(e) => e.stopPropagation()}
>
<Trash size={14} />
Delete file
</ContextMenuItem_Shadcn_>
</>
)}
</ContextMenuContent_Shadcn_>
</ContextMenu_Shadcn_>
)
}}
/>
</div>
</div>
<div className="flex-1 min-h-0 relative px-3 bg-surface-200">
<AIEditor
language={getLanguageFromFileName(selectedFile?.name || 'index.ts')}
value={selectedFile?.content}
onChange={handleChange}
aiEndpoint={aiEndpoint}
aiMetadata={aiMetadata}
options={{
tabSize: 2,
fontSize: 12,
minimap: { enabled: false },
wordWrap: 'on',
lineNumbers: 'on',
folding: false,
padding: { top: 20, bottom: 20 },
lineNumbersMinChars: 3,
}}
/>
</div>
</div>
)
}
export default FileExplorerAndEditor
@@ -28,9 +28,7 @@ const ProductMenu = ({ page, menu }: ProductMenuProps) => {
/>
<div>
{group.items.map((item) => {
const isActive = !!item.pages
? item.pages.includes(page ?? '')
: page === item.key
const isActive = !!item.pages ? item.pages.includes(page) : page === item.key
return (
<ProductMenuItem
@@ -2,7 +2,8 @@ import { ReactNode } from 'react'
export interface ProductMenuGroup {
title?: string
key?: string
/** Set to "main" if page is on a '/' route */
key?: string | 'main'
isPreview?: boolean
items: ProductMenuGroupItem[]
}
@@ -17,5 +18,5 @@ export interface ProductMenuGroupItem {
isExternal?: boolean
disabled?: boolean
label?: string
pages?: string[]
pages?: (string | undefined)[]
}
@@ -1,10 +1,10 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { components } from 'api-types'
import { handleError, post } from 'data/fetchers'
import type { ResponseError } from 'types'
import { edgeFunctionsKeys } from './keys'
import { components } from 'api-types'
export type EdgeFunctionsDeployVariables = {
projectRef: string
@@ -61,7 +61,7 @@ export const useEdgeFunctionDeployMutation = ({
{
async onSuccess(data, variables, context) {
const { projectRef } = variables
await Promise.all([queryClient.invalidateQueries(edgeFunctionsKeys.list(projectRef))])
await queryClient.invalidateQueries(edgeFunctionsKeys.list(projectRef))
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
@@ -20,7 +20,7 @@ export const instanceSizeSpecs: Record<
cpu: '2-core',
priceHourly: 0.01344,
priceMonthly: 10,
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id, PROVIDERS.FLY.id],
},
small: {
label: 'Small',
@@ -28,7 +28,7 @@ export const instanceSizeSpecs: Record<
cpu: '2-core',
priceHourly: 0.0206,
priceMonthly: 15,
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id, PROVIDERS.FLY.id],
},
medium: {
label: 'Medium',
@@ -36,7 +36,7 @@ export const instanceSizeSpecs: Record<
cpu: '2-core',
priceHourly: 0.0822,
priceMonthly: 60,
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id, PROVIDERS.FLY.id],
},
large: {
label: 'Large',
@@ -44,7 +44,7 @@ export const instanceSizeSpecs: Record<
cpu: '2-core',
priceHourly: 0.1517,
priceMonthly: 110,
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id, PROVIDERS.FLY.id],
},
xlarge: {
label: 'XL',
@@ -52,7 +52,7 @@ export const instanceSizeSpecs: Record<
cpu: '4-core',
priceHourly: 0.2877,
priceMonthly: 210,
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id, PROVIDERS.FLY.id],
},
'2xlarge': {
label: '2XL',
@@ -60,7 +60,7 @@ export const instanceSizeSpecs: Record<
cpu: '8-core',
priceHourly: 0.562,
priceMonthly: 410,
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id, PROVIDERS.FLY.id],
},
'4xlarge': {
label: '4XL',
@@ -68,7 +68,7 @@ export const instanceSizeSpecs: Record<
cpu: '16-core',
priceHourly: 1.32,
priceMonthly: 960,
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.FLY.id],
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id, PROVIDERS.FLY.id],
},
'8xlarge': {
label: '8XL',
@@ -76,7 +76,7 @@ export const instanceSizeSpecs: Record<
cpu: '32-core',
priceHourly: 2.562,
priceMonthly: 1870,
cloud_providers: [PROVIDERS.AWS.id],
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id],
},
'12xlarge': {
label: '12XL',
@@ -84,7 +84,7 @@ export const instanceSizeSpecs: Record<
cpu: '48-core',
priceHourly: 3.836,
priceMonthly: 2800,
cloud_providers: [PROVIDERS.AWS.id],
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id],
},
'16xlarge': {
label: '16XL',
@@ -92,6 +92,6 @@ export const instanceSizeSpecs: Record<
cpu: '64-core',
priceHourly: 5.12,
priceMonthly: 3730,
cloud_providers: [PROVIDERS.AWS.id],
cloud_providers: [PROVIDERS.AWS.id, PROVIDERS.AWS_NEW.id],
},
}
+25
View File
@@ -0,0 +1,25 @@
import { useParams } from 'common'
import { useProjectLintsQuery } from 'data/lint/lint-query'
/**
* Hook to fetch and filter project lints
*
* Retrieves all lints for the current project and filters them by:
* - Security-related lints
* - Error-level security lints
*
* @returns {Object} Object containing filtered lint arrays
* @returns {Array} securityLints - All security-related lints
* @returns {Array} errorLints - Security lints with ERROR level
*/
export const useLints = () => {
const { ref } = useParams()
const { data } = useProjectLintsQuery({
projectRef: ref,
})
const securityLints = (data ?? []).filter((lint) => lint.categories.includes('SECURITY'))
const errorLints = securityLints.filter((lint) => lint.level === 'ERROR')
return { securityLints, errorLints }
}
@@ -39,6 +39,13 @@ export const PROVIDERS = {
default_region: AWS_REGIONS_DEFAULT,
regions: { ...AWS_REGIONS },
},
AWS_NEW: {
id: 'AWS_NEW',
name: 'AWS (Revamped)',
DEFAULT_SSH_KEY: 'supabase-app-instance',
default_region: AWS_REGIONS_DEFAULT,
regions: { ...AWS_REGIONS },
},
} as const
export const PROJECT_STATUS: {
+1
View File
@@ -10,6 +10,7 @@ const HOSTED_SUPPORTED_API_URLS = [
'/ai/sql/suggest',
'/ai/sql/generate-v2',
'/ai/sql/generate-v3',
'/ai/edge-function/complete',
'/ai/onboarding/design',
'/ai/sql/complete',
'/ai/sql/title',
+6 -1
View File
@@ -460,6 +460,11 @@ const nextConfig = {
source: '/project/:ref/settings/warehouse',
destination: '/project/:ref/settings/general',
},
{
permanent: true,
source: '/project/:ref/settings/functions',
destination: '/project/:ref/functions/secrets',
},
...(process.env.NEXT_PUBLIC_BASE_PATH?.length
? [
{
@@ -489,7 +494,7 @@ const nextConfig = {
key: 'Strict-Transport-Security',
value:
process.env.NEXT_PUBLIC_IS_PLATFORM === 'true' && process.env.VERCEL === '1'
? 'max-age=31536000'
? 'max-age=1200; includeSubDomains'
: '',
},
{
@@ -0,0 +1,292 @@
import { openai } from '@ai-sdk/openai'
import pgMeta from '@supabase/pg-meta'
import { streamText } from 'ai'
import { executeSql } from 'data/sql/execute-sql-query'
import { NextApiRequest, NextApiResponse } from 'next'
import { getTools } from '../sql/tools'
export const maxDuration = 30
const openAiKey = process.env.OPENAI_API_KEY
const pgMetaSchemasList = pgMeta.schemas.list()
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!openAiKey) {
return new Response(
JSON.stringify({
error: 'No OPENAI_API_KEY set. Create this environment variable to use AI features.',
}),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
)
}
const { method } = req
switch (method) {
case 'POST':
return handlePost(req, res)
default:
return new Response(
JSON.stringify({ data: null, error: { message: `Method ${method} Not Allowed` } }),
{
status: 405,
headers: { 'Content-Type': 'application/json', Allow: 'POST' },
}
)
}
}
async function handlePost(req: NextApiRequest, res: NextApiResponse) {
try {
const { completionMetadata, projectRef, connectionString, includeSchemaMetadata } = req.body
const { textBeforeCursor, textAfterCursor, language, prompt, selection } = completionMetadata
if (!projectRef) {
return res.status(400).json({
error: 'Missing project_ref in request body',
})
}
const authorization = req.headers.authorization
const { result: schemas } = includeSchemaMetadata
? await executeSql(
{
projectRef,
connectionString,
sql: pgMetaSchemasList.sql,
},
undefined,
{
'Content-Type': 'application/json',
...(authorization && { Authorization: authorization }),
}
)
: { result: [] }
const result = await streamText({
model: openai('gpt-4o-mini-2024-07-18'),
maxSteps: 5,
tools: getTools({ projectRef, connectionString, authorization, includeSchemaMetadata }),
system: `
# Writing Supabase Edge Functions
You're an expert in writing TypeScript and Deno JavaScript runtime. Generate **high-quality Supabase Edge Functions** that adhere to the following best practices:
## Guidelines
1. Try to use Web APIs and Deno's core APIs instead of external dependencies (eg: use fetch instead of Axios, use WebSockets API instead of node-ws)
2. Do NOT use bare specifiers when importing dependencies. If you need to use an external dependency, make sure it's prefixed with either \`npm:\` or \`jsr:\`. For example, \`@supabase/supabase-js\` should be written as \`npm:@supabase/supabase-js\`.
3. For external imports, always define a version. For example, \`npm:@express\` should be written as \`npm:express@4.18.2\`.
4. For external dependencies, importing via \`npm:\` and \`jsr:\` is preferred. Minimize the use of imports from @\`deno.land/x\` , \`esm.sh\` and @\`unpkg.com\` . If you have a package from one of those CDNs, you can replace the CDN hostname with \`npm:\` specifier.
5. You can also use Node built-in APIs. You will need to import them using \`node:\` specifier. For example, to import Node process: \`import process from "node:process"\`. Use Node APIs when you find gaps in Deno APIs.
6. Do NOT use \`import { serve } from "https://deno.land/std@0.168.0/http/server.ts"\`. Instead use the built-in \`Deno.serve\`.
7. Following environment variables (ie. secrets) are pre-populated in both local and hosted Supabase environments. Users don't need to manually set them:
* SUPABASE_URL
* SUPABASE_ANON_KEY
* SUPABASE_SERVICE_ROLE_KEY
* SUPABASE_DB_URL
8. To set other environment variables the user can go to project settings then edge functions to set them
9. A single Edge Function can handle multiple routes. It is recommended to use a library like Express or Hono to handle the routes as it's easier for developer to understand and maintain. Each route must be prefixed with \`/function-name\` so they are routed correctly.
10. File write operations are ONLY permitted on \`/tmp\` directory. You can use either Deno or Node File APIs.
11. Use \`EdgeRuntime.waitUntil(promise)\` static method to run long-running tasks in the background without blocking response to a request. Do NOT assume it is available in the request / execution context.
## Example Templates
### Simple Hello World Function
\`\`\`edge
// Setup type definitions for built-in Supabase Runtime APIs
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
interface reqPayload {
name: string;
}
console.info('server started');
Deno.serve(async (req: Request) => {
const { name }: reqPayload = await req.json();
const data = {
message: \`Hello \${name} from foo!\`,
};
return new Response(
JSON.stringify(data),
{ headers: { 'Content-Type': 'application/json', 'Connection': 'keep-alive' }}
);
});
\`\`\`
### Example Function using Node built-in API
\`\`\`edge
// Setup type definitions for built-in Supabase Runtime APIs
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { randomBytes } from "node:crypto";
import { createServer } from "node:http";
import process from "node:process";
const generateRandomString = (length) => {
const buffer = randomBytes(length);
return buffer.toString('hex');
};
const randomString = generateRandomString(10);
console.log(randomString);
const server = createServer((req, res) => {
const message = \`Hello\`;
res.end(message);
});
server.listen(9999);
\`\`\`
### Using npm packages in Functions
\`\`\`edge
// Setup type definitions for built-in Supabase Runtime APIs
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import express from "npm:express@4.18.2";
const app = express();
app.get(/(.*)/, (req, res) => {
res.send("Welcome to Supabase");
});
app.listen(8000);
\`\`\`
### Generate embeddings using built-in @Supabase.ai API
\`\`\`edge
// Setup type definitions for built-in Supabase Runtime APIs
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
const model = new Supabase.ai.Session('gte-small');
Deno.serve(async (req: Request) => {
const params = new URL(req.url).searchParams;
const input = params.get('text');
const output = await model.run(input, { mean_pool: true, normalize: true });
return new Response(
JSON.stringify(output),
{
headers: {
'Content-Type': 'application/json',
'Connection': 'keep-alive',
},
},
);
});
\`\`\`
## Integrating with Supabase Auth
\`\`\`edge
// Setup type definitions for built-in Supabase Runtime APIs
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from \\'jsr:@supabase/supabase-js@2\\'
import { corsHeaders } from \\'../_shared/cors.ts\\'
console.log(\`Function "select-from-table-with-auth-rls" up and running!\`)
Deno.serve(async (req: Request) => {
// This is needed if you\\'re planning to invoke your function from a browser.
if (req.method === \\'OPTIONS\\') {
return new Response(\\'ok\\', { headers: corsHeaders })
}
try {
// Create a Supabase client with the Auth context of the logged in user.
const supabaseClient = createClient(
// Supabase API URL - env var exported by default.
Deno.env.get('SUPABASE_URL')!,
// Supabase API ANON KEY - env var exported by default.
Deno.env.get('SUPABASE_ANON_KEY')!,
// Create client with Auth context of the user that called the function.
// This way your row-level-security (RLS) policies are applied.
{
global: {
headers: { Authorization: req.headers.get(\\'Authorization\\')! },
},
}
)
// First get the token from the Authorization header
const token = req.headers.get(\\'Authorization\\').replace(\\'Bearer \\', \\'\\')
// Now we can get the session or user object
const {
data: { user },
} = await supabaseClient.auth.getUser(token)
// And we can run queries in the context of our authenticated user
const { data, error } = await supabaseClient.from(\\'users\\').select(\\'*\\')
if (error) throw error
return new Response(JSON.stringify({ user, data }), {
headers: { ...corsHeaders, \\'Content-Type\\': \\'application/json\\' },
status: 200,
})
} catch (error) {
return new Response(JSON.stringify({ error: error.message }), {
headers: { ...corsHeaders, \\'Content-Type\\': \\'application/json\\' },
status: 400,
})
}
})
// To invoke:
// curl -i --location --request POST \\'http://localhost:54321/functions/v1/select-from-table-with-auth-rls\\' \\
// --header \\'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24ifQ.625_WdcF3KHqz5amU0x2X5WWHP-OEs_4qj0ssLNHzTs\\' \\
// --header \\'Content-Type: application/json\\' \\
// --data \\'{"name":"Functions"}\\'
\`\`\`
Database Integration:
- Use the getSchema tool to understand the database structure when needed
- Reference existing tables and schemas to ensure edge functions work with the user's data model
- Use proper types that match the database schema
- When accessing the database:
- Use RLS policies appropriately for security
- Handle database errors gracefully
- Use efficient queries and proper indexing
- Consider rate limiting for resource-intensive operations
- Use connection pooling when appropriate
- Implement proper error handling for database operations
# For all your abilities, follow these instructions:
- First look at the list of provided schemas and if needed, get more information about a schema to understand the data model you're working with
- If the edge function needs to interact with user data, check both the public and auth schemas to understand the authentication setup
Here are the existing database schema names you can retrieve: ${schemas}
`,
messages: [
{
role: 'user',
content: `You are helping me write TypeScript/JavaScript code for an edge function.
Here is the context:
${textBeforeCursor}<selection>${selection}</selection>${textAfterCursor}
Instructions:
1. Only modify the selected text based on this prompt: ${prompt}
2. Your response should be ONLY the modified selection text, nothing else. Remove selected text if needed.
3. Do not wrap in code blocks or markdown
4. You can respond with one word or multiple words
5. Ensure the modified text flows naturally within the current line
6. Avoid duplicating variable declarations, imports, or function definitions when considering the full code
7. If there is no surrounding context (before or after), make sure your response is a complete valid Deno Edge Function including imports.
Modify the selected text now:`,
},
],
})
return result.pipeDataStreamToResponse(res)
} catch (error) {
console.error('Completion error:', error)
return res.status(500).json({
error: 'Failed to generate completion',
})
}
}
+3 -3
View File
@@ -1,9 +1,9 @@
import { streamText } from 'ai'
import { openai } from '@ai-sdk/openai'
import pgMeta from '@supabase/pg-meta'
import { streamText } from 'ai'
import { executeSql } from 'data/sql/execute-sql-query'
import { NextApiRequest, NextApiResponse } from 'next'
import { getTools } from '../sql/tools'
import { executeSql } from 'data/sql/execute-sql-query'
import pgMeta from '@supabase/pg-meta'
export const maxDuration = 30
const openAiKey = process.env.OPENAI_API_KEY
+1 -1
View File
@@ -12,7 +12,7 @@ const pgMetaSchemasList = pgMeta.schemas.list()
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!openAiKey) {
return res.status(400).json({
return res.status(500).json({
error: 'No OPENAI_API_KEY set. Create this environment variable to use AI features.',
})
}
+1 -1
View File
@@ -1,8 +1,8 @@
import { StreamingTextResponse } from 'ai'
import { chatRlsPolicy } from 'ai-commands/edge'
import { DatabasePoliciesData } from 'data/database-policies/database-policies-query'
import { NextRequest } from 'next/server'
import OpenAI from 'openai'
import { DatabasePoliciesData } from 'data/database-policies/database-policies-query'
export const config = {
runtime: 'edge',
@@ -1,20 +1,20 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useState } from 'react'
import { useIsInlineEditorEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { CreateFunction, DeleteFunction } from 'components/interfaces/Database'
import FunctionsList from 'components/interfaces/Database/Functions/FunctionsList/FunctionsList'
import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout'
import DefaultLayout from 'components/layouts/DefaultLayout'
import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
import { FormHeader } from 'components/ui/Forms/FormHeader'
import NoPermission from 'components/ui/NoPermission'
import { DatabaseFunction } from 'data/database-functions/database-functions-query'
import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions'
import type { NextPageWithLayout } from 'types'
import DefaultLayout from 'components/layouts/DefaultLayout'
import { useIsInlineEditorEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useAppStateSnapshot } from 'state/app-state'
import type { NextPageWithLayout } from 'types'
const FunctionsPage: NextPageWithLayout = () => {
const DatabaseFunctionsPage: NextPageWithLayout = () => {
const [selectedFunction, setSelectedFunction] = useState<DatabaseFunction | undefined>()
const [showCreateFunctionForm, setShowCreateFunctionForm] = useState(false)
const [showDeleteFunctionForm, setShowDeleteFunctionForm] = useState(false)
@@ -101,10 +101,10 @@ $$;`,
)
}
FunctionsPage.getLayout = (page) => (
DatabaseFunctionsPage.getLayout = (page) => (
<DefaultLayout>
<DatabaseLayout title="Database">{page}</DatabaseLayout>
</DefaultLayout>
)
export default FunctionsPage
export default DatabaseFunctionsPage
@@ -1,12 +1,17 @@
import { EdgeFunctionDetails } from 'components/interfaces/Functions'
import FunctionsLayout from 'components/layouts/FunctionsLayout/FunctionsLayout'
import { EdgeFunctionDetails } from 'components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails'
import DefaultLayout from 'components/layouts/DefaultLayout'
import EdgeFunctionDetailsLayout from 'components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout'
import type { NextPageWithLayout } from 'types'
const PageLayout: NextPageWithLayout = () => <EdgeFunctionDetails />
const PageLayout: NextPageWithLayout = () => (
<div className="px-6">
<EdgeFunctionDetails />
</div>
)
PageLayout.getLayout = (page) => (
<DefaultLayout>
<FunctionsLayout>{page}</FunctionsLayout>
<EdgeFunctionDetailsLayout>{page}</EdgeFunctionDetailsLayout>
</DefaultLayout>
)
@@ -7,7 +7,8 @@ import { useRouter } from 'next/router'
import { useMemo, useState } from 'react'
import ReportWidget from 'components/interfaces/Reports/ReportWidget'
import FunctionsLayout from 'components/layouts/FunctionsLayout/FunctionsLayout'
import DefaultLayout from 'components/layouts/DefaultLayout'
import EdgeFunctionDetailsLayout from 'components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout'
import AreaChart from 'components/ui/Charts/AreaChart'
import StackedBarChart from 'components/ui/Charts/StackedBarChart'
import NoPermission from 'components/ui/NoPermission'
@@ -30,7 +31,6 @@ import {
Button,
WarningIcon,
} from 'ui'
import DefaultLayout from 'components/layouts/DefaultLayout'
const CHART_INTERVALS: ChartIntervals[] = [
{
@@ -80,7 +80,6 @@ const PageLayout: NextPageWithLayout = () => {
slug: functionSlug,
})
const id = selectedFunction?.id
const reqStatsResult = useFunctionsReqStatsQuery({
projectRef,
functionId: id,
@@ -157,174 +156,183 @@ const PageLayout: NextPageWithLayout = () => {
if (!canReadFunction) {
return <NoPermission isFullPage resourceText="access this edge function" />
}
return (
<div className="space-y-6 py-3">
<div className="flex flex-row items-center gap-2">
<div className="flex items-center">
{CHART_INTERVALS.map((item, i) => {
const classes = []
<div className="px-6 w-full pt-6">
<div className="space-y-6">
<div className="flex flex-row items-center gap-2">
<div className="flex items-center">
{CHART_INTERVALS.map((item, i) => {
const classes = []
if (i === 0) {
classes.push('rounded-tr-none rounded-br-none')
} else if (i === CHART_INTERVALS.length - 1) {
classes.push('rounded-tl-none rounded-bl-none')
} else {
classes.push('rounded-none')
}
if (i === 0) {
classes.push('rounded-tr-none rounded-br-none')
} else if (i === CHART_INTERVALS.length - 1) {
classes.push('rounded-tl-none rounded-bl-none')
} else {
classes.push('rounded-none')
}
return (
<Button
key={`function-filter-${i}`}
type={interval === item.key ? 'secondary' : 'default'}
onClick={() => setInterval(item.key)}
className={classes.join(' ')}
>
{item.label}
</Button>
)
})}
</div>
<span className="text-xs text-foreground-light">
Statistics for past {selectedInterval.label}
</span>
</div>
<div>
<div className="grid grid-cols-1 md:grid-cols-2 md:gap-4 lg:grid-cols-2 lg:gap-8">
<ReportWidget
title="Execution time"
tooltip="Average execution time of function invocations"
data={execTimeChartData}
isLoading={reqStatsResult.isLoading}
renderer={(props) => {
return isErrorExecTime ? (
<Alert_Shadcn_ variant="warning">
<WarningIcon />
<AlertTitle_Shadcn_>Failed to reterieve execution time</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>{execTimeError.message}</AlertDescription_Shadcn_>
</Alert_Shadcn_>
) : (
<AreaChart
className="w-full"
xAxisKey="timestamp"
customDateFormat={selectedInterval.format}
yAxisKey="avg_execution_time"
data={props.data}
format="ms"
highlightedValue={meanBy(props.data, 'avg_execution_time')}
/>
return (
<Button
key={`function-filter-${i}`}
type={interval === item.key ? 'secondary' : 'default'}
onClick={() => setInterval(item.key)}
className={classes.join(' ')}
>
{item.label}
</Button>
)
}}
/>
<ReportWidget
title="Invocations"
data={invocationsChartData}
isLoading={reqStatsResult.isLoading}
renderer={(props) => {
if (isErrorInvocations) {
return (
})}
</div>
<span className="text-xs text-foreground-light">
Statistics for past {selectedInterval.label}
</span>
</div>
<div>
<div className="grid grid-cols-1 md:grid-cols-2 md:gap-4 lg:grid-cols-2 lg:gap-8">
<ReportWidget
title="Execution time"
tooltip="Average execution time of function invocations"
data={execTimeChartData}
isLoading={reqStatsResult.isLoading}
renderer={(props) => {
return isErrorExecTime ? (
<Alert_Shadcn_ variant="warning">
<WarningIcon />
<AlertTitle_Shadcn_>Failed to reterieve invocations</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>{invocationsError.message}</AlertDescription_Shadcn_>
<AlertTitle_Shadcn_>Failed to reterieve execution time</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>{execTimeError.message}</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)
} else {
const data = props.data
.map((d: any) => [
{
status: '2xx',
count: d.success_count,
timestamp: d.timestamp,
},
{
status: '3xx',
count: d.redirect_count,
timestamp: d.timestamp,
},
{
status: '4xx',
count: d.client_err_count,
timestamp: d.timestamp,
},
{
status: '5xx',
count: d.server_err_count,
timestamp: d.timestamp,
},
])
.flat()
return (
<StackedBarChart
) : (
<AreaChart
className="w-full"
xAxisKey="timestamp"
yAxisKey="count"
stackKey="status"
data={data}
highlightedValue={sumBy(data, 'count')}
customDateFormat={selectedInterval.format}
stackColors={['brand', 'slate', 'yellow', 'red']}
onBarClick={() => {
router.push(
`/project/${projectRef}/functions/${functionSlug}/invocations?its=${startDate.toISOString()}`
)
}}
yAxisKey="avg_execution_time"
data={props.data}
format="ms"
highlightedValue={meanBy(props.data, 'avg_execution_time')}
/>
)
}
}}
/>
<ReportWidget
title="CPU time"
tooltip="Average CPU time usage for the function"
data={resourceUsageChartData}
isLoading={resourceUsageResult.isLoading}
renderer={(props) => {
return isErrorResourceUsage ? (
<Alert_Shadcn_ variant="warning">
<WarningIcon />
<AlertTitle_Shadcn_>Failed to retrieve CPU time</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>{resourceUsageError.message}</AlertDescription_Shadcn_>
</Alert_Shadcn_>
) : (
<AreaChart
className="w-full"
xAxisKey="timestamp"
customDateFormat={selectedInterval.format}
yAxisKey="avg_cpu_time_used"
data={props.data}
format="ms"
highlightedValue={meanBy(props.data, 'avg_cpu_time_used')}
/>
)
}}
/>
<ReportWidget
title="Memory"
tooltip="Average memory usage for the function"
data={resourceUsageChartData}
isLoading={resourceUsageResult.isLoading}
renderer={(props) => {
return isErrorResourceUsage ? (
<Alert_Shadcn_ variant="warning">
<WarningIcon />
<AlertTitle_Shadcn_>Failed to retrieve memory usage</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>{resourceUsageError.message}</AlertDescription_Shadcn_>
</Alert_Shadcn_>
) : (
<AreaChart
className="w-full"
xAxisKey="timestamp"
customDateFormat={selectedInterval.format}
yAxisKey="avg_memory_used"
data={props.data}
format="MB"
highlightedValue={meanBy(props.data, 'avg_memory_used')}
/>
)
}}
/>
}}
/>
<ReportWidget
title="Invocations"
data={invocationsChartData}
isLoading={reqStatsResult.isLoading}
renderer={(props) => {
if (isErrorInvocations) {
return (
<Alert_Shadcn_ variant="warning">
<WarningIcon />
<AlertTitle_Shadcn_>Failed to reterieve invocations</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
{invocationsError.message}
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
)
} else {
const data = props.data
.map((d: any) => [
{
status: '2xx',
count: d.success_count,
timestamp: d.timestamp,
},
{
status: '3xx',
count: d.redirect_count,
timestamp: d.timestamp,
},
{
status: '4xx',
count: d.client_err_count,
timestamp: d.timestamp,
},
{
status: '5xx',
count: d.server_err_count,
timestamp: d.timestamp,
},
])
.flat()
return (
<StackedBarChart
className="w-full"
xAxisKey="timestamp"
yAxisKey="count"
stackKey="status"
data={data}
highlightedValue={sumBy(data, 'count')}
customDateFormat={selectedInterval.format}
stackColors={['brand', 'slate', 'yellow', 'red']}
onBarClick={() => {
router.push(
`/project/${projectRef}/functions/${functionSlug}/invocations?its=${startDate.toISOString()}`
)
}}
/>
)
}
}}
/>
<ReportWidget
title="CPU time"
tooltip="Average CPU time usage for the function"
data={resourceUsageChartData}
isLoading={resourceUsageResult.isLoading}
renderer={(props) => {
return isErrorResourceUsage ? (
<Alert_Shadcn_ variant="warning">
<WarningIcon />
<AlertTitle_Shadcn_>Failed to retrieve CPU time</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
{resourceUsageError.message}
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
) : (
<AreaChart
className="w-full"
xAxisKey="timestamp"
customDateFormat={selectedInterval.format}
yAxisKey="avg_cpu_time_used"
data={props.data}
format="ms"
highlightedValue={meanBy(props.data, 'avg_cpu_time_used')}
/>
)
}}
/>
<ReportWidget
title="Memory"
tooltip="Average memory usage for the function"
data={resourceUsageChartData}
isLoading={resourceUsageResult.isLoading}
renderer={(props) => {
return isErrorResourceUsage ? (
<Alert_Shadcn_ variant="warning">
<WarningIcon />
<AlertTitle_Shadcn_>Failed to retrieve memory usage</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
{resourceUsageError.message}
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
) : (
<AreaChart
className="w-full"
xAxisKey="timestamp"
customDateFormat={selectedInterval.format}
yAxisKey="avg_memory_used"
data={props.data}
format="MB"
highlightedValue={meanBy(props.data, 'avg_memory_used')}
/>
)
}}
/>
</div>
</div>
</div>
</div>
@@ -333,7 +341,7 @@ const PageLayout: NextPageWithLayout = () => {
PageLayout.getLayout = (page) => (
<DefaultLayout>
<FunctionsLayout>{page}</FunctionsLayout>
<EdgeFunctionDetailsLayout>{page}</EdgeFunctionDetailsLayout>
</DefaultLayout>
)
@@ -1,14 +1,10 @@
import { useParams } from 'common'
import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer'
import FunctionsLayout from 'components/layouts/FunctionsLayout/FunctionsLayout'
import DefaultLayout from 'components/layouts/DefaultLayout'
import EdgeFunctionDetailsLayout from 'components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout'
import { useEdgeFunctionQuery } from 'data/edge-functions/edge-function-query'
import type { NextPageWithLayout } from 'types'
/**
* Placeholder page for logs previewers until we figure out where to slot them
*/
export const LogPage: NextPageWithLayout = () => {
const { ref, functionSlug } = useParams()
const { data: selectedFunction, isLoading } = useEdgeFunctionQuery({
@@ -19,19 +15,20 @@ export const LogPage: NextPageWithLayout = () => {
if (selectedFunction === undefined || isLoading) return null
return (
<LogsPreviewer
condensedLayout
projectRef={ref as string}
queryType={'fn_edge'}
filterOverride={{ function_id: selectedFunction.id }}
filterPanelClassName="px-0"
/>
<div className="flex-1">
<LogsPreviewer
condensedLayout
projectRef={ref as string}
queryType="fn_edge"
filterOverride={{ function_id: selectedFunction.id }}
/>
</div>
)
}
LogPage.getLayout = (page) => (
<DefaultLayout>
<FunctionsLayout>{page}</FunctionsLayout>
<EdgeFunctionDetailsLayout>{page}</EdgeFunctionDetailsLayout>
</DefaultLayout>
)
@@ -1,7 +1,7 @@
import { useParams } from 'common/hooks'
import { useParams } from 'common'
import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer'
import FunctionsLayout from 'components/layouts/FunctionsLayout/FunctionsLayout'
import DefaultLayout from 'components/layouts/DefaultLayout'
import EdgeFunctionDetailsLayout from 'components/layouts/EdgeFunctionsLayout/EdgeFunctionDetailsLayout'
import { useEdgeFunctionQuery } from 'data/edge-functions/edge-function-query'
import type { NextPageWithLayout } from 'types'
@@ -16,19 +16,20 @@ export const LogPage: NextPageWithLayout = () => {
if (selectedFunction === undefined || isLoading) return null
return (
<LogsPreviewer
condensedLayout
projectRef={ref as string}
queryType={'functions'}
filterOverride={{ 'metadata.function_id': selectedFunction.id }}
filterPanelClassName="px-0"
/>
<div className="flex-1">
<LogsPreviewer
condensedLayout
projectRef={ref as string}
queryType="functions"
filterOverride={{ 'metadata.function_id': selectedFunction.id }}
/>
</div>
)
}
LogPage.getLayout = (page) => (
<DefaultLayout>
<FunctionsLayout>{page}</FunctionsLayout>
<EdgeFunctionDetailsLayout>{page}</EdgeFunctionDetailsLayout>
</DefaultLayout>
)
@@ -1,22 +1,40 @@
import { useParams } from 'common'
import { Button, Dialog, DialogContent, DialogSection, DialogTrigger } from 'ui'
import { ChevronDown, Code, ExternalLink, Terminal } from 'lucide-react'
import { useRouter } from 'next/router'
import {
EdgeFunctionsListItem,
FunctionsEmptyState,
TerminalInstructions,
} from 'components/interfaces/Functions'
import FunctionsLayout from 'components/layouts/FunctionsLayout/FunctionsLayout'
import { useParams } from 'common'
import { EdgeFunctionsListItem } from 'components/interfaces/Functions/EdgeFunctionsListItem'
import { FunctionsEmptyState } from 'components/interfaces/Functions/FunctionsEmptyState'
import { TerminalInstructions } from 'components/interfaces/Functions/TerminalInstructions'
import DefaultLayout from 'components/layouts/DefaultLayout'
import EdgeFunctionsLayout from 'components/layouts/EdgeFunctionsLayout/EdgeFunctionsLayout'
import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
import Table from 'components/to-be-cleaned/Table'
import AlertError from 'components/ui/AlertError'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { DocsButton } from 'components/ui/DocsButton'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query'
import { useFlag } from 'hooks/ui/useFlag'
import { useAppStateSnapshot } from 'state/app-state'
import type { NextPageWithLayout } from 'types'
import {
AiIconAnimation,
Button,
Dialog,
DialogContent,
DialogSection,
DialogTrigger,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from 'ui'
const PageLayout: NextPageWithLayout = () => {
const EdgeFunctionsPage: NextPageWithLayout = () => {
const { ref } = useParams()
const { setAiAssistantPanel } = useAppStateSnapshot()
const router = useRouter()
const {
data: functions,
error,
@@ -24,35 +42,111 @@ const PageLayout: NextPageWithLayout = () => {
isError,
isSuccess,
} = useEdgeFunctionsQuery({ projectRef: ref })
const edgeFunctionCreate = useFlag('edgeFunctionCreate')
const hasFunctions = (functions ?? []).length > 0
const deployButton = (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="primary" iconRight={<ChevronDown className="w-4 h-4" strokeWidth={1.5} />}>
Deploy a new function
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80">
<Dialog>
<DialogTrigger asChild>
<DropdownMenuItem className="gap-4" onSelect={(e) => e.preventDefault()}>
<Terminal className="shrink-0" size={16} strokeWidth={1.5} />
<div>
<span className="text-foreground">Via CLI</span>
<p>
Create an edge function locally and then deploy your function via the Supabase CLI
</p>
</div>
</DropdownMenuItem>
</DialogTrigger>
<DialogContent size="large">
<DialogSection padding="small">
<TerminalInstructions />
</DialogSection>
</DialogContent>
</Dialog>
{edgeFunctionCreate && (
<DropdownMenuItem
onSelect={() => router.push(`/project/${ref}/functions/new`)}
className="gap-4"
>
<Code className="shrink-0" size={16} strokeWidth={1.5} />
<div>
<span className="text-foreground">Via Editor</span>
<p>
Create an edge function in the Supabase Studio editor and then deploy your function
</p>
</div>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)
const secondaryActions = [
<DocsButton key="docs" href="https://supabase.com/docs/guides/functions" />,
<Button asChild key="edge-function-examples" type="default" icon={<ExternalLink />}>
<a
target="_blank"
rel="noreferrer"
href="https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions"
>
Examples
</a>
</Button>,
<ButtonTooltip
key="edge-function-create"
type="default"
className="px-1 pointer-events-auto"
icon={<AiIconAnimation size={16} />}
onClick={() =>
setAiAssistantPanel({
open: true,
initialInput: `Create a new edge function that ...`,
suggestions: {
title:
'I can help you create a new edge function. Here are a few example prompts to get you started:',
prompts: [
'Create a new edge function that processes payments with Stripe',
'Create a new edge function that sends emails with Resend',
'Create a new edge function that generates PDFs from HTML templates',
],
},
})
}
tooltip={{
content: {
side: 'bottom',
text: 'Create with Supabase Assistant',
},
}}
/>,
]
return (
<>
<div className="py-6">
{isLoading && <GenericSkeletonLoader />}
<PageLayout
size="large"
title="Edge Functions"
subtitle="Deploy edge functions to handle complex business logic"
primaryActions={deployButton}
secondaryActions={secondaryActions}
>
<ScaffoldContainer size="large">
<ScaffoldSection isFullWidth>
{isLoading && <GenericSkeletonLoader />}
{isError && <AlertError error={error} subject="Failed to retrieve edge functions" />}
{isError && <AlertError error={error} subject="Failed to retrieve edge functions" />}
{isSuccess && (
<>
{hasFunctions ? (
<div className="py-6 space-y-4">
<div className="flex justify-between items-center">
<span className="text-sm text-foreground-lighter">{`${functions.length} function${
functions.length > 1 ? 's' : ''
} deployed`}</span>
<Dialog>
<DialogTrigger asChild>
<Button type="primary">Deploy a new function</Button>
</DialogTrigger>
<DialogContent size={'large'}>
<DialogSection padding="small">
<TerminalInstructions />
</DialogSection>
</DialogContent>
</Dialog>
</div>
{isSuccess && (
<>
{hasFunctions ? (
<Table
head={
<>
@@ -72,21 +166,23 @@ const PageLayout: NextPageWithLayout = () => {
</>
}
/>
</div>
) : (
<FunctionsEmptyState />
)}
</>
)}
</div>
</>
) : (
<FunctionsEmptyState />
)}
</>
)}
</ScaffoldSection>
</ScaffoldContainer>
</PageLayout>
)
}
PageLayout.getLayout = (page) => (
<DefaultLayout>
<FunctionsLayout>{page}</FunctionsLayout>
</DefaultLayout>
)
EdgeFunctionsPage.getLayout = (page) => {
return (
<DefaultLayout>
<EdgeFunctionsLayout>{page}</EdgeFunctionsLayout>
</DefaultLayout>
)
}
export default PageLayout
export default EdgeFunctionsPage
@@ -0,0 +1,383 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { AlertCircle, Book, Check } from 'lucide-react'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import * as z from 'zod'
import { useParams } from 'common'
import { EDGE_FUNCTION_TEMPLATES } from 'components/interfaces/Functions/Functions.templates'
import DefaultLayout from 'components/layouts/DefaultLayout'
import EdgeFunctionsLayout from 'components/layouts/EdgeFunctionsLayout/EdgeFunctionsLayout'
import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
import FileExplorerAndEditor from 'components/ui/FileExplorerAndEditor/FileExplorerAndEditor'
import { useEdgeFunctionDeployMutation } from 'data/edge-functions/edge-functions-deploy-mutation'
import { useOrgOptedIntoAi } from 'hooks/misc/useOrgOptedIntoAi'
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
import { useFlag } from 'hooks/ui/useFlag'
import { BASE_PATH, IS_PLATFORM } from 'lib/constants'
import { useAppStateSnapshot } from 'state/app-state'
import {
AiIconAnimation,
Button,
cn,
Command_Shadcn_,
CommandEmpty_Shadcn_,
CommandGroup_Shadcn_,
CommandInput_Shadcn_,
CommandItem_Shadcn_,
CommandList_Shadcn_,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
FormItem_Shadcn_,
Input_Shadcn_,
Label_Shadcn_,
Popover_Shadcn_,
PopoverContent_Shadcn_,
PopoverTrigger_Shadcn_,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
// Array of adjectives and nouns for random function name generation
const ADJECTIVES = [
'quick',
'clever',
'bright',
'swift',
'rapid',
'smart',
'smooth',
'dynamic',
'super',
'hyper',
]
const NOUNS = [
'function',
'handler',
'processor',
'responder',
'worker',
'service',
'api',
'endpoint',
'action',
'task',
]
// Function name validation regex - only allows alphanumeric characters, hyphens, and underscores
const FUNCTION_NAME_REGEX = /^[A-Za-z0-9_-]+$/
// Define form schema with yup
const FormSchema = z.object({
functionName: z
.string()
.min(1, 'Function name is required')
.regex(FUNCTION_NAME_REGEX, 'Only letters, numbers, hyphens, and underscores allowed'),
})
// Generate a random function name
const generateRandomFunctionName = () => {
const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]
const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)]
return `${adjective}-${noun}`
}
// Convert invalid function name to valid one
const sanitizeFunctionName = (name: string): string => {
// Replace invalid characters with hyphens
return name.replace(/[^A-Za-z0-9_-]/g, '-')
}
// Type for the form values
type FormValues = z.infer<typeof FormSchema>
const NewFunctionPage = () => {
const router = useRouter()
const { ref, template } = useParams()
const project = useSelectedProject()
const isOptedInToAI = useOrgOptedIntoAi()
const includeSchemaMetadata = isOptedInToAI || !IS_PLATFORM
const { setAiAssistantPanel } = useAppStateSnapshot()
const edgeFunctionCreate = useFlag('edgeFunctionCreate')
const [files, setFiles] = useState<
{ id: number; name: string; content: string; selected?: boolean }[]
>([
{
id: 1,
name: 'index.ts',
selected: true,
content: EDGE_FUNCTION_TEMPLATES[0].content,
},
])
const [open, setOpen] = useState(false)
const [isPreviewingTemplate, setIsPreviewingTemplate] = useState(false)
const [savedCode, setSavedCode] = useState<string>('')
const form = useForm<FormValues>({
resolver: zodResolver(FormSchema),
defaultValues: {
functionName: generateRandomFunctionName(),
},
})
const { mutate: deployFunction, isLoading: isDeploying } = useEdgeFunctionDeployMutation({
// [Joshen] To investigate: For some reason, the invalidation for list of edge functions isn't triggering
onSuccess: () => {
toast.success('Successfully deployed edge function')
const functionName = form.getValues('functionName')
if (ref && functionName) {
router.push(`/project/${ref}/functions/${functionName}/details`)
}
},
})
const onSubmit = (values: FormValues) => {
if (isDeploying || !ref) return
deployFunction({
projectRef: ref,
metadata: {
entrypoint_path: 'index.ts',
name: values.functionName,
verify_jwt: true,
},
files: files.map(({ name, content }) => ({ name, content })),
})
}
const handleChat = () => {
const selectedFile = files.find((f) => f.selected) ?? files[0]
setAiAssistantPanel({
open: true,
sqlSnippets: [selectedFile.content],
initialInput: 'Help me understand and improve this edge function...',
suggestions: {
title:
'I can help you understand and improve your edge function. Here are a few example prompts to get you started:',
prompts: [
'Explain what this function does...',
'Help me optimize this function...',
'Show me how to add more features...',
'Help me handle errors better...',
],
},
})
}
const onSelectTemplate = (templateValue: string) => {
const template = EDGE_FUNCTION_TEMPLATES.find((t) => t.value === templateValue)
if (template) {
setFiles((prev) =>
prev.map((file) => (file.selected ? { ...file, content: template.content } : file))
)
setOpen(false)
}
}
const handleTemplateMouseEnter = (content: string) => {
if (!isPreviewingTemplate) {
const selectedFile = files.find((f) => f.selected) ?? files[0]
setSavedCode(selectedFile.content)
}
setIsPreviewingTemplate(true)
setFiles((prev) => prev.map((file) => (file.selected ? { ...file, content } : file)))
}
const handleTemplateMouseLeave = () => {
if (isPreviewingTemplate) {
setIsPreviewingTemplate(false)
setFiles((prev) =>
prev.map((file) => (file.selected ? { ...file, content: savedCode } : file))
)
}
}
// Try to sanitize function name when it's invalid
const handleDeploy = () => {
const currentName = form.getValues('functionName')
const isValid = FUNCTION_NAME_REGEX.test(currentName)
if (!isValid && currentName) {
const sanitizedName = sanitizeFunctionName(currentName)
form.setValue('functionName', sanitizedName, { shouldValidate: true })
}
form.handleSubmit(onSubmit)()
}
// TODO (Saxon): Remove this once the flag is fully launched
useEffect(() => {
if (!edgeFunctionCreate) {
router.push(`/project/${ref}/functions`)
}
}, [edgeFunctionCreate, ref, router])
useEffect(() => {
if (template) {
const templateMeta = EDGE_FUNCTION_TEMPLATES.find((x) => x.value === template)
if (templateMeta) {
form.reset({ functionName: template })
setFiles([
{
id: 1,
name: 'index.ts',
selected: true,
content: templateMeta.content,
},
])
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [template])
return (
<PageLayout
size="full"
isCompact
title="Create new edge function"
breadcrumbs={[
{
label: 'Edge Functions',
href: `/project/${ref}/functions`,
},
]}
primaryActions={
<>
<Popover_Shadcn_ open={open} onOpenChange={setOpen}>
<PopoverTrigger_Shadcn_ asChild>
<Button
size="tiny"
type="default"
role="combobox"
aria-expanded={open}
icon={<Book size={14} />}
>
Templates
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ className="w-[300px] p-0" align="end">
<Command_Shadcn_>
<CommandInput_Shadcn_ placeholder="Search templates..." />
<CommandList_Shadcn_>
<CommandEmpty_Shadcn_>No templates found.</CommandEmpty_Shadcn_>
<CommandGroup_Shadcn_>
{EDGE_FUNCTION_TEMPLATES.map((template) => (
<CommandItem_Shadcn_
key={template.value}
value={template.value}
onSelect={onSelectTemplate}
onMouseEnter={() => handleTemplateMouseEnter(template.content)}
onMouseLeave={handleTemplateMouseLeave}
className="cursor-pointer"
>
<div className="flex flex-col gap-1">
<div className="flex items-center">
<Check
className={cn(
'mr-2 h-4 w-4',
files.some((f) => f.selected && f.content === template.content)
? 'opacity-100'
: 'opacity-0'
)}
/>
<span className="text-foreground">{template.name}</span>
</div>
<span className="text-xs text-foreground-light pl-6">
{template.description}
</span>
</div>
</CommandItem_Shadcn_>
))}
</CommandGroup_Shadcn_>
</CommandList_Shadcn_>
</Command_Shadcn_>
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
<Button
size="tiny"
type="default"
onClick={handleChat}
icon={<AiIconAnimation size={16} />}
>
Chat
</Button>
</>
}
>
<FileExplorerAndEditor
files={files}
onFilesChange={setFiles}
aiEndpoint={`${BASE_PATH}/api/ai/edge-function/complete`}
aiMetadata={{
projectRef: project?.ref,
connectionString: project?.connectionString,
includeSchemaMetadata,
}}
/>
<Form_Shadcn_ {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex items-center bg-background-muted justify-end p-4 border-t bg-surface-100 gap-3"
>
<div className="flex items-center gap-3">
<Label_Shadcn_ htmlFor="functionName">Function name</Label_Shadcn_>
<FormField_Shadcn_
control={form.control}
name="functionName"
render={({ field }) => (
<FormItem_Shadcn_ className="flex flex-col gap-0 m-0">
<div className="flex items-center">
<FormControl_Shadcn_>
<Input_Shadcn_
id="functionName"
type="text"
size={'large'}
placeholder="Give your function a name..."
className="w-[250px]"
{...field}
/>
</FormControl_Shadcn_>
{form.formState.errors.functionName && (
<Tooltip>
<TooltipTrigger>
<AlertCircle className="w-4 h-4 text-destructive ml-2" />
</TooltipTrigger>
<TooltipContent>
{form.formState.errors.functionName.message}
</TooltipContent>
</Tooltip>
)}
</div>
</FormItem_Shadcn_>
)}
/>
</div>
<Button
loading={isDeploying}
size="medium"
disabled={files.length === 0 || isDeploying}
onClick={handleDeploy}
>
Deploy function
</Button>
</form>
</Form_Shadcn_>
</PageLayout>
)
}
NewFunctionPage.getLayout = (page: React.ReactNode) => {
return (
<DefaultLayout>
<EdgeFunctionsLayout>{page}</EdgeFunctionsLayout>
</DefaultLayout>
)
}
export default NewFunctionPage
@@ -0,0 +1,32 @@
import EdgeFunctionSecrets from 'components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecrets'
import DefaultLayout from 'components/layouts/DefaultLayout'
import EdgeFunctionsLayout from 'components/layouts/EdgeFunctionsLayout/EdgeFunctionsLayout'
import { PageLayout } from 'components/layouts/PageLayout/PageLayout'
import { ScaffoldContainer } from 'components/layouts/Scaffold'
import type { NextPageWithLayout } from 'types'
const SecretsPage: NextPageWithLayout = () => {
return (
<ScaffoldContainer size="large" className="pt-6">
<EdgeFunctionSecrets />
</ScaffoldContainer>
)
}
SecretsPage.getLayout = (page) => {
return (
<DefaultLayout>
<EdgeFunctionsLayout>
<PageLayout
size="large"
title="Edge Function Secrets"
subtitle="Manage the secrets for your project's edge functions"
>
{page}
</PageLayout>
</EdgeFunctionsLayout>
</DefaultLayout>
)
}
export default SecretsPage
@@ -1,42 +0,0 @@
import EdgeFunctionSecrets from 'components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecrets'
import DefaultLayout from 'components/layouts/DefaultLayout'
import SettingsLayout from 'components/layouts/ProjectSettingsLayout/SettingsLayout'
import {
ScaffoldContainer,
ScaffoldDescription,
ScaffoldHeader,
ScaffoldTitle,
} from 'components/layouts/Scaffold'
import type { NextPageWithLayout } from 'types'
import { DocsButton } from 'components/ui/DocsButton'
const PageLayout: NextPageWithLayout = () => {
return (
<>
<ScaffoldContainer>
<ScaffoldHeader className="flex flex-row justify-between">
<div>
<ScaffoldTitle>Edge Function Secrets</ScaffoldTitle>
<ScaffoldDescription>
Manage the secrets (environment variables) for your project's Edge Functions
</ScaffoldDescription>
</div>
<div className="flex items-center space-x-2">
<DocsButton href="https://supabase.com/docs/guides/functions/secrets" />
</div>
</ScaffoldHeader>
</ScaffoldContainer>
<ScaffoldContainer bottomPadding>
<EdgeFunctionSecrets />
</ScaffoldContainer>
</>
)
}
PageLayout.getLayout = (page) => (
<DefaultLayout>
<SettingsLayout title="Settings">{page}</SettingsLayout>
</DefaultLayout>
)
export default PageLayout
@@ -0,0 +1,320 @@
---
title: 'Postgres as a Graph Database: (Ab)using pgRouting'
description:
author: paul_copplestone
image: pgrouting-graph/pgrouting-og.png
thumb: pgrouting-graph/pgrouting-thumb.png
categories:
- developers
tags:
- postgres
date: '2025-02-25'
toc_depth: 3
---
[pgRouting](https://github.com/pgRouting/pgrouting) is a Postgres extension. It's often used for finding the “shortest path” between two locations, however it's a hidden gem in Postgres and can be used for basic graph functionality.
![pgrouting on HN](/images/blog/pgrouting-graph/pgrouting-hn.png)
pgRouting is typically combined with PostGIS for working with geospatial data, but it can also be [useful beyond that](https://news.ycombinator.com/item?id=26350454) as a lightweight alternative to Graph extensions like [Apache AGE](https://age.apache.org/), or specialized graph databases like [Neo4j](https://neo4j.com/).
Let's explore some useful applications of pgRouting and graphs.
## What is pgRouting?
pgRouting is an extension of PostGIS that provides geospatial routing functionality. You can use it to calculate the shortest path, perform network analysis, and solve complex routing problems on a graph-based structure. Most commonly, this is used in Geographic Information Systems (GIS) for tasks like determining the fastest route between two locations.
![pgrouting map](/images/blog/pgrouting-graph/pgrouting-map.png)
## Working with Graphs
The power of pgRouting lies in its ability to work with any data structured as a graph. A graph is essentially a network of interconnected points, where:
- **Nodes** represent entities.
- **Edges** represent relationships or paths between those nodes.
In maps / [GIS](https://en.wikipedia.org/wiki/Geographic_information_system), nodes and edges represent intersections and roads respectively. However, this structure can also be applied to abstract systems like a social networks, where users are nodes and friendships are edges.
<Img
alt="create API key on Cal.com"
src={{
dark: '/images/blog/pgrouting-graph/graph-dark.png',
light: '/images/blog/pgrouting-graph/graph-light.png',
}}
/>
## Non-GIS Use Cases for pgRouting
Let's explore how pgRouting can be applied to a few non-[GIS](https://en.wikipedia.org/wiki/Geographic_information_system) problems.
### Task scheduling
In any project, tasks have dependencies. For example, task B can only start after task A is completed. This creates a [directed acyclic graph](https://en.wikipedia.org/wiki/Directed_acyclic_graph) (DAG), where:
- nodes represent tasks
- edges represent dependencies
One of the most challenging aspects of managing projects is determining the “critical path” — the project's overall duration, determined by the longest sequence of dependencies.
<Img
alt="create API key on Cal.com"
src={{
dark: '/images/blog/pgrouting-graph/path-dark.png',
light: '/images/blog/pgrouting-graph/path-light.png',
}}
/>
Using pgRouting, you can model your task's dependencies, using graph algorithms to find the critical path. Suppose we have a table tasks with task dependencies modeled as a graph:
```sql
-- Create the tasks table with dependencies
create table tasks (
id serial primary key,
name text not null
);
-- insert tasks into the table
insert into tasks (name)
values
('Start Project'),
('Task A'),
('Task B'),
('Task C'),
('Task D'),
('End Project');
-- create the dependencies table
create table dependencies (
id serial primary key,
source integer not null, -- task id where the dependency starts
target integer not null, -- task id where the dependency ends
duration integer not null, -- duration of the task in days
constraint fk_source foreign key (source) references tasks (id),
constraint fk_target foreign key (target) references tasks (id)
);
-- insert dependencies with durations (directed edges)
insert into dependencies (source, target, duration)
values
(1, 2, 3), -- start project -> task a (3 days)
(2, 3, 4), -- task a -> task b (4 days)
(3, 4, 5), -- task b -> task c (5 days)
(4, 5, 2), -- task c -> task d (2 days)
(5, 6, 6);
-- task d -> end project (6 days)
```
You can then use the [`pgr_dijkstra()`](https://docs.pgrouting.org/latest/en/pgr_dijkstra.html) function to find the shortest (or longest) path through the tasks, allowing you to map out the project schedule effectively:
```sql
create schema if not exists extensions;
create extension pgrouting schema extensions cascade;
-- find the longest path using pgr_dijkstra()
-- (as it calculates shortest path, use negative weights)
select * FROM extensions.pgr_dijkstra(
'select id, source, target, duration as cost from dependencies',
1, -- Start Project (Task ID 1)
6 -- End Project (Task ID 6)
);
```
Which returns a table showing that this project will take 20 days from start to finish:
| **seq** | **path_seq** | **node** | **edge** | **cost** | **agg_cost** |
| ------- | ------------ | -------- | -------- | -------- | ------------ |
| 1 | 1 | 1 | 1 | 3 | 0 |
| 2 | 2 | 2 | 2 | 4 | 3 |
| 3 | 3 | 3 | 3 | 5 | 7 |
| 4 | 4 | 4 | 4 | 2 | 12 |
| 5 | 5 | 5 | 5 | 6 | 14 |
| 6 | 6 | 6 | -1 | 0 | 20 |
<details>
<summary>Tangent: the Dijkstra algorithm</summary>
<div>
The `pgr_dijkstra()` function implements [Dijkstra's
algorithm](https://en.wikipedia.org/wiki/Dijkstra's_algorithm), which is used to find the
shortest path between nodes in a graph. This algorithm guarantees the shortest path from a
source node to a target node (or all other nodes), based on the cost of edges connecting the
nodes.
</div>
<div>
Fun fact: Dijkstra's algorithm was published in 1959 by Dutch computer scientist Edsger
Dijkstra. It's a “greedy” algorithm, meaning it always picks the closest, cheapest node to
explore next.
</div>
</details>
### Reverse proxy routing based on resource allocation
Distributed systems usually involve allocating resources efficiently across a network of nodes. Each node might represent a physical location or a computing process, and the edges represent the available pathways to move resources between them. For example, in a cloud infrastructure, pgRouting could help determine how to allocate compute tasks across a set of distributed servers by finding the shortest or least-congested path to route data.
Suppose you have a network of servers represented by nodes and their data connections as edges in a table servers.
```sql
-- create the servers table representing the nodes
create table servers (
id serial primary key,
name text,
x double precision, -- x coordinate for spatial data (latitude)
y double precision -- y coordinate for spatial data (longitude)
);
-- insert some sample servers
insert into servers (name, x, y)
values
('server a', 0, 0),
('server b', 2, 1),
('server c', 4, 3),
('server d', 3, 5);
-- create the server_connections table representing the edges
create table server_latency (
id serial primary key,
source integer,
target integer,
cost double precision, -- cost could represent latency or bandwidth
x1 double precision, -- x coordinate of source
y1 double precision, -- y coordinate of source
x2 double precision, -- x coordinate of target
y2 double precision, -- y coordinate of target,
constraint fk_source foreign key (source) references servers (id),
constraint fk_target foreign key (target) references servers (id)
);
-- insert connections between servers
insert into server_latency (source, target, cost, x1, y1, x2, y2)
values
(1, 2, 1.5, 0, 0, 2, 1), -- server a -> server b with a cost of 1.5 (could be latency)
(2, 3, 2.0, 2, 1, 4, 3), -- server b -> server c with a cost of 2.0
(2, 4, 1.8, 2, 1, 3, 5), -- server b -> server d with a cost of 1.8
(4, 3, 1.0, 3, 5, 4, 3);
-- server d -> server c with a cost of 1.0
```
You can then use [`pgr_astar`](https://docs.pgrouting.org/latest/en/pgr_aStar.html)() to find the most efficient path for data or compute tasks to travel through this network, optimizing for speed or load:
```sql
-- Query to find the most efficient path (using pgr_astar)
select *
from
extensions.pgr_astar(
'select id, source, target, cost, x1, y1, x2, y2 from server_latency',
1,
3 -- Start from Server A (id=1) to Server C (id=3)
);
```
<details>
<summary>Tangent: the A\* algorithm</summary>
<div>
The `pgr_astar()` function is an implementation of the [A\* (A-star)
algorithm](https://en.wikipedia.org/wiki/A*_search_algorithm). It's used to find the most
efficient (shortest) path between two points in a graph. A\* is commonly used in navigation and
routing because it is more efficient than Dijkstra's algorithm in many scenarios, especially
when you have spatial data with coordinates (e.g., X, Y positions).
</div>
<div>
Fun fact: A\* was originally designed in the 1960s for artificial intelligence applications and
pathfinding in games. Today, it's one of the most widely used algorithms in video game
development to help characters navigate complex environments efficiently.
</div>
</details>
### Recommendation engines like YouTube
In recommendation engines or search algorithms that use knowledge graphs, pgRouting can be used to build relationships between entities and events. Take YouTube's recommendation algorithm, we can structure this data as a graph where:
- **Nodes** represent entities like users, videos, or categories.
- **Edges** represent relationships or interactions between those entities, such as a user liking a video or videos being part of the same category.
Let's create a list of “nodes”:
```sql
create table categories (
id serial primary key,
name text
);
insert into categories (name)
values
('Graph Theory'),
('AI & Machine Learning'),
('Python Programming');
create table videos (
id serial primary key,
title text,
category_id int references categories (id)
);
insert into videos (title, category_id)
values
('Intro to Graph Theory', 1),
('Advanced Graph Algorithms', 1),
('Graph Neural Networks', 2),
('Beginner Python Tutorial', 3),
('Advanced Python Techniques', 3);
```
And some “edges”:
```sql
create table video_relationships (
source_video_id int references videos (id),
target_video_id int references videos (id),
relationship_type text, -- 'same_category', 'watched_by_same_users', etc.
weight int default 1 -- strength of the relationship
);
insert into video_relationships (source_video_id, target_video_id, relationship_type, weight)
values
(1, 2, 'same_category', 5), -- "Intro to Graph Theory" and "Advanced Graph Algorithms" are in the same category
(2, 3, 'watched_by_same_users', 3), -- "Advanced Graph Algorithms" and "Graph Neural Networks" are often watched together
(4, 5, 'same_category', 5); -- "Beginner Python Tutorial
create table interactions (
user_id int references auth.users (id),
video_id int references videos (id),
interaction_type text, -- 'liked', 'viewed', etc.
weight int default 1 -- strength of the interaction
);
insert into interactions (user_id, video_id, interaction_type, weight)
values
('user_01', 1, 'viewed', 5), -- "User 01" watched "Intro to Graph Theory" to the end (weight = 5)
('user_01', 2, 'liked', 5), -- "User 01" liked "Advanced Graph Algorithms"
('user_02', 3, 'viewed', 2), -- "User 02" watched "Graph Neural Networks" and bounced halfway through (weight = 2)
('user_03', 4, 'liked', 5), -- "User 03" liked "Beginner Python Tutorial"
('user_03', 5, 'viewed', 2);
-- "User 03" watched "Advanced Python Techniques" and bounced halfway through (weight = 2)
```
Now we can use the `pgr_dijkstra()` function to find the shortest or most relevant path between a user and new videos. For example, let's find videos that are most relevant to `user_01` considering their past interactions:
<details>
<summary>Tangent: ranking recommendations</summary>
<div>
Since it's “just postgres” it's simple enough to rank the results using an `order by` clause. For example, if we stored the `pgr_dijkstra()` results above in a table called “recommendations”, we use this a query like this to sort the paths by the highest ranking:
</div>
<div>
```sql
select videos.title, sum(weight) as recommendation_score
from
recommendations
join videos on recommendations.target = videos.id
group by videos.title
order by recommendation_score desc;
```
</div>
</details>
## Get Started
pgRouting is a powerful extension for Postgres that can be used to solve a wide range of graph-based problems. Check out the [pgRouting docs](https://docs.pgrouting.org/latest/en/index.html) for more information on how to use it. You can also use it on Supabase:
- Docs: [pgrouting: Geospatial Routing](/docs/guides/database/extensions/pgrouting)
- Launch a new Postgres database: [database.new](https://database.new)
+1 -1
View File
@@ -84,7 +84,7 @@ const nextConfig = {
source: "/(.*)",
headers: [{
key: 'Strict-Transport-Security',
value: process.env.NEXT_PUBLIC_IS_PLATFORM === 'true' && process.env.VERCEL === '1' ? 'max-age=31536000' : '' }],
value: process.env.NEXT_PUBLIC_IS_PLATFORM === 'true' && process.env.VERCEL === '1' ? 'max-age=1200; includeSubDomains' : '' }],
},
]
},
Binary file not shown.

After

Width:  |  Height:  |  Size: 473 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 502 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

+8 -1
View File
@@ -5,9 +5,16 @@
<link>https://supabase.com</link>
<description>Latest news from Supabase</description>
<language>en</language>
<lastBuildDate>Fri, 20 Dec 2024 00:00:00 -0700</lastBuildDate>
<lastBuildDate>Tue, 25 Feb 2025 00:00:00 -0700</lastBuildDate>
<atom:link href="https://supabase.com/rss.xml" rel="self" type="application/rss+xml"/>
<item>
<guid>https://supabase.com/blog/pgrouting-postgres-graph-database</guid>
<title>Postgres as a Graph Database: (Ab)using pgRouting</title>
<link>https://supabase.com/blog/pgrouting-postgres-graph-database</link>
<description></description>
<pubDate>Tue, 25 Feb 2025 00:00:00 -0700</pubDate>
</item>
<item>
<guid>https://supabase.com/blog/ai-hackathon-at-y-combinator</guid>
<title>AI Hackathon at Y Combinator</title>
<link>https://supabase.com/blog/ai-hackathon-at-y-combinator</link>
+1 -1
View File
@@ -1,4 +1,4 @@
export type CloudProvider = 'FLY' | 'AWS'
export type CloudProvider = 'FLY' | 'AWS' | 'AWS_NEW'
export type Region = typeof AWS_REGIONS | typeof FLY_REGIONS
export const AWS_REGIONS = {
@@ -90,17 +90,17 @@ const SidebarProvider = React.forwardRef<
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
toggleSidebar()
}
}
// React.useEffect(() => {
// const handleKeyDown = (event: KeyboardEvent) => {
// if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
// event.preventDefault()
// toggleSidebar()
// }
// }
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [toggleSidebar])
// window.addEventListener('keydown', handleKeyDown)
// return () => window.removeEventListener('keydown', handleKeyDown)
// }, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
@@ -209,7 +209,8 @@ const Sidebar = React.forwardRef<
ref={ref}
className={cn(
overflowing ? 'w-12' : '',
'relative group peer hidden md:block text-sidebar-foreground'
'relative group peer hidden md:block text-sidebar-foreground',
'flex-shrink-0'
)}
data-state={state}
data-collapsible={state === 'collapsed' ? collapsible : ''}
+1 -1
View File
@@ -16,5 +16,5 @@ rules.filler = "include('supa-mdx-lint/Rule004ExcludeWords/filler.toml')"
rules.first_person = "include('supa-mdx-lint/Rule004ExcludeWords/first_person.toml')"
rules.gender_neutral = "include('supa-mdx-lint/Rule004ExcludeWords/gender_neutral.toml')"
rules.inclusive_wording = "include('supa-mdx-lint/Rule004ExcludeWords/inclusive_wording.toml')"
rules.preferred_spelling = "include('supa-mdx-lint/Rule004ExcludeWords/preferred_spelling.toml')"
rules.preferred_usage = "include('supa-mdx-lint/Rule004ExcludeWords/preferred_usage.toml')"
rules.slang = "include('supa-mdx-lint/Rule004ExcludeWords/slang.toml')"
+1
View File
@@ -75,6 +75,7 @@ allow_list = [
"[Pp]laintext",
"[Pp]olyfill(s|ed)?",
"[Pp]oolers?",
"[Pp]resign(ed|ing)?",
"[Pp]sycopg",
"[Qq]uickstarts?",
"[Rr]ealtime",
@@ -21,4 +21,8 @@ words = [
"PostgreSQL",
"Postgres",
],
[
"concurrent clients",
"concurrent connections",
],
]