diff --git a/apps/design-system/__registry__/index.tsx b/apps/design-system/__registry__/index.tsx index 8d55cc7b07a..9ecdd2dd9cc 100644 --- a/apps/design-system/__registry__/index.tsx +++ b/apps/design-system/__registry__/index.tsx @@ -1919,6 +1919,17 @@ export const Index: Record = { subcategory: "undefined", chunks: [] }, + "multi-select-combobox-creatable": { + name: "multi-select-combobox-creatable", + type: "components:example", + registryDependencies: undefined, + component: React.lazy(() => import("@/registry/default/example/multi-select-combobox-creatable")), + source: "", + files: ["registry/default/example/multi-select-combobox-creatable.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, "multi-select-deletable-badge": { name: "multi-select-deletable-badge", type: "components:example", diff --git a/apps/design-system/config/docs.ts b/apps/design-system/config/docs.ts index 471a9c89158..e96ec0b2e5c 100644 --- a/apps/design-system/config/docs.ts +++ b/apps/design-system/config/docs.ts @@ -349,6 +349,11 @@ export const docsConfig: DocsConfig = { href: '/docs/components/slider', items: [], }, + { + title: 'Sidebar', + href: '/docs/components/sidebar', + items: [], + }, { title: 'Sonner', href: '/docs/components/sonner', diff --git a/apps/design-system/content/docs/components/sidebar.mdx b/apps/design-system/content/docs/components/sidebar.mdx new file mode 100644 index 00000000000..f8706d0f4ae --- /dev/null +++ b/apps/design-system/content/docs/components/sidebar.mdx @@ -0,0 +1,1372 @@ +--- +title: Sidebar +description: A composable, themeable and customizable sidebar component. +component: true +--- + +
+ +
+ A sidebar that collapses to icons. +
+
+ +Sidebars are one of the most complex components to build. They are central +to any application and often contain a lot of moving parts. + +I don't like building sidebars. So I built 30+ of them. All kinds of +configurations. Then I extracted the core components into `sidebar.tsx`. + +We now have a solid foundation to build on top of. Composable. Themeable. +Customizable. + +[Browse the Blocks Library](/blocks). + +## Installation + + + + + CLI + Manual + + + + + +Run the following command to install `sidebar.tsx` + +```bash +npx shadcn@latest add sidebar +``` + +Add the following colors to your CSS file + +The command above should install the colors for you. If not, copy and paste the following in your CSS file. + +We'll go over the colors later in the [theming section](/docs/components/sidebar#theming). + +```css title="app/globals.css" +@layer base { + :root { + --sidebar-background: 0 0% 98%; + --sidebar-foreground: 240 5.3% 26.1%; + --sidebar-primary: 240 5.9% 10%; + --sidebar-primary-foreground: 0 0% 98%; + --sidebar-accent: 240 4.8% 95.9%; + --sidebar-accent-foreground: 240 5.9% 10%; + --sidebar-border: 220 13% 91%; + --sidebar-ring: 217.2 91.2% 59.8%; + } + + .dark { + --sidebar-background: 240 5.9% 10%; + --sidebar-foreground: 240 4.8% 95.9%; + --sidebar-primary: 224.3 76.3% 48%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 240 3.7% 15.9%; + --sidebar-accent-foreground: 240 4.8% 95.9%; + --sidebar-border: 240 3.7% 15.9%; + --sidebar-ring: 217.2 91.2% 59.8%; + } +} +``` + + + + + + + + + +Copy and paste the following code into your project. + + + +Update the import paths to match your project setup. + +Add the following colors to your CSS file + +We'll go over the colors later in the [theming section](/docs/components/sidebar#theming). + +```css title="app/globals.css" +@layer base { + :root { + --sidebar-background: 0 0% 98%; + --sidebar-foreground: 240 5.3% 26.1%; + --sidebar-primary: 240 5.9% 10%; + --sidebar-primary-foreground: 0 0% 98%; + --sidebar-accent: 240 4.8% 95.9%; + --sidebar-accent-foreground: 240 5.9% 10%; + --sidebar-border: 220 13% 91%; + --sidebar-ring: 217.2 91.2% 59.8%; + } + + .dark { + --sidebar-background: 240 5.9% 10%; + --sidebar-foreground: 240 4.8% 95.9%; + --sidebar-primary: 224.3 76.3% 48%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 240 3.7% 15.9%; + --sidebar-accent-foreground: 240 4.8% 95.9%; + --sidebar-border: 240 3.7% 15.9%; + --sidebar-ring: 217.2 91.2% 59.8%; + } +} +``` + +Add the Sidebar tailwind config into `tailwind.config.js` + +Add the following object in the `theme.extend.colors` section of your `tailwind.config.js` file +this config enable sidebar related style utilities like bg-sidebar + +```javascript title="tailwind.config.js" +// ... +sidebar: { + DEFAULT: 'hsl(var(--sidebar-background))', + foreground: 'hsl(var(--sidebar-foreground))', + primary: 'hsl(var(--sidebar-primary))', + 'primary-foreground': 'hsl(var(--sidebar-primary-foreground))', + accent: 'hsl(var(--sidebar-accent))', + 'accent-foreground': 'hsl(var(--sidebar-accent-foreground))', + border: 'hsl(var(--sidebar-border))', + ring: 'hsl(var(--sidebar-ring))', +}, +// ... +``` + + + + + + + +## Structure + +A `Sidebar` component is composed of the following parts: + +- `SidebarProvider` - Handles collapsible state. +- `Sidebar` - The sidebar container. +- `SidebarHeader` and `SidebarFooter` - Sticky at the top and bottom of the sidebar. +- `SidebarContent` - Scrollable content. +- `SidebarGroup` - Section within the `SidebarContent`. +- `SidebarTrigger` - Trigger for the `Sidebar`. + +Sidebar Structure +Sidebar Structure + +## Usage + +```tsx showLineNumbers title="app/layout.tsx" +import { SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar' +import { AppSidebar } from '@/components/app-sidebar' + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + + +
+ + {children} +
+
+ ) +} +``` + +```tsx showLineNumbers title="components/app-sidebar.tsx" +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarHeader, +} from '@/components/ui/sidebar' + +export function AppSidebar() { + return ( + + + + + + + + + ) +} +``` + +## Your First Sidebar + +Let's start with the most basic sidebar. A collapsible sidebar with a menu. + + + +Add a `SidebarProvider` and `SidebarTrigger` at the root of your application. + +```tsx showLineNumbers title="app/layout.tsx" +import { SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar' +import { AppSidebar } from '@/components/app-sidebar' + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + + +
+ + {children} +
+
+ ) +} +``` + +Create a new sidebar component at `components/app-sidebar.tsx`. + +```tsx showLineNumbers title="components/app-sidebar.tsx" +import { Sidebar, SidebarContent } from '@/components/ui/sidebar' + +export function AppSidebar() { + return ( + + + + ) +} +``` + +Now, let's add a `SidebarMenu` to the sidebar. + +We'll use the `SidebarMenu` component in a `SidebarGroup`. + +```tsx showLineNumbers title="components/app-sidebar.tsx" +import { Calendar, Home, Inbox, Search, Settings } from 'lucide-react' + +import { + Sidebar, + SidebarContent, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from '@/components/ui/sidebar' + +// Menu items. +const items = [ + { + title: 'Home', + url: '#', + icon: Home, + }, + { + title: 'Inbox', + url: '#', + icon: Inbox, + }, + { + title: 'Calendar', + url: '#', + icon: Calendar, + }, + { + title: 'Search', + url: '#', + icon: Search, + }, + { + title: 'Settings', + url: '#', + icon: Settings, + }, +] + +export function AppSidebar() { + return ( + + + + Application + + + {items.map((item) => ( + + + + + {item.title} + + + + ))} + + + + + + ) +} +``` + +You've created your first sidebar. + +
+ +
Your first sidebar.
+
+ +
+ +## Components + +The components in `sidebar.tsx` are built to be composable i.e you build your sidebar by putting the provided components together. They also compose well with other shadcn/ui components such as `DropdownMenu`, `Collapsible` or `Dialog` etc. + +**If you need to change the code in `sidebar.tsx`, you are encouraged to do so. The code is yours. Use `sidebar.tsx` as a starting point and build your own.** + +In the next sections, we'll go over each component and how to use them. + +## SidebarProvider + +The `SidebarProvider` component is used to provide the sidebar context to the `Sidebar` component. You should always wrap your application in a `SidebarProvider` component. + +### Props + +| Name | Type | Description | +| -------------- | ------------------------- | -------------------------------------------- | +| `defaultOpen` | `boolean` | Default open state of the sidebar. | +| `open` | `boolean` | Open state of the sidebar (controlled). | +| `onOpenChange` | `(open: boolean) => void` | Sets open state of the sidebar (controlled). | + +### Width + +If you have a single sidebar in your application, you can use the `SIDEBAR_WIDTH` and `SIDEBAR_WIDTH_MOBILE` variables in `sidebar.tsx` to set the width of the sidebar. + +```tsx showLineNumbers title="components/ui/sidebar.tsx" +const SIDEBAR_WIDTH = '16rem' +const SIDEBAR_WIDTH_MOBILE = '18rem' +``` + +For multiple sidebars in your application, you can use the `style` prop to set the width of the sidebar. + +To set the width of the sidebar, you can use the `--sidebar-width` and `--sidebar-width-mobile` CSS variables in the `style` prop. + +```tsx showLineNumbers title="components/ui/sidebar.tsx" + + + +``` + +This will handle the width of the sidebar but also the layout spacing. + +### Keyboard Shortcut + +The `SIDEBAR_KEYBOARD_SHORTCUT` variable is used to set the keyboard shortcut used to open and close the sidebar. + +To trigger the sidebar, you use the `cmd+b` keyboard shortcut on Mac and `ctrl+b` on Windows. + +You can change the keyboard shortcut by updating the `SIDEBAR_KEYBOARD_SHORTCUT` variable. + +```tsx showLineNumbers title="components/ui/sidebar.tsx" +const SIDEBAR_KEYBOARD_SHORTCUT = 'b' +``` + +### Persisted State + +The `SidebarProvider` supports persisting the sidebar state across page reloads and server-side rendering. It uses cookies to store the current state of the sidebar. When the sidebar state changes, a default cookie named `sidebar_state` is set with the current open/closed state. This cookie is then read on subsequent page loads to restore the sidebar state. + +To persist sidebar state in Next.js, set up your `SidebarProvider` in `app/layout.tsx` like this: + +```tsx showLineNumbers title="app/layout.tsx" +import { cookies } from 'next/headers' + +import { SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar' +import { AppSidebar } from '@/components/app-sidebar' + +export async function Layout({ children }: { children: React.ReactNode }) { + const cookieStore = await cookies() + const defaultOpen = cookieStore.get('sidebar_state')?.value === 'true' + + return ( + + +
+ + {children} +
+
+ ) +} +``` + +You can change the name of the cookie by updating the `SIDEBAR_COOKIE_NAME` variable in `sidebar.tsx`. + +```tsx showLineNumbers title="components/ui/sidebar.tsx" +const SIDEBAR_COOKIE_NAME = 'sidebar_state' +``` + +## Sidebar + +The main `Sidebar` component used to render a collapsible sidebar. + +```tsx showLineNumbers +import { Sidebar } from '@/components/ui/sidebar' + +export function AppSidebar() { + return +} +``` + +### Props + +| Property | Type | Description | +| ------------- | --------------------------------- | --------------------------------- | +| `side` | `left` or `right` | The side of the sidebar. | +| `variant` | `sidebar`, `floating`, or `inset` | The variant of the sidebar. | +| `collapsible` | `offcanvas`, `icon`, or `none` | Collapsible state of the sidebar. | + +### side + +Use the `side` prop to change the side of the sidebar. + +Available options are `left` and `right`. + +```tsx showLineNumbers +import { Sidebar } from '@/components/ui/sidebar' + +export function AppSidebar() { + return +} +``` + +### variant + +Use the `variant` prop to change the variant of the sidebar. + +Available options are `sidebar`, `floating` and `inset`. + +```tsx showLineNumbers +import { Sidebar } from '@/components/ui/sidebar' + +export function AppSidebar() { + return +} +``` + + + **Note:** If you use the `inset` variant, remember to wrap your main content in a `SidebarInset` + component. + + +```tsx showLineNumbers + + + +
{children}
+
+
+``` + +### collapsible + +Use the `collapsible` prop to make the sidebar collapsible. + +Available options are `offcanvas`, `icon` and `none`. + +```tsx showLineNumbers +import { Sidebar } from '@/components/ui/sidebar' + +export function AppSidebar() { + return +} +``` + +| Prop | Description | +| ----------- | ------------------------------------------------------------ | +| `offcanvas` | A collapsible sidebar that slides in from the left or right. | +| `icon` | A sidebar that collapses to icons. | +| `none` | A non-collapsible sidebar. | + +## useSidebar + +The `useSidebar` hook is used to control the sidebar. + +```tsx showLineNumbers +import { useSidebar } from '@/components/ui/sidebar' + +export function AppSidebar() { + const { state, open, setOpen, openMobile, setOpenMobile, isMobile, toggleSidebar } = useSidebar() +} +``` + +| Property | Type | Description | +| --------------- | ------------------------- | --------------------------------------------- | +| `state` | `expanded` or `collapsed` | The current state of the sidebar. | +| `open` | `boolean` | Whether the sidebar is open. | +| `setOpen` | `(open: boolean) => void` | Sets the open state of the sidebar. | +| `openMobile` | `boolean` | Whether the sidebar is open on mobile. | +| `setOpenMobile` | `(open: boolean) => void` | Sets the open state of the sidebar on mobile. | +| `isMobile` | `boolean` | Whether the sidebar is on mobile. | +| `toggleSidebar` | `() => void` | Toggles the sidebar. Desktop and mobile. | + +## SidebarHeader + +Use the `SidebarHeader` component to add a sticky header to the sidebar. + +The following example adds a `` to the `SidebarHeader`. + +
+ +
+ A sidebar header with a dropdown menu. +
+
+ +```tsx showLineNumbers title="components/app-sidebar.tsx" + + + + + + + + Select Workspace + + + + + + Acme Inc + + + Acme Corp. + + + + + + + +``` + +## SidebarFooter + +Use the `SidebarFooter` component to add a sticky footer to the sidebar. + +The following example adds a `` to the `SidebarFooter`. + +
+ +
+ A sidebar footer with a dropdown menu. +
+
+ +```tsx showLineNumbers title="components/app-sidebar.tsx" +export function AppSidebar() { + return ( + + + + + + + + + + + Username + + + + + + Account + + + Billing + + + Sign out + + + + + + + + + ) +} +``` + +## SidebarContent + +The `SidebarContent` component is used to wrap the content of the sidebar. This is where you add your `SidebarGroup` components. It is scrollable. + +```tsx showLineNumbers +import { Sidebar, SidebarContent } from '@/components/ui/sidebar' + +export function AppSidebar() { + return ( + + + + + + + ) +} +``` + +## SidebarGroup + +Use the `SidebarGroup` component to create a section within the sidebar. + +A `SidebarGroup` has a `SidebarGroupLabel`, a `SidebarGroupContent` and an optional `SidebarGroupAction`. + +
+ +
A sidebar group.
+
+ +```tsx showLineNumbers +import { Sidebar, SidebarContent, SidebarGroup } from '@/components/ui/sidebar' + +export function AppSidebar() { + return ( + + + + Application + + Add Project + + + + + + ) +} +``` + +## Collapsible SidebarGroup + +To make a `SidebarGroup` collapsible, wrap it in a `Collapsible`. + +
+ +
+ A collapsible sidebar group. +
+
+ +```tsx showLineNumbers +export function AppSidebar() { + return ( + + + + + Help + + + + + + + + + ) +} +``` + + + **Note:** We wrap the `CollapsibleTrigger` in a `SidebarGroupLabel` to render a button. + + +## SidebarGroupAction + +Use the `SidebarGroupAction` component to add an action button to the `SidebarGroup`. + +```tsx showLineNumbers {5-7} +export function AppSidebar() { + return ( + + Projects + + Add Project + + + + ) +} +``` + +
+ +
+ A sidebar group with an action button. +
+
+ +## SidebarMenu + +The `SidebarMenu` component is used for building a menu within a `SidebarGroup`. + +A `SidebarMenu` component is composed of `SidebarMenuItem`, `SidebarMenuButton`, `` and `` components. + +Sidebar Menu +Sidebar Menu + +Here's an example of a `SidebarMenu` component rendering a list of projects. + +
+ +
+ A sidebar menu with a list of projects. +
+
+ +```tsx showLineNumbers + + + + Projects + + + {projects.map((project) => ( + + + + + {project.name} + + + + ))} + + + + + +``` + +## SidebarMenuButton + +The `SidebarMenuButton` component is used to render a menu button within a `SidebarMenuItem`. + +### Link or Anchor + +By default, the `SidebarMenuButton` renders a button but you can use the `asChild` prop to render a different component such as a `Link` or an `a` tag. + +```tsx showLineNumbers + + Home + +``` + +### Icon and Label + +You can render an icon and a truncated label inside the button. Remember to wrap the label in a ``. + +```tsx showLineNumbers + + + + Home + + +``` + +### isActive + +Use the `isActive` prop to mark a menu item as active. + +```tsx showLineNumbers + + Home + +``` + +## SidebarMenuAction + +The `SidebarMenuAction` component is used to render a menu action within a `SidebarMenuItem`. + +This button works independently of the `SidebarMenuButton` i.e you can have the `` as a clickable link and the `` as a button. + +```tsx showLineNumbers + + + + + Home + + + + Add Project + + +``` + +### DropdownMenu + +Here's an example of a `SidebarMenuAction` component rendering a `DropdownMenu`. + +
+ +
+ A sidebar menu action with a dropdown menu. +
+
+ +```tsx showLineNumbers + + + + + Home + + + + + + + + + + + Edit Project + + + Delete Project + + + + +``` + +## SidebarMenuSub + +The `SidebarMenuSub` component is used to render a submenu within a `SidebarMenu`. + +Use `` and `` to render a submenu item. + +
+ +
+ A sidebar menu with a submenu. +
+
+ +```tsx showLineNumbers + + + + + + + + + + + +``` + +## Collapsible SidebarMenu + +To make a `SidebarMenu` component collapsible, wrap it and the `SidebarMenuSub` components in a `Collapsible`. + +
+ +
A collapsible sidebar menu.
+
+ +```tsx showLineNumbers + + + + + + + + + + + + + + +``` + +## SidebarMenuBadge + +The `SidebarMenuBadge` component is used to render a badge within a `SidebarMenuItem`. + +
+ +
+ A sidebar menu with a badge. +
+
+ +```tsx showLineNumbers + + + 24 + +``` + +## SidebarMenuSkeleton + +The `SidebarMenuSkeleton` component is used to render a skeleton for a `SidebarMenu`. You can use this to show a loading state when using React Server Components, SWR or react-query. + +```tsx showLineNumbers +function NavProjectsSkeleton() { + return ( + + {Array.from({ length: 5 }).map((_, index) => ( + + + + ))} + + ) +} +``` + +## SidebarSeparator + +The `SidebarSeparator` component is used to render a separator within a `Sidebar`. + +```tsx showLineNumbers + + + + + + + + + +``` + +## SidebarTrigger + +Use the `SidebarTrigger` component to render a button that toggles the sidebar. + +The `SidebarTrigger` component must be used within a `SidebarProvider`. + +```tsx showLineNumbers + + +
+ +
+
+``` + +### Custom Trigger + +To create a custom trigger, you can use the `useSidebar` hook. + +```tsx showLineNumbers +import { useSidebar } from '@/components/ui/sidebar' + +export function CustomTrigger() { + const { toggleSidebar } = useSidebar() + + return +} +``` + +## SidebarRail + +The `SidebarRail` component is used to render a rail within a `Sidebar`. This rail can be used to toggle the sidebar. + +```tsx showLineNumbers + + + + + + + + +``` + +## Data Fetching + +### React Server Components + +Here's an example of a `SidebarMenu` component rendering a list of projects using React Server Components. + +
+ +
+ A sidebar menu using React Server Components. +
+
+ +```tsx showLineNumbers {6} title="Skeleton to show loading state." +function NavProjectsSkeleton() { + return ( + + {Array.from({ length: 5 }).map((_, index) => ( + + + + ))} + + ) +} +``` + +```tsx showLineNumbers {2} title="Server component fetching data." +async function NavProjects() { + const projects = await fetchProjects() + + return ( + + {projects.map((project) => ( + + + + + {project.name} + + + + ))} + + ) +} +``` + +```tsx showLineNumbers {8-10} title="Usage with React Suspense." +function AppSidebar() { + return ( + + + + Projects + + }> + + + + + + + ) +} +``` + +### SWR and React Query + +You can use the same approach with [SWR](https://swr.vercel.app/) or [react-query](https://tanstack.com/query/latest/docs/framework/react/overview). + +```tsx showLineNumbers title="SWR" +function NavProjects() { + const { data, isLoading } = useSWR("/api/projects", fetcher) + + if (isLoading) { + return ( + + {Array.from({ length: 5 }).map((_, index) => ( + + + + ))} + + ) + } + + if (!data) { + return ... + } + + return ( + + {data.map((project) => ( + + + + + {project.name} + + + + ))} + + ) +} +``` + +```tsx showLineNumbers title="React Query" +function NavProjects() { + const { data, isLoading } = useQuery() + + if (isLoading) { + return ( + + {Array.from({ length: 5 }).map((_, index) => ( + + + + ))} + + ) + } + + if (!data) { + return ... + } + + return ( + + {data.map((project) => ( + + + + + {project.name} + + + + ))} + + ) +} +``` + +## Controlled Sidebar + +Use the `open` and `onOpenChange` props to control the sidebar. + +
+ +
A controlled sidebar.
+
+ +```tsx showLineNumbers +export function AppSidebar() { + const [open, setOpen] = React.useState(false) + + return ( + + + + ) +} +``` + +## Theming + +We use the following CSS variables to theme the sidebar. + +```css +@layer base { + :root { + --sidebar-background: 0 0% 98%; + --sidebar-foreground: 240 5.3% 26.1%; + --sidebar-primary: 240 5.9% 10%; + --sidebar-primary-foreground: 0 0% 98%; + --sidebar-accent: 240 4.8% 95.9%; + --sidebar-accent-foreground: 240 5.9% 10%; + --sidebar-border: 220 13% 91%; + --sidebar-ring: 217.2 91.2% 59.8%; + } + + .dark { + --sidebar-background: 240 5.9% 10%; + --sidebar-foreground: 240 4.8% 95.9%; + --sidebar-primary: 0 0% 98%; + --sidebar-primary-foreground: 240 5.9% 10%; + --sidebar-accent: 240 3.7% 15.9%; + --sidebar-accent-foreground: 240 4.8% 95.9%; + --sidebar-border: 240 3.7% 15.9%; + --sidebar-ring: 217.2 91.2% 59.8%; + } +} +``` + +**We intentionally use different variables for the sidebar and the rest of the application** to make it easy to have a sidebar that is styled differently from the rest of the application. Think a sidebar with a darker shade from the main application. + +## Styling + +Here are some tips for styling the sidebar based on different states. + +- **Styling an element based on the sidebar collapsible state.** The following will hide the `SidebarGroup` when the sidebar is in `icon` mode. + +```tsx + + + + + +``` + +- **Styling a menu action based on the menu button active state.** The following will force the menu action to be visible when the menu button is active. + +```tsx + + + + +``` + +You can find more tips on using states for styling in this [Twitter thread](https://x.com/shadcn/status/1842329158879420864). + +## Changelog + +### 2024-10-30 Cookie handling in setOpen + +- [#5593](https://github.com/shadcn-ui/ui/pull/5593) - Improved setOpen callback logic in ``. + +Update the `setOpen` callback in `` as follows: + +```tsx showLineNumbers +const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === 'function' ? value(open) : value + if (setOpenProp) { + setOpenProp(openState) + } else { + _setOpen(openState) + } + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` + }, + [setOpenProp, open] +) +``` + +### 2024-10-21 Fixed `text-sidebar-foreground` + +- [#5491](https://github.com/shadcn-ui/ui/pull/5491) - Moved `text-sidebar-foreground` from `` to `` component. + +### 2024-10-20 Typo in `useSidebar` hook. + +Fixed typo in `useSidebar` hook. + +```diff showLineNumbers title="sidebar.tsx" +- throw new Error("useSidebar must be used within a Sidebar.") ++ throw new Error("useSidebar must be used within a SidebarProvider.") +``` diff --git a/apps/design-system/content/docs/fragments/multi-select.mdx b/apps/design-system/content/docs/fragments/multi-select.mdx index 8425bb4a988..1c3d40faf46 100644 --- a/apps/design-system/content/docs/fragments/multi-select.mdx +++ b/apps/design-system/content/docs/fragments/multi-select.mdx @@ -47,6 +47,13 @@ Use `MultiSelectorInput`to add a search input. +### Badge Combo Box with creatable selector + +creatable: `boolean` +`creatable` prop on the `MultiSelectorList` component can be used to add a new option if no items match + + + ### Badge Limit badgeLimit: `number` | `"wrap"`. diff --git a/apps/design-system/registry/default/example/multi-select-combobox-creatable.tsx b/apps/design-system/registry/default/example/multi-select-combobox-creatable.tsx new file mode 100644 index 00000000000..5b8ddf96c71 --- /dev/null +++ b/apps/design-system/registry/default/example/multi-select-combobox-creatable.tsx @@ -0,0 +1,34 @@ +import { useState } from 'react' +import { + MultiSelector, + MultiSelectorContent, + MultiSelectorInput, + MultiSelectorItem, + MultiSelectorList, + MultiSelectorTrigger, +} from 'ui-patterns/multi-select' + +export default function MultiSelectDemo() { + const [selectedValues, setSelectedValues] = useState([]) + + return ( + + + + + + Mango + Date + Apple + Elderberrie + Fig + Grape + Banana + Kiwi + Strawberry + Cherry + + + + ) +} diff --git a/apps/design-system/registry/examples.ts b/apps/design-system/registry/examples.ts index 58d7c1dd508..a9f4de3e7c8 100644 --- a/apps/design-system/registry/examples.ts +++ b/apps/design-system/registry/examples.ts @@ -1091,6 +1091,11 @@ export const examples: Registry = [ type: 'components:example', files: ['example/multi-select-combobox.tsx'], }, + { + name: 'multi-select-combobox-creatable', + type: 'components:example', + files: ['example/multi-select-combobox-creatable.tsx'], + }, { name: 'multi-select-deletable-badge', type: 'components:example', diff --git a/apps/design-system/tailwind.config.js b/apps/design-system/tailwind.config.js index 5a512287d82..5be9a0d5a1d 100644 --- a/apps/design-system/tailwind.config.js +++ b/apps/design-system/tailwind.config.js @@ -8,7 +8,8 @@ module.exports = config({ // purge styles from grid library // './../../packages/ui/src/**/*.{tsx,ts,js}', - './../../packages/ui-patterns/!(node_modules)/**/*.{tsx,ts,js}', + './../../packages/ui-patterns/**/*.{tsx,ts,js}', + '!./../../packages/ui-patterns/node_modules/**/*', // ignore node_modules rule ], theme: { extend: { diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index 4440a3a331c..09c57917ca9 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -1747,6 +1747,10 @@ export const ai = { url: '/guides/ai/vector-indexes', items: vectorIndexItems, }, + { + name: 'Automatic embeddings', + url: '/guides/ai/automatic-embeddings', + }, { name: 'Engineering for scale', url: '/guides/ai/engineering-for-scale', diff --git a/apps/docs/content/guides/ai/automatic-embeddings.mdx b/apps/docs/content/guides/ai/automatic-embeddings.mdx new file mode 100644 index 00000000000..48f1f7389c6 --- /dev/null +++ b/apps/docs/content/guides/ai/automatic-embeddings.mdx @@ -0,0 +1,817 @@ +--- +id: 'ai-automatic-embeddings' +title: 'Automatic embeddings' +description: 'Automate embedding generation and updates in Postgres' +sidebar_label: 'Automatic embeddings' +--- + +Vector embeddings enable powerful [semantic search](/docs/guides/ai/semantic-search) capabilities in Postgres, but managing them alongside your content has traditionally been complex. This guide demonstrates how to automate embedding generation and updates using Supabase [Edge Functions](/docs/guides/functions), [pgmq](/docs/guides/database/extensions/pgmq), [pg_net](/docs/guides/database/extensions/pg_net), and [pg_cron](/docs/guides/cron). + +## Understanding the challenge + +When implementing semantic search with pgvector, developers typically need to: + +1. Generate embeddings via an external API (like OpenAI) +2. Store these embeddings alongside the content +3. Keep embeddings in sync when content changes +4. Handle failures and retries in the embedding generation process + +While Postgres [full-text search](/docs/guides/database/full-text-search) can handle this internally through synchronous calls to `to_tsvector` and [triggers](https://www.postgresql.org/docs/current/textsearch-features.html#TEXTSEARCH-UPDATE-TRIGGERS), semantic search requires asynchronous API calls to a provider like OpenAI to generate vector embeddings. This guide demonstrates how to use triggers, queues, and Supabase Edge Functions to bridge this gap. + +## Understanding the architecture + +We'll leverage the following Postgres and Supabase features to create the automated embedding system: + +1. [pgvector](/docs/guides/database/extensions/pgvector): Stores and queries vector embeddings +1. [pgmq](/docs/guides/queues): Queues embedding generation requests for processing and retries +1. [pg_net](/docs/guides/database/extensions/pg_net): Handles asynchronous HTTP requests to Edge Functions directly from Postgres +1. [pg_cron](/docs/guides/cron): Automatically processes and retries embedding generations +1. [Triggers](/docs/guides/database/postgres/triggers): Detects content changes and enqueues embedding generation requests +1. [Edge Functions](/docs/guides/functions): Generates embeddings via an API like OpenAI (customizable) + +We'll design the system to: + +1. Be generic, so that it can be used with any table and content. This allows you to configure embeddings in multiple places, each with the ability to customize the input used for embedding generation. These will all use the same queue infrastructure and Edge Function to generate the embeddings. + +1. Handle failures gracefully, by retrying failed jobs and providing detailed information about the status of each job. + +## Implementation + +We'll start by setting up the infrastructure needed to queue and process embedding generation requests. Then we'll create an example table with triggers to enqueue these embedding requests whenever content is inserted or updated. + +### Step 1: Enable extensions + +First, let's enable the required extensions: + + + + +```sql +-- For vector operations +create extension if not exists vector +with + schema extensions; + +-- For queueing and processing jobs +-- (pgmq will create its own schema) +create extension if not exists pgmq; + +-- For async HTTP requests +create extension if not exists pg_net +with + schema extensions; + +-- For scheduled processing and retries +-- (pg_cron will create its own schema) +create extension if not exists pg_cron; + +-- For clearing embeddings during updates +create extension if not exists hstore +with + schema extensions; +``` + +Even though the SQL code is `create extension`, this is the equivalent of "enabling the extension". +To disable an extension, call `drop extension`. + + + + +1. Go to the [Extensions](/dashboard/project/_/database/extensions) page in the Dashboard. +2. Search for and enable the following extensions: + - `vector` + - `pgmq` + - `pg_net` + - `pg_cron` + - `hstore` + + + + +### Step 2: Create utility functions + +Before we set up our embedding logic, we need to create some utility functions: + +```sql +-- Schema for utility functions +create schema util; + +-- Utility function to get the Supabase project URL (required for Edge Functions) +create function util.project_url() +returns text +language plpgsql +security definer +as $$ +declare + secret_value text; +begin + -- Retrieve the project URL from Vault + select decrypted_secret into secret_value from vault.decrypted_secrets where name = 'project_url'; + return secret_value; +end; +$$; + +-- Generic function to invoke any Edge Function +create or replace function util.invoke_edge_function( + name text, + body jsonb, + timeout_milliseconds int = 5 * 60 * 1000 -- default 5 minute timeout +) +returns void +language plpgsql +as $$ +declare + headers_raw text; + auth_header text; +begin + -- If we're in a PostgREST session, reuse the request headers for authorization + headers_raw := current_setting('request.headers', true); + + -- Only try to parse if headers are present + auth_header := case + when headers_raw is not null then + (headers_raw::json->>'authorization') + else + null + end; + + -- Perform async HTTP request to the edge function + perform net.http_post( + url => util.project_url() || '/functions/v1/' || name, + headers => jsonb_build_object( + 'Content-Type', 'application/json', + 'Authorization', auth_header + ), + body => body, + timeout_milliseconds => timeout_milliseconds + ); +end; +$$; + +-- Generic trigger function to clear a column on update +create or replace function util.clear_column() +returns trigger +language plpgsql as $$ +declare + clear_column text := TG_ARGV[0]; +begin + NEW := NEW #= hstore(clear_column, NULL); + return NEW; +end; +$$; +``` + +Here we create: + +- A schema `util` to store utility functions. +- A function to retrieve the Supabase project URL from [Vault](/docs/guides/database/vault). We'll add this secret next. +- A generic function to invoke any Edge Function with a given name and request body. +- A generic trigger function to clear a column on update. This function accepts the column name as an argument and sets it to `NULL` in the `NEW` record. We'll explain how to use this function later. + +Every project has a unique API URL that is required to invoke Edge Functions. Let's go ahead and add the project URL secret to Vault depending on your environment. + +When working with a local Supabase stack, add the following to your `supabase/seed.sql` file: + +```sql +select + vault.create_secret('http://api.supabase.internal:8000', 'project_url'); +``` + +When deploying to the cloud platform, open the [SQL editor](/dashboard/project/_/sql/new) and run the following, replacing `` with your [project's API URL](/dashboard/project/_/settings/api): + +```sql +select + vault.create_secret('', 'project_url'); +``` + +### Step 3: Create queue and triggers + +Our goal is to automatically generate embeddings whenever content is inserted or updated within a table. We can use triggers and queues to achieve this. Our approach is to automatically queue embedding jobs whenever records are inserted or updated in a table, then process them asynchronously using a cron job. If a job fails, it will remain in the queue and be retried in the next scheduled task. + +First we create a `pgmq` queue for processing embedding requests: + +```sql +-- Queue for processing embedding jobs +select pgmq.create('embedding_jobs'); +``` + +Next we create a trigger function to queue embedding jobs. We'll use this function to handle both insert and update events: + +```sql +-- Generic trigger function to queue embedding jobs +create or replace function util.queue_embeddings() +returns trigger +language plpgsql +as $$ +declare + content_function text = TG_ARGV[0]; + embedding_column text = TG_ARGV[1]; +begin + perform pgmq.send( + queue_name => 'embedding_jobs', + msg => jsonb_build_object( + 'id', NEW.id, + 'schema', TG_TABLE_SCHEMA, + 'table', TG_TABLE_NAME, + 'contentFunction', content_function, + 'embeddingColumn', embedding_column + ) + ); + return NEW; +end; +$$; +``` + +Our `util.queue_embeddings` trigger function is generic and can be used with any table and content function. It accepts two arguments: + +1. `content_function`: The name of a function that returns the text content to be embedded. The function should accept a single row as input and return text (see the `embedding_input` example). + + This allows you to customize the text input passed to the embedding model - for example, you could concatenate multiple columns together like `title` and `content` and use the result as input. + +2. `embedding_column`: The name of the destination column where the embedding will be stored. + +Note that the `util.queue_embeddings` trigger function requires a `for each row` clause to work correctly. See [Usage](#usage) for an example of how to use this trigger function with your table. + +Next we'll create a function to process the embedding jobs. This function will read jobs from the queue, group them into batches, and invoke the Edge Function to generate embeddings. We'll use `pg_cron` to schedule this function to run every 10 seconds. + +```sql +-- Function to process embedding jobs from the queue +create or replace function util.process_embeddings( + batch_size int = 10, + max_requests int = 10, + timeout_milliseconds int = 5 * 60 * 1000 -- default 5 minute timeout +) +returns void +language plpgsql +as $$ +declare + job_batches jsonb[]; + batch jsonb; +begin + with + -- First get jobs and assign batch numbers + numbered_jobs as ( + select + message || jsonb_build_object('jobId', msg_id) as job_info, + (row_number() over (order by 1) - 1) / batch_size as batch_num + from pgmq.read( + queue_name => 'embedding_jobs', + vt => timeout_milliseconds / 1000, + qty => max_requests * batch_size + ) + ), + -- Then group jobs into batches + batched_jobs as ( + select + jsonb_agg(job_info) as batch_array, + batch_num + from numbered_jobs + group by batch_num + ) + -- Finally aggregate all batches into array + select array_agg(batch_array) + from batched_jobs + into job_batches; + + -- Invoke the embed edge function for each batch + foreach batch in array job_batches loop + perform util.invoke_edge_function( + name => 'embed', + body => batch, + timeout_milliseconds => timeout_milliseconds + ); + end loop; +end; +$$; + +-- Schedule the embedding processing +select + cron.schedule( + 'process-embeddings', + '10 seconds', + $$ + select util.process_embeddings(); + $$ + ); +``` + +Let's discuss some common questions about this approach: + +#### Why not generate all embeddings in a single Edge Function request? + +While this is possible, it can lead to long processing times and potential timeouts. Batching allows us to process multiple embeddings concurrently and handle failures more effectively. + +#### Why not one request per row? + +This approach can lead to API rate limiting and performance issues. Batching provides a balance between efficiency and reliability. + +#### Why queue requests instead of processing them immediately? + +Queuing allows us to handle failures gracefully, retry requests, and manage concurrency more effectively. Specifically we are using `pgmq`'s visibility timeouts to ensure that failed requests are retried. + +#### How do visibility timeouts work? + +Every time we read a message from the queue, we set a visibility timeout which tells `pgmq` to hide the message from other readers for a certain period. If the Edge Function fails to process the message within this period, the message becomes visible again and will be retried by the next scheduled task. + +#### How do we handle retries? + +We use `pg_cron` to schedule a task that reads messages from the queue and processes them. If the Edge Function fails to process a message, it becomes visible again after a timeout and can be retried by the next scheduled task. + +#### Is 10 seconds a good interval for processing? + +This interval is a good starting point, but you may need to adjust it based on your workload and the time it takes to generate embeddings. You can adjust the `batch_size`, `max_requests`, and `timeout_milliseconds` parameters to optimize performance. + +### Step 4: Create the Edge Function + +Finally we'll create the Edge Function to generate embeddings. We'll use OpenAI's API in this example, but you can replace it with any other embedding generation service. + +Use the Supabase CLI to create a new Edge Function: + +```bash +supabase functions new embed +``` + +This will create a new directory `supabase/functions/embed` with an `index.ts` file. Replace the contents of this file with the following: + +_supabase/functions/embed/index.ts_: + +```typescript +// Setup type definitions for built-in Supabase Runtime APIs +import 'jsr:@supabase/functions-js/edge-runtime.d.ts' + +// We'll use the OpenAI API to generate embeddings +import OpenAI from 'jsr:@openai/openai' + +import { z } from 'npm:zod' + +// We'll make a direct Postgres connection to update the document +import postgres from 'https://deno.land/x/postgresjs@v3.4.5/mod.js' + +// Initialize OpenAI client +const openai = new OpenAI({ + // We'll need to manually set the `OPENAI_API_KEY` environment variable + apiKey: Deno.env.get('OPENAI_API_KEY'), +}) + +// Initialize Postgres client +const sql = postgres( + // `SUPABASE_DB_URL` is a built-in environment variable + Deno.env.get('SUPABASE_DB_URL')! +) + +const jobSchema = z.object({ + jobId: z.number(), + id: z.number(), + schema: z.string(), + table: z.string(), + contentFunction: z.string(), + embeddingColumn: z.string(), +}) + +const failedJobSchema = jobSchema.extend({ + error: z.string(), +}) + +type Job = z.infer +type FailedJob = z.infer + +type Row = { + id: string + content: unknown +} + +const QUEUE_NAME = 'embedding_jobs' + +// Listen for HTTP requests +Deno.serve(async (req) => { + if (req.method !== 'POST') { + return new Response('expected POST request', { status: 405 }) + } + + if (req.headers.get('content-type') !== 'application/json') { + return new Response('expected json body', { status: 400 }) + } + + // Use Zod to parse and validate the request body + const parseResult = z.array(jobSchema).safeParse(await req.json()) + + if (parseResult.error) { + return new Response(`invalid request body: ${parseResult.error.message}`, { + status: 400, + }) + } + + const pendingJobs = parseResult.data + + // Track jobs that completed successfully + const completedJobs: Job[] = [] + + // Track jobs that failed due to an error + const failedJobs: FailedJob[] = [] + + async function processJobs() { + let currentJob: Job | undefined + + while ((currentJob = pendingJobs.shift()) !== undefined) { + try { + await processJob(currentJob) + completedJobs.push(currentJob) + } catch (error) { + failedJobs.push({ + ...currentJob, + error: error instanceof Error ? error.message : JSON.stringify(error), + }) + } + } + } + + try { + // Process jobs while listening for worker termination + await Promise.race([processJobs(), catchUnload()]) + } catch (error) { + // If the worker is terminating (e.g. wall clock limit reached), + // add pending jobs to fail list with termination reason + failedJobs.push( + ...pendingJobs.map((job) => ({ + ...job, + error: error instanceof Error ? error.message : JSON.stringify(error), + })) + ) + } + + // Log completed and failed jobs for traceability + console.log('finished processing jobs:', { + completedJobs: completedJobs.length, + failedJobs: failedJobs.length, + }) + + return new Response( + JSON.stringify({ + completedJobs, + failedJobs, + }), + { + // 200 OK response + status: 200, + + // Custom headers to report job status + headers: { + 'Content-Type': 'application/json', + 'X-Completed-Jobs': completedJobs.length.toString(), + 'X-Failed-Jobs': failedJobs.length.toString(), + }, + } + ) +}) + +/** + * Generates an embedding for the given text. + */ +async function generateEmbedding(text: string) { + const response = await openai.embeddings.create({ + model: 'text-embedding-3-small', + input: text, + }) + const [data] = response.data + + if (!data) { + throw new Error('failed to generate embedding') + } + + return data.embedding +} + +/** + * Processes an embedding job. + */ +async function processJob(job: Job) { + const { jobId, id, schema, table, contentFunction, embeddingColumn } = job + + // Fetch content for the schema/table/row combination + const [row]: [Row] = await sql` + select + id, + ${sql(contentFunction)}(t) as content + from + ${sql(schema)}.${sql(table)} t + where + id = ${id} + ` + + if (!row) { + throw new Error(`row not found: ${schema}.${table}/${id}`) + } + + if (typeof row.content !== 'string') { + throw new Error(`invalid content - expected string: ${schema}.${table}/${id}`) + } + + const embedding = await generateEmbedding(row.content) + + await sql` + update + ${sql(schema)}.${sql(table)} + set + ${sql(embeddingColumn)} = ${JSON.stringify(embedding)} + where + id = ${id} + ` + + await sql` + select pgmq.delete(${QUEUE_NAME}, ${jobId}::bigint) + ` +} + +/** + * Returns a promise that rejects if the worker is terminating. + */ +function catchUnload() { + return new Promise((reject) => { + addEventListener('beforeunload', (ev: any) => { + reject(new Error(ev.detail?.reason)) + }) + }) +} +``` + +The Edge Function listens for incoming HTTP requests from `pg_net` and processes each embedding job. It is a generic worker that can handle embedding jobs for any table and column. It uses OpenAI's API to generate embeddings and updates the corresponding row in the database. It also deletes the job from the queue once it has been processed. + +The function is designed to process multiple jobs independently. If one job fails, it will not affect the processing of other jobs. The function returns a `200 OK` response with a list of completed and failed jobs. We can use this information to diagnose failed jobs. See [Troubleshooting](#troubleshooting) for more details. + +You will need to set the `OPENAI_API_KEY` environment variable to authenticate with OpenAI. When running the Edge Function locally, you can add it to a `.env` file: + +_.env_: + +``` +OPENAI_API_KEY=your-api-key +``` + +When you're ready to deploy the Edge Function, set can set the environment variable using the Supabase CLI: + +```shell +supabase secrets set --env-file .env +``` + +or + +```shell +supabase secrets set OPENAI_API_KEY= +``` + +Alternatively, you can replace the `generateEmbedding` function with your own embedding generation logic. + +See [Deploy to Production](/docs/guides/functions/deploy) for more information on how to deploy the Edge Function. + +## Usage + +Now that the infrastructure is in place, let's go through an example of how to use this system to automatically generate embeddings for a table of documents. You can use this approach with multiple tables and customize the input for each embedding generation as needed. + +### 1. Create table to store documents with embeddings + +We'll set up a new `documents` table that will store our content and embeddings: + +```sql +-- Table to store documents with embeddings +create table documents ( + id integer primary key generated always as identity, + title text not null, + content text not null, + embedding halfvec, + created_at timestamp with time zone default now() +); + +-- Index for vector search over document embeddings +create index on documents using hnsw (embedding halfvec_cosine_ops); +``` + +Our `documents` table stores the title and content of each document along with its vector embedding. We use a `halfvec` column to store the embeddings, which is a `pgvector` data type that stores float values in half precision (16 bits) to save space. + +We use an [HNSW index](/docs/guides/ai/vector-indexes/hnsw-indexes) on the vector column. Note that we are choosing `halfvec_cosine_ops` as the index method, which means our future queries will need to use cosine distance (`<=>`) to find similar embeddings. Also note that HNSW indexes support a maximum of 4000 dimensions for `halfvec` vectors, so keep this in mind when choosing an embedding model. If your model generates embeddings with more than 4000 dimensions, you will need to reduce the dimensionality before indexing them. See [Matryoshka embeddings](/blog/matryoshka-embeddings) for a potential solution to shortening dimensions. + +Also note that the table must have a primary key column named `id` for our triggers to work correctly with the `util.queue_embeddings` function and for our Edge Function to update the correct row. + +### 2. Create triggers to enqueue embedding jobs + +Now we'll set up the triggers to enqueue embedding jobs whenever content is inserted or updated: + +```sql +-- Customize the input for embedding generation +-- e.g. Concatenate title and content with a markdown header +create or replace function embedding_input(doc documents) +returns text +language plpgsql +immutable +as $$ +begin + return '# ' || doc.title || E'\n\n' || doc.content; +end; +$$; + +-- Trigger for insert events +create trigger embed_documents_on_insert + after insert + on documents + for each row + execute function util.queue_embeddings('embedding_input', 'embedding'); + +-- Trigger for update events +create trigger embed_documents_on_update + after update of title, content -- must match the columns in embedding_input() + on documents + for each row + execute function util.queue_embeddings('embedding_input', 'embedding'); +``` + +We create 2 triggers: + +1. `embed_documents_on_insert`: Enqueues embedding jobs whenever new rows are inserted into the `documents` table. + +2. `embed_documents_on_update`: Enqueues embedding jobs whenever the `title` or `content` columns are updated in the `documents` table. + +Both of these triggers use the same `util.queue_embeddings` function that will queue the embedding jobs for processing. They accept 2 arguments: + +1. `embedding_input`: The name of the function that generates the input for embedding generation. This function allows you to customize the text input passed to the embedding model (e.g. concatenating the title and content). The function should accept a single row as input and return text. + +2. `embedding`: The name of the destination column where the embedding will be stored. + +Note that the update trigger only fires when the `title` or `content` columns are updated. This is to avoid unnecessary updates to the embedding column when other columns are updated. Make sure that these columns match the columns used in the `embedding_input` function. + +{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */} + +#### (Optional) Clearing embeddings on update + +Note that our trigger will enqueue new embedding jobs when content is updated, but it will not clear any existing embeddings. This means that an embedding can be temporarily out of sync with the content until the new embedding is generated and updated. + +If it is more important to have _accurate_ embeddings than _any_ embedding, you can add another trigger to clear the existing embedding until the new one is generated: + +```sql +-- Trigger to clear the embedding column on update +create trigger clear_document_embedding_on_update + before update of title, content -- must match the columns in embedding_input() + on documents + for each row + execute function util.clear_column('embedding'); +``` + +`util.clear_column` is a generic trigger function we created earlier that can be used to clear any column in a table. + +- It accepts the column name as an argument. This column must be nullable. +- It requires a `before` trigger with a `for each row` clause. +- It requires the `hstore` extension we created earlier. + +This example will clear the `embedding` column whenever the `title` or `content` columns are updated (note the `of title, content` clause). This ensures that the embedding is always in sync with the title and content, but it will result in temporary gaps in search results until the new embedding is generated. + +We intentionally use a `before` trigger because it allows us to modify the record before it's written to disk, avoiding an extra `update` statement that would be needed with an `after` trigger. + +### 3. Insert and update documents + +Let's insert a new document and update its content to see the embedding generation in action: + +```sql +-- Insert a new document +insert into documents (title, content) +values + ('Understanding Vector Databases', 'Vector databases are specialized...'); + +-- Immediately check the embedding column +select id, embedding +from documents +where title = 'Understanding Vector Databases'; +``` + +You should observe that the `embedding` column is initially `null` after inserting the document. This is because the embedding generation is asynchronous and will be processed by the Edge Function in the next scheduled task. + +Wait up to 10 seconds for the next task to run, then check the `embedding` column again: + +```sql +select id, embedding +from documents +where title = 'Understanding Vector Databases'; +``` + +You should see the generated embedding for the document. + +Next let's update the content of the document: + +```sql +-- Update the content of the document +update documents +set content = 'Vector databases allow you to query...' +where title = 'Understanding Vector Databases'; + +-- Immediately check the embedding column +select id, embedding +from documents +where title = 'Understanding Vector Databases'; +``` + +You should observe that the `embedding` column is reset to `null` after updating the content. This is because of the trigger we added to clear existing embeddings whenever the content is updated. The embedding will be regenerated by the Edge Function in the next scheduled task. + +Wait up to 10 seconds for the next task to run, then check the `embedding` column again: + +```sql +select id, embedding +from documents +where title = 'Understanding Vector Databases'; +``` + +You should see the updated embedding for the document. + +Finally we'll update the title of the document: + +```sql +-- Update the title of the document +update documents +set title = 'Understanding Vector Databases with Supabase' +where title = 'Understanding Vector Databases'; +``` + +You should observe that the `embedding` column is once again reset to `null` after updating the title. This is because the trigger we added to clear existing embeddings fires when either the `content` or `title` columns are updated. The embedding will be regenerated by the Edge Function in the next scheduled task. + +Wait up to 10 seconds for the next task to run, then check the `embedding` column again: + +```sql +select id, embedding +from documents +where title = 'Understanding Vector Databases with Supabase'; +``` + +You should see the updated embedding for the document. + +## Troubleshooting + +The `embed` Edge Function processes a batch of embedding jobs and returns a `200 OK` response with a list of completed and failed jobs in the body. For example: + +```json +{ + "completedJobs": [ + { + "jobId": "1", + "id": "1", + "schema": "public", + "table": "documents", + "contentFunction": "embedding_input", + "embeddingColumn": "embedding" + } + ], + "failedJobs": [ + { + "jobId": "2", + "id": "2", + "schema": "public", + "table": "documents", + "contentFunction": "embedding_input", + "embeddingColumn": "embedding", + "error": "error connecting to openai api" + } + ] +} +``` + +It also returns the number of completed and failed jobs in the response headers. For example: + +``` +X-Completed-Jobs: 1 +X-Failed-Jobs: 1 +``` + +You can also use the `X-Deno-Execution-Id` header to trace the execution of the Edge Function within the [dashboard](/dashboard/project/_/functions) logs. + +Each failed job includes an `error` field with a description of the failure. Reasons for a job failing could include: + +- An error generating the embedding via external API +- An error connecting to the database +- The edge function being terminated (e.g. due to a wall clock limit) +- Any other error thrown during processing + +`pg_net` stores HTTP responses in the `net._http_response` table, which can be queried to diagnose issues with the embedding generation process. + +```sql +select + * +from + net._http_response +where + (headers->>'X-Failed-Jobs')::int > 0; +``` + +## Conclusion + +Automating embedding generation and updates in Postgres allow you to build powerful semantic search capabilities without the complexity of managing embeddings manually. + +By combining Postgres features like triggers, queues, and other extensions with Supabase Edge Functions, we can create a robust system that handles embedding generation asynchronously and retries failed jobs automatically. + +This system can be customized to work with any content and embedding generation service, providing a flexible and scalable solution for semantic search in Postgres. + +## See also + +- [What are embeddings?](/docs/guides/ai/concepts) +- [Semantic search](/docs/guides/ai/semantic-search) +- [Vector indexes](/docs/guides/ai/vector-indexes) +- [Supabase Edge Functions](/docs/guides/functions) diff --git a/apps/docs/content/guides/api/automatic-retries-in-supabase-js.mdx b/apps/docs/content/guides/api/automatic-retries-in-supabase-js.mdx new file mode 100644 index 00000000000..e9cfbe80eaf --- /dev/null +++ b/apps/docs/content/guides/api/automatic-retries-in-supabase-js.mdx @@ -0,0 +1,118 @@ +--- +id: automatic-retries-in-supabase-js +title: 'How to do automatic retries with `supabase-js`' +subtitle: 'Learn how to add automatic retries to your Supabase API requests using `fetch-retry`.' +--- + + + +You should only enable retries if your requests fail with network errors (e.g. 520 status from Cloudflare). A high number of retries have the potential to exhaust the Data API connection pool, which could result in lower throughput and failed requests. + + + +The `fetch-retry` package allows you to add retry logic to `fetch` requests, making it a useful tool for enhancing the resilience of API calls in your Supabase applications. Here's a step-by-step guide on how to integrate `fetch-retry` with the `supabase-js` library. + +## 1. Install dependencies + +To get started, ensure you have both `supabase-js` and `fetch-retry` installed in your project: + +```bash +npm install @supabase/supabase-js fetch-retry +``` + +## 2. Wrap the fetch function + +The `fetch-retry` package works by wrapping the native `fetch` function. You can create a custom fetch instance with retry logic and pass it to the `supabase-js` client. + +```javascript +import { createClient } from '@supabase/supabase-js' +import fetchRetry from 'fetch-retry' + +// Wrap the global fetch with fetch-retry +const fetchWithRetry = fetchRetry(fetch) + +// Create a Supabase client instance with the custom fetch +const supabase = createClient('https://your-supabase-url.supabase.co', 'your-anon-key', { + global: { + fetch: fetchWithRetry, + }, +}) +``` + +## 3. Configure retry options + +You can configure `fetch-retry` options to control retry behavior, such as the number of retries, retry delay, and which errors should trigger a retry. + +Here is an example with custom retry options: + +```javascript +const fetchWithRetry = fetchRetry(fetch, { + retries: 3, // Number of retry attempts + retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30000), // Exponential backoff + retryOn: [520], // Retry only on Cloudflare errors +}) +``` + +In this example, the `retryDelay` function implements an exponential backoff strategy, and retries are triggered only for specific HTTP status codes. + +## 4. Using the Supabase client + +With `fetch-retry` integrated, you can use the Supabase client as usual. The retry logic will automatically apply to all network requests made by `supabase-js`. + +```javascript +async function fetchData() { + const { data, error } = await supabase.from('your_table').select('*') + + if (error) { + console.error('Error fetching data:', error) + } else { + console.log('Fetched data:', data) + } +} + +fetchData() +``` + +## 5. Fine-Tuning retries for specific requests + +If you need different retry logic for certain requests, you can use the `retryOn` with a custom function to inspect the URL or response and decide whether to retry the request. + +```javascript +const fetchWithRetry = fetchRetry(fetch, { + retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30000), + retryOn: (attempt, error, response) => { + const shouldRetry + = (attempt: number, error: Error | null, response: Response | null) => + attempt < 3 + && response + && response.status == 520 // Cloudflare errors + && response.url.includes('rpc/your_stored_procedure') + + if (shouldRetry(attempt, error, response)) { + console.log(`Retrying request... Attempt #${attempt}`, response) + return true + } + + return false + } +}) + +async function yourStoredProcedure() { + const { data, error } = await supabase + .rpc('your_stored_procedure', { param1: 'value1' }); + + if (error) { + console.log('Error executing RPC:', error); + } else { + console.log('Response:', data); + } +} + +yourStoredProcedure(); +``` + +By using `retryOn` with a custom function, you can define specific conditions for retrying requests. In this example, the retry logic is applied only to requests targeting a specific stored procedure. + +## Conclusion + +Integrating `fetch-retry` with `supabase-js` is a straightforward way to add robustness to your Supabase API requests. By handling transient errors and implementing retry strategies, you can improve the reliability of your application while maintaining a seamless user experience. diff --git a/apps/docs/content/guides/auth/quickstarts/nextjs.mdx b/apps/docs/content/guides/auth/quickstarts/nextjs.mdx index 6db4230d2ac..9bb001cb9af 100644 --- a/apps/docs/content/guides/auth/quickstarts/nextjs.mdx +++ b/apps/docs/content/guides/auth/quickstarts/nextjs.mdx @@ -72,7 +72,7 @@ hideToc: true Start the development server, go to http://localhost:3000 in a browser, and you should see the contents of `app/page.tsx`. - To sign up a new user, navigate to http://localhost:3000/login, and click `Sign Up Now`. + To sign up a new user, navigate to http://localhost:3000/sign-up, and click `Sign up`. *NOTE: .env.example must be renamed to .env.local before this route becomes available* diff --git a/apps/docs/content/guides/database/prisma.mdx b/apps/docs/content/guides/database/prisma.mdx index 737ce4f6e01..dbb1606d7d3 100644 --- a/apps/docs/content/guides/database/prisma.mdx +++ b/apps/docs/content/guides/database/prisma.mdx @@ -98,11 +98,11 @@ If you plan to solely use Prisma instead of the Supabase Data API (PostgREST), t ```bash yarn init -y - yarn install prisma typescript ts-node @types/node --save-dev + yarn add prisma typescript ts-node @types/node --save-dev - yarn dlx tsc --init + npx tsc --init - yarn dlx prisma init + npx prisma init ``` @@ -241,7 +241,7 @@ If you plan to solely use Prisma instead of the Supabase Data API (PostgREST), t ```bash - yarn dlx prisma migrate dev --name first_prisma_migration + npx prisma migrate dev --name first_prisma_migration ``` @@ -318,7 +318,7 @@ If you plan to solely use Prisma instead of the Supabase Data API (PostgREST), t ```bash - yarn dlx prisma db pull + npx prisma db pull ``` Create a migration file @@ -328,7 +328,7 @@ If you plan to solely use Prisma instead of the Supabase Data API (PostgREST), t Synchronize the migrations ```bash - yarn dlx prisma migrate diff \ + npx prisma migrate diff \ --from-empty \ --to-schema-datamodel prisma/schema.prisma \ --script > prisma/migrations/0_init_supabase/migration.sql @@ -338,7 +338,7 @@ If you plan to solely use Prisma instead of the Supabase Data API (PostgREST), t ```bash - yarn dlx prisma migrate resolve --applied 0_init_supabase + npx prisma migrate resolve --applied 0_init_supabase ``` @@ -403,8 +403,8 @@ If you plan to solely use Prisma instead of the Supabase Data API (PostgREST), t ```sh - yarn install @prisma/client - yarn dlx prisma generate + yarn add @prisma/client + npx prisma generate ``` diff --git a/apps/docs/content/guides/platform/fly-postgres.mdx b/apps/docs/content/guides/platform/fly-postgres.mdx index cc9690c3268..47f96ab8ad5 100644 --- a/apps/docs/content/guides/platform/fly-postgres.mdx +++ b/apps/docs/content/guides/platform/fly-postgres.mdx @@ -6,7 +6,7 @@ description: 'Deploy Supabase projects on the Fly.io platform' Fly Postgres databases are deployed on the Fly.io edge network. Fly Postgres is supported in [every region](https://fly.io/docs/reference/regions/) where Fly.io operates. - + Fly Postgres is being deprecated on March 14, 2025. Head over to the [announcement](https://github.com/orgs/supabase/discussions/33413) for more information. diff --git a/apps/docs/features/docs/GuidesMdx.template.tsx b/apps/docs/features/docs/GuidesMdx.template.tsx index 7e5bf96871e..023a6775c02 100644 --- a/apps/docs/features/docs/GuidesMdx.template.tsx +++ b/apps/docs/features/docs/GuidesMdx.template.tsx @@ -1,6 +1,7 @@ import { ExternalLink } from 'lucide-react' import { type SerializeOptions } from 'next-mdx-remote/dist/types' import { type ReactNode } from 'react' +import ReactMarkdown from 'react-markdown' import { cn } from 'ui' @@ -78,9 +79,13 @@ const GuideTemplate = ({ meta, content, children, editLink, mdxOptions }: GuideT id="sb-docs-guide-main-article" className="prose max-w-none" > -

