diff --git a/apps/design-system/__registry__/index.tsx b/apps/design-system/__registry__/index.tsx index 57cf860d20b..b91cfecb325 100644 --- a/apps/design-system/__registry__/index.tsx +++ b/apps/design-system/__registry__/index.tsx @@ -1798,13 +1798,68 @@ export const Index: Record = { subcategory: "undefined", chunks: [] }, - "multi-select-form": { - name: "multi-select-form", + "multi-select-disabled": { + name: "multi-select-disabled", type: "components:example", registryDependencies: undefined, - component: React.lazy(() => import("@/registry/default/example/multi-select-form")), + component: React.lazy(() => import("@/registry/default/example/multi-select-disabled")), source: "", - files: ["registry/default/example/multi-select-form.tsx"], + files: ["registry/default/example/multi-select-disabled.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, + "multi-select-badge-limit-wrap": { + name: "multi-select-badge-limit-wrap", + type: "components:example", + registryDependencies: undefined, + component: React.lazy(() => import("@/registry/default/example/multi-select-badge-limit-wrap")), + source: "", + files: ["registry/default/example/multi-select-badge-limit-wrap.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, + "multi-select-badge-limit": { + name: "multi-select-badge-limit", + type: "components:example", + registryDependencies: undefined, + component: React.lazy(() => import("@/registry/default/example/multi-select-badge-limit")), + source: "", + files: ["registry/default/example/multi-select-badge-limit.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, + "multi-select-combobox": { + name: "multi-select-combobox", + type: "components:example", + registryDependencies: undefined, + component: React.lazy(() => import("@/registry/default/example/multi-select-combobox")), + source: "", + files: ["registry/default/example/multi-select-combobox.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, + "multi-select-deletable-badge": { + name: "multi-select-deletable-badge", + type: "components:example", + registryDependencies: undefined, + component: React.lazy(() => import("@/registry/default/example/multi-select-deletable-badge")), + source: "", + files: ["registry/default/example/multi-select-deletable-badge.tsx"], + category: "undefined", + subcategory: "undefined", + chunks: [] + }, + "multi-select-inline-search-input": { + name: "multi-select-inline-search-input", + type: "components:example", + registryDependencies: undefined, + component: React.lazy(() => import("@/registry/default/example/multi-select-inline-search-input")), + source: "", + files: ["registry/default/example/multi-select-inline-search-input.tsx"], category: "undefined", subcategory: "undefined", chunks: [] diff --git a/apps/design-system/content/docs/changelog.mdx b/apps/design-system/content/docs/changelog.mdx index cbee68f1521..1ae4506b73a 100644 --- a/apps/design-system/content/docs/changelog.mdx +++ b/apps/design-system/content/docs/changelog.mdx @@ -4,6 +4,14 @@ description: Latest updates and announcements. toc: false --- +## 18th Oct 2024 - Update multi-select + +Improved Multi Select with combobox, badge limit, and more examples. + +- [Multi Select](/design-system/components/multi-select) + +[PR](https://github.com/supabase/supabase/pull/29695) + ## 12th June 2024 - Button styles updated - [` + + Limit: {limit} + + + + + + + + Apple + Banana + Cherry + Date + Elderberrie + Fig + Grape + Kiwi + Mango + Strawberry + + + + + ) +} diff --git a/apps/design-system/registry/default/example/multi-select-combobox.tsx b/apps/design-system/registry/default/example/multi-select-combobox.tsx new file mode 100644 index 00000000000..5a79a6057b1 --- /dev/null +++ b/apps/design-system/registry/default/example/multi-select-combobox.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 ( + + + + + + Apple + Banana + Cherry + Date + Elderberrie + Fig + Grape + Kiwi + Mango + Strawberry + + + + ) +} diff --git a/apps/design-system/registry/default/example/multi-select-deletable-badge.tsx b/apps/design-system/registry/default/example/multi-select-deletable-badge.tsx new file mode 100644 index 00000000000..daf693d3edf --- /dev/null +++ b/apps/design-system/registry/default/example/multi-select-deletable-badge.tsx @@ -0,0 +1,37 @@ +import { useState } from 'react' +import { + MultiSelector, + MultiSelectorContent, + MultiSelectorItem, + MultiSelectorList, + MultiSelectorTrigger, +} from 'ui-patterns/multi-select' + +export default function MultiSelectDemo() { + const [selectedValues, setSelectedValues] = useState(['Apple', 'Banana']) + + return ( + + + + + Banana + Cherry + Date + Elderberrie + Fig + Grape + Kiwi + Mango + Strawberry + + + + ) +} diff --git a/apps/design-system/registry/default/example/multi-select-demo.tsx b/apps/design-system/registry/default/example/multi-select-demo.tsx index faa684876f7..fafed2a1c4c 100644 --- a/apps/design-system/registry/default/example/multi-select-demo.tsx +++ b/apps/design-system/registry/default/example/multi-select-demo.tsx @@ -2,25 +2,42 @@ import { useState } from 'react' import { MultiSelector, MultiSelectorContent, - MultiSelectorInput, MultiSelectorItem, MultiSelectorList, MultiSelectorTrigger, } from 'ui-patterns/multi-select' export default function MultiSelectDemo() { - const [value, setValue] = useState([]) + const [selectedValues, setSelectedValues] = useState([]) + + const fruits = [ + { value: 'Apple', isDisabled: false }, + { value: 'Banana', isDisabled: false }, + { value: 'Cherry', isDisabled: false }, + { value: 'Date', isDisabled: false }, + { value: 'Elderberrie', isDisabled: false }, + { value: 'Fig', isDisabled: false }, + { value: 'Grape', isDisabled: false }, + { value: 'Kiwi', isDisabled: true }, + { value: 'Mango', isDisabled: false }, + { value: 'Strawberry', isDisabled: false }, + ] return ( - - - - + + - Item 1 - Item 2 - Item 3 + {fruits.map(({ value, isDisabled }) => ( + + {value} + + ))} diff --git a/apps/design-system/registry/default/example/multi-select-disabled.tsx b/apps/design-system/registry/default/example/multi-select-disabled.tsx new file mode 100644 index 00000000000..16a4ba9aad8 --- /dev/null +++ b/apps/design-system/registry/default/example/multi-select-disabled.tsx @@ -0,0 +1,18 @@ +import { useState } from 'react' +import { + MultiSelector, + MultiSelectorContent, + MultiSelectorItem, + MultiSelectorList, + MultiSelectorTrigger, +} from 'ui-patterns/multi-select' + +export default function MultiSelectDemo() { + const [selectedValues, setSelectedValues] = useState([]) + + return ( + + + + ) +} diff --git a/apps/design-system/registry/default/example/multi-select-form.tsx b/apps/design-system/registry/default/example/multi-select-form.tsx deleted file mode 100644 index 83892ee5bab..00000000000 --- a/apps/design-system/registry/default/example/multi-select-form.tsx +++ /dev/null @@ -1,105 +0,0 @@ -'use client' - -import { zodResolver } from '@hookform/resolvers/zod' -import Image from 'next/image' -import { useForm } from 'react-hook-form' -import { toast } from 'sonner' -import z from 'zod' - -import { - Button, - Form_Shadcn_, - FormDescription_Shadcn_, - FormField_Shadcn_, - FormItem_Shadcn_, - FormLabel_Shadcn_, - FormMessage_Shadcn_, -} from 'ui' -import { - MultiSelector, - MultiSelectorContent, - MultiSelectorInput, - MultiSelectorItem, - MultiSelectorList, - MultiSelectorTrigger, -} from 'ui-patterns/multi-select' - -const form = z.object({ - value: z.array(z.string()).nonempty('Please select at least one person'), -}) - -type Form = z.infer - -const users = [ - { - name: 'ThePrimeagen', - picture: 'https://pbs.twimg.com/profile_images/1759330620160049152/2i_wkOoK_400x400.jpg', - }, - { - name: 'Shadcn', - picture: 'https://pbs.twimg.com/profile_images/1593304942210478080/TUYae5z7_400x400.jpg', - }, - { - name: 'Theo', - picture: 'https://pbs.twimg.com/profile_images/1605762947686375425/lsoGWWty_400x400.jpg', - }, -] - -const MultiSelectZod = () => { - const multiForm = useForm
({ - resolver: zodResolver(form), - defaultValues: form.parse({ value: [users[0].name] }), - }) - - const onSubmit = (data: Form) => { - toast('Form submitted ', { description: JSON.stringify(data, null, 2) }) - } - - return ( - - - ( - - Invite people - - - - - - - {users.map((user) => ( - -
- {user.name} - {user.name} -
-
- ))} -
-
-
- - Select people to invite to this event - - -
- )} - /> - - -
- ) -} - -export default MultiSelectZod diff --git a/apps/design-system/registry/default/example/multi-select-inline-search-input.tsx b/apps/design-system/registry/default/example/multi-select-inline-search-input.tsx new file mode 100644 index 00000000000..775762a39f5 --- /dev/null +++ b/apps/design-system/registry/default/example/multi-select-inline-search-input.tsx @@ -0,0 +1,39 @@ +import { useState } from 'react' +import { + MultiSelector, + MultiSelectorContent, + MultiSelectorItem, + MultiSelectorList, + MultiSelectorTrigger, +} from 'ui-patterns/multi-select' + +export default function MultiSelectDemo() { + const [selectedValues, setSelectedValues] = useState(['Apple']) + + return ( + + + + + Apple + Banana + Cherry + Date + Elderberrie + Fig + Grape + Kiwi + Mango + Strawberry + + + + ) +} diff --git a/apps/design-system/registry/examples.ts b/apps/design-system/registry/examples.ts index a7e7a595bb7..6cd26e5e819 100644 --- a/apps/design-system/registry/examples.ts +++ b/apps/design-system/registry/examples.ts @@ -1036,9 +1036,34 @@ export const examples: Registry = [ files: ['example/multi-select-demo.tsx'], }, { - name: 'multi-select-form', + name: 'multi-select-disabled', type: 'components:example', - files: ['example/multi-select-form.tsx'], + files: ['example/multi-select-disabled.tsx'], + }, + { + name: 'multi-select-badge-limit-wrap', + type: 'components:example', + files: ['example/multi-select-badge-limit-wrap.tsx'], + }, + { + name: 'multi-select-badge-limit', + type: 'components:example', + files: ['example/multi-select-badge-limit.tsx'], + }, + { + name: 'multi-select-combobox', + type: 'components:example', + files: ['example/multi-select-combobox.tsx'], + }, + { + name: 'multi-select-deletable-badge', + type: 'components:example', + files: ['example/multi-select-deletable-badge.tsx'], + }, + { + name: 'multi-select-inline-search-input', + type: 'components:example', + files: ['example/multi-select-inline-search-input.tsx'], }, { name: 'tree-view-demo', diff --git a/apps/docs/DEVELOPERS.md b/apps/docs/DEVELOPERS.md index ed8fa91080d..2d9f7cb3684 100644 --- a/apps/docs/DEVELOPERS.md +++ b/apps/docs/DEVELOPERS.md @@ -14,7 +14,7 @@ For a complete run-down on how all of our tools work together, see the main DEVE ## Local setup -[supabase.com/docs](https://supabase.com/docs) is a Next.JS site. You can get setup by following the same steps for all of our other Next.JS projects: +[supabase.com/docs](https://supabase.com/docs) is a Next.js site. You can get setup by following the same steps for all of our other Next.js projects: 1. Follow the steps outlined in the Local Development section of the main [DEVELOPERS.md](https://github.com/supabase/supabase/blob/master/DEVELOPERS.md) 2. If you work at Supabase, run `dev:secrets:pull` to pull down the internal environment variables. If you're a community member, create a `.env` file and add this line to it: `NEXT_PUBLIC_IS_PLATFORM=false` diff --git a/apps/docs/README.md b/apps/docs/README.md index c8a0cd91609..7803882d6c7 100644 --- a/apps/docs/README.md +++ b/apps/docs/README.md @@ -31,7 +31,7 @@ We use documentation specifications which can be used to generate human-readable - CLISpec (custom to Supabase): for CLI commands and usage. The benefit of using custom specifications is that we can generate many other types from a strict schema (eg, HTML and manpages). -It also means that we can switch to any documentation system we want. On this site we use Next.JS, but on Supabase's official website, we use a custom React site and expose only a subset of the available API for each tool. +It also means that we can switch to any documentation system we want. On this site we use Next.js, but on Supabase's official website, we use a custom React site and expose only a subset of the available API for each tool. ## Contributing diff --git a/apps/docs/app/guides/(with-sidebar)/self-hosting/analytics/config/page.tsx b/apps/docs/app/guides/(with-sidebar)/self-hosting/analytics/config/page.tsx index d397c0003f0..03a88cc171f 100644 --- a/apps/docs/app/guides/(with-sidebar)/self-hosting/analytics/config/page.tsx +++ b/apps/docs/app/guides/(with-sidebar)/self-hosting/analytics/config/page.tsx @@ -21,7 +21,7 @@ const AnalyticsConfigPage = async () => { diff --git a/apps/docs/app/guides/(with-sidebar)/self-hosting/auth/config/page.tsx b/apps/docs/app/guides/(with-sidebar)/self-hosting/auth/config/page.tsx index d8a33d04694..9d6ff028b51 100644 --- a/apps/docs/app/guides/(with-sidebar)/self-hosting/auth/config/page.tsx +++ b/apps/docs/app/guides/(with-sidebar)/self-hosting/auth/config/page.tsx @@ -21,7 +21,7 @@ const AuthConfigPage = async () => { diff --git a/apps/docs/app/guides/(with-sidebar)/self-hosting/realtime/config/page.tsx b/apps/docs/app/guides/(with-sidebar)/self-hosting/realtime/config/page.tsx index 01180ba859c..d28ca1077a7 100644 --- a/apps/docs/app/guides/(with-sidebar)/self-hosting/realtime/config/page.tsx +++ b/apps/docs/app/guides/(with-sidebar)/self-hosting/realtime/config/page.tsx @@ -21,7 +21,7 @@ const RealtimeConfigPage = async () => { diff --git a/apps/docs/app/guides/(with-sidebar)/self-hosting/storage/config/page.tsx b/apps/docs/app/guides/(with-sidebar)/self-hosting/storage/config/page.tsx index 766bc4d190a..1201543f943 100644 --- a/apps/docs/app/guides/(with-sidebar)/self-hosting/storage/config/page.tsx +++ b/apps/docs/app/guides/(with-sidebar)/self-hosting/storage/config/page.tsx @@ -21,7 +21,7 @@ const StorageConfigPage = async () => { diff --git a/apps/docs/app/page.tsx b/apps/docs/app/page.tsx index ad7a21f787b..b01885c6b6d 100644 --- a/apps/docs/app/page.tsx +++ b/apps/docs/app/page.tsx @@ -4,6 +4,7 @@ import { IconBackground, TextLink } from 'ui' import { IconPanel } from 'ui-patterns/IconPanel' import MenuIconPicker from '~/components/Navigation/NavigationMenu/MenuIconPicker' +import { MIGRATION_PAGES } from '~/components/Navigation/NavigationMenu/NavigationMenu.constants' import { GlassPanelWithIconPicker } from '~/features/ui/GlassPanelWithIconPicker' import { IconPanelWithIconPicker } from '~/features/ui/IconPanelWithIconPicker' import HomeLayout from '~/layouts/HomeLayout' @@ -70,60 +71,6 @@ const products = [ }, ] -const migrationGuides = [ - { - title: 'Auth0', - icon: '/docs/img/icons/auth0-icon', - href: '/guides/resources/migrating-to-supabase/auth0', - hasLightIcon: true, - }, - { - title: 'Firebase Auth', - icon: '/docs/img/icons/firebase-icon', - href: '/guides/resources/migrating-to-supabase/firebase-auth', - }, - { - title: 'Firestore Data', - icon: '/docs/img/icons/firebase-icon', - href: '/guides/resources/migrating-to-supabase/firestore-data', - }, - { - title: 'Firebase Storage', - icon: '/docs/img/icons/firebase-icon', - href: '/guides/resources/migrating-to-supabase/firebase-storage', - }, - { - title: 'Heroku', - icon: '/docs/img/icons/heroku-icon', - href: '/guides/resources/migrating-to-supabase/heroku', - }, - { - title: 'Render', - icon: '/docs/img/icons/render-icon', - href: '/guides/resources/migrating-to-supabase/render', - }, - { - title: 'Amazon RDS', - icon: '/docs/img/icons/aws-rds-icon', - href: '/guides/resources/migrating-to-supabase/amazon-rds', - }, - { - title: 'Postgres', - icon: '/docs/img/icons/postgres-icon', - href: '/guides/resources/migrating-to-supabase/postgres', - }, - { - title: 'MySQL', - icon: '/docs/img/icons/mysql-icon', - href: '/guides/resources/migrating-to-supabase/mysql', - }, - { - title: 'MSSQL', - icon: '/docs/img/icons/mssql-icon', - href: '/guides/resources/migrating-to-supabase/mssql', - }, -] - const selfHostingOptions = [ { title: 'Auth', @@ -268,11 +215,11 @@ const HomePage = () => (
    - {migrationGuides.map((guide) => { + {MIGRATION_PAGES.map((guide) => { return ( -
  • - - +
  • + +
  • ) diff --git a/apps/docs/components/MDX/oauth_pkce_flow.mdx b/apps/docs/components/MDX/oauth_pkce_flow.mdx index f4bb7617a45..02beff87623 100644 --- a/apps/docs/components/MDX/oauth_pkce_flow.mdx +++ b/apps/docs/components/MDX/oauth_pkce_flow.mdx @@ -68,7 +68,7 @@ export async function GET(request: Request) { const next = searchParams.get('next') ?? '/' if (code) { - const supabase = createClient() + const supabase = await createClient() const { error } = await supabase.auth.exchangeCodeForSession(code) if (!error) { const forwardedHost = request.headers.get('x-forwarded-host') // original origin before load balancer diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index ac355889d2d..1eff5c9286e 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -1,3 +1,7 @@ +import type { ComponentProps } from 'react' + +import type { IconPanel } from 'ui-patterns/IconPanel' + import type { GlobalMenuItems, NavMenuConstant, NavMenuSection } from '../Navigation.types' export const GLOBAL_MENU_ITEMS: GlobalMenuItems = [ @@ -959,10 +963,6 @@ export const database: NavMenuConstant = { name: 'pg_net: Async Networking', url: '/guides/database/extensions/pg_net', }, - { - name: 'pg_partman: Partition Maintenance', - url: '/guides/database/extensions/pg_partman', - }, { name: 'pg_plan_filter: Restrict Total Cost', url: '/guides/database/extensions/pg_plan_filter', @@ -983,10 +983,6 @@ export const database: NavMenuConstant = { name: 'PostGIS: Geo queries', url: '/guides/database/extensions/postgis', }, - { - name: 'pgmq: Queues', - url: '/guides/database/extensions/pgmq', - }, { name: 'pgsodium (pending deprecation): Encryption Features', url: '/guides/database/extensions/pgsodium', @@ -1809,23 +1805,70 @@ export const local_development: NavMenuConstant = { ], } -export const MIGRATION_PAGES: Partial[] = [ - { name: 'Amazon RDS', url: '/guides/platform/migrating-to-supabase/amazon-rds' }, - { name: 'Auth0', url: '/guides/platform/migrating-to-supabase/auth0' }, - { name: 'Firebase Auth', url: '/guides/platform/migrating-to-supabase/firebase-auth' }, +export const MIGRATION_PAGES: Partial>[] = [ { - name: 'Firebase Firestore', + name: 'Auth0', + icon: '/docs/img/icons/auth0-icon', + url: '/guides/platform/migrating-to-supabase/auth0', + hasLightIcon: true, + }, + { + name: 'Firebase Auth', + icon: '/docs/img/icons/firebase-icon', + url: '/guides/platform/migrating-to-supabase/firebase-auth', + }, + { + name: 'Firestore Data', + icon: '/docs/img/icons/firebase-icon', url: '/guides/platform/migrating-to-supabase/firestore-data', }, { name: 'Firebase Storage', + icon: '/docs/img/icons/firebase-icon', url: '/guides/platform/migrating-to-supabase/firebase-storage', }, - { name: 'Heroku Postgres', url: '/guides/platform/migrating-to-supabase/heroku' }, - { name: 'MySQL', url: '/guides/platform/migrating-to-supabase/mysql' }, - { name: 'MSSQL', url: '/guides/platform/migrating-to-supabase/mssql' }, - { name: 'Postgres', url: '/guides/platform/migrating-to-supabase/postgres' }, - { name: 'Render', url: '/guides/platform/migrating-to-supabase/render' }, + { + name: 'Heroku', + icon: '/docs/img/icons/heroku-icon', + url: '/guides/platform/migrating-to-supabase/heroku', + }, + { + name: 'Render', + icon: '/docs/img/icons/render-icon', + url: '/guides/platform/migrating-to-supabase/render', + }, + { + name: 'Amazon RDS', + icon: '/docs/img/icons/aws-rds-icon', + url: '/guides/platform/migrating-to-supabase/amazon-rds', + }, + { + name: 'Postgres', + icon: '/docs/img/icons/postgres-icon', + url: '/guides/platform/migrating-to-supabase/postgres', + }, + { + name: 'Vercel Postgres', + icon: '/docs/img/icons/vercel-icon', + url: '/guides/platform/migrating-to-supabase/vercel-postgres', + hasLightIcon: true, + }, + { + name: 'Neon', + icon: '/docs/img/icons/neon-icon', + url: '/guides/platform/migrating-to-supabase/neon', + hasLightIcon: true, + }, + { + name: 'MySQL', + icon: '/docs/img/icons/mysql-icon', + url: '/guides/platform/migrating-to-supabase/mysql', + }, + { + name: 'MSSQL', + icon: '/docs/img/icons/mssql-icon', + url: '/guides/platform/migrating-to-supabase/mssql', + }, ] export const platform: NavMenuConstant = { diff --git a/apps/docs/content/guides/auth/auth-helpers/nextjs.mdx b/apps/docs/content/guides/auth/auth-helpers/nextjs.mdx index b0f1f3cbd30..5b64c6a4f0e 100644 --- a/apps/docs/content/guides/auth/auth-helpers/nextjs.mdx +++ b/apps/docs/content/guides/auth/auth-helpers/nextjs.mdx @@ -20,7 +20,7 @@ The `auth-helpers` package has been replaced with the `@supabase/ssr` package. W size="medium" className="text-foreground-light border-b mt-8 pb-2" > - + ({ cookies: () => cookieStore }) await supabase.auth.exchangeCodeForSession(code) } diff --git a/apps/docs/content/guides/auth/auth-hooks/send-email-hook.mdx b/apps/docs/content/guides/auth/auth-hooks/send-email-hook.mdx index f9df8e203c7..c6e2f30ab0b 100644 --- a/apps/docs/content/guides/auth/auth-hooks/send-email-hook.mdx +++ b/apps/docs/content/guides/auth/auth-hooks/send-email-hook.mdx @@ -6,6 +6,17 @@ subtitle: 'Use a custom email provider to send authentication messages' The Send Email Hook runs before an email is sent and allows for flexibility around email sending. You can use this hook to configure a back-up email provider or add internationalization to your emails. +## Email Sending Behavior + +Email sending depends on two settings: Email Provider and Auth Hook status. + +| Email Provider | Auth Hook | Result | +| -------------- | --------- | -------------------------------------------------------------------- | +| Enabled | Enabled | Auth Hook handles email sending (SMTP not used) | +| Enabled | Disabled | SMTP handles email sending (custom if configured, default otherwise) | +| Disabled | Enabled | Email Signups Disabled | +| Disabled | Disabled | Email Signups Disabled | + **Inputs** | Field | Type | Description | @@ -630,6 +641,11 @@ const templates = { }, } +function generateConfirmationURL(email_data) { + // TODO: replace the ref with your project ref + return `https://.supabase.co/auth/v1/verify?token=${email_data.token}&type=${email_data.email_action_type}&redirect_to=${email_data.redirect_to}` +} + Deno.serve(async (req) => { const payload = await req.text() const serverToken = Deno.env.get('POSTMARK_SERVER_TOKEN') @@ -642,8 +658,9 @@ Deno.serve(async (req) => { const subject = subjects[language][email_data.email_action_type] || 'Notification' let template = templates[language][email_data.email_action_type] + const confirmation_url = generateConfirmationURL(email_data) let htmlBody = template - .replace('{{confirmation_url}}', email_data.confirmation_url) + .replace('{{confirmation_url}}', confirmation_url) .replace('{{token}}', email_data.token || '') .replace('{{new_token}}', email_data.new_token || '') .replace('{{site_url}}', email_data.site_url || '') @@ -736,10 +753,16 @@ const templates = { reauthentication: `

    Confirm reauthentication

    Enter the code: {{token}}

    ` }; +function generateConfirmationURL(email_data) { + // TODO: replace the ref with your project ref + return `https://.supabase.co/auth/v1/verify?token=${email_data.token}&type=${email_data.email_action_type}&redirect_to=${email_data.redirect_to}` +} + async function sendEmailWithPostmark(user: any, email_data: any, serverToken: string): Promise { const subject = subjects[email_data.email_action_type] || 'Notification'; + const confirmation_url = generateConfirmationURL(email_data) let template = templates[email_data.email_action_type]; - let htmlBody = template.replace('{{confirmation_url}}', email_data.confirmation_url) + let htmlBody = template.replace('{{confirmation_url}}', confirmation_url) .replace('{{token}}', email_data.token || '') .replace('{{new_token}}', email_data.new_token || '') .replace('{{site_url}}', email_data.site_url || '') @@ -767,7 +790,8 @@ async function sendEmailWithPostmark(user: any, email_data: any, serverToken: st async function sendEmailWithSendGrid(user: any, email_data: any, apiKey: string): Promise { const subject = subjects[email_data.email_action_type] || 'Notification'; let template = templates[email_data.email_action_type]; - let htmlBody = template.replace('{{confirmation_url}}', email_data.confirmation_url) + cont confirmation_url = generateConfirmationURL(email_data) + let htmlBody = template.replace('{{confirmation_url}}', confirmation_url) .replace('{{token}}', email_data.token || '') .replace('{{new_token}}', email_data.new_token || '') .replace('{{site_url}}', email_data.site_url || '') diff --git a/apps/docs/content/guides/auth/passwords.mdx b/apps/docs/content/guides/auth/passwords.mdx index c671ac333dc..9d3d0f83bf1 100644 --- a/apps/docs/content/guides/auth/passwords.mdx +++ b/apps/docs/content/guides/auth/passwords.mdx @@ -184,7 +184,7 @@ export async function GET(request: NextRequest) { redirectTo.pathname = next if (token_hash && type) { - const supabase = createClient() + const supabase = await createClient() const { error } = await supabase.auth.verifyOtp({ type, @@ -648,7 +648,7 @@ export async function GET(request: NextRequest) { redirectTo.pathname = next if (token_hash && type) { - const supabase = createClient() + const supabase = await createClient() const { error } = await supabase.auth.verifyOtp({ type, diff --git a/apps/docs/content/guides/auth/quickstarts/nextjs.mdx b/apps/docs/content/guides/auth/quickstarts/nextjs.mdx index 9a7117247af..6db4230d2ac 100644 --- a/apps/docs/content/guides/auth/quickstarts/nextjs.mdx +++ b/apps/docs/content/guides/auth/quickstarts/nextjs.mdx @@ -72,9 +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`. - - Check out the [Supabase Auth docs](https://supabase.com/docs/guides/auth#authentication) for more authentication methods. + To sign up a new user, navigate to http://localhost:3000/login, and click `Sign Up Now`. @@ -88,3 +86,8 @@ hideToc: true + +## Learn more + +- [Setting up Server-Side Auth for Next.js](https://supabase.com/docs/guides/auth/server-side/nextjs) for a Next.js deep dive +- [Supabase Auth docs](https://supabase.com/docs/guides/auth#authentication) for more Supabase authentication methods diff --git a/apps/docs/content/guides/auth/server-side/creating-a-client.mdx b/apps/docs/content/guides/auth/server-side/creating-a-client.mdx index b9e5d752c51..7d8e3f0ecc3 100644 --- a/apps/docs/content/guides/auth/server-side/creating-a-client.mdx +++ b/apps/docs/content/guides/auth/server-side/creating-a-client.mdx @@ -171,8 +171,8 @@ export function createClient() { import { createServerClient } from '@supabase/ssr' import { cookies } from 'next/headers' -export function createClient() { - const cookieStore = cookies() +export async function createClient() { + const cookieStore = await cookies() return createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, diff --git a/apps/docs/content/guides/auth/server-side/nextjs.mdx b/apps/docs/content/guides/auth/server-side/nextjs.mdx index 384f78b869a..8189371cc26 100644 --- a/apps/docs/content/guides/auth/server-side/nextjs.mdx +++ b/apps/docs/content/guides/auth/server-side/nextjs.mdx @@ -125,8 +125,8 @@ export function createClient() { import { createServerClient, type CookieOptions } from '@supabase/ssr' import { cookies } from 'next/headers' -export function createClient() { - const cookieStore = cookies() +export async function createClient() { + const cookieStore = await cookies() return createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -337,7 +337,7 @@ import { redirect } from 'next/navigation' import { createClient } from '@/utils/supabase/server' export async function login(formData: FormData) { - const supabase = createClient() + const supabase = await createClient() // type-casting here for convenience // in practice, you should validate your inputs @@ -357,7 +357,7 @@ export async function login(formData: FormData) { } export async function signup(formData: FormData) { - const supabase = createClient() + const supabase = await createClient() // type-casting here for convenience // in practice, you should validate your inputs @@ -429,7 +429,7 @@ export async function GET(request: NextRequest) { const next = searchParams.get('next') ?? '/' if (token_hash && type) { - const supabase = createClient() + const supabase = await createClient() const { error } = await supabase.auth.verifyOtp({ type, @@ -482,7 +482,7 @@ import { redirect } from 'next/navigation' import { createClient } from '@/utils/supabase/server' export default async function PrivatePage() { - const supabase = createClient() + const supabase = await createClient() const { data, error } = await supabase.auth.getUser() if (error || !data?.user) { diff --git a/apps/docs/content/guides/auth/social-login/auth-github.mdx b/apps/docs/content/guides/auth/social-login/auth-github.mdx index 70b3e077f61..db3e01abe02 100644 --- a/apps/docs/content/guides/auth/social-login/auth-github.mdx +++ b/apps/docs/content/guides/auth/social-login/auth-github.mdx @@ -77,6 +77,20 @@ Future signInWithGithub() async { } ``` + + + +When your user signs in, call [signInWithWithOAuth](/docs/reference/swift/auth-signinwithoauth) with `.github` as the `Provider`: + +```swift +func signInWithGithub() async throws { + try await supabase.auth.signInWithOAuth( + provider: .github, + redirectTo: URL(string: "my-custom-scheme://my-app-host") + ) +} +``` + diff --git a/apps/docs/content/guides/auth/social-login/auth-google.mdx b/apps/docs/content/guides/auth/social-login/auth-google.mdx index 8ebb31ea2c3..c79d6d15300 100644 --- a/apps/docs/content/guides/auth/social-login/auth-google.mdx +++ b/apps/docs/content/guides/auth/social-login/auth-google.mdx @@ -341,9 +341,9 @@ Most web apps and websites can utilize Google's [personalized sign-in buttons](h // Use 'nonce' when invoking the supabase.auth.signInWithIdToken() method ``` -### One-tap with NextJS +### One-tap with Next.js -If you're integrating Google One-Tap with your NextJS application, you can refer to the example below to get started: +If you're integrating Google One-Tap with your Next.js application, you can refer to the example below to get started: ```tsx 'use client' diff --git a/apps/docs/content/guides/database/connecting-to-postgres.mdx b/apps/docs/content/guides/database/connecting-to-postgres.mdx index 09ede71962c..dbf6406a713 100644 --- a/apps/docs/content/guides/database/connecting-to-postgres.mdx +++ b/apps/docs/content/guides/database/connecting-to-postgres.mdx @@ -128,7 +128,7 @@ If you'd like us to add a new quickstart guide, share your [feedback](https://su - [Prisma](/docs/guides/database/prisma) - [Drizzle](/docs/guides/database/drizzle) -- [Postgres.js](/docs/guides/database/drizzle) +- [Postgres.js](/docs/guides/database/postgres-js) ### External tools diff --git a/apps/docs/content/guides/database/custom-postgres-config.mdx b/apps/docs/content/guides/database/custom-postgres-config.mdx index ba8093c1c4e..1fb50e31c2e 100644 --- a/apps/docs/content/guides/database/custom-postgres-config.mdx +++ b/apps/docs/content/guides/database/custom-postgres-config.mdx @@ -160,6 +160,6 @@ alter role "" set "" to default; ### Considerations -1. Changes through the CLI must restart the database and will cause momentary disruption to existing database connections; in most cases this should not take more than a few seconds. +1. Changes through the CLI must restart the database and will cause momentary disruption to existing database connections; in most cases this should not take more than a few seconds. However, you can use the --no-restart flag to bypass the restart and keep the connections intact. Keep in mind that this depends on the specific configuration changes you’re making. if the change requires a restart, using the --no-restart flag will prevent the restart but you won’t see those changes take effect until a restart is manually triggered. 1. Custom Postgres Config will always override the default optimizations generated by Supabase. When changing compute add-ons, you should also review and update your custom Postgres Config to ensure they remain compatible and effective with the updated compute. 1. Some parameters (e.g. `wal_keep_size`) can increase disk utilization, triggering disk expansion, which in turn can lead to [increases in your bill](/docs/guides/platform/compute-add-ons#disk-io). diff --git a/apps/docs/content/guides/database/extensions/pg_net.mdx b/apps/docs/content/guides/database/extensions/pg_net.mdx index 8556179afb4..bfedae9c95d 100644 --- a/apps/docs/content/guides/database/extensions/pg_net.mdx +++ b/apps/docs/content/guides/database/extensions/pg_net.mdx @@ -309,9 +309,9 @@ order by "created" desc; ## Configuration - + -See the [self-hosting docs](/docs/guides/hosting/overview#configuration) for self-hosting instructions. +Supabase supports reconfiguring pg*net starting from v0.10.0+. For the latest release, initiate a postgres upgrade in the [Infrastructure Settings](https://supabase.com/dashboard/project/*/settings/infrastructure). @@ -329,18 +329,11 @@ where "name" like 'pg_net%'; ### Alter settings -Note, that doing `ALTER SYSTEM` requires SUPERUSER but on PostgreSQL >= 15, you can do: +Change variables: ```sql -grant alter system on parameter pg_net.ttl to ; -grant alter system on parameter pg_net.batch_size to ; -``` - -Then variables can be changed: - -```sql -alter system set pg_net.ttl to '1 hour' -alter system set pg_net.batch_size to 500; +alter role "postgres" set pg_net.ttl to '24 hour'; +alter role "postgres" set pg_net.batch_size to 500; ``` Then reload the settings and restart the `pg_net` background worker with: diff --git a/apps/docs/content/guides/database/extensions/pg_partman.mdx b/apps/docs/content/guides/database/extensions/pg_partman.mdx deleted file mode 100644 index d907d55cdca..00000000000 --- a/apps/docs/content/guides/database/extensions/pg_partman.mdx +++ /dev/null @@ -1,100 +0,0 @@ ---- -id: 'pg_partman' -title: 'pg_partman: partition management' -description: 'Automated partition management' ---- - -[`pg_partman`](https://github.com/pgpartman/pg_partman) is a PostgreSQL extension that enables automated partition management. It removes the need to create and detach partitions for tables partitioned by time or an integer series. - -## Enable the extension - -To enable `pg_partman`, first enable the extension in your database inside a dedicated schema. - -{/* prettier-ignore */} -```sql -create schema partman; -create extension pg_partman schema partman; -``` - -## Setting up Partitioning - -pg_partman supports partitioning based on time (e.g., by day, month, or year) or by a sequential integer. Setup is performed using the `create_parent` function. - -For time-based partitioning: - -{/* prettier-ignore */} -```sql -select partman.create_parent('schema_name.table_name', 'partition_column', 'time', 'daily'); -``` - -or for integer-based partitioning: - -{/* prettier-ignore */} -```sql -select partman.create_parent('schema_name.table_name', 'partition_column', 'integer', '1000'); -``` - -## API - -- partman.create_parent: creates a parent table for partitioning -- partman.run_maintenance: ensures partitions are created and dropped as per the defined settings. This should be run periodically, such as via a cron job scheduled by pg_cron - -## Example - -Let’s set up a simple partitioning example using pg_partman with time-based partitions. - -{/* prettier-ignore */} -```sql --- Create the table -create table public.foo ( - id serial primary key, - data text, - created_at timestamp not null -); - --- Create the parent table -select - partman.create_parent( - 'public.foo', - 'created_at', - 'time', - 'daily' - ); - --- Configure the partitioned table -update - partman.part_config -set - infinite_time_partitions = true, - retention = '3 months', - retention_keep_table=true -where - parent_table = 'public.foo'; -``` - -For more information about available configuration, see the [pg_partman docs](https://github.com/pgpartman/pg_partman/blob/de93f4a104b8566087f4b9b2a0edf25893672da8/doc/pg_partman.md#configuration-tables). - -Following these statements, partitions for `public.foo` will be automatically managed as necessary when the `partman.run_maintenance()` function is called. - -## Running Maintenance - -It is important to call the `partman.run_maintenance()` procedure frequently to ensure partitions are created and detached according to the partitioned table's configuration. - -{/* prettier-ignore */} -```sql -call partman.run_maintenance_proc(); -``` - -To automate this task, consider using `pg_cron`. - -{/* prettier-ignore */} -```sql -create extension pg_cron; - -select - cron.schedule('@hourly', $$call partman.run_maintenance_proc()$$); -``` - -## Resources - -- Official [pg_partman documentation](https://github.com/pgpartman/pg_partman/blob/master/doc/pg_partman_howto.md) diff --git a/apps/docs/content/guides/database/extensions/pgmq.mdx b/apps/docs/content/guides/database/extensions/pgmq.mdx deleted file mode 100644 index bd33c51dad7..00000000000 --- a/apps/docs/content/guides/database/extensions/pgmq.mdx +++ /dev/null @@ -1,707 +0,0 @@ ---- -id: 'pgmq' -title: 'pgmq: Queues' -description: 'pgmq: Managed queues in Postgres' ---- - -pgmq is a lightweight message queue built on Postgres. - -## Features - -- Lightweight - No background worker or external dependencies, just Postgres functions packaged in an extension -- "exactly once" delivery of messages to a consumer within a visibility timeout -- API parity with AWS SQS and RSMQ -- Messages stay in the queue until explicitly removed -- Messages can be archived, instead of deleted, for long-term retention and replayability - -## Enable the extension - -```sql -create extension pgmq; -``` - -## Usage [#get-usage] - -### Sending Messages - -#### send - -Send a single message to a queue. - -{/* prettier-ignore */} -```sql -pgmq.send( - queue_name text, - msg jsonb, - delay integer default 0 -) -returns setof bigint -``` - -**Parameters:** - -| Parameter | Type | Description | -| :--------- | :------ | :----------------------------------------------------------------- | -| queue_name | text | The name of the queue | -| msg | jsonb | The message to send to the queue | -| delay | integer | Time in seconds before the message becomes visible. Defaults to 0. | - -Example: - -{/* prettier-ignore */} -```sql -select * from pgmq.send('my_queue', '{"hello": "world"}'); - send ------- - 4 -``` - ---- - -#### send_batch - -Send 1 or more messages to a queue. - -{/* prettier-ignore */} -```sql -pgmq.send_batch( - queue_name text, - msgs jsonb[], - delay integer default 0 -) -returns setof bigint -``` - -**Parameters:** - -| Parameter | Type | Description | -| :--------- | :------ | :------------------------------------------------------------------ | -| queue_name | text | The name of the queue | -| msgs | jsonb[] | Array of messages to send to the queue | -| delay | integer | Time in seconds before the messages becomes visible. Defaults to 0. | - -{/* prettier-ignore */} -```sql -select * from pgmq.send_batch( - 'my_queue', - array[ - '{"hello": "world_0"}'::jsonb, - '{"hello": "world_1"}'::jsonb - ] -); - send_batch ------------- - 1 - 2 -``` - ---- - -### Reading Messages - -#### read - -Read 1 or more messages from a queue. The VT specifies the delay in seconds between reading and the message becoming invisible to other consumers. - -{/* prettier-ignore */} -```sql -pgmq.read( - queue_name text, - vt integer, - qty integer -) - -returns setof pgmq.message_record -``` - -**Parameters:** - -| Parameter | Type | Description | -| :--------- | :------ | :-------------------------------------------------------------- | -| queue_name | text | The name of the queue | -| vt | integer | Time in seconds that the message become invisible after reading | -| qty | integer | The number of messages to read from the queue. Defaults to 1 | - -Example: - -{/* prettier-ignore */} -```sql -select * from pgmq.read('my_queue', 10, 2); - msg_id | read_ct | enqueued_at | vt | message ---------+---------+-------------------------------+-------------------------------+---------------------- - 1 | 1 | 2023-10-28 19:14:47.356595-05 | 2023-10-28 19:17:08.608922-05 | {"hello": "world_0"} - 2 | 1 | 2023-10-28 19:14:47.356595-05 | 2023-10-28 19:17:08.608974-05 | {"hello": "world_1"} -(2 rows) -``` - ---- - -#### read_with_poll - -Same as read(). Also provides convenient long-poll functionality. -When there are no messages in the queue, the function call will wait for `max_poll_seconds` in duration before returning. -If messages reach the queue during that duration, they will be read and returned immediately. - -{/* prettier-ignore */} -```sql - pgmq.read_with_poll( - queue_name text, - vt integer, - qty integer, - max_poll_seconds integer default 5, - poll_interval_ms integer default 100 -) -returns setof pgmq.message_record -``` - -**Parameters:** - -| Parameter | Type | Description | -| :--------------- | :------ | :-------------------------------------------------------------------------- | -| queue_name | text | The name of the queue | -| vt | integer | Time in seconds that the message become invisible after reading. | -| qty | integer | The number of messages to read from the queue. Defaults to 1. | -| max_poll_seconds | integer | Time in seconds to wait for new messages to reach the queue. Defaults to 5. | -| poll_interval_ms | integer | Milliseconds between the internal poll operations. Defaults to 100. | - -Example: - -{/* prettier-ignore */} -```sql -select * from pgmq.read_with_poll('my_queue', 1, 1, 5, 100); - msg_id | read_ct | enqueued_at | vt | message ---------+---------+-------------------------------+-------------------------------+-------------------- - 1 | 1 | 2023-10-28 19:09:09.177756-05 | 2023-10-28 19:27:00.337929-05 | {"hello": "world"} -``` - ---- - -#### pop - -Reads a single message from a queue and deletes it upon read. - -Note: utilization of pop() results in at-most-once delivery semantics if the consuming application does not guarantee processing of the message. - -{/* prettier-ignore */} -```sql -pgmq.pop(queue_name text) -returns setof pgmq.message_record -``` - -**Parameters:** - -| Parameter | Type | Description | -| :--------- | :--- | :-------------------- | -| queue_name | text | The name of the queue | - -Example: - -{/* prettier-ignore */} -```sql -pgmq=# select * from pgmq.pop('my_queue'); - msg_id | read_ct | enqueued_at | vt | message ---------+---------+-------------------------------+-------------------------------+-------------------- - 1 | 2 | 2023-10-28 19:09:09.177756-05 | 2023-10-28 19:27:00.337929-05 | {"hello": "world"} -``` - ---- - -### Deleting/Archiving Messages - -#### delete (single) - -Deletes a single message from a queue. - -{/* prettier-ignore */} -```sql -pgmq.delete (queue_name text, msg_id: bigint) -returns boolean -``` - -**Parameters:** - -| Parameter | Type | Description | -| :--------- | :----- | :---------------------------------- | -| queue_name | text | The name of the queue | -| msg_id | bigint | Message ID of the message to delete | - -Example: - -{/* prettier-ignore */} -```sql -select pgmq.delete('my_queue', 5); - delete --------- - t -``` - ---- - -#### delete (batch) - -Delete one or many messages from a queue. - -{/* prettier-ignore */} -```sql -pgmq.delete (queue_name text, msg_ids: bigint[]) -returns setof bigint -``` - -**Parameters:** - -| Parameter | Type | Description | -| :--------- | :------- | :----------------------------- | -| queue_name | text | The name of the queue | -| msg_ids | bigint[] | Array of message IDs to delete | - -Examples: - -Delete two messages that exist. - -{/* prettier-ignore */} -```sql -select * from pgmq.delete('my_queue', array[2, 3]); - delete --------- - 2 - 3 -``` - -Delete two messages, one that exists and one that does not. Message `999` does not exist. - -```sql -select * from pgmq.delete('my_queue', array[6, 999]); - delete --------- - 6 -``` - ---- - -#### purge_queue - -Permanently deletes all messages in a queue. Returns the number of messages that were deleted. - -```text -purge_queue(queue_name text) -returns bigint -``` - -**Parameters:** - -| Parameter | Type | Description | -| :--------- | :--- | :-------------------- | -| queue_name | text | The name of the queue | - -Example: - -Purge the queue when it contains 8 messages; - -{/* prettier-ignore */} -```sql -select * from pgmq.purge_queue('my_queue'); - purge_queue -------------- - 8 -``` - ---- - -#### archive (single) - -Removes a single requested message from the specified queue and inserts it into the queue's archive. - -{/* prettier-ignore */} -```sql -pgmq.archive(queue_name text, msg_id bigint) -returns boolean -``` - -**Parameters:** - -| Parameter | Type | Description | -| :--------- | :----- | :----------------------------------- | -| queue_name | text | The name of the queue | -| msg_id | bigint | Message ID of the message to archive | - -Returns -Boolean value indicating success or failure of the operation. - -Example; remove message with ID 1 from queue `my_queue` and archive it: - -{/* prettier-ignore */} -```sql -select * from pgmq.archive('my_queue', 1); - archive ---------- - t -``` - ---- - -#### archive (batch) - -Deletes a batch of requested messages from the specified queue and inserts them into the queue's archive. -Returns an array of message ids that were successfully archived. - -```text -pgmq.archive(queue_name text, msg_ids bigint[]) -RETURNS SETOF bigint -``` - -**Parameters:** - -| Parameter | Type | Description | -| :--------- | :------- | :------------------------------ | -| queue_name | text | The name of the queue | -| msg_ids | bigint[] | Array of message IDs to archive | - -Examples: - -Delete messages with ID 1 and 2 from queue `my_queue` and move to the archive. - -{/* prettier-ignore */} -```sql -select * from pgmq.archive('my_queue', array[1, 2]); - archive ---------- - 1 - 2 -``` - -Delete messages 4, which exists and 999, which does not exist. - -{/* prettier-ignore */} -```sql -select * from pgmq.archive('my_queue', array[4, 999]); - archive ---------- - 4 -``` - ---- - -### Queue Management - -#### create - -Create a new queue. - -{/* prettier-ignore */} -```sql -pgmq.create(queue_name text) -returns void -``` - -**Parameters:** - -| Parameter | Type | Description | -| :--------- | :--- | :-------------------- | -| queue_name | text | The name of the queue | - -Example: - -{/* prettier-ignore */} -```sql -select from pgmq.create('my_queue'); - create --------- -``` - ---- - -#### create_partitioned - -Create a partitioned queue. - -{/* prettier-ignore */} -```sql -pgmq.create_partitioned ( - queue-ue_name text, - partition_interval text default '10000'::text, - retention_interval text default '100000'::text -) -returns void -``` - -**Parameters:** - -| Parameter | Type | Description | -| :----------------- | :--- | :-------------------- | -| queue_name | text | The name of the queue | -| partition_interval | text | The name of the queue | -| retention_interval | text | The name of the queue | - -Example: - -Create a queue with 100,000 messages per partition, and will retain 10,000,000 messages on old partitions. Partitions greater than this will be deleted. - -{/* prettier-ignore */} -```sql -select * from pgmq.create_partitioned( - 'my_partitioned_queue', - '100000', - '10000000' -); - create_partitioned --------------------- -``` - ---- - -#### create_unlogged - -Creates an unlogged table. This is useful when write throughput is more important that durability. -See Postgres documentation for [unlogged tables](https://www.postgresql.org/docs/current/sql-createtable.html#SQL-CREATETABLE-UNLOGGED) for more information. - -{/* prettier-ignore */} -```sql -pgmq.create_unlogged(queue_name text) -returns void -``` - -**Parameters:** - -| Parameter | Type | Description | -| :--------- | :--- | :-------------------- | -| queue_name | text | The name of the queue | - -Example: - -{/* prettier-ignore */} -```sql -select pgmq.create_unlogged('my_unlogged'); - create_unlogged ------------------ -``` - ---- - -#### detach_archive - -Drop the queue's archive table as a member of the PGMQ extension. Useful for preventing the queue's archive table from being drop when `drop extension pgmq` is executed. -This does not prevent the further archives() from appending to the archive table. - -{/* prettier-ignore */} -```sql -pgmq.detach_archive(queue_name text) -``` - -**Parameters:** - -| Parameter | Type | Description | -| :--------- | :--- | :-------------------- | -| queue_name | text | The name of the queue | - -Example: - -{/* prettier-ignore */} -```sql -select * from pgmq.detach_archive('my_queue'); - detach_archive ----------------- -``` - ---- - -#### drop_queue - -Deletes a queue and its archive table. - -{/* prettier-ignore */} -```sql -pgmq.drop_queue(queue_name text) -returns boolean -``` - -**Parameters:** - -| Parameter | Type | Description | -| :--------- | :--- | :-------------------- | -| queue_name | text | The name of the queue | - -Example: - -{/* prettier-ignore */} -```sql -select * from pgmq.drop_queue('my_unlogged'); - drop_queue ------------- - t -``` - -### Utilities - -#### set_vt - -Sets the visibility timeout of a message to a specified time duration in the future. Returns the record of the message that was updated. - -{/* prettier-ignore */} -```sql -pgmq.set_vt( - queue_name text, - msg_id bigint, - vt_offset integer -) -returns pgmq.message_record -``` - -**Parameters:** - -| Parameter | Type | Description | -| :--------- | :------ | :-------------------------------------------------------------------- | -| queue_name | text | The name of the queue | -| msg_id | bigint | ID of the message to set visibility time | -| vt_offset | integer | Duration from now, in seconds, that the message's VT should be set to | - -Example: - -Set the visibility timeout of message 1 to 30 seconds from now. - -```sql -select * from pgmq.set_vt('my_queue', 11, 30); - msg_id | read_ct | enqueued_at | vt | message ---------+---------+-------------------------------+-------------------------------+---------------------- - 1 | 0 | 2023-10-28 19:42:21.778741-05 | 2023-10-28 19:59:34.286462-05 | {"hello": "world_0"} -``` - ---- - -#### list_queues - -List all the queues that currently exist. - -{/* prettier-ignore */} -```sql -list_queues() -RETURNS TABLE( - queue_name text, - created_at timestamp with time zone, - is_partitioned boolean, - is_unlogged boolean -) -``` - -Example: - -{/* prettier-ignore */} -```sql -select * from pgmq.list_queues(); - queue_name | created_at | is_partitioned | is_unlogged -----------------------+-------------------------------+----------------+------------- - my_queue | 2023-10-28 14:13:17.092576-05 | f | f - my_partitioned_queue | 2023-10-28 19:47:37.098692-05 | t | f - my_unlogged | 2023-10-28 20:02:30.976109-05 | f | t -``` - ---- - -#### metrics - -Get metrics for a specific queue. - -{/* prettier-ignore */} -```sql -pgmq.metrics(queue_name: text) -returns table( - queue_name text, - queue_length bigint, - newest_msg_age_sec integer, - oldest_msg_age_sec integer, - total_messages bigint, - scrape_time timestamp with time zone -) -``` - -**Parameters:** - -| Parameter | Type | Description | -| :--------- | :--- | :-------------------- | -| queue_name | text | The name of the queue | - -**Returns:** - -| Attribute | Type | Description | -| :----------------- | :----------------------- | :------------------------------------------------------------------------ | -| queue_name | text | The name of the queue | -| queue_length | bigint | Number of messages currently in the queue | -| newest_msg_age_sec | integer \| null | Age of the newest message in the queue, in seconds | -| oldest_msg_age_sec | integer \| null | Age of the oldest message in the queue, in seconds | -| total_messages | bigint | Total number of messages that have passed through the queue over all time | -| scrape_time | timestamp with time zone | The current timestamp | - -Example: - -{/* prettier-ignore */} -```sql -select * from pgmq.metrics('my_queue'); - queue_name | queue_length | newest_msg_age_sec | oldest_msg_age_sec | total_messages | scrape_time -------------+--------------+--------------------+--------------------+----------------+------------------------------- - my_queue | 16 | 2445 | 2447 | 35 | 2023-10-28 20:23:08.406259-05 -``` - ---- - -#### metrics_all - -Get metrics for all existing queues. - -```text -pgmq.metrics_all() -RETURNS TABLE( - queue_name text, - queue_length bigint, - newest_msg_age_sec integer, - oldest_msg_age_sec integer, - total_messages bigint, - scrape_time timestamp with time zone -) -``` - -**Returns:** - -| Attribute | Type | Description | -| :----------------- | :----------------------- | :------------------------------------------------------------------------ | -| queue_name | text | The name of the queue | -| queue_length | bigint | Number of messages currently in the queue | -| newest_msg_age_sec | integer \| null | Age of the newest message in the queue, in seconds | -| oldest_msg_age_sec | integer \| null | Age of the oldest message in the queue, in seconds | -| total_messages | bigint | Total number of messages that have passed through the queue over all time | -| scrape_time | timestamp with time zone | The current timestamp | - -{/* prettier-ignore */} -```sql -select * from pgmq.metrics_all(); - queue_name | queue_length | newest_msg_age_sec | oldest_msg_age_sec | total_messages | scrape_time -----------------------+--------------+--------------------+--------------------+----------------+------------------------------- - my_queue | 16 | 2563 | 2565 | 35 | 2023-10-28 20:25:07.016413-05 - my_partitioned_queue | 1 | 11 | 11 | 1 | 2023-10-28 20:25:07.016413-05 - my_unlogged | 1 | 3 | 3 | 1 | 2023-10-28 20:25:07.016413-05 -``` - -### Types - -#### message_record - -The complete representation of a message in a queue. - -| Attribute Name | Type | Description | -| :------------- | :----------------------- | :--------------------------------------------------------------------- | -| msg_id | bigint | Unique ID of the message | -| read_ct | bigint | Number of times the message has been read. Increments on read(). | -| enqueued_at | timestamp with time zone | time that the message was inserted into the queue | -| vt | timestamp with time zone | Timestamp when the message will become available for consumers to read | -| message | jsonb | The message payload | - -Example: - -{/* prettier-ignore */} -```sql - msg_id | read_ct | enqueued_at | vt | message ---------+---------+-------------------------------+-------------------------------+-------------------- - 1 | 1 | 2023-10-28 19:06:19.941509-05 | 2023-10-28 19:06:27.419392-05 | {"hello": "world"} -``` - -## Resources - -- Official Docs: [pgmq/api](https://tembo.io/pgmq/#creating-a-queue) diff --git a/apps/docs/content/guides/functions/limits.mdx b/apps/docs/content/guides/functions/limits.mdx index fe7c2475d6a..c21f46009fd 100644 --- a/apps/docs/content/guides/functions/limits.mdx +++ b/apps/docs/content/guides/functions/limits.mdx @@ -14,8 +14,6 @@ subtitle: "Limits applied Edge Functions in Supabase's hosted platform." - Maximum Function Size (after bundling via CLI): 10MB - Maximum log message length: 10,000 characters - Log event threshold: 100 events per 10 seconds -- Max Request: 5GB -- Max Response: No limit ## Other limits & restrictions diff --git a/apps/docs/content/guides/getting-started/quickstarts/nextjs.mdx b/apps/docs/content/guides/getting-started/quickstarts/nextjs.mdx index 720ea4966e5..1b11196eaeb 100644 --- a/apps/docs/content/guides/getting-started/quickstarts/nextjs.mdx +++ b/apps/docs/content/guides/getting-started/quickstarts/nextjs.mdx @@ -8,7 +8,7 @@ hideToc: true - + Go to [database.new](https://database.new) and create a new Supabase project. @@ -118,7 +118,7 @@ hideToc: true import { createClient } from '@/utils/supabase/server'; export default async function Notes() { - const supabase = createClient(); + const supabase = await createClient(); const { data: notes } = await supabase.from("notes").select(); return
    {JSON.stringify(notes, null, 2)}
    @@ -129,8 +129,8 @@ hideToc: true import { createServerClient } from '@supabase/ssr' import { cookies } from 'next/headers' - export function createClient() { - const cookieStore = cookies() + export async function createClient() { + const cookieStore = await cookies() return createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, diff --git a/apps/docs/content/guides/getting-started/tutorials/with-nextjs.mdx b/apps/docs/content/guides/getting-started/tutorials/with-nextjs.mdx index b849b2b1af0..02d0c095791 100644 --- a/apps/docs/content/guides/getting-started/tutorials/with-nextjs.mdx +++ b/apps/docs/content/guides/getting-started/tutorials/with-nextjs.mdx @@ -385,7 +385,7 @@ import { redirect } from 'next/navigation' import { createClient } from '@/utils/supabase/server' export async function login(formData) { - const supabase = createClient() + const supabase = await createClient() // type-casting here for convenience // in practice, you should validate your inputs @@ -405,7 +405,7 @@ export async function login(formData) { } export async function signup(formData) { - const supabase = createClient() + const supabase = await createClient() const data = { email: formData.get('email'), @@ -512,7 +512,7 @@ export async function GET(request) { redirectTo.searchParams.delete('type') if (token_hash && type) { - const supabase = createClient() + const supabase = await createClient() const { error } = await supabase.auth.verifyOtp({ type, @@ -720,7 +720,7 @@ import AccountForm from './account-form' import { createClient } from '@/utils/supabase/server' export default async function Account() { - const supabase = createClient() + const supabase = await createClient() const { data: { user }, diff --git a/apps/docs/content/guides/monitoring-troubleshooting/advanced-log-filtering.mdx b/apps/docs/content/guides/monitoring-troubleshooting/advanced-log-filtering.mdx index 670f5f958d6..2097c3d163a 100644 --- a/apps/docs/content/guides/monitoring-troubleshooting/advanced-log-filtering.mdx +++ b/apps/docs/content/guides/monitoring-troubleshooting/advanced-log-filtering.mdx @@ -7,7 +7,7 @@ description: 'Filter logs with regular expressions' ## Understanding field references -The log tables are queried with subset of BigQuery SQL syntax. They all have three columns: `event_message`, `timestamp`, and `metadata`. +The log tables are queried with a subset of BigQuery SQL syntax. They all have three columns: `event_message`, `timestamp`, and `metadata`. | column | description | | ------------- | --------------------------- | diff --git a/apps/docs/content/guides/platform/billing-faq.mdx b/apps/docs/content/guides/platform/billing-faq.mdx index 09ddc3ef0b6..f1bdf94d83e 100644 --- a/apps/docs/content/guides/platform/billing-faq.mdx +++ b/apps/docs/content/guides/platform/billing-faq.mdx @@ -154,6 +154,10 @@ Any changes made to your billing details will only be reflected in your upcoming We accept credit card payments only. If you cannot pay via credit card, we do offer alternatives for larger upfront payments. Please create a [support ticket](https://supabase.help) in case you’re interested. +#### What credit card brands are supported? + +Visa, Mastercard, American Express, Japan Credit Bureau (JCB), China UnionPay (CUP), Cartes Bancaires + #### What currency can I pay in? All our invoices are issued in USD, but you can pay in any currency as long as your credit card provider allows charging in USD after conversion. diff --git a/apps/docs/content/guides/platform/database-size.mdx b/apps/docs/content/guides/platform/database-size.mdx index a455371e9ad..3ddc6cb224c 100644 --- a/apps/docs/content/guides/platform/database-size.mdx +++ b/apps/docs/content/guides/platform/database-size.mdx @@ -1,19 +1,18 @@ --- -id: 'database-size' -title: 'Database size' +id: 'disk-metrics' +title: 'Understanding Database and Disk Size' description: 'Understanding how database size applies to your subscription.' --- -Database size refers to the _monthly average storage usage_, as reported by Postgres. This metric is reported in your organization's [billing usage](https://supabase.com/dashboard/org/_/usage) and is updated daily. As you read this document we will refer to "database size" and "disk size": +Disk metrics refer to the storage usage reported by Postgres. These metrics are updated daily. As you read through this document, we will refer to "database size" and "disk size": -- "Database size" is the total size of used storage from your database. -- "Disk Storage size" describes the size of the underlying available storage. +- _Database size_: Displays the actual size of the data within your Postgres database. This can be found on the [Database Reports page](https://supabase.com/dashboard/project/_/reports/database). -## Database space management +- _Disk size_: Shows the overall disk space usage, which includes both the database size and additional files required for Postgres to function like the Write Ahead Log (WAL) and other system log files. You can view this on the [Database Settings page](https://supabase.com/dashboard/project/_/settings/database). -### Database size +## Database size -This SQL query will show the current size of your Postgres database: +This SQL query will show the size of all databases in your Postgres cluster: ```sql select @@ -23,7 +22,7 @@ from pg_database; This value is reported in the [database report page](https://supabase.com/dashboard/project/_/reports/database). -Database Space is consumed primarily by your data, indexes, and materialized views. You can reduce your disk size by removing any of these and running a Vacuum operation. +Database size is consumed primarily by your data, indexes, and materialized views. You can reduce your database size by removing any of these and running a Vacuum operation. @@ -69,39 +68,37 @@ Running a manual vacuum after deleting large amounts of data from your DB could New Supabase projects have a database size of ~40-60mb. This space includes pre-installed extensions, schemas, and default Postgres data. Additional database size is used when installing extensions, even if those extensions are inactive. -## Disk management +## Disk size -Supabase uses network-attached storage to balance performance with scalability. The behavior of your disk depends on your billing plan. +Supabase uses network-attached storage to balance performance with scalability. The disk scaling behavior depends on your billing plan. ### Paid plan behavior -Projects on the Pro Plan and above have auto-scaling Disk Storage. +Projects on the Pro Plan and higher have auto-scaling disks. -Disk Storage expands automatically when the database reaches 90% of the disk size. The disk is expanded to be 50% larger (e.g., 8GB -> 12GB). Auto-scaling can only take place once every 6 hours. If within those 6 hours you reach 95% of the disk space, your project will enter read-only mode. +Disk size expands automatically when the database reaches 90% of the allocated disk size. The disk is expanded to be 50% larger (for example, 8 GB -> 12 GB). Auto-scaling can only take place once every 6 hours. If within those 6 hours you reach 95% of the disk space, your project will enter read-only mode. -The automatic resize operation will add an additional 50% capped to a maximum of 200GB. If 50% of your current usage is more than 200GB then only 200GB will be added to your disk (e.g. a size of 1500GB will resize to 1700GB). +The automatic resize operation will add an additional 50% capped to a maximum of 200 GB. If 50% of your current usage is more than 200 GB then only 200 GB will be added to your disk (for example a size of 1500 GB will resize to 1700 GB). -The Disk Storage Size can also be manually expanded on the [Database settings page](https://supabase.com/dashboard/project/_/settings/database). The maximum default you can expand the Disk Storage to is 200GB. If you wish to manually expand it to any more than 200GB, please [contact us](https://supabase.com/dashboard/support/new) to discuss expanding the 200GB limit. +Disk size can also be manually expanded on the [Database settings page](https://supabase.com/dashboard/project/_/settings/database). The maximum disk size for the Pro/Team Plan is 60 TB. If you need more than this, [contact us](https://forms.supabase.com/enterprise) to learn more about the Enterprise Plan. -You may want to import a lot of data into your database which requires multiple disk expansions; for example, uploading more than 1.5x the current size of your database storage will put your database into [read-only mode](#read-only-mode). If so, it is highly recommended you increase the Disk Storage Size manually on the [Database settings page](https://supabase.com/dashboard/project/_/settings/database). +You may want to import a lot of data into your database which requires multiple disk expansions. for example, uploading more than 1.5x the current size of your database storage will put your database into [read-only mode](#read-only-mode). If so, it is highly recommended you increase the disk size manually on the [Database settings page](https://supabase.com/dashboard/project/_/settings/database). -Due to provider restrictions, disk expansions can occur only once every six hours. During the six-hour window, the disk cannot be resized again. +Due to restrictions on the underlying cloud provider, disk expansions can occur only once every six hours. During the six hour cool down window, the disk cannot be resized again. -The maximum Disk Storage Size for the Pro/Team Plan is 60TB. If you need more than this, [contact us](https://forms.supabase.com/enterprise) to learn more about the Enterprise Plan. - ### Free Plan behavior -Free Plan projects enter [read-only](#read-only-mode) mode when you exceed the 500MB limit. Once in read-only mode, you have several options: +Free Plan projects enter [read-only](#read-only-mode) mode when you exceed the 500 MB limit. Once in read-only mode, you have these options: -- [Upgrade to the Pro Plan](https://supabase.com/dashboard/org/_/billing) to increase the limit to 8GB. [Disable the Spend Cap](https://app.supabase.com/org/_/billing?panel=costControl) if you want your Pro instance to auto-scale and expand beyond the 8GB database size limit. +- [Upgrade to the Pro Plan](https://supabase.com/dashboard/org/_/billing) to increase the limit to 8 GB. [Disable the Spend Cap](https://app.supabase.com/org/_/billing?panel=costControl) if you want your Pro instance to auto-scale beyond the 8 GB disk size limit. - [Disable read-only mode](#disabling-read-only-mode) and reduce your database size. ### Read-only mode @@ -134,6 +131,6 @@ set default_transaction_read_only = 'off'; ### Reducing disk size -Disks don't automatically downsize during normal operation. Once you have [reduced your database space usage](/docs/guides/platform/database-size#database-space-management), they _will_ automatically "right-size" when a Supabase (or Postgres) update is applied. Their final size after the update is 1.2x the size of the database (min 8 GB). For example, if your database is 100GB in size, and you have a 200GB disk, the size after a Supabase update will be 120GB. +Disks don't automatically downsize during normal operation. Once you have [reduced your database size](/docs/guides/platform/database-size#database-size), they _will_ automatically "right-size" during a [project upgrade](/docs/guides/platform/upgrading). The final disk size after the upgrade is 1.2x the size of the database with a minimum of 8 GB. For example, if your database size is 100GB, and you have a 200GB disk, the size after a project upgrade will be 120 GB. -Learn more about [upgrading your project](/docs/guides/platform/migrating-and-upgrading-projects#upgrade-your-project). +In the event that your project is already on the latest version of Postgres and cannot be upgraded, a new version of Postgres will be released approximately every week which you can then upgrade to once it becomes available. diff --git a/apps/docs/content/guides/platform/migrating-to-supabase.mdx b/apps/docs/content/guides/platform/migrating-to-supabase.mdx index 494520af8af..f4715f672cf 100644 --- a/apps/docs/content/guides/platform/migrating-to-supabase.mdx +++ b/apps/docs/content/guides/platform/migrating-to-supabase.mdx @@ -11,7 +11,13 @@ Learn how to migrate to Supabase from another database service.
    {migrationPages.map((page) => ( - + ))}
    diff --git a/apps/docs/content/guides/platform/migrating-to-supabase/neon.mdx b/apps/docs/content/guides/platform/migrating-to-supabase/neon.mdx new file mode 100644 index 00000000000..8a983829a5d --- /dev/null +++ b/apps/docs/content/guides/platform/migrating-to-supabase/neon.mdx @@ -0,0 +1,90 @@ +--- +title: 'Migrate from Neon to Supabase' +description: 'Migrate your existing Neon database to Supabase.' +subtitle: 'Migrate your existing Neon database to Supabase.' +--- + +This guide demonstrates how to migrate your Neon database to Supabase to get the most out of Postgres while gaining access to all the features you need to build a project. + +## Retrieve your Neon database credentials [#retrieve-credentials] + +1. Log in to your Neon Console [https://console.neon.tech/login](https://console.neon.tech/login). +1. Select **Projects** on the left. +1. Click on your project in the list. +1. From your Project Dashboard find your **Connection string** and click **Copy snippet** to copy it to the clipboard (do not check "pooled connection"). + +Example: + +```bash +postgresql://neondb_owner:xxxxxxxxxxxxxxx-random-word-yyyyyyyy.us-west-2.aws.neon.tech/neondb?sslmode=require +``` + +## Set your OLD_DB_URL environment variable + +Set the **OLD_DB_URL** environment variable at the command line using your Neon database credentials from the clipboard. + +Example: + +```bash +export OLD_DB_URL="postgresql://neondb_owner:xxxxxxxxxxxxxxx-random-word-yyyyyyyy.us-west-2.aws.neon.tech/neondb?sslmode=require" +``` + +## Retrieve your Supabase connection string [#retrieve-supabase-connection-string] + +1. If you're new to Supabase, [create a project](https://supabase.com/dashboard). + Make a note of your password, you will need this later. If you forget it, you can [reset it here](https://supabase.com/dashboard/project/_/settings/database). + +1. Go to the [Database settings](https://supabase.com/dashboard/project/_/settings/database) for your project in the Supabase Dashboard. +1. Under **Connection string**, select **URI**, make sure **Display connection pooler** is checked, and **Mode: Session** is set. +1. Click the **Copy** button to the right of your connection string to copy it to the clipboard. + +## Set your NEW_DB_URL environment variable + +Set the **NEW_DB_URL** environment variable at the command line using your Supabase connection string. You will need to replace `[YOUR-PASSWORD]` with your actual database password. + +Example: + +```bash +export NEW_DB_URL="postgresql://postgres.xxxxxxxxxxxxxxxxxxxx:[YOUR-PASSWORD]@aws-0-us-west-1.pooler.supabase.com:5432/postgres" +``` + +## Migrate the database + +You will need the [pg_dump](https://www.postgresql.org/docs/current/app-pgdump.html) and [psql](https://www.postgresql.org/docs/current/app-psql.html) command line tools, which are included in a full [PostgreSQL installation](https://www.postgresql.org/download). + +1. Export your database to a file in console + + Use `pg_dump` with your Postgres credentials to export your database to a file (e.g., `dump.sql`). + +```bash +pg_dump "$OLD_DB_URL" \ + --clean \ + --if-exists \ + --quote-all-identifiers \ + --no-owner \ + --no-privileges \ + > dump.sql +``` + +2. Import the database to your Supabase project + + Use `psql` to import the Postgres database file to your Supabase project. + + ```bash + psql -d "$NEW_DB_URL" -f dump.sql + ``` + +Additional options + +- To only migrate a single database schema, add the `--schema=PATTERN` parameter to your `pg_dump` command. +- To exclude a schema: `--exclude-schema=PATTERN`. +- To only migrate a single table: `--table=PATTERN`. +- To exclude a table: `--exclude-table=PATTERN`. + +Run `pg_dump --help` for a full list of options. + + + +## Enterprise + +[Contact us](https://forms.supabase.com/enterprise) if you need more help migrating your project. diff --git a/apps/docs/content/guides/platform/migrating-to-supabase/vercel-postgres.mdx b/apps/docs/content/guides/platform/migrating-to-supabase/vercel-postgres.mdx new file mode 100644 index 00000000000..a94bfaf697f --- /dev/null +++ b/apps/docs/content/guides/platform/migrating-to-supabase/vercel-postgres.mdx @@ -0,0 +1,97 @@ +--- +title: 'Migrate from Vercel Postgres to Supabase' +description: 'Migrate your existing Vercel Postgres database to Supabase.' +subtitle: 'Migrate your existing Vercel Postgres database to Supabase.' +--- + +This guide demonstrates how to migrate your Vercel Postgres database to Supabase to get the most out of Postgres while gaining access to all the features you need to build a project. + +## Retrieve your Vercel Postgres database credentials [#retrieve-credentials] + +1. Log in to your Vercel Dashboard [https://vercel.com/login](https://vercel.com/login). +1. Click on the **Storage** tab. +1. Click on your Postgres Database. +1. Under the **Quickstart** section, select **psql** then click **Show Secret** to reveal your database password. +1. Copy the string after `psql ` to the clipboard. + +Example: + +```bash +psql "postgres://default:xxxxxxxxxxxx@yy-yyyyy-yyyyyy-yyyyyyy.us-west-2.aws.neon.tech:5432/verceldb?sslmode=require" +``` + +Copy this part to your clipboard: + +```bash +"postgres://default:xxxxxxxxxxxx@yy-yyyyy-yyyyyy-yyyyyyy.us-west-2.aws.neon.tech:5432/verceldb?sslmode=require" +``` + +## Set your OLD_DB_URL environment variable + +Set the **OLD_DB_URL** environment variable at the command line using your Vercel Postgres Database credentials. + +Example: + +```bash +export OLD_DB_URL="postgres://default:xxxxxxxxxxxx@yy-yyyyy-yyyyyy-yyyyyyy.us-west-2.aws.neon.tech:5432/verceldb?sslmode=require" +``` + +## Retrieve your Supabase connection string [#retrieve-supabase-connection-string] + +1. If you're new to Supabase, [create a project](https://supabase.com/dashboard). + Make a note of your password, you will need this later. If you forget it, you can [reset it here](https://supabase.com/dashboard/project/_/settings/database). + +1. Go to the [Database settings](https://supabase.com/dashboard/project/_/settings/database) for your project in the Supabase Dashboard. +1. Under **Connection string**, select **URI**, make sure **Display connection pooler** is checked, and **Mode: Session** is set. +1. Click the **Copy** button to the right of your connection string to copy it to the clipboard. + +## Set your NEW_DB_URL environment variable + +Set the **NEW_DB_URL** environment variable at the command line using your Supabase connection string. You will need to replace `[YOUR-PASSWORD]` with your actual database password. + +Example: + +```bash +export NEW_DB_URL="postgresql://postgres.xxxxxxxxxxxxxxxxxxxx:[YOUR-PASSWORD]@aws-0-us-west-1.pooler.supabase.com:5432/postgres" +``` + +## Migrate the database + +You will need the [pg_dump](https://www.postgresql.org/docs/current/app-pgdump.html) and [psql](https://www.postgresql.org/docs/current/app-psql.html) command line tools, which are included in a full [PostgreSQL installation](https://www.postgresql.org/download). + +1. Export your database to a file in console + + Use `pg_dump` with your Postgres credentials to export your database to a file (e.g., `dump.sql`). + +```bash +pg_dump "$OLD_DB_URL" \ + --clean \ + --if-exists \ + --quote-all-identifiers \ + --no-owner \ + --no-privileges \ + > dump.sql +``` + +2. Import the database to your Supabase project + + Use `psql` to import the Postgres database file to your Supabase project. + + ```bash + psql -d "$NEW_DB_URL" -f dump.sql + ``` + +Additional options + +- To only migrate a single database schema, add the `--schema=PATTERN` parameter to your `pg_dump` command. +- To exclude a schema: `--exclude-schema=PATTERN`. +- To only migrate a single table: `--table=PATTERN`. +- To exclude a table: `--exclude-table=PATTERN`. + +Run `pg_dump --help` for a full list of options. + + + +## Enterprise + +[Contact us](https://forms.supabase.com/enterprise) if you need more help migrating your project. diff --git a/apps/docs/content/guides/platform/upgrading.mdx b/apps/docs/content/guides/platform/upgrading.mdx index e6f848bffa0..a4f4ff210fa 100644 --- a/apps/docs/content/guides/platform/upgrading.mdx +++ b/apps/docs/content/guides/platform/upgrading.mdx @@ -99,7 +99,7 @@ If you upgrade to a paid plan while your project is paused, any expired one-clic If the 90 day project restore window has expired but you need to access data contained within your project using SQL, you can attempt to restore the project into a local Postgres instance. Supabase publishes tooling that can be used for that purpose. Be aware that this workflow does not produce a complete Supabase environment with REST/Auth/Storage. Instead, it creates a standalone Postgres instance that is maximally compatible with your project's backup file to assist with recovering your data. -Downloaded backups are plain text SQL files that can be executed in Postgres to restore your project's schema and data. If you need you access data within a backup we recommend restoring into a Postgres instance built using the Supabase open source build tooling to ensure that the local instance is as similar to the original project as possible. Here are the steps to start Supabase Postgres locally and restore the backup: +Downloaded backups are plain text SQL files that can be executed in Postgres to restore your project's schema and data. If you need to access data within a backup we recommend restoring into a Postgres instance built using the Supabase open source build tooling to ensure that the local instance is as similar to the original project as possible. Here are the steps to start Supabase Postgres locally and restore the backup: Install [nix](https://nixos.org/): diff --git a/apps/docs/content/guides/self-hosting/docker.mdx b/apps/docs/content/guides/self-hosting/docker.mdx index d45e1886573..4139801d85b 100644 --- a/apps/docs/content/guides/self-hosting/docker.mdx +++ b/apps/docs/content/guides/self-hosting/docker.mdx @@ -265,7 +265,7 @@ Each system has a number of configuration options which can be found in the rele - [Postgres](https://hub.docker.com/_/postgres/) - [PostgREST](https://postgrest.org/en/stable/configuration.html) - [Realtime](https://github.com/supabase/realtime#server) -- [GoTrue](https://github.com/supabase/gotrue) +- [Auth](https://github.com/supabase/auth) - [Storage](https://github.com/supabase/storage-api) - [Kong](https://docs.konghq.com/gateway/latest/install/docker/) - [Supavisor](https://supabase.github.io/supavisor/development/docs/) diff --git a/apps/docs/features/docs/Troubleshooting.ui.client.tsx b/apps/docs/features/docs/Troubleshooting.ui.client.tsx index cfc85576915..276262a614c 100644 --- a/apps/docs/features/docs/Troubleshooting.ui.client.tsx +++ b/apps/docs/features/docs/Troubleshooting.ui.client.tsx @@ -15,6 +15,14 @@ import { import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' import { MultiSelect } from '~/components/MultiSelect.client' +import { + MultiSelector, + MultiSelectorContent, + MultiSelectorInput, + MultiSelectorItem, + MultiSelectorList, + MultiSelectorTrigger, +} from 'ui-patterns/multi-select' import { type ITroubleshootingMetadata } from './Troubleshooting.utils' import { formatError, @@ -30,7 +38,7 @@ function useTroubleshootingSearchState() { const setSelectedProducts = useCallback( (products: string[]) => { _setState({ - products: products.length === 0 ? null : products, + products: products.length === 0 ? [] : products, }) }, [_setState] @@ -38,7 +46,7 @@ function useTroubleshootingSearchState() { const setSelectedErrorCodes = useCallback( (errorCodes: string[]) => { _setState({ - errorCodes: errorCodes.length === 0 ? null : errorCodes, + errorCodes: errorCodes.length === 0 ? [] : errorCodes, }) }, [_setState] @@ -46,7 +54,7 @@ function useTroubleshootingSearchState() { const setSelectedTags = useCallback( (tags: string[]) => { _setState({ - tags: tags.length === 0 ? null : tags, + tags: tags.length === 0 ? [] : tags, }) }, [_setState] @@ -177,9 +185,6 @@ function TroubleshootingFilterInternal({ reset, } = useTroubleshootingSearchState() - const [productsOpen, _setProductsOpen] = useState(false) - const [errorsOpen, _setErrorsOpen] = useState(false) - const [tagsOpen, _setTagsOpen] = useState(false) const searchInputRef = useRef(null) const allEntries = useRef([]) @@ -195,112 +200,46 @@ function TroubleshootingFilterInternal({ allEntries.current = entries }, []) - const setProductsOpen = useCallback( - (open: boolean) => { - _setProductsOpen(open) - if (open) { - allEntries.current?.forEach((entry) => (entry.inert = true)) - } else { - allEntries.current?.forEach((entry) => (entry.inert = false)) - } - }, - [_setProductsOpen] - ) - const setErrorsOpen = useCallback( - (open: boolean) => { - _setErrorsOpen(open) - if (open) { - allEntries.current?.forEach((entry) => (entry.inert = true)) - } else { - allEntries.current?.forEach((entry) => (entry.inert = false)) - } - }, - [_setErrorsOpen] - ) - const setTagsOpen = useCallback( - (open: boolean) => { - _setTagsOpen(open) - if (open) { - allEntries.current?.forEach((entry) => (entry.inert = true)) - } else { - allEntries.current?.forEach((entry) => (entry.inert = false)) - } - }, - [_setTagsOpen] - ) - - const convertToItem = useCallback( - (arr: string[], { capitalize = false }: { capitalize?: boolean } = {}) => - arr.map((item) => ({ - value: item, - label: capitalize ? item[0].toUpperCase() + item.slice(1) : item, - })), - [] - ) - return ( <>

    Search and filter

    - { - const newItems = - typeof newItemsOrCreateNewItems === 'function' - ? newItemsOrCreateNewItems(convertToItem(selectedProducts, { capitalize: true })) - : newItemsOrCreateNewItems - setSelectedProducts(newItems.map((item) => item.value)) - }} - > - - - {products.map((product) => ( - - ))} - - - { - const newItems = - typeof newItemsOrCreateNewItems === 'function' - ? newItemsOrCreateNewItems(convertToItem(selectedErrorCodes, { capitalize: true })) - : newItemsOrCreateNewItems - setSelectedErrorCodes(newItems.map((item) => item.value)) - }} - > - - - {errors.map((error) => ( - - ))} - - - { - const newItems = - typeof newItemsOrCreateNewItems === 'function' - ? newItemsOrCreateNewItems(convertToItem(selectedTags, { capitalize: true })) - : newItemsOrCreateNewItems - setSelectedTags(newItems.map((item) => item.value)) - }} - > - - - {keywords.map((keyword) => ( - - ))} - - + + + + + {products?.map((product) => ( + + {product} + + ))} + + + + + + + + {errors?.map((error) => ( + + {error.code} + + ))} + + + + + + + + {keywords?.map((keyword) => ( + + {keyword} + + ))} + + +
    + + + diff --git a/apps/docs/public/img/icons/neon-icon.svg b/apps/docs/public/img/icons/neon-icon.svg new file mode 100644 index 00000000000..7fb2f931cc4 --- /dev/null +++ b/apps/docs/public/img/icons/neon-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/docs/public/img/icons/vercel-icon-light.svg b/apps/docs/public/img/icons/vercel-icon-light.svg new file mode 100644 index 00000000000..72948d01a7d --- /dev/null +++ b/apps/docs/public/img/icons/vercel-icon-light.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/docs/public/img/icons/vercel-icon.svg b/apps/docs/public/img/icons/vercel-icon.svg new file mode 100644 index 00000000000..4e3b0e93358 --- /dev/null +++ b/apps/docs/public/img/icons/vercel-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/docs/spec/api_v1_openapi.json b/apps/docs/spec/api_v1_openapi.json index 4e1cf38665d..f8b3bbc4bc6 100644 --- a/apps/docs/spec/api_v1_openapi.json +++ b/apps/docs/spec/api_v1_openapi.json @@ -1570,8 +1570,7 @@ }, "500": { "description": "Failed to retrieve project's Postgres config" } }, - "tags": ["Database"], - "security": [{ "bearer": [] }] + "tags": ["Database"] }, "put": { "operationId": "v1-update-postgres-config", @@ -1605,8 +1604,7 @@ "403": { "description": "" }, "500": { "description": "Failed to update project's Postgres config" } }, - "tags": ["Database"], - "security": [{ "bearer": [] }] + "tags": ["Database"] } }, "/v1/projects/{ref}/config/database/pgbouncer": { @@ -1936,7 +1934,7 @@ }, "/v1/projects/{ref}/functions": { "post": { - "operationId": "createFunction", + "operationId": "v1-create-a-function", "summary": "Create a function", "description": "Creates a function and adds it to the specified project.", "parameters": [ diff --git a/apps/docs/spec/cli_v1_commands.yaml b/apps/docs/spec/cli_v1_commands.yaml index cda35983ec8..d207ef08c80 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: 1.200.3 + version: 1.207.8 title: Supabase CLI language: sh source: https://github.com/supabase/cli @@ -1128,6 +1128,10 @@ commands: name: --config description: Config overrides specified as a 'key=value' pair default_value: '[]' + - id: no-restart + name: --no-restart + description: Do not restart the database after updating config. + default_value: 'false' - id: replace-existing-overrides name: --replace-existing-overrides description: | @@ -2878,7 +2882,7 @@ commands: Requires the local development stack to be started by running `supabase start`. - Recreates the local Postgres container and applies all local migrations found in `supabase/migrations` directory. If test data is defined in `supabase/seed.sql` by default, it will be seeded after the migrations are run. Any other data or schema changes made during local development will be discarded. + Recreates the local Postgres container and applies all local migrations found in `supabase/migrations` directory. If test data is defined in `supabase/seed.sql`, it will be seeded after the migrations are run. Any other data or schema changes made during local development will be discarded. Note that since Postgres roles are cluster level entities, those changes will persist between resets. In order to reset custom roles, you need to restart the local development stack. examples: @@ -2976,7 +2980,7 @@ commands: default_value: 'false' - id: include-seed name: --include-seed - description: Include seed data from supabase/seed.sql. + description: Include seed data from your config. default_value: 'false' - id: linked name: --linked diff --git a/apps/docs/spec/common-api-sections.json b/apps/docs/spec/common-api-sections.json index a6f799286de..0d323a5932c 100644 --- a/apps/docs/spec/common-api-sections.json +++ b/apps/docs/spec/common-api-sections.json @@ -225,6 +225,12 @@ "type": "category", "title": "Edge Functions", "items": [ + { + "id": "v1-create-a-function", + "title": "Create a function", + "slug": "v1-create-a-function", + "type": "operation" + }, { "id": "v1-delete-a-function", "title": "Delete a function", diff --git a/apps/docs/spec/gotrue_v1_config.yaml b/apps/docs/spec/gotrue_v1_config.yaml index 31827802154..ff4a418c9ff 100644 --- a/apps/docs/spec/gotrue_v1_config.yaml +++ b/apps/docs/spec/gotrue_v1_config.yaml @@ -11,9 +11,11 @@ info: title: 'GoTrue' # {string} A readable name. source: 'https://github.com/supabase/gotrue' # {string} Where developers can find the source code. bugs: 'https://github.com/supabase/gotrue/issues' # {string} Where developers can file bugs. - spec: 'https://github.com/supabase/supabase/blob/master/spec/gotrue_v1_config.yaml' # {string} Where developers can find this spec (to link directly in the docs). + spec: 'https://github.com/supabase/supabase/blob/master/docs/spec/gotrue_v1_config.yaml' # {string} Where developers can find this spec (to link directly in the docs). description: | - A `config.toml` file is generated after running `supabase init`. This file is located in the `supabase` folder under `supabase/config.toml`. + In a self-hosted environment, you do not have access to the Auth configuration such as third party OAuth provider settings through the Supabase dashboard. Instead, you configure them through the `docker-compose.yml` file. You can read more about the configuration in the [Self-hosting guide](/docs/guides/self-hosting/docker#configuring-services). + + You can find the complete list of available configuration parameters on the README of the [Auth repository](https://github.com/supabase/auth?tab=readme-ov-file#configuration). tags: - id: general title: General @@ -21,58 +23,68 @@ info: # This section is an array of public functions which a user might need to execute. parameters: - - id: 'project_id' # {string} A unique identifier for this param. - title: 'project_id' # {string} Any name. - tags: ['general'] # {string[]} These tags are useful for grouping parameters - required: true - # default: '5432' - description: | - A string used to distinguish different Supabase projects on the same host. Defaults to the working directory name when running `supabase init`. - - id: 'GOTRUE_EXTERNAL_GITHUB' # {string} A unique identifier for this param. - title: 'auth.external.github' # {string} Any name. - tags: ['general'] # {string[]} These tags are useful for grouping parameters - required: true - # default: '5432' - description: | - Describes whether the Github provider is enabled or not. - id: 'GOTRUE_SITE_URL' # {string} A unique identifier for this param. - title: 'auth.site_url' # {string} Any name. + title: 'GOTRUE_SITE_URL' # {string} Any name. tags: ['general'] # {string[]} These tags are useful for grouping parameters required: true description: | The base URL of your website. Used as an allow-list for redirects and for constructing URLs used in emails. + - id: 'GOTRUE_EXTERNAL_GITHUB_ENABLED' # {string} A unique identifier for this param. + title: 'GOTRUE_EXTERNAL_GITHUB_ENABLED' # {string} Any name. + tags: ['general'] # {string[]} These tags are useful for grouping parameters + required: false + description: | + Whether the external provider, GitHub in this case, is enabled or not. + - id: 'GOTRUE_EXTERNAL_GITHUB_CLIENT_ID' # {string} A unique identifier for this param. + title: 'GOTRUE_EXTERNAL_GITHUB_CLIENT_ID' # {string} Any name. + tags: ['general'] # {string[]} These tags are useful for grouping parameters + required: false + description: | + The OAuth2 Client ID registered with the external provider. + - id: 'GOTRUE_EXTERNAL_GITHUB_SECRET' # {string} A unique identifier for this param. + title: 'GOTRUE_EXTERNAL_GITHUB_SECRET' # {string} Any name. + tags: ['general'] # {string[]} These tags are useful for grouping parameters + required: false + description: | + The OAuth2 Client Secret provided by the external provider when you registered. + - id: 'GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI' # {string} A unique identifier for this param. + title: 'GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI' # {string} Any name. + tags: ['general'] # {string[]} These tags are useful for grouping parameters + required: false + description: | + The URI a OAuth2 provider will redirect to with the `code` and `state` values. - id: 'GOTRUE_URI_ALLOW_LIST' # {string} A unique identifier for this param. - title: 'auth.additional_redirect_urls' # {string} Any name. + title: 'GOTRUE_URI_ALLOW_LIST' # {string} Any name. tags: ['general'] # {string[]} These tags are useful for grouping parameters required: true description: | - A list of *exact* URLs that auth providers are permitted to redirect to post authentication. + A comma separated list of URIs (e.g. `"https://foo.example.com,https://*.foo.example.com,https://bar.example.com"`) which are permitted as valid `redirect_to` destinations. - id: 'GOTRUE_JWT_EXP' # {string} A unique identifier for this param. - title: 'auth.jwt_expiry' # {string} Any name. + title: 'GOTRUE_JWT_EXP' # {string} Any name. tags: ['general'] # {string[]} These tags are useful for grouping parameters required: true description: | How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 seconds (one week). - id: 'GOTRUE_DISABLE_SIGNUP' # {string} A unique identifier for this param. - title: 'auth.enable_signup' # {string} Any name. + title: 'GOTRUE_DISABLE_SIGNUP' # {string} Any name. tags: ['general'] # {string[]} These tags are useful for grouping parameters required: true description: | Allow/disallow new user signups to your project. - id: 'GOTRUE_EXTERNAL_EMAIL_ENABLED' # {string} A unique identifier for this param. - title: 'auth.email.enable_signup' # {string} Any name. + title: 'GOTRUE_EXTERNAL_EMAIL_ENABLED' # {string} Any name. tags: ['general', 'email'] # {string[]} These tags are useful for grouping parameters required: true description: | Allow/disallow new user signups via email to your project. - id: 'GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED' # {string} A unique identifier for this param. - title: 'auth.email.double_confirm_changes' # {string} Any name. + title: 'GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED' # {string} Any name. tags: ['general', 'email'] # {string[]} These tags are useful for grouping parameters required: true description: | If enabled, a user will be required to confirm any email change on both the old, and new email addresses. If disabled, only the new email is required to confirm. - id: 'GOTRUE_MAILER_AUTOCONFIRM' # {string} A unique identifier for this param. - title: 'auth.email.enable_confirmations' # {string} Any name. + title: 'GOTRUE_MAILER_AUTOCONFIRM' # {string} Any name. tags: ['general', 'email'] # {string[]} These tags are useful for grouping parameters required: true description: | diff --git a/apps/docs/spec/transforms/api_v1_openapi_deparsed.json b/apps/docs/spec/transforms/api_v1_openapi_deparsed.json index 0c4c9b65b7f..b8166df0a38 100644 --- a/apps/docs/spec/transforms/api_v1_openapi_deparsed.json +++ b/apps/docs/spec/transforms/api_v1_openapi_deparsed.json @@ -4604,12 +4604,7 @@ "description": "Failed to retrieve project's Postgres config" } }, - "tags": ["Database"], - "security": [ - { - "bearer": [] - } - ] + "tags": ["Database"] }, "put": { "operationId": "v1-update-postgres-config", @@ -4814,12 +4809,7 @@ "description": "Failed to update project's Postgres config" } }, - "tags": ["Database"], - "security": [ - { - "bearer": [] - } - ] + "tags": ["Database"] } }, "/v1/projects/{ref}/config/database/pgbouncer": { @@ -7832,7 +7822,7 @@ }, "/v1/projects/{ref}/functions": { "post": { - "operationId": "createFunction", + "operationId": "v1-create-a-function", "summary": "Create a function", "description": "Creates a function and adds it to the specified project.", "parameters": [ diff --git a/apps/studio/components/grid/SupabaseGrid.tsx b/apps/studio/components/grid/SupabaseGrid.tsx index c25b503426d..4d9472a26b9 100644 --- a/apps/studio/components/grid/SupabaseGrid.tsx +++ b/apps/studio/components/grid/SupabaseGrid.tsx @@ -28,8 +28,12 @@ import { InitialStateType } from './store/reducers' import type { SupabaseGridProps } from './types' import { getGridColumns } from './utils/gridColumns' -function onLoadStorage(storageRef: string, tableName: string, schema?: string | null) { - const storageKey = getStorageKey(STORAGE_KEY_PREFIX, storageRef) +export function loadTableEditorSortsAndFiltersFromLocalStorage( + projectRef: string, + tableName: string, + schema?: string | null +) { + const storageKey = getStorageKey(STORAGE_KEY_PREFIX, projectRef) const jsonStr = localStorage.getItem(storageKey) if (!jsonStr) return const json = JSON.parse(jsonStr) @@ -45,7 +49,11 @@ async function initTable( filter?: string[] // Comes directly from URL param ): Promise<{ savedState: { sorts?: string[]; filters?: string[] } }> { const savedState = props.projectRef - ? onLoadStorage(props.projectRef, props.table.name, props.table.schema) + ? loadTableEditorSortsAndFiltersFromLocalStorage( + props.projectRef, + props.table.name, + props.table.schema + ) : undefined // Check for saved state on initial load and also, load sort and filters via URL param only if given diff --git a/apps/studio/components/grid/SupabaseGrid.utils.ts b/apps/studio/components/grid/SupabaseGrid.utils.ts index 1e6a6e8c5be..25fddc31895 100644 --- a/apps/studio/components/grid/SupabaseGrid.utils.ts +++ b/apps/studio/components/grid/SupabaseGrid.utils.ts @@ -1,12 +1,20 @@ +import type { PostgresRelationship, PostgresTable } from '@supabase/postgres-meta' import AwesomeDebouncePromise from 'awesome-debounce-promise' -import type { Filter } from 'components/grid/types' import { compact } from 'lodash' -import type { Dictionary } from 'types' + +import type { Filter } from 'components/grid/types' +import { FOREIGN_KEY_CASCADE_ACTION } from 'data/database/database-query-constants' +import { + ForeignKeyConstraint, + ForeignKeyConstraintsData, +} from 'data/database/foreign-key-constraints-query' +import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants' +import { TableLike } from 'hooks/misc/useTable' +import type { Dictionary, SchemaView } from 'types' import { FilterOperatorOptions } from './components/header/filter/Filter.constants' import { STORAGE_KEY_PREFIX } from './constants' import { InitialStateType } from './store/reducers' import type { Sort, SupabaseGridProps, SupaColumn, SupaTable } from './types' - /** * Ensure that if editable is false, we should remove all editing actions * to prevent rare-case bugs with the UI @@ -124,6 +132,54 @@ export function parseSupaTable( } } +export function getSupaTable({ + selectedTable, + entityType, + foreignKeyMeta, + encryptedColumns, +}: { + selectedTable: TableLike + entityType?: ENTITY_TYPE + foreignKeyMeta: ForeignKeyConstraintsData + encryptedColumns: string[] +}) { + // [Joshen] We can tweak below to eventually support composite keys as the data + // returned from foreignKeyMeta should be easy to deal with, rather than pg-meta + const formattedRelationships = ( + ('relationships' in selectedTable && selectedTable.relationships) || + [] + ).map((relationship: PostgresRelationship) => { + const relationshipMeta = foreignKeyMeta.find( + (fk: ForeignKeyConstraint) => fk.id === relationship.id + ) + return { + ...relationship, + deletion_action: relationshipMeta?.deletion_action ?? FOREIGN_KEY_CASCADE_ACTION.NO_ACTION, + } + }) + + const isViewSelected = + entityType === ENTITY_TYPE.VIEW || entityType === ENTITY_TYPE.MATERIALIZED_VIEW + const isForeignTableSelected = entityType === ENTITY_TYPE.FOREIGN_TABLE + + return !isViewSelected && !isForeignTableSelected + ? parseSupaTable( + { + table: selectedTable as PostgresTable, + columns: (selectedTable as PostgresTable).columns ?? [], + primaryKeys: (selectedTable as PostgresTable).primary_keys ?? [], + relationships: formattedRelationships, + }, + encryptedColumns + ) + : parseSupaTable({ + table: selectedTable as SchemaView, + columns: (selectedTable as SchemaView).columns ?? [], + primaryKeys: [], + relationships: [], + }) +} + export const saveStorageDebounced = AwesomeDebouncePromise(saveStorage, 500) function saveStorage( diff --git a/apps/studio/components/grid/components/editor/TextEditor.tsx b/apps/studio/components/grid/components/editor/TextEditor.tsx index 7496f78c727..14c111adea1 100644 --- a/apps/studio/components/grid/components/editor/TextEditor.tsx +++ b/apps/studio/components/grid/components/editor/TextEditor.tsx @@ -15,6 +15,7 @@ import { TooltipContent_Shadcn_, TooltipTrigger_Shadcn_, Tooltip_Shadcn_, + cn, } from 'ui' import { useTrackedState } from '../../store/Store' import { BlockKeys } from '../common/BlockKeys' @@ -188,9 +189,10 @@ export const TextEditor = ({ } >
    setIsPopoverOpen(!isPopoverOpen)} > {value === null ? : value === '' ? : value} diff --git a/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx b/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx index 7738c14b7d3..2948e7ff0f5 100644 --- a/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx +++ b/apps/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx @@ -1,9 +1,9 @@ import { ArrowRight } from 'lucide-react' -import Link from 'next/link' import type { PropsWithChildren } from 'react' import type { RenderCellProps } from 'react-data-grid' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { EditorTablePageLink } from 'data/prefetchers/project.$ref.editor.$id' import { useTableQuery } from 'data/tables/table-query' import { useTablesQuery } from 'data/tables/tables-query' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' @@ -62,11 +62,20 @@ export const ForeignKeyFormatter = (props: Props) => { className="translate-y-[2px]" style={{ padding: '3px' }} > - { const snap = useAppStateSnapshot() - const enablePostHogTelemetry = useFlag('enablePosthogChanges') const { mutate: sendReset } = useSendResetMutation() - const consentFlag = enablePostHogTelemetry - ? LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT_PH - : LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT - const onToggleOptIn = () => { const value = !snap.isOptedInTelemetry ? 'true' : 'false' - snap.setIsOptedInTelemetry(value === 'true', consentFlag) + snap.setIsOptedInTelemetry(value === 'true') if (value === 'false') sendReset() } diff --git a/apps/studio/components/interfaces/App/CommandMenu/ApiKeys.tsx b/apps/studio/components/interfaces/App/CommandMenu/ApiKeys.tsx index a3c3f2909a8..c86051bba3f 100644 --- a/apps/studio/components/interfaces/App/CommandMenu/ApiKeys.tsx +++ b/apps/studio/components/interfaces/App/CommandMenu/ApiKeys.tsx @@ -1,7 +1,7 @@ import { Key } from 'lucide-react' import { useMemo } from 'react' -import { useProjectApiQuery } from 'data/config/project-api-query' +import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { copyToClipboard } from 'lib/helpers' import { Badge } from 'ui' @@ -23,14 +23,13 @@ export function useApiKeysCommands() { const setPage = useSetPage() const project = useSelectedProject() - const { data: settings } = useProjectApiQuery( + const ref = project?.ref || '_' + + const { data: settings } = useProjectSettingsV2Query( { projectRef: project?.ref }, { enabled: !!project } ) - const ref = project?.ref || '_' - - const anonKey = settings?.autoApiService?.defaultApiKey ?? undefined - const serviceKey = settings?.autoApiService?.serviceApiKey ?? undefined + const { anonKey, serviceKey } = getAPIKeys(settings) const commands = useMemo( () => @@ -40,7 +39,7 @@ export function useApiKeysCommands() { id: 'anon-key', name: `Copy anonymous API key`, action: () => { - copyToClipboard(anonKey ?? '') + copyToClipboard(anonKey.api_key ?? '') setIsOpen(false) }, badge: () => ( @@ -56,7 +55,7 @@ export function useApiKeysCommands() { id: 'service-key', name: `Copy service API key`, action: () => { - copyToClipboard(serviceKey ?? '') + copyToClipboard(serviceKey.api_key ?? '') setIsOpen(false) }, badge: () => ( diff --git a/apps/studio/components/interfaces/App/CommandMenu/ApiUrl.tsx b/apps/studio/components/interfaces/App/CommandMenu/ApiUrl.tsx index 28b36682981..1205a8f3333 100644 --- a/apps/studio/components/interfaces/App/CommandMenu/ApiUrl.tsx +++ b/apps/studio/components/interfaces/App/CommandMenu/ApiUrl.tsx @@ -1,10 +1,10 @@ import { Link } from 'lucide-react' -import { useProjectApiQuery } from 'data/config/project-api-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' +import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { copyToClipboard } from 'lib/helpers' import { Badge } from 'ui' import { useRegisterCommands, useSetCommandMenuOpen } from 'ui-patterns/CommandMenu' -import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { COMMAND_MENU_SECTIONS } from './CommandMenu.utils' import { orderCommandSectionsByPriority } from './ordering' @@ -12,13 +12,13 @@ export function useApiUrlCommand() { const setIsOpen = useSetCommandMenuOpen() const project = useSelectedProject() - const { data: settings } = useProjectApiQuery( + const { data: settings } = useProjectSettingsV2Query( { projectRef: project?.ref }, { enabled: !!project } ) - const apiUrl = settings?.autoApiService.endpoint - ? `${settings.autoApiService.protocol ?? 'https'}://${settings.autoApiService.endpoint}` + const apiUrl = settings?.app_config?.endpoint + ? `https://${settings.app_config.endpoint}` : undefined useRegisterCommands( diff --git a/apps/studio/components/interfaces/App/FeaturePreview/APISidePanelPreview.tsx b/apps/studio/components/interfaces/App/FeaturePreview/APISidePanelPreview.tsx index 5e96abfb824..9a4bde83334 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/APISidePanelPreview.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/APISidePanelPreview.tsx @@ -3,7 +3,7 @@ import { Markdown } from 'components/interfaces/Markdown' import { BASE_PATH } from 'lib/constants' import Image from 'next/image' -const APISidePanelPreview = () => { +export const APISidePanelPreview = () => { const { ref } = useParams() return ( @@ -38,5 +38,3 @@ const APISidePanelPreview = () => {
    ) } - -export default APISidePanelPreview diff --git a/apps/studio/components/interfaces/App/FeaturePreview/CLSPreview.tsx b/apps/studio/components/interfaces/App/FeaturePreview/CLSPreview.tsx index 56293f7b9b0..a88c1eddc96 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/CLSPreview.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/CLSPreview.tsx @@ -5,7 +5,7 @@ import { Markdown } from 'components/interfaces/Markdown' import { BASE_PATH } from 'lib/constants' import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, WarningIcon } from 'ui' -const CLSPreview = () => { +export const CLSPreview = () => { const { ref } = useParams() return ( @@ -53,5 +53,3 @@ const CLSPreview = () => {
    ) } - -export default CLSPreview diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx index a77d22b887d..84aa5f6c7fe 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx @@ -3,6 +3,33 @@ import { PropsWithChildren, createContext, useContext, useEffect, useState } fro import { LOCAL_STORAGE_KEYS } from 'lib/constants' import { EMPTY_OBJ } from 'lib/void' +import { APISidePanelPreview } from './APISidePanelPreview' +import { CLSPreview } from './CLSPreview' +import { FunctionsAssistantPreview } from './FunctionsAssistantPreview' + +export const FEATURE_PREVIEWS = [ + { + key: LOCAL_STORAGE_KEYS.UI_PREVIEW_FUNCTIONS_ASSISTANT, + name: 'Database Functions Assistant', + content: , + discussionsUrl: undefined, + isNew: true, + }, + { + key: LOCAL_STORAGE_KEYS.UI_PREVIEW_API_SIDE_PANEL, + name: 'Project API documentation', + content: , + discussionsUrl: 'https://github.com/orgs/supabase/discussions/18038', + isNew: false, + }, + { + key: LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS, + name: 'Column-level privileges', + content: , + discussionsUrl: 'https://github.com/orgs/supabase/discussions/20295', + isNew: false, + }, +] type FeaturePreviewContextType = { flags: { [key: string]: boolean } @@ -17,22 +44,19 @@ const FeaturePreviewContext = createContext({ export const useFeaturePreviewContext = () => useContext(FeaturePreviewContext) export const FeaturePreviewContextProvider = ({ children }: PropsWithChildren<{}>) => { - const [flags, setFlags] = useState({ - [LOCAL_STORAGE_KEYS.UI_PREVIEW_NAVIGATION_LAYOUT]: false, - [LOCAL_STORAGE_KEYS.UI_PREVIEW_API_SIDE_PANEL]: false, - [LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS]: false, - }) + const [flags, setFlags] = useState(() => + FEATURE_PREVIEWS.reduce((a, b) => { + return { ...a, [b.key]: false } + }, {}) + ) useEffect(() => { if (typeof window !== 'undefined') { - setFlags({ - [LOCAL_STORAGE_KEYS.UI_PREVIEW_NAVIGATION_LAYOUT]: - localStorage.getItem(LOCAL_STORAGE_KEYS.UI_PREVIEW_NAVIGATION_LAYOUT) === 'true', - [LOCAL_STORAGE_KEYS.UI_PREVIEW_API_SIDE_PANEL]: - localStorage.getItem(LOCAL_STORAGE_KEYS.UI_PREVIEW_API_SIDE_PANEL) === 'true', - [LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS]: - localStorage.getItem(LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS) === 'true', - }) + setFlags( + FEATURE_PREVIEWS.reduce((a, b) => { + return { ...a, [b.key]: localStorage.getItem(b.key) === 'true' } + }, {}) + ) } }, []) @@ -61,3 +85,8 @@ export const useIsColumnLevelPrivilegesEnabled = () => { const { flags } = useFeaturePreviewContext() return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS] } + +export const useIsDatabaseFunctionsAssistantEnabled = () => { + const { flags } = useFeaturePreviewContext() + return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_FUNCTIONS_ASSISTANT] +} diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx index de6979679e8..01cbee09063 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx @@ -3,38 +3,23 @@ import Link from 'next/link' import { useEffect, useState } from 'react' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { LOCAL_STORAGE_KEYS } from 'lib/constants' import { useAppStateSnapshot } from 'state/app-state' -import { Button, Modal, ScrollArea, cn } from 'ui' -import APISidePanelPreview from './APISidePanelPreview' -import CLSPreview from './CLSPreview' -import { useFeaturePreviewContext } from './FeaturePreviewContext' +import { Badge, Button, Modal, ScrollArea, cn } from 'ui' +import { FEATURE_PREVIEWS, useFeaturePreviewContext } from './FeaturePreviewContext' +import { useFlag } from 'hooks/ui/useFlag' const FeaturePreviewModal = () => { - // [Ivan] We should probably move this to a separate file, together with LOCAL_STORAGE_KEYS. We should make adding new feature previews as simple as possible. - const FEATURE_PREVIEWS: { key: string; name: string; content: any; discussionsUrl?: string }[] = [ - { - key: LOCAL_STORAGE_KEYS.UI_PREVIEW_API_SIDE_PANEL, - name: 'Project API documentation', - content: , - discussionsUrl: 'https://github.com/orgs/supabase/discussions/18038', - }, - { - key: LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS, - name: 'Column-level privileges', - content: , - discussionsUrl: 'https://github.com/orgs/supabase/discussions/20295', - }, - ] - const snap = useAppStateSnapshot() const featurePreviewContext = useFeaturePreviewContext() const { mutate: sendEvent } = useSendEventMutation() + const enableFunctionsAssistant = useFlag('functionsAssistantV2') const selectedFeaturePreview = snap.selectedFeaturePreview === '' ? FEATURE_PREVIEWS[0].key : snap.selectedFeaturePreview const [selectedFeatureKey, setSelectedFeatureKey] = useState(selectedFeaturePreview) + const isNotReleased = + selectedFeatureKey === 'supabase-ui-functions-assistant' && !enableFunctionsAssistant // this modal can be triggered on other pages // Update local state when valtio state changes @@ -103,8 +88,11 @@ const FeaturePreviewModal = () => {
    -

    {selectedFeature?.name}

    -
    +
    +

    {selectedFeature?.name}

    + {selectedFeature?.isNew && New} +
    +
    {selectedFeature?.discussionsUrl !== undefined && ( )} - + {isNotReleased ? ( + + ) : ( + + )}
    {selectedFeature?.content} diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FunctionsAssistantPreview.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FunctionsAssistantPreview.tsx new file mode 100644 index 00000000000..513715133e7 --- /dev/null +++ b/apps/studio/components/interfaces/App/FeaturePreview/FunctionsAssistantPreview.tsx @@ -0,0 +1,55 @@ +import Image from 'next/image' + +import { useParams } from 'common' +import { Markdown } from 'components/interfaces/Markdown' +import { BASE_PATH } from 'lib/constants' +import { detectOS } from 'lib/helpers' + +export const FunctionsAssistantPreview = () => { + const os = detectOS() + const { ref } = useParams() + + return ( +
    +

    + We're providing an additional alternative UX to creating database functions through the + dashboard with the integration of our AI Assistant that you might have seen in the Auth + Policies section. +

    + api-docs-side-panel-preview +

    + This preview also shares an improved Assistant interface where you may provide the Assistant + with contexts in hopes to generate more relevant and higher quality outputs. Contexts that + you may provide include specific schemas, and / or specific tables from your database. +

    +

    + We'd hope to use this as a consistent pattern throughout the dashboard eventually if this + feature preview proves itself to benefit most of our users, so as usual please do feel free + to let us know what you think if the attached GitHub discussion above! +

    +
    +

    Enabling this preview will:

    +
      +
    • + +
    • +
    • + +
    • +
    +
    +
    + ) +} diff --git a/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx b/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx index 69fa28ff76a..57fe2333163 100644 --- a/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx +++ b/apps/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx @@ -1,18 +1,18 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useParams } from 'common' import { Check, ChevronUp, ExternalLink } from 'lucide-react' +import Link from 'next/link' import { useEffect, useRef, useState } from 'react' import ReactMarkdown from 'react-markdown' import { toast } from 'sonner' +import { useParams } from 'common' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import type { components } from 'data/api' import { useAuthConfigUpdateMutation } from 'data/auth/auth-config-update-mutation' -import { useProjectApiQuery } from 'data/config/project-api-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { BASE_PATH } from 'lib/constants' -import Link from 'next/link' import { Alert, Alert_Shadcn_, @@ -109,8 +109,8 @@ const ProviderForm = ({ config, provider }: ProviderFormProps) => { } } - const { data: settings } = useProjectApiQuery({ projectRef }) - const apiUrl = `${settings?.autoApiService.protocol}://${settings?.autoApiService.endpoint}` + const { data: settings } = useProjectSettingsV2Query({ projectRef }) + const apiUrl = `https://${settings?.app_config?.endpoint}` const { data: customDomainData } = useCustomDomainsQuery({ projectRef }) diff --git a/apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx b/apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx index ca32ae51599..cfc81587e92 100644 --- a/apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx +++ b/apps/studio/components/interfaces/Auth/Hooks/CreateHookSheet.tsx @@ -123,6 +123,11 @@ export const CreateHookSheet = ({ [title] ) + const supportedReturnTypes = + definition.enabledKey === 'HOOK_SEND_EMAIL_ENABLED' + ? ['json', 'jsonb', 'void'] + : ['json', 'jsonb'] + const hook: Hook = useMemo(() => { return { ...definition, @@ -288,20 +293,21 @@ export const CreateHookSheet = ({
    - +
    ( ( - + -
    +
    ( ( { - if (func.return_type === 'json' || func.return_type === 'jsonb') { + if (supportedReturnTypes.includes(func.return_type)) { const { value } = convertArgumentTypes(func.argument_types) if (value.length !== 1) return false return value[0].type === 'json' || value[0].type === 'jsonb' @@ -403,7 +409,11 @@ export const CreateHookSheet = ({ No function with a single JSON/B argument
    - and JSON/B return type found in this schema. + and JSON/B + {definition.enabledKey === 'HOOK_SEND_EMAIL_ENABLED' + ? ' or void' + : ''}{' '} + return type found in this schema.
    } /> @@ -413,8 +423,8 @@ export const CreateHookSheet = ({ />
    -

    - The following statements will be executed on the function: +

    + The following statements will be executed on the selected function:

    { tooltip={{ content: { side: 'bottom', - text: 'You need additional permissions to configure auth hooks', + text: !canUpdateAuthHook + ? 'You need additional permissions to configure auth hooks' + : undefined, }, }} > diff --git a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/RLSCodeEditor.tsx b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/RLSCodeEditor.tsx index 1323ec5c305..e27f8699a4d 100644 --- a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/RLSCodeEditor.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/RLSCodeEditor.tsx @@ -68,9 +68,6 @@ const RLSCodeEditor = ({ lineNumbersMinChars: 4, folding: undefined, scrollBeyondLastLine: false, - padding: { - top: editView === 'conversation' ? 20 : 0, - }, } const onMount: OnMount = async (editor, monaco) => { diff --git a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/index.tsx b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/index.tsx index b11333148e0..ba6aa9ab2ae 100644 --- a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/index.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/index.tsx @@ -804,7 +804,9 @@ export const AIPolicyEditorPanel = memo(function ({ tooltip={{ content: { side: 'top', - text: 'You need additional permissions to update policies', + text: !canUpdatePolicies + ? 'You need additional permissions to update policies' + : undefined, }, }} > diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow.tsx index ae34fb099d4..9944a44b4c4 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow.tsx @@ -3,7 +3,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { noop } from 'lodash' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' -import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { DropdownMenuItemTooltip } from 'components/ui/DropdownMenuItemTooltip' import Panel from 'components/ui/Panel' import { useAuthConfigQuery } from 'data/auth/auth-config-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' @@ -20,7 +20,6 @@ import { TooltipContent_Shadcn_, TooltipTrigger_Shadcn_, } from 'ui' -import { DropdownMenuItemTooltip } from 'components/ui/DropdownMenuItemTooltip' interface PolicyRowProps { policy: PostgresPolicy diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx index 87b385513f5..4823c3bc72e 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx @@ -1,12 +1,13 @@ -import type { PostgresTable } from '@supabase/postgres-meta' -import { PermissionAction } from '@supabase/shared-types/out/constants' -import { ButtonTooltip } from 'components/ui/ButtonTooltip' -import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { noop } from 'lodash' import { Lock, Unlock } from 'lucide-react' -import Link from 'next/link' -import { useRouter } from 'next/router' import { useQueryState } from 'nuqs' + +import type { PostgresTable } from '@supabase/postgres-meta' +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useParams } from 'common' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { EditorTablePageLink } from 'data/prefetchers/project.$ref.editor.$id' +import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { AiIconAnimation, Badge, @@ -29,8 +30,7 @@ const PolicyTableRowHeader = ({ onSelectToggleRLS = noop, onSelectCreatePolicy, }: PolicyTableRowHeaderProps) => { - const router = useRouter() - const { ref } = router.query + const { ref } = useParams() const canToggleRLS = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') @@ -42,7 +42,11 @@ const PolicyTableRowHeader = ({ return (
    - + {table.rls_enabled ? (
    @@ -53,7 +57,7 @@ const PolicyTableRowHeader = ({
    )}

    {table.name}

    - +
    {isTableLocked && ( @@ -75,7 +79,9 @@ const PolicyTableRowHeader = ({ tooltip={{ content: { side: 'bottom', - text: 'You need additional permissions to toggle RLS', + text: !canToggleRLS + ? 'You need additional permissions to toggle RLS' + : undefined, }, }} > @@ -90,7 +96,9 @@ const PolicyTableRowHeader = ({ content: { side: 'bottom', text: !canToggleRLS - ? 'You need additional permissions to create RLS policies' + ? !canToggleRLS + ? 'You need additional permissions to create RLS policies' + : undefined : undefined, }, }} @@ -111,7 +119,7 @@ const PolicyTableRowHeader = ({ - + {!canToggleRLS ? 'You need additional permissions to create RLS policies' : 'Create with Supabase Assistant'} diff --git a/apps/studio/components/interfaces/Auth/RateLimits/RateLimits.tsx b/apps/studio/components/interfaces/Auth/RateLimits/RateLimits.tsx index a6258a90e71..bbb3210480f 100644 --- a/apps/studio/components/interfaces/Auth/RateLimits/RateLimits.tsx +++ b/apps/studio/components/interfaces/Auth/RateLimits/RateLimits.tsx @@ -53,7 +53,7 @@ const RateLimits = () => { }) const canUpdateEmailLimit = authConfig?.EXTERNAL_EMAIL_ENABLED && isSmtpEnabled(authConfig) - const canUpdateSMSRateLimit = authConfig?.EXTERNAL_PHONE_ENABLED && !authConfig?.SMS_AUTOCONFIRM + const canUpdateSMSRateLimit = authConfig?.EXTERNAL_PHONE_ENABLED const canUpdateAnonymousUsersRateLimit = authConfig?.EXTERNAL_ANONYMOUS_USERS_ENABLED const FormSchema = z.object({ @@ -408,8 +408,8 @@ const RateLimits = () => { - Number of sign up and sign-in requests that can be made per hour per IP - address (excludes anonymous users). + Number of sign up and sign-in requests that can be made in a 5 minute + interval per IP address (excludes anonymous users).

    } > diff --git a/apps/studio/components/interfaces/Auth/RedirectUrls/RedirectUrlList.tsx b/apps/studio/components/interfaces/Auth/RedirectUrls/RedirectUrlList.tsx index 8ee007d54b4..304cac3af4d 100644 --- a/apps/studio/components/interfaces/Auth/RedirectUrls/RedirectUrlList.tsx +++ b/apps/studio/components/interfaces/Auth/RedirectUrls/RedirectUrlList.tsx @@ -72,7 +72,9 @@ export const RedirectUrlList = ({ tooltip={{ content: { side: 'bottom', - text: 'You need additional permissions to remove redirect URLs', + text: !canUpdateConfig + ? 'You need additional permissions to remove redirect URLs' + : undefined, }, }} icon={} @@ -88,7 +90,9 @@ export const RedirectUrlList = ({ tooltip={{ content: { side: 'bottom', - text: 'You need additional permissions to update redirect URLs', + text: !canUpdateConfig + ? 'You need additional permissions to update redirect URLs' + : undefined, }, }} > diff --git a/apps/studio/components/interfaces/Auth/Users/BanUserModal.tsx b/apps/studio/components/interfaces/Auth/Users/BanUserModal.tsx index 1ae878d8855..cb203fcc064 100644 --- a/apps/studio/components/interfaces/Auth/Users/BanUserModal.tsx +++ b/apps/studio/components/interfaces/Auth/Users/BanUserModal.tsx @@ -1,10 +1,14 @@ import { zodResolver } from '@hookform/resolvers/zod' import dayjs from 'dayjs' -import { useForm } from 'react-hook-form' -import * as z from 'zod' -import { toast } from 'sonner' import { useEffect } from 'react' +import { useForm } from 'react-hook-form' +import { toast } from 'sonner' +import * as z from 'zod' +import { useParams } from 'common' +import { useUserUpdateMutation } from 'data/auth/user-update-mutation' +import { User } from 'data/auth/users-query' +import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { Button, cn, @@ -20,10 +24,6 @@ import { Separator, } from 'ui' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' -import { useUserUpdateMutation } from 'data/auth/user-update-mutation' -import { User } from 'data/auth/users-query' -import { useProjectApiQuery } from 'data/config/project-api-query' -import { useParams } from 'common' interface BanUserModalProps { visible: boolean @@ -34,7 +34,7 @@ interface BanUserModalProps { export const BanUserModal = ({ visible, user, onClose }: BanUserModalProps) => { const { ref: projectRef } = useParams() - const { data: apiData } = useProjectApiQuery({ projectRef }) + const { data: settings } = useProjectSettingsV2Query({ projectRef }) const { mutate: updateUser, isLoading: isBanningUser } = useUserUpdateMutation({ onSuccess: (_, vars) => { const bannedUntil = dayjs() @@ -62,19 +62,24 @@ export const BanUserModal = ({ visible, user, onClose }: BanUserModalProps) => { const bannedUntil = dayjs().add(Number(value), unit).format('DD MMM YYYY HH:mm (ZZ)') const onSubmit = (data: FormType) => { - if (!apiData) { + if (!settings) { return toast.error(`Failed to ban user: Error loading project config`) } else if (user.id === undefined) { return toast.error(`Failed to ban user: User ID not found`) } const durationHours = data.unit === 'hours' ? Number(data.value) : Number(data.value) * 24 - const { protocol, endpoint, serviceApiKey } = apiData.autoApiService + const endpoint = settings.app_config?.endpoint + const { serviceKey } = getAPIKeys(settings) + + if (!endpoint) return toast.error(`Failed to ban user: Unable to retrieve API endpoint`) + if (!serviceKey?.api_key) return toast.error(`Failed to ban user: Unable to retrieve API key`) + updateUser({ projectRef, - protocol, + protocol: 'https', endpoint, - serviceApiKey, + serviceApiKey: serviceKey.api_key, userId: user.id, banDuration: durationHours, }) diff --git a/apps/studio/components/interfaces/Auth/Users/CreateUserModal.tsx b/apps/studio/components/interfaces/Auth/Users/CreateUserModal.tsx index 90d0e02b97e..41a421ddcd6 100644 --- a/apps/studio/components/interfaces/Auth/Users/CreateUserModal.tsx +++ b/apps/studio/components/interfaces/Auth/Users/CreateUserModal.tsx @@ -1,13 +1,13 @@ import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useParams } from 'common' import { Lock, Mail } from 'lucide-react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' import * as z from 'zod' +import { useParams } from 'common' import { useUserCreateMutation } from 'data/auth/user-create-mutation' -import { useProjectApiQuery } from 'data/config/project-api-query' +import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { Button, @@ -40,7 +40,11 @@ const CreateUserFormSchema = z.object({ const CreateUserModal = ({ visible, setVisible }: CreateUserModalProps) => { const { ref: projectRef } = useParams() - const { data, isLoading, isSuccess } = useProjectApiQuery({ projectRef }, { enabled: visible }) + const { + data: settings, + isLoading, + isSuccess, + } = useProjectSettingsV2Query({ projectRef }, { enabled: visible }) const canCreateUsers = useCheckPermissions(PermissionAction.AUTH_EXECUTE, 'create_user') @@ -56,8 +60,20 @@ const CreateUserModal = ({ visible, setVisible }: CreateUserModalProps) => { if (!isSuccess) { return toast.error(`Failed to create user: Error loading project config`) } - const { protocol, endpoint, serviceApiKey } = data.autoApiService - createUser({ projectRef, endpoint, protocol, serviceApiKey, user: values }) + const endpoint = settings?.app_config?.endpoint + const { serviceKey } = getAPIKeys(settings) + + if (!endpoint) return toast.error(`Failed to create user: Unable to retrieve API endpoint`) + if (!serviceKey?.api_key) + return toast.error(`Failed to create user: Unable to retrieve API key`) + + createUser({ + projectRef, + endpoint, + protocol: 'https', + serviceApiKey: serviceKey.api_key, + user: values, + }) } const form = useForm>({ diff --git a/apps/studio/components/interfaces/Auth/Users/UserLogs.tsx b/apps/studio/components/interfaces/Auth/Users/UserLogs.tsx index 6e5636ca8a8..391f65f922d 100644 --- a/apps/studio/components/interfaces/Auth/Users/UserLogs.tsx +++ b/apps/studio/components/interfaces/Auth/Users/UserLogs.tsx @@ -9,7 +9,7 @@ import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { User } from 'data/auth/users-query' import useLogsPreview from 'hooks/analytics/useLogsPreview' import { Button, cn, CriticalIcon, Separator } from 'ui' -import { Admonition } from 'ui-patterns' +import { Admonition, TimestampInfo } from 'ui-patterns' import { UserHeader } from './UserHeader' import { PANEL_PADDING } from './UserPanel' @@ -103,7 +103,6 @@ export const UserLogs = ({ user }: UserLogsProps) => {
    {authLogs.map((log) => { const status = ((log.status ?? '-') as any).toString() - const formattedTime = dayjs(log.timestamp).format('DD MMM HH:mm:ss') const is400 = status.startsWith('4') const is500 = status.startsWith('5') @@ -112,8 +111,8 @@ export const UserLogs = ({ user }: UserLogsProps) => { key={log.id} className="flex items-center transition font-mono px-2 py-1.5 bg-surface-100 divide-x" > -

    - {formattedTime} +

    +

    { const [isDeleteFactorsModalOpen, setIsDeleteFactorsModalOpen] = useState(false) const { data } = useAuthConfigQuery({ projectRef }) - const { data: apiData } = useProjectApiQuery({ projectRef }) + const { data: settings } = useProjectSettingsV2Query({ projectRef }) + const mailerOtpExpiry = data?.MAILER_OTP_EXP ?? 0 const minutes = Math.floor(mailerOtpExpiry / 60) const seconds = Math.floor(mailerOtpExpiry % 60) @@ -142,18 +143,23 @@ export const UserOverview = ({ user, onDeleteSuccess }: UserOverviewProps) => { } const handleUnban = () => { - if (!apiData) { + if (!settings) { return toast.error(`Failed to ban user: Error loading project config`) } else if (user.id === undefined) { return toast.error(`Failed to ban user: User ID not found`) } - const { protocol, endpoint, serviceApiKey } = apiData.autoApiService + const endpoint = settings.app_config?.endpoint + const { serviceKey } = getAPIKeys(settings) + + if (!endpoint) return toast.error(`Failed to unban user: Unable to retrieve API endpoint`) + if (!serviceKey?.api_key) return toast.error(`Failed to unban user: Unable to retrieve API key`) + updateUser({ projectRef, - protocol, + protocol: 'https', endpoint, - serviceApiKey, + serviceApiKey: serviceKey.api_key, userId: user.id, banDuration: 'none', }) @@ -515,6 +521,8 @@ const RowAction = ({ } className?: string }) => { + const disabled = button?.disabled ?? false + return (
    @@ -529,11 +537,13 @@ const RowAction = ({ icon={success ? : button.icon} loading={button.isLoading ?? false} onClick={button.onClick} - disabled={button?.disabled ?? false} + disabled={disabled} tooltip={{ content: { side: 'bottom', - text: `You need additional permissions to ${button.text.toLowerCase()}`, + text: disabled + ? `You need additional permissions to ${button.text.toLowerCase()}` + : undefined, }, }} > diff --git a/apps/studio/components/interfaces/Auth/Users/Users.utils.tsx b/apps/studio/components/interfaces/Auth/Users/Users.utils.tsx index 54ace3d0262..065feb6cca7 100644 --- a/apps/studio/components/interfaces/Auth/Users/Users.utils.tsx +++ b/apps/studio/components/interfaces/Auth/Users/Users.utils.tsx @@ -115,6 +115,20 @@ const phoneProviders = providers.phone.map((x) => { return key }) +function toPrettyJsonString(value: unknown): string | undefined { + if (!value) return undefined + if (typeof value === 'string') return value + if (Array.isArray(value)) return value.map((item) => toPrettyJsonString(item)).join(' ') + + try { + return JSON.stringify(value) + } catch (error) { + // ignore the error + } + + return undefined +} + export function getDisplayName(user: User, fallback = '-'): string { const { custom_claims, @@ -149,39 +163,44 @@ export function getDisplayName(user: User, fallback = '-'): string { first_name: cc_first_name, } = custom_claims ?? {} - const last = + const last = toPrettyJsonString( familyName || - family_name || - surname || - lastName || - last_name || - ccFamilyName || - cc_family_name || - ccSurname || - ccLastName || - cc_last_name + family_name || + surname || + lastName || + last_name || + ccFamilyName || + cc_family_name || + ccSurname || + ccLastName || + cc_last_name + ) - const first = + const first = toPrettyJsonString( givenName || - given_name || - firstName || - first_name || - ccGivenName || - cc_given_name || - ccFirstName || - cc_first_name + given_name || + firstName || + first_name || + ccGivenName || + cc_given_name || + ccFirstName || + cc_first_name + ) return ( - displayName || - display_name || - ccDisplayName || - cc_display_name || - fullName || - full_name || - ccFullName || - cc_full_name || - (first && last && `${first} ${last}`) || - fallback + toPrettyJsonString( + displayName || + display_name || + ccDisplayName || + cc_display_name || + fullName || + full_name || + ccFullName || + cc_full_name || + (first && last && `${first} ${last}`) || + last || + first + ) || fallback ) } @@ -210,9 +229,12 @@ export function getAvatarUrl(user: User): string | undefined { profile_url || profileImageUrl || profileImageURL || - profile_image_url) as string | undefined + profile_image_url || + '') as unknown - return SUPPORTED_CSP_AVATAR_URLS.some((x) => (url ?? '').startsWith(x)) ? url : undefined + if (typeof url !== 'string') return undefined + const isSupported = SUPPORTED_CSP_AVATAR_URLS.some((x) => url.startsWith(x)) + return isSupported ? url : undefined } export const formatUserColumns = ({ diff --git a/apps/studio/components/interfaces/BranchManagement/BranchManagement.tsx b/apps/studio/components/interfaces/BranchManagement/BranchManagement.tsx index 435e55c6fa4..366d9e47ff2 100644 --- a/apps/studio/components/interfaces/BranchManagement/BranchManagement.tsx +++ b/apps/studio/components/interfaces/BranchManagement/BranchManagement.tsx @@ -216,7 +216,9 @@ const BranchManagement = () => { tooltip={{ content: { side: 'bottom', - text: 'You need additional permissions to create branches', + text: !canCreateBranches + ? 'You need additional permissions to create branches' + : undefined, }, }} > @@ -271,7 +273,9 @@ const BranchManagement = () => { tooltip={{ content: { side: 'bottom', - text: 'You need additional permissions to disable branching', + text: !canDisableBranching + ? 'You need additional permissions to disable branching' + : undefined, }, }} > diff --git a/apps/studio/components/interfaces/BranchManagement/EmptyStates.tsx b/apps/studio/components/interfaces/BranchManagement/EmptyStates.tsx index 4432578919c..264af8aa091 100644 --- a/apps/studio/components/interfaces/BranchManagement/EmptyStates.tsx +++ b/apps/studio/components/interfaces/BranchManagement/EmptyStates.tsx @@ -29,7 +29,9 @@ export const BranchingEmptyState = () => { tooltip={{ content: { side: 'bottom', - text: 'You need additional permissions to enable branching', + text: !canEnableBranching + ? 'You need additional permissions to enable branching' + : undefined, }, }} > diff --git a/apps/studio/components/interfaces/DataWarehouse/CreateWarehouseAccessToken.tsx b/apps/studio/components/interfaces/DataWarehouse/CreateWarehouseAccessToken.tsx index af20da05fdd..cbb8960f1fa 100644 --- a/apps/studio/components/interfaces/DataWarehouse/CreateWarehouseAccessToken.tsx +++ b/apps/studio/components/interfaces/DataWarehouse/CreateWarehouseAccessToken.tsx @@ -40,7 +40,9 @@ const CreateWarehouseAccessToken = ({ onSubmit, loading, open, setOpen }: Create tooltip={{ content: { side: 'bottom', - text: 'You need additional permissions to create access tokens', + text: !canCreateAccessTokens + ? 'You need additional permissions to create access tokens' + : undefined, }, }} > diff --git a/apps/studio/components/interfaces/DataWarehouse/WarehouseCollectionDetail.tsx b/apps/studio/components/interfaces/DataWarehouse/WarehouseCollectionDetail.tsx index 5bd3d6a413e..dd6032a6ece 100644 --- a/apps/studio/components/interfaces/DataWarehouse/WarehouseCollectionDetail.tsx +++ b/apps/studio/components/interfaces/DataWarehouse/WarehouseCollectionDetail.tsx @@ -19,6 +19,8 @@ import DatePickers from '../Settings/Logs/Logs.DatePickers' import { DatetimeHelper } from '../Settings/Logs/Logs.types' import dayjs from 'dayjs' import { useKeyboardShortcuts } from 'components/grid/components/common/Hooks' +import { useWarehouseLogDetailQuery } from 'data/analytics/warehouse-collection-log-detail-query' +import { useSelectedLog } from 'hooks/analytics/useSelectedLog' const INTERVALS: DatetimeHelper[] = [ { @@ -154,6 +156,19 @@ order by timestamp desc limit ${filters.limit} offset ${filters.offset} ) } + + // Selected Log Detail + const [selectedLogId, setSelectedLogId] = useSelectedLog() + const { + data: selectedLogDetail, + isLoading: selectedLogDetailLoading, + error: selectedLogDetailError, + } = useWarehouseLogDetailQuery({ + ref: projectRef, + collectionName: collection?.name, + logId: selectedLogId ?? undefined, + }) + return (
    @@ -248,9 +263,11 @@ order by timestamp desc limit ${filters.limit} offset ${filters.offset} hasEditorValue={false} projectRef={projectRef} isLoading={isLoading} + onSelectedLogChange={(log) => setSelectedLogId(log?.id ?? null)} + selectedLog={selectedLogDetail?.result[0] ?? undefined} + selectedLogError={selectedLogDetailError ?? undefined} + isSelectedLogLoading={!!selectedLogId && selectedLogDetailLoading} data={results} - params={{ sql }} - maxHeight="calc(100vh - 139px)" showHeader={false} EmptyState={ @@ -277,20 +294,18 @@ order by timestamp desc limit ${filters.limit} offset ${filters.offset} {!isError && ( -
    -
    +
    +
    {results.length > 0 && ( - <> - - + )} {filters.offset !== 0 && ( <> diff --git a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx index c86f4e08d2d..b5f5bc8021d 100644 --- a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx +++ b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx @@ -1,5 +1,6 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { includes, noop, sortBy } from 'lodash' +import { Edit, Edit2, FileText, MoreVertical, Trash } from 'lucide-react' import { useRouter } from 'next/router' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' @@ -7,12 +8,13 @@ import Table from 'components/to-be-cleaned/Table' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useDatabaseFunctionsQuery } from 'data/database-functions/database-functions-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { Edit3, FileText, MoreVertical, Trash } from 'lucide-react' +import { useAppStateSnapshot } from 'state/app-state' import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from 'ui' @@ -33,6 +35,7 @@ const FunctionList = ({ }: FunctionListProps) => { const router = useRouter() const { project: selectedProject } = useProjectContext() + const { setAiAssistantPanel } = useAppStateSnapshot() const { data: functions } = useDatabaseFunctionsQuery({ projectRef: selectedProject?.ref, @@ -116,11 +119,21 @@ const FunctionList = ({ )} editFunction(x)}> - +

    Edit function

    + { + setAiAssistantPanel({ open: true, editor: 'functions', entity: x }) + }} + > + +

    Edit function with Assistant

    +
    + deleteFunction(x)}> - +

    Delete function

    diff --git a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx index 759a4dac823..56b09f32faf 100644 --- a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx +++ b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx @@ -5,6 +5,7 @@ import { Search } from 'lucide-react' import { useRouter } from 'next/router' import { useParams } from 'common' +import { useIsDatabaseFunctionsAssistantEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState' import Table from 'components/to-be-cleaned/Table' @@ -17,7 +18,8 @@ import { useSchemasQuery } from 'data/database/schemas-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas' -import { Input } from 'ui' +import { useAppStateSnapshot } from 'state/app-state' +import { AiIconAnimation, Input } from 'ui' import ProtectedSchemaWarning from '../../ProtectedSchemaWarning' import FunctionList from './FunctionList' @@ -32,10 +34,13 @@ const FunctionsList = ({ editFunction = noop, deleteFunction = noop, }: FunctionsListProps) => { - const { project } = useProjectContext() const router = useRouter() const { search } = useParams() + const { project } = useProjectContext() + const { setAiAssistantPanel } = useAppStateSnapshot() + const enableFunctionsAssistant = useIsDatabaseFunctionsAssistantEnabled() const { selectedSchema, setSelectedSchema } = useQuerySchemaState() + const filterString = search ?? '' const setFilterString = (str: string) => { @@ -122,20 +127,51 @@ const FunctionsList = ({ />
    - {!isLocked && ( - createFunction()} - tooltip={{ - content: { - side: 'bottom', - text: 'You need additional permissions to create functions', - }, - }} - > - Create a new function - - )} +
    + {!isLocked && ( + <> + createFunction()} + tooltip={{ + content: { + side: 'bottom', + text: !canCreateFunctions + ? 'You need additional permissions to create functions' + : undefined, + }, + }} + > + Create a new function + + {enableFunctionsAssistant && ( + + } + onClick={() => + setAiAssistantPanel({ + open: true, + editor: 'functions', + entity: undefined, + }) + } + tooltip={{ + content: { + side: 'bottom', + text: !canCreateFunctions + ? 'You need additional permissions to create functions' + : 'Create with Supabase Assistant', + }, + }} + /> + )} + + )} +
    {isLocked && } diff --git a/apps/studio/components/interfaces/Database/Hooks/HTTPRequestFields.tsx b/apps/studio/components/interfaces/Database/Hooks/HTTPRequestFields.tsx index 53103b49630..590a3caeee3 100644 --- a/apps/studio/components/interfaces/Database/Hooks/HTTPRequestFields.tsx +++ b/apps/studio/components/interfaces/Database/Hooks/HTTPRequestFields.tsx @@ -1,10 +1,11 @@ import clsx from 'clsx' +import { ChevronDown, Plus, Trash } from 'lucide-react' import Link from 'next/link' -import { useParams } from 'common/hooks' +import { useParams } from 'common' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection' -import { useProjectApiQuery } from 'data/config/project-api-query' +import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query' import { uuidv4 } from 'lib/helpers' import { @@ -19,7 +20,6 @@ import { SidePanel, } from 'ui' import { HTTPArgument } from './EditHookPanel' -import { Trash, Plus, ChevronDown } from 'lucide-react' interface HTTPRequestFieldsProps { type: 'http_request' | 'supabase_function' @@ -48,12 +48,12 @@ const HTTPRequestFields = ({ }: HTTPRequestFieldsProps) => { const { project: selectedProject } = useProjectContext() const { ref } = useParams() - const { data: settings } = useProjectApiQuery({ projectRef: ref }) + const { data: settings } = useProjectSettingsV2Query({ projectRef: ref }) const { data: functions } = useEdgeFunctionsQuery({ projectRef: ref }) const edgeFunctions = functions ?? [] - const apiService = settings?.autoApiService - const apiKey = apiService?.serviceApiKey ?? '[YOUR API KEY]' + const { serviceKey } = getAPIKeys(settings) + const apiKey = serviceKey?.api_key ?? '[YOUR API KEY]' return ( <> diff --git a/apps/studio/components/interfaces/Database/Indexes/CreateIndexSidePanel.tsx b/apps/studio/components/interfaces/Database/Indexes/CreateIndexSidePanel.tsx index dd527130d09..983bb4f4283 100644 --- a/apps/studio/components/interfaces/Database/Indexes/CreateIndexSidePanel.tsx +++ b/apps/studio/components/interfaces/Database/Indexes/CreateIndexSidePanel.tsx @@ -1,4 +1,4 @@ -import { Check, ChevronsUpDown } from 'lucide-react' +import { Check, ChevronsUpDown, Loader2 } from 'lucide-react' import Link from 'next/link' import { useEffect, useMemo, useState } from 'react' import { toast } from 'sonner' @@ -50,7 +50,7 @@ const CreateIndexSidePanel = ({ visible, onClose }: CreateIndexSidePanelProps) = const [selectedIndexType, setSelectedIndexType] = useState(INDEX_TYPES[0].value) const [schemaDropdownOpen, setSchemaDropdownOpen] = useState(false) const [tableDropdownOpen, setTableDropdownOpen] = useState(false) - + const [searchTerm, setSearchTerm] = useState('') const { refetch: refetchIndexes } = useIndexesQuery({ schema: selectedSchema, projectRef: project?.ref, @@ -60,10 +60,10 @@ const CreateIndexSidePanel = ({ visible, onClose }: CreateIndexSidePanelProps) = projectRef: project?.ref, connectionString: project?.connectionString, }) - const { data: entities, isLoading } = useEntityTypesQuery({ - schema: selectedSchema, + const { data: entities, isLoading: isLoadingEntities } = useEntityTypesQuery({ + schemas: [selectedSchema], sort: 'alphabetical', - search: undefined, + search: searchTerm, projectRef: project?.ref, connectionString: project?.connectionString, }) @@ -94,6 +94,10 @@ const CreateIndexSidePanel = ({ visible, onClose }: CreateIndexSidePanelProps) = [entities?.pages] ) + function handleSearchChange(value: string) { + setSearchTerm(value) + } + const columns = tableColumns?.result[0]?.columns ?? [] const columnOptions: MultiSelectOption[] = columns .filter((column): column is NonNullable => column !== null) @@ -181,7 +185,11 @@ CREATE INDEX ON "${selectedSchema}"."${selectedEntity}" USING ${selectedIndexTyp sameWidthAsTrigger > - + No schemas found @@ -218,7 +226,7 @@ CREATE INDEX ON "${selectedSchema}"."${selectedEntity}" USING ${selectedIndexTyp name="select-table" description={ isSelectEntityDisabled && - !isLoading && + !isLoadingEntities && 'Create a table in this schema via the Table or SQL editor first' } isReactForm={false} @@ -228,28 +236,27 @@ CREATE INDEX ON "${selectedSchema}"."${selectedEntity}" USING ${selectedIndexTyp open={tableDropdownOpen} onOpenChange={setTableDropdownOpen} > - - {isLoading ? ( - - ) : ( - - )} + + - - + {/* [Terry] shouldFilter context: + https://github.com/pacocoursey/cmdk/issues/267#issuecomment-2252717107 */} + + - No tables found + + {isLoadingEntities ? ( +
    + + Loading... +
    + ) : ( + 'No tables found' + )} +
    7 ? 'h-[210px]' : ''}> {(entityTypes ?? []).map((entity) => ( diff --git a/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx b/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx index 5efc751fca3..83610ce74b7 100644 --- a/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx +++ b/apps/studio/components/interfaces/Database/Schemas/SchemaGraph.tsx @@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } 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 AlertError from 'components/ui/AlertError' import SchemaSelector from 'components/ui/SchemaSelector' @@ -13,10 +14,9 @@ import { useTablesQuery } from 'data/tables/tables-query' import { useLocalStorage } from 'hooks/misc/useLocalStorage' import { LOCAL_STORAGE_KEYS } from 'lib/constants' import { Button, Tooltip_Shadcn_, TooltipContent_Shadcn_, TooltipTrigger_Shadcn_ } from 'ui' -import { TableNode } from 'ui-patterns/SchemaTableNode' +import { TableNode } from './SchemaTableNode' import { SchemaGraphLegend } from './SchemaGraphLegend' import { getGraphDataFromTables, getLayoutedElementsViaDagre } from './Schemas.utils' -import { useParams } from 'common' // [Joshen] Persisting logic: Only save positions to local storage WHEN a node is moved OR when explicitly clicked to reset layout diff --git a/packages/ui-patterns/SchemaTableNode.tsx b/apps/studio/components/interfaces/Database/Schemas/SchemaTableNode.tsx similarity index 83% rename from packages/ui-patterns/SchemaTableNode.tsx rename to apps/studio/components/interfaces/Database/Schemas/SchemaTableNode.tsx index 76424665157..f3f9597eabe 100644 --- a/packages/ui-patterns/SchemaTableNode.tsx +++ b/apps/studio/components/interfaces/Database/Schemas/SchemaTableNode.tsx @@ -1,13 +1,17 @@ -import { DiamondIcon, Fingerprint, Hash, Key, Table2 } from 'lucide-react' +import { DiamondIcon, ExternalLink, Fingerprint, Hash, Key, Link2, Table2 } from 'lucide-react' import { Handle, NodeProps } from 'reactflow' -import { cn } from 'ui' +import Link from 'next/link' + +import { Button, cn, Tooltip_Shadcn_, TooltipContent_Shadcn_, TooltipTrigger_Shadcn_ } from 'ui' // ReactFlow is scaling everything by the factor of 2 const TABLE_NODE_WIDTH = 320 const TABLE_NODE_ROW_HEIGHT = 40 export type TableNodeData = { + id?: number name: string + ref: string isForeign: boolean columns: { id: string @@ -48,12 +52,21 @@ const TableNode = ({ data, targetPosition, sourcePosition }: NodeProps
    - - {data.name} +
    + + {data.name} +
    + {data.id && ( + + )}
    {data.columns.map((column) => ( @@ -63,8 +76,6 @@ const TableNode = ({ data, targetPosition, sourcePosition }: NodeProps diff --git a/apps/studio/components/interfaces/Database/Tables/TableList.tsx b/apps/studio/components/interfaces/Database/Tables/TableList.tsx index 1242353bfee..d7245c85ec8 100644 --- a/apps/studio/components/interfaces/Database/Tables/TableList.tsx +++ b/apps/studio/components/interfaces/Database/Tables/TableList.tsx @@ -30,12 +30,12 @@ import { useDatabasePublicationsQuery } from 'data/database-publications/databas import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants' import { useForeignTablesQuery } from 'data/foreign-tables/foreign-tables-query' import { useMaterializedViewsQuery } from 'data/materialized-views/materialized-views-query' +import { usePrefetchEditorTablePage } from 'data/prefetchers/project.$ref.editor.$id' import { useTablesQuery } from 'data/tables/tables-query' import { useViewsQuery } from 'data/views/views-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas' -import { useTableEditorStateSnapshot } from 'state/table-editor' import { Button, Checkbox_Shadcn_, @@ -74,6 +74,8 @@ const TableList = ({ const { ref } = useParams() const { project } = useProjectContext() + const prefetchEditorTablePage = usePrefetchEditorTablePage() + const { selectedSchema, setSelectedSchema } = useQuerySchemaState() const [filterString, setFilterString] = useState('') @@ -273,7 +275,9 @@ const TableList = ({ tooltip={{ content: { side: 'bottom', - text: 'You need additional permissions to create tables', + text: !canUpdateTables + ? 'You need additional permissions to create tables' + : undefined, }, }} > @@ -476,6 +480,9 @@ const TableList = ({ onClick={() => router.push(`/project/${project?.ref}/editor/${x.id}`) } + onMouseEnter={() => + prefetchEditorTablePage({ id: x.id ? String(x.id) : undefined }) + } >

    View in Table Editor

    diff --git a/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx b/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx index afeacada59b..ae5566ebb88 100644 --- a/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx +++ b/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx @@ -125,9 +125,7 @@ const TriggerList = ({ {canUpdateTriggers ? ( - +
    ) @@ -142,7 +142,7 @@ const CreateWrapper = () => { 'absolute -left-20 top-1 opacity-75 hover:opacity-100', ].join(' ')} > - +

    Back

    diff --git a/apps/studio/components/interfaces/Database/Wrappers/EditWrapper.tsx b/apps/studio/components/interfaces/Database/Wrappers/EditWrapper.tsx index 895d5aa505c..71aed86921c 100644 --- a/apps/studio/components/interfaces/Database/Wrappers/EditWrapper.tsx +++ b/apps/studio/components/interfaces/Database/Wrappers/EditWrapper.tsx @@ -62,7 +62,7 @@ const EditWrapper = () => { const hasNewSchema = wrapperTables.some((table) => table.is_new_schema) if (hasNewSchema) invalidateSchemasQuery(queryClient, ref) - router.push(`/project/${ref}/database/wrappers`) + router.push(`/project/${ref}/integrations/wrappers`) }, }) @@ -117,7 +117,7 @@ const EditWrapper = () => {

    ) @@ -169,7 +169,7 @@ const EditWrapper = () => { 'absolute -left-20 top-1 opacity-75 hover:opacity-100', ].join(' ')} > - +

    Back

    diff --git a/apps/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx b/apps/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx index 21c9fce17f9..6dc505446c8 100644 --- a/apps/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx +++ b/apps/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx @@ -1,14 +1,15 @@ -import * as Tooltip from '@radix-ui/react-tooltip' import { PermissionAction } from '@supabase/shared-types/out/constants' import { partition } from 'lodash' +import { ChevronUp, Edit, ExternalLink, Trash } from 'lucide-react' import Image from 'next/legacy/image' import Link from 'next/link' -import { Button, Collapsible } from 'ui' -import { useParams } from 'common/hooks' +import { useParams } from 'common' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' import type { FDW } from 'data/fdw/fdws-query' +import { EditorTablePageLink } from 'data/prefetchers/project.$ref.editor.$id' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { ChevronUp, Edit, ExternalLink, Trash } from 'lucide-react' +import { Collapsible } from 'ui' import type { WrapperMeta } from './Wrappers.types' interface WrapperRowProps { @@ -119,11 +120,15 @@ const WrapperRow = ({
    {wrapper.tables ? ( wrapper.tables.map((table: any) => ( - +
    {table.name}
    - +
    )) ) : (

    No tables available

    @@ -131,72 +136,41 @@ const WrapperRow = ({
    -
    - {canManageWrappers ? ( - -
    ) diff --git a/apps/studio/components/interfaces/Database/Wrappers/WrappersDisabledState.tsx b/apps/studio/components/interfaces/Database/Wrappers/WrappersDisabledState.tsx index 920e6a3577d..61933f017b7 100644 --- a/apps/studio/components/interfaces/Database/Wrappers/WrappersDisabledState.tsx +++ b/apps/studio/components/interfaces/Database/Wrappers/WrappersDisabledState.tsx @@ -75,7 +75,7 @@ const WrappersDisabledState = () => { return (
    { setOpen(false) router.push( - `/project/${ref}/database/wrappers/new?type=${wrapper.name.toLowerCase()}` + `/project/${ref}/integrations/wrappers/new?type=${wrapper.name.toLowerCase()}` ) }} className="gap-2 cursor-pointer" diff --git a/apps/studio/components/interfaces/Database/Wrappers/WrappersEmptyState.tsx b/apps/studio/components/interfaces/Database/Wrappers/WrappersEmptyState.tsx index 39bc4db5957..3196e7c2cb9 100644 --- a/apps/studio/components/interfaces/Database/Wrappers/WrappersEmptyState.tsx +++ b/apps/studio/components/interfaces/Database/Wrappers/WrappersEmptyState.tsx @@ -19,7 +19,7 @@ const WrappersEmptyState = () => {
    {WRAPPERS.map((wrapper) => ( diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelForm.tsx b/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelForm.tsx index 98221dbad77..e9e82e8e642 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelForm.tsx +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelForm.tsx @@ -1,7 +1,7 @@ import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' import { AnimatePresence, motion } from 'framer-motion' -import { HelpCircle, InfoIcon, RotateCcw } from 'lucide-react' +import { ExternalLink, HelpCircle, InfoIcon, RotateCcw } from 'lucide-react' import Link from 'next/link' import { useEffect, useState } from 'react' import { useForm } from 'react-hook-form' @@ -60,7 +60,7 @@ import { calculateIOPSPrice, calculateThroughputPrice, } from './DiskManagement.utils' -import { DiskStorageSchema, DiskStorageSchemaType } from './DiskManagementPanelSchema' +import { getDiskStorageSchema, DiskStorageSchemaType } from './DiskManagementPanelSchema' import { DiskManagementPlanUpgradeRequired } from './DiskManagementPlanUpgradeRequired' import { DiskManagementDiskSizeReadReplicas, @@ -68,6 +68,8 @@ import { DiskManagementThroughputReadReplicas, } from './DiskManagementReadReplicas' import { DiskManagementReviewAndSubmitDialog } from './DiskManagementReviewAndSubmitDialog' +import { Admonition } from 'ui-patterns' +import { Markdown } from '../Markdown' export function DiskManagementPanelForm() { const { project } = useProjectContext() @@ -123,7 +125,7 @@ export function DiskManagementPanelForm() { const { data: addons } = useProjectAddonsQuery({ projectRef }) const currentCompute = (addons?.selected_addons ?? []).find( - (x) => x.type === 'compute_instance' + (x: { type: string }) => x.type === 'compute_instance' )?.variant const maxIopsBasedOnCompute = COMPUTE_SIZE_MAX_IOPS[(currentCompute?.identifier ?? '') as keyof typeof COMPUTE_SIZE_MAX_IOPS] @@ -135,7 +137,7 @@ export function DiskManagementPanelForm() { const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: org?.slug, }) - const planId = subscription?.plan.id ?? '' + const planId = subscription?.plan.id ?? 'free' const isPlanUpgradeRequired = subscription?.plan.id === 'pro' && !subscription.usage_billing_enabled @@ -156,11 +158,12 @@ export function DiskManagementPanelForm() { }) const defaultValues = { - storageType: type, + storageType: type || DiskType.GP3, provisionedIOPS: iops, throughput: throughput_mbps, totalSize: size_gb, } + const DiskStorageSchema = getDiskStorageSchema(size_gb) const form = useForm({ resolver: zodResolver(DiskStorageSchema), defaultValues, @@ -180,8 +183,7 @@ export function DiskManagementPanelForm() { isWithinCooldownWindow || !canUpdateDiskConfiguration - const { includedDiskGB: includedDiskGBMeta } = - PLAN_DETAILS?.[planId as keyof typeof PLAN_DETAILS] ?? {} + const { includedDiskGB: includedDiskGBMeta } = PLAN_DETAILS[planId] const includedDiskGB = includedDiskGBMeta[watchedStorageType] const minIOPS = IOPS_RANGE[watchedStorageType]?.min ?? 0 @@ -201,12 +203,21 @@ export function DiskManagementPanelForm() { updateDiskConfigurationRQ({ ref: projectRef, ...data }) } + // i hate typescript enums + // gotta clean this up but it allows me to remove type casts for now + function getEnumFromFormValue(value: string) { + if (value === 'io2') return DiskType.IO2 + if (value === 'gp3') return DiskType.GP3 + return DiskType.IO2 + } + + const newStorageType = form.getValues('storageType') const diskSizePrice = calculateDiskSizePrice({ planId, oldSize: form.formState.defaultValues?.totalSize || 0, - oldStorageType: form.formState.defaultValues?.storageType as DiskType, + oldStorageType: getEnumFromFormValue(form.formState.defaultValues?.storageType || 'io2'), newSize: form.getValues('totalSize'), - newStorageType: form.getValues('storageType') as DiskType, + newStorageType: getEnumFromFormValue(newStorageType), }) const iopsPrice = calculateIOPSPrice({ @@ -608,8 +619,31 @@ export function DiskManagementPanelForm() { label="Disk Size" layout="horizontal" description={ - includedDiskGB > 0 && - `Your plan includes ${includedDiskGB} GB of disk size for ${watchedStorageType}.` + <> + {includedDiskGB > 0 && + `Your plan includes ${includedDiskGB} GB of disk size for ${watchedStorageType}.`} + {field.value < size_gb && ( + + } + > +
    + +
    +
    + )} + } >
    diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelSchema.ts b/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelSchema.ts index ca1db6987fc..37bf8f73fb9 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelSchema.ts +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelSchema.ts @@ -11,89 +11,98 @@ const baseSchema = z.object({ throughput: z.number().optional().describe('Throughput in MB/s for gp3'), }) -export const DiskStorageSchema = baseSchema.superRefine((data, ctx) => { - const { storageType, totalSize, provisionedIOPS, throughput } = data +export const getDiskStorageSchema = (currentDiskSize: number) => + baseSchema.superRefine((data, ctx) => { + const { storageType, totalSize, provisionedIOPS, throughput } = data - if (storageType === 'io2') { - // Validation rules for io2 - const maxIOPS = Math.min(1000 * totalSize, 256000) - if (provisionedIOPS < 100) { + if (totalSize < currentDiskSize) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: 'Provisioned IOPS must be at least 100', - path: ['provisionedIOPS'], - }) - } else if (provisionedIOPS > maxIOPS) { - if (totalSize >= 8) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Provisioned IOPS must be at most ${maxIOPS}.`, - path: ['provisionedIOPS'], - }) - } else { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Invalid IOPS value due to invalid disk size`, - path: ['provisionedIOPS'], - }) - } - } - if (throughput !== undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Throughput is not configurable for io2.', - path: ['throughput'], - }) - } - if (totalSize > DISK_LIMITS[DiskType.IO2].maxStorage) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Allocated disksize must not exceed 61,440 GB', + message: 'Disk size cannot be reduced from current configuration', path: ['totalSize'], }) } - } else if (storageType === 'gp3') { - // Validation rules for gp3 - const maxIOPS = Math.min(500 * totalSize, 16000) - const maxThroughput = Math.min(0.25 * provisionedIOPS, 1000) - if (provisionedIOPS < 3000) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Provisioned IOPS must be at least 3000`, - path: ['provisionedIOPS'], - }) - } else if (provisionedIOPS > maxIOPS) { - if (totalSize >= 8) { + if (storageType === 'io2') { + // Validation rules for io2 + const maxIOPS = Math.min(1000 * totalSize, 256000) + if (provisionedIOPS < 100) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: `Provisioned IOPS must be at most ${maxIOPS}`, + message: 'Provisioned IOPS must be at least 100', path: ['provisionedIOPS'], }) - } else { + } else if (provisionedIOPS > maxIOPS) { + if (totalSize >= 8) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Provisioned IOPS must be at most ${maxIOPS}.`, + path: ['provisionedIOPS'], + }) + } else { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid IOPS value due to invalid disk size`, + path: ['provisionedIOPS'], + }) + } + } + if (throughput !== undefined) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: `Invalid IOPS value due to invalid disk size`, + message: 'Throughput is not configurable for io2.', + path: ['throughput'], + }) + } + if (totalSize > DISK_LIMITS[DiskType.IO2].maxStorage) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Allocated disk size must not exceed 61,440 GB', + path: ['totalSize'], + }) + } + } else if (storageType === 'gp3') { + // Validation rules for gp3 + const maxIOPS = Math.min(500 * totalSize, 16000) + const maxThroughput = Math.min(0.25 * provisionedIOPS, 1000) + + if (provisionedIOPS < 3000) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Provisioned IOPS must be at least 3000`, path: ['provisionedIOPS'], }) + } else if (provisionedIOPS > maxIOPS) { + if (totalSize >= 8) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Provisioned IOPS must be at most ${maxIOPS}`, + path: ['provisionedIOPS'], + }) + } else { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid IOPS value due to invalid disk size`, + path: ['provisionedIOPS'], + }) + } + } + + if (throughput !== undefined && (throughput < 125 || throughput > maxThroughput)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Throughput must be set between 125 and ${maxThroughput?.toLocaleString()} MB/s.`, + path: ['throughput'], + }) + } + if (totalSize > DISK_LIMITS[DiskType.GP3].maxStorage) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Allocated disk size must not exceed 16,384 GB', + path: ['totalSize'], + }) } } + }) - if (throughput !== undefined && (throughput < 125 || throughput > maxThroughput)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Throughput must be set between 125 and ${maxThroughput?.toLocaleString()} MB/s.`, - path: ['throughput'], - }) - } - if (totalSize > DISK_LIMITS[DiskType.GP3].maxStorage) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Allocated disksize must not exceed 16,384 GB', - path: ['totalSize'], - }) - } - } -}) - -export type DiskStorageSchemaType = z.infer +export type DiskStorageSchemaType = z.infer> diff --git a/apps/studio/components/interfaces/DiskManagement/DiskSpaceBar.tsx b/apps/studio/components/interfaces/DiskManagement/DiskSpaceBar.tsx index 1f3ff172e64..4c588d05af4 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskSpaceBar.tsx +++ b/apps/studio/components/interfaces/DiskManagement/DiskSpaceBar.tsx @@ -9,6 +9,10 @@ import { TooltipTrigger_Shadcn_, } from 'ui' import { AUTOSCALING_THRESHOLD } from './DiskManagement.constants' +import { Info } from 'lucide-react' +import { formatBytes } from 'lib/helpers' +import { useDatabaseSizeQuery } from 'data/database/database-size-query' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' interface DiskSpaceBarProps { showNewBar: boolean @@ -29,6 +33,13 @@ export default function DiskSpaceBar({ const newUsedPercentage = (usedSize / newTotalSize) * 100 const newResizePercentage = AUTOSCALING_THRESHOLD * 100 + const { project } = useProjectContext() + const { data } = useDatabaseSizeQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + + const databaseSizeBytes = data?.result[0].db_size ?? 0 return (
    @@ -154,8 +165,8 @@ export default function DiskSpaceBar({ > -
    - Autoscaling +
    + Autoscaling
    @@ -186,6 +197,12 @@ export default function DiskSpaceBar({ Available space
    +

    + Note: Disk Size refers to the total space your + project occupies on disk, including the database itself (currently{' '} + {formatBytes(databaseSizeBytes, 2, 'GB')}), additional files like the + write-ahead log (WAL), and other internal resources. +

    ) } diff --git a/apps/studio/components/interfaces/Docs/Authentication.tsx b/apps/studio/components/interfaces/Docs/Authentication.tsx index d3a44692039..6fe9c58a950 100644 --- a/apps/studio/components/interfaces/Docs/Authentication.tsx +++ b/apps/studio/components/interfaces/Docs/Authentication.tsx @@ -1,24 +1,30 @@ import Link from 'next/link' -import type { AutoApiService } from 'data/config/project-api-query' +import { useParams } from 'common' +import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import CodeSnippet from './CodeSnippet' import Snippets from './Snippets' interface AuthenticationProps { - autoApiService: AutoApiService selectedLang: 'bash' | 'js' showApiKey: string } -const Authentication = ({ autoApiService, selectedLang, showApiKey }: AuthenticationProps) => { +const Authentication = ({ selectedLang, showApiKey }: AuthenticationProps) => { + const { ref: projectRef } = useParams() + const { data: settings } = useProjectSettingsV2Query({ projectRef }) + + const { anonKey, serviceKey } = getAPIKeys(settings) + const endpoint = `https://${settings?.app_config?.endpoint ?? ''}` + // [Joshen] ShowApiKey should really be a boolean, its confusing const defaultApiKey = showApiKey !== 'SUPABASE_KEY' - ? autoApiService?.defaultApiKey ?? 'SUPABASE_CLIENT_API_KEY' + ? anonKey?.api_key ?? 'SUPABASE_CLIENT_API_KEY' : 'SUPABASE_CLIENT_API_KEY' const serviceApiKey = showApiKey !== 'SUPABASE_KEY' - ? autoApiService?.serviceApiKey ?? 'SUPABASE_SERVICE_KEY' + ? serviceKey?.api_key ?? 'SUPABASE_SERVICE_KEY' : 'SUPABASE_SERVICE_KEY' return ( @@ -53,8 +59,7 @@ const Authentication = ({ autoApiService, selectedLang, showApiKey }: Authentica

    We have provided you a Client Key to get started. You will soon be able to add as many keys as you like. You can find the anon key in the{' '} - API Settings{' '} - page. + API Settings page.

    @@ -64,7 +69,7 @@ const Authentication = ({ autoApiService, selectedLang, showApiKey }: Authentica /> @@ -85,8 +90,7 @@ const Authentication = ({ autoApiService, selectedLang, showApiKey }: Authentica

    We have provided you with a Service Key to get started. Soon you will be able to add as many keys as you like. You can find the service_role in the{' '} - API Settings{' '} - page. + API Settings page.

    @@ -96,9 +100,7 @@ const Authentication = ({ autoApiService, selectedLang, showApiKey }: Authentica />
    diff --git a/apps/studio/components/interfaces/Docs/GeneralContent.tsx b/apps/studio/components/interfaces/Docs/GeneralContent.tsx index cb336120919..4be9860fdcb 100644 --- a/apps/studio/components/interfaces/Docs/GeneralContent.tsx +++ b/apps/studio/components/interfaces/Docs/GeneralContent.tsx @@ -4,26 +4,19 @@ import TablesIntroduction from 'components/interfaces/Docs/Pages/Tables/Introduc import UserManagement from 'components/interfaces/Docs/Pages/UserManagement' import RpcIntroduction from 'components/interfaces/Docs/Pages/Rpc/Introduction' -const GeneralContent = ({ autoApiService, selectedLang, page, showApiKey }: any) => { +interface GeneralContentProps { + page?: string + selectedLang: 'bash' | 'js' + showApiKey: string +} + +const GeneralContent = ({ selectedLang, page, showApiKey }: GeneralContentProps) => { let selected = page?.toLowerCase() - if (selected == 'intro' || selected == null) - return + if (selected == 'intro' || selected == null) return if (selected == 'auth') - return ( - - ) + return if (selected == 'users') - return ( - - ) + return if (selected == 'tables-intro') return if (selected == 'rpc-intro') return else diff --git a/apps/studio/components/interfaces/Docs/Introduction.tsx b/apps/studio/components/interfaces/Docs/Introduction.tsx index 6c4c68c167e..c187493a1f0 100644 --- a/apps/studio/components/interfaces/Docs/Introduction.tsx +++ b/apps/studio/components/interfaces/Docs/Introduction.tsx @@ -1,21 +1,23 @@ import { useParams } from 'common' import Snippets from 'components/interfaces/Docs/Snippets' -import type { AutoApiService } from 'data/config/project-api-query' import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-config-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import CodeSnippet from './CodeSnippet' import PublicSchemaNotEnabledAlert from './PublicSchemaNotEnabledAlert' interface Props { - autoApiService: AutoApiService selectedLang: 'bash' | 'js' } -export default function Introduction({ autoApiService, selectedLang }: Props) { +export default function Introduction({ selectedLang }: Props) { const { ref: projectRef } = useParams() + const { data: settings } = useProjectSettingsV2Query({ projectRef }) const { data: config } = useProjectPostgrestConfigQuery({ projectRef }) + const endpoint = `https://${settings?.app_config?.endpoint ?? ''}` + const isPublicSchemaEnabled = config?.db_schema .split(',') .map((name) => name.trim()) @@ -24,7 +26,7 @@ export default function Introduction({ autoApiService, selectedLang }: Props) { return (
    - + {!isPublicSchemaEnabled && }
    diff --git a/apps/studio/components/interfaces/Docs/LangSelector.tsx b/apps/studio/components/interfaces/Docs/LangSelector.tsx index 53376c063c9..b523ded9d53 100644 --- a/apps/studio/components/interfaces/Docs/LangSelector.tsx +++ b/apps/studio/components/interfaces/Docs/LangSelector.tsx @@ -1,8 +1,10 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' - -import type { showApiKey } from 'components/interfaces/Docs/Docs.types' -import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { Key } from 'lucide-react' + +import { useParams } from 'common' +import type { showApiKey } from 'components/interfaces/Docs/Docs.types' +import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' +import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { Button, DropdownMenu, @@ -15,26 +17,26 @@ const DEFAULT_KEY = { name: 'hide', key: 'SUPABASE_KEY' } interface LangSelectorProps { selectedLang: string - setSelectedLang: (selectedLang: string) => void showApiKey: showApiKey + setSelectedLang: (selectedLang: string) => void setShowApiKey: (showApiKey: showApiKey) => void - apiKey: string | undefined - autoApiService: any } const LangSelector = ({ selectedLang, - setSelectedLang, showApiKey, + setSelectedLang, setShowApiKey, - apiKey, - autoApiService, }: LangSelectorProps) => { + const { ref: projectRef } = useParams() const canReadServiceKey = useCheckPermissions( PermissionAction.READ, 'service_api_keys.service_role_key' ) + const { data: settings } = useProjectSettingsV2Query({ projectRef }) + const { anonKey: anonApiKey, serviceKey: serviceApiKey } = getAPIKeys(settings) + return (
    @@ -75,12 +77,12 @@ const LangSelector = ({ setShowApiKey(DEFAULT_KEY)}> hide - {apiKey && ( + {anonApiKey && ( setShowApiKey({ - key: apiKey, + key: anonApiKey.api_key ?? '-', name: 'anon (public)', }) } @@ -93,7 +95,7 @@ const LangSelector = ({ key="service" onClick={() => setShowApiKey({ - key: autoApiService.serviceApiKey, + key: serviceApiKey?.api_key ?? '-', name: 'service_role (secret)', }) } diff --git a/apps/studio/components/interfaces/Docs/Pages/UserManagement.tsx b/apps/studio/components/interfaces/Docs/Pages/UserManagement.tsx index 9cbf5f4ac2d..b32c5168a46 100644 --- a/apps/studio/components/interfaces/Docs/Pages/UserManagement.tsx +++ b/apps/studio/components/interfaces/Docs/Pages/UserManagement.tsx @@ -1,7 +1,8 @@ import Link from 'next/link' import { useRouter } from 'next/router' -import type { AutoApiService } from 'data/config/project-api-query' +import { useParams } from 'common' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { makeRandomString } from 'lib/helpers' import CodeSnippet from '../CodeSnippet' import Snippets from '../Snippets' @@ -9,20 +10,18 @@ import Snippets from '../Snippets' const randomPassword = makeRandomString(20) interface UserManagementProps { - autoApiService: AutoApiService selectedLang: 'bash' | 'js' showApiKey: string } -export default function UserManagement({ - autoApiService, - selectedLang, - showApiKey, -}: UserManagementProps) { +export default function UserManagement({ selectedLang, showApiKey }: UserManagementProps) { const router = useRouter() - + const { ref: projectRef } = useParams() const keyToShow = showApiKey ? showApiKey : 'SUPABASE_KEY' + const { data: settings } = useProjectSettingsV2Query({ projectRef }) + const endpoint = `https://${settings?.app_config?.endpoint ?? ''}` + return ( <>

    User Management

    @@ -53,7 +52,7 @@ export default function UserManagement({
    @@ -70,7 +69,7 @@ export default function UserManagement({
    @@ -87,7 +86,7 @@ export default function UserManagement({
    @@ -111,7 +110,7 @@ export default function UserManagement({
    @@ -131,7 +130,7 @@ export default function UserManagement({
    @@ -151,7 +150,7 @@ export default function UserManagement({
    @@ -218,7 +217,7 @@ export default function UserManagement({
    @@ -231,7 +230,7 @@ export default function UserManagement({
    @@ -247,7 +246,7 @@ export default function UserManagement({
    @@ -263,7 +262,7 @@ export default function UserManagement({
    @@ -279,7 +278,7 @@ export default function UserManagement({
    @@ -300,7 +299,7 @@ export default function UserManagement({
    diff --git a/apps/studio/components/interfaces/Docs/RpcContent.tsx b/apps/studio/components/interfaces/Docs/RpcContent.tsx index b7ba1558eb2..b3f0a91d016 100644 --- a/apps/studio/components/interfaces/Docs/RpcContent.tsx +++ b/apps/studio/components/interfaces/Docs/RpcContent.tsx @@ -1,38 +1,53 @@ +import { useParams } from 'common' import CodeSnippet from 'components/interfaces/Docs/CodeSnippet' import Description from 'components/interfaces/Docs/Description' import Param from 'components/interfaces/Docs/Param' import Snippets from 'components/interfaces/Docs/Snippets' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' +import { ProjectJsonSchemaPaths } from 'data/docs/project-json-schema-query' /** * TODO: need to support rpc with the same name and different params type */ + +interface RpcContentProps { + rpcId: string + rpcs: { [key: string]: any } + paths?: ProjectJsonSchemaPaths + selectedLang: 'bash' | 'js' + showApiKey: string + refreshDocs: () => void +} + const RpcContent = ({ - autoApiService, rpcId, rpcs, paths, selectedLang, - refreshDocs, showApiKey, -}: any) => { + refreshDocs, +}: RpcContentProps) => { + const { ref: projectRef } = useParams() + const { data: settings } = useProjectSettingsV2Query({ projectRef }) + const endpoint = `https://${settings?.app_config?.endpoint ?? ''}` + const meta = rpcs[rpcId] const pathKey = `/rpc/${rpcId}` const path = paths && pathKey in paths ? paths[pathKey] : undefined const keyToShow = !!showApiKey ? showApiKey : 'SUPABASE_KEY' - if (!path) return null - - const { - post: { parameters, summary }, - } = path + const { post } = path! + const { parameters, summary } = post || {} const rpcParamsObject = - parameters && parameters[0] && parameters[0].schema && parameters[0].schema.properties - ? parameters[0].schema.properties + parameters && parameters[0] && parameters[0].schema && (parameters[0].schema as any).properties + ? (parameters[0].schema as any).properties : {} const rpcParams = Object.entries(rpcParamsObject) .map(([k, v]: any) => ({ name: k, ...v })) .filter((x) => !!x.name) + if (!path) return null + return ( <>

    @@ -42,7 +57,7 @@ const RpcContent = ({
    - +
    diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx index 73bb231b853..aca153eb113 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx @@ -1,6 +1,5 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import clsx from 'clsx' -import { useParams } from 'common' import dayjs from 'dayjs' import { ExternalLink, Maximize2, Minimize2, Terminal } from 'lucide-react' import Link from 'next/link' @@ -8,12 +7,13 @@ import { useRouter } from 'next/router' import { useEffect, useMemo, useState } from 'react' import { toast } from 'sonner' +import { useParams } from 'common' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { FormActions } from 'components/ui/Forms/FormActions' import { FormHeader } from 'components/ui/Forms/FormHeader' import { FormPanel } from 'components/ui/Forms/FormPanel' import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection' -import { useProjectApiQuery } from 'data/config/project-api-query' +import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query' import { useEdgeFunctionQuery } from 'data/edge-functions/edge-function-query' import { useEdgeFunctionDeleteMutation } from 'data/edge-functions/edge-functions-delete-mutation' @@ -39,7 +39,7 @@ const EdgeFunctionDetails = () => { const [showDeleteModal, setShowDeleteModal] = useState(false) const [showInstructions, setShowInstructions] = useState(false) - const { data: settings } = useProjectApiQuery({ projectRef }) + const { data: settings } = useProjectSettingsV2Query({ projectRef }) const { data: customDomainData } = useCustomDomainsQuery({ projectRef }) const { data: selectedFunction } = useEdgeFunctionQuery({ projectRef, slug: functionSlug }) const { mutate: updateEdgeFunction, isLoading: isUpdating } = useEdgeFunctionUpdateMutation() @@ -53,20 +53,19 @@ const EdgeFunctionDetails = () => { const formId = 'edge-function-update-form' const canUpdateEdgeFunction = useCheckPermissions(PermissionAction.FUNCTIONS_WRITE, '*') - // Get the API service - const apiService = settings?.autoApiService - const anonKey = apiService?.defaultApiKey ?? '[YOUR ANON KEY]' + const { anonKey } = getAPIKeys(settings) + const apiKey = anonKey?.api_key ?? '[YOUR ANON KEY]' - const endpoint = apiService?.app_config.endpoint ?? '' + const endpoint = settings?.app_config?.endpoint ?? '' const functionUrl = customDomainData?.customDomain?.status === 'active' - ? `${apiService?.protocol}://${customDomainData.customDomain.hostname}/functions/v1/${selectedFunction?.slug}` - : `${apiService?.protocol}://${endpoint}/functions/v1/${selectedFunction?.slug}` + ? `https://${customDomainData.customDomain.hostname}/functions/v1/${selectedFunction?.slug}` + : `https://${endpoint}/functions/v1/${selectedFunction?.slug}` const { managementCommands, secretCommands, invokeCommands } = generateCLICommands( selectedFunction, functionUrl, - anonKey + apiKey ) const onUpdateFunction = async (values: any, { resetForm }: any) => { @@ -258,7 +257,9 @@ const EdgeFunctionDetails = () => { tooltip={{ content: { side: 'bottom', - text: 'You need additional permissions to delete edge functions', + text: !canUpdateEdgeFunction + ? 'You need additional permissions to delete edge functions' + : undefined, }, }} > diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecret.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecret.tsx index 516266d5c20..ba5212aafdf 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecret.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecret.tsx @@ -42,7 +42,9 @@ const EdgeFunctionSecret = ({ secret, onSelectDelete }: EdgeFunctionSecretProps) side: 'bottom', text: isReservedSecret ? 'This is a reserved secret and cannot be deleted' - : 'You need additional permissions to delete edge function secrets', + : !canUpdateSecrets + ? 'You need additional permissions to delete edge function secrets' + : undefined, }, }} /> diff --git a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecrets.tsx b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecrets.tsx index c5183483b9e..903970244ed 100644 --- a/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecrets.tsx +++ b/apps/studio/components/interfaces/Functions/EdgeFunctionSecrets/EdgeFunctionSecrets.tsx @@ -12,7 +12,7 @@ import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useSecretsDeleteMutation } from 'data/secrets/secrets-delete-mutation' import { ProjectSecret, useSecretsQuery } from 'data/secrets/secrets-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { Button } from 'ui' +import { Badge, Button } from 'ui' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import AddNewSecretModal from './AddNewSecretModal' import EdgeFunctionSecret from './EdgeFunctionSecret' @@ -81,7 +81,9 @@ const EdgeFunctionSecrets = () => { tooltip={{ content: { side: 'bottom', - text: 'You need additional permissions to update edge function secrets', + text: !canUpdateSecrets + ? 'You need additional permissions to update edge function secrets' + : undefined, }, }} > @@ -92,7 +94,12 @@ const EdgeFunctionSecrets = () => { Name, - Digest, + + Digest{' '} + + SHA256 + + , , ]} body={ diff --git a/apps/studio/components/interfaces/Functions/TerminalInstructions.tsx b/apps/studio/components/interfaces/Functions/TerminalInstructions.tsx index 6ae3158ed18..1c100782e7f 100644 --- a/apps/studio/components/interfaces/Functions/TerminalInstructions.tsx +++ b/apps/studio/components/interfaces/Functions/TerminalInstructions.tsx @@ -5,7 +5,7 @@ import { ComponentPropsWithoutRef, ElementRef, forwardRef, useState } from 'reac import { useParams } from 'common' import CommandRender from 'components/interfaces/Functions/CommandRender' import { useAccessTokensQuery } from 'data/access-tokens/access-tokens-query' -import { useProjectApiQuery } from 'data/config/project-api-query' +import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query' import { Button, @@ -29,19 +29,19 @@ const TerminalInstructions = forwardRef< const [showInstructions, setShowInstructions] = useState(!closable) const { data: tokens } = useAccessTokensQuery() - const { data: settings } = useProjectApiQuery({ projectRef }) + const { data: settings } = useProjectSettingsV2Query({ projectRef }) const { data: customDomainData } = useCustomDomainsQuery({ projectRef }) - const apiService = settings?.autoApiService - const anonKey = apiService?.defaultApiKey ?? '[YOUR ANON KEY]' - const endpoint = settings?.autoApiService.app_config?.endpoint ?? '' + const { anonKey } = getAPIKeys(settings) + const apiKey = anonKey?.api_key ?? '[YOUR ANON KEY]' + const endpoint = settings?.app_config?.endpoint ?? '' const functionsEndpoint = customDomainData?.customDomain?.status === 'active' ? `https://${customDomainData.customDomain.hostname}/functions/v1` : `https://${endpoint}/functions/v1` // get the .co or .net TLD from the restUrl - const restUrl = settings?.autoApiService.restUrl + const restUrl = `https://${endpoint}` const restUrlTld = restUrl ? new URL(restUrl).hostname.split('.').pop() : 'co' const commands: Commands[] = [ @@ -72,7 +72,7 @@ const TerminalInstructions = forwardRef< }, { command: `curl -L -X POST 'https://${projectRef}.supabase.${restUrlTld}/functions/v1/hello-world' -H 'Authorization: Bearer ${ - anonKey ?? '[YOUR ANON KEY]' + apiKey ?? '[YOUR ANON KEY]' }' --data '{"name":"Functions"}'`, description: 'Invokes the hello-world function', jsx: () => { diff --git a/apps/studio/components/interfaces/Home/Connect/Connect.tsx b/apps/studio/components/interfaces/Home/Connect/Connect.tsx index d00330491b2..2478ade775a 100644 --- a/apps/studio/components/interfaces/Home/Connect/Connect.tsx +++ b/apps/studio/components/interfaces/Home/Connect/Connect.tsx @@ -5,10 +5,9 @@ import { useState } from 'react' import { DatabaseConnectionString } from 'components/interfaces/Settings/Database/DatabaseSettings/DatabaseConnectionString' import { PoolingModesModal } from 'components/interfaces/Settings/Database/PoolingModesModal' -import { useProjectApiQuery } from 'data/config/project-api-query' -import { useProjectSettingsQuery } from 'data/config/project-settings-query' +import Panel from 'components/ui/Panel' +import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { DEFAULT_PROJECT_API_SERVICE_ID } from 'lib/constants' import { Button, DIALOG_PADDING_X, @@ -30,7 +29,6 @@ import { CONNECTION_TYPES, ConnectionType, FRAMEWORKS, MOBILES, ORMS } from './C import { getContentFilePath } from './Connect.utils' import ConnectDropdown from './ConnectDropdown' import ConnectTabContent from './ConnectTabContent' -import Panel from 'components/ui/Panel' const Connect = () => { const { ref: projectRef } = useParams() @@ -46,6 +44,9 @@ const Connect = () => { ?.children.find((child) => child.key === selectedChild)?.children[0]?.key || '' ) + const { data: settings } = useProjectSettingsV2Query({ projectRef }) + const canReadAPIKeys = useCheckPermissions(PermissionAction.READ, 'service_api_keys') + const handleParentChange = (value: string) => { setSelectedParent(value) @@ -127,32 +128,12 @@ const Connect = () => { return [] } - const { data: projectSettings } = useProjectSettingsQuery({ projectRef }) - - const { data: apiSettings } = useProjectApiQuery({ - projectRef, - }) - - // Get the API service - const apiService = (projectSettings?.services ?? []).find( - (x: any) => x.app.id == DEFAULT_PROJECT_API_SERVICE_ID - ) - - const canReadAPIKeys = useCheckPermissions(PermissionAction.READ, 'service_api_keys') - - // Get the API service - const apiHost = canReadAPIKeys - ? `${apiSettings?.autoApiService?.protocol ?? 'https'}://${ - apiSettings?.autoApiService?.endpoint ?? '-' - }` - : '' + const endpoint = settings?.app_config?.endpoint + const apiHost = canReadAPIKeys ? `https://${endpoint ?? '-'}` : '' const apiUrl = canReadAPIKeys ? apiHost : null - const anonKey = canReadAPIKeys - ? apiService?.service_api_keys.find((key) => key.tags === 'anon')?.api_key ?? null - : null - - const projectKeys = { apiUrl, anonKey } + const { anonKey } = canReadAPIKeys ? getAPIKeys(settings) : { anonKey: null } + const projectKeys = { apiUrl, anonKey: anonKey?.api_key ?? null } const filePath = getContentFilePath({ connectionObject, diff --git a/apps/studio/components/interfaces/Home/Connect/ConnectTabContent.tsx b/apps/studio/components/interfaces/Home/Connect/ConnectTabContent.tsx index 59eac3804d6..005b806419e 100644 --- a/apps/studio/components/interfaces/Home/Connect/ConnectTabContent.tsx +++ b/apps/studio/components/interfaces/Home/Connect/ConnectTabContent.tsx @@ -1,14 +1,14 @@ -import { useParams } from 'common' import dynamic from 'next/dynamic' import { forwardRef, HTMLAttributes } from 'react' +import { useParams } from 'common' import { getConnectionStrings } from 'components/interfaces/Settings/Database/DatabaseSettings/DatabaseSettings.utils' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' -import { useProjectSettingsQuery } from 'data/config/project-settings-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { usePoolingConfigurationQuery } from 'data/database/pooling-configuration-query' import { pluckObjectFields } from 'lib/helpers' -import type { projectKeys } from './Connect.types' import { cn } from 'ui' +import type { projectKeys } from './Connect.types' interface ConnectContentTabProps extends HTMLAttributes { projectKeys: projectKeys @@ -23,13 +23,13 @@ interface ConnectContentTabProps extends HTMLAttributes { const ConnectTabContentNew = forwardRef( ({ projectKeys, filePath, ...props }, ref) => { const { ref: projectRef } = useParams() - const { data } = useProjectSettingsQuery({ projectRef }) + + const { data: settings } = useProjectSettingsV2Query({ projectRef }) const { data: poolingInfo } = usePoolingConfigurationQuery({ projectRef }) - const { project } = data ?? {} const DB_FIELDS = ['db_host', 'db_name', 'db_port', 'db_user', 'inserted_at'] const emptyState = { db_user: '', db_host: '', db_port: '', db_name: '' } - const connectionInfo = pluckObjectFields(project || emptyState, DB_FIELDS) + const connectionInfo = pluckObjectFields(settings || emptyState, DB_FIELDS) const poolingConfiguration = poolingInfo?.find((x) => x.database_type === 'PRIMARY') const connectionStringsPooler = diff --git a/apps/studio/components/interfaces/Home/Connect/content/nextjs/app/supabasejs/content.tsx b/apps/studio/components/interfaces/Home/Connect/content/nextjs/app/supabasejs/content.tsx index 9a2cd70d26c..2ff11fda1fc 100644 --- a/apps/studio/components/interfaces/Home/Connect/content/nextjs/app/supabasejs/content.tsx +++ b/apps/studio/components/interfaces/Home/Connect/content/nextjs/app/supabasejs/content.tsx @@ -35,7 +35,7 @@ import { createClient } from '@/utils/supabase/server' import { cookies } from 'next/headers' export default async function Page() { - const cookieStore = cookies() + const cookieStore = await cookies() const supabase = createClient(cookieStore) const { data: todos } = await supabase.from('todos').select() diff --git a/apps/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx b/apps/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx index 93af608f4e7..7265f3c38f5 100644 --- a/apps/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx +++ b/apps/studio/components/interfaces/Home/NewProjectPanel/APIKeys.tsx @@ -1,16 +1,16 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { JwtSecretUpdateStatus } from '@supabase/shared-types/out/events' +import { AlertCircle, Loader } from 'lucide-react' import Link from 'next/link' import { useState } from 'react' -import { useParams } from 'common/hooks' import { SimpleCodeBlock } from '@ui/components/SimpleCodeBlock' +import { useParams } from 'common' import Panel from 'components/ui/Panel' import { useJwtSecretUpdatingStatusQuery } from 'data/config/jwt-secret-updating-status-query' -import { useProjectApiQuery } from 'data/config/project-api-query' +import { getAPIKeys, useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { Input } from 'ui' -import { AlertCircle, Loader } from 'lucide-react' const generateInitSnippet = (endpoint: string) => ({ js: ` @@ -42,7 +42,7 @@ const APIKeys = () => { data: settings, isError: isProjectSettingsError, isLoading: isProjectSettingsLoading, - } = useProjectApiQuery({ + } = useProjectSettingsV2Query({ projectRef, }) @@ -55,17 +55,16 @@ const APIKeys = () => { const canReadAPIKeys = useCheckPermissions(PermissionAction.READ, 'service_api_keys') - // Get the API service - const apiService = settings?.autoApiService - const apiKeys = apiService?.service_api_keys ?? [] - - // API keys should not be empty. However it can be populated with a delay on project creation - const isApiKeysEmpty = apiKeys.length === 0 const isNotUpdatingJwtSecret = jwtSecretUpdateStatus === undefined || jwtSecretUpdateStatus === JwtSecretUpdateStatus.Updated - const apiUrl = `${apiService?.protocol ?? 'https'}://${apiService?.endpoint ?? '-'}` - const anonKey = apiKeys.find((key) => key.tags === 'anon') + const endpoint = settings?.app_config?.endpoint + const apiUrl = `https://${endpoint ?? '-'}` + const apiKeys = settings?.service_api_keys ?? [] + const { anonKey } = getAPIKeys(settings) + + // API keys should not be empty. However it can be populated with a delay on project creation + const isApiKeysEmpty = apiKeys.length === 0 const clientInitSnippet: any = generateInitSnippet(apiUrl) const selectedLanguageSnippet = clientInitSnippet[selectedLanguage.key] ?? 'No snippet available' @@ -146,7 +145,7 @@ const APIKeys = () => { ? 'JWT secret update failed, new API key may have issues' : jwtSecretUpdateStatus === JwtSecretUpdateStatus.Updating ? 'Updating JWT secret...' - : apiService?.defaultApiKey + : anonKey?.api_key } onChange={() => {}} descriptionText={ diff --git a/apps/studio/components/interfaces/Home/NewProjectPanel/NewProjectPanel.tsx b/apps/studio/components/interfaces/Home/NewProjectPanel/NewProjectPanel.tsx index 19b5708425e..e77fbe11591 100644 --- a/apps/studio/components/interfaces/Home/NewProjectPanel/NewProjectPanel.tsx +++ b/apps/studio/components/interfaces/Home/NewProjectPanel/NewProjectPanel.tsx @@ -1,8 +1,9 @@ import { ExternalLink } from 'lucide-react' import Link from 'next/link' -import { useRouter } from 'next/router' +import { useParams } from 'common' import Panel from 'components/ui/Panel' +import { EditorIndexPageLink } from 'data/prefetchers/project.$ref.editor' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { Auth, EdgeFunctions, Realtime, SqlEditor, Storage, TableEditor } from 'icons' import { Button } from 'ui' @@ -10,8 +11,7 @@ import APIKeys from './APIKeys' import GetStartedHero from './GetStartedHero' const NewProjectPanel = () => { - const router = useRouter() - const { ref } = router.query + const { ref } = useParams() const { projectAuthAll: authEnabled, @@ -47,7 +47,7 @@ const NewProjectPanel = () => {
    -
    - ))} - - )} {type === 'datadog' && ( @@ -429,45 +417,90 @@ export function LogDrainDestinationSheetForm({ /> )} + {type === 'loki' && ( +
    + +
    + )} + {/* This form needs to be outside the */} - {type === 'webhook' && ( -
    { - e.preventDefault() - e.stopPropagation() - addHeader() - }} - className="flex border-t py-4 gap-4 items-center px-content" - > - - setNewCustomHeader({ ...newCustomHeader, name: e.target.value })} - /> - - setNewCustomHeader({ ...newCustomHeader, value: e.target.value })} - /> + {(type === 'webhook' || type === 'loki') && ( + <> +
    +
    +

    Custom Headers

    +

    + {getHeadersSectionDescription()} +

    +
    +
    + {hasHeaders && + Object.keys(headers || {})?.map((headerKey) => ( +
    +
    {headerKey}
    +
    + {headers?.[headerKey]} +
    + +
    + ))} +
    +
    + { + e.preventDefault() + e.stopPropagation() + addHeader() + }} + className="flex border-t py-4 gap-4 items-center px-content" + > + + setNewCustomHeader({ ...newCustomHeader, name: e.target.value })} + /> + + + setNewCustomHeader({ ...newCustomHeader, value: e.target.value }) + } + /> - - + + + )} diff --git a/apps/studio/components/interfaces/LogDrains/LogDrains.constants.tsx b/apps/studio/components/interfaces/LogDrains/LogDrains.constants.tsx index 8a00b1e3e30..e73608d482d 100644 --- a/apps/studio/components/interfaces/LogDrains/LogDrains.constants.tsx +++ b/apps/studio/components/interfaces/LogDrains/LogDrains.constants.tsx @@ -1,6 +1,11 @@ -import { BracesIcon, DogIcon } from 'lucide-react' +import { BracesIcon } from 'lucide-react' +import { Datadog, Grafana } from 'icons' -const iconProps = { size: 24, strokeWidth: 1.5, className: 'text-foreground-light' } +const iconProps = { + height: 24, + width: 24, + className: 'text-foreground-light', +} export const LOG_DRAIN_TYPES = [ { @@ -13,7 +18,14 @@ export const LOG_DRAIN_TYPES = [ value: 'datadog', name: 'Datadog', description: 'Datadog is a monitoring service for cloud-scale applications', - icon: , + icon: , + }, + { + value: 'loki', + name: 'Loki', + description: + 'Loki is an open-source log aggregation system designed to store and query logs from multiple sources', + icon: , }, ] as const diff --git a/apps/studio/components/interfaces/LogDrains/LogDrains.tsx b/apps/studio/components/interfaces/LogDrains/LogDrains.tsx index ce41a14e033..894832547f7 100644 --- a/apps/studio/components/interfaces/LogDrains/LogDrains.tsx +++ b/apps/studio/components/interfaces/LogDrains/LogDrains.tsx @@ -27,6 +27,7 @@ import { import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import { LOG_DRAIN_TYPES, LogDrainType } from './LogDrains.constants' +import { useFlag } from 'hooks/ui/useFlag' export function LogDrains({ onNewDrainClick, @@ -39,6 +40,7 @@ export function LogDrains({ const { isLoading: orgPlanLoading, plan } = useCurrentOrgPlan() const logDrainsEnabled = !orgPlanLoading && (plan?.id === 'team' || plan?.id === 'enterprise') + const lokiLogDrainsEnabled = useFlag('lokilogdrains') const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false) const [selectedLogDrain, setSelectedLogDrain] = useState(null) @@ -90,18 +92,20 @@ export function LogDrains({ if (!isLoading && logDrains?.length === 0) { return ( -
    - {LOG_DRAIN_TYPES.map((src) => ( - { - onNewDrainClick(src.value) - }} - /> - ))} +
    + {LOG_DRAIN_TYPES.map((src) => + src.value === 'loki' && !lokiLogDrainsEnabled ? null : ( + { + onNewDrainClick(src.value) + }} + /> + ) + )}
    ) } diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingMetric.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingMetric.tsx index 476370e1f9f..e32f8ed77fd 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingMetric.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/BillingMetric.tsx @@ -78,20 +78,20 @@ const BillingMetric = ({ return ( - -
    -
    + +
    + {metric.anchor ? ( - -
    -

    - {metric.name} -

    - {usageMeta.available_in_plan && ( +
    +

    + {metric.name} +

    + {usageMeta.available_in_plan && ( + - )} -
    - + + )} +
    ) : (

    {metric.name}

    )} @@ -101,7 +101,7 @@ const BillingMetric = ({ ) : usageMeta.available_in_plan && !usageMeta.unlimited && relativeToSubscription ? ( {percentageLabel} ) : null} -
    + {usageMeta.available_in_plan ? (
    diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/UpcomingInvoice.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/UpcomingInvoice.tsx index f8d9f5c3047..2bfef53e189 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/UpcomingInvoice.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingBreakdown/UpcomingInvoice.tsx @@ -113,7 +113,7 @@ const UpcomingInvoice = ({ slug }: UpcomingInvoiceProps) => {
    - @@ -124,6 +124,9 @@ const UpcomingInvoice = ({ slug }: UpcomingInvoiceProps) => { {!item.proration && ( )} {!item.proration && ( - )} diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx index 172cf511853..0a4e467a763 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx @@ -1,6 +1,5 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import dayjs from 'dayjs' -import { Calendar, ExternalLink } from 'lucide-react' +import { ExternalLink } from 'lucide-react' import { useTheme } from 'next-themes' import Image from 'next/image' import Link from 'next/link' @@ -14,13 +13,12 @@ import { import AlertError from 'components/ui/AlertError' import NoPermission from 'components/ui/NoPermission' import ShimmeringLoader from 'components/ui/ShimmeringLoader' -import { useOrganizationBillingSubscriptionCancelSchedule } from 'data/subscriptions/org-subscription-cancel-schedule-mutation' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useFlag } from 'hooks/ui/useFlag' import { BASE_PATH } from 'lib/constants' import { useOrgSettingsPageStateSnapshot } from 'state/organization-settings' -import { Alert, AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button } from 'ui' +import { Alert, Button } from 'ui' import ProjectUpdateDisabledTooltip from '../ProjectUpdateDisabledTooltip' import SpendCapSidePanel from './SpendCapSidePanel' @@ -51,9 +49,6 @@ const CostControl = ({}: CostControlProps) => { const canChangeTier = !projectUpdateDisabled && !['team', 'enterprise'].includes(currentPlan?.id || '') - const { mutate: cancelSubscriptionSchedule, isLoading: cancelSubscriptionScheduleLoading } = - useOrganizationBillingSubscriptionCancelSchedule() - return ( <> @@ -132,36 +127,6 @@ const CostControl = ({}: CostControlProps) => {

    )} - {/** Toggled on spend cap, scheduled change for end-of-cycle */} - {subscription?.scheduled_plan_change?.target_plan === 'pro' && - subscription?.scheduled_plan_change?.usage_billing_enabled === false && - subscription?.plan.id === 'pro' && ( - - - Scheduled change - -
    - Your spend cap will be enabled on{' '} - {dayjs(subscription?.scheduled_plan_change?.at).format('MMMM D, YYYY')}. - You will not be charged for any over-usage moving on. If you would like - to keep the spend cap disabled and scale as you go, you may still cancel - the scheduled change. -
    -
    - -
    -
    -
    - )} -
    diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx index 674d32fb95b..c24a6a46c70 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/Subscription/Subscription.tsx @@ -1,7 +1,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useParams } from 'common' import dayjs from 'dayjs' -import { Calendar, ExternalLink } from 'lucide-react' +import { ExternalLink } from 'lucide-react' import Link from 'next/link' import { @@ -13,12 +13,11 @@ import AlertError from 'components/ui/AlertError' import NoPermission from 'components/ui/NoPermission' import ShimmeringLoader from 'components/ui/ShimmeringLoader' import SparkBar from 'components/ui/SparkBar' -import { useOrganizationBillingSubscriptionCancelSchedule } from 'data/subscriptions/org-subscription-cancel-schedule-mutation' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useFlag } from 'hooks/ui/useFlag' import { useOrgSettingsPageStateSnapshot } from 'state/organization-settings' -import { Alert, AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button } from 'ui' +import { Alert, Button } from 'ui' import ProjectUpdateDisabledTooltip from '../ProjectUpdateDisabledTooltip' import { Restriction } from '../Restriction' import PlanUpdateSidePanel from './PlanUpdateSidePanel' @@ -41,9 +40,6 @@ const Subscription = () => { isSuccess, } = useOrgSubscriptionQuery({ orgSlug: slug }, { enabled: canReadSubscriptions }) - const { mutate: cancelSubscriptionSchedule, isLoading: cancelSubscriptionScheduleLoading } = - useOrganizationBillingSubscriptionCancelSchedule() - const currentPlan = subscription?.plan const planName = currentPlan?.name ?? 'Unknown' const billingCycleStart = dayjs.unix(subscription?.current_period_start ?? 0).utc() @@ -99,38 +95,6 @@ const Subscription = () => {

    - {subscription?.scheduled_plan_change && - subscription?.scheduled_plan_change?.target_plan !== subscription.plan.id && ( - - - Scheduled downgrade - -
    - Your organization will automatically be downgraded from the{' '} - {subscription.plan.name} plan to the{' '} - - {subscription?.scheduled_plan_change?.target_plan} - {' '} - plan on{' '} - {dayjs(subscription?.scheduled_plan_change?.at).format('MMMM D, YYYY')}. - If you would like to stay on the {subscription.plan.name}{' '} - plan, cancel the scheduled downgrade. -
    -
    - -
    -
    -
    - )} -
    diff --git a/apps/studio/components/interfaces/Settings/Addons/CustomDomainSidePanel.tsx b/apps/studio/components/interfaces/Settings/Addons/CustomDomainSidePanel.tsx index 04e6b853e75..efc429764c8 100644 --- a/apps/studio/components/interfaces/Settings/Addons/CustomDomainSidePanel.tsx +++ b/apps/studio/components/interfaces/Settings/Addons/CustomDomainSidePanel.tsx @@ -215,60 +215,11 @@ const CustomDomainSidePanel = () => {
    - {hasChanges && ( - <> - {selectedOption === 'cd_none' || - (selectedCustomDomain?.price ?? 0) < (subscriptionCDOption?.variant.price ?? 0) - ? subscription?.billing_via_partner === false && - // Old addon billing with upfront payment - subscription.usage_based_billing_project_addons === false && ( -

    - - Upon clicking confirm, the add-on is removed immediately and any unused time - in the current billing cycle is added as prorated credits to your - organization and used in subsequent billing cycles. - -

    - ) - : !subscription?.billing_via_partner && ( -

    - {subscription?.usage_based_billing_project_addons === false ? ( - - Upon clicking confirm, the amount of{' '} - - {formatCurrency(selectedCustomDomain?.price)} - {' '} - will be added to your monthly invoice. The addon is prepaid per month and - in case of a downgrade, you get credits for the remaining time. For the - current billing cycle you're immediately charged a prorated amount for the - remaining days. - - ) : ( - - There are no immediate charges. The addon is billed at the end of your - billing cycle based on your usage and prorated to the hour. - - )} -

    - )} - - { - // Billed via partner - subscription?.billing_via_partner && - // Project addons are still billed the old way (upfront payment) - subscription?.usage_based_billing_project_addons === false && - // Scheduled billing plan change - subscription.scheduled_plan_change?.target_plan !== undefined && ( - - - - You have a scheduled subscription change that will be canceled if you change - your custom domain add on. - - - ) - } - + {hasChanges && selectedOption !== 'cd_none' && ( +

    + There are no immediate charges. The addon is billed at the end of your billing cycle + based on your usage and prorated to the hour. +

    )} {isFreePlan && ( diff --git a/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx b/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx index ab14efb9640..252cabdfd02 100644 --- a/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx +++ b/apps/studio/components/interfaces/Settings/Addons/IPv4SidePanel.tsx @@ -187,72 +187,30 @@ const IPv4SidePanel = () => { {hasChanges && ( <> - {selectedOption === 'ipv4_none' || - (selectedIPv4?.price ?? 0) < (subscriptionIpV4Option?.variant.price ?? 0) ? ( - subscription?.billing_via_partner === false && - // Old addon billing with upfront payment - subscription.usage_based_billing_project_addons === false && ( -

    - Upon clicking confirm, the add-on is removed immediately and any unused time in - the current billing cycle is added as prorated credits to your organization and - used in subsequent billing cycles. -

    - ) - ) : ( - <> - - There might be some downtime when enabling the add-on since some DNS clients - might have cached the old DNS entry. Generally, this should be less than a - minute. - -

    - By default, this is only applied to the Primary database for your project. If{' '} - - Read replicas - {' '} - are used, each replica also gets its own IPv4 address, with a corresponding{' '} - {formatCurrency(selectedIPv4?.price)}{' '} - charge. -

    - {!subscription?.billing_via_partner && ( -

    - {subscription?.usage_based_billing_project_addons === false ? ( - - Upon clicking confirm, the respective amount will be added to your monthly - invoice. The addon is prepaid per month and in case of a downgrade, you - get credits for the remaining time. For the current billing cycle you're - immediately charged a prorated amount for the remaining days. - - ) : ( - - There are no immediate charges. The addon is billed at the end of your - billing cycle based on your usage and prorated to the hour. - - )} -

    - )} - - { - // Billed via partner - subscription?.billing_via_partner && - // Project addons are still billed the old way (upfront payment) - subscription?.usage_based_billing_project_addons === false && - // Scheduled billing plan change - subscription.scheduled_plan_change?.target_plan !== undefined && ( - - - - You have a scheduled subscription change that will be canceled if you - change your PITR add on. - - - ) - } - + + There might be some downtime when enabling the add-on since some DNS clients might + have cached the old DNS entry. Generally, this should be less than a minute. + + {selectedOption !== 'ipv4_none' && ( +

    + By default, this is only applied to the Primary database for your project. If{' '} + + Read replicas + {' '} + are used, each replica also gets its own IPv4 address, with a corresponding{' '} + {formatCurrency(selectedIPv4?.price)}{' '} + charge. +

    + )} + {!subscription?.billing_via_partner && ( +

    + There are no immediate charges. The addon is billed at the end of your billing + cycle based on your usage and prorated to the hour. +

    )} )} diff --git a/apps/studio/components/interfaces/Settings/Addons/PITRSidePanel.tsx b/apps/studio/components/interfaces/Settings/Addons/PITRSidePanel.tsx index 8169b1ec33f..62b21a77f8f 100644 --- a/apps/studio/components/interfaces/Settings/Addons/PITRSidePanel.tsx +++ b/apps/studio/components/interfaces/Settings/Addons/PITRSidePanel.tsx @@ -346,58 +346,11 @@ const PITRSidePanel = () => {
    )} - {hasChanges && !blockDowngradeDueToReadReplicas && ( - <> - {selectedOption === 'pitr_0' || - (selectedPitr?.price ?? 0) < (subscriptionPitr?.variant.price ?? 0) - ? subscription?.billing_via_partner === false && - // Old addon billing with upfront payment - subscription.usage_based_billing_project_addons === false && ( -

    - Upon clicking confirm, the add-on is removed immediately and any unused time - in the current billing cycle is added as prorated credits to your organization - and used in subsequent billing cycles. -

    - ) - : !subscription?.billing_via_partner && ( -

    - {subscription?.usage_based_billing_project_addons === false ? ( - - Upon clicking confirm, the amount of{' '} - - {formatCurrency(selectedPitr?.price)} - {' '} - will be added to your monthly invoice. The addon is prepaid per month and - in case of a downgrade, you get credits for the remaining time. For the - current billing cycle you're immediately charged a prorated amount for the - remaining days. - - ) : ( - - There are no immediate charges. The addon is billed at the end of your - billing cycle based on your usage and prorated to the hour. - - )} -

    - )} - - { - // Billed via partner - subscription?.billing_via_partner && - // Project addons are still billed the old way (upfront payment) - subscription?.usage_based_billing_project_addons === false && - // Scheduled billing plan change - subscription.scheduled_plan_change?.target_plan !== undefined && ( - - - - You have a scheduled subscription change that will be canceled if you change - your PITR add on. - - - ) - } - + {hasChanges && !blockDowngradeDueToReadReplicas && selectedOption !== 'pitr_0' && ( +

    + There are no immediate charges. The addon is billed at the end of your billing cycle + based on your usage and prorated to the hour. +

    )} diff --git a/apps/studio/components/interfaces/Settings/Database/BannedIPs.tsx b/apps/studio/components/interfaces/Settings/Database/BannedIPs.tsx index 2c942355681..cc291744349 100644 --- a/apps/studio/components/interfaces/Settings/Database/BannedIPs.tsx +++ b/apps/studio/components/interfaces/Settings/Database/BannedIPs.tsx @@ -99,7 +99,9 @@ const BannedIPs = () => { tooltip={{ content: { side: 'bottom', - text: 'You need additional permissions to unban networks', + text: !canUnbanNetworks + ? 'You need additional permissions to unban networks' + : undefined, }, }} > diff --git a/apps/studio/components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions.tsx b/apps/studio/components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions.tsx index 19a6f94a6fd..b39170c9349 100644 --- a/apps/studio/components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions.tsx +++ b/apps/studio/components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions.tsx @@ -55,7 +55,9 @@ const DisallowAllAccessButton = ({ disabled, onClick }: AccessButtonProps) => ( tooltip={{ content: { side: 'bottom', - text: 'You need additional permissions to update network restrictions', + text: disabled + ? 'You need additional permissions to update network restrictions' + : undefined, }, }} > diff --git a/apps/studio/components/interfaces/Settings/Database/SSLConfiguration.tsx b/apps/studio/components/interfaces/Settings/Database/SSLConfiguration.tsx index b9621087efb..88a0ac96998 100644 --- a/apps/studio/components/interfaces/Settings/Database/SSLConfiguration.tsx +++ b/apps/studio/components/interfaces/Settings/Database/SSLConfiguration.tsx @@ -10,7 +10,7 @@ import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { FormHeader } from 'components/ui/Forms/FormHeader' import { FormPanel } from 'components/ui/Forms/FormPanel' import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection' -import { useProjectSettingsQuery } from 'data/config/project-settings-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useSSLEnforcementQuery } from 'data/ssl-enforcement/ssl-enforcement-query' import { useSSLEnforcementUpdateMutation } from 'data/ssl-enforcement/ssl-enforcement-update-mutation' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' @@ -27,7 +27,7 @@ const SSLConfiguration = () => { const { ref } = useParams() const [isEnforced, setIsEnforced] = useState(false) - const { data: projectSettings } = useProjectSettingsQuery({ projectRef: ref }) + const { data: settings } = useProjectSettingsV2Query({ projectRef: ref }) const { data: sslEnforcementConfiguration, isLoading, @@ -65,8 +65,7 @@ const SSLConfiguration = () => { ) const env = process.env.NEXT_PUBLIC_ENVIRONMENT === 'prod' ? 'prod' : 'staging' const hasSSLCertificate = - projectSettings?.project !== undefined && - new Date(projectSettings.project.inserted_at) >= new Date('2021-04-30') + settings?.inserted_at !== undefined && new Date(settings.inserted_at) >= new Date('2021-04-30') useEffect(() => { if (!isLoading && sslEnforcementConfiguration) { diff --git a/apps/studio/components/interfaces/Settings/Database/UsePoolerCheckbox.tsx b/apps/studio/components/interfaces/Settings/Database/UsePoolerCheckbox.tsx index 9f163c335db..6990f9f60d0 100644 --- a/apps/studio/components/interfaces/Settings/Database/UsePoolerCheckbox.tsx +++ b/apps/studio/components/interfaces/Settings/Database/UsePoolerCheckbox.tsx @@ -6,7 +6,7 @@ import { useState } from 'react' import { useParams } from 'common' import ShimmeringLoader from 'components/ui/ShimmeringLoader' import { usePgBouncerStatus } from 'data/config/pgbouncer-enabled-query' -import { useProjectSettingsQuery } from 'data/config/project-settings-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { usePoolingConfigurationQuery } from 'data/database/pooling-configuration-query' import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' import { useDatabaseSettingsStateSnapshot } from 'state/database-settings' @@ -51,12 +51,12 @@ export const UsePoolerCheckbox = ({ const state = useDatabaseSelectorStateSnapshot() const { data, isLoading, isSuccess } = usePoolingConfigurationQuery({ projectRef }) - const { data: settings, isSuccess: isSuccessSettings } = useProjectSettingsQuery({ projectRef }) + const { data: settings, isSuccess: isSuccessSettings } = useProjectSettingsV2Query({ projectRef }) const { data: pgBouncerStatus } = usePgBouncerStatus({ projectRef: projectRef }) const isDatabaseSettingsPage = router.pathname.endsWith('/settings/database') const poolingConfiguration = data?.find((x) => x.identifier === state.selectedDatabaseId) - const resolvesToIpV6 = settings?.project.db_ip_addr_config === 'ipv6' + const resolvesToIpV6 = settings?.db_ip_addr_config === 'ipv6' const onSelectOption = (value: 'session' | 'transaction') => { onSelectPoolingMode(value) diff --git a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainActivate.tsx b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainActivate.tsx index eee3f944a8b..5d1a25ac097 100644 --- a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainActivate.tsx +++ b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainActivate.tsx @@ -1,16 +1,16 @@ +import { AlertCircle, ExternalLink } from 'lucide-react' import Link from 'next/link' import { useState } from 'react' import { toast } from 'sonner' import Panel from 'components/ui/Panel' -import { useProjectApiQuery } from 'data/config/project-api-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCheckCNAMERecordMutation } from 'data/custom-domains/check-cname-mutation' import { useCustomDomainActivateMutation } from 'data/custom-domains/custom-domains-activate-mutation' import { useCustomDomainDeleteMutation } from 'data/custom-domains/custom-domains-delete-mutation' import type { CustomDomainResponse } from 'data/custom-domains/custom-domains-query' import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button } from 'ui' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' -import { AlertCircle, ExternalLink } from 'lucide-react' export type CustomDomainActivateProps = { projectRef?: string @@ -20,7 +20,7 @@ export type CustomDomainActivateProps = { const CustomDomainActivate = ({ projectRef, customDomain }: CustomDomainActivateProps) => { const [isActivateConfirmModalVisible, setIsActivateConfirmModalVisible] = useState(false) - const { data: settings } = useProjectApiQuery({ projectRef }) + const { data: settings } = useProjectSettingsV2Query({ projectRef }) const { mutate: checkCNAMERecord, isLoading: isCheckingRecord } = useCheckCNAMERecordMutation() const { mutate: activateCustomDomain, isLoading: isActivating } = useCustomDomainActivateMutation( { @@ -32,7 +32,7 @@ const CustomDomainActivate = ({ projectRef, customDomain }: CustomDomainActivate ) const { mutate: deleteCustomDomain, isLoading: isDeleting } = useCustomDomainDeleteMutation() - const endpoint = settings?.autoApiService.endpoint + const endpoint = settings?.app_config?.endpoint const onActivateCustomDomain = async () => { if (!projectRef) return console.error('Project ref is required') @@ -101,10 +101,9 @@ const CustomDomainActivate = ({ projectRef, customDomain }: CustomDomainActivate
    diff --git a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx index bcde78bace3..fcfe9c4f4f3 100644 --- a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx +++ b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx @@ -5,7 +5,7 @@ import { useParams } from 'common' import { FormHeader } from 'components/ui/Forms/FormHeader' import Panel from 'components/ui/Panel' import UpgradeToPro from 'components/ui/UpgradeToPro' -import { useProjectApiQuery } from 'data/config/project-api-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' @@ -23,11 +23,9 @@ const CustomDomainConfig = () => { const customDomainsDisabledDueToQuota = useFlag('customDomainsDisabledDueToQuota') const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: organization?.slug }) - const plan = subscription?.plan?.id - const { isLoading: isSettingsLoading, data: settings } = useProjectApiQuery({ - projectRef: ref, - }) + + const { isLoading: isSettingsLoading } = useProjectSettingsV2Query({ projectRef: ref }) const { isLoading: isCustomDomainsLoading, @@ -101,11 +99,7 @@ const CustomDomainConfig = () => { {(data.status === '1_not_started' || data.status === '2_initiated' || data.status === '3_challenge_verified') && ( - + )} {data.status === '4_origin_setup_completed' && ( diff --git a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainVerify.tsx b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainVerify.tsx index 244664f456d..c3daa0950be 100644 --- a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainVerify.tsx +++ b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainVerify.tsx @@ -2,8 +2,9 @@ import { AlertCircle, ExternalLink, HelpCircle, RefreshCw } from 'lucide-react' import Link from 'next/link' import { useState } from 'react' +import { useParams } from 'common' import Panel from 'components/ui/Panel' -import type { ProjectApiResponse } from 'data/config/project-api-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCustomDomainDeleteMutation } from 'data/custom-domains/custom-domains-delete-mutation' import type { CustomDomainResponse } from 'data/custom-domains/custom-domains-query' import { useCustomDomainReverifyMutation } from 'data/custom-domains/custom-domains-reverify-mutation' @@ -19,14 +20,15 @@ import DNSRecord from './DNSRecord' import { DNSTableHeaders } from './DNSTableHeaders' export type CustomDomainVerifyProps = { - projectRef?: string customDomain: CustomDomainResponse - settings?: ProjectApiResponse } -const CustomDomainVerify = ({ projectRef, customDomain, settings }: CustomDomainVerifyProps) => { +const CustomDomainVerify = ({ customDomain }: CustomDomainVerifyProps) => { + const { ref: projectRef } = useParams() const [isNotVerifiedYet, setIsNotVerifiedYet] = useState(false) + const { data: settings } = useProjectSettingsV2Query({ projectRef }) + const { mutate: reverifyCustomDomain, isLoading: isReverifyLoading } = useCustomDomainReverifyMutation({ onSuccess: (res) => { @@ -152,7 +154,7 @@ const CustomDomainVerify = ({ projectRef, customDomain, settings }: CustomDomain )} @@ -193,7 +195,8 @@ const CustomDomainVerify = ({ projectRef, customDomain, settings }: CustomDomain -
    - {isLoading || (warehouseQueryEnabled && warehouseQueryLoading) ? ( +
    + {isLoading ? (
    ) : ( <> - - + + - {isLoading && 'Loading...'} - {JSON.stringify(rawLog, null, 2)} + {JSON.stringify(log, null, 2)} @@ -253,3 +107,40 @@ const LogSelection = ({ } export default LogSelection + +function LogDetailEmptyState({ + title = 'Select an Event', + message = 'Select an Event to view the complete JSON payload', +}: { + title?: string + message?: string +}) { + return ( +
    +
    +
    +
    +
    + +
    +
    +
    +

    {title}

    +

    {message}

    +
    +
    +
    + ) +} + +function LogErrorState({ error }: { error?: string | object }) { + return
    {JSON.stringify(error, null, 2)}
    +} diff --git a/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DatabaseApiSelectionRender.tsx b/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DatabaseApiSelectionRender.tsx index c7a6d94f3f1..055552e3e7a 100644 --- a/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DatabaseApiSelectionRender.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/LogSelectionRenderers/DatabaseApiSelectionRender.tsx @@ -39,8 +39,8 @@ const DatabaseApiSelectionRender = ({ log }: any) => {
    -

    Request Metadata

    -
    +        

    Request Metadata

    +
               
    {
    -

    Response Metadata

    -
    +        

    + Response Metadata +

    +
               
    )}
    -

    Request Metadata

    -
    +        

    Request Metadata

    +
               
    -

    Response Metadata

    -
    +        

    + Response Metadata +

    +
               
    { return (
    -
    {JSON.stringify(log, null, 2)}
    -
    {Object.entries(log).map(([key, value], index) => { return (
    (
    + data?: LogData[] onHistogramToggle?: () => void isHistogramShowing?: boolean isLoading?: boolean @@ -47,17 +48,19 @@ interface Props { showDownload?: boolean queryType?: QueryType projectRef: string - params: LogSelectionProps['params'] onRun?: () => void onSave?: () => void hasEditorValue?: boolean - maxHeight?: string className?: string collectionName?: string // Used for warehouse queries warehouseError?: string EmptyState?: ReactNode showHeader?: boolean showHistogramToggle?: boolean + selectedLog?: LogData + isSelectedLogLoading?: boolean + selectedLogError?: LogQueryError | ResponseError + onSelectedLogChange?: (log: LogData | null) => void } type LogMap = { [id: string]: LogData } @@ -74,35 +77,44 @@ const LogTable = ({ isLoading, error, projectRef, - params, onRun, onSave, hasEditorValue, - maxHeight, className, collectionName, EmptyState, showHeader = true, showHistogramToggle = true, + selectedLog, + isSelectedLogLoading, + selectedLogError, + onSelectedLogChange, }: Props) => { const { profile } = useProfile() const { show: showContextMenu } = useContextMenu() const [cellPosition, setCellPosition] = useState() - const [focusedLog, setFocusedLog] = useState(null) + + const [selectionOpen, setSelectionOpen] = useState(false) + + useEffect(() => { + if (selectedLog || isSelectedLogLoading) { + setSelectionOpen(true) + } + }, [selectedLog, isSelectedLogLoading]) const canCreateLogQuery = useCheckPermissions(PermissionAction.CREATE, 'user_content', { resource: { type: 'log_sql', owner_id: profile?.id }, subject: { id: profile?.id }, }) - const firstRow: LogData | undefined = data?.[0] as LogData + const firstRow = data[0] // move timestamp to the first column, if it exists function getFirstRow() { if (!firstRow) return {} - const { timestamp, ...rest } = firstRow || {} + const { timestamp, ...rest } = firstRow if (!timestamp) return firstRow @@ -177,31 +189,22 @@ const LogTable = ({ } } - const stringData = JSON.stringify(data) + const stringData = useMemo(() => JSON.stringify(data), [data]) const [dedupedData, logMap] = useMemo<[LogData[], LogMap]>(() => { const deduped = [...new Set(data)] as LogData[] if (!hasId) { - return [deduped, {} as LogMap] + return [deduped, {}] } const map = deduped.reduce((acc: LogMap, d: LogData) => { acc[d.id] = d return acc - }, {}) as LogMap + }, {}) return [deduped, map] }, [data, hasId]) - useEffect(() => { - if (!data) return - const found = data.find((datum) => isEqual(datum, focusedLog)) - if (!found) { - // close selection panel if not found in dataset - setFocusedLog(null) - } - }, [data, focusedLog, stringData]) - const logDataRows = useMemo(() => { if (hasId && hasTimestamp) { return Object.values(logMap).sort((a, b) => b.timestamp - a.timestamp) @@ -351,37 +354,44 @@ const LogTable = ({ else return } - if (!data) return null + const [selectedRow, setSelectedRow] = useState(null) + function onRowClick(row: LogData) { + setSelectedRow(row) + onSelectedLogChange?.(row) + } + + if (!data) return null return ( -
    +
    {!queryType && } - + { - setFocusedLog(row.row as LogData) setCellPosition(row) }} - selectedRows={new Set([])} + onCellClick={(row) => { + onRowClick(row.row) + }} columns={columns} - rowClass={(row: LogData) => - [ - 'font-mono tracking-tight', - isEqual(row, focusedLog) - ? '!bg-surface-300 rdg-row--focused' - : ' !bg-studio hover:!bg-surface-100 cursor-pointer', - ].join(' ') - } + rowClass={(row: LogData) => { + return cn( + 'font-mono tracking-tight !bg-studio hover:!bg-surface-100 cursor-pointer', + { + '!bg-surface-200 rdg-row--focused': isEqual(row, selectedRow), + } + ) + }} rows={logDataRows} rowKeyGetter={(r) => { if (!hasId) return JSON.stringify(r) @@ -409,15 +419,20 @@ const LogTable = ({ document.body )} - - {focusedLog && ( - + + + {selectionOpen && ( + setFocusedLog(null)} - log={focusedLog} + onClose={() => { + onSelectedLogChange?.(null) + setSelectionOpen(false) + }} + log={selectedLog} + error={selectedLogError} queryType={queryType} - params={params} collectionName={collectionName} /> diff --git a/apps/studio/components/interfaces/Settings/Logs/LogsFormatters.tsx b/apps/studio/components/interfaces/Settings/Logs/LogsFormatters.tsx index 082b03969ca..e36a9b7bcc6 100644 --- a/apps/studio/components/interfaces/Settings/Logs/LogsFormatters.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/LogsFormatters.tsx @@ -30,20 +30,20 @@ export const SelectionDetailedRow = ({ valueRender?: React.ReactNode }) => { return ( -
    +
    {label} {valueRender ?? value} diff --git a/apps/studio/components/interfaces/Settings/Logs/LogsPreviewer.tsx b/apps/studio/components/interfaces/Settings/Logs/LogsPreviewer.tsx index 8eddcc8d4a8..ee03bbd0c93 100644 --- a/apps/studio/components/interfaces/Settings/Logs/LogsPreviewer.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/LogsPreviewer.tsx @@ -19,6 +19,8 @@ import { LOGS_TABLES, LOG_ROUTES_WITH_REPLICA_SUPPORT, LogsTableName } from './L import type { Filters, LogSearchCallback, LogTemplate, QueryType } from './Logs.types' import { ensureNoTimestampConflict, maybeShowUpgradePrompt } from './Logs.utils' import UpgradePrompt from './UpgradePrompt' +import { useSelectedLog } from 'hooks/analytics/useSelectedLog' +import useSingleLog from 'hooks/analytics/useSingleLog' /** * Acts as a container component for the entire log display @@ -73,6 +75,18 @@ export const LogsPreviewer = ({ setParams, } = useLogsPreview({ projectRef, table, filterOverride }) + const [selectedLogId, setSelectedLogId] = useSelectedLog() + const { + data: selectedLog, + isLoading: isSelectedLogLoading, + error: selectedLogError, + } = useSingleLog({ + projectRef, + id: selectedLogId ?? undefined, + queryType, + paramsToMerge: params, + }) + const { showUpgradePrompt, setShowUpgradePrompt } = useUpgradePrompt( params.iso_timestamp_start as string ) @@ -182,15 +196,8 @@ export const LogsPreviewer = ({ } } - const noResults = logData.length === 0 && !error - const footerHeight = noResults ? '0px' : '48px' - const navBarHeight = '48px' - const filterPanelHeight = '58px' - const chartHeight = showChart && !noResults ? '114px' : '0px' - const maxHeight = `calc(100vh - ${navBarHeight} - ${filterPanelHeight} - ${footerHeight} - ${chartHeight})` - return ( -
    +
    0 ? 'mb-4 h-24 pt-4 opacity-100' : 'h-0 opacity-0') + (showChart && logData.length > 0 ? 'mb-4 h-28 opacity-100' : 'h-0 opacity-0') } > -
    +
    {showChart && (
    -
    +
    setShowChart(!showChart)} - params={params} error={error} EmptyState={EmptyState} + onSelectedLogChange={(log) => setSelectedLogId(log?.id ?? null)} + selectedLog={selectedLog} + isSelectedLogLoading={isSelectedLogLoading} + selectedLogError={selectedLogError ?? undefined} />
    {!error && logData.length > 0 && ( -
    +
    +
    + Showing {logData.length} results +
    diff --git a/apps/studio/components/interfaces/Settings/Logs/PreviewFilterPanel.tsx b/apps/studio/components/interfaces/Settings/Logs/PreviewFilterPanel.tsx index 04be1d56724..ffe90c561d0 100644 --- a/apps/studio/components/interfaces/Settings/Logs/PreviewFilterPanel.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/PreviewFilterPanel.tsx @@ -121,9 +121,7 @@ const PreviewFilterPanel = ({ const handleInputSearch = (query: string) => onSearch('search-input-change', { query }) return ( -
    +
    ) => { return ( - Secrets Management + + Secrets Management + - Encryption Keys + Encryption Keys ) diff --git a/apps/studio/components/interfaces/TableGridEditor/EmptyState.tsx b/apps/studio/components/interfaces/TableGridEditor/EmptyState.tsx index bd8f0a90362..07a77e633d8 100644 --- a/apps/studio/components/interfaces/TableGridEditor/EmptyState.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/EmptyState.tsx @@ -27,7 +27,7 @@ const EmptyState = ({}: EmptyStateProps) => { const { data } = useEntityTypesQuery({ projectRef: project?.ref, connectionString: project?.connectionString, - schema: selectedSchema, + schemas: [selectedSchema], sort, }) diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/ForeignRowSelector.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/ForeignRowSelector.tsx index 76f0ec80047..13f03b53110 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/ForeignRowSelector.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/ForeignRowSelector.tsx @@ -13,13 +13,13 @@ import RefreshButton from 'components/grid/components/header/RefreshButton' import FilterPopover from 'components/grid/components/header/filter/FilterPopover' import { SortPopover } from 'components/grid/components/header/sort' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { useEncryptedColumnsQuery } from 'data/encrypted-columns/encrypted-columns-query' import { useTableRowsQuery } from 'data/table-rows/table-rows-query' import { useTableQuery } from 'data/tables/table-query' import { useRoleImpersonationStateSnapshot } from 'state/role-impersonation-state' import { SidePanel } from 'ui' import ActionBar from '../../ActionBar' import { ForeignKey } from '../../ForeignKeySelector/ForeignKeySelector.types' -import { useEncryptedColumns } from '../../SidePanelEditor.utils' import Pagination from './Pagination' import SelectorGrid from './SelectorGrid' @@ -47,7 +47,12 @@ const ForeignRowSelector = ({ id: tableId, }) - const encryptedColumns = useEncryptedColumns({ schemaName, tableName }) + const { data: encryptedColumns } = useEncryptedColumnsQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + schema: schemaName, + tableName, + }) const supaTable = table && diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx index 3f8035af672..37c28bcd263 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx @@ -1,11 +1,9 @@ import type { PostgresPrimaryKey, PostgresTable } from '@supabase/postgres-meta' import { chunk, find, isEmpty, isEqual } from 'lodash' import Papa from 'papaparse' -import { useEffect, useState } from 'react' import { toast } from 'sonner' import { Query } from 'components/grid/query/Query' -import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import SparkBar from 'components/ui/SparkBar' import { createDatabaseColumn } from 'data/database-columns/database-column-create-mutation' import { deleteDatabaseColumn } from 'data/database-columns/database-column-delete-mutation' @@ -23,7 +21,6 @@ import { deleteTable as deleteTableMutation } from 'data/tables/table-delete-mut import { getTable } from 'data/tables/table-query' import { updateTable as updateTableMutation } from 'data/tables/table-update-mutation' import { getTables } from 'data/tables/tables-query' -import { getViews } from 'data/views/views-query' import { timeout, tryParseJson } from 'lib/helpers' import { generateCreateColumnPayload, @@ -37,67 +34,6 @@ import type { ImportContent } from './TableEditor/TableEditor.types' const BATCH_SIZE = 1000 const CHUNK_SIZE = 1024 * 1024 * 0.1 // 0.1MB -export interface UseEncryptedColumnsArgs { - schemaName?: string - tableName?: string -} -const listEncryptedColumns = async ( - projectRef: string, - connectionString: string | undefined = undefined, - schema: string, - table: string -) => { - if (!table) return [] - - const views = await getViews({ projectRef, connectionString, schema }) - const decryptedView = views.find((view) => view.name === `decrypted_${table}`) - if (!decryptedView) return [] - - try { - const encryptedColumns = await executeSql({ - projectRef, - connectionString, - sql: `SELECT column_name as name FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'decrypted_${table}' and column_name like 'decrypted_%'`, - }) - return encryptedColumns.result.map((column: any) => column.name.split('decrypted_')[1]) - } catch (error) { - console.error('Error fetching encrypted columns', error) - return [] - } -} - -export function useEncryptedColumns({ schemaName, tableName }: UseEncryptedColumnsArgs) { - const { project } = useProjectContext() - const [encryptedColumns, setEncryptedColumns] = useState([]) - - useEffect(() => { - let isMounted = true - - const getEncryptedColumns = async () => { - if (schemaName !== undefined && tableName !== undefined) { - const columns = await listEncryptedColumns( - project?.ref!, - project?.connectionString, - schemaName, - tableName - ) - - if (isMounted) { - setEncryptedColumns(columns) - } - } - } - - getEncryptedColumns() - - return () => { - isMounted = false - } - }, [schemaName, tableName]) - - return encryptedColumns -} - /** * The functions below are basically just queries but may be supported directly * from the pg-meta library in the future diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/ForeignKeysManagement/ForeignKeyRow.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/ForeignKeysManagement/ForeignKeyRow.tsx index 82d0142d26a..182ce4d15c5 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/ForeignKeysManagement/ForeignKeyRow.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/ForeignKeysManagement/ForeignKeyRow.tsx @@ -1,12 +1,13 @@ import clsx from 'clsx' import { useParams } from 'common' -import Link from 'next/link' +import { ArrowRight } from 'lucide-react' import SVG from 'react-inlinesvg' import { Badge, Button, cn } from 'ui' -import { ArrowRight } from 'lucide-react' +import { EditorTablePageLink } from 'data/prefetchers/project.$ref.editor.$id' import { BASE_PATH } from 'lib/constants' import type { ForeignKey } from '../../ForeignKeySelector/ForeignKeySelector.types' +import Link from 'next/link' interface ForeignKeyProps { foreignKey: ForeignKey @@ -78,8 +79,9 @@ export const ForeignKeyRow = ({ } > closePanel()} > {foreignKey.schema}.{foreignKey.table} diff --git a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx index 431ee1b7d5b..693f25789e5 100644 --- a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx @@ -1,4 +1,4 @@ -import type { PostgresColumn, PostgresRelationship, PostgresTable } from '@supabase/postgres-meta' +import type { PostgresColumn, PostgresTable } from '@supabase/postgres-meta' import { PermissionAction } from '@supabase/shared-types/out/constants' import { QueryKey, useQueryClient } from '@tanstack/react-query' import { find, isUndefined } from 'lodash' @@ -8,14 +8,11 @@ import { toast } from 'sonner' import { useParams } from 'common' import { SupabaseGrid } from 'components/grid/SupabaseGrid' -import { parseSupaTable } from 'components/grid/SupabaseGrid.utils' +import { getSupaTable } from 'components/grid/SupabaseGrid.utils' import { SupaTable } from 'components/grid/types' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' -import { FOREIGN_KEY_CASCADE_ACTION } from 'data/database/database-query-constants' -import { - ForeignKeyConstraint, - useForeignKeyConstraintsQuery, -} from 'data/database/foreign-key-constraints-query' +import { useForeignKeyConstraintsQuery } from 'data/database/foreign-key-constraints-query' +import { useEncryptedColumnsQuery } from 'data/encrypted-columns/encrypted-columns-query' import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants' import { sqlKeys } from 'data/sql/keys' import { useTableRowUpdateMutation } from 'data/table-rows/table-row-update-mutation' @@ -28,13 +25,12 @@ import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas' import { EMPTY_ARR } from 'lib/void' import { useGetImpersonatedRole } from 'state/role-impersonation-state' import { useTableEditorStateSnapshot } from 'state/table-editor' -import type { Dictionary, SchemaView } from 'types' +import type { Dictionary } from 'types' import { Button } from 'ui' import GridHeaderActions from './GridHeaderActions' import { TableGridSkeletonLoader } from './LoadingState' import NotFoundState from './NotFoundState' import SidePanelEditor from './SidePanelEditor/SidePanelEditor' -import { useEncryptedColumns } from './SidePanelEditor/SidePanelEditor.utils' import TableDefinition from './TableDefinition' export interface TableGridEditorProps { @@ -62,8 +58,10 @@ const TableGridEditor = ({ const canEditColumns = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'columns') const isReadOnly = !canEditTables && !canEditColumns - const encryptedColumns = useEncryptedColumns({ - schemaName: selectedTable?.schema, + const { data: encryptedColumns } = useEncryptedColumnsQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + schema: selectedTable?.schema, tableName: selectedTable?.name, }) @@ -148,43 +146,16 @@ const TableGridEditor = ({ const isViewSelected = entityType?.type === ENTITY_TYPE.VIEW || entityType?.type === ENTITY_TYPE.MATERIALIZED_VIEW const isTableSelected = entityType?.type === ENTITY_TYPE.TABLE - const isForeignTableSelected = entityType?.type === ENTITY_TYPE.FOREIGN_TABLE const isLocked = EXCLUDED_SCHEMAS.includes(entityType?.schema ?? '') const canEditViaTableEditor = isTableSelected && !isLocked - // [Joshen] We can tweak below to eventually support composite keys as the data - // returned from foreignKeyMeta should be easy to deal with, rather than pg-meta - const formattedRelationships = ( - ('relationships' in selectedTable && selectedTable.relationships) || - [] - ).map((relationship: PostgresRelationship) => { - const relationshipMeta = foreignKeyMeta.find( - (fk: ForeignKeyConstraint) => fk.id === relationship.id - ) - return { - ...relationship, - deletion_action: relationshipMeta?.deletion_action ?? FOREIGN_KEY_CASCADE_ACTION.NO_ACTION, - } + const gridTable = getSupaTable({ + encryptedColumns, + foreignKeyMeta, + selectedTable, + entityType: entityType?.type, }) - const gridTable = - !isViewSelected && !isForeignTableSelected - ? parseSupaTable( - { - table: selectedTable as PostgresTable, - columns: (selectedTable as PostgresTable).columns ?? [], - primaryKeys: (selectedTable as PostgresTable).primary_keys ?? [], - relationships: formattedRelationships, - }, - encryptedColumns - ) - : parseSupaTable({ - table: selectedTable as SchemaView, - columns: (selectedTable as SchemaView).columns ?? [], - primaryKeys: [], - relationships: [], - }) - const gridKey = `${selectedTable.schema}_${selectedTable.name}` const onTableCreated = (table: PostgresTable) => { diff --git a/apps/studio/components/layouts/AccountLayout/AccountLayout.tsx b/apps/studio/components/layouts/AccountLayout/AccountLayout.tsx index 3debb82a26a..bb5424c43ac 100644 --- a/apps/studio/components/layouts/AccountLayout/AccountLayout.tsx +++ b/apps/studio/components/layouts/AccountLayout/AccountLayout.tsx @@ -4,6 +4,7 @@ import { PropsWithChildren, useEffect } from 'react' import PartnerIcon from 'components/ui/PartnerIcon' import { useOrganizationsQuery } from 'data/organizations/organizations-query' +import { useSendResetMutation } from 'data/telemetry/send-reset-mutation' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { withAuth } from 'hooks/misc/withAuth' import { useFlag } from 'hooks/ui/useFlag' @@ -12,7 +13,6 @@ import { IS_PLATFORM } from 'lib/constants' import SettingsLayout from '../SettingsLayout/SettingsLayout' import type { SidebarSection } from './AccountLayout.types' import WithSidebar from './WithSidebar' -import { useSendResetMutation } from 'data/telemetry/send-reset-mutation' export interface AccountLayoutProps { title: string @@ -28,12 +28,11 @@ const AccountLayout = ({ children, title, breadcrumbs }: PropsWithChildren { - if (enablePostHogTelemetry) await sendReset() + await sendReset() await signOut() await router.push('/sign-in') } diff --git a/apps/studio/components/layouts/DatabaseLayout/Database.Commands.tsx b/apps/studio/components/layouts/DatabaseLayout/Database.Commands.tsx index 891f526f234..0fdc8a11075 100644 --- a/apps/studio/components/layouts/DatabaseLayout/Database.Commands.tsx +++ b/apps/studio/components/layouts/DatabaseLayout/Database.Commands.tsx @@ -91,7 +91,7 @@ export function useDatabaseGotoCommands(options?: CommandOptions) { id: 'nav-database-wrappers', name: 'Wrappers', value: 'Database: Wrappers', - route: `/project/${ref}/database/wrappers`, + route: `/project/${ref}/integrations/wrappers`, defaultHidden: true, }, { diff --git a/apps/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.tsx b/apps/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.tsx index 02f7c059f0f..ec80389df06 100644 --- a/apps/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.tsx +++ b/apps/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.tsx @@ -98,34 +98,26 @@ export const generateDatabaseMenu = ( }, ] : []), - { - name: 'Wrappers', - key: 'wrappers', - url: `/project/${ref}/database/wrappers`, - items: [], - }, { name: 'Migrations', key: 'migrations', url: `/project/${ref}/database/migrations`, items: [], }, + { + name: 'Wrappers', + key: 'wrappers', + url: `/project/${ref}/integrations/wrappers`, + rightIcon: , + items: [], + }, ...(!!pgNetExtensionExists ? [ { name: 'Webhooks', key: 'hooks', - url: `/project/${ref}/database/hooks`, - items: [], - }, - ] - : []), - ...(!!cronUiEnabled - ? [ - { - name: 'Cron Jobs', - key: 'cron-jobs', - url: `/project/${ref}/database/cron-jobs`, + url: `/project/${ref}/integrations/hooks`, + rightIcon: , items: [], }, ] diff --git a/apps/studio/components/layouts/DocsLayout/DocsLayout.utils.tsx b/apps/studio/components/layouts/DocsLayout/DocsLayout.utils.tsx index f6f69f2c2bf..04aa4edd1e5 100644 --- a/apps/studio/components/layouts/DocsLayout/DocsLayout.utils.tsx +++ b/apps/studio/components/layouts/DocsLayout/DocsLayout.utils.tsx @@ -1,6 +1,6 @@ import type { ProductMenuGroup } from 'components/ui/ProductMenu/ProductMenu.types' import { BASE_PATH } from 'lib/constants' -import { Book, BookOpen } from 'lucide-react' +import { ArrowUpRight, Book, BookOpen } from 'lucide-react' import SVG from 'react-inlinesvg' export const generateDocsMenu = ( @@ -71,7 +71,7 @@ export const generateDocsMenu = ( { name: 'GraphiQL', key: 'graphiql', - url: `/project/${ref}/api/graphiql`, + url: `/project/${ref}/integrations/graphiql`, icon: ( ), items: [], + rightIcon: , }, ], }, diff --git a/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx b/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx index c3dae163bf9..7f156fc719c 100644 --- a/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx +++ b/apps/studio/components/layouts/LogsLayout/LogsSidebarMenuV2.tsx @@ -11,7 +11,7 @@ import { useContentQuery } from 'data/content/content-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' import { useFlag } from 'hooks/ui/useFlag' -import { ArrowUpRight, ChevronRight, FilePlus, Plus } from 'lucide-react' +import { ArrowUpRight, ChevronRight, DatabaseIcon, FilePlus, Plus } from 'lucide-react' import Link from 'next/link' import { useRouter } from 'next/router' import { useState } from 'react' @@ -30,6 +30,7 @@ import { Separator, } from 'ui' import { + InnerSideBarEmptyPanel, InnerSideBarFilters, InnerSideBarFilterSearchInput, InnerSideMenuItem, @@ -282,14 +283,16 @@ export function LogsSidebarMenuV2() {
    )} {savedQueries.length === 0 && ( -
    - - No queries created yet - - You can create and save queries from the "Create query" button in the top left. - - -
    + + Create query + + } + /> )} {savedQueries.map((query) => ( diff --git a/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrations.Commands.tsx b/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrations.Commands.tsx new file mode 100644 index 00000000000..0fdc8a11075 --- /dev/null +++ b/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrations.Commands.tsx @@ -0,0 +1,107 @@ +import { Code } from 'lucide-react' + +import { COMMAND_MENU_SECTIONS } from 'components/interfaces/App/CommandMenu/CommandMenu.utils' +import type { CommandOptions } from 'ui-patterns/CommandMenu' +import { useRegisterCommands } from 'ui-patterns/CommandMenu' +import { useParams } from 'common' +import { orderCommandSectionsByPriority } from 'components/interfaces/App/CommandMenu/ordering' + +export function useDatabaseGotoCommands(options?: CommandOptions) { + let { ref } = useParams() + ref ||= '_' + + useRegisterCommands( + COMMAND_MENU_SECTIONS.QUERY, + [ + { + id: 'run-sql', + name: 'Run SQL', + route: `/project/${ref}/sql/new`, + icon: () => , + }, + ], + { + ...options, + deps: [ref], + orderSection: orderCommandSectionsByPriority, + sectionMeta: { priority: 2 }, + } + ) + + useRegisterCommands( + COMMAND_MENU_SECTIONS.NAVIGATE, + [ + { + id: 'nav-database-tables', + name: 'Tables', + value: 'Database: Tables', + route: `/project/${ref}/database/tables`, + defaultHidden: true, + }, + { + id: 'nav-database-triggers', + name: 'Triggers', + value: 'Database: Triggers', + route: `/project/${ref}/database/triggers`, + defaultHidden: true, + }, + { + id: 'nav-database-functions', + name: 'Functions', + value: 'Database: Functions', + route: `/project/${ref}/database/functions`, + defaultHidden: true, + }, + { + id: 'nav-database-extensions', + name: 'Extensions', + value: 'Database: Extensions', + route: `/project/${ref}/database/extensions`, + defaultHidden: true, + }, + { + id: 'nav-database-roles', + name: 'Roles', + value: 'Database: Roles', + route: `/project/${ref}/database/roles`, + defaultHidden: true, + }, + { + id: 'nav-database-replication', + name: 'Replication', + value: 'Database: Replication', + route: `/project/${ref}/database/replication`, + defaultHidden: true, + }, + { + id: 'nav-database-hooks', + name: 'Webhooks', + value: 'Database: Webhooks', + route: `/project/${ref}/database/hooks`, + defaultHidden: true, + }, + { + id: 'nav-database-backups', + name: 'Backups', + value: 'Database: Backups', + route: `/project/${ref}/database/backups/scheduled`, + defaultHidden: true, + }, + { + id: 'nav-database-wrappers', + name: 'Wrappers', + value: 'Database: Wrappers', + route: `/project/${ref}/integrations/wrappers`, + defaultHidden: true, + }, + { + id: 'nav-database-migrations', + name: 'Migrations', + value: 'Database: Migrations', + route: `/project/${ref}/database/migrations`, + defaultHidden: true, + }, + ], + { ...options, deps: [ref] } + ) +} diff --git a/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout.tsx b/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout.tsx new file mode 100644 index 00000000000..e945199971f --- /dev/null +++ b/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout.tsx @@ -0,0 +1,61 @@ +import { useRouter } from 'next/router' +import { PropsWithChildren } from 'react' + +import { IS_PLATFORM } from 'common' +import { ProductMenu } from 'components/ui/ProductMenu' +import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' +import { useSelectedProject } from 'hooks/misc/useSelectedProject' +import { withAuth } from 'hooks/misc/withAuth' +import { useFlag } from 'hooks/ui/useFlag' +import ProjectLayout from '../ProjectLayout/ProjectLayout' +import { generateProjectIntegrationsMenu } from './ProjectIntegrationsMenu.utils' + +export interface ProjectIntegrationsLayoutProps { + title?: string +} + +const ProjectIntegrationsMenu = () => { + // if running on self-hosted, cron UI should be always enabled + const cronUiEnabled = useFlag('cronUi') || !IS_PLATFORM + const project = useSelectedProject() + + const router = useRouter() + const page = router.pathname.split('/')[4] + + const { data } = useDatabaseExtensionsQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + + const pgNetExtensionExists = (data ?? []).find((ext) => ext.name === 'pg_net') !== undefined + const graphqlExtensionExists = (data ?? []).find((ext) => ext.name === 'pg_graphql') !== undefined + + return ( + <> + + + ) +} + +const ProjectIntegrationsLayout = ({ + children, +}: PropsWithChildren) => { + return ( + } + isBlocking={false} + > + {children} + + ) +} + +export default withAuth(ProjectIntegrationsLayout) diff --git a/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsMenu.utils.tsx b/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsMenu.utils.tsx new file mode 100644 index 00000000000..78f2f91eb6a --- /dev/null +++ b/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsMenu.utils.tsx @@ -0,0 +1,66 @@ +import type { ProductMenuGroup } from 'components/ui/ProductMenu/ProductMenu.types' +import type { Project } from 'data/projects/project-detail-query' + +export const generateProjectIntegrationsMenu = ( + project?: Project, + flags?: { + pgNetExtensionExists: boolean + cronUiEnabled: boolean + graphqlExtensionExists: boolean + } +): ProductMenuGroup[] => { + const ref = project?.ref ?? 'default' + const { pgNetExtensionExists, cronUiEnabled, graphqlExtensionExists } = flags || {} + + return [ + { + title: 'Manage', + items: [ + { + name: 'Wrappers', + key: 'wrappers', + url: `/project/${ref}/integrations/wrappers`, + items: [], + }, + ...(!!pgNetExtensionExists + ? [ + { + name: 'Webhooks', + key: 'hooks', + url: `/project/${ref}/integrations/hooks`, + items: [], + }, + ] + : []), + + ...(!!cronUiEnabled + ? [ + { + name: 'Cron Jobs', + key: 'cron-jobs', + url: `/project/${ref}/integrations/cron-jobs`, + items: [], + }, + ] + : []), + ...(!!graphqlExtensionExists + ? [ + { + name: 'GraphiQL', + key: 'graphiql', + url: `/project/${ref}/integrations/graphiql`, + items: [], + }, + ] + : []), + { + name: 'Vault', + key: 'vault', + url: `/project/${ref}/integrations/vault/secrets`, + items: [], + label: 'BETA', + }, + ], + }, + ] +} diff --git a/apps/studio/components/layouts/ProjectLayout/NavigationBar/CommandOption.tsx b/apps/studio/components/layouts/ProjectLayout/NavigationBar/CommandOption.tsx new file mode 100644 index 00000000000..5e56cc96511 --- /dev/null +++ b/apps/studio/components/layouts/ProjectLayout/NavigationBar/CommandOption.tsx @@ -0,0 +1,45 @@ +import { Command } from 'lucide-react' +import { ReactNode } from 'react' + +import { detectOS } from 'lib/helpers' +import { cn } from 'ui' + +interface CommandOptionProps { + icon: ReactNode + label: string + shortcut: string + onClick: () => void +} + +export const CommandOption = ({ icon, label, shortcut, onClick }: CommandOptionProps) => { + const os = detectOS() + + return ( +
    +
    + {icon} +

    {label}

    +
    +
    + {/* Issue with `os` and hydration fail */} + {os === 'macos' || true ? ( + + ) : ( +

    CTRL

    + )} +

    {shortcut}

    +
    +
    + ) +} diff --git a/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.tsx b/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.tsx index 645e52260ef..8fd77400bda 100644 --- a/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.tsx +++ b/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.tsx @@ -7,8 +7,13 @@ import Link from 'next/link' import { useRouter } from 'next/router' import { useState } from 'react' -import { useIsAPIDocsSidePanelEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' +import { + useIsAPIDocsSidePanelEnabled, + useIsDatabaseFunctionsAssistantEnabled, +} from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' +import { useProjectLintsQuery } from 'data/lint/lint-query' import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' +import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { useFlag } from 'hooks/ui/useFlag' import { useSignOut } from 'lib/auth' import { IS_PLATFORM, LOCAL_STORAGE_KEYS } from 'lib/constants' @@ -16,6 +21,7 @@ import { detectOS } from 'lib/helpers' import { useProfile } from 'lib/profile' import { useAppStateSnapshot } from 'state/app-state' import { + AiIconAnimation, Button, DropdownMenu, DropdownMenuContent, @@ -26,13 +32,20 @@ import { DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger, + HoverCardContent_Shadcn_, + HoverCardTrigger_Shadcn_, + HoverCard_Shadcn_, Separator, Theme, + TooltipContent_Shadcn_, + TooltipTrigger_Shadcn_, + Tooltip_Shadcn_, cn, singleThemes, } from 'ui' import { useSetCommandMenuOpen } from 'ui-patterns/CommandMenu' import { useProjectContext } from '../ProjectContext' +import { CommandOption } from './CommandOption' import { generateOtherRoutes, generateProductRoutes, @@ -41,9 +54,6 @@ import { } from './NavigationBar.utils' import { NavigationIconButton } from './NavigationIconButton' import NavigationIconLink from './NavigationIconLink' -import { useProjectLintsQuery } from 'data/lint/lint-query' -import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' -import { Tooltip_Shadcn_, TooltipContent_Shadcn_, TooltipTrigger_Shadcn_ } from 'ui' export const ICON_SIZE = 20 export const ICON_STROKE_WIDTH = 1.5 @@ -62,6 +72,7 @@ const NavigationBar = () => { const navLayoutV2 = useFlag('navigationLayoutV2') const isNewAPIDocsEnabled = useIsAPIDocsSidePanelEnabled() + const isFunctionsAssistantEnabled = useIsDatabaseFunctionsAssistantEnabled() const [userDropdownOpen, setUserDropdownOpenState] = useState(false) const [allowNavPanelToExpand] = useLocalStorageQuery( @@ -108,7 +119,7 @@ const NavigationBar = () => { ) } - const SearchButton = ( + const CommandButton = !isFunctionsAssistantEnabled ? ( { @@ -138,6 +149,41 @@ const NavigationBar = () => { > Search + ) : ( + + + } + > + Commands + + + + + +
    + } + label="Search" + shortcut="K" + onClick={() => { + setCommandMenuOpen(true) + snap.setNavigationPanelOpen(false) + }} + /> + + } + label="Assistant" + shortcut="I" + onClick={() => snap.setAiAssistantPanel({ open: true, editor: null })} + /> + + ) const UserAccountButton = ( @@ -312,13 +358,13 @@ const NavigationBar = () => { <> {!allowNavPanelToExpand && ( - {SearchButton} + {CommandButton} - Search + Commands )} - {allowNavPanelToExpand && SearchButton} + {allowNavPanelToExpand && CommandButton} )} diff --git a/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx b/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx index 3073b168764..8b807064bc6 100644 --- a/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx +++ b/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx @@ -1,6 +1,8 @@ +import { Blocks, FileText, Lightbulb, List, Settings } from 'lucide-react' + import type { Route } from 'components/ui/ui.types' +import { EditorIndexPageLink } from 'data/prefetchers/project.$ref.editor' import type { Project } from 'data/projects/project-detail-query' -import { IS_PLATFORM, PROJECT_STATUS } from 'lib/constants' import { Auth, Database, @@ -11,8 +13,8 @@ import { Storage, TableEditor, } from 'icons' +import { IS_PLATFORM, PROJECT_STATUS } from 'lib/constants' import { ICON_SIZE, ICON_STROKE_WIDTH } from './NavigationBar' -import { Settings, FileText, List, Lightbulb } from 'lucide-react' export const generateToolRoutes = (ref?: string, project?: Project): Route[] => { const isProjectBuilding = project?.status === PROJECT_STATUS.COMING_UP @@ -24,6 +26,7 @@ export const generateToolRoutes = (ref?: string, project?: Project): Route[] => label: 'Table Editor', icon: , link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/editor`), + linkComponent: , }, { key: 'sql', @@ -138,6 +141,12 @@ export const generateOtherRoutes = (ref?: string, project?: Project): Route[] => icon: , link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/api`), }, + { + key: 'integrations', + label: 'Integrations', + icon: , + link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/integrations/wrappers`), + }, ] } diff --git a/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationIconLink.tsx b/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationIconLink.tsx index ca8b6143169..758deedc315 100644 --- a/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationIconLink.tsx +++ b/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationIconLink.tsx @@ -1,13 +1,18 @@ import { noop } from 'lodash' import Link from 'next/link' -import { AnchorHTMLAttributes, forwardRef } from 'react' -import { cn } from 'ui' -import { Tooltip_Shadcn_, TooltipContent_Shadcn_, TooltipTrigger_Shadcn_ } from 'ui' +import { + AnchorHTMLAttributes, + cloneElement, + ComponentPropsWithoutRef, + forwardRef, + isValidElement, +} from 'react' +import { cn, Tooltip_Shadcn_, TooltipContent_Shadcn_, TooltipTrigger_Shadcn_ } from 'ui' import type { Route } from 'components/ui/ui.types' -import { useAppStateSnapshot } from 'state/app-state' import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage' import { LOCAL_STORAGE_KEYS } from 'lib/constants' +import { useAppStateSnapshot } from 'state/app-state' interface NavigationIconButtonProps extends AnchorHTMLAttributes { route: Route @@ -41,8 +46,18 @@ const NavigationIconLink = forwardRef>( + function LinkComponent(props, ref) { + if (route.linkComponent && isValidElement(route.linkComponent)) { + return cloneElement(route.linkComponent, { ...props, ref }) + } + + return + } + ) + const linkContent = ( - {route.label} - + ) if (!allowNavPanelToExpand) { diff --git a/apps/studio/components/layouts/ProjectLayout/PauseFailedState.tsx b/apps/studio/components/layouts/ProjectLayout/PauseFailedState.tsx index 88eb1049fe9..9e8e36faf38 100644 --- a/apps/studio/components/layouts/ProjectLayout/PauseFailedState.tsx +++ b/apps/studio/components/layouts/ProjectLayout/PauseFailedState.tsx @@ -75,7 +75,12 @@ const PauseFailedState = () => { icon={} loading={isDownloading} disabled={backups.length === 0} - tooltip={{ content: { side: 'bottom', text: 'No available backups to download' } }} + tooltip={{ + content: { + side: 'bottom', + text: backups.length === 0 ? 'No available backups to download' : undefined, + }, + }} onClick={onClickDownloadBackup} > Download backup diff --git a/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx b/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx index 9c2e528eb2a..a64d8ccff44 100644 --- a/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx +++ b/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx @@ -276,7 +276,9 @@ export const ProjectPausedState = ({ product }: ProjectPausedStateProps) => { tooltip={{ content: { side: 'bottom', - text: 'You need additional permissions to resume this project', + text: !canResumeProject + ? 'You need additional permissions to resume this project' + : undefined, }, }} > diff --git a/apps/studio/components/layouts/ProjectLayout/RestoreFailedState.tsx b/apps/studio/components/layouts/ProjectLayout/RestoreFailedState.tsx index 8b416e8a6a0..6ba94a4833b 100644 --- a/apps/studio/components/layouts/ProjectLayout/RestoreFailedState.tsx +++ b/apps/studio/components/layouts/ProjectLayout/RestoreFailedState.tsx @@ -75,7 +75,12 @@ const RestoreFailedState = () => { icon={} loading={isDownloading} disabled={backups.length === 0} - tooltip={{ content: { side: 'bottom', text: 'No available backups to download' } }} + tooltip={{ + content: { + side: 'bottom', + text: backups.length === 0 ? 'No available backups to download' : undefined, + }, + }} onClick={onClickDownloadBackup} > Download backup diff --git a/apps/studio/components/layouts/ProjectLayout/RestoringState.tsx b/apps/studio/components/layouts/ProjectLayout/RestoringState.tsx index 4f95cd0f2c4..5f4bf20491e 100644 --- a/apps/studio/components/layouts/ProjectLayout/RestoringState.tsx +++ b/apps/studio/components/layouts/ProjectLayout/RestoringState.tsx @@ -126,7 +126,12 @@ const RestoringState = () => { icon={} loading={isDownloading} disabled={backups.length === 0} - tooltip={{ content: { side: 'bottom', text: 'No available backups to download' } }} + tooltip={{ + content: { + side: 'bottom', + text: backups.length === 0 ? 'No available backups to download' : undefined, + }, + }} onClick={onClickDownloadBackup} > Download backup diff --git a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.ts b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx similarity index 96% rename from apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.ts rename to apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx index 534af51b477..768c38a20c1 100644 --- a/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.ts +++ b/apps/studio/components/layouts/ProjectSettingsLayout/SettingsMenu.utils.tsx @@ -2,6 +2,7 @@ import type { ProductMenuGroup } from 'components/ui/ProductMenu/ProductMenu.typ import type { Project } from 'data/projects/project-detail-query' import { IS_PLATFORM, PROJECT_STATUS } from 'lib/constants' import type { Organization } from 'types' +import { ArrowUpRight } from 'lucide-react' export const generateSettingsMenu = ( ref?: string, @@ -62,8 +63,9 @@ export const generateSettingsMenu = ( { name: 'Vault', key: 'vault', - url: isProjectBuilding ? buildingUrl : `/project/${ref}/settings/vault/secrets`, + url: isProjectBuilding ? buildingUrl : `/project/${ref}/integrations/vault/secrets`, items: [], + rightIcon: , label: 'BETA', }, ], diff --git a/apps/studio/components/layouts/ReportsLayout/ReportsMenu.tsx b/apps/studio/components/layouts/ReportsLayout/ReportsMenu.tsx index a4e768de272..dbb19165499 100644 --- a/apps/studio/components/layouts/ReportsLayout/ReportsMenu.tsx +++ b/apps/studio/components/layouts/ReportsLayout/ReportsMenu.tsx @@ -136,7 +136,9 @@ const ReportsMenu = () => { tooltip={{ content: { side: 'bottom', - text: 'You need additional permissions to create custom reports', + text: !canCreateCustomReport + ? 'You need additional permissions to create custom reports' + : undefined, }, }} > diff --git a/apps/studio/components/layouts/StorageLayout/BucketRow.tsx b/apps/studio/components/layouts/StorageLayout/BucketRow.tsx index b44f233ebb7..1474325de01 100644 --- a/apps/studio/components/layouts/StorageLayout/BucketRow.tsx +++ b/apps/studio/components/layouts/StorageLayout/BucketRow.tsx @@ -34,7 +34,7 @@ const BucketRow = ({ onSelectDeleteBucket = noop, onSelectEditBucket = noop, }: BucketRowProps) => { - const canUpdateBuckets = useCheckPermissions(PermissionAction.STORAGE_ADMIN_WRITE, '*') + const canUpdateBuckets = useCheckPermissions(PermissionAction.STORAGE_WRITE, '*') return (
    { const project = useSelectedProject() const storageExplorerStore = useStorageStore() - const { data: settings, isLoading } = useProjectApiQuery({ projectRef }) - const apiService = settings?.autoApiService + const { data: settings, isLoading } = useProjectSettingsV2Query({ projectRef }) + const endpoint = settings?.app_config?.endpoint + const { serviceKey } = getAPIKeys(settings) const isPaused = project?.status === PROJECT_STATUS.INACTIVE useEffect(() => { - if (!isLoading && apiService) initializeStorageStore(apiService) - }, [isLoading, projectRef]) + if (!isLoading && endpoint && serviceKey?.api_key) { + initializeStorageStore(endpoint, serviceKey.api_key) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isLoading, projectRef, endpoint, serviceKey?.api_key]) - const initializeStorageStore = async (apiService: AutoApiService) => { + const initializeStorageStore = async (endpoint: string, serviceApiKey: string) => { if (isPaused) return - if (apiService.endpoint) { - storageExplorerStore.initStore( - projectRef!, - apiService.endpoint, - apiService.serviceApiKey, - apiService.protocol - ) + if (endpoint) { + storageExplorerStore.initStore(projectRef!, endpoint, serviceApiKey, 'https') } else { toast.error( 'Failed to fetch project configuration. Try refreshing your browser, or reach out to us at support@supabase.io' diff --git a/apps/studio/components/layouts/StorageLayout/StorageMenu.tsx b/apps/studio/components/layouts/StorageLayout/StorageMenu.tsx index 0906835d8a3..d47cd875f5f 100644 --- a/apps/studio/components/layouts/StorageLayout/StorageMenu.tsx +++ b/apps/studio/components/layouts/StorageLayout/StorageMenu.tsx @@ -37,7 +37,7 @@ const StorageMenu = () => { const [selectedBucketToEdit, setSelectedBucketToEdit] = useState() const [selectedBucketToEmpty, setSelectedBucketToEmpty] = useState() const [selectedBucketToDelete, setSelectedBucketToDelete] = useState() - const canCreateBuckets = useCheckPermissions(PermissionAction.STORAGE_ADMIN_WRITE, '*') + const canCreateBuckets = useCheckPermissions(PermissionAction.STORAGE_WRITE, '*') const [sort, setSort] = useLocalStorage<'alphabetical' | 'created-at'>( 'storage-explorer-sort', @@ -79,7 +79,9 @@ const StorageMenu = () => { tooltip={{ content: { side: 'bottom', - text: 'You need additional permissions to create buckets', + text: !canCreateBuckets + ? 'You need additional permissions to create buckets' + : undefined, }, }} > diff --git a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx index 2a2f463ea13..98cd65ec2b4 100644 --- a/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/EntityListItem.tsx @@ -46,6 +46,7 @@ import { } from 'ui' import { useProjectContext } from '../ProjectLayout/ProjectContext' import { Markdown } from 'components/interfaces/Markdown' +import { EditorTablePageLink } from 'data/prefetchers/project.$ref.editor.$id' export interface EntityListItemProps { id: number @@ -277,8 +278,9 @@ const EntityListItem: ItemRenderer = ({ } return ( - = ({ )} - + ) } diff --git a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx index 1e985ecf34d..215893ac1d3 100644 --- a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx @@ -12,10 +12,10 @@ import SchemaSelector from 'components/ui/SchemaSelector' import { useSchemasQuery } from 'data/database/schemas-query' import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants' import { useEntityTypesQuery } from 'data/entity-types/entity-types-infinite-query' -import { useTableQuery } from 'data/tables/table-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useLocalStorage } from 'hooks/misc/useLocalStorage' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' +import useTable from 'hooks/misc/useTable' import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas' import { useTableEditorStateSnapshot } from 'state/table-editor' import { @@ -67,7 +67,7 @@ const TableEditorMenu = () => { { projectRef: project?.ref, connectionString: project?.connectionString, - schema: selectedSchema, + schemas: [selectedSchema], search: searchText.trim() || undefined, sort, filterTypes: visibleTypes, @@ -96,15 +96,7 @@ const TableEditorMenu = () => { ) const isLocked = protectedSchemas.some((s) => s.id === schema?.id) - const { data: selectedTable } = useTableQuery( - { - projectRef: project?.ref, - connectionString: project?.connectionString, - id: Number(id), - }, - // only run if we have a selected table - { enabled: Boolean(id) } - ) + const { data: selectedTable } = useTable(id !== undefined ? Number(id) : undefined) useEffect(() => { if (selectedTable) { @@ -144,7 +136,9 @@ const TableEditorMenu = () => { tooltip={{ content: { side: 'bottom', - text: 'You need additional permissions to create tables', + text: !canCreateTables + ? 'You need additional permissions to create tables' + : undefined, }, }} > diff --git a/apps/studio/components/to-be-cleaned/Charts/ChartHandler.tsx b/apps/studio/components/to-be-cleaned/Charts/ChartHandler.tsx index c6e8ec5e6bc..5248759b69e 100644 --- a/apps/studio/components/to-be-cleaned/Charts/ChartHandler.tsx +++ b/apps/studio/components/to-be-cleaned/Charts/ChartHandler.tsx @@ -170,9 +170,7 @@ const ChartHandler = ({
    {chartStyle === 'bar' ? ( { return ( - {canUpdateFiles && ( - <> - onSelectCreateFolder(props.index)}> - - New folder - - - - )} + {canUpdateFiles && [ + onSelectCreateFolder(props.index)}> + + New folder + , + , + ]} onSelectAllItemsInColumn(props.index)}> Select all items diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerHeader.tsx b/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerHeader.tsx index 479fc46ca2b..e9b7bf980c9 100644 --- a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerHeader.tsx +++ b/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerHeader.tsx @@ -1,10 +1,10 @@ -import * as Tooltip from '@radix-ui/react-tooltip' import { PermissionAction } from '@supabase/shared-types/out/constants' import { compact, debounce, isEqual, noop } from 'lodash' import { useCallback, useEffect, useRef, useState } from 'react' import { useIsAPIDocsSidePanelEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import APIDocsButton from 'components/ui/APIDocsButton' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useStorageStore } from 'localStores/storageExplorer/StorageExplorerStore' import { @@ -426,64 +426,38 @@ const FileExplorerHeader = ({ {/* @ts-ignore */}
    - - - - - {!canUpdateStorage && ( - - - -
    - - You need additional permissions to upload files - -
    -
    -
    - )} -
    - - - - - {!canUpdateStorage && ( - - - -
    - - You need additional permissions to create folders - -
    -
    -
    - )} -
    + } + type="text" + disabled={!canUpdateStorage || breadcrumbs.length === 0} + onClick={onSelectUpload} + tooltip={{ + content: { + side: 'bottom', + text: !canUpdateStorage + ? 'You need additional permissions to upload files' + : undefined, + }, + }} + > + Upload files + + } + type="text" + disabled={!canUpdateStorage || breadcrumbs.length === 0} + onClick={() => addNewFolderPlaceholder(-1)} + tooltip={{ + content: { + side: 'bottom', + text: !canUpdateStorage + ? 'You need additional permissions to create folders' + : undefined, + }, + }} + > + Create folder +
    diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerHeaderSelection.tsx b/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerHeaderSelection.tsx index c1131dd741b..8b6daad9cf2 100644 --- a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerHeaderSelection.tsx +++ b/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/FileExplorerHeaderSelection.tsx @@ -48,6 +48,7 @@ const FileExplorerHeaderSelection = () => { } type="primary" + disabled={!canUpdateFiles} onClick={() => setSelectedItemsToDelete(selectedItems)} tooltip={{ content: { @@ -62,6 +63,7 @@ const FileExplorerHeaderSelection = () => { } type="primary" + disabled={!canUpdateFiles} onClick={() => setSelectedItemsToMove(selectedItems)} tooltip={{ content: { diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/useCopyUrl.tsx b/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/useCopyUrl.tsx index 925eea0cfdf..7543eb48899 100644 --- a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/useCopyUrl.tsx +++ b/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/useCopyUrl.tsx @@ -1,19 +1,14 @@ import { toast } from 'sonner' -import { useProjectSettingsQuery } from 'data/config/project-settings-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCustomDomainsQuery } from 'data/custom-domains/custom-domains-query' -import { DEFAULT_PROJECT_API_SERVICE_ID } from 'lib/constants/infrastructure' import { copyToClipboard } from 'lib/helpers' export const useCopyUrl = (ref: string) => { const { data: customDomainData } = useCustomDomainsQuery({ projectRef: ref }) - const { data: projectSettings } = useProjectSettingsQuery({ projectRef: ref }) + const { data: settings } = useProjectSettingsV2Query({ projectRef: ref }) - const apiService = (projectSettings?.services ?? []).find( - (x) => x.app.id == DEFAULT_PROJECT_API_SERVICE_ID - ) - const apiConfig = apiService?.app_config - const apiUrl = `${apiConfig?.protocol ?? 'https'}://${apiConfig?.endpoint ?? '-'}` + const apiUrl = `https://${settings?.app_config?.endpoint ?? '-'}` const onCopyUrl = (name: string, url: string | Promise) => { const formattedUrl = Promise.resolve(url).then((url) => { diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageSettings/S3Connection.tsx b/apps/studio/components/to-be-cleaned/Storage/StorageSettings/S3Connection.tsx index 25a5c517e49..1e58b14c992 100644 --- a/apps/studio/components/to-be-cleaned/Storage/StorageSettings/S3Connection.tsx +++ b/apps/studio/components/to-be-cleaned/Storage/StorageSettings/S3Connection.tsx @@ -12,7 +12,7 @@ import Table from 'components/to-be-cleaned/Table' import { FormHeader } from 'components/ui/Forms/FormHeader' import NoPermission from 'components/ui/NoPermission' import Panel from 'components/ui/Panel' -import { useProjectApiQuery } from 'data/config/project-api-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useStorageCredentialsQuery } from 'data/storage/s3-access-key-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' import { AlertDescription_Shadcn_, Alert_Shadcn_, Button, WarningIcon } from 'ui' @@ -35,14 +35,15 @@ export const S3Connection = () => { const canReadS3Credentials = useCheckPermissions(PermissionAction.STORAGE_ADMIN_READ, '*') - const { data: projectAPI } = useProjectApiQuery({ projectRef: projectRef }) + const { data: settings } = useProjectSettingsV2Query({ projectRef }) const { data: storageCreds, ...storageCredsQuery } = useStorageCredentialsQuery( { projectRef }, { enabled: canReadS3Credentials } ) + const endpoint = settings?.app_config?.endpoint const hasStorageCreds = storageCreds?.data && storageCreds.data.length > 0 - const s3connectionUrl = getConnectionURL(projectRef ?? '', projectAPI) + const s3connectionUrl = getConnectionURL(projectRef ?? '', endpoint) return ( <> diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageSettings/StorageSettings.tsx b/apps/studio/components/to-be-cleaned/Storage/StorageSettings/StorageSettings.tsx index 85a659af2a1..41099ee368b 100644 --- a/apps/studio/components/to-be-cleaned/Storage/StorageSettings/StorageSettings.tsx +++ b/apps/studio/components/to-be-cleaned/Storage/StorageSettings/StorageSettings.tsx @@ -13,7 +13,9 @@ import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import UpgradeToPro from 'components/ui/UpgradeToPro' import { useProjectStorageConfigQuery } from 'data/config/project-storage-config-query' import { useProjectStorageConfigUpdateUpdateMutation } from 'data/config/project-storage-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 { Button, @@ -45,7 +47,12 @@ const StorageSettings = () => { isSuccess, isError, } = useProjectStorageConfigQuery({ projectRef }, { enabled: IS_PLATFORM }) - const { isFreeTier } = config || {} + + const organization = useSelectedOrganization() + const { data: subscription, isSuccess: isSuccessSubscription } = useOrgSubscriptionQuery({ + orgSlug: organization?.slug, + }) + const isFreeTier = isSuccessSubscription && subscription.plan.id === 'free' const [initialValues, setInitialValues] = useState({ fileSizeLimit: 0, @@ -142,7 +149,7 @@ const StorageSettings = () => { type="number" {...field} className="w-full" - disabled={!canUpdateStorageSettings} + disabled={isFreeTier || !canUpdateStorageSettings} /> @@ -223,7 +230,7 @@ const StorageSettings = () => { type="default" htmlType="reset" onClick={() => form.reset()} - disabled={!canUpdateStorageSettings || isUpdating} + disabled={!form.formState.isDirty || !canUpdateStorageSettings || isUpdating} > Cancel @@ -231,7 +238,7 @@ const StorageSettings = () => { type="primary" htmlType="submit" loading={isUpdating} - disabled={!canUpdateStorageSettings || isUpdating} + disabled={!form.formState.isDirty || !canUpdateStorageSettings || isUpdating} > Save diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageSettings/StorageSettings.utils.ts b/apps/studio/components/to-be-cleaned/Storage/StorageSettings/StorageSettings.utils.ts index 44cf98726af..d04aa865ea0 100644 --- a/apps/studio/components/to-be-cleaned/Storage/StorageSettings/StorageSettings.utils.ts +++ b/apps/studio/components/to-be-cleaned/Storage/StorageSettings/StorageSettings.utils.ts @@ -1,4 +1,3 @@ -import { ProjectApiResponse } from 'data/config/project-api-query' import { StorageSizeUnits } from './StorageSettings.constants' const k = 1024 @@ -26,12 +25,8 @@ export const convertToBytes = (size: number, unit: StorageSizeUnits = StorageSiz return size * Math.pow(k, i) } -export function getConnectionURL(projectRef: string, projectAPI?: ProjectApiResponse) { - if (projectAPI === undefined) return '' - - const projUrl = projectAPI - ? `${projectAPI.autoApiService.protocol}://${projectAPI.autoApiService.endpoint}` - : `https://${projectRef}.supabase.co` +export function getConnectionURL(projectRef: string, endpoint?: string) { + const projUrl = endpoint ? `https://${endpoint}` : `https://${projectRef}.supabase.co` const url = new URL(projUrl) url.pathname = '/storage/v1/s3' diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx new file mode 100644 index 00000000000..59864323626 --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx @@ -0,0 +1,738 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { AnimatePresence, motion } from 'framer-motion' +import { last } from 'lodash' +import { ExternalLink, FileText, MessageCircleMore, Plus, WandSparkles } from 'lucide-react' +import Link from 'next/link' +import { useEffect, useMemo, useRef, useState } from 'react' +import { toast } from 'sonner' + +import type { Message as MessageType } from 'ai/react' +import { useChat } from 'ai/react' +import { useParams } from 'common' +import OptInToOpenAIToggle from 'components/interfaces/Organization/GeneralSettings/OptInToOpenAIToggle' +import { MessageWithDebug } from 'components/interfaces/SQLEditor/AiAssistantPanel' +import { DiffType } from 'components/interfaces/SQLEditor/SQLEditor.types' +import { useSqlDebugMutation } from 'data/ai/sql-debug-mutation' +import { useEntityDefinitionsQuery } from 'data/database/entity-definitions-query' +import { useOrganizationUpdateMutation } from 'data/organizations/organization-update-mutation' +import { useSendEventMutation } from 'data/telemetry/send-event-mutation' +import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useOrgOptedIntoAi } from 'hooks/misc/useOrgOptedIntoAi' +import { useSchemasForAi } from 'hooks/misc/useSchemasForAi' +import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' +import { useSelectedProject } from 'hooks/misc/useSelectedProject' +import { useFlag } from 'hooks/ui/useFlag' +import { + BASE_PATH, + IS_PLATFORM, + OPT_IN_TAGS, + TELEMETRY_ACTIONS, + TELEMETRY_CATEGORIES, + TELEMETRY_LABELS, +} from 'lib/constants' +import { useAppStateSnapshot } from 'state/app-state' +import { + AiIconAnimation, + Button, + cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, + SheetHeader, + SheetSection, + Tooltip_Shadcn_, + TooltipContent_Shadcn_, + TooltipTrigger_Shadcn_, +} from 'ui' +import { Admonition, AssistantChatForm } from 'ui-patterns' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { ASSISTANT_SUPPORT_ENTITIES } from './AiAssistant.constants' +import { SupportedAssistantEntities, SupportedAssistantQuickPromptTypes } from './AIAssistant.types' +import { generatePrompt, retrieveDocsUrl } from './AIAssistant.utils' +import { ContextBadge } from './ContextBadge' +import { EntitiesDropdownMenu } from './EntitiesDropdownMenu' +import { Message } from './Message' +import { SchemasDropdownMenu } from './SchemasDropdownMenu' +import { useEntityDefinitionQuery } from 'data/database/entity-definition-query' + +const ANIMATION_DURATION = 0.3 + +interface AIAssistantProps { + id: string + className?: string + debugThread: MessageWithDebug[] + onDiff: ({ id, diffType, sql }: { id: string; diffType: DiffType; sql: string }) => void + onResetConversation: () => void +} + +// [Joshen] For some reason I'm having issues working with dropdown menu and scroll area + +export const AIAssistant = ({ + id, + className, + debugThread, + onDiff, + onResetConversation, +}: AIAssistantProps) => { + const { ref } = useParams() + const project = useSelectedProject() + const isOptedInToAI = useOrgOptedIntoAi() + const selectedOrganization = useSelectedOrganization() + const includeSchemaMetadata = isOptedInToAI || !IS_PLATFORM + + const disablePrompts = useFlag('disableAssistantPrompts') + const { aiAssistantPanel } = useAppStateSnapshot() + const { editor, entity } = aiAssistantPanel + + const inputRef = useRef(null) + const bottomRef = useRef(null) + + const [value, setValue] = useState('') + const [selectedDatabaseEntity, setSelectedDatabaseEntity] = useState< + SupportedAssistantEntities | '' + >('') + const [selectedSchemas, setSelectedSchemas] = useSchemasForAi(project?.ref!) + const [selectedTables, setSelectedTables] = useState<{ schema: string; name: string }[]>([]) + const [contextHistory, setContextHistory] = useState<{ + [key: string]: { entity: string; schemas: string[]; tables: string[] } + }>({}) + // [Joshen] Mainly for error handling on useChat - cause last sent messages will be voided + const [assistantError, setAssistantError] = useState() + const [lastSentMessage, setLastSentMessage] = useState() + const [isConfirmOptInModalOpen, setIsConfirmOptInModalOpen] = useState(false) + + const docsUrl = retrieveDocsUrl(selectedDatabaseEntity as SupportedAssistantEntities) + const entityContext = ASSISTANT_SUPPORT_ENTITIES.find((x) => x.id === selectedDatabaseEntity) + const noContextAdded = + selectedDatabaseEntity.length === 0 && + selectedSchemas.length === 0 && + selectedTables.length === 0 + + const { data: existingDefinition } = useEntityDefinitionQuery({ + id: entity?.id, + type: editor, + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + + const { data } = useEntityDefinitionsQuery( + { + schemas: selectedSchemas, + projectRef: project?.ref, + connectionString: project?.connectionString, + }, + { enabled: includeSchemaMetadata } + ) + + const tableDefinitions = + selectedTables.length === 0 + ? data?.map((def) => def.sql.trim()) ?? [] + : data + ?.filter((def) => { + return selectedTables.some((table) => { + return def.sql.startsWith(`CREATE TABLE ${table.schema}.${table.name}`) + }) + }) + .map((def) => def.sql.trim()) ?? [] + const entityDefinitions = includeSchemaMetadata ? tableDefinitions : undefined + + const { mutate: sendEvent } = useSendEventMutation() + const sendTelemetryEvent = (action: string) => { + sendEvent({ + action, + category: TELEMETRY_CATEGORIES.AI_ASSISTANT, + label: TELEMETRY_LABELS.QUICK_SQL_EDITOR, + }) + } + + const { + messages: chatMessages, + isLoading: isChatLoading, + append, + } = useChat({ + id, + api: `${BASE_PATH}/api/ai/sql/generate-v2`, + body: { entityDefinitions, context: selectedDatabaseEntity, existingSql: existingDefinition }, + onError: (error) => setAssistantError(JSON.parse(error.message).error), + }) + + const canUpdateOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations') + const { mutate: updateOrganization, isLoading: isUpdating } = useOrganizationUpdateMutation() + + const { isLoading: isDebugSqlLoading } = useSqlDebugMutation() + const isLoading = isChatLoading || isDebugSqlLoading + + const messages = useMemo(() => { + const merged = [ + ...debugThread, + ...chatMessages.map((m) => ({ ...m, isDebug: false })), + ...(assistantError !== undefined && lastSentMessage !== undefined ? [lastSentMessage] : []), + ] + + return merged.sort( + (a, b) => + (a.createdAt?.getTime() ?? 0) - (b.createdAt?.getTime() ?? 0) || + a.role.localeCompare(b.role) + ) + }, [chatMessages, debugThread, assistantError, lastSentMessage]) + + const hasMessages = messages.length > 0 + const lastMessage = last(messages) + const pendingChatReply = isLoading && lastMessage?.role === 'user' + const pendingDebugReply = + (lastMessage as MessageWithDebug)?.isDebug && + lastMessage?.role === 'assistant' && + lastMessage.content === 'Thinking...' + const pendingReply = pendingChatReply || pendingDebugReply + + const sendMessageToAssistant = (content: string) => { + const payload = { role: 'user', createdAt: new Date(), content } as MessageType + append(payload) + setAssistantError(undefined) + setLastSentMessage(payload) + sendTelemetryEvent(TELEMETRY_ACTIONS.PROMPT_SUBMITTED) + } + + const toggleSchema = (schema: string) => { + if (selectedSchemas.includes(schema)) { + setSelectedSchemas(selectedSchemas.filter((s) => s !== schema)) + } else { + const newSelectedSchemas = [...selectedSchemas, schema].sort((a, b) => a.localeCompare(b)) + setSelectedSchemas(newSelectedSchemas) + sendTelemetryEvent(TELEMETRY_ACTIONS.SCHEMA_CONTEXT_ADDED) + } + } + + const toggleEntity = ({ schema, name }: { schema: string; name: string }) => { + const isExisting = selectedTables.find((x) => x.schema === schema && x.name === name) + if (isExisting) { + setSelectedTables(selectedTables.filter((x) => !(x.schema === schema && x.name === name))) + } else { + const newselectedTables = [...selectedTables, { schema, name }].sort( + (a, b) => a.schema.localeCompare(b.schema) || a.name.localeCompare(b.name) + ) + setSelectedTables(newselectedTables) + sendTelemetryEvent(TELEMETRY_ACTIONS.TABLE_CONTEXT_ADDED) + } + } + + const onClickQuickPrompt = (type: SupportedAssistantQuickPromptTypes) => { + const prompt = generatePrompt({ + type, + context: selectedDatabaseEntity as any, + schemas: selectedSchemas, + tables: selectedTables, + }) + if (prompt) { + setValue(prompt) + sendMessageToAssistant(prompt) + sendTelemetryEvent(TELEMETRY_ACTIONS.QUICK_PROMPT_SELECTED(type)) + } + } + + const confirmOptInToShareSchemaData = async () => { + if (!canUpdateOrganization) { + return toast.error('You do not have the required permissions to update this organization') + } + + if (!selectedOrganization?.slug) return console.error('Organization slug is required') + + const existingOptInTags = selectedOrganization?.opt_in_tags ?? [] + + const updatedOptInTags = existingOptInTags.includes(OPT_IN_TAGS.AI_SQL) + ? existingOptInTags + : [...existingOptInTags, OPT_IN_TAGS.AI_SQL] + + updateOrganization( + { slug: selectedOrganization?.slug, opt_in_tags: updatedOptInTags }, + { + onSuccess: () => { + toast.success('Successfully opted-in') + setIsConfirmOptInModalOpen(false) + }, + } + ) + } + + useEffect(() => { + if (editor) { + const mode = ASSISTANT_SUPPORT_ENTITIES.find((x) => x.id === editor) + if (mode) setSelectedDatabaseEntity(mode.id) + } + }, [editor]) + + useEffect(() => { + if (!isLoading) { + setValue('') + if (inputRef.current) inputRef.current.focus() + } + + setTimeout( + () => { + if (bottomRef.current) bottomRef.current.scrollIntoView({ behavior: 'smooth' }) + }, + isLoading ? 100 : 500 + ) + }, [isLoading]) + + useEffect(() => { + if (bottomRef.current) bottomRef.current.scrollIntoView({ behavior: 'smooth' }) + }, [messages]) + + useEffect(() => { + const lastMessage = last(messages) + if (lastMessage?.role === 'user') { + const entity = entityContext?.label ?? selectedDatabaseEntity + setContextHistory({ + ...contextHistory, + [lastMessage.id]: { + entity, + schemas: selectedSchemas, + tables: selectedTables.map((x) => + selectedSchemas.length > 1 ? `${x.schema}.${x.name}` : x.name + ), + }, + }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [messages.length]) + + return ( + <> +
    + +
    + +

    Assistant

    +
    + + {hasMessages && ( + + + + )} + +
    + + + {hasMessages && ( + + {messages.map((m, index) => { + const isFirstUserMessage = + m.role === 'user' && messages.slice(0, index).every((msg) => msg.role !== 'user') + + return ( + onDiff({ id: m.id, diffType, sql })} + > + {isFirstUserMessage && !includeSchemaMetadata && ( + + + + )} + {isFirstUserMessage && + includeSchemaMetadata && + selectedSchemas.length === 0 && ( + + )} + + ) + })} + {assistantError !== undefined && ( + + + + )} + {!isLoading && !pendingReply && assistantError === undefined && ( +

    + Please verify all responses as the Assistant can make mistakes +

    + )} + {pendingChatReply && ( + + )} +
    + + )} + +
    + + {!hasMessages && ( + +

    + How can I help you + {!!entityContext ? ( + <> + {' '} + with{' '} + + {entityContext.id === 'rls-policies' + ? entityContext.label + : `Database ${entityContext.label}`} + + + ) : ( + ' today' + )} + ? +

    +
    + )} +
    +
    + {disablePrompts && ( + + )} +
    +
    + + + + + + + + Add context for the assistant + + + + + + Improve the output quality of the assistant by giving it context about what + you need help with + + + {editor === null && ( + + +
    +

    Database Entity

    +

    + Inform about what you're working with +

    +
    +
    + + + setSelectedDatabaseEntity(value as SupportedAssistantEntities) + } + > + {ASSISTANT_SUPPORT_ENTITIES.map((x) => ( + + {x.label} + + ))} + + +
    + )} + {includeSchemaMetadata && ( + <> + + +
    +

    Schemas

    +

    + Share table definitions in the selected schemas +

    +
    +
    + + + +
    + {selectedSchemas.length > 0 && ( + + +
    +

    Tables

    +

    + Select specific tables to share definitions for +

    +
    +
    + + + +
    + )} + + )} +
    +
    + + {!!entityContext && ( + setSelectedDatabaseEntity('') : undefined} + /> + )} + + {selectedSchemas.length > 0 && ( + 2 ? ` and ${selectedSchemas.length - 2} other${selectedSchemas.length > 3 ? 's' : ''}` : ''}`} + onRemove={() => { + setSelectedSchemas([]) + setSelectedTables([]) + }} + tooltip={ + selectedSchemas.length > 2 ? ( + <> +

    + {selectedSchemas.length} schemas selected: +

    +
      + {selectedSchemas.map((x) => ( +
    • {x}
    • + ))} +
    + + ) : undefined + } + /> + )} + + {selectedTables.length > 0 && ( + x.name) + .join( + ', ' + )}${selectedTables.length > 2 ? ` and ${selectedTables.length - 2} other${selectedTables.length > 3 ? 's' : ''}` : ''}`} + onRemove={() => setSelectedTables([])} + tooltip={ + selectedTables.length > 2 ? ( + <> +

    + {selectedTables.length} tables selected: +

    +
      + {selectedTables.map((x) => ( +
    • + {x.schema}.{x.name} +
    • + ))} +
    + + ) : undefined + } + /> + )} +
    + + textarea]:rounded-none [&>textarea]:border-0 [&>textarea]:!outline-none [&>textarea]:!ring-offset-0 [&>textarea]:!ring-0' + )} + loading={isLoading} + disabled={disablePrompts || isLoading} + placeholder={ + hasMessages ? 'Reply to the assistant...' : 'How can we help you today?' + } + value={value} + onValueChange={(e) => setValue(e.target.value)} + onSubmit={(event) => { + event.preventDefault() + sendMessageToAssistant(value) + }} + /> + {!hasMessages && ( +
    + The Assistant is in Alpha and your prompts might be rate limited +
    + )} +
    + + + {!hasMessages && ( + +
    + + + + + + Suggest some{' '} + {entityContext?.id === 'rls-policies' + ? entityContext.label + : `database ${entityContext?.label.toLowerCase()}`} + + + + + + + + Provide some examples of{' '} + {entityContext?.id === 'rls-policies' + ? entityContext.label + : `database ${entityContext?.label.toLowerCase()}`} + + + + + + + + What are{' '} + {entityContext?.id === 'rls-policies' + ? entityContext.label + : `database ${entityContext?.label.toLowerCase()}`} + ? + + + {docsUrl !== undefined && ( + + )} +
    +
    + )} +
    +
    +
    + +
    + setIsConfirmOptInModalOpen(false)} + onConfirm={confirmOptInToShareSchemaData} + loading={isUpdating} + > +

    + By opting into sending anonymous data, Supabase AI can improve the answers it shows you. + This is an organization-wide setting, and affects all projects in your organization. +

    + + +
    + + ) +} diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.types.ts b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.types.ts new file mode 100644 index 00000000000..38fb580b306 --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.types.ts @@ -0,0 +1,2 @@ +export type SupportedAssistantEntities = 'rls-policies' | 'functions' +export type SupportedAssistantQuickPromptTypes = 'suggest' | 'examples' | 'ask' diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts new file mode 100644 index 00000000000..10285b026b6 --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.utils.ts @@ -0,0 +1,156 @@ +import { CommonDatabaseEntity } from 'state/app-state' +import { SupportedAssistantEntities, SupportedAssistantQuickPromptTypes } from './AIAssistant.types' + +const PLACEHOLDER_PREFIX = `-- Press tab to use this code +\n \n` + +const PLACEHOLDER_LIMIT = `Just three examples will do.` + +export const generateTitle = ( + editor?: SupportedAssistantEntities | null, + entity?: CommonDatabaseEntity +) => { + switch (editor) { + case 'functions': + if (entity === undefined) return 'Create a new function' + else return `Edit function: ${entity.name}` + case 'rls-policies': + if (entity === undefined) return 'Create a new RLS policy' + else return `Edit RLS policy: ${entity.name}` + default: + return 'SQL Scratch Pad' + } +} + +export const generateCTA = (editor?: SupportedAssistantEntities | null) => { + switch (editor) { + case 'functions': + return 'Save function' + case 'rls-policies': + return 'Save policy' + default: + return 'Run query' + } +} + +export const generatePlaceholder = ( + editor?: SupportedAssistantEntities | null, + entity?: CommonDatabaseEntity, + existingDefinition?: string +) => { + switch (editor) { + case 'functions': + if (entity === undefined) { + return `${PLACEHOLDER_PREFIX} +CREATE FUNCTION *schema*.*function_name*(*param1 type*, *param2 type*)\n +  RETURNS *return_type*\n +  LANGUAGE *plpgsql*\n +  SECURITY DEFINER\n +  SET *search_path = ''*\n +AS $$\n +DECLARE\n +  *-- Variable declarations*\n +BEGIN\n +  *-- Function logic*\n +END;\n +$$; +` + } else { + return `${PLACEHOLDER_PREFIX} +-- To rename the function\n +ALTER FUNCTION *${entity.name}* RENAME TO *new_name*;\n + \n +-- To change the schema of the function\n +ALTER FUNCTION *${entity.name}* SET SCHEMA *new_schema*;\n + \n +-- To update the function body or the arguments, use\n +-- the create or replace statement instead\n +${existingDefinition + ?.replaceAll( + '\n ', + `\n\ +   ` + ) + .replaceAll('\n', '\n\n') + .trim()} +` + } + case 'rls-policies': + return `${PLACEHOLDER_PREFIX} +CREATE POLICY *name* ON *table_name*\n +AS PERMISSIVE -- PERMISSIVE | RESTRICTIVE\n +FOR ALL -- ALL | SELECT | INSERT | UPDATE | DELETE\n +TO *role_name* -- Default: public\n +USING ( *using_expression* )\n +WITH CHECK ( *check_expression* ); +` + default: + return undefined + } +} + +export const retrieveDocsUrl = (editor?: SupportedAssistantEntities | null) => { + switch (editor) { + case 'functions': + return 'https://supabase.com/docs/guides/database/functions' + case 'rls-policies': + return 'https://supabase.com/docs/guides/database/postgres/row-level-security' + default: + return undefined + } +} + +// [Joshen] This is just very basic validation, but possible can extend perhaps +export const validateQuery = (editor: SupportedAssistantEntities | null, query: string) => { + const formattedQuery = query.toLowerCase().replaceAll('\n', ' ') + + switch (editor) { + case 'functions': + return ( + formattedQuery.includes('create function') || + formattedQuery.includes('create or replace function') + ) + case 'rls-policies': + return formattedQuery.includes('create policy') + default: + return true + } +} + +export const generatePrompt = ({ + type, + context, + schemas, + tables, +}: { + type: SupportedAssistantQuickPromptTypes + context: SupportedAssistantEntities + schemas: string[] + tables: { schema: string; name: string }[] +}) => { + if (type === 'examples') { + return `What are some common examples of user-defined database ${context}? ${PLACEHOLDER_LIMIT}` + } else if (type === 'ask') { + return `Could you explain to me what are used-defined database ${context}?` + } else if (type === 'suggest') { + const output = + context === 'functions' + ? 'user-defined database functions' + : context === 'rls-policies' + ? 'RLS policies' + : '' + + const suffix = + context === 'functions' ? 'Let me know for which tables each function will be useful' : '' + + const basePrompt = `Suggest some ${output} that might be useful` + + if (tables.length > 0 && schemas.length > 0) { + return `${basePrompt} for the following tables within this database: ${tables.map((x) => `${x.schema}.${x.name}`)}. ${PLACEHOLDER_LIMIT} ${suffix}`.trim() + } else if (schemas.length > 0) { + return `${basePrompt} for the tables in the following schemas within this database: ${schemas.join(', ')}. ${suffix}`.trim() + } + + return basePrompt + } +} diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistantPanel.tsx b/apps/studio/components/ui/AIAssistantPanel/AIAssistantPanel.tsx new file mode 100644 index 00000000000..937e55bdce5 --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistantPanel.tsx @@ -0,0 +1,461 @@ +import { useQueryClient } from '@tanstack/react-query' +import { uniqBy } from 'lodash' +import { Command, CornerDownLeft, Loader2, X } from 'lucide-react' +import { useRouter } from 'next/router' +import { useCallback, useEffect, useRef, useState } from 'react' +import { toast } from 'sonner' + +import { useChat } from 'ai/react' +import { useParams } from 'common' +import { useIsDatabaseFunctionsAssistantEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' +import { generateThreadMessage } from 'components/interfaces/Auth/Policies/AIPolicyEditorPanel/AIPolicyEditorPanel.utils' +import { MessageWithDebug } from 'components/interfaces/SQLEditor/AiAssistantPanel' +import { sqlAiDisclaimerComment } from 'components/interfaces/SQLEditor/SQLEditor.constants' +import { DiffType, IStandaloneCodeEditor } from 'components/interfaces/SQLEditor/SQLEditor.types' +import { suffixWithLimit } from 'components/interfaces/SQLEditor/SQLEditor.utils' +import Results from 'components/interfaces/SQLEditor/UtilityPanel/Results' +import { useSqlDebugMutation } from 'data/ai/sql-debug-mutation' +import { databasePoliciesKeys } from 'data/database-policies/keys' +import { QueryResponseError, useExecuteSqlMutation } from 'data/sql/execute-sql-mutation' +import { sqlKeys } from 'data/sql/keys' +import { useSendEventMutation } from 'data/telemetry/send-event-mutation' +import { usePrevious } from 'hooks/deprecated' +import { useLocalStorage } from 'hooks/misc/useLocalStorage' +import { useSelectedProject } from 'hooks/misc/useSelectedProject' +import { SqlEditor } from 'icons' +import { + BASE_PATH, + LOCAL_STORAGE_KEYS, + TELEMETRY_ACTIONS, + TELEMETRY_CATEGORIES, + TELEMETRY_LABELS, +} from 'lib/constants' +import { detectOS, uuidv4 } from 'lib/helpers' +import { useAppStateSnapshot } from 'state/app-state' +import { + Button, + cn, + Sheet, + SheetContent, + SheetFooter, + SheetHeader, + Tooltip_Shadcn_, + TooltipContent_Shadcn_, + TooltipTrigger_Shadcn_, +} from 'ui' +import { Admonition } from 'ui-patterns' +import CodeEditor from '../CodeEditor/CodeEditor' +import { AIAssistant } from './AIAssistant' +import { generateCTA, generatePlaceholder, generateTitle, validateQuery } from './AIAssistant.utils' +import { ASSISTANT_SUPPORT_ENTITIES } from './AiAssistant.constants' +import { useEntityDefinitionQuery } from 'data/database/entity-definition-query' + +export const AiAssistantPanel = () => { + const os = detectOS() + const router = useRouter() + const { ref } = useParams() + const project = useSelectedProject() + const queryClient = useQueryClient() + const { aiAssistantPanel, setAiAssistantPanel } = useAppStateSnapshot() + const isEnabled = useIsDatabaseFunctionsAssistantEnabled() + + const { open, editor, content, entity } = aiAssistantPanel + const previousEditor = usePrevious(editor) + + const [isAcknowledged, setIsAcknowledged] = useLocalStorage( + LOCAL_STORAGE_KEYS.SQL_SCRATCH_PAD_BANNER_ACKNOWLEDGED, + false + ) + + const [chatId, setChatId] = useState(() => uuidv4()) + const [error, setError] = useState() + const [results, setResults] = useState(undefined) + const [showResults, setShowResults] = useState(false) + const [showWarning, setShowWarning] = useState(false) + const [debugThread, setDebugThread] = useState([]) + + const { data: existingDefinition } = useEntityDefinitionQuery({ + id: entity?.id, + type: editor, + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + + // [Joshen] JFYI I'm opting to just have the assistant always show and not toggle-able + // I don't really see any negatives of keeping it open (or benefits from hiding) tbh + // const [showAssistant, setShowAssistant] = useState(true) + const title = generateTitle(editor, entity) + const placeholder = generatePlaceholder(editor, entity, existingDefinition) + const ctaText = generateCTA(editor) + const editorRef = useRef() + const editorModel = editorRef.current?.getModel() + + const numResults = (results ?? []).length + const [errorHeader, ...errorContent] = + (error?.formattedError?.split('\n') ?? [])?.filter((x: string) => x.length > 0) ?? [] + const entityContext = ASSISTANT_SUPPORT_ENTITIES.find((x) => x.id === editor) + + const { mutate: sendEvent } = useSendEventMutation() + const sendTelemetryEvent = (action: string) => { + sendEvent({ + action, + category: TELEMETRY_CATEGORIES.AI_ASSISTANT, + label: TELEMETRY_LABELS.QUICK_SQL_EDITOR, + }) + } + + const { mutate: executeSql, isLoading: isExecuting } = useExecuteSqlMutation({ + onSuccess: async (res) => { + // [Joshen] If in a specific editor context mode, assume that intent was to create/update + // a database entity - so close it once success. Otherwise it's in Quick SQL mode and we + // show the results. Currently though it assumes we're "creating", thinking need to support "updating" too + if (editor !== null) { + switch (editor) { + case 'functions': + await queryClient.invalidateQueries(sqlKeys.query(ref, ['functions-list'])) + break + case 'rls-policies': + await queryClient.invalidateQueries(databasePoliciesKeys.list(ref)) + break + } + + toast.success(`Successfully created ${entityContext?.name}!`) + setAiAssistantPanel({ open: false }) + } else { + setShowResults(true) + setResults(res.result) + } + }, + onError: (error) => { + setError(error) + setResults([]) + }, + }) + + const { append } = useChat({ + id: chatId, + api: `${BASE_PATH}/api/ai/sql/generate-v2`, + // [Joshen] Don't need entity definitions if calling here cause support action + // via AI here is just to explain code segment, no need for entity definitions context + body: {}, + }) + + const { mutateAsync: debugSql } = useSqlDebugMutation({ onError: () => {} }) + + const onExecuteSql = (skipValidation = false) => { + setError(undefined) + setShowWarning(false) + + const query = editorRef.current?.getValue() ?? '' + if (query.length === 0) return + + if (editor !== undefined && !skipValidation) { + // [Joshen] Some basic validation logic + const validated = validateQuery(editor, query) + if (!validated) { + return setShowWarning(true) + } + } + + executeSql({ + sql: suffixWithLimit(query, 100), + projectRef: project?.ref, + connectionString: project?.connectionString, + handleError: (error) => { + throw error + }, + }) + } + + const onFixWithAssistant = async () => { + if (!error) return + + const messageId = uuidv4() + const query = editorRef.current?.getValue() ?? '' + + const assistantMessageBefore = generateThreadMessage({ + id: messageId, + content: 'Thinking...', + isDebug: true, + }) + + setDebugThread([...debugThread, assistantMessageBefore]) + sendTelemetryEvent(TELEMETRY_ACTIONS.FIX_WITH_ASSISTANT) + + try { + const { solution, sql } = await debugSql({ + sql: query, + errorMessage: error.message, + }) + + const assistantMessageAfter = generateThreadMessage({ + id: messageId, + content: `${solution}\n\`\`\`sql\n${sql}\n\`\`\``, + isDebug: true, + }) + const cleanedMessages = uniqBy([...debugThread, assistantMessageAfter], (m) => m.id) + setDebugThread(cleanedMessages) + } catch (error) { + const assistantMessageAfter = generateThreadMessage({ + id: messageId, + content: `Hmm, Sorry but I'm unable to find a solution for the error that you're facing`, + isDebug: true, + }) + const cleanedMessages = uniqBy([...debugThread, assistantMessageAfter], (m) => m.id) + setDebugThread(cleanedMessages) + } + } + + const onExplainSql = (value: string) => { + append({ + role: 'user', + createdAt: new Date(), + content: ` + Can you explain this section to me in more detail?\n + ${value} + `.trim(), + }) + sendTelemetryEvent(TELEMETRY_ACTIONS.EXPLAIN_CODE) + } + + const updateEditorWithCheckForDiff = useCallback( + ({ id, diffType, sql }: { id: string; diffType: DiffType; sql: string }) => { + if (!editorModel) return + + const existingValue = editorRef.current?.getValue() ?? '' + if (existingValue.length === 0) { + // if the editor is empty, just copy over the code + editorRef.current?.executeEdits('apply-ai-message', [ + { + text: `${sqlAiDisclaimerComment}\n\n${sql}`, + range: editorModel.getFullModelRange(), + }, + ]) + } else { + const currentSql = editorRef.current?.getValue() + editorRef.current?.executeEdits('apply-ai-message', [ + { + text: `${currentSql}\n\n${sqlAiDisclaimerComment}\n\n${sql}`, + range: editorModel.getFullModelRange(), + }, + ]) + } + }, + [editorModel] + ) + + const onTogglePanel = () => { + if (open) { + // [Joshen] Save the code content when closing - should allow users to continue from + // where they left off IF they are opening the assistant again in the same "editor" context + const existingValue = editorRef.current?.getValue() ?? '' + setAiAssistantPanel({ open: false, content: existingValue }) + } else { + setAiAssistantPanel({ open: true, editor: null, entity: undefined }) + } + } + + useEffect(() => { + if (open) { + // [Joshen] Only reset the assistant if the editor changed + if (previousEditor !== editor) { + setChatId(uuidv4()) + setError(undefined) + setShowWarning(false) + setDebugThread([]) + setResults(undefined) + setShowResults(false) + } else { + // [Joshen] Repopulate the code editor with the content from where the user left off + // setTimeout is just to give time for the editorRef to get associated + setTimeout(() => { + const editorModel = editorRef.current?.getModel() + if (content && editorModel) { + editorRef.current?.executeEdits('apply-ai-message', [ + { text: content, range: editorModel.getFullModelRange() }, + ]) + } + }, 100) + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]) + + // [Joshen] Just to test the concept of a universal assistant of sorts + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.metaKey && e.code === 'KeyI') onTogglePanel() + } + if (project !== undefined && isEnabled) window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [project, isEnabled, open]) + + // [Joshen] Whenever the deps change recalculate the height of the editor + useEffect(() => { + editorRef.current?.layout({ width: 0, height: 0 }) + window.requestAnimationFrame(() => editorRef.current?.layout()) + }, [error, showWarning, isExecuting, numResults, showResults]) + + return ( + onTogglePanel()}> + + {/* Assistant */} + setChatId(uuidv4())} + /> + + {/* Editor */} +
    + + {title} + + + + + } + /> + )} + {showWarning && ( + +

    Are you sure you want to run this query?

    + + + } + /> + )} + {results !== undefined && results.length > 0 && ( + <> +
    + +
    +
    +

    + {results.length} rows + {results.length >= 100 && ` (Limited to only 100 rows)`} +

    + +
    + + )} + {results !== undefined && results.length === 0 && ( +
    +

    Success. No rows returned.

    +
    + )} + + + + +
    +
    +
    + + + ) +} diff --git a/apps/studio/components/ui/AIAssistantPanel/AiAssistant.constants.ts b/apps/studio/components/ui/AIAssistantPanel/AiAssistant.constants.ts new file mode 100644 index 00000000000..e9bc5bdeb55 --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/AiAssistant.constants.ts @@ -0,0 +1,10 @@ +import { SupportedAssistantEntities } from './AIAssistant.types' + +export const ASSISTANT_SUPPORT_ENTITIES: { + id: SupportedAssistantEntities + label: string + name: string +}[] = [ + { id: 'rls-policies', label: 'RLS Policies', name: 'RLS policy' }, + { id: 'functions', label: 'Functions', name: 'database function' }, +] diff --git a/apps/studio/components/ui/AIAssistantPanel/ContextBadge.tsx b/apps/studio/components/ui/AIAssistantPanel/ContextBadge.tsx new file mode 100644 index 00000000000..ba205b176e0 --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/ContextBadge.tsx @@ -0,0 +1,33 @@ +import { X } from 'lucide-react' +import { ReactNode } from 'react' +import { Tooltip_Shadcn_, TooltipContent_Shadcn_, TooltipTrigger_Shadcn_ } from 'ui' + +interface ContextBadgeProps { + label: string + value: string + tooltip?: ReactNode + onRemove?: () => void +} + +export const ContextBadge = ({ label, value, tooltip, onRemove }: ContextBadgeProps) => { + return ( + + +
    +
    + {label} + {value} +
    + {onRemove !== undefined && ( + onRemove()} + /> + )} +
    +
    + {tooltip !== undefined && {tooltip}} +
    + ) +} diff --git a/apps/studio/components/ui/AIAssistantPanel/DropdownScrollArea.tsx b/apps/studio/components/ui/AIAssistantPanel/DropdownScrollArea.tsx new file mode 100644 index 00000000000..1bbe09c0190 --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/DropdownScrollArea.tsx @@ -0,0 +1,36 @@ +import { PropsWithChildren, useEffect } from 'react' +import { ScrollArea } from 'ui' + +interface DropdownScrollAreaProps { + id?: string + className?: string +} + +// [Joshen] This is a temporary solution to a really odd behavior i've observed with the components in Shadcn +// Specifically if we're using a ScrollArea (or even just anything with overflow) within a DropdownMenu, within a Sheet +// The mouse wheel event to scroll in the ScrollArea is somehow getting ignored in this specific context, hence the fix here + +export const DropdownScrollArea = ({ + id, + className, + children, +}: PropsWithChildren) => { + useEffect(() => { + const scrollFix = (e: WheelEvent) => e.stopPropagation() + + if (id) { + const el = document.getElementById(id) + if (el) { + el.addEventListener('wheel', scrollFix, { passive: false }) + return () => el.removeEventListener('wheel', scrollFix) + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return ( + + {children} + + ) +} diff --git a/apps/studio/components/ui/AIAssistantPanel/EntitiesDropdownMenu.tsx b/apps/studio/components/ui/AIAssistantPanel/EntitiesDropdownMenu.tsx new file mode 100644 index 00000000000..d301fb5814e --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/EntitiesDropdownMenu.tsx @@ -0,0 +1,110 @@ +import { useEntityTypesQuery } from 'data/entity-types/entity-types-infinite-query' +import { useSelectedProject } from 'hooks/misc/useSelectedProject' +import { debounce } from 'lodash' +import { Check, Loader2, Search } from 'lucide-react' +import { useCallback, useState } from 'react' +import { + Command_Shadcn_, + CommandEmpty_Shadcn_, + CommandGroup_Shadcn_, + CommandItem_Shadcn_, + CommandList_Shadcn_, + ScrollArea, +} from 'ui' +import { Input } from 'ui-patterns/DataInputs/Input' +import { DropdownScrollArea } from './DropdownScrollArea' +import { Admonition } from 'ui-patterns' + +interface EntitiesDropdownMenuProps { + selectedSchemas: string[] + selectedEntities: { schema: string; name: string }[] + onToggleEntity: (value: { schema: string; name: string }) => void +} + +export const EntitiesDropdownMenu = ({ + selectedSchemas, + selectedEntities, + onToggleEntity, +}: EntitiesDropdownMenuProps) => { + const project = useSelectedProject() + const [search, setSearch] = useState('') + + // [Joshen] Not gonna do infinite loading here, but rather just try to load sufficient tables + // I reckon users will opt to just search if there's many tables here given the small real estate + const { + data: entitiesData, + error, + isLoading, + isFetching, + isError, + } = useEntityTypesQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + schemas: selectedSchemas, + limit: 100, + search: search.length === 0 ? undefined : search, + }) + const entities = ( + entitiesData?.pages[0].data.entities ? [...entitiesData?.pages[0].data.entities] : [] + ).sort((a, b) => (a.name > b.name ? 0 : -1)) + + // eslint-disable-next-line react-hooks/exhaustive-deps + const searchInputHandler = useCallback(debounce(setSearch, 1000), []) + + if (isError) { + return ( + + ) + } + + return ( + + + {/* Separate input for async searching */} + } + className="text-xs outline-none rounded-b-none border-0 border-b pl-9 pr-7" + onChange={(e) => searchInputHandler(e.target.value)} + actions={ + (isLoading || isFetching) && ( + + ) + } + /> + No tables found + + 7 ? 'h-[210px]' : ''} + > + {entities?.map((entity) => ( + onToggleEntity({ schema: entity.schema, name: entity.name })} + onClick={() => onToggleEntity({ schema: entity.schema, name: entity.name })} + > + + {entity.schema} + {entity.name} + + {selectedEntities.find( + (x) => x.name === entity.name && x.schema === entity.schema + ) && } + + ))} + + + + + ) +} diff --git a/apps/studio/components/ui/AIAssistantPanel/Message.tsx b/apps/studio/components/ui/AIAssistantPanel/Message.tsx new file mode 100644 index 00000000000..3d20ff3c6c6 --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/Message.tsx @@ -0,0 +1,146 @@ +import dayjs from 'dayjs' +import { noop } from 'lodash' +import Image from 'next/image' +import { PropsWithChildren, memo, useMemo } from 'react' +import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import { AiIconAnimation, Badge, cn, markdownComponents, WarningIcon } from 'ui' + +import { DiffType } from 'components/interfaces/SQLEditor/SQLEditor.types' +import { useProfile } from 'lib/profile' +import { MessagePre } from './MessagePre' + +interface MessageProps { + name?: string + role: 'function' | 'system' | 'user' | 'assistant' | 'data' | 'tool' + content?: string + createdAt?: number + isDebug?: boolean + isSelected?: boolean + action?: React.ReactNode + context?: { entity: string; schemas: string[]; tables: string[] } + onDiff?: (type: DiffType, s: string) => void + variant?: 'default' | 'warning' +} + +export const Message = memo(function Message({ + name, + role, + content, + createdAt, + isDebug, + isSelected = false, + context, + children, + action = null, + variant = 'default', + onDiff = noop, +}: PropsWithChildren) { + const { profile } = useProfile() + const isUser = role === 'user' + + const icon = useMemo(() => { + return role === 'assistant' ? ( + + ) : ( +
    + +
    + ) + }, [content, profile?.username, role]) + + const formattedContext = + context !== undefined + ? Object.entries(context) + .filter(([_, value]) => value.length > 0) + .map(([key, value]) => { + return `${key.charAt(0).toUpperCase() + key.slice(1)}: ${Array.isArray(value) ? value.join(', ') : value}` + }) + .join(' • ') + : undefined + + if (!content) return null + + return ( +
    +
    +
    + {variant === 'warning' ? : icon} + +
    +
    + {!isUser ? 'Assistant' : name ? name : 'You'} + {createdAt && ( + + {dayjs(createdAt).fromNow()} + + )} +
    + {role === 'user' && context !== undefined && ( + {formattedContext} + )} +
    + + {isDebug && Debug request} +
    {' '} + {action} +
    + + { + return ( + div>pre]:max-w-full', + isSelected ? '[&>div>pre]:!border-stronger [&>div>pre]:!bg-surface-200' : '' + )} + > + {props.children[0].props.children} + + ) + }, + ol: (props: any) => { + return
      {props.children}
    + }, + li: (props: any) => { + return
  • {props.children}
  • + }, + h3: (props: any) => { + return

    {props.children}

    + }, + code: (props: any) => { + return {props.children} + }, + }} + > + {content} +
    + {children} +
    + ) +}) diff --git a/apps/studio/components/ui/AIAssistantPanel/MessagePre.tsx b/apps/studio/components/ui/AIAssistantPanel/MessagePre.tsx new file mode 100644 index 00000000000..414b5fc0917 --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/MessagePre.tsx @@ -0,0 +1,113 @@ +import { InsertCode, ReplaceCode } from 'icons' +import { Check, Copy } from 'lucide-react' +import { useEffect, useState } from 'react' +import { format } from 'sql-formatter' +import { + Button, + CodeBlock, + TooltipContent_Shadcn_, + TooltipTrigger_Shadcn_, + Tooltip_Shadcn_, + cn, +} from 'ui' + +import { useSendEventMutation } from 'data/telemetry/send-event-mutation' +import { DiffType } from 'components/interfaces/SQLEditor/SQLEditor.types' + +interface MessagePreProps { + onDiff: (type: DiffType, s: string) => void + children: string[] + className?: string +} + +export const MessagePre = ({ onDiff, children, className }: MessagePreProps) => { + const [copied, setCopied] = useState(false) + + const { mutate: sendEvent } = useSendEventMutation() + + useEffect(() => { + if (!copied) return + const timer = setTimeout(() => setCopied(false), 2000) + return () => clearTimeout(timer) + }, [copied]) + + let formatted = (children || [''])[0] + try { + formatted = format(formatted, { language: 'postgresql', keywordCase: 'upper' }) + } catch {} + + if (formatted.length === 0) { + return null + } + + function handleCopy(formatted: string) { + navigator.clipboard.writeText(formatted).then() + setCopied(true) + } + + return ( +
    +      code]:m-0 [&>code>span]:flex [&>code>span]:flex-wrap [&>code]:block'
    +        )}
    +        hideCopy
    +        hideLineNumbers
    +      />
    +      
    + + + + + + Insert code + + + + + + + + + Copy code + + +
    +
    + ) +} diff --git a/apps/studio/components/ui/AIAssistantPanel/SchemasDropdownMenu.tsx b/apps/studio/components/ui/AIAssistantPanel/SchemasDropdownMenu.tsx new file mode 100644 index 00000000000..6db2472077c --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/SchemasDropdownMenu.tsx @@ -0,0 +1,61 @@ +import { + cn, + Command_Shadcn_, + CommandEmpty_Shadcn_, + CommandGroup_Shadcn_, + CommandInput_Shadcn_, + CommandItem_Shadcn_, + CommandList_Shadcn_, +} from 'ui' +import { DropdownScrollArea } from './DropdownScrollArea' +import { Check } from 'lucide-react' +import { useSchemasQuery } from 'data/database/schemas-query' +import { useSelectedProject } from 'hooks/misc/useSelectedProject' + +interface SchemasDropdownMenuProps { + selectedSchemas: string[] + onToggleSchema: (schema: string) => void +} + +export const SchemasDropdownMenu = ({ + selectedSchemas, + onToggleSchema, +}: SchemasDropdownMenuProps) => { + const project = useSelectedProject() + + const { data: schemaDatas } = useSchemasQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + const schemas = (schemaDatas || []).sort((a, b) => a.name.localeCompare(b.name)) + + return ( + + + + No schemas found + + 7 ? 'h-[210px]' : '')} + > + {schemas?.map((schema) => ( + onToggleSchema(schema.name)} + onClick={() => onToggleSchema(schema.name)} + > + {schema.name} + {selectedSchemas.includes(schema.name) && ( + + )} + + ))} + + + + + ) +} diff --git a/apps/studio/components/ui/ButtonTooltip.tsx b/apps/studio/components/ui/ButtonTooltip.tsx index f1ff45d08e8..23372130b80 100644 --- a/apps/studio/components/ui/ButtonTooltip.tsx +++ b/apps/studio/components/ui/ButtonTooltip.tsx @@ -18,7 +18,7 @@ export const ButtonTooltip = forwardRef< {props.children} - {props.disabled && props.tooltip.content.text !== undefined && ( + {props.tooltip.content.text !== undefined && ( {props.tooltip.content.text} diff --git a/apps/studio/components/ui/Charts/AreaChart.tsx b/apps/studio/components/ui/Charts/AreaChart.tsx index 933999a0cf1..c5820d5b7e9 100644 --- a/apps/studio/components/ui/Charts/AreaChart.tsx +++ b/apps/studio/components/ui/Charts/AreaChart.tsx @@ -48,7 +48,7 @@ const AreaChart = ({ if (data.length === 0) { return ( void } +const DEFAULT_ACTIONS = { + runQuery: { enabled: false, callback: noop }, + explainCode: { enabled: false, callback: noop }, + formatDocument: { enabled: true, callback: noop }, + placeholderFill: { enabled: true }, + closeAssistant: { enabled: false, callback: noop }, +} interface CodeEditorProps { id: string @@ -15,15 +27,22 @@ interface CodeEditorProps { autofocus?: boolean defaultValue?: string isReadOnly?: boolean - onInputChange?: (value?: string) => void - onInputRun?: (value: string) => void hideLineNumbers?: boolean className?: string loading?: boolean options?: EditorProps['options'] value?: string placeholder?: string - disableTabToUsePlaceholder?: boolean + /* Determines what actions to add for code editor context menu */ + actions?: Partial<{ + runQuery: CodeEditorActions + formatDocument: CodeEditorActions + placeholderFill: Omit + explainCode: CodeEditorActions + closeAssistant: CodeEditorActions + }> + editorRef?: MutableRefObject + onInputChange?: (value?: string) => void } const CodeEditor = ({ @@ -33,83 +52,31 @@ const CodeEditor = ({ autofocus = true, isReadOnly = false, hideLineNumbers = false, - onInputChange = noop, - onInputRun = noop, className, loading, options, value, placeholder, - disableTabToUsePlaceholder = false, + actions = DEFAULT_ACTIONS, + editorRef: editorRefProps, + onInputChange = noop, }: CodeEditorProps) => { - const hasValue = useRef() + const monaco = useMonaco() + const project = useSelectedProject() - const editorRef = useRef() + const hasValue = useRef() + const ref = useRef() + const editorRef = editorRefProps || ref const monacoRef = useRef() + const { runQuery, placeholderFill, formatDocument, explainCode, closeAssistant } = { + ...DEFAULT_ACTIONS, + ...actions, + } + const showPlaceholderDefault = placeholder !== undefined && (value ?? '').trim().length === 0 const [showPlaceholder, setShowPlaceholder] = useState(showPlaceholderDefault) - const onMount: OnMount = async (editor, monaco) => { - editorRef.current = editor - monacoRef.current = monaco - alignEditor(editor) - - hasValue.current = editor.createContextKey('hasValue', false) - hasValue.current.set(value !== undefined && value.trim().length > 0) - setShowPlaceholder(showPlaceholderDefault) - - if (!disableTabToUsePlaceholder) { - editor.addCommand( - monaco.KeyCode.Tab, - () => { - editor.executeEdits('source', [ - { - // @ts-ignore - identifier: 'add-placeholder', - range: new monaco.Range(1, 1, 1, 1), - text: (placeholder ?? '') - .split('\n\n') - .join('\n') - .replaceAll('*', '') - .replaceAll(' ', ''), - }, - ]) - }, - '!hasValue' - ) - } - - editor.addAction({ - id: 'supabase', - label: 'Run Query', - keybindings: [monaco.KeyMod.CtrlCmd + monaco.KeyCode.Enter], - contextMenuGroupId: 'operation', - contextMenuOrder: 0, - run: async () => { - const selectedValue = (editorRef?.current as any) - .getModel() - .getValueInRange((editorRef?.current as any)?.getSelection()) - onInputRun(selectedValue || (editorRef?.current as any)?.getValue()) - }, - }) - - const model = editor.getModel() - if (model) { - const position = model.getPositionAt((value ?? '').length) - editor.setPosition(position) - } - - await timeout(500) - if (autofocus) editor?.focus() - } - - const onChangeContent: OnChange = (value) => { - hasValue.current.set((value ?? '').length > 0) - setShowPlaceholder(!value) - onInputChange(value) - } - const optionsMerged = merge( { tabSize: 2, @@ -128,13 +95,114 @@ const CodeEditor = ({ options ) + const formatPgsql = async (value: string) => { + try { + if (!project) throw new Error('No project') + const formatted = await formatQuery({ + projectRef: project.ref, + connectionString: project.connectionString, + sql: value, + }) + return formatted.result + } catch (error) { + console.error('formatPgsql error:', error) + return value + } + } + + const onMount: OnMount = async (editor, monaco) => { + editorRef.current = editor + monacoRef.current = monaco + alignEditor(editor) + + hasValue.current = editor.createContextKey('hasValue', false) + hasValue.current.set(value !== undefined && value.trim().length > 0) + setShowPlaceholder(showPlaceholderDefault) + + if (placeholderFill.enabled) { + editor.addCommand( + monaco.KeyCode.Tab, + () => { + editor.executeEdits('source', [ + { + // @ts-ignore + identifier: 'add-placeholder', + range: new monaco.Range(1, 1, 1, 1), + text: (placeholder ?? '') + .split('\n\n') + .join('\n') + .replaceAll('*', '') + .replaceAll(' ', ' '), + }, + ]) + }, + '!hasValue' + ) + } + + if (runQuery.enabled) { + editor.addAction({ + id: 'run-query', + label: 'Run Query', + keybindings: [monaco.KeyMod.CtrlCmd + monaco.KeyCode.Enter], + contextMenuGroupId: 'operation', + contextMenuOrder: 0, + run: () => { + const selectedValue = (editorRef?.current as any) + .getModel() + .getValueInRange((editorRef?.current as any)?.getSelection()) + runQuery.callback(selectedValue || (editorRef?.current as any)?.getValue()) + }, + }) + } + + if (explainCode.enabled) { + editor.addAction({ + id: 'explain-code', + label: 'Explain Code', + contextMenuGroupId: 'operation', + contextMenuOrder: 1, + run: () => { + const selectedValue = (editorRef?.current as any) + .getModel() + .getValueInRange((editorRef?.current as any)?.getSelection()) + explainCode.callback(selectedValue) + }, + }) + } + + if (closeAssistant.enabled) { + editor.addAction({ + id: 'close-assistant', + label: 'Close Assistant', + keybindings: [monaco.KeyMod.CtrlCmd + monaco.KeyCode.KeyI], + run: () => closeAssistant.callback(), + }) + } + + const model = editor.getModel() + if (model) { + const position = model.getPositionAt((value ?? '').length) + editor.setPosition(position) + } + + await timeout(500) + if (autofocus) editor?.focus() + } + + const onChangeContent: OnChange = (value) => { + hasValue.current.set((value ?? '').length > 0) + setShowPlaceholder(!value) + onInputChange(value) + } + useEffect(() => { setShowPlaceholder(showPlaceholderDefault) }, [showPlaceholderDefault]) useEffect(() => { if ( - !disableTabToUsePlaceholder && + placeholderFill.enabled && editorRef.current !== undefined && monacoRef.current !== undefined ) { @@ -160,7 +228,22 @@ const CodeEditor = ({ '!hasValue' ) } - }, [placeholder, disableTabToUsePlaceholder]) + }, [placeholder, placeholderFill.enabled]) + + useEffect(() => { + if (monaco && project && formatDocument.enabled) { + const formatProvider = monaco.languages.registerDocumentFormattingEditProvider('pgsql', { + async provideDocumentFormattingEdits(model: any) { + const value = model.getValue() + const formatted = await formatPgsql(value) + formatDocument.callback(formatted) + return [{ range: model.getFullModelRange(), text: formatted }] + }, + }) + return () => formatProvider.dispose() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [monaco, project, formatDocument.enabled]) return ( <> diff --git a/apps/studio/components/ui/FunctionSelector.tsx b/apps/studio/components/ui/FunctionSelector.tsx index 136f2f3b42f..eb39782dc2b 100644 --- a/apps/studio/components/ui/FunctionSelector.tsx +++ b/apps/studio/components/ui/FunctionSelector.tsx @@ -112,7 +112,7 @@ const FunctionSelector = ({
    ) : (
    -

    Choose a function...

    +

    Select a function

    )} @@ -124,7 +124,7 @@ const FunctionSelector = ({ No functions found 7 ? 'h-[210px]' : ''}> - {!(functions || []).length && ( + {!functions.length && ( )} - {functions?.map((func) => ( + {functions.map((func) => ( ) => { const consentToastId = useRef() const previousPathname = usePrevious(router.pathname) - const enablePostHogTelemetry = useFlag('enablePosthogChanges') - const consentFlag = enablePostHogTelemetry - ? LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT_PH - : LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT + const consentFlag = LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT + const consent = typeof window !== 'undefined' ? localStorage.getItem(consentFlag) : null - const trackTelemetryPH = enablePostHogTelemetry && consent === 'true' + const trackTelemetryPH = consent === 'true' const { mutate: sendPage } = useSendPageMutation() const { mutateAsync: sendPageLeave } = useSendPageLeaveMutation() @@ -48,27 +45,22 @@ const PageTelemetry = ({ children }: PropsWithChildren<{}>) => { const { mutate: sendGroupsReset } = useSendGroupsResetMutation() useEffect(() => { - if (typeof window !== 'undefined' && enablePostHogTelemetry !== undefined) { + if (typeof window !== 'undefined') { const onAcceptConsent = () => { - snap.setIsOptedInTelemetry(true, consentFlag) + snap.setIsOptedInTelemetry(true) if (consentToastId.current) toast.dismiss(consentToastId.current) } const onOptOut = () => { - snap.setIsOptedInTelemetry(false, consentFlag) + snap.setIsOptedInTelemetry(false) if (consentToastId.current) toast.dismiss(consentToastId.current) } - const hasAcknowledgedConsent = localStorage.getItem( - enablePostHogTelemetry - ? LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT_PH - : LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT - ) + const hasAcknowledgedConsent = localStorage.getItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT) snap.setIsOptedInTelemetry( typeof hasAcknowledgedConsent === 'string' ? hasAcknowledgedConsent === 'true' - : hasAcknowledgedConsent, - consentFlag + : hasAcknowledgedConsent ) if (IS_PLATFORM && hasAcknowledgedConsent === null) { @@ -86,7 +78,7 @@ const PageTelemetry = ({ children }: PropsWithChildren<{}>) => { } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [enablePostHogTelemetry]) + }, []) useEffect(() => { function handleRouteChange() { @@ -163,7 +155,7 @@ const PageTelemetry = ({ children }: PropsWithChildren<{}>) => { useEffect(() => { const handleBeforeUnload = async () => { - if (enablePostHogTelemetry && snap.isOptedInTelemetry) await sendPageLeave() + if (snap.isOptedInTelemetry) await sendPageLeave() } window.addEventListener('beforeunload', handleBeforeUnload) diff --git a/apps/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx b/apps/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx index 60df961219c..9cf249a7ca3 100644 --- a/apps/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx +++ b/apps/studio/components/ui/ProjectSettings/DisplayApiSettings.tsx @@ -1,14 +1,13 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { JwtSecretUpdateStatus } from '@supabase/shared-types/out/events' -import { Button, Input } from 'ui' +import { AlertCircle, BookOpen, Loader2 } from 'lucide-react' -import { useParams } from 'common/hooks' +import { useParams } from 'common' import Panel from 'components/ui/Panel' import { useJwtSecretUpdatingStatusQuery } from 'data/config/jwt-secret-updating-status-query' -import { useProjectSettingsQuery } from 'data/config/project-settings-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { DEFAULT_PROJECT_API_SERVICE_ID } from 'lib/constants' -import { AlertCircle, BookOpen, Loader2 } from 'lucide-react' +import { Button, Input } from 'ui' const DisplayApiSettings = ({ legacy }: { legacy?: boolean }) => { const { ref: projectRef } = useParams() @@ -17,7 +16,7 @@ const DisplayApiSettings = ({ legacy }: { legacy?: boolean }) => { data: settings, isError: isProjectSettingsError, isLoading: isProjectSettingsLoading, - } = useProjectSettingsQuery({ projectRef }) + } = useProjectSettingsV2Query({ projectRef }) const { data, isError: isJwtSecretUpdateStatusError, @@ -29,11 +28,7 @@ const DisplayApiSettings = ({ legacy }: { legacy?: boolean }) => { const isNotUpdatingJwtSecret = jwtSecretUpdateStatus === undefined || jwtSecretUpdateStatus === JwtSecretUpdateStatus.Updated - // Get the API service - const apiService = (settings?.services ?? []).find( - (x: any) => x.app.id == DEFAULT_PROJECT_API_SERVICE_ID - ) - const apiKeys = apiService?.service_api_keys ?? [] + const apiKeys = settings?.service_api_keys ?? [] // api keys should not be empty. However it can be populated with a delay on project creation const isApiKeysEmpty = apiKeys.length === 0 diff --git a/apps/studio/components/ui/ProjectSettings/DisplayConfigSettings.tsx b/apps/studio/components/ui/ProjectSettings/DisplayConfigSettings.tsx index 55ca8c52fce..cb9929f26f1 100644 --- a/apps/studio/components/ui/ProjectSettings/DisplayConfigSettings.tsx +++ b/apps/studio/components/ui/ProjectSettings/DisplayConfigSettings.tsx @@ -1,13 +1,13 @@ import { JwtSecretUpdateStatus } from '@supabase/shared-types/out/events' -import { useParams } from 'common' +import { AlertCircle, Loader } from 'lucide-react' import { PropsWithChildren } from 'react' -import { Input } from 'ui' +import { useParams } from 'common' import Panel from 'components/ui/Panel' import { useJwtSecretUpdatingStatusQuery } from 'data/config/jwt-secret-updating-status-query' -import { useProjectSettingsQuery } from 'data/config/project-settings-query' -import { DEFAULT_PROJECT_API_SERVICE_ID } from 'lib/constants' -import { AlertCircle, Loader } from 'lucide-react' +import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-config-query' +import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' +import { Input } from 'ui' const DisplayConfigSettings = () => { const { ref: projectRef } = useParams() @@ -15,9 +15,10 @@ const DisplayConfigSettings = () => { data: settings, isLoading: isProjectSettingsLoading, isError: isProjectSettingsError, - } = useProjectSettingsQuery({ + } = useProjectSettingsV2Query({ projectRef, }) + const { data: config, isError: isPostgrestError } = useProjectPostgrestConfigQuery({ projectRef }) const { data, @@ -25,29 +26,24 @@ const DisplayConfigSettings = () => { isLoading: isJwtSecretUpdateStatusLoading, } = useJwtSecretUpdatingStatusQuery({ projectRef }) const jwtSecretUpdateStatus = data?.jwtSecretUpdateStatus - const isNotUpdatingJwtSecret = jwtSecretUpdateStatus === undefined || jwtSecretUpdateStatus === JwtSecretUpdateStatus.Updated - // Get the API service - const jwtSecret = settings?.project?.jwt_secret ?? '' - const apiService = (settings?.services ?? []).find( - (x: any) => x.app.id == DEFAULT_PROJECT_API_SERVICE_ID - ) - const apiConfig = apiService?.app_config - const apiUrl = `${apiConfig?.protocol ?? 'https'}://${apiConfig?.endpoint ?? '-'}` + + const jwtSecret = config?.jwt_secret ?? '' + const apiUrl = `https://${settings?.app_config?.endpoint ?? '-'}` return ( - {isProjectSettingsError || isJwtSecretUpdateStatusError ? ( + {isProjectSettingsError || isPostgrestError || isJwtSecretUpdateStatusError ? (

    - {isProjectSettingsError + {isProjectSettingsError || isPostgrestError ? 'Failed to retrieve configuration' : 'Failed to update JWT secret'}

    - ) : isProjectSettingsLoading || isJwtSecretUpdateStatusLoading ? ( + ) : isProjectSettingsLoading || isPostgrestError || isJwtSecretUpdateStatusLoading ? (

    diff --git a/apps/studio/components/ui/SchemaComboBox.tsx b/apps/studio/components/ui/SchemaComboBox.tsx index dc93562dc9c..f4810f6df7c 100644 --- a/apps/studio/components/ui/SchemaComboBox.tsx +++ b/apps/studio/components/ui/SchemaComboBox.tsx @@ -19,6 +19,7 @@ import { Popover_Shadcn_, ScrollArea, } from 'ui' +import { useSelectedProject } from 'hooks/misc/useSelectedProject' interface SchemaComboBoxProps { className?: string @@ -44,7 +45,7 @@ export const SchemaComboBox = ({ }: SchemaComboBoxProps) => { const [open, setOpen] = useState(false) - const { project } = useProjectContext() + const project = useSelectedProject() const { data, isLoading: isSchemasLoading, diff --git a/apps/studio/components/ui/ui.types.ts b/apps/studio/components/ui/ui.types.ts index 8ede34b7f18..2aee180817c 100644 --- a/apps/studio/components/ui/ui.types.ts +++ b/apps/studio/components/ui/ui.types.ts @@ -6,4 +6,5 @@ export interface Route { icon: ReactNode link?: string disabled?: boolean + linkComponent?: ReactNode } diff --git a/apps/studio/data/analytics/org-daily-stats-query.ts b/apps/studio/data/analytics/org-daily-stats-query.ts index 4f03e565f34..f6c0ca56ed7 100644 --- a/apps/studio/data/analytics/org-daily-stats-query.ts +++ b/apps/studio/data/analytics/org-daily-stats-query.ts @@ -40,6 +40,8 @@ export enum PricingMetric { DISK_THROUGHPUT_GP3 = 'DISK_THROUGHPUT_GP3', LOG_DRAIN = 'LOG_DRAIN', LOG_DRAIN_EVENTS = 'LOG_DRAIN_EVENTS', + AUTH_MFA_PHONE = 'AUTH_MFA_PHONE', + AUTH_MFA_WEB_AUTHN = 'AUTH_MFA_WEB_AUTHN', } export enum ComputeUsageMetric { diff --git a/apps/studio/data/analytics/warehouse-collection-log-detail-query.ts b/apps/studio/data/analytics/warehouse-collection-log-detail-query.ts new file mode 100644 index 00000000000..4d8beefd712 --- /dev/null +++ b/apps/studio/data/analytics/warehouse-collection-log-detail-query.ts @@ -0,0 +1,19 @@ +import { useWarehouseQueryQuery } from './warehouse-query' + +export function useWarehouseLogDetailQuery({ + ref, + collectionName, + logId, + columns = ['id', 'timestamp'], +}: { + ref: string + collectionName?: string + logId?: string + columns?: string[] +}) { + const enabled = !!logId && !!collectionName + const sql = `select ${columns.join(',')} from \`${collectionName}\` + where id = '${logId}' and timestamp > '2024-01-01' limit 1` + + return useWarehouseQueryQuery({ ref, sql }, { enabled }) +} diff --git a/apps/studio/data/config/keys.ts b/apps/studio/data/config/keys.ts index 6f4b61da217..a2aab7748f1 100644 --- a/apps/studio/data/config/keys.ts +++ b/apps/studio/data/config/keys.ts @@ -2,6 +2,7 @@ export const configKeys = { pgBouncerStatus: (projectRef: string | undefined) => ['projects', projectRef, 'pgbouncer'] as const, settings: (projectRef: string | undefined) => ['projects', projectRef, 'settings'] as const, + settingsV2: (projectRef: string | undefined) => ['projects', projectRef, 'settings-v2'] as const, api: (projectRef: string | undefined) => ['projects', projectRef, 'settings', 'api'] as const, postgrest: (projectRef: string | undefined) => ['projects', projectRef, 'postgrest'] as const, jwtSecretUpdatingStatus: (projectRef: string | undefined) => diff --git a/apps/studio/data/config/project-api-query.ts b/apps/studio/data/config/project-api-query.ts deleted file mode 100644 index 8baaee61d0d..00000000000 --- a/apps/studio/data/config/project-api-query.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { useQuery, UseQueryOptions } from '@tanstack/react-query' - -import { components } from 'api-types' -import { get, handleError } from 'data/fetchers' -import { useProjectByRef } from 'hooks/misc/useSelectedProject' -import { PROJECT_STATUS } from 'lib/constants' -import type { ResponseError } from 'types' -import { configKeys } from './keys' - -export type ProjectApiVariables = { - projectRef?: string -} - -export type AutoApiService = components['schemas']['AutoApiService'] - -export type ProjectApiResponse = { - autoApiService: AutoApiService -} - -export async function getProjectApi({ projectRef }: ProjectApiVariables, signal?: AbortSignal) { - if (!projectRef) throw new Error('projectRef is required') - - const { data, error } = await get('/platform/props/project/{ref}/api', { - params: { path: { ref: projectRef } }, - signal, - }) - - if (error) handleError(error) - return data -} - -export type ProjectApiData = Awaited> -export type ProjectApiError = ResponseError - -export const useProjectApiQuery = ( - { projectRef }: ProjectApiVariables, - { enabled = true, ...options }: UseQueryOptions = {} -) => { - return useQuery( - configKeys.api(projectRef), - ({ signal }) => getProjectApi({ projectRef }, signal), - { - enabled: enabled && typeof projectRef !== 'undefined', - refetchInterval(data) { - if (!data) { - return false - } - - const { autoApiService } = data as unknown as ProjectApiData - - const apiKeys = autoApiService?.service_api_keys ?? [] - const interval = apiKeys.length === 0 ? 2000 : 0 - - return interval - }, - ...options, - } - ) -} diff --git a/apps/studio/data/config/project-settings-query.ts b/apps/studio/data/config/project-settings-query.ts deleted file mode 100644 index 96a3a82385d..00000000000 --- a/apps/studio/data/config/project-settings-query.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { useQuery, UseQueryOptions } from '@tanstack/react-query' - -import type { components } from 'data/api' -import { get, handleError } from 'data/fetchers' -import { DEFAULT_PROJECT_API_SERVICE_ID } from 'lib/constants' -import type { ResponseError } from 'types' -import { configKeys } from './keys' - -export type ProjectSettingsVariables = { - projectRef?: string -} - -// [Joshen] API typing needs to be fixed - this is completely wrong -type ProjectSettingsInfo = components['schemas']['ProjectResponse'] & { db_port: number } -interface ProjectSettingsInfoExtended extends ProjectSettingsInfo { - // [Joshen] Based on the enums here - // https://github.com/supabase/infrastructure/blob/95dc09fe077dba7817bb112fa72b6814a620ecd3/shared/src/projects.ts#L177 - db_ip_addr_config: 'legacy' | 'static-ipv4' | 'concurrent-ipv6' | 'ipv6' -} - -export type ProjectSettings = { - project: ProjectSettingsInfoExtended - services: components['schemas']['SettingsResponse']['services'] -} - -export async function getProjectSettings( - { projectRef }: ProjectSettingsVariables, - signal?: AbortSignal -) { - if (!projectRef) throw new Error('projectRef is required') - - // [Joshen] API typing is wrong here - const { data, error } = await get('/platform/props/project/{ref}/settings', { - params: { path: { ref: projectRef } }, - signal, - }) - - if (error) handleError(error) - return data as unknown as ProjectSettings -} - -export type ProjectSettingsData = Awaited> -export type ProjectSettingsError = ResponseError - -export const useProjectSettingsQuery = ( - { projectRef }: ProjectSettingsVariables, - { - enabled = true, - ...options - }: UseQueryOptions = {} -) => - useQuery( - configKeys.settings(projectRef), - ({ signal }) => getProjectSettings({ projectRef }, signal), - { - enabled: enabled && typeof projectRef !== 'undefined', - refetchInterval(data) { - const apiService = ((data as ProjectSettings)?.services ?? []).find( - (x) => x.app.id == DEFAULT_PROJECT_API_SERVICE_ID - ) - const apiKeys = apiService?.service_api_keys ?? [] - const interval = apiKeys.length === 0 ? 2000 : 0 - - return interval - }, - ...options, - } - ) diff --git a/apps/studio/data/config/project-settings-v2-query.ts b/apps/studio/data/config/project-settings-v2-query.ts new file mode 100644 index 00000000000..129d756c0bd --- /dev/null +++ b/apps/studio/data/config/project-settings-v2-query.ts @@ -0,0 +1,62 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useQuery, UseQueryOptions } from '@tanstack/react-query' + +import type { components } from 'data/api' +import { get, handleError } from 'data/fetchers' +import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import type { ResponseError } from 'types' +import { configKeys } from './keys' + +export type ProjectSettingsVariables = { projectRef?: string } +export type ProjectSettings = components['schemas']['ProjectSettingsResponse'] + +export async function getProjectSettings( + { projectRef }: ProjectSettingsVariables, + signal?: AbortSignal +) { + if (!projectRef) throw new Error('projectRef is required') + + const { data, error } = await get('/platform/projects/{ref}/settings', { + params: { path: { ref: projectRef } }, + signal, + }) + + if (error) handleError(error) + return data as unknown as ProjectSettings +} + +type ProjectSettingsData = Awaited> +type ProjectSettingsError = ResponseError + +export const useProjectSettingsV2Query = ( + { projectRef }: ProjectSettingsVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => { + // [Joshen] Sync with API perms checking here - shouldReturnApiKeys + // https://github.com/supabase/infrastructure/blob/develop/api/src/routes/platform/projects/ref/settings.controller.ts#L92 + const canReadAPIKeys = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, '*') + + return useQuery( + configKeys.settingsV2(projectRef), + ({ signal }) => getProjectSettings({ projectRef }, signal), + { + enabled: enabled && typeof projectRef !== 'undefined', + refetchInterval(data) { + const apiKeys = (data as ProjectSettings)?.service_api_keys ?? [] + const interval = canReadAPIKeys && apiKeys.length === 0 ? 2000 : 0 + return interval + }, + ...options, + } + ) +} + +export const getAPIKeys = (settings?: ProjectSettings) => { + const anonKey = (settings?.service_api_keys ?? []).find((x) => x.tags === 'anon') + const serviceKey = (settings?.service_api_keys ?? []).find((x) => x.tags === 'service_role') + + return { anonKey, serviceKey } +} diff --git a/apps/studio/data/custom-domains/check-cname-mutation.ts b/apps/studio/data/custom-domains/check-cname-mutation.ts index d86bc749afe..0ef8604ae32 100644 --- a/apps/studio/data/custom-domains/check-cname-mutation.ts +++ b/apps/studio/data/custom-domains/check-cname-mutation.ts @@ -21,7 +21,7 @@ export type CheckCNAMERecordResponse = { // [Joshen] Should tally with https://github.com/supabase/cli/blob/63790a1bd43bee06f82c4f510e709925526a4daa/internal/utils/api.go#L98 export async function checkCNAMERecord({ domain }: CheckCNAMERecordVariables) { - const res = await fetch(`https://1.1.1.1/dns-query?name=${domain}&type=CNAME`, { + const res = await fetch(`https://one.one.one.one/dns-query?name=${domain}&type=CNAME`, { method: 'GET', headers: { accept: 'application/dns-json' }, }) diff --git a/apps/studio/data/database/entity-definition-query.ts b/apps/studio/data/database/entity-definition-query.ts new file mode 100644 index 00000000000..ddc44aa59fd --- /dev/null +++ b/apps/studio/data/database/entity-definition-query.ts @@ -0,0 +1,61 @@ +import { UseQueryOptions } from '@tanstack/react-query' +import { ExecuteSqlData, ExecuteSqlError, useExecuteSqlQuery } from '../sql/execute-sql-query' +import { databaseKeys } from './keys' +import { SupportedAssistantEntities } from 'components/ui/AIAssistantPanel/AIAssistant.types' + +// [Joshen] Eventually should support table definition and view definition as well if possible +export const getEntityDefinitionQuery = ({ + id, + type, +}: { + id?: number + type?: SupportedAssistantEntities | 'table' | 'view' | null +}) => { + if (!id || !type) return '' + + switch (type) { + case 'functions': + return /* SQL */ ` + select pg_get_functiondef(${id}) + `.trim() + case 'rls-policies': + // [Joshen] Eventually to-do, unless we have to piece it manually? + return /* SQL */ ` + select 1; + `.trim() + } + + return '' +} + +export type EntityDefinitionVariables = { + id?: number + type?: SupportedAssistantEntities | null + projectRef?: string + connectionString?: string +} + +// type EntityDefinition = { id: number; sql: string } +export type EntityDefinitionData = string +export type EntityDefinitionError = ExecuteSqlError + +export const useEntityDefinitionQuery = ( + { id, type, projectRef, connectionString }: EntityDefinitionVariables, + { enabled = true, ...options }: UseQueryOptions = {} +) => { + return useExecuteSqlQuery( + { + projectRef, + connectionString, + sql: getEntityDefinitionQuery({ id, type }), + queryKey: databaseKeys.entityDefinition(projectRef, id), + }, + { + select(data) { + return data.result[0].pg_get_functiondef + }, + enabled: enabled && typeof id !== 'undefined' && typeof type !== 'undefined', + ...options, + } + ) +} diff --git a/apps/studio/data/database/entity-definitions-query.ts b/apps/studio/data/database/entity-definitions-query.ts index 0e820869fcf..4506ea58ec9 100644 --- a/apps/studio/data/database/entity-definitions-query.ts +++ b/apps/studio/data/database/entity-definitions-query.ts @@ -3,7 +3,13 @@ import { ExecuteSqlData, ExecuteSqlError, useExecuteSqlQuery } from '../sql/exec import { CREATE_PG_GET_TABLEDEF_SQL } from './database-query-constants' import { databaseKeys } from './keys' -export const getEntityDefinitionsQuery = (schemas: string[], limit = 100) => { +export const getEntityDefinitionsQuery = ({ + schemas, + limit = 100, +}: { + schemas: string[] + limit?: number +}) => { const sql = /* SQL */ ` ${CREATE_PG_GET_TABLEDEF_SQL} @@ -26,13 +32,7 @@ with records as ( 'create materialized view ', concat(nc.nspname, '.', c.relname), ' as', pg_get_viewdef(concat(nc.nspname, '.', c.relname), true) ) - when 'f' then pg_temp.pg_get_tabledef( - concat(nc.nspname), - concat(c.relname), - false, - 'FKEYS_INTERNAL', - 'NO_TRIGGERS' - ) + when 'f' then concat('create foreign table ', nc.nspname, '.', c.relname, ' ( ... )') when 'p' then pg_temp.pg_get_tabledef( concat(nc.nspname), concat(c.relname), @@ -99,7 +99,7 @@ export const useEntityDefinitionsQuery = < { projectRef, connectionString, - sql: getEntityDefinitionsQuery(schemas, limit), + sql: getEntityDefinitionsQuery({ schemas, limit }), queryKey: databaseKeys.entityDefinitions(projectRef, schemas), }, { diff --git a/apps/studio/data/database/foreign-key-constraints-query.ts b/apps/studio/data/database/foreign-key-constraints-query.ts index 3c348b11ec2..7c34c7894fd 100644 --- a/apps/studio/data/database/foreign-key-constraints-query.ts +++ b/apps/studio/data/database/foreign-key-constraints-query.ts @@ -1,5 +1,12 @@ -import { UseQueryOptions } from '@tanstack/react-query' -import { ExecuteSqlData, ExecuteSqlError, useExecuteSqlQuery } from '../sql/execute-sql-query' +import { QueryClient, UseQueryOptions } from '@tanstack/react-query' + +import { sqlKeys } from 'data/sql/keys' +import { + executeSql, + ExecuteSqlData, + ExecuteSqlError, + useExecuteSqlQuery, +} from '../sql/execute-sql-query' type GetForeignKeyConstraintsVariables = { schema?: string @@ -96,6 +103,22 @@ export type ForeignKeyConstraintsVariables = GetForeignKeyConstraintsVariables & export type ForeignKeyConstraintsData = ForeignKeyConstraint[] export type ForeignKeyConstraintsError = ExecuteSqlError +function select( + data: + | { + result: any + } + | undefined +) { + return (data?.result ?? []).map((foreignKey: ForeignKeyConstraintRaw) => { + return { + ...foreignKey, + source_columns: foreignKey.source_columns.replace('{', '').replace('}', '').split(','), + target_columns: foreignKey.target_columns.replace('{', '').replace('}', '').split(','), + } + }) +} + export const useForeignKeyConstraintsQuery = < TData extends ForeignKeyConstraintsData = ForeignKeyConstraintsData, >( @@ -107,19 +130,30 @@ export const useForeignKeyConstraintsQuery = < projectRef, connectionString, sql: getForeignKeyConstraintsQuery({ schema }), - queryKey: ['foreign-key-constraints'], + queryKey: ['foreign-key-constraints', schema], }, { - select(data) { - return ((data as any)?.result ?? []).map((foreignKey: ForeignKeyConstraintRaw) => { - return { - ...foreignKey, - source_columns: foreignKey.source_columns.replace('{', '').replace('}', '').split(','), - target_columns: foreignKey.target_columns.replace('{', '').replace('}', '').split(','), - } - }) - }, + select, ...options, } ) } + +export function prefetchForeignKeyConstraints( + client: QueryClient, + { projectRef, connectionString, schema }: ForeignKeyConstraintsVariables +) { + return client + .fetchQuery(sqlKeys.query(projectRef, ['foreign-key-constraints', schema]), ({ signal }) => + executeSql( + { + projectRef, + connectionString, + sql: getForeignKeyConstraintsQuery({ schema }), + queryKey: ['foreign-key-constraints', schema], + }, + signal + ) + ) + .then((data) => select(data) as ForeignKeyConstraintsData) +} diff --git a/apps/studio/data/database/keys.ts b/apps/studio/data/database/keys.ts index 2f43f0492aa..c3a98169d40 100644 --- a/apps/studio/data/database/keys.ts +++ b/apps/studio/data/database/keys.ts @@ -1,4 +1,8 @@ +import { SupportedAssistantEntities } from 'components/ui/AIAssistantPanel/AIAssistant.types' + export const databaseKeys = { + entityDefinition: (projectRef: string | undefined, id?: number) => + ['projects', projectRef, 'entity-definition', id].filter(Boolean), entityDefinitions: (projectRef: string | undefined, schemas: string[]) => ['projects', projectRef, 'entity-definitions', schemas] as const, backups: (projectRef: string | undefined) => [projectRef, 'database', 'backups'] as const, diff --git a/apps/studio/data/database/retrieve-index-from-select-query.ts b/apps/studio/data/database/retrieve-index-from-select-query.ts index 06d80dddebc..8ef31cde994 100644 --- a/apps/studio/data/database/retrieve-index-from-select-query.ts +++ b/apps/studio/data/database/retrieve-index-from-select-query.ts @@ -112,6 +112,7 @@ export const useGetIndexesFromSelectQuery = ( export function invalidateSchemasQuery(client: QueryClient, projectRef: string | undefined) { return client.invalidateQueries(sqlKeys.query(projectRef, ['schemas', 'list'])) } + +export function prefetchSchemas( + client: QueryClient, + { projectRef, connectionString }: SchemasVariables +) { + return client.fetchQuery(sqlKeys.query(projectRef, ['schemas', 'list']), ({ signal }) => + executeSql( + { + projectRef, + connectionString, + sql: pgMetaSchemasList.sql, + queryKey: ['schemas', 'list'], + }, + signal + ) + ) +} diff --git a/apps/studio/data/database/table-definition-query.ts b/apps/studio/data/database/table-definition-query.ts index 9f29d08c6a7..be3f657168b 100644 --- a/apps/studio/data/database/table-definition-query.ts +++ b/apps/studio/data/database/table-definition-query.ts @@ -7,6 +7,7 @@ type GetTableDefinition = { name?: string } +// [Joshen] Eventually move this into entity-definition-query export const getTableDefinitionQuery = ({ schema = 'public', name }: GetTableDefinition) => { const sql = /* SQL */ ` ${CREATE_PG_GET_TABLEDEF_SQL} diff --git a/apps/studio/data/database/view-definition-query.ts b/apps/studio/data/database/view-definition-query.ts index 6fb09d538fc..fe2a98567aa 100644 --- a/apps/studio/data/database/view-definition-query.ts +++ b/apps/studio/data/database/view-definition-query.ts @@ -6,6 +6,7 @@ type GetViewDefinition = { name?: string } +// [Joshen] Eventually move this into entity-definition-query export const getViewDefinitionQuery = ({ schema, name }: GetViewDefinition) => { const sql = /* SQL */ ` select pg_get_viewdef(to_regclass('"${schema}"."${name}"'), true) as definition diff --git a/apps/studio/data/encrypted-columns/encrypted-columns-query.ts b/apps/studio/data/encrypted-columns/encrypted-columns-query.ts new file mode 100644 index 00000000000..e21e2685631 --- /dev/null +++ b/apps/studio/data/encrypted-columns/encrypted-columns-query.ts @@ -0,0 +1,69 @@ +import { QueryClient, useQuery, UseQueryOptions } from '@tanstack/react-query' + +import { executeSql } from 'data/sql/execute-sql-query' +import type { ResponseError } from 'types' +import { encryptedColumnKeys } from './keys' + +export type EncryptedColumnsVariables = { + projectRef?: string + connectionString?: string + schema?: string + tableName?: string +} + +export async function getEncryptedColumns( + { projectRef, connectionString, schema, tableName }: EncryptedColumnsVariables, + signal?: AbortSignal +) { + if (!tableName) return [] + + try { + const encryptedColumns = await executeSql( + { + projectRef, + connectionString, + sql: `select column_name as name from information_schema.columns where table_schema = '${schema}' and table_name = 'decrypted_${tableName}' and column_name like 'decrypted_%'`, + queryKey: ['encrypted-columns', tableName], + }, + signal + ) + return encryptedColumns.result.map((column: any) => column.name.split('decrypted_')[1]) + } catch (error) { + console.error('Error fetching encrypted columns', error) + return [] + } +} + +export type EncryptedColumnsData = Awaited> +export type EncryptedColumnsError = ResponseError + +export const useEncryptedColumnsQuery = ( + { projectRef, connectionString, schema, tableName }: EncryptedColumnsVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => + useQuery( + encryptedColumnKeys.list(projectRef, schema, tableName), + ({ signal }) => + getEncryptedColumns({ projectRef, connectionString, schema, tableName }, signal), + { enabled: enabled && typeof projectRef !== 'undefined', ...options } + ) + +export function prefetchEncryptedColumns( + client: QueryClient, + { projectRef, connectionString, schema, tableName }: EncryptedColumnsVariables +) { + return client.fetchQuery(encryptedColumnKeys.list(projectRef, schema, tableName), ({ signal }) => + getEncryptedColumns( + { + projectRef, + connectionString, + schema, + tableName, + }, + signal + ) + ) +} diff --git a/apps/studio/data/encrypted-columns/keys.ts b/apps/studio/data/encrypted-columns/keys.ts new file mode 100644 index 00000000000..e73d19d7a53 --- /dev/null +++ b/apps/studio/data/encrypted-columns/keys.ts @@ -0,0 +1,7 @@ +export const encryptedColumnKeys = { + list: ( + projectRef: string | undefined, + schema: string | undefined, + tableName: string | undefined + ) => ['projects', projectRef, 'encrypted-columns', schema, tableName] as const, +} diff --git a/apps/studio/data/entity-types/entity-type-query.ts b/apps/studio/data/entity-types/entity-type-query.ts index c650159ce52..b0e26577073 100644 --- a/apps/studio/data/entity-types/entity-type-query.ts +++ b/apps/studio/data/entity-types/entity-type-query.ts @@ -1,5 +1,12 @@ -import { UseQueryOptions } from '@tanstack/react-query' -import { ExecuteSqlData, ExecuteSqlError, useExecuteSqlQuery } from '../sql/execute-sql-query' +import { QueryClient, UseQueryOptions } from '@tanstack/react-query' + +import { sqlKeys } from 'data/sql/keys' +import { + executeSql, + ExecuteSqlData, + ExecuteSqlError, + useExecuteSqlQuery, +} from '../sql/execute-sql-query' import { ENTITY_TYPE } from './entity-type-constants' type EntityTypeArgs = { @@ -75,3 +82,22 @@ export const useEntityTypeQuery = + executeSql( + { + projectRef, + connectionString, + sql: entityTypeSqlQuery({ id }), + queryKey: ['entity-type', id], + }, + signal + ) + ) + .then((data: { result: EntityTypeData[] }) => data?.result?.[0] ?? null) +} diff --git a/apps/studio/data/entity-types/entity-types-infinite-query.ts b/apps/studio/data/entity-types/entity-types-infinite-query.ts index 470bd56933b..91d1b549bd9 100644 --- a/apps/studio/data/entity-types/entity-types-infinite-query.ts +++ b/apps/studio/data/entity-types/entity-types-infinite-query.ts @@ -1,11 +1,12 @@ -import { useInfiniteQuery, UseInfiniteQueryOptions } from '@tanstack/react-query' +import { QueryClient, useInfiniteQuery, UseInfiniteQueryOptions } from '@tanstack/react-query' import { executeSql, ExecuteSqlVariables } from 'data/sql/execute-sql-query' import type { Entity } from './entity-type-query' import { entityTypeKeys } from './keys' +import { ENTITY_TYPE } from './entity-type-constants' export type EntityTypesVariables = { projectRef?: string - schema?: string + schemas?: string[] search?: string limit?: number page?: number @@ -24,12 +25,12 @@ export async function getEntityTypes( { projectRef, connectionString, - schema = 'public', + schemas = ['public'], search, limit = 100, page = 0, sort = 'alphabetical', - filterTypes, + filterTypes = Object.values(ENTITY_TYPE), }: EntityTypesVariables, signal?: AbortSignal ) { @@ -57,7 +58,7 @@ export async function getEntityTypes( pg_namespace nc join pg_class c on nc.oid = c.relnamespace where - c.relkind in (${filterTypes === undefined ? `'r', 'v', 'm', 'f', 'p'` : filterTypes.map((x) => `'${x}'`).join(', ')}) + c.relkind in (${filterTypes.map((x) => `'${x}'`).join(', ')}) and not pg_is_other_temp_schema(nc.oid) and ( pg_has_role(c.relowner, 'USAGE') @@ -67,7 +68,7 @@ export async function getEntityTypes( ) or has_any_column_privilege(c.oid, 'SELECT, INSERT, UPDATE, REFERENCES') ) - and nc.nspname = '${schema}' + and nc.nspname in (${schemas.map((x) => `'${x}'`)}) ${search ? `and c.relname ilike '%${search}%'` : ''} order by ${innerOrderBy} limit ${limit} @@ -111,7 +112,7 @@ export const useEntityTypesQuery = ( { projectRef, connectionString, - schema = 'public', + schemas = ['public'], search, limit = 100, sort, @@ -123,10 +124,19 @@ export const useEntityTypesQuery = ( }: UseInfiniteQueryOptions = {} ) => useInfiniteQuery( - entityTypeKeys.list(projectRef, { schema, search, sort, limit, filterTypes }), + entityTypeKeys.list(projectRef, { schemas, search, sort, limit, filterTypes }), ({ signal, pageParam }) => getEntityTypes( - { projectRef, connectionString, schema, search, limit, page: pageParam, sort, filterTypes }, + { + projectRef, + connectionString, + schemas, + search, + limit, + page: pageParam, + sort, + filterTypes, + }, signal ), { @@ -146,3 +156,34 @@ export const useEntityTypesQuery = ( ...options, } ) + +export function prefetchEntityTypes( + client: QueryClient, + { + projectRef, + connectionString, + schemas = ['public'], + search, + limit = 100, + sort, + filterTypes, + }: Omit +) { + return client.prefetchInfiniteQuery( + entityTypeKeys.list(projectRef, { schemas, search, sort, limit, filterTypes }), + ({ signal, pageParam }) => + getEntityTypes( + { + projectRef, + connectionString, + schemas, + search, + limit, + page: pageParam, + sort, + filterTypes, + }, + signal + ) + ) +} diff --git a/apps/studio/data/entity-types/keys.ts b/apps/studio/data/entity-types/keys.ts index c04fc7dbd12..7e3505fdffc 100644 --- a/apps/studio/data/entity-types/keys.ts +++ b/apps/studio/data/entity-types/keys.ts @@ -2,7 +2,7 @@ export const entityTypeKeys = { list: ( projectRef: string | undefined, params?: { - schema?: string + schemas?: string[] search?: string sort?: 'alphabetical' | 'grouped-alphabetical' limit?: number diff --git a/apps/studio/data/foreign-tables/foreign-table-query.ts b/apps/studio/data/foreign-tables/foreign-table-query.ts index d6e3af3748f..cc7370d4ff8 100644 --- a/apps/studio/data/foreign-tables/foreign-table-query.ts +++ b/apps/studio/data/foreign-tables/foreign-table-query.ts @@ -1,5 +1,5 @@ import type { PostgresTable } from '@supabase/postgres-meta' -import { useQuery, UseQueryOptions } from '@tanstack/react-query' +import { QueryClient, useQuery, UseQueryOptions } from '@tanstack/react-query' import { get } from 'lib/common/fetch' import { API_URL } from 'lib/constants' import { foreignTableKeys } from './keys' @@ -56,3 +56,12 @@ export const useForeignTableQuery = ( ...options, } ) + +export function prefetchForeignTable( + client: QueryClient, + { projectRef, connectionString, id }: ForeignTableVariables +) { + return client.fetchQuery(foreignTableKeys.foreignTable(projectRef, id), ({ signal }) => + getForeignTable({ id, projectRef, connectionString }, signal) + ) +} diff --git a/apps/studio/data/materialized-views/materialized-view-query.ts b/apps/studio/data/materialized-views/materialized-view-query.ts index 0b714667b9b..1d6e2411e24 100644 --- a/apps/studio/data/materialized-views/materialized-view-query.ts +++ b/apps/studio/data/materialized-views/materialized-view-query.ts @@ -1,5 +1,6 @@ import type { PostgresMaterializedView } from '@supabase/postgres-meta' -import { useQuery, UseQueryOptions } from '@tanstack/react-query' +import { QueryClient, useQuery, UseQueryOptions } from '@tanstack/react-query' + import { get } from 'lib/common/fetch' import { API_URL } from 'lib/constants' import { materializedViewKeys } from './keys' @@ -59,3 +60,12 @@ export const useMaterializedViewQuery = ( ...options, } ) + +export function prefetchMaterializedView( + client: QueryClient, + { projectRef, connectionString, id }: MaterializedViewVariables +) { + return client.fetchQuery(materializedViewKeys.materializedView(projectRef, id), ({ signal }) => + getMaterializedView({ id, projectRef, connectionString }, signal) + ) +} diff --git a/apps/studio/data/misc/signup-mutation.ts b/apps/studio/data/misc/signup-mutation.ts index 84e18d2bb4f..ea410aa06e2 100644 --- a/apps/studio/data/misc/signup-mutation.ts +++ b/apps/studio/data/misc/signup-mutation.ts @@ -10,6 +10,7 @@ const WHITELIST_ERRORS = [ 'Password should contain at least one character of each', 'You attempted to send email to an inactive recipient', 'email must be an email', + 'Password is known to be weak and easy to guess, please choose a different one', ] export type SignUpVariables = { diff --git a/apps/studio/data/prefetchers/project.$ref.editor.$id.tsx b/apps/studio/data/prefetchers/project.$ref.editor.$id.tsx new file mode 100644 index 00000000000..ad4ed80b1fe --- /dev/null +++ b/apps/studio/data/prefetchers/project.$ref.editor.$id.tsx @@ -0,0 +1,182 @@ +import { useQueryClient } from '@tanstack/react-query' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { ComponentProps, PropsWithChildren, useCallback } from 'react' + +import { loadTableEditorSortsAndFiltersFromLocalStorage } from 'components/grid/SupabaseGrid' +import { + formatFilterURLParams, + formatSortURLParams, + getSupaTable, +} from 'components/grid/SupabaseGrid.utils' +import { Filter, Sort } from 'components/grid/types' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { + ForeignKeyConstraintsData, + prefetchForeignKeyConstraints, +} from 'data/database/foreign-key-constraints-query' +import { prefetchEncryptedColumns } from 'data/encrypted-columns/encrypted-columns-query' +import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants' +import { Entity, prefetchEntityType } from 'data/entity-types/entity-type-query' +import { ForeignTableData, prefetchForeignTable } from 'data/foreign-tables/foreign-table-query' +import { + MaterializedViewData, + prefetchMaterializedView, +} from 'data/materialized-views/materialized-view-query' +import { prefetchTableRows } from 'data/table-rows/table-rows-query' +import { prefetchTable, TableData } from 'data/tables/table-query' +import { prefetchView, ViewData } from 'data/views/view-query' +import { useFlag } from 'hooks/ui/useFlag' +import { useRoleImpersonationStateSnapshot } from 'state/role-impersonation-state' +import { TABLE_EDITOR_DEFAULT_ROWS_PER_PAGE } from 'state/table-editor' + +export function usePrefetchEditorTablePage() { + const router = useRouter() + const queryClient = useQueryClient() + const { project } = useProjectContext() + const roleImpersonationState = useRoleImpersonationStateSnapshot() + const tableEditorPrefetchingEnabled = useFlag('tableEditorPrefetching') + + return useCallback( + ({ id: _id, filters, sorts }: { id?: string; filters?: Filter[]; sorts?: Sort[] }) => { + if (!tableEditorPrefetchingEnabled) return + + const id = _id ? Number(_id) : undefined + if (!project || !id || isNaN(id)) return + + // Prefetch the code + router.prefetch(`/project/${project.ref}/editor/${id}`) + + // Prefetch the data + prefetchEntityType(queryClient, { + projectRef: project.ref, + connectionString: project.connectionString, + id, + }) + .then((entity) => { + let promises: [ + Promise, + Promise, + Promise, + ( + | Promise + | undefined + ), + ] = [ + Promise.resolve(entity), + prefetchForeignKeyConstraints(queryClient, { + projectRef: project.ref, + connectionString: project.connectionString, + schema: entity?.schema, + }), + prefetchEncryptedColumns(queryClient, { + projectRef: project.ref, + connectionString: project.connectionString, + schema: entity?.schema, + tableName: entity?.name, + }), + undefined, + ] + + switch (entity?.type) { + case ENTITY_TYPE.TABLE: + case ENTITY_TYPE.PARTITIONED_TABLE: + promises[3] = prefetchTable(queryClient, { + projectRef: project.ref, + connectionString: project.connectionString, + id, + }) + break + + case ENTITY_TYPE.VIEW: + promises[3] = prefetchView(queryClient, { + projectRef: project.ref, + connectionString: project.connectionString, + id, + }) + break + + case ENTITY_TYPE.MATERIALIZED_VIEW: + promises[3] = prefetchMaterializedView(queryClient, { + projectRef: project.ref, + connectionString: project.connectionString, + id, + }) + break + + case ENTITY_TYPE.FOREIGN_TABLE: + promises[3] = prefetchForeignTable(queryClient, { + projectRef: project.ref, + connectionString: project.connectionString, + id, + }) + break + } + + return Promise.all(promises) + }) + .then(([entity, foreignKeyConstraints, encryptedColumns, table]) => { + if (entity && table) { + const supaTable = getSupaTable({ + selectedTable: table, + encryptedColumns, + foreignKeyMeta: foreignKeyConstraints, + entityType: entity.type, + }) + + const { sorts: localSorts = [], filters: localFilters = [] } = + loadTableEditorSortsAndFiltersFromLocalStorage( + project.ref, + entity.name, + entity.schema + ) ?? {} + + prefetchTableRows(queryClient, { + queryKey: [supaTable.schema, supaTable.name], + projectRef: project?.ref, + connectionString: project?.connectionString, + table: supaTable, + sorts: sorts ?? formatSortURLParams(supaTable.name, localSorts), + filters: filters ?? formatFilterURLParams(localFilters), + page: 1, + limit: TABLE_EDITOR_DEFAULT_ROWS_PER_PAGE, + impersonatedRole: roleImpersonationState.role, + }) + } + }) + }, + [project, queryClient, router] + ) +} + +type LinkProps = ComponentProps + +interface EditorTablePageLinkProps extends Omit { + projectRef?: string + id?: string + sorts?: Sort[] + filters?: Filter[] + href?: LinkProps['href'] +} + +export function EditorTablePageLink({ + projectRef, + id, + sorts, + filters, + href, + children, + ...props +}: PropsWithChildren) { + const prefetch = usePrefetchEditorTablePage() + + return ( + prefetch({ id, sorts, filters })} + {...props} + > + {children} + + ) +} diff --git a/apps/studio/data/prefetchers/project.$ref.editor.tsx b/apps/studio/data/prefetchers/project.$ref.editor.tsx new file mode 100644 index 00000000000..e21d6fed797 --- /dev/null +++ b/apps/studio/data/prefetchers/project.$ref.editor.tsx @@ -0,0 +1,64 @@ +import { useQueryClient } from '@tanstack/react-query' +import Link from 'next/link' +import { ComponentProps, PropsWithChildren, useCallback } from 'react' + +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { prefetchSchemas } from 'data/database/schemas-query' +import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants' +import { prefetchEntityTypes } from 'data/entity-types/entity-types-infinite-query' +import { useLocalStorage } from 'hooks/misc/useLocalStorage' +import { useFlag } from 'hooks/ui/useFlag' +import { useRouter } from 'next/router' + +export function usePrefetchEditorIndexPage() { + const router = useRouter() + const queryClient = useQueryClient() + const { project } = useProjectContext() + const tableEditorPrefetchingEnabled = useFlag('tableEditorPrefetching') + + const [entityTypesSort] = useLocalStorage<'alphabetical' | 'grouped-alphabetical'>( + 'table-editor-sort', + 'alphabetical' + ) + + return useCallback(() => { + if (!tableEditorPrefetchingEnabled || !project) return + + // Prefetch code + router.prefetch(`/project/${project.ref}/editor`) + + // Prefetch data + prefetchSchemas(queryClient, { + projectRef: project.ref, + connectionString: project.connectionString, + }) + prefetchEntityTypes(queryClient, { + projectRef: project?.ref, + connectionString: project?.connectionString, + sort: entityTypesSort, + filterTypes: Object.values(ENTITY_TYPE), + }) + }, [entityTypesSort, project, queryClient, router]) +} + +type LinkProps = ComponentProps + +interface EditorIndexPageLinkProps extends Omit { + projectRef?: string + href?: LinkProps['href'] +} + +export function EditorIndexPageLink({ + href, + projectRef, + children, + ...props +}: PropsWithChildren) { + const prefetch = usePrefetchEditorIndexPage() + + return ( + + {children} + + ) +} diff --git a/apps/studio/data/subscriptions/org-subscription-cancel-schedule-mutation.ts b/apps/studio/data/subscriptions/org-subscription-cancel-schedule-mutation.ts deleted file mode 100644 index 161c05277d1..00000000000 --- a/apps/studio/data/subscriptions/org-subscription-cancel-schedule-mutation.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' -import { toast } from 'sonner' - -import { del, handleError } from 'data/fetchers' -import type { ResponseError } from 'types' -import { subscriptionKeys } from './keys' - -export type OrganizationBillingSubscriptionCancelScheduleVariables = { - slug: string -} - -export async function cancelSubscriptionSchedule({ - slug, -}: OrganizationBillingSubscriptionCancelScheduleVariables) { - const { data, error } = await del( - '/platform/organizations/{slug}/billing/subscription/schedule', - { - params: { path: { slug } }, - } - ) - if (error) handleError(error) - return data -} - -export const useOrganizationBillingSubscriptionCancelSchedule = ({ - onSuccess, - onError, - ...options -}: Omit< - UseMutationOptions, - 'mutationFn' -> = {}) => { - const queryClient = useQueryClient() - - return useMutation( - (vars) => cancelSubscriptionSchedule(vars), - { - async onSuccess(data, variables, context) { - await queryClient.invalidateQueries(subscriptionKeys.orgSubscription(variables.slug)) - await onSuccess?.(data, variables, context) - }, - async onError(data, variables, context) { - if (onError === undefined) { - toast.error(`Failed to cancel subscription schedule: ${data.message}`) - } else { - onError(data, variables, context) - } - }, - ...options, - } - ) -} diff --git a/apps/studio/data/table-rows/table-rows-query.ts b/apps/studio/data/table-rows/table-rows-query.ts index dfa8e2b3dc4..39ecb7dce9a 100644 --- a/apps/studio/data/table-rows/table-rows-query.ts +++ b/apps/studio/data/table-rows/table-rows-query.ts @@ -1,4 +1,4 @@ -import type { QueryKey, UseQueryOptions } from '@tanstack/react-query' +import type { QueryClient, QueryKey, UseQueryOptions } from '@tanstack/react-query' import { IS_PLATFORM } from 'common' import { Query } from 'components/grid/query/Query' @@ -7,13 +7,17 @@ import { JSON_TYPES, TEXT_TYPES, } from 'components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.constants' +import { sqlKeys } from 'data/sql/keys' import { KB } from 'lib/constants' import { ImpersonationRole, ROLE_IMPERSONATION_NO_RESULTS, wrapWithRoleImpersonation, } from 'lib/role-impersonation' -import { useIsRoleImpersonationEnabled } from 'state/role-impersonation-state' +import { + isRoleImpersonationEnabled, + useIsRoleImpersonationEnabled, +} from 'state/role-impersonation-state' import { ExecuteSqlData, ExecuteSqlError, @@ -204,6 +208,21 @@ export type TableRowsVariables = GetTableRowsArgs & { export type TableRowsData = TableRows export type TableRowsError = ExecuteSqlError +function getTableRowsQueryKey({ queryKey, table, impersonatedRole, ...args }: TableRowsVariables) { + return [ + ...(queryKey ?? []), + { + table: { + name: table?.name, + schema: table?.schema, + columns: table?.columns.map((c) => c.name), + }, + impersonatedRole, + ...args, + }, + ] +} + export const useTableRowsQuery = ( { projectRef, connectionString, queryKey, table, impersonatedRole, ...args }: TableRowsVariables, options: UseQueryOptions = {} @@ -218,18 +237,7 @@ export const useTableRowsQuery = ( projectRef: projectRef ?? 'ref', role: impersonatedRole, }), - queryKey: [ - ...(queryKey ?? []), - { - table: { - name: table?.name, - schema: table?.schema, - columns: table?.columns.map((c) => c.name), - }, - impersonatedRole, - ...args, - }, - ], + queryKey: getTableRowsQueryKey({ queryKey, table, impersonatedRole, ...args }), isRoleImpersonationEnabled, }, { @@ -247,3 +255,26 @@ export const useTableRowsQuery = ( } ) } + +export function prefetchTableRows( + client: QueryClient, + { projectRef, connectionString, queryKey, table, impersonatedRole, ...args }: TableRowsVariables +) { + return client.fetchQuery( + sqlKeys.query(projectRef, getTableRowsQueryKey({ queryKey, table, impersonatedRole, ...args })), + ({ signal }) => + executeSql( + { + projectRef, + connectionString, + sql: wrapWithRoleImpersonation(getTableRowsSqlQuery({ table, ...args }), { + projectRef: projectRef ?? 'ref', + role: impersonatedRole, + }), + queryKey: getTableRowsQueryKey({ queryKey, table, impersonatedRole, ...args }), + isRoleImpersonationEnabled: isRoleImpersonationEnabled(impersonatedRole), + }, + signal + ) + ) +} diff --git a/apps/studio/data/tables/table-query.ts b/apps/studio/data/tables/table-query.ts index 2e8bea2b5dd..36b5d4195a3 100644 --- a/apps/studio/data/tables/table-query.ts +++ b/apps/studio/data/tables/table-query.ts @@ -1,6 +1,5 @@ import type { PostgresTable } from '@supabase/postgres-meta' -import { useQuery, useQueryClient, UseQueryOptions } from '@tanstack/react-query' -import { useCallback } from 'react' +import { QueryClient, useQuery, UseQueryOptions } from '@tanstack/react-query' import { get, handleError } from 'data/fetchers' import { tableKeys } from './keys' @@ -57,23 +56,11 @@ export const useTableQuery = ( } ) -/** - * useGetTable - * Tries to get a table from the react-query cache, or loads it from the server if it's not cached. - */ -export function useGetTable({ - projectRef, - connectionString, -}: Pick) { - const queryClient = useQueryClient() - - return useCallback( - (id: NonNullable) => { - return queryClient.fetchQuery({ - queryKey: tableKeys.table(projectRef, id), - queryFn: ({ signal }) => getTable({ id, projectRef, connectionString }, signal), - }) - }, - [connectionString, projectRef, queryClient] +export function prefetchTable( + client: QueryClient, + { projectRef, connectionString, id }: TableVariables +) { + return client.fetchQuery(tableKeys.table(projectRef, id), ({ signal }) => + getTable({ id, projectRef, connectionString }, signal) ) } diff --git a/apps/studio/data/telemetry/send-event-mutation.ts b/apps/studio/data/telemetry/send-event-mutation.ts index c2a22289c48..1fe377f8c4e 100644 --- a/apps/studio/data/telemetry/send-event-mutation.ts +++ b/apps/studio/data/telemetry/send-event-mutation.ts @@ -3,13 +3,11 @@ import { components } from 'api-types' import { isBrowser } from 'common' import { handleError, post } from 'data/fetchers' -import { useFlag } from 'hooks/ui/useFlag' import { LOCAL_STORAGE_KEYS } from 'lib/constants' import { useRouter } from 'next/router' import type { ResponseError } from 'types' -type SendEventGA = components['schemas']['TelemetryEventBody'] -type SendEventPH = components['schemas']['TelemetryEventBodyV2'] +type SendEvent = components['schemas']['TelemetryEventBodyV2'] export type SendEventVariables = { action: string @@ -20,18 +18,15 @@ export type SendEventVariables = { type SendEventPayload = any -export async function sendEvent({ - consent, - type, - body, -}: { - consent: boolean - type: 'GA' | 'PH' - body: SendEventPayload -}) { +export async function sendEvent({ body }: { body: SendEventPayload }) { + const consent = + (typeof window !== 'undefined' + ? localStorage.getItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT) + : null) === 'true' + if (!consent) return undefined - const headers = type === 'PH' ? { Version: '2' } : undefined + const headers = { Version: '2' } const { data, error } = await post(`/platform/telemetry/event`, { body, headers }) if (error) handleError(error) return data @@ -48,22 +43,16 @@ export const useSendEventMutation = ({ 'mutationFn' > = {}) => { const router = useRouter() - const usePostHogParameters = useFlag('enablePosthogChanges') - - const consent = - (typeof window !== 'undefined' - ? localStorage.getItem( - usePostHogParameters - ? LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT_PH - : LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT - ) - : null) === 'true' const title = typeof document !== 'undefined' ? document?.title : '' const referrer = typeof document !== 'undefined' ? document?.referrer : '' - const payload = usePostHogParameters - ? ({ + return useMutation( + (vars) => { + const { action, ...otherVars } = vars + + const body: SendEvent = { + action, page_url: window.location.href, page_title: title, pathname: router.pathname, @@ -75,30 +64,11 @@ export const useSendEventMutation = ({ viewport_height: isBrowser ? window.innerHeight : 0, viewport_width: isBrowser ? window.innerWidth : 0, }, - custom_properties: {}, - } as SendEventPH) - : ({ - page_referrer: referrer, - page_title: title, - page_location: router.asPath.split('#')[0], - ga: { - screen_resolution: isBrowser ? `${window.innerWidth}x${window.innerHeight}` : undefined, - language: router?.locale ?? 'en-US', - }, - } as SendEventGA) + // @ts-expect-error - API is returning a wrong type + custom_properties: otherVars, + } - return useMutation( - (vars) => { - const { action, ...otherVars } = vars - const type = usePostHogParameters ? 'PH' : 'GA' - const body = usePostHogParameters - ? ({ - action, - ...(payload as Omit), - custom_properties: otherVars, - } as unknown as SendEventPH) - : ({ ...vars, ...payload } as SendEventGA) - return sendEvent({ consent, type, body }) + return sendEvent({ body }) }, { async onSuccess(data, variables, context) { diff --git a/apps/studio/data/telemetry/send-groups-identify-mutation.ts b/apps/studio/data/telemetry/send-groups-identify-mutation.ts index 04bd2673623..128ecdff8f5 100644 --- a/apps/studio/data/telemetry/send-groups-identify-mutation.ts +++ b/apps/studio/data/telemetry/send-groups-identify-mutation.ts @@ -7,13 +7,12 @@ import type { ResponseError } from 'types' export type SendGroupsIdentifyVariables = components['schemas']['TelemetryGroupsIdentityBody'] -export async function sendGroupsIdentify({ - consent, - body, -}: { - consent: boolean - body: SendGroupsIdentifyVariables -}) { +export async function sendGroupsIdentify({ body }: { body: SendGroupsIdentifyVariables }) { + const consent = + (typeof window !== 'undefined' + ? localStorage.getItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT) + : null) === 'true' + if (!consent) return undefined const { data, error } = await post(`/platform/telemetry/groups/identify`, { @@ -34,13 +33,8 @@ export const useSendGroupsIdentifyMutation = ({ UseMutationOptions, 'mutationFn' > = {}) => { - const consent = - (typeof window !== 'undefined' - ? localStorage.getItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT_PH) - : null) === 'true' - return useMutation( - (vars) => sendGroupsIdentify({ consent, body: vars }), + (vars) => sendGroupsIdentify({ body: vars }), { async onSuccess(data, variables, context) { await onSuccess?.(data, variables, context) diff --git a/apps/studio/data/telemetry/send-groups-reset-mutation.ts b/apps/studio/data/telemetry/send-groups-reset-mutation.ts index 3523235426c..435834fd1e4 100644 --- a/apps/studio/data/telemetry/send-groups-reset-mutation.ts +++ b/apps/studio/data/telemetry/send-groups-reset-mutation.ts @@ -7,13 +7,12 @@ import type { ResponseError } from 'types' export type SendGroupsResetVariables = components['schemas']['TelemetryGroupsResetBody'] -export async function sendGroupsReset({ - consent, - body, -}: { - consent: boolean - body: SendGroupsResetVariables -}) { +export async function sendGroupsReset({ body }: { body: SendGroupsResetVariables }) { + const consent = + (typeof window !== 'undefined' + ? localStorage.getItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT) + : null) === 'true' + if (!consent) return undefined const { data, error } = await post(`/platform/telemetry/groups/reset`, { @@ -34,13 +33,8 @@ export const useSendGroupsResetMutation = ({ UseMutationOptions, 'mutationFn' > = {}) => { - const consent = - (typeof window !== 'undefined' - ? localStorage.getItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT_PH) - : null) === 'true' - return useMutation( - (vars) => sendGroupsReset({ consent, body: vars }), + (vars) => sendGroupsReset({ body: vars }), { async onSuccess(data, variables, context) { await onSuccess?.(data, variables, context) diff --git a/apps/studio/data/telemetry/send-identify-mutation.ts b/apps/studio/data/telemetry/send-identify-mutation.ts index ad63eb75d6f..733f7ac4f60 100644 --- a/apps/studio/data/telemetry/send-identify-mutation.ts +++ b/apps/studio/data/telemetry/send-identify-mutation.ts @@ -1,16 +1,12 @@ import { useMutation, UseMutationOptions } from '@tanstack/react-query' -import { useRouter } from 'next/router' import { components } from 'api-types' -import { isBrowser } from 'common' import { handleError, post } from 'data/fetchers' import { Profile } from 'data/profile/types' -import { useFlag } from 'hooks/ui/useFlag' import { LOCAL_STORAGE_KEYS } from 'lib/constants' import type { ResponseError } from 'types' -type SendIdentifyGA = components['schemas']['TelemetryIdentifyBody'] -type SendIdentifyPH = components['schemas']['TelemetryIdentifyBodyV2'] +type SendIdentify = components['schemas']['TelemetryIdentifyBodyV2'] export type SendIdentifyVariables = { slug?: string @@ -20,18 +16,15 @@ export type SendIdentifyVariables = { type SendIdentifyPayload = any -export async function sendIdentify({ - consent, - type, - body, -}: { - consent: boolean - type: 'GA' | 'PH' - body: SendIdentifyPayload -}) { +export async function sendIdentify({ body }: { body: SendIdentifyPayload }) { + const consent = + (typeof window !== 'undefined' + ? localStorage.getItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT) + : null) === 'true' + if (!consent) return undefined - const headers = type === 'PH' ? { Version: '2' } : undefined + const headers = { Version: '2' } const { data, error } = await post(`/platform/telemetry/identify`, { body, headers }) if (error) handleError(error) return data @@ -47,40 +40,17 @@ export const useSendIdentifyMutation = ({ UseMutationOptions, 'mutationFn' > = {}) => { - const router = useRouter() - const usePostHogParameters = useFlag('enablePosthogChanges') - - const consent = - (typeof window !== 'undefined' - ? localStorage.getItem( - usePostHogParameters - ? LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT_PH - : LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT - ) - : null) === 'true' - - const payload = usePostHogParameters - ? ({} as SendIdentifyPH) - : ({ - ga: { - screen_resolution: isBrowser ? `${window.innerWidth}x${window.innerHeight}` : undefined, - language: router?.locale ?? 'en-US', - }, - } as SendIdentifyGA) - return useMutation( (vars) => { const { user, slug, ref } = vars - const { disabled_features, ...userInfo } = user - const type = usePostHogParameters ? 'PH' : 'GA' - const body = usePostHogParameters - ? ({ - user_id: user.gotrue_id, - organization_slug: slug, - project_ref: ref, - } as SendIdentifyPH) - : ({ user: userInfo, ...payload } as SendIdentifyGA) - return sendIdentify({ consent, type, body }) + + const body: SendIdentify = { + user_id: user.gotrue_id, + organization_slug: slug, + project_ref: ref, + } + + return sendIdentify({ body }) }, { async onSuccess(data, variables, context) { diff --git a/apps/studio/data/telemetry/send-page-leave-mutation.ts b/apps/studio/data/telemetry/send-page-leave-mutation.ts index ad187b4ea07..366931b13bd 100644 --- a/apps/studio/data/telemetry/send-page-leave-mutation.ts +++ b/apps/studio/data/telemetry/send-page-leave-mutation.ts @@ -2,20 +2,18 @@ import { useMutation, UseMutationOptions } from '@tanstack/react-query' import { components } from 'api-types' import { handleError, post } from 'data/fetchers' -import { useFlag } from 'hooks/ui/useFlag' import { LOCAL_STORAGE_KEYS } from 'lib/constants' import { useRouter } from 'next/router' import type { ResponseError } from 'types' type SendPageLeaveBody = components['schemas']['TelemetryPageLeaveBody'] -export async function sendPageLeave({ - consent, - body, -}: { - consent: boolean - body: SendPageLeaveBody -}) { +export async function sendPageLeave({ body }: { body: SendPageLeaveBody }) { + const consent = + (typeof window !== 'undefined' + ? localStorage.getItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT) + : null) === 'true' + if (!consent) return undefined const { data, error } = await post(`/platform/telemetry/page-leave`, { @@ -34,10 +32,6 @@ export const useSendPageLeaveMutation = ({ ...options }: Omit, 'mutationFn'> = {}) => { const router = useRouter() - const consent = - (typeof window !== 'undefined' - ? localStorage.getItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT_PH) - : null) === 'true' const url = typeof window !== 'undefined' ? window.location.href : '' const title = typeof document !== 'undefined' ? document?.title : '' @@ -48,7 +42,7 @@ export const useSendPageLeaveMutation = ({ pathname: router.pathname, } as SendPageLeaveBody - return useMutation((vars) => sendPageLeave({ consent, body }), { + return useMutation((vars) => sendPageLeave({ body }), { async onSuccess(data, variables, context) { await onSuccess?.(data, variables, context) }, diff --git a/apps/studio/data/telemetry/send-page-mutation.ts b/apps/studio/data/telemetry/send-page-mutation.ts index 6d3d7a67499..ad32f1b6336 100644 --- a/apps/studio/data/telemetry/send-page-mutation.ts +++ b/apps/studio/data/telemetry/send-page-mutation.ts @@ -3,13 +3,11 @@ import { useMutation, UseMutationOptions } from '@tanstack/react-query' import { components } from 'api-types' import { isBrowser } from 'common' import { handleError, post } from 'data/fetchers' -import { useFlag } from 'hooks/ui/useFlag' import { LOCAL_STORAGE_KEYS } from 'lib/constants' import { useRouter } from 'next/router' import type { ResponseError } from 'types' -type SendPageGA = components['schemas']['TelemetryPageBody'] -type SendPagePH = components['schemas']['TelemetryPageBodyV2'] +type SendPage = components['schemas']['TelemetryPageBodyV2'] export type SendPageVariables = { url: string @@ -17,18 +15,15 @@ export type SendPageVariables = { type SendPagePayload = any -export async function sendPage({ - consent, - type, - body, -}: { - consent: boolean - type: 'GA' | 'PH' - body: SendPagePayload -}) { +export async function sendPage({ body }: { body: SendPagePayload }) { + const consent = + (typeof window !== 'undefined' + ? localStorage.getItem(LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT) + : null) === 'true' + if (!consent) return undefined - const headers = type === 'PH' ? { Version: '2' } : undefined + const headers = { Version: '2' } const { data, error } = await post(`/platform/telemetry/page`, { body, headers }) if (error) handleError(error) return data @@ -42,22 +37,16 @@ export const useSendPageMutation = ({ ...options }: Omit, 'mutationFn'> = {}) => { const router = useRouter() - const usePostHogParameters = useFlag('enablePosthogChanges') - - const consent = - (typeof window !== 'undefined' - ? localStorage.getItem( - usePostHogParameters - ? LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT_PH - : LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT - ) - : null) === 'true' const title = typeof document !== 'undefined' ? document?.title : '' const referrer = typeof document !== 'undefined' ? document?.referrer : '' - const payload = usePostHogParameters - ? ({ + return useMutation( + (vars) => { + const { url } = vars + const type = 'PH' + const body: SendPage = { + page_url: url, page_title: title, pathname: router.pathname, ph: { @@ -68,24 +57,8 @@ export const useSendPageMutation = ({ viewport_height: isBrowser ? window.innerHeight : 0, viewport_width: isBrowser ? window.innerWidth : 0, }, - } as SendPagePH) - : ({ - title, - referrer, - ga: { - screen_resolution: isBrowser ? `${window.innerWidth}x${window.innerHeight}` : undefined, - language: router?.locale ?? 'en-US', - }, - } as SendPageGA) - - return useMutation( - (vars) => { - const { url } = vars - const type = usePostHogParameters ? 'PH' : 'GA' - const body = usePostHogParameters - ? ({ page_url: url, ...payload } as SendPagePH) - : ({ route: url, ...payload } as SendPageGA) - return sendPage({ consent, type, body }) + } + return sendPage({ body }) }, { async onSuccess(data, variables, context) { diff --git a/apps/studio/data/usage/org-usage-query.ts b/apps/studio/data/usage/org-usage-query.ts index d23cb9ba5b5..07355b037b5 100644 --- a/apps/studio/data/usage/org-usage-query.ts +++ b/apps/studio/data/usage/org-usage-query.ts @@ -43,6 +43,7 @@ export const useOrgUsageQuery = ( ({ signal }) => getOrgUsage({ orgSlug, projectRef, start, end }, signal), { enabled: enabled && typeof orgSlug !== 'undefined', + staleTime: 1000 * 60 * 30, // 30 mins, underlying usage data only refreshes once an hour, so safe to cache for a while ...options, } ) diff --git a/apps/studio/data/views/view-query.ts b/apps/studio/data/views/view-query.ts index ae6d11b6c2d..9fc16cf2299 100644 --- a/apps/studio/data/views/view-query.ts +++ b/apps/studio/data/views/view-query.ts @@ -1,5 +1,5 @@ import type { PostgresView } from '@supabase/postgres-meta' -import { useQuery, UseQueryOptions } from '@tanstack/react-query' +import { QueryClient, useQuery, UseQueryOptions } from '@tanstack/react-query' import { get, handleError } from 'data/fetchers' import type { ResponseError } from 'types' @@ -55,3 +55,12 @@ export const useViewQuery = ( ...options, } ) + +export function prefetchView( + client: QueryClient, + { projectRef, connectionString, id }: ViewVariables +) { + return client.fetchQuery(viewKeys.view(projectRef, id), ({ signal }) => + getView({ id, projectRef, connectionString }, signal) + ) +} diff --git a/apps/studio/hooks/analytics/useLogsPreview.tsx b/apps/studio/hooks/analytics/useLogsPreview.tsx index e3b61acfeb3..cd98e62145f 100644 --- a/apps/studio/hooks/analytics/useLogsPreview.tsx +++ b/apps/studio/hooks/analytics/useLogsPreview.tsx @@ -26,6 +26,7 @@ import { get, isResponseOk } from 'lib/common/fetch' import { API_URL } from 'lib/constants' import { useFillTimeseriesSorted } from './useFillTimeseriesSorted' import useTimeseriesUnixToIso from './useTimeseriesUnixToIso' +import { parseAsString, useQueryStates } from 'nuqs' interface LogsPreviewHook { logData: LogData[] @@ -60,20 +61,15 @@ function useLogsPreview({ const [filters, setFilters] = useState({ ...filterOverride }) const isFirstRender = useRef(true) - const [params, setParams] = useState({ - project: projectRef, - sql: genDefaultQuery(table, filters, limit), - iso_timestamp_start: defaultHelper.calcFrom(), - iso_timestamp_end: defaultHelper.calcTo(), + const [queryParams, setQueryParams] = useQueryStates({ + project: parseAsString.withDefault(projectRef), + iso_timestamp_start: parseAsString.withDefault(defaultHelper.calcFrom()), + iso_timestamp_end: parseAsString.withDefault(defaultHelper.calcTo()), }) - useEffect(() => { - if (isFirstRender.current) { - isFirstRender.current = false - return - } - refresh() - }, [JSON.stringify(filters)]) + const [sql, setSQL] = useState(genDefaultQuery(table, filters, limit)) + + const params: LogsEndpointParams = { ...queryParams, sql } const { data, @@ -108,6 +104,16 @@ function useLogsPreview({ } ) + useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false + return + } + const newSql = genDefaultQuery(table, filters, limit) + setSQL(newSql) + refresh(newSql) + }, [JSON.stringify(filters)]) + // memoize all this calculations stuff const { logData, error, oldestTimestamp } = useMemo(() => { let logData: LogData[] = [] @@ -176,9 +182,10 @@ function useLogsPreview({ { refetchOnWindowFocus: false } ) - const refresh = async () => { - const generatedSql = genDefaultQuery(table, filters, limit) - setParams((prev) => ({ ...prev, sql: generatedSql })) + const refresh = async (newSql?: string) => { + const generatedSql = newSql || genDefaultQuery(table, filters, limit) + setSQL(generatedSql) + setQueryParams((prev) => ({ ...prev, sql: generatedSql })) setLatestRefresh(new Date().toISOString()) refreshEventChart() refetch() @@ -224,7 +231,7 @@ function useLogsPreview({ setFilters: handleSetFilters, refresh, loadOlder: () => fetchNextPage(), - setParams, + setParams: setQueryParams, } } export default useLogsPreview diff --git a/apps/studio/hooks/analytics/useSelectedLog.ts b/apps/studio/hooks/analytics/useSelectedLog.ts new file mode 100644 index 00000000000..5b52ce62d38 --- /dev/null +++ b/apps/studio/hooks/analytics/useSelectedLog.ts @@ -0,0 +1,6 @@ +import { useQueryState } from 'nuqs' + +// Used in logs and warehouse. +export function useSelectedLog() { + return useQueryState('log') +} diff --git a/apps/studio/hooks/analytics/useSingleLog.tsx b/apps/studio/hooks/analytics/useSingleLog.tsx index 45b7961bfec..e4e293a93c7 100644 --- a/apps/studio/hooks/analytics/useSingleLog.tsx +++ b/apps/studio/hooks/analytics/useSingleLog.tsx @@ -11,19 +11,27 @@ import { get } from 'lib/common/fetch' import { API_URL } from 'lib/constants' interface SingleLogHook { - logData: LogData | undefined + data: LogData | undefined error: string | Object | null isLoading: boolean refresh: () => void } -function useSingleLog( - projectRef: string, - queryType?: QueryType, - paramsToMerge?: Partial, - id?: string | null -): SingleLogHook { + +type SingleLogParams = { + id?: string + projectRef: string + queryType?: QueryType + paramsToMerge?: Partial +} +function useSingleLog({ + projectRef, + id, + queryType, + paramsToMerge, +}: SingleLogParams): SingleLogHook { const table = queryType ? LOGS_TABLES[queryType] : undefined const sql = id && table ? genSingleLogQuery(table, id) : '' + const params: LogsEndpointParams = { ...paramsToMerge, project: projectRef, sql } const endpointUrl = `${API_URL}/projects/${projectRef}/analytics/endpoints/logs.all?${genQueryParams( @@ -41,7 +49,7 @@ function useSingleLog( isRefetching, refetch, } = useQuery( - ['projects', projectRef, 'log', id], + ['projects', projectRef, 'single-log', id, queryType], ({ signal }) => get(endpointUrl, { signal }) as Promise, { enabled, @@ -52,8 +60,9 @@ function useSingleLog( ) let error: null | string | object = rcError ? (rcError as any).message : null + const result = data?.result ? data.result[0] : undefined return { - logData: data?.result ? data.result[0] : undefined, + data: result, isLoading: (enabled && isLoading) || isRefetching, error, refresh: () => refetch(), diff --git a/apps/studio/hooks/misc/useTable.ts b/apps/studio/hooks/misc/useTable.ts index 17bf4042be1..891d25fe9df 100644 --- a/apps/studio/hooks/misc/useTable.ts +++ b/apps/studio/hooks/misc/useTable.ts @@ -25,18 +25,7 @@ function useTable(id?: number) { id: entity?.id, }, { - enabled: entity?.type === ENTITY_TYPE.TABLE, - } - ) - - const partitionTableResult = useTableQuery( - { - projectRef: project?.ref, - connectionString: project?.connectionString, - id: entity?.id, - }, - { - enabled: entity?.type === ENTITY_TYPE.PARTITIONED_TABLE, + enabled: entity?.type === ENTITY_TYPE.TABLE || entity?.type === ENTITY_TYPE.PARTITIONED_TABLE, } ) @@ -75,9 +64,8 @@ function useTable(id?: number) { switch (entity?.type) { case ENTITY_TYPE.TABLE: - return tableResult case ENTITY_TYPE.PARTITIONED_TABLE: - return partitionTableResult + return tableResult case ENTITY_TYPE.VIEW: return viewResult case ENTITY_TYPE.MATERIALIZED_VIEW: diff --git a/apps/studio/lib/constants/index.ts b/apps/studio/lib/constants/index.ts index da484ef951d..15a3cb2af51 100644 --- a/apps/studio/lib/constants/index.ts +++ b/apps/studio/lib/constants/index.ts @@ -29,15 +29,16 @@ export const USAGE_APPROACHING_THRESHOLD = 0.75 export const LOCAL_STORAGE_KEYS = { RECENTLY_VISITED_ORGANIZATION: 'supabase-organization', - TELEMETRY_CONSENT: 'supabase-consent', - // Used for when we enable PH for telemetry instead of GA - TELEMETRY_CONSENT_PH: 'supabase-consent-ph', + TELEMETRY_CONSENT: 'supabase-consent-ph', UI_PREVIEW_NAVIGATION_LAYOUT: 'supabase-ui-preview-nav-layout', UI_PREVIEW_API_SIDE_PANEL: 'supabase-ui-api-side-panel', UI_PREVIEW_CLS: 'supabase-ui-cls', + UI_PREVIEW_FUNCTIONS_ASSISTANT: 'supabase-ui-functions-assistant', UI_ONBOARDING_NEW_PAGE_SHOWN: 'supabase-ui-onboarding-new-page-shown', + SQL_SCRATCH_PAD_BANNER_ACKNOWLEDGED: 'supabase-sql-scratch-pad-banner-acknowledged', + DASHBOARD_HISTORY: (ref: string) => `dashboard-history-${ref}`, SQL_EDITOR_INTELLISENSE: 'supabase_sql-editor-intellisense-enabled', @@ -83,3 +84,22 @@ export const OPT_IN_TAGS = { export const GB = 1024 * 1024 * 1024 export const MB = 1024 * 1024 export const KB = 1024 + +// [Joshen] Just adding these to start consolidating our telemetry configs +// may change depending on how we choose to standardize across all apps +export enum TELEMETRY_CATEGORIES { + AI_ASSISTANT = 'ai-assistant', +} + +export enum TELEMETRY_LABELS { + QUICK_SQL_EDITOR = 'quick-sql-editor', +} + +export const TELEMETRY_ACTIONS = { + PROMPT_SUBMITTED: 'prompt-submitted', + QUICK_PROMPT_SELECTED: (type: string) => `quick-prompt-selected-${type}`, + SCHEMA_CONTEXT_ADDED: 'schema-context-added', + TABLE_CONTEXT_ADDED: 'table-context-added', + FIX_WITH_ASSISTANT: 'fix-with-assistant', + EXPLAIN_CODE: 'explain-code', +} diff --git a/apps/studio/next.config.js b/apps/studio/next.config.js index 5b25632526d..55a6ff08d0a 100644 --- a/apps/studio/next.config.js +++ b/apps/studio/next.config.js @@ -41,7 +41,7 @@ const STRIPE_SUBDOMAINS_URL = 'https://*.stripe.com' const STRIPE_JS_URL = 'https://js.stripe.com' const STRIPE_NETWORK_URL = 'https://*.stripe.network' const CLOUDFLARE_URL = 'https://www.cloudflare.com' -const ONE_ONE_ONE_ONE_URL = 'https://1.1.1.1' +const ONE_ONE_ONE_ONE_URL = 'https://one.one.one.one' const VERCEL_URL = 'https://vercel.com' const VERCEL_INSIGHTS_URL = 'https://*.vercel-insights.com' const GITHUB_API_URL = 'https://api.github.com' @@ -394,6 +394,36 @@ const nextConfig = { source: '/project/:ref/database/performance-advisor', destination: '/project/:ref/advisors/performance', }, + { + permanent: true, + source: '/project/:ref/database/webhooks', + destination: '/project/:ref/integrations/webhooks', + }, + { + permanent: true, + source: '/project/:ref/database/wrappers', + destination: '/project/:ref/integrations/wrappers', + }, + { + permanent: true, + source: '/project/:ref/.....', + destination: '/project/:ref/integrations/cron-jobs', + }, + { + permanent: true, + source: '/project/:ref/api/graphiql', + destination: '/project/:ref/integrations/graphiql', + }, + { + permanent: true, + source: '/project/:ref/settings/vault/secrets', + destination: '/project/:ref/integrations/vault/secrets', + }, + { + permanent: true, + source: '/project/:ref/settings/vault/keys', + destination: '/project/:ref/integrations/vault/keys', + }, ...(process.env.NEXT_PUBLIC_BASE_PATH?.length ? [ { diff --git a/apps/studio/package.json b/apps/studio/package.json index e5afc2f6c2f..0b1f65ed4d8 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -8,6 +8,7 @@ "build": "next build", "start": "next start", "lint": "next lint", + "clean": "rimraf node_modules tsconfig.tsbuildinfo .next .turbo", "test": "vitest --run", "test:watch": "vitest watch", "test:ui": "vitest --ui", diff --git a/apps/studio/pages/_app.tsx b/apps/studio/pages/_app.tsx index 6c7899c806b..63fa4d1a825 100644 --- a/apps/studio/pages/_app.tsx +++ b/apps/studio/pages/_app.tsx @@ -41,6 +41,7 @@ import { StudioCommandMenu } from 'components/interfaces/App/CommandMenu' import { FeaturePreviewContextProvider } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import FeaturePreviewModal from 'components/interfaces/App/FeaturePreview/FeaturePreviewModal' import { GenerateSql } from 'components/interfaces/SqlGenerator/SqlGenerator' +import { AiAssistantPanel } from 'components/ui/AIAssistantPanel/AIAssistantPanel' import { ErrorBoundaryState } from 'components/ui/ErrorBoundaryState' import FlagProvider from 'components/ui/Flag/FlagProvider' import PageTelemetry from 'components/ui/PageTelemetry' @@ -147,6 +148,7 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) { + diff --git a/apps/studio/pages/api/ai/sql/generate-v2.ts b/apps/studio/pages/api/ai/sql/generate-v2.ts index d529568da0b..d21b384a479 100644 --- a/apps/studio/pages/api/ai/sql/generate-v2.ts +++ b/apps/studio/pages/api/ai/sql/generate-v2.ts @@ -1,5 +1,6 @@ import { StreamingTextResponse } from 'ai' import { chatSql } from 'ai-commands/edge' +import { SupportedAssistantEntities } from 'components/ui/AIAssistantPanel/AIAssistant.types' import { NextRequest } from 'next/server' import OpenAI from 'openai' @@ -70,12 +71,13 @@ async function handlePost(request: NextRequest) { messages: { content: string; role: 'user' | 'assistant' }[] existingSql?: string entityDefinitions: string[] + context?: SupportedAssistantEntities }>) - const { messages, existingSql, entityDefinitions } = body + const { messages, existingSql, entityDefinitions, context } = body try { - const stream = await chatSql(openai, messages, existingSql, entityDefinitions) + const stream = await chatSql(openai, messages, existingSql, entityDefinitions, context) return new StreamingTextResponse(stream) } catch (error) { if (error instanceof Error) { diff --git a/apps/studio/pages/api/organizations/[slug]/billing/subscription.ts b/apps/studio/pages/api/organizations/[slug]/billing/subscription.ts index ed1da8d5d54..5baa6aa7966 100644 --- a/apps/studio/pages/api/organizations/[slug]/billing/subscription.ts +++ b/apps/studio/pages/api/organizations/[slug]/billing/subscription.ts @@ -38,7 +38,6 @@ const handleGet = async (req: NextApiRequest, res: NextApiResponse scheduled_plan_change: null, customer_balance: 0, nano_enabled: false, - usage_based_billing_project_addons: false, } return res.status(200).json(response) diff --git a/apps/studio/pages/authorize.tsx b/apps/studio/pages/authorize.tsx index 2c23397a595..0cb3e37402c 100644 --- a/apps/studio/pages/authorize.tsx +++ b/apps/studio/pages/authorize.tsx @@ -22,31 +22,37 @@ import { Alert, Button, Listbox } from 'ui' const APIAuthorizationPage: NextPageWithLayout = () => { const router = useRouter() const { auth_id } = useParams() + const [isApproving, setIsApproving] = useState(false) + const [isDeclining, setIsDeclining] = useState(false) const [selectedOrgSlug, setSelectedOrgSlug] = useState() - const { data: organizations, isLoading: isLoadingOrganizations } = useOrganizationsQuery() + const { + data: organizations, + isSuccess: isSuccessOrganizations, + isLoading: isLoadingOrganizations, + } = useOrganizationsQuery() const { data: requester, isLoading, isError, error } = useApiAuthorizationQuery({ id: auth_id }) const isApproved = (requester?.approved_at ?? null) !== null const isExpired = dayjs().isAfter(dayjs(requester?.expires_at)) - const { mutate: approveRequest, isLoading: isApproving } = useApiAuthorizationApproveMutation({ + const { mutate: approveRequest } = useApiAuthorizationApproveMutation({ onSuccess: (res) => { window.location.href = res.url }, }) - const { mutate: declineRequest, isLoading: isDeclining } = useApiAuthorizationDeclineMutation({ + const { mutate: declineRequest } = useApiAuthorizationDeclineMutation({ onSuccess: () => { toast.success('Declined API authorization request') router.push('/projects') }, }) - const isSubmitting = isApproving || isDeclining useEffect(() => { - if (!isLoadingOrganizations) { - setSelectedOrgSlug(organizations?.[0].slug ?? undefined) + if (isSuccessOrganizations && organizations.length > 0) { + setSelectedOrgSlug(organizations[0].slug) } - }, [isLoadingOrganizations]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isSuccessOrganizations]) const onApproveRequest = async () => { if (!auth_id) { @@ -56,7 +62,8 @@ const APIAuthorizationPage: NextPageWithLayout = () => { return toast.error('Unable to approve request: No organization selected') } - approveRequest({ id: auth_id, slug: selectedOrgSlug }) + setIsApproving(true) + approveRequest({ id: auth_id, slug: selectedOrgSlug }, { onError: () => setIsApproving(false) }) } const onDeclineRequest = async () => { @@ -67,7 +74,8 @@ const APIAuthorizationPage: NextPageWithLayout = () => { return toast.error('Unable to decline request: No organization selected') } - declineRequest({ id: auth_id, slug: selectedOrgSlug }) + setIsDeclining(true) + declineRequest({ id: auth_id, slug: selectedOrgSlug }, { onError: () => setIsDeclining(false) }) } if (isLoading) { @@ -113,7 +121,7 @@ const APIAuthorizationPage: NextPageWithLayout = () => { if (isApproved) { const approvedOrganization = organizations?.find( - (org) => org.slug === requester.approved_organization_slug + (org) => org?.slug === requester.approved_organization_slug ) return ( @@ -139,12 +147,17 @@ const APIAuthorizationPage: NextPageWithLayout = () => { footer={

    - +
    +
    +
    + {!filteredFeatures?.length ? ( +

    + No features found with these filters +

    + ) : ( +
    + {filteredFeatures + .sort((a, b) => (a.title > b.title ? 1 : -1)) + .map((feature) => ( + + +
    + +
    +
    +

    + {feature.title} +

    +

    + {feature.subtitle} +

    +
    +
    + + ))} +
    + )} +
    + + + + ) +} + +export default FeaturesPage diff --git a/apps/www/pages/features/[slug].tsx b/apps/www/pages/features/[slug].tsx new file mode 100644 index 00000000000..db79dc3f6f4 --- /dev/null +++ b/apps/www/pages/features/[slug].tsx @@ -0,0 +1,237 @@ +import { ChevronLeft, ChevronRight, LucideIcon } from 'lucide-react' +import { GetStaticPaths, GetStaticProps } from 'next' +import { NextSeo } from 'next-seo' +import Link from 'next/link' +import ReactMarkdown from 'react-markdown' + +import { Badge, Button, cn, Image } from 'ui' +import DefaultLayout from '~/components/Layouts/Default' +import ShareArticleActions from '~/components/Blog/ShareArticleActions' +import SectionContainer from '~/components/Layouts/SectionContainer' +import CTABanner from '~/components/CTABanner' +import PrevNextFeatureNav from '~/components/PrevNextFeatureNav' + +import { features } from '~/data/features' +import type { FeatureType } from '~/data/features' + +interface FeaturePageProps { + feature: FeatureType + prevFeature: FeatureType | null + nextFeature: FeatureType | null +} + +export const getStaticPaths: GetStaticPaths = async () => { + // Generate paths based on each feature's slug + const paths = features.map((feature) => ({ + params: { slug: feature.slug }, + })) + return { paths, fallback: false } +} + +export const getStaticProps: GetStaticProps = async ({ params }) => { + const { slug } = params as { slug: string } + const featureIndex = features.findIndex((feature) => feature.slug === slug) + + if (featureIndex === -1) { + return { notFound: true } + } + + const feature = features[featureIndex] + const prevFeature = featureIndex > 0 ? features[featureIndex - 1] : features[features.length - 1] + const nextFeature = featureIndex < features.length - 1 ? features[featureIndex + 1] : features[0] + + // Destructure the icon property out, and add the icon name to props instead + const { icon, ...featureWithoutIcon } = feature + const { icon: _prevIcon, ...prevFeatureWithoutIcon } = prevFeature + const { icon: _nextIcon, ...nextFeatureWithoutIcon } = nextFeature + + return { + props: { + feature: featureWithoutIcon, + prevFeature: prevFeatureWithoutIcon, + nextFeature: nextFeatureWithoutIcon, + }, + } +} + +const FeaturePage: React.FC = ({ feature, prevFeature, nextFeature }) => { + const meta = { + title: `${feature.title} | Supabase Features`, + description: feature.subtitle, + url: `https://supabase.com/features/${feature.slug}`, + // image: ogImageUrl, + } + + const Icon = features.find((f) => f.slug === feature.slug)?.icon as LucideIcon + + return ( + <> + + +
    + +
    + +
    +
    + {feature.products.map((product) => ( + + + {product} + + + ))} +
    +

    {feature.title}

    +

    {feature.subtitle}

    +
    +
    + {!!feature.heroImage ? ( + feature.heroImage.includes('.mp4') ? ( + + ) : feature.heroImage.includes('.youtube') ? ( +
    +
    Usage + Unit price Cost
    {item.description ?? 'Unknown'} + {item.usage_metric && + billingMetricUnit(item.usage_metric) && + ` (${billingMetricUnit(item.usage_metric)})`} @@ -131,12 +134,12 @@ const UpcomingInvoice = ({ slug }: UpcomingInvoiceProps) => { + {item.unit_price === 0 ? 'FREE' : item.unit_price ? formatCurrency(item.unit_price) - : null} + : `${item.unit_price_desc}`} {formatCurrency(item.amount)}