{meta?.title || 'Supabase Docs'}

+

+ {meta?.title || 'Supabase Docs'} +

{meta?.subtitle && ( -

{meta.subtitle}

+

+ {meta.subtitle} +

)}
diff --git a/apps/docs/features/docs/GuidesMdx.utils.tsx b/apps/docs/features/docs/GuidesMdx.utils.tsx index 04c56ce7dd2..6fbb0858829 100644 --- a/apps/docs/features/docs/GuidesMdx.utils.tsx +++ b/apps/docs/features/docs/GuidesMdx.utils.tsx @@ -89,7 +89,7 @@ const getGuidesMarkdownInternal = async (slug: string[]) => { const getGuidesMarkdown = cache_fullProcess_withDevCacheBust( getGuidesMarkdownInternal, GUIDES_DIRECTORY, - (filename: string) => JSON.stringify([{ slug: filename.replace(/\.mdx$/, '').split(sep) }]) + (filename: string) => JSON.stringify([filename.replace(/\.mdx$/, '').split(sep)]) ) const genGuidesStaticParams = (directory?: string) => async () => { diff --git a/apps/docs/spec/api_v1_openapi.json b/apps/docs/spec/api_v1_openapi.json index cee17c87cd1..c3ef4092d80 100644 --- a/apps/docs/spec/api_v1_openapi.json +++ b/apps/docs/spec/api_v1_openapi.json @@ -4501,7 +4501,7 @@ "type": "string", "enum": ["internal", "alpha", "beta", "ga", "withdrawn", "preview"] }, - "postgres_engine": { "type": "string", "enum": ["13", "14", "15", "17-oriole"] } + "postgres_engine": { "type": "string", "enum": ["13", "14", "15", "17", "17-oriole"] } }, "required": ["version", "release_channel", "postgres_engine"] }, diff --git a/apps/docs/spec/cli_v1_commands.yaml b/apps/docs/spec/cli_v1_commands.yaml index 6715e529876..63e8b0ed8e5 100644 --- a/apps/docs/spec/cli_v1_commands.yaml +++ b/apps/docs/spec/cli_v1_commands.yaml @@ -1,7 +1,7 @@ clispec: '001' info: id: cli - version: 2.9.6 + version: 2.12.0 title: Supabase CLI language: sh source: https://github.com/supabase/cli @@ -3006,7 +3006,7 @@ commands: Linked project is up to date. - id: self-hosted name: Self hosted - code: supabase db push --db-url "postgres://user:pass@127.0.0.1:5432/postgres" + code: supabase db push --db-url "postgres://user.tenant:pass@127.0.0.1:5432" response: | Pushing migration 20230410135622_create_employees_table.sql... Finished supabase db push. diff --git a/apps/docs/spec/supabase_kt_v3.yml b/apps/docs/spec/supabase_kt_v3.yml index b375a697e46..970fc1229bb 100644 --- a/apps/docs/spec/supabase_kt_v3.yml +++ b/apps/docs/spec/supabase_kt_v3.yml @@ -2883,7 +2883,7 @@ functions: isSpotlight: true code: | ```kotlin - val supabase = createSupabaseClient(supabaseURL = "https://xyzcompany.supabase.co'", supabaseKey = "public-anon-key") { ... } + val supabase = createSupabaseClient(supabaseUrl = "https://xyzcompany.supabase.co'", supabaseKey = "public-anon-key") { ... } val auth = supabase.auth ``` - id: sign-up diff --git a/apps/docs/spec/transforms/api_v1_openapi_deparsed.json b/apps/docs/spec/transforms/api_v1_openapi_deparsed.json index 62ea0ee1c78..ee6a05a7eb3 100644 --- a/apps/docs/spec/transforms/api_v1_openapi_deparsed.json +++ b/apps/docs/spec/transforms/api_v1_openapi_deparsed.json @@ -8234,7 +8234,7 @@ }, "postgres_engine": { "type": "string", - "enum": ["13", "14", "15", "17-oriole"] + "enum": ["13", "14", "15", "17", "17-oriole"] } }, "required": ["version", "release_channel", "postgres_engine"] @@ -14521,7 +14521,7 @@ }, "postgres_engine": { "type": "string", - "enum": ["13", "14", "15", "17-oriole"] + "enum": ["13", "14", "15", "17", "17-oriole"] } }, "required": ["version", "release_channel", "postgres_engine"] @@ -14543,7 +14543,7 @@ }, "postgres_engine": { "type": "string", - "enum": ["13", "14", "15", "17-oriole"] + "enum": ["13", "14", "15", "17", "17-oriole"] } }, "required": ["version", "release_channel", "postgres_engine"] diff --git a/apps/docs/tailwind.config.js b/apps/docs/tailwind.config.js index eb86da3cc96..176e4401cee 100644 --- a/apps/docs/tailwind.config.js +++ b/apps/docs/tailwind.config.js @@ -10,7 +10,8 @@ module.exports = config({ './layouts/**/*.tsx', './pages/**/*.{tsx,mdx}', './../../packages/ui/src/**/*.{tsx,ts,js}', - './../../packages/ui-patterns/!(node_modules)/**/*.{tsx,ts,js}', + './../../packages/ui-patterns/**/*.{tsx,ts,js}', + '!./../../packages/ui-patterns/node_modules/**/*', // ignore node_modules rule ], plugins: [ function ({ addUtilities, addVariant }) { diff --git a/apps/studio/components/interfaces/Auth/Auth.constants.ts b/apps/studio/components/interfaces/Auth/Auth.constants.ts index a970de63739..27458458bc6 100644 --- a/apps/studio/components/interfaces/Auth/Auth.constants.ts +++ b/apps/studio/components/interfaces/Auth/Auth.constants.ts @@ -41,3 +41,6 @@ export function urlRegex( 'i' ) } + +// Use a const string to represent no chars option. Represented as empty string on the backend side. +export const NO_REQUIRED_CHARACTERS = 'NO_REQUIRED_CHARS' diff --git a/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx b/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx index 00f1850f6aa..98f1e2fd379 100644 --- a/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx +++ b/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx @@ -7,6 +7,7 @@ import ReactMarkdown from 'react-markdown' import { toast } from 'sonner' import { useParams } from 'common' +import { Markdown } from 'components/interfaces/Markdown' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { DocsButton } from 'components/ui/DocsButton' import { ResourceItem } from 'components/ui/Resource/ResourceItem' @@ -30,10 +31,10 @@ import { SheetTitle, WarningIcon, } from 'ui' +import { Admonition } from 'ui-patterns' +import { NO_REQUIRED_CHARACTERS } from '../Auth.constants' import type { Provider } from './AuthProvidersForm.types' import FormField from './FormField' -import { Markdown } from 'components/interfaces/Markdown' -import { Admonition } from 'ui-patterns' export interface ProviderFormProps { config: components['schemas']['GoTrueConfigResponse'] @@ -154,6 +155,11 @@ const ProviderForm = ({ config, provider, isActive }: ProviderFormProps) => { if (payload[x] === '') payload[x] = null }) + // The backend uses empty string to represent no required characters in the password + if (payload.PASSWORD_REQUIRED_CHARACTERS === NO_REQUIRED_CHARACTERS) { + payload.PASSWORD_REQUIRED_CHARACTERS = '' + } + updateAuthConfig( { projectRef: projectRef!, config: payload }, { diff --git a/apps/studio/components/interfaces/Auth/AuthProvidersFormValidation.tsx b/apps/studio/components/interfaces/Auth/AuthProvidersFormValidation.tsx index 7b695478ac4..9cd4c2e2ca7 100644 --- a/apps/studio/components/interfaces/Auth/AuthProvidersFormValidation.tsx +++ b/apps/studio/components/interfaces/Auth/AuthProvidersFormValidation.tsx @@ -1,6 +1,6 @@ -import { boolean, number, object, string } from 'yup' -import { urlRegex } from 'components/interfaces/Auth/Auth.constants' +import { NO_REQUIRED_CHARACTERS, urlRegex } from 'components/interfaces/Auth/Auth.constants' import { ProjectAuthConfigData } from 'data/auth/auth-config-query' +import { boolean, number, object, string } from 'yup' const parseBase64URL = (b64url: string) => { return atob(b64url.replace(/[-]/g, '+').replace(/[_]/g, '/')) @@ -56,7 +56,7 @@ const PROVIDER_EMAIL = { enum: [ { label: 'No required characters (default)', - value: 'NO_REQUIRED_CHARS', + value: NO_REQUIRED_CHARACTERS, }, { label: 'Letters and digits', diff --git a/apps/studio/components/interfaces/Auth/BasicAuthSettingsForm/BasicAuthSettingsForm.tsx b/apps/studio/components/interfaces/Auth/BasicAuthSettingsForm/BasicAuthSettingsForm.tsx index 88698ee2389..be7b7491eb3 100644 --- a/apps/studio/components/interfaces/Auth/BasicAuthSettingsForm/BasicAuthSettingsForm.tsx +++ b/apps/studio/components/interfaces/Auth/BasicAuthSettingsForm/BasicAuthSettingsForm.tsx @@ -10,6 +10,10 @@ import { yupResolver } from '@hookform/resolvers/yup' import { useParams } from 'common' import { Markdown } from 'components/interfaces/Markdown' import { FormFieldWrapper } from 'components/ui/Forms' +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 NoPermission from 'components/ui/NoPermission' import { useAuthConfigQuery } from 'data/auth/auth-config-query' import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation' @@ -30,8 +34,7 @@ import { } from 'ui' import { SectionHeader } from 'components/layouts/PageLayout' -// Use a const string to represent no chars option. Represented as empty string on the backend side. -const NO_REQUIRED_CHARACTERS = 'NO_REQUIRED_CHARS' +import { NO_REQUIRED_CHARACTERS } from '../Auth.constants' const schema = object({ DISABLE_SIGNUP: boolean().required(), diff --git a/apps/studio/components/interfaces/Auth/ProtectionAuthSettingsForm/ProtectionAuthSettingsForm.tsx b/apps/studio/components/interfaces/Auth/ProtectionAuthSettingsForm/ProtectionAuthSettingsForm.tsx index a53f0d0d08a..ce1aad4abbc 100644 --- a/apps/studio/components/interfaces/Auth/ProtectionAuthSettingsForm/ProtectionAuthSettingsForm.tsx +++ b/apps/studio/components/interfaces/Auth/ProtectionAuthSettingsForm/ProtectionAuthSettingsForm.tsx @@ -6,15 +6,13 @@ import { boolean, number, object, string } from 'yup' import { useParams } from 'common' 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 NoPermission from 'components/ui/NoPermission' import { useAuthConfigQuery } from 'data/auth/auth-config-query' import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation' -import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { IS_PLATFORM } from 'lib/constants' import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, @@ -25,11 +23,8 @@ import { Toggle, WarningIcon, } from 'ui' +import { NO_REQUIRED_CHARACTERS } from '../Auth.constants' import FormField from '../AuthProvidersForm/FormField' -import { FormHeader } from 'components/ui/Forms/FormHeader' - -// Use a const string to represent no chars option. Represented as empty string on the backend side. -const NO_REQUIRED_CHARACTERS = 'NO_REQUIRED_CHARS' const schema = object({ DISABLE_SIGNUP: boolean().required(), diff --git a/apps/studio/components/interfaces/Connect/ConnectionPanel.tsx b/apps/studio/components/interfaces/Connect/ConnectionPanel.tsx index bce5602468d..807c47a7724 100644 --- a/apps/studio/components/interfaces/Connect/ConnectionPanel.tsx +++ b/apps/studio/components/interfaces/Connect/ConnectionPanel.tsx @@ -1,6 +1,10 @@ import { ChevronRight, FileCode, X } from 'lucide-react' import Link from 'next/link' +import { useParams } from 'common' +import Panel from 'components/ui/Panel' +import { usePoolingConfigurationQuery } from 'data/database/pooling-configuration-query' +import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' import { Button, cn, @@ -11,6 +15,7 @@ import { CollapsibleTrigger_Shadcn_, WarningIcon, } from 'ui' +import { Admonition } from 'ui-patterns' import { ConnectionParameters } from './ConnectionParameters' import { DirectConnectionIcon, TransactionIcon } from './PoolerIcons' @@ -105,6 +110,13 @@ export const ConnectionPanel = ({ fileTitle, onCopyCallback, }: ConnectionPanelProps) => { + const { ref: projectRef } = useParams() + const state = useDatabaseSelectorStateSnapshot() + + const { data: poolingInfo } = usePoolingConfigurationQuery({ projectRef }) + const poolingConfiguration = poolingInfo?.find((x) => x.identifier === state.selectedDatabaseId) + const isSessionMode = poolingConfiguration?.pool_mode === 'session' + return (
@@ -112,27 +124,59 @@ export const ConnectionPanel = ({

{description}

{fileTitle && } - - {notice && ( -
- {notice?.map((text: string) => ( -

- {text} -

- ))} -
+ {type === 'transaction' && isSessionMode ? ( + + + + ) : ( + <> + {/* [Joshen] Can probably remove this after Feb 28 */} + {type === 'session' && isSessionMode && ( + + )} + + {notice && ( +
+ {notice?.map((text: string) => ( +

+ {text} +

+ ))} +
+ )} + {parameters.length > 0 && } + )} - {parameters.length > 0 && }
diff --git a/apps/studio/components/interfaces/Connect/DatabaseConnectionString.tsx b/apps/studio/components/interfaces/Connect/DatabaseConnectionString.tsx index 357ad0f78af..9e3817a5ff2 100644 --- a/apps/studio/components/interfaces/Connect/DatabaseConnectionString.tsx +++ b/apps/studio/components/interfaces/Connect/DatabaseConnectionString.tsx @@ -11,6 +11,7 @@ import { usePoolingConfigurationQuery } from 'data/database/pooling-configuratio import { useReadReplicasQuery } from 'data/read-replicas/replicas-query' import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' +import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { pluckObjectFields } from 'lib/helpers' import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' import { @@ -36,7 +37,6 @@ import { import { CodeBlockFileHeader, ConnectionPanel } from './ConnectionPanel' import { getConnectionStrings } from './DatabaseSettings.utils' import examples, { Example } from './DirectConnectionExamples' -import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' const StepLabel = ({ number, diff --git a/apps/studio/components/interfaces/DataWarehouse/CollectionForm.tsx b/apps/studio/components/interfaces/DataWarehouse/CollectionForm.tsx index 98543751db2..5bb7e906d59 100644 --- a/apps/studio/components/interfaces/DataWarehouse/CollectionForm.tsx +++ b/apps/studio/components/interfaces/DataWarehouse/CollectionForm.tsx @@ -127,7 +127,9 @@ export const CollectionForm = ({ className="mt-2" type="primary" onClick={() => - router.push(`/org/${currentOrg?.slug}/billing?panel=subscriptionPlan`) + router.push( + `/org/${currentOrg?.slug}/billing?panel=subscriptionPlan&source=dataWarehouseIncreaseRetention` + ) } > Upgrade plan diff --git a/apps/studio/components/interfaces/Database/Backups/BackupsList.tsx b/apps/studio/components/interfaces/Database/Backups/BackupsList.tsx index 48a962d8840..ac58b65c35b 100644 --- a/apps/studio/components/interfaces/Database/Backups/BackupsList.tsx +++ b/apps/studio/components/interfaces/Database/Backups/BackupsList.tsx @@ -62,6 +62,7 @@ const BackupsList = () => { icon={} primaryText="Free Plan does not include project backups." secondaryText="Upgrade to the Pro Plan for up to 7 days of scheduled backups." + source="backups" /> ) } diff --git a/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx b/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx index 706c526cfd5..d404c52701a 100644 --- a/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx +++ b/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx @@ -1,18 +1,20 @@ import type { PostgresSchema } from '@supabase/postgres-meta' import { Loader2 } from 'lucide-react' import { useTheme } from 'next-themes' -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo } from 'react' import ReactFlow, { Background, BackgroundVariant, MiniMap, useReactFlow } from 'reactflow' import 'reactflow/dist/style.css' import { useParams } from 'common' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState' import AlertError from 'components/ui/AlertError' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import SchemaSelector from 'components/ui/SchemaSelector' import { useSchemasQuery } from 'data/database/schemas-query' import { useTablesQuery } from 'data/tables/tables-query' import { useLocalStorage } from 'hooks/misc/useLocalStorage' +import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' import { LOCAL_STORAGE_KEYS } from 'lib/constants' import { SchemaGraphLegend } from './SchemaGraphLegend' import { getGraphDataFromTables, getLayoutedElementsViaDagre } from './Schemas.utils' @@ -24,7 +26,7 @@ export const SchemaGraph = () => { const { ref } = useParams() const { resolvedTheme } = useTheme() const { project } = useProjectContext() - const [selectedSchema, setSelectedSchema] = useState('public') + const { selectedSchema, setSelectedSchema } = useQuerySchemaState() const miniMapNodeColor = '#111318' const miniMapMaskColor = resolvedTheme?.includes('dark') @@ -147,42 +149,58 @@ export const SchemaGraph = () => {
)} {isSuccessTables && ( -
- saveNodePositions()} - > - - - - -
+ <> + {tables.length === 0 ? ( +
+ +

+ There are no tables found in the schema "{selectedSchema}" +

+
+
+ ) : ( +
+ saveNodePositions()} + > + + + + +
+ )} + )} ) diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx b/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx index 84a224ed922..784bc7e930a 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx @@ -314,7 +314,11 @@ export function DiskManagementForm() { title="Compute and Disk configuration is not available on the Free Plan" actions={ } description="You will need to upgrade to at least the Pro Plan to configure compute and disk" diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelForm.tsx b/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelForm.tsx index 7f42ba51bce..a0c7d66194c 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelForm.tsx +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelForm.tsx @@ -316,7 +316,7 @@ export function DiskManagementPanelForm() { Upgrade plan diff --git a/apps/studio/components/interfaces/Docs/Introduction.tsx b/apps/studio/components/interfaces/Docs/Introduction.tsx index ae8dd1b4f9d..18ee355957d 100644 --- a/apps/studio/components/interfaces/Docs/Introduction.tsx +++ b/apps/studio/components/interfaces/Docs/Introduction.tsx @@ -15,7 +15,7 @@ export default function Introduction({ selectedLang }: Props) { const { ref: projectRef } = useParams() const { data: settings } = useProjectSettingsV2Query({ projectRef }) - const { data: config } = useProjectPostgrestConfigQuery({ projectRef }) + const { data: config, isSuccess } = useProjectPostgrestConfigQuery({ projectRef }) const protocol = settings?.app_config?.protocol ?? 'https' const hostEndpoint = settings?.app_config?.endpoint @@ -42,9 +42,9 @@ export default function Introduction({ selectedLang }: Props) { the easiest way to interact with everything we offer within the Supabase ecosystem.

-
+
- {!isPublicSchemaEnabled && } + {isSuccess && !isPublicSchemaEnabled && }
diff --git a/apps/studio/components/interfaces/Docs/Pages/Tables/Introduction.tsx b/apps/studio/components/interfaces/Docs/Pages/Tables/Introduction.tsx index d3116933cb6..5a7ad40d0f1 100644 --- a/apps/studio/components/interfaces/Docs/Pages/Tables/Introduction.tsx +++ b/apps/studio/components/interfaces/Docs/Pages/Tables/Introduction.tsx @@ -13,7 +13,7 @@ interface IntroductionProps { const Introduction = ({ selectedLang }: IntroductionProps) => { const { ref: projectRef } = useParams() - const { data: config } = useProjectPostgrestConfigQuery({ projectRef }) + const { data: config, isSuccess } = useProjectPostgrestConfigQuery({ projectRef }) const isPublicSchemaEnabled = config?.db_schema .split(',') @@ -24,13 +24,14 @@ const Introduction = ({ selectedLang }: IntroductionProps) => { <>

Introduction

-
+

All views and tables in the public schema and accessible by the active database role for a request are available for querying.

- - {!isPublicSchemaEnabled && } +
+
+ {isSuccess && !isPublicSchemaEnabled && }
diff --git a/apps/studio/components/interfaces/Docs/PublicSchemaNotEnabledAlert.tsx b/apps/studio/components/interfaces/Docs/PublicSchemaNotEnabledAlert.tsx index 99be76b020f..15d2a8b505c 100644 --- a/apps/studio/components/interfaces/Docs/PublicSchemaNotEnabledAlert.tsx +++ b/apps/studio/components/interfaces/Docs/PublicSchemaNotEnabledAlert.tsx @@ -1,36 +1,28 @@ -import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button } from 'ui' -import { Info } from 'lucide-react' -import ReactMarkdown from 'react-markdown' import Link from 'next/link' + import { useParams } from 'common' +import { Button } from 'ui' +import { Admonition } from 'ui-patterns' const PublicSchemaNotEnabledAlert = () => { const { ref: projectRef } = useParams() return ( - - - - The `public` schema for this project is not exposed - - - - You will not be able to query tables and views in the `public` schema via supabase-js or - HTTP clients. - - -
- -
-
-
+ +

The public schema for this project is not exposed

+

+ You will not be able to query tables and views in the public schema via supabase-js or HTTP + clients. Configure this behavior in your project's Data API settings. +

+ +
) } diff --git a/apps/studio/components/interfaces/Organization/AuditLogs/AuditLogs.tsx b/apps/studio/components/interfaces/Organization/AuditLogs/AuditLogs.tsx index 15b82f94d7d..5abde526b91 100644 --- a/apps/studio/components/interfaces/Organization/AuditLogs/AuditLogs.tsx +++ b/apps/studio/components/interfaces/Organization/AuditLogs/AuditLogs.tsx @@ -166,7 +166,7 @@ const AuditLogs = () => {
diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingMetric.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingMetric.tsx index e32f8ed77fd..110578487aa 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingMetric.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingMetric.tsx @@ -143,7 +143,11 @@ const BillingMetric = ({ ) : (
)} diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.tsx index 2e9a6b3504d..0ee67a41a87 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingEmail.tsx @@ -1,8 +1,11 @@ +import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useQueryClient } from '@tanstack/react-query' import { useEffect } from 'react' +import { useForm } from 'react-hook-form' import { toast } from 'sonner' +import { z } from 'zod' +import { Form, FormControl, FormField } from '@ui/components/shadcn/ui/form' import { useParams } from 'common' import { ScaffoldSection, @@ -12,29 +15,56 @@ import { import { FormActions } from 'components/ui/Forms/FormActions' import { FormPanel } from 'components/ui/Forms/FormPanel' import { FormSection, FormSectionContent } from 'components/ui/Forms/FormSection' +import NoPermission from 'components/ui/NoPermission' +import { useOrganizationCustomerProfileQuery } from 'data/organizations/organization-customer-profile-query' import { useOrganizationUpdateMutation } from 'data/organizations/organization-update-mutation' -import { invalidateOrganizationsQuery } from 'data/organizations/organizations-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { Form, Input } from 'ui' +import { FormMessage_Shadcn_, Input_Shadcn_ } from 'ui' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { InfoTooltip } from 'ui-patterns/info-tooltip' +import { + MultiSelector, + MultiSelectorContent, + MultiSelectorList, + MultiSelectorTrigger, +} from 'ui-patterns/multi-select' + +const FORM_ID = 'org-billing-email' +const formSchema = z.object({ + billingEmail: z.string().email('Please provide a valid email address').optional(), + additionalBillingEmails: z.string().email({ message: 'invalid_email' }).array().default([]), +}) const BillingEmail = () => { const { slug } = useParams() - const queryClient = useQueryClient() const selectedOrganization = useSelectedOrganization() - const { name, billing_email } = selectedOrganization ?? {} - const formId = 'org-billing-email' - const initialValues = { billing_email: billing_email ?? '' } + const { name, billing_email } = selectedOrganization ?? {} const canUpdateOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations') const canReadBillingEmail = useCheckPermissions( PermissionAction.BILLING_READ, 'stripe.subscriptions' ) + + const { data: billingCustomer, isLoading: loadingBillingCustomer } = + useOrganizationCustomerProfileQuery({ slug }, { enabled: canReadBillingEmail }) + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + billingEmail: billing_email ?? '', + additionalBillingEmails: billingCustomer?.additional_emails ?? [], + }, + }) + const { additionalBillingEmails } = form.watch() + const { errors } = form.formState + const additionalEmailsError = errors.additionalBillingEmails ?? [] + const { mutate: updateOrganization, isLoading: isUpdating } = useOrganizationUpdateMutation() - const onUpdateOrganizationEmail = async (values: any, { resetForm }: any) => { + const onUpdateOrganizationEmail = async (values: z.infer) => { if (!canUpdateOrganization) { return toast.error('You do not have the required permissions to update this organization') } @@ -45,19 +75,26 @@ const BillingEmail = () => { { slug, name, - billing_email: values.billing_email, + billing_email: values.billingEmail, + additional_billing_emails: values.additionalBillingEmails, }, { - onSuccess: ({ billing_email }) => { - resetForm({ values: { billing_email }, initialValues: { billing_email } }) - invalidateOrganizationsQuery(queryClient) + onSuccess: () => { toast.success('Successfully saved settings') + form.reset(values) }, } ) } - if (!canReadBillingEmail) return null + useEffect(() => { + if (billingCustomer) { + form.reset({ + billingEmail: billing_email, + additionalBillingEmails: billingCustomer.additional_emails ?? [], + }) + } + }, [billingCustomer]) return ( @@ -70,31 +107,23 @@ const BillingEmail = () => {
-
- {({ 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(() => { - const values = { billing_email: billing_email ?? '' } - resetForm({ values, initialValues: values }) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [slug]) - - return ( + {!canReadBillingEmail ? ( + + ) : ( + + @@ -102,20 +131,79 @@ const BillingEmail = () => { } > - - + ( + + + + + + + )} + /> + + ( + + Additional emails + + These email addresses will be CC'd in automated invoice or payment + failure emails. Payment receipts will still only go to the primary + billing address. + + + } + > + + + + + + + + + {/* [Joshen] Manually construct the message here as MultiSelector doesn't handle array errors from RHF atm */} + {Array.isArray(additionalEmailsError) && + additionalEmailsError.length > 0 && ( +
+ {additionalEmailsError.map((x, idx) => ( +

+ "{additionalBillingEmails[idx]}" is not a valid email address +

+ ))} +
+ )} +
+ )} />
- ) - }} - + + + )}
) diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Restriction.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Restriction.tsx index 4d5dd46d636..fce069a56d4 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Restriction.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Restriction.tsx @@ -97,7 +97,11 @@ export const Restriction = () => {

) : ( diff --git a/apps/studio/components/interfaces/Organization/Documents/SecurityQuestionnaire.tsx b/apps/studio/components/interfaces/Organization/Documents/SecurityQuestionnaire.tsx index 9aa74921ba5..28b6d1704c5 100644 --- a/apps/studio/components/interfaces/Organization/Documents/SecurityQuestionnaire.tsx +++ b/apps/studio/components/interfaces/Organization/Documents/SecurityQuestionnaire.tsx @@ -74,7 +74,9 @@ const SecurityQuestionnaire = () => { {isSuccess && (
{currentPlan?.id === 'free' || currentPlan?.id === 'pro' ? ( - + ) : ( diff --git a/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx b/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx index 6cc226b233d..c306c9f87d9 100644 --- a/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx +++ b/apps/studio/components/interfaces/Organization/NewOrg/NewOrgForm.tsx @@ -305,7 +305,10 @@ const NewOrgForm = ({ onPaymentMethodReset }: NewOrgFormProps) => {

The plan applies only to this new organization. To upgrade an existing organization,{' '} - + click here . diff --git a/apps/studio/components/interfaces/Organization/NewProject/FreeProjectLimitWarning.tsx b/apps/studio/components/interfaces/Organization/NewProject/FreeProjectLimitWarning.tsx index 0c4923d91fe..6411fbe4571 100644 --- a/apps/studio/components/interfaces/Organization/NewProject/FreeProjectLimitWarning.tsx +++ b/apps/studio/components/interfaces/Organization/NewProject/FreeProjectLimitWarning.tsx @@ -38,7 +38,11 @@ const FreeProjectLimitWarning = ({

diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx index 56b641157f8..f7f0a9d7e51 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx @@ -408,7 +408,9 @@ export const InviteMemberButton = () => { {isSuccessSubscription && (currentPlan?.id === 'free' || currentPlan?.id === 'pro') && ( diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx index af4c964549e..dc8b94ea948 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/MemberRow.tsx @@ -1,13 +1,13 @@ import { ArrowRight, Check, Minus, User, X } from 'lucide-react' -import Image from 'next/legacy/image' -import { useState } from 'react' import Table from 'components/to-be-cleaned/Table' import PartnerIcon from 'components/ui/PartnerIcon' +import { ProfileImage } from 'components/ui/ProfileImage' import { useOrganizationRolesV2Query } from 'data/organization-members/organization-roles-query' import { OrganizationMember } from 'data/organizations/organization-members-query' import { useProjectsQuery } from 'data/projects/projects-query' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' +import { getGitHubProfileImgUrl } from 'lib/github' import { useProfile } from 'lib/profile' import Link from 'next/link' import { @@ -33,7 +33,6 @@ const MEMBER_ORIGIN_TO_MANAGED_BY = { export const MemberRow = ({ member }: MemberRowProps) => { const { profile } = useProfile() const selectedOrganization = useSelectedOrganization() - const [hasInvalidImg, setHasInvalidImg] = useState(false) const { data: projects } = useProjectsQuery() const { data: roles, isLoading: isLoadingRoles } = useOrganizationRolesV2Query({ @@ -47,6 +46,9 @@ export const MemberRow = ({ member }: MemberRowProps) => { const isEmailUser = member.username === member.primary_email const isFlyUser = Boolean(member.primary_email?.endsWith('customer.fly.io')) + const profileImageUrl = + isInvitedUser || isEmailUser || isFlyUser ? undefined : getGitHubProfileImgUrl(member.username) + // [Joshen] From project role POV, mask any roles for other projects const isObfuscated = member.role_ids.filter((id) => { @@ -69,25 +71,22 @@ export const MemberRow = ({ member }: MemberRowProps) => { return ( -
-
- {isInvitedUser || isEmailUser || isFlyUser || hasInvalidImg ? ( -
+
+
- ) : ( - {member.username} { - setHasInvalidImg(true) - }} - /> - )} -
+ } + />
{isInvitedUser === undefined ? (

{member.primary_email}

diff --git a/apps/studio/components/interfaces/Organization/Usage/Usage.utils.ts b/apps/studio/components/interfaces/Organization/Usage/Usage.utils.ts index 62dcce33ae1..73feec66168 100644 --- a/apps/studio/components/interfaces/Organization/Usage/Usage.utils.ts +++ b/apps/studio/components/interfaces/Organization/Usage/Usage.utils.ts @@ -13,14 +13,14 @@ export const generateUsageData = (attribute: string, days: number): DataPoint[] }) } -export const getUpgradeUrl = (slug: string, subscription?: OrgSubscription) => { +export const getUpgradeUrl = (slug: string, subscription?: OrgSubscription, source?: string) => { if (!subscription) { return `/org/${slug}/billing` } return subscription?.plan?.id === 'pro' && subscription?.usage_billing_enabled === false ? `/org/${slug}/billing#cost-control` - : `/org/${slug}/billing?panel=subscriptionPlan` + : `/org/${slug}/billing?panel=subscriptionPlan&source=usage${source}` } const compactNumberFormatter = new Intl.NumberFormat('en-US', { diff --git a/apps/studio/components/interfaces/Organization/Usage/UsageSection/AttributeUsage.tsx b/apps/studio/components/interfaces/Organization/Usage/UsageSection/AttributeUsage.tsx index 517b5ae00f4..d48d114fed4 100644 --- a/apps/studio/components/interfaces/Organization/Usage/UsageSection/AttributeUsage.tsx +++ b/apps/studio/components/interfaces/Organization/Usage/UsageSection/AttributeUsage.tsx @@ -53,7 +53,7 @@ const AttributeUsage = ({ isSuccess, currentBillingCycleSelected, }: AttributeUsageProps) => { - const upgradeUrl = getUpgradeUrl(slug ?? '', subscription) + const upgradeUrl = getUpgradeUrl(slug ?? '', subscription, attribute.key) const usageRatio = (usageMeta?.usage ?? 0) / (usageMeta?.pricing_free_units ?? 0) const usageExcess = (usageMeta?.usage ?? 0) - (usageMeta?.pricing_free_units ?? 0) const usageBasedBilling = subscription?.usage_billing_enabled diff --git a/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.tsx b/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.tsx index ed45378c08c..585b5b9d3b0 100644 --- a/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.tsx +++ b/apps/studio/components/interfaces/QueryPerformance/QueryPerformance.tsx @@ -141,7 +141,7 @@ export const QueryPerformance = ({ tab.id !== QUERY_PERFORMANCE_REPORT_TYPES.MOST_FREQUENT ? tabMax > 1000 ? (tabMax / 1000).toFixed(2) - : tabMax.toLocaleString() + : tabMax.toFixed(0) : tabMax.toLocaleString() return ( @@ -180,7 +180,6 @@ export const QueryPerformance = ({ ) : ( - {/* {Number(tab.max).toLocaleString()} */} {maxValue} {tab.id !== QUERY_PERFORMANCE_REPORT_TYPES.MOST_FREQUENT ? tabMax > 1000 diff --git a/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx b/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx index 06d0bd6eefe..e38b84243c2 100644 --- a/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx +++ b/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx @@ -71,9 +71,7 @@ export const QueryPerformanceGrid = ({ queryPerformanceQuery }: QueryPerformance renderCell: (props) => { const value = props.row?.[col.id] const isTime = col.name.includes('time') - const formattedValue = isTime - ? `${Number(value.toFixed(2)).toLocaleString()}ms` - : value.toLocaleString() + const formattedValue = isTime ? `${value.toFixed(0)}ms` : value.toLocaleString() return (
{ title="Changing your compute size is only available on the Pro Plan" actions={ diff --git a/apps/studio/components/interfaces/Settings/Addons/CustomDomainSidePanel.tsx b/apps/studio/components/interfaces/Settings/Addons/CustomDomainSidePanel.tsx index efc429764c8..c3c66febde3 100644 --- a/apps/studio/components/interfaces/Settings/Addons/CustomDomainSidePanel.tsx +++ b/apps/studio/components/interfaces/Settings/Addons/CustomDomainSidePanel.tsx @@ -229,7 +229,9 @@ const CustomDomainSidePanel = () => { title="Custom domains are unavailable on the Free Plan" actions={ diff --git a/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx b/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx index 5958fdb3060..eaa3c0e1c42 100644 --- a/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx +++ b/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx @@ -222,7 +222,9 @@ const IPv4SidePanel = () => {

Upgrade your plan to enable a IPv4 address for your project

diff --git a/apps/studio/components/interfaces/Settings/Addons/PITRSidePanel.tsx b/apps/studio/components/interfaces/Settings/Addons/PITRSidePanel.tsx index 62b21a77f8f..474d6d8dbef 100644 --- a/apps/studio/components/interfaces/Settings/Addons/PITRSidePanel.tsx +++ b/apps/studio/components/interfaces/Settings/Addons/PITRSidePanel.tsx @@ -279,7 +279,9 @@ const PITRSidePanel = () => { title="Changing your Point-In-Time-Recovery is only available on the Pro Plan" actions={ diff --git a/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx b/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx index 379691b2bab..e1a5a7fc8d4 100644 --- a/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx +++ b/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx @@ -36,6 +36,7 @@ import { Listbox, Separator, } from 'ui' +import { Admonition } from 'ui-patterns' import { SESSION_MODE_DESCRIPTION, TRANSACTION_MODE_DESCRIPTION } from '../Database.constants' import { POOLING_OPTIMIZATIONS } from './ConnectionPooling.constants' import { useAuthConfigQuery } from 'data/auth/auth-config-query' @@ -98,7 +99,7 @@ export const ConnectionPooling = () => { isLoading, isError, isSuccess, - } = usePoolingConfigurationQuery({ projectRef: projectRef }) + } = usePoolingConfigurationQuery({ projectRef }) const { data: authConfig } = useAuthConfigQuery({ projectRef }) @@ -297,29 +298,32 @@ export const ConnectionPooling = () => { <> {field.value === 'transaction' ? ( - - - Pool mode will be set to transaction permanently on port 6543 - - - This will take into effect once saved. You can use session mode by - pointing the pooler connection to use port 5432. - - + ) : ( - - - Set to transaction mode to use both pooling modes concurrently - - - Session mode can be used concurrently with transaction mode by + {/* [Joshen] Can probably remove this after Feb 28 */} + + )} diff --git a/apps/studio/components/interfaces/Settings/Database/DatabaseReadOnlyAlert.tsx b/apps/studio/components/interfaces/Settings/Database/DatabaseReadOnlyAlert.tsx index 3ecae543018..930aed2d448 100644 --- a/apps/studio/components/interfaces/Settings/Database/DatabaseReadOnlyAlert.tsx +++ b/apps/studio/components/interfaces/Settings/Database/DatabaseReadOnlyAlert.tsx @@ -39,14 +39,18 @@ export const DatabaseReadOnlyAlert = () => { {subscription?.plan.id === 'free' ? (
  • - + Upgrade to the Pro Plan {' '} to increase your database size limit to 8GB.
  • ) : subscription?.plan.id === 'pro' && subscription?.usage_billing_enabled ? (
  • - + Disable your Spend Cap {' '} to allow your project to auto-scale and expand beyond the 8GB database size limit diff --git a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx index fcfe9c4f4f3..9491f6c6d5d 100644 --- a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx +++ b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx @@ -90,6 +90,7 @@ const CustomDomainConfig = () => { : 'To configure a custom domain for your project, please enable the add-on' } addon="customDomain" + source="customDomain" disabled={customDomainsDisabledDueToQuota} /> ) : ( diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DeployNewReplicaPanel.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DeployNewReplicaPanel.tsx index 83f3adab5ad..1d52a9242d0 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DeployNewReplicaPanel.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DeployNewReplicaPanel.tsx @@ -301,7 +301,7 @@ const DeployNewReplicaPanel = ({ = ({ show, setShowUpgradePrompt }) => { Close diff --git a/apps/studio/components/interfaces/Support/PlanExpectationInfoBox.tsx b/apps/studio/components/interfaces/Support/PlanExpectationInfoBox.tsx index 994c70f5c16..cf4a34b5528 100644 --- a/apps/studio/components/interfaces/Support/PlanExpectationInfoBox.tsx +++ b/apps/studio/components/interfaces/Support/PlanExpectationInfoBox.tsx @@ -56,7 +56,11 @@ export const PlanExpectationInfoBox = ({
    diff --git a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx index 9d59aa0834c..8dca2baec40 100644 --- a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx @@ -1,3 +1,9 @@ +import saveAs from 'file-saver' +import { Clipboard, Copy, Download, Edit, Lock, MoreHorizontal, Trash, Unlock } from 'lucide-react' +import Link from 'next/link' +import Papa from 'papaparse' +import { toast } from 'sonner' + import { IS_PLATFORM } from 'common' import { MAX_EXPORT_ROW_COUNT, @@ -17,14 +23,8 @@ import { EditorTablePageLink } from 'data/prefetchers/project.$ref.editor.$id' import { getTableEditor } from 'data/table-editor/table-editor-query' import { isTableLike } from 'data/table-editor/table-editor-types' import { fetchAllTableRows } from 'data/table-rows/table-rows-query' -import saveAs from 'file-saver' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' -import { LOCAL_STORAGE_KEYS } from 'lib/constants' import { copyToClipboard } from 'lib/helpers' -import { Clipboard, Copy, Download, Edit, Lock, MoreHorizontal, Trash, Unlock } from 'lucide-react' -import Link from 'next/link' -import Papa from 'papaparse' -import { toast } from 'sonner' import { useTableEditorStateSnapshot } from 'state/table-editor' import { cn, @@ -41,7 +41,6 @@ import { TooltipTrigger, TreeViewItemVariant, } from 'ui' -import { useSnapshot } from 'valtio' import { useProjectContext } from '../ProjectLayout/ProjectContext' export interface EntityListItemProps { diff --git a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx index eda09f71fc2..4e331ac57bb 100644 --- a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx @@ -1,4 +1,9 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' +import { partition } from 'lodash' +import { Filter, Plus } from 'lucide-react' +import { useRouter } from 'next/router' +import { useEffect, useMemo, useState } from 'react' + import { useParams } from 'common' import { useBreakpoint } from 'common/hooks/useBreakpoint' import { ProtectedSchemaModal } from 'components/interfaces/Database/ProtectedSchemaWarning' @@ -15,10 +20,6 @@ import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useLocalStorage } from 'hooks/misc/useLocalStorage' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' import { PROTECTED_SCHEMAS } from 'lib/constants/schemas' -import { partition } from 'lodash' -import { Filter, Plus } from 'lucide-react' -import { useRouter } from 'next/router' -import { useEffect, useMemo, useState } from 'react' import { useTableEditorStateSnapshot } from 'state/table-editor' import { AlertDescription_Shadcn_, @@ -123,7 +124,7 @@ const TableEditorMenu = () => { onSelectSchema={(name: string) => { setSearchText('') setSelectedSchema(name) - router.push(`/project/${project?.ref}/editor`) + router.push(`/project/${project?.ref}/editor?schema=${name}`) }} onSelectCreateSchema={() => snap.onAddSchema()} /> diff --git a/apps/studio/components/to-be-cleaned/ProductEmptyState.tsx b/apps/studio/components/to-be-cleaned/ProductEmptyState.tsx index 7255dde4c4c..2b7d675a58a 100644 --- a/apps/studio/components/to-be-cleaned/ProductEmptyState.tsx +++ b/apps/studio/components/to-be-cleaned/ProductEmptyState.tsx @@ -2,6 +2,7 @@ import { ExternalLink } from 'lucide-react' import { PropsWithChildren } from 'react' import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import Link from 'next/link' import { Button } from 'ui' interface ProductEmptyStateProps { @@ -14,6 +15,7 @@ interface ProductEmptyStateProps { loading?: boolean disabled?: boolean disabledMessage?: string + ctaUrl?: string } const ProductEmptyState = ({ @@ -27,6 +29,7 @@ const ProductEmptyState = ({ loading = false, disabled = false, disabledMessage = '', + ctaUrl, }: PropsWithChildren) => { const hasAction = (ctaButtonLabel && onClickCta) || (infoButtonUrl && infoButtonLabel) @@ -40,7 +43,11 @@ const ProductEmptyState = ({
    {children}
    {hasAction && (
    - {ctaButtonLabel && onClickCta && ( + {ctaButtonLabel && !!ctaUrl ? ( + + ) : ctaButtonLabel && !!onClickCta ? ( {ctaButtonLabel} - )} + ) : null} {infoButtonUrl && infoButtonLabel ? (
    )} diff --git a/apps/studio/components/ui/Panel.tsx b/apps/studio/components/ui/Panel.tsx index fd3724b6372..dbc51b7dfa0 100644 --- a/apps/studio/components/ui/Panel.tsx +++ b/apps/studio/components/ui/Panel.tsx @@ -1,6 +1,7 @@ import { Megaphone } from 'lucide-react' import { forwardRef, PropsWithChildren, ReactNode } from 'react' -import { Badge, Button, Loading, cn } from 'ui' + +import { Badge, Button, cn, Loading } from 'ui' interface PanelProps { className?: string @@ -62,71 +63,78 @@ const PanelNotice = forwardRef< description?: string href?: string buttonText?: string + layout?: 'horizontal' | 'vertical' badgeLabel?: string } ->(({ className, title, description, href, buttonText, badgeLabel, ...props }, ref) => { - return ( -
    +>( + ( + { + className, + title, + description, + href, + buttonText, + layout = 'horizontal', + badgeLabel, + ...props + }, + ref + ) => { + return ( + ) + } +) PanelNotice.displayName = 'PanelNotice' diff --git a/apps/studio/components/ui/ProfileImage.tsx b/apps/studio/components/ui/ProfileImage.tsx new file mode 100644 index 00000000000..da31b8d0c9e --- /dev/null +++ b/apps/studio/components/ui/ProfileImage.tsx @@ -0,0 +1,35 @@ +import Image from 'next/image' +import { ReactNode, useState } from 'react' + +import { User } from 'icons' +import { cn } from 'ui' + +interface ProfileImageProps { + alt?: string + src?: string + placeholder?: ReactNode + className?: string +} + +export const ProfileImage = ({ alt, src, placeholder, className }: ProfileImageProps) => { + const [hasInvalidImg, setHasInvalidImg] = useState(false) + + return !!src && !hasInvalidImg ? ( + {alt setHasInvalidImg(true)} + /> + ) : ( + placeholder ?? ( +
    + +
    + ) + ) +} diff --git a/apps/studio/components/ui/SchemaSelector.tsx b/apps/studio/components/ui/SchemaSelector.tsx index 5bda5d52592..df05ba0ceba 100644 --- a/apps/studio/components/ui/SchemaSelector.tsx +++ b/apps/studio/components/ui/SchemaSelector.tsx @@ -1,9 +1,10 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' +import { Check, ChevronsUpDown, Plus } from 'lucide-react' +import { useState } from 'react' + import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { useSchemasQuery } from 'data/database/schemas-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { Check, ChevronsUpDown, Plus } from 'lucide-react' -import { useState } from 'react' import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, diff --git a/apps/studio/components/ui/UpgradeToPro.tsx b/apps/studio/components/ui/UpgradeToPro.tsx index c3b9e00301e..b1979f74866 100644 --- a/apps/studio/components/ui/UpgradeToPro.tsx +++ b/apps/studio/components/ui/UpgradeToPro.tsx @@ -16,6 +16,7 @@ interface UpgradeToProProps { secondaryText: string addon?: 'pitr' | 'customDomain' | 'computeInstance' buttonText?: string + source?: string disabled?: boolean } @@ -25,6 +26,7 @@ const UpgradeToPro = ({ secondaryText, addon, buttonText, + source = 'upgrade', disabled = false, }: UpgradeToProProps) => { const project = useSelectedProject() @@ -80,8 +82,8 @@ const UpgradeToPro = ({ {buttonText || (plan === 'free' ? 'Upgrade to Pro' : 'Enable add on')} diff --git a/apps/studio/data/edge-functions/edge-function-query.ts b/apps/studio/data/edge-functions/edge-function-query.ts index ad6f54dee2a..c8d14919498 100644 --- a/apps/studio/data/edge-functions/edge-function-query.ts +++ b/apps/studio/data/edge-functions/edge-function-query.ts @@ -2,6 +2,7 @@ import { useQuery, UseQueryOptions } from '@tanstack/react-query' import { components } from 'api-types' import { get, handleError } from 'data/fetchers' import { IS_PLATFORM } from 'lib/constants' +import { ResponseError } from 'types' import { edgeFunctionsKeys } from './keys' export type EdgeFunctionVariables = { @@ -28,7 +29,7 @@ export async function getEdgeFunction( } export type EdgeFunctionData = Awaited> -export type EdgeFunctionError = unknown +export type EdgeFunctionError = ResponseError export const useEdgeFunctionQuery = ( { projectRef, slug }: EdgeFunctionVariables, diff --git a/apps/studio/data/organizations/organization-update-mutation.ts b/apps/studio/data/organizations/organization-update-mutation.ts index ebd346df750..628ae5f8c12 100644 --- a/apps/studio/data/organizations/organization-update-mutation.ts +++ b/apps/studio/data/organizations/organization-update-mutation.ts @@ -10,6 +10,7 @@ export type OrganizationUpdateVariables = { slug: string name?: string billing_email?: string + additional_billing_emails?: string[] opt_in_tags?: string[] } @@ -18,12 +19,14 @@ export async function updateOrganization({ name, billing_email, opt_in_tags, + additional_billing_emails, }: OrganizationUpdateVariables) { // @ts-ignore [Joshen] API spec is wrong const payload: components['schemas']['UpdateOrganizationBody'] = {} if (name) payload.name = name if (billing_email) payload.billing_email = billing_email if (opt_in_tags) payload.opt_in_tags = opt_in_tags + if (additional_billing_emails) payload.additional_billing_emails = additional_billing_emails const { data, error } = await patch('/platform/organizations/{slug}', { params: { path: { slug } }, @@ -50,8 +53,57 @@ export const useOrganizationUpdateMutation = ({ (vars) => updateOrganization(vars), { async onSuccess(data, variables, context) { - // [Joshen] Not sure if necessary to refresh the organizations list though - await queryClient.invalidateQueries(organizationKeys.list()) + queryClient.setQueriesData( + { + queryKey: organizationKeys.list(), + exact: true, + }, + (prev: components['schemas']['OrganizationResponse'][] | undefined) => { + if (!prev) return prev + + return prev.map((org) => { + if (org.slug !== variables.slug) return org + + return { + ...org, + name: variables.name || org.name, + billing_email: variables.billing_email || org.billing_email, + opt_in_tags: variables.opt_in_tags || org.opt_in_tags, + } + }) + } + ) + + queryClient.setQueriesData( + { + queryKey: organizationKeys.customerProfile(data.slug), + exact: true, + }, + (prev: components['schemas']['CustomerResponse'] | undefined) => { + if (!prev) return prev + return { + ...prev, + additional_emails: variables.additional_billing_emails || prev.additional_emails, + } + } + ) + + queryClient.setQueriesData( + { + queryKey: organizationKeys.detail(data.slug), + exact: true, + }, + (prev: components['schemas']['OrganizationSlugResponse'] | undefined) => { + if (!prev) return prev + return { + ...prev, + name: variables.name || prev.name, + billing_email: variables.billing_email || prev.billing_email, + opt_in_tags: variables.opt_in_tags || prev.opt_in_tags, + } + } + ) + await onSuccess?.(data, variables, context) }, async onError(data, variables, context) { diff --git a/apps/studio/data/profile/types.ts b/apps/studio/data/profile/types.ts index c16127203bb..4b756ae7e08 100644 --- a/apps/studio/data/profile/types.ts +++ b/apps/studio/data/profile/types.ts @@ -1,5 +1,7 @@ import type { components } from 'data/api' -export type Profile = components['schemas']['ProfileResponse'] +export type Profile = components['schemas']['ProfileResponse'] & { + profileImageUrl?: string +} export type Feature = Profile['disabled_features'][number] diff --git a/apps/studio/lib/github.tsx b/apps/studio/lib/github.tsx index e3c8702cb3c..8de3ef52368 100644 --- a/apps/studio/lib/github.tsx +++ b/apps/studio/lib/github.tsx @@ -63,3 +63,7 @@ export function openInstallGitHubIntegrationWindow(type: 'install' | 'authorize' newWindow.focus() } } + +export const getGitHubProfileImgUrl = (username: string) => { + return `https://github.com/${username}.png?size=80` +} diff --git a/apps/studio/lib/profile.tsx b/apps/studio/lib/profile.tsx index e303b69d0ba..97664b3756a 100644 --- a/apps/studio/lib/profile.tsx +++ b/apps/studio/lib/profile.tsx @@ -12,6 +12,7 @@ import type { Profile } from 'data/profile/types' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import type { ResponseError } from 'types' import { useSignOut } from './auth' +import { getGitHubProfileImgUrl } from './github' export type ProfileContextType = { profile: Profile | undefined @@ -74,10 +75,12 @@ export const ProfileProvider = ({ children }: PropsWithChildren<{}>) => { const value = useMemo(() => { const isLoading = isLoadingProfile || isCreatingProfile || isLoadingPermissions + const isGHUser = !!profile && 'auth0_id' in profile && profile?.auth0_id.startsWith('github') + const profileImageUrl = isGHUser ? getGitHubProfileImgUrl(profile.username) : undefined return { error, - profile, + profile: !!profile ? { ...profile, profileImageUrl } : undefined, isLoading, isError, isSuccess, diff --git a/apps/studio/pages/project/[ref]/auth/advanced.tsx b/apps/studio/pages/project/[ref]/auth/advanced.tsx index a270d960910..5d39023c395 100644 --- a/apps/studio/pages/project/[ref]/auth/advanced.tsx +++ b/apps/studio/pages/project/[ref]/auth/advanced.tsx @@ -2,6 +2,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { AdvancedAuthSettingsForm, SmtpForm, ThirdPartyAuthForm } from 'components/interfaces/Auth' import AuthLayout from 'components/layouts/AuthLayout/AuthLayout' +import DefaultLayout from 'components/layouts/DefaultLayout' import { ScaffoldContainer, ScaffoldDescription, @@ -13,7 +14,7 @@ import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' import type { NextPageWithLayout } from 'types' -const AdvancedAuthSettings: NextPageWithLayout = () => { +const PageLayout: NextPageWithLayout = () => { const isPermissionsLoaded = usePermissionsLoaded() // TODO: check if these permissions cover third party auth as well const canReadAuthSettings = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue') @@ -40,7 +41,11 @@ const AdvancedAuthSettings: NextPageWithLayout = () => { ) } -AdvancedAuthSettings.getLayout = (page) => { - return {page} +PageLayout.getLayout = (page) => { + return ( + + {page} + + ) } -export default AdvancedAuthSettings +export default PageLayout diff --git a/apps/studio/pages/project/[ref]/auth/mfa.tsx b/apps/studio/pages/project/[ref]/auth/mfa.tsx index d56932a49a4..8bdcd91c193 100644 --- a/apps/studio/pages/project/[ref]/auth/mfa.tsx +++ b/apps/studio/pages/project/[ref]/auth/mfa.tsx @@ -2,6 +2,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { MfaAuthSettingsForm } from 'components/interfaces/Auth' import AuthLayout from 'components/layouts/AuthLayout/AuthLayout' +import DefaultLayout from 'components/layouts/DefaultLayout' import { ScaffoldContainer, ScaffoldDescription, @@ -13,7 +14,7 @@ import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' import type { NextPageWithLayout } from 'types' -const AuthSettings: NextPageWithLayout = () => { +const PageLayout: NextPageWithLayout = () => { const isPermissionsLoaded = usePermissionsLoaded() // TODO: check if these permissions cover third party auth as well const canReadAuthSettings = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue') @@ -43,7 +44,11 @@ const AuthSettings: NextPageWithLayout = () => { ) } -AuthSettings.getLayout = (page) => { - return {page} +PageLayout.getLayout = (page) => { + return ( + + {page} + + ) } -export default AuthSettings +export default PageLayout diff --git a/apps/studio/pages/project/[ref]/auth/protection.tsx b/apps/studio/pages/project/[ref]/auth/protection.tsx index e0651326bb3..6f7bbddef9d 100644 --- a/apps/studio/pages/project/[ref]/auth/protection.tsx +++ b/apps/studio/pages/project/[ref]/auth/protection.tsx @@ -6,6 +6,7 @@ import { ThirdPartyAuthForm, } from 'components/interfaces/Auth' import AuthLayout from 'components/layouts/AuthLayout/AuthLayout' +import DefaultLayout from 'components/layouts/DefaultLayout' import { ScaffoldHeader, ScaffoldContainer, ScaffoldTitle } from 'components/layouts/Scaffold' import NoPermission from 'components/ui/NoPermission' import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' @@ -35,7 +36,10 @@ const PageLayout: NextPageWithLayout = () => { } PageLayout.getLayout = (page) => { - return {page} + return ( + + {page} + + ) } - export default PageLayout diff --git a/apps/studio/pages/project/[ref]/auth/sessions.tsx b/apps/studio/pages/project/[ref]/auth/sessions.tsx index 2835de7767c..7dd24438315 100644 --- a/apps/studio/pages/project/[ref]/auth/sessions.tsx +++ b/apps/studio/pages/project/[ref]/auth/sessions.tsx @@ -2,13 +2,14 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { SessionsAuthSettingsForm, SmtpForm, ThirdPartyAuthForm } from 'components/interfaces/Auth' import AuthLayout from 'components/layouts/AuthLayout/AuthLayout' +import DefaultLayout from 'components/layouts/DefaultLayout' import { ScaffoldContainer, ScaffoldHeader, ScaffoldTitle } from 'components/layouts/Scaffold' import NoPermission from 'components/ui/NoPermission' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' import type { NextPageWithLayout } from 'types' -const AuthSettings: NextPageWithLayout = () => { +const PageLayout: NextPageWithLayout = () => { const isPermissionsLoaded = usePermissionsLoaded() // TODO: check if these permissions cover third party auth as well const canReadAuthSettings = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue') @@ -33,7 +34,11 @@ const AuthSettings: NextPageWithLayout = () => { ) } -AuthSettings.getLayout = (page) => { - return {page} +PageLayout.getLayout = (page) => { + return ( + + {page} + + ) } -export default AuthSettings +export default PageLayout diff --git a/apps/studio/pages/project/[ref]/auth/smtp.tsx b/apps/studio/pages/project/[ref]/auth/smtp.tsx index 9db26e4661e..51fe769af7c 100644 --- a/apps/studio/pages/project/[ref]/auth/smtp.tsx +++ b/apps/studio/pages/project/[ref]/auth/smtp.tsx @@ -16,6 +16,7 @@ import { } from 'components/layouts/Scaffold' import NoPermission from 'components/ui/NoPermission' import type { NextPageWithLayout } from 'types' +import DefaultLayout from 'components/layouts/DefaultLayout' const PageLayout: NextPageWithLayout = () => { const canReadAuthSettings = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue') @@ -69,7 +70,10 @@ const PageLayout: NextPageWithLayout = () => { } PageLayout.getLayout = (page) => { - return {page} + return ( + + {page} + + ) } - export default PageLayout diff --git a/apps/studio/pages/project/[ref]/auth/third-party.tsx b/apps/studio/pages/project/[ref]/auth/third-party.tsx index c9782a0d636..f48b3eabf6a 100644 --- a/apps/studio/pages/project/[ref]/auth/third-party.tsx +++ b/apps/studio/pages/project/[ref]/auth/third-party.tsx @@ -16,6 +16,7 @@ import { } from 'components/layouts/Scaffold' import NoPermission from 'components/ui/NoPermission' import type { NextPageWithLayout } from 'types' +import DefaultLayout from 'components/layouts/DefaultLayout' const PageLayout: NextPageWithLayout = () => { const canReadAuthSettings = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue') @@ -69,7 +70,10 @@ const PageLayout: NextPageWithLayout = () => { } PageLayout.getLayout = (page) => { - return {page} + return ( + + {page} + + ) } - export default PageLayout diff --git a/apps/studio/pages/project/[ref]/database/backups/pitr.tsx b/apps/studio/pages/project/[ref]/database/backups/pitr.tsx index 04cfdb5d989..2a38ea3c293 100644 --- a/apps/studio/pages/project/[ref]/database/backups/pitr.tsx +++ b/apps/studio/pages/project/[ref]/database/backups/pitr.tsx @@ -89,6 +89,7 @@ const PITR = () => { {!isEnabled ? ( { return ( diff --git a/apps/studio/tailwind.config.js b/apps/studio/tailwind.config.js index e75ba3b554e..ee13f7833a8 100644 --- a/apps/studio/tailwind.config.js +++ b/apps/studio/tailwind.config.js @@ -6,7 +6,8 @@ module.exports = config({ './components/**/*.{js,ts,jsx,tsx}', // purge styles from grid library './../../packages/ui/src/**/*.{tsx,ts,js}', - './../../packages/ui-patterns/!(node_modules)/**/*.{tsx,ts,js}', + './../../packages/ui-patterns/**/*.{tsx,ts,js}', + '!./../../packages/ui-patterns/node_modules/**/*', // ignore node_modules rule ], theme: { extend: { diff --git a/apps/www/_blog/2024-08-12-github-copilot-extension-for-vs-code.mdx b/apps/www/_blog/2024-08-12-github-copilot-extension-for-vs-code.mdx index 9cd05fe0e1e..a69492142f8 100644 --- a/apps/www/_blog/2024-08-12-github-copilot-extension-for-vs-code.mdx +++ b/apps/www/_blog/2024-08-12-github-copilot-extension-for-vs-code.mdx @@ -64,7 +64,7 @@ List the Storage buckets in your Supabase project. We're excited to continue adding more features that will make your development experience with Supabase even more delightful - and for this we need your help! If you have any feedback, feature requests, or bug reports, please [open an issue on GitHub](https://github.com/supabase-community/supabase-vscode-extension/issues). -The extension requires you to have the Supabase CLI installed and have your project running locally. In a future release, we will integrate the [Supabase Managamenet API](/docs/reference/api/introduction) into the extension to make connecting to your hosted Supabase projects as seamless as possible. +The extension requires you to have the Supabase CLI installed and have your project running locally. In a future release, we will integrate the [Supabase Management API](/docs/reference/api/introduction) into the extension to make connecting to your hosted Supabase projects as seamless as possible. ## Contributing to Supabase diff --git a/apps/www/_blog/2024-08-13-supabase-realtime-broadcast-and-presence-authorization.mdx b/apps/www/_blog/2024-08-13-supabase-realtime-broadcast-and-presence-authorization.mdx index 15999b61864..8e70366f9c5 100644 --- a/apps/www/_blog/2024-08-13-supabase-realtime-broadcast-and-presence-authorization.mdx +++ b/apps/www/_blog/2024-08-13-supabase-realtime-broadcast-and-presence-authorization.mdx @@ -212,7 +212,7 @@ Realtime uses the private flag client's define when creating channel, takes the light: '/images/blog/2024-08-13-supabase-realtime-broadcast-and-presence-authorization/one-user-connection--light.png', }} - alt="Flow of checks done against the databse to determine the policies for a user connection." + alt="Flow of checks done against the database to determine the policies for a user connection." /> ## How is this approach performant? diff --git a/apps/www/_blog/2024-12-06-launch-week-13-top-10.mdx b/apps/www/_blog/2024-12-06-launch-week-13-top-10.mdx index 098653eff97..51281ddcf66 100644 --- a/apps/www/_blog/2024-12-06-launch-week-13-top-10.mdx +++ b/apps/www/_blog/2024-12-06-launch-week-13-top-10.mdx @@ -35,7 +35,7 @@ A feature tri-fecta: Edge Functions now support Websockets for things like OpenA ## #4: CLI v2: Config as Code -We realeased v2 of the CLI, adding support for Configuration as Code. You can now use the CLI in your GitHub actions to keep all of your project configuration in sync. +We released v2 of the CLI, adding support for Configuration as Code. You can now use the CLI in your GitHub actions to keep all of your project configuration in sync. ## #5: High Performance Disks diff --git a/apps/www/tailwind.config.js b/apps/www/tailwind.config.js index 2f59a96c6c4..61fd861a21b 100644 --- a/apps/www/tailwind.config.js +++ b/apps/www/tailwind.config.js @@ -9,7 +9,8 @@ module.exports = config({ './lib/mdx/mdxComponents.tsx', './pages/**/*.{tsx,mdx}', './../../packages/ui/src/**/*.{tsx,ts,js}', - './../../packages/ui-patterns/!(node_modules)/**/*.{tsx,ts,js}', + './../../packages/ui-patterns/**/*.{tsx,ts,js}', + '!./../../packages/ui-patterns/node_modules/**/*', // ignore node_modules rule ], theme: { extend: { diff --git a/packages/api-types/types/api.d.ts b/packages/api-types/types/api.d.ts index 9e9904a03f6..cc5c6d25bc3 100644 --- a/packages/api-types/types/api.d.ts +++ b/packages/api-types/types/api.d.ts @@ -8255,6 +8255,7 @@ export interface components { walg_enabled: boolean } BillingCustomerUpdateBody: { + additional_emails?: string[] | undefined address?: components['schemas']['CustomerBillingAddress'] } /** @enum {string} */ @@ -8791,6 +8792,7 @@ export interface components { state?: string } CustomerResponse: { + additional_emails: string[] | null address: components['schemas']['CustomerBillingAddress'] | null balance: number billing_via_partner: boolean diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index 9d416c71366..31c38e82a07 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -1069,7 +1069,13 @@ export interface StudioPricingPlanCtaClickedEvent { */ export interface StudioPricingSidePanelOpenedEvent { action: TelemetryActions.STUDIO_PRICING_SIDE_PANEL_OPENED - properties: { currentPlan?: string } + properties: { + currentPlan?: string + /** + * Tracks how user landed on the Pricing side panel, e.g. diskManagementPanelDiskSize, backupsRestoreToNewProject + */ + origin?: string + } groups: { organization: string } } diff --git a/packages/ui-patterns/admonition.tsx b/packages/ui-patterns/admonition.tsx index 40a72225c05..89c2fc3c77e 100644 --- a/packages/ui-patterns/admonition.tsx +++ b/packages/ui-patterns/admonition.tsx @@ -69,9 +69,9 @@ const WarningIcon = () => ( const admonitionSVG = cva('', { variants: { type: { - default: `[&>svg]:bg-foreground-muted [&>svg]:text-background-muted`, - warning: `[&>svg]:bg-warning [&>svg]:text-warning-100`, - destructive: `[&>svg]:bg-destructive [&>svg]:text-white`, + default: `[&>svg]:bg-foreground-muted`, + warning: ``, + destructive: ``, }, }, }) @@ -80,7 +80,7 @@ const admonitionBase = cva('', { variants: { type: { default: `bg-surface-200/25 border border-default`, - warning: `bg-alternative border border-default ![&_p]:text-background-muted`, + warning: `bg-alternative border border-default`, destructive: `bg-alternative border border-default`, }, }, @@ -96,13 +96,13 @@ export const Admonition = forwardRef< ) => { const typeMapped = variant ? admonitionToAlertMapping[variant] : admonitionToAlertMapping[type] + return ( (null) +const commandItemClass = cn( + 'relative text-foreground-lighter text-left px-2 py-1.5 rounded', + 'hover:text-foreground hover:!bg-overlay-hover w-full flex items-center space-x-2', + 'peer-data-[value=true]:bg-overlay-hover peer-data-[value=true]:text-strong' +) + function useMultiSelect() { const context = React.useContext(MultiSelectContext) if (!context) { @@ -211,6 +217,7 @@ const MultiSelectorTrigger = React.forwardRef = React.useCallback( (event) => { setOpen(true) + setInputValue('') if (IS_INLINE_MODE) { event.stopPropagation() @@ -229,6 +236,7 @@ const MultiSelectorTrigger = React.forwardRef !isDeleteHovered && handleTriggerClick(e)} disabled={disabled} + type="button" role="combobox" className={cn( 'flex w-full min-w-[200px] min-h-[40px] items-center justify-between rounded-md border', @@ -257,7 +265,8 @@ const MultiSelectorTrigger = React.forwardRef setIsDeleteHovered(true)} onMouseLeave={() => setIsDeleteHovered(false)} - onClick={() => { + onClick={(e) => { + e.stopPropagation() toggleValue(value) setIsDeleteHovered(false) }} @@ -419,21 +428,55 @@ MultiSelector.Content = MultiSelectorContent const MultiSelectorList = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children }, ref) => { + React.ComponentPropsWithoutRef & { + creatable?: boolean + } +>(({ className, children, creatable = false }, ref) => { + const { open, inputValue, setInputValue, toggleValue } = useMultiSelect() + + const options = !!children + ? Array.isArray(children) + ? (children as React.ReactNode[]) + : typeof children === 'object' && 'props' in children + ? children.props.children + : [] + : [] + const availableOptions = options + .filter((x: any) => !!x.props.value) + .map((x: any) => x.props.value.toLowerCase()) + const isOptionExists = availableOptions.some((x: string) => x === inputValue.toLowerCase()) + return ( {children} - - No results found - + {creatable && inputValue.length > 0 && !isOptionExists ? ( + { + open && toggleValue(inputValue) + setInputValue('') + }} + className={commandItemClass} + > + Create "{inputValue}" + + ) : creatable && options.length === 0 ? ( +
    + Type to add a value +
    + ) : ( + + No results found + + )}
    ) }) @@ -457,15 +500,7 @@ const MultiSelectorItem = React.forwardRef< open && toggleValue(value) setInputValue('') }} - className={cn( - 'relative', - 'text-foreground-lighter text-left', - 'px-2 py-1.5 rounded', - 'hover:text-foreground hover:!bg-overlay-hover', - 'w-full flex items-center space-x-2', - 'peer-data-[value=true]:bg-overlay-hover peer-data-[value=true]:text-strong', - className - )} + className={cn(commandItemClass, className)} {...props} >
    (undefined) + + React.useEffect(() => { + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) + const onChange = () => { + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + } + mql.addEventListener('change', onChange) + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) + return () => mql.removeEventListener('change', onChange) + }, []) + + return !!isMobile +} diff --git a/packages/ui/src/components/shadcn/ui/sidebar.tsx b/packages/ui/src/components/shadcn/ui/sidebar.tsx new file mode 100644 index 00000000000..77aea8b0612 --- /dev/null +++ b/packages/ui/src/components/shadcn/ui/sidebar.tsx @@ -0,0 +1,740 @@ +'use client' + +import * as React from 'react' +import { Slot } from '@radix-ui/react-slot' +import { VariantProps, cva } from 'class-variance-authority' +import { PanelLeft } from 'lucide-react' + +import { useIsMobile } from '../hooks/useMobile' +import { cn } from './../../../lib/utils' +import { Button } from './button' +import { Input } from './input' +import { Separator } from './separator' +import { Sheet, SheetContent } from './sheet' +import { Skeleton } from './skeleton' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip' + +const SIDEBAR_COOKIE_NAME = 'sidebar:state' +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 +const SIDEBAR_WIDTH = '13rem' +const SIDEBAR_WIDTH_MOBILE = '18rem' +const SIDEBAR_WIDTH_ICON = '3rem' +const SIDEBAR_KEYBOARD_SHORTCUT = '[' + +type SidebarContext = { + state: 'expanded' | 'collapsed' + open: boolean + setOpen: (open: boolean) => void + openMobile: boolean + setOpenMobile: (open: boolean) => void + isMobile: boolean + toggleSidebar: () => void +} + +const SidebarContext = React.createContext(null) + +function useSidebar() { + const context = React.useContext(SidebarContext) + if (!context) { + throw new Error('useSidebar must be used within a SidebarProvider.') + } + + return context +} + +const SidebarProvider = React.forwardRef< + HTMLDivElement, + React.ComponentProps<'div'> & { + defaultOpen?: boolean + open?: boolean + onOpenChange?: (open: boolean) => void + } +>( + ( + { + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props + }, + ref + ) => { + const isMobile = useIsMobile() + const [openMobile, setOpenMobile] = React.useState(false) + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(defaultOpen) + const open = openProp ?? _open + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === 'function' ? value(open) : value + if (setOpenProp) { + setOpenProp(openState) + } else { + _setOpen(openState) + } + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` + }, + [setOpenProp, open] + ) + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open) + }, [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() + } + } + + 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. + const state = open ? 'expanded' : 'collapsed' + + const contextValue = React.useMemo( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] + ) + + return ( + + +
    + {children} +
    +
    +
    + ) + } +) +SidebarProvider.displayName = 'SidebarProvider' + +const Sidebar = React.forwardRef< + HTMLDivElement, + React.ComponentProps<'div'> & { + side?: 'left' | 'right' + variant?: 'sidebar' | 'floating' | 'inset' + collapsible?: 'offcanvas' | 'icon' | 'none' + } +>( + ( + { + side = 'left', + variant = 'sidebar', + collapsible = 'offcanvas', + className, + children, + ...props + }, + ref + ) => { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar() + + if (collapsible === 'none') { + return ( +
    + {children} +
    + ) + } + + if (isMobile) { + return ( + + +
    {children}
    +
    +
    + ) + } + + return ( +
    + {/* This is what handles the sidebar gap on desktop */} +
    + +
    + ) + } +) +Sidebar.displayName = 'Sidebar' + +const SidebarTrigger = React.forwardRef< + React.ElementRef, + React.ComponentProps +>(({ className, onClick, ...props }, ref) => { + const { toggleSidebar } = useSidebar() + + return ( + + ) +}) +SidebarTrigger.displayName = 'SidebarTrigger' + +const SidebarRail = React.forwardRef>( + ({ className, ...props }, ref) => { + const { toggleSidebar } = useSidebar() + + return ( +