diff --git a/.github/workflows/studio-build.yml b/.github/workflows/studio-build.yml deleted file mode 100644 index e2fb6aa46e6..00000000000 --- a/.github/workflows/studio-build.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: Studio Build - -on: - pull_request: - branches: - - master - - studio - paths: - - 'studio/**' - - 'package-lock.json' -jobs: - build: - runs-on: ubuntu-latest - - strategy: - matrix: - node-version: [16.x] - # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ - - steps: - - uses: actions/checkout@v3 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3 - with: - node-version: ${{ matrix.node-version }} - cache: 'npm' - - name: Install deps - run: npm ci - working-directory: ./ - - name: Run build - run: npx turbo run build --filter=studio - working-directory: ./ diff --git a/DEVELOPERS.md b/DEVELOPERS.md index 1845aa29ca5..5487e76808a 100644 --- a/DEVELOPERS.md +++ b/DEVELOPERS.md @@ -136,3 +136,9 @@ Create a new entry in the [`redirects.js`](https://github.com/supabase/supabase/ ## Community channels Stuck somewhere? Have any questions? Join the [Discord Community Server](https://discord.supabase.com/) or the [Github Discussions](https://github.com/supabase/supabase/discussions). We are here to help! + +## Contributors + + + contributors + diff --git a/SECURITY.md b/SECURITY.md index b257313bf5d..65dd29a7e19 120000 --- a/SECURITY.md +++ b/SECURITY.md @@ -1 +1 @@ -apps/reference/static/.well-known/security.txt +apps/docs/public/.well-known/security.txt \ No newline at end of file diff --git a/apps/docs/components/JwtGenerator.js b/apps/docs/components/JwtGenerator.js index 4ac1b05e255..d0dc7a7bf08 100644 --- a/apps/docs/components/JwtGenerator.js +++ b/apps/docs/components/JwtGenerator.js @@ -1,7 +1,6 @@ import React, { useState } from 'react' import KJUR from 'jsrsasign' -import CodeBlock from './CodeBlock/CodeBlock' -import { Button, Select, Input } from 'ui' +import { Button, Select, Input, CodeBlock } from 'ui' const JWT_HEADER = { alg: 'HS256', typ: 'JWT' } const now = new Date() diff --git a/apps/docs/components/MDX/storage_management.mdx b/apps/docs/components/MDX/storage_management.mdx index d9df719486d..1eebf7cb575 100644 --- a/apps/docs/components/MDX/storage_management.mdx +++ b/apps/docs/components/MDX/storage_management.mdx @@ -5,26 +5,26 @@ from `public.profiles` and the older versions getting orphaned. To automatically remove obsolete storage objects, extend the database triggers. Note that it is not sufficient to delete the objects from the `storage.objects` table because that would orphan and leak the actual storage objects in -the S3 backend. Instead, invoke the storage API within Postgres via the `http` extension. +the S3 backend. Instead, invoke the storage API within Postgres via the `http` extension. Enable the [http extension for the `extensions` schema](https://app.supabase.com/project/_/database/extensions) in the Dashboard. Then, define the following SQL functions in the SQL Editor to delete storage objects via the API: ```SQL -create or replace function delete_storage_object(bucket text, object text, out status int, out content varchar) +create or replace function delete_storage_object(bucket text, object text, out status int, out content text) returns record language 'plpgsql' security definer as $$ declare - project_url varchar := ''; - service_role_key varchar := ''; -- full access needed - url varchar := project_url||'/storage/v1/object/'||bucket||'/'||object; + project_url text := ''; + service_role_key text := ''; -- full access needed + url text := project_url||'/storage/v1/object/'||bucket||'/'||object; begin select into status, content - result.status::int, result.content::varchar + result.status::int, result.content::text FROM extensions.http(( 'DELETE', url, @@ -34,7 +34,7 @@ begin end; $$; -create or replace function delete_avatar(avatar_url text, out status int, out content varchar) +create or replace function delete_avatar(avatar_url text, out status int, out content text) returns record language 'plpgsql' security definer @@ -60,7 +60,7 @@ security definer as $$ declare status int; - content varchar; + content text; begin if coalesce(old.avatar_url, '') <> '' and (tg_op = 'DELETE' or (old.avatar_url <> new.avatar_url)) then diff --git a/apps/docs/components/Navigation/AlgoliaSearch.tsx b/apps/docs/components/Navigation/AlgoliaSearch.tsx deleted file mode 100644 index eeb954f037a..00000000000 --- a/apps/docs/components/Navigation/AlgoliaSearch.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import { render } from 'react-dom' -import { IconCommand } from 'ui' -import { createElement, FC, useEffect, useRef, Fragment } from 'react' -import algoliasearch from 'algoliasearch/lite' -import { autocomplete, getAlgoliaResults } from '@algolia/autocomplete-js' -import { createLocalStorageRecentSearchesPlugin } from '@algolia/autocomplete-plugin-recent-searches' -import { useRouter } from 'next/router' - -// [Joshen] We're currently using DocSearch from Algolia as it provides a nice -// UI out of the box + some good preconfigured search settings (e.g hierarchy). -// However, we're using our own Algolia account to store the records in the indexes -// (rather than going through the DocSearch program from Algolia where they'll crawl -// our site for us). Refer to scripts/build-search on how we're saving the records. - -// Using Algolia's autocomplete library gives us full flexbility in terms of customizing -// our search experience, but that will take time to figure out. Hence why for now we're just -// using DocSearch with our own records. - -// Potentially for search, we could -// - Go ahead with the DocSearch program and let them crawl our site to generate the records -// - But we need to ensure that our site is semantically correct first -// - Use Algolia itself to flesh out our own search logic -// - The basics are already set up to be honest, but will take time to make it great -// - Go back to Typesense if we deem that Algolia is not helpful in the long run - -const searchClient = algoliasearch( - process.env.NEXT_PUBLIC_ALGOLIA_APP_ID, - process.env.NEXT_PUBLIC_ALGOLIA_SEARCH_API_KEY -) - -// [Joshen] Not working properly, but lets not get stuck on this -// Priority just to get a search working -const recentSearchesPlugin = createLocalStorageRecentSearchesPlugin({ - key: 'docs-search', - limit: 3, - //@ts-ignore - transformSource({ source }) { - return { - ...source, - templates: { - ...source.templates, - header({ state }) { - if (state.query) return null - return ( - - Your searches -
- - ) - }, - }, - } - }, -}) - -interface Props {} - -const AlgoliaSearch: FC = ({}) => { - const searchRef = useRef(null) - const router = useRouter() - - useEffect(() => { - if (!searchRef.current) { - return undefined - } - const search = autocomplete({ - openOnFocus: true, - container: searchRef.current, - defaultActiveItemId: 0, - detachedMediaQuery: '', - // @ts-ignore - renderer: { createElement, Fragment, render }, - placeholder: 'Search docs', - plugins: [recentSearchesPlugin], - renderNoResults({ state, render }, root) { - render( -
- No results found for {state.query}. -
, - root - ) - }, - navigator: { - navigate({ itemUrl }) { - router.push(itemUrl) - }, - }, - onSelect({ item, setQuery, setIsOpen, refresh }) { - //console.log('onSelect', item.url) - }, - // @ts-ignore - getSources({ query }) { - return [ - { - sourceId: 'pages', - templates: { - item({ item, components }) { - return ( - -
-
- {item.category && ( -

- <> - {item.category} - {item.version ? ` (${item.version})` : ''}: - -

- )} -

- -

-
-

{item.description as string}

-
-
- ) - }, - }, - getItemUrl({ item }) { - return item.url - }, - getItems() { - // if (!query) return [] - return getAlgoliaResults({ - searchClient, - queries: [ - { - indexName: 'dev_docs', - query, - }, - ], - }) - }, - }, - ] - }, - }) - - return () => { - search.destroy() - } - }, []) - - return ( -
-
-
-
- -
-
- K -
-
-
- ) -} - -export default AlgoliaSearch diff --git a/apps/docs/components/Navigation/NavBar.tsx b/apps/docs/components/Navigation/NavBar.tsx index e86cdb2802e..eb7ee10434f 100644 --- a/apps/docs/components/Navigation/NavBar.tsx +++ b/apps/docs/components/Navigation/NavBar.tsx @@ -1,13 +1,12 @@ +import { useTheme } from 'common/Providers' import Image from 'next/image' import Link from 'next/link' import { useRouter } from 'next/router' -import { useState, useEffect, FC } from 'react' -import { IconMenu, IconMoon, IconSearch, IconSun, IconCommand, Listbox } from 'ui' -import { useTheme } from 'common/Providers' +import { FC, useEffect, useState } from 'react' +import { IconCommand, IconMenu, IconMoon, IconSearch, IconSun, Listbox, SearchButton } from 'ui' import { REFERENCES } from './Navigation.constants' import { getPageType } from '~/lib/helpers' -import SearchButton from '../Search/SearchButton' const NavBar: FC = () => { const { isDarkMode, toggleTheme } = useTheme() @@ -36,14 +35,6 @@ const NavBar: FC = () => { { text: 'Reference', key: 'reference', link: '/reference' }, ] - const toggleDarkMode = () => { - localStorage.setItem('supabaseDarkMode', (!isDarkMode).toString()) - toggleTheme() - - const key = localStorage.getItem('supabaseDarkMode') - document.documentElement.className = key === 'true' ? 'dark' : '' - } - const onSelectVersion = (version: string) => { // [Joshen] Ideally we use but this works for now if (!library) return @@ -183,7 +174,7 @@ const NavBar: FC = () => {
  • -
    +
    toggleTheme()}> {isDarkMode ? ( ) : ( diff --git a/apps/docs/components/Navigation/Navigation.constants.ts b/apps/docs/components/Navigation/Navigation.constants.ts index 94f40fce1ee..96799eb177d 100644 --- a/apps/docs/components/Navigation/Navigation.constants.ts +++ b/apps/docs/components/Navigation/Navigation.constants.ts @@ -241,12 +241,62 @@ export const menuItems: NavMenu = { { label: 'Realtime', items: [ - { name: 'Overview', url: '/guides/realtime', items: [] }, - { name: 'Quickstart', url: '/guides/realtime/quickstart', items: [] }, - { name: 'Broadcast', url: '/guides/realtime/broadcast', items: [] }, - { name: 'Presence', url: '/guides/realtime/presence', items: [] }, - { name: 'Postgres Changes', url: '/guides/realtime/postgres-changes', items: [] }, - { name: 'Rate Limits', url: '/guides/realtime/rate-limits', items: [] }, + { + name: 'Overview', + url: '/guides/realtime', + items: [], + }, + { + name: 'Quickstart', + url: '/guides/realtime/quickstart', + items: [], + }, + { + name: 'Features', + url: undefined, + items: [ + { name: 'Channels', url: '/guides/realtime/channels', items: [] }, + { + name: 'Extensions', + url: '/guides/realtime/extensions', + items: [ + { name: 'Broadcast', url: '/guides/realtime/broadcast', items: [] }, + { name: 'Presence', url: '/guides/realtime/presence', items: [] }, + { name: 'Postgres Changes', url: '/guides/realtime/postgres-changes', items: [] }, + ], + }, + ], + }, + { + name: 'Guides', + url: undefined, + items: [ + { + name: 'Subscribing to Database Changes', + url: '/guides/realtime/subscribing-to-database-changes', + items: [], + }, + { + name: 'Bring Your Own Database', + url: '/guides/realtime/bring-your-own-database', + items: [], + }, + { + name: 'Using Realtime with Next.js', + url: '/guides/realtime/realtime-with-nextjs', + items: [], + }, + ], + }, + { + name: 'Deep dive', + url: undefined, + items: [ + { name: 'Rate Limits', url: '/guides/realtime/rate-limits', items: [] }, + { name: 'Architecture', url: '/guides/realtime/architecture', items: [] }, + { name: 'Protocol', url: '/guides/realtime/protocol', items: [] }, + ], + }, ], }, { diff --git a/apps/docs/components/Navigation/NavigationMenu/HomeMenu.tsx b/apps/docs/components/Navigation/NavigationMenu/HomeMenu.tsx index 36f01bf6830..a202f5d5548 100644 --- a/apps/docs/components/Navigation/NavigationMenu/HomeMenu.tsx +++ b/apps/docs/components/Navigation/NavigationMenu/HomeMenu.tsx @@ -30,7 +30,7 @@ const home = [ { label: 'Auth', icon: '/img/icons/menu/auth', - href: '/guides/auth/overview', + href: '/guides/auth', level: 'auth', }, { diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index 528afb8db03..14f72d845cc 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -46,17 +46,18 @@ export const gettingstarted = { icon: 'getting-started', title: 'Getting Started', items: [ - { name: 'Features', url: '/guides/getting-started/features', items: [] }, - { name: 'Architecture', url: '/guides/getting-started/architecture', items: [] }, + { name: 'Features', url: '/guides/getting-started/features' }, + { name: 'Architecture', url: '/guides/getting-started/architecture' }, + { name: 'Local Development', url: '/guides/cli/local-development' }, { name: 'Framework Quickstarts', items: [ - { name: 'React', url: '/guides/getting-started/quickstarts/reactjs', items: [] }, - { name: 'NextJS', url: '/guides/getting-started/quickstarts/nextjs', items: [] }, - { name: 'Flutter', url: '/guides/getting-started/quickstarts/flutter', items: [] }, - { name: 'SvelteKit', url: '/guides/getting-started/quickstarts/sveltekit', items: [] }, - { name: 'SolidJS', url: '/guides/getting-started/quickstarts/solidjs', items: [] }, - { name: 'Vue', url: '/guides/getting-started/quickstarts/vue', items: [] }, + { name: 'React', url: '/guides/getting-started/quickstarts/reactjs' }, + { name: 'NextJS', url: '/guides/getting-started/quickstarts/nextjs' }, + { name: 'Flutter', url: '/guides/getting-started/quickstarts/flutter' }, + { name: 'SvelteKit', url: '/guides/getting-started/quickstarts/sveltekit' }, + { name: 'SolidJS', url: '/guides/getting-started/quickstarts/solidjs' }, + { name: 'Vue', url: '/guides/getting-started/quickstarts/vue' }, ], }, { @@ -65,47 +66,38 @@ export const gettingstarted = { { name: 'NextJS', url: '/guides/getting-started/tutorials/with-nextjs', - items: [], }, { name: 'React', url: '/guides/getting-started/tutorials/with-react', - items: [], }, { name: 'Vue 3', url: '/guides/getting-started/tutorials/with-vue-3', - items: [], }, { name: 'Nuxt 3', url: '/guides/getting-started/tutorials/with-nuxt-3', - items: [], }, { name: 'Angular', url: '/guides/getting-started/tutorials/with-angular', - items: [], }, { name: 'RedwoodJS', url: '/guides/getting-started/tutorials/with-redwoodjs', - items: [], }, { name: 'SolidJS', url: '/guides/getting-started/tutorials/with-solidjs', - items: [], }, { name: 'Svelte', url: '/guides/getting-started/tutorials/with-svelte', - items: [], }, { name: 'SvelteKit', url: '/guides/getting-started/tutorials/with-sveltekit', - items: [], }, ], }, @@ -115,28 +107,23 @@ export const gettingstarted = { { name: 'Flutter', url: '/guides/getting-started/tutorials/with-flutter', - items: [], }, { name: 'Expo', url: '/guides/getting-started/tutorials/with-expo', - items: [], }, { name: 'Ionic React', url: '/guides/getting-started/tutorials/with-ionic-react', - items: [], }, { name: 'Ionic Vue', url: '/guides/getting-started/tutorials/with-ionic-vue', - items: [], }, { name: 'Ionic Angular', url: '/guides/getting-started/tutorials/with-ionic-angular', - items: [], }, ], }, @@ -146,9 +133,9 @@ export const gettingstarted = { export const cli = { label: 'CLI', items: [ - { name: 'Overview', url: '/guides/cli', items: [] }, - { name: 'Local Development', url: '/guides/cli/local-development', items: [] }, - { name: 'Managing Environments', url: '/guides/cli/managing-environments', items: [] }, + { name: 'Overview', url: '/guides/cli' }, + { name: 'Local Development', url: '/guides/cli/local-development' }, + { name: 'Managing Environments', url: '/guides/cli/managing-environments' }, ], } @@ -157,104 +144,87 @@ export const SocialLoginItems = [ name: 'Google', icon: '/docs/img/icons/google-icon', url: '/guides/auth/social-login/auth-google', - items: [], }, { name: 'Facebook', icon: '/docs/img/icons/facebook-icon', url: '/guides/auth/social-login/auth-facebook', - items: [], }, { name: 'Apple', icon: '/docs/img/icons/apple-icon', url: '/guides/auth/social-login/auth-apple', - items: [], }, { name: 'Azure', icon: '/docs/img/icons/microsoft-icon', url: '/guides/auth/social-login/auth-azure', - items: [], }, { name: 'Twitter', icon: '/docs/img/icons/twitter-icon', url: '/guides/auth/social-login/auth-twitter', - items: [], }, { name: 'GitHub', icon: '/docs/img/icons/github-icon', url: '/guides/auth/social-login/auth-github', - items: [], isDarkMode: true, }, { name: 'Gitlab', icon: '/docs/img/icons/gitlab-icon', url: '/guides/auth/social-login/auth-gitlab', - items: [], }, { name: 'Bitbucket', icon: '/docs/img/icons/bitbucket-icon', url: '/guides/auth/social-login/auth-bitbucket', - items: [], }, { name: 'Discord', icon: '/docs/img/icons/discord-icon', url: '/guides/auth/social-login/auth-discord', - items: [], }, { name: 'Keycloak', icon: '/docs/img/icons/keycloak-icon', url: '/guides/auth/social-login/auth-keycloak', - items: [], }, { name: 'LinkedIn', icon: '/docs/img/icons/linkedin-icon', url: '/guides/auth/social-login/auth-linkedin', - items: [], }, { name: 'Notion', icon: '/docs/img/icons/notion-icon', url: '/guides/auth/social-login/auth-notion', - items: [], }, { name: 'Slack', icon: '/docs/img/icons/slack-icon', url: '/guides/auth/social-login/auth-slack', - items: [], }, { name: 'Spotify', icon: '/docs/img/icons/spotify-icon', url: '/guides/auth/social-login/auth-spotify', - items: [], }, { name: 'Twitch', icon: '/docs/img/icons/twitch-icon', url: '/guides/auth/social-login/auth-twitch', - items: [], }, { name: 'WorkOS', icon: '/docs/img/icons/workos-icon', url: '/guides/auth/social-login/auth-workos', - items: [], }, { name: 'Zoom', icon: '/docs/img/icons/zoom-icon', url: '/guides/auth/social-login/auth-zoom', - items: [], }, ] @@ -264,14 +234,12 @@ export const PhoneLoginsItems = [ icon: '/docs/img/icons/messagebird-icon', linkDescription: 'Communication between businesses and their customers — across any channel.', url: '/guides/auth/phone-login/messagebird', - items: [], }, { name: 'Twilio SMS Login', icon: '/docs/img/icons/twilio-icon', url: '/guides/auth/phone-login/twilio', linkDescription: 'Customer engagement platform used by hundreds of thousands of businesses.', - items: [], }, { name: 'Vonage SMS Login', @@ -279,7 +247,6 @@ export const PhoneLoginsItems = [ url: '/guides/auth/phone-login/vonage', linkDescription: 'Vonage is a communication platform as a service (CPaaS) provider for consumers and businesses.', - items: [], isDarkMode: true, }, ] @@ -290,14 +257,14 @@ export const auth = { items: [ { name: 'Overview', - url: '/guides/auth/overview', + url: '/guides/auth', }, { name: 'Authentication', url: undefined, items: [ - { name: 'Email Login', url: '/guides/auth/auth-email', items: [] }, - { name: 'Magic Link Login', url: '/guides/auth/auth-magic-link', items: [] }, + { name: 'Email Login', url: '/guides/auth/auth-email' }, + { name: 'Magic Link Login', url: '/guides/auth/auth-magic-link' }, { name: 'Phone Login', url: '/guides/auth/phone-login', @@ -315,38 +282,36 @@ export const auth = { { name: 'SAML 2.0 (Beta)', url: '/guides/auth/sso/auth-sso-saml', - items: [], }, ], }, - { name: 'Email Templates', url: '/guides/auth/auth-email-templates', items: [] }, + { name: 'Email Templates', url: '/guides/auth/auth-email-templates' }, ], }, { name: 'Authorization', url: undefined, items: [ - { name: 'Enable Captcha Protection', url: '/guides/auth/auth-captcha', items: [] }, - { name: 'Managing User Data', url: '/guides/auth/managing-user-data', items: [] }, - { name: 'Multi-Factor Authentication', url: '/guides/auth/auth-mfa', items: [] }, - { name: 'Row Level Security', url: '/guides/auth/row-level-security', items: [] }, - { name: 'Server-side Rendering', url: '/guides/auth/server-side-rendering', items: [] }, + { name: 'Enable Captcha Protection', url: '/guides/auth/auth-captcha' }, + { name: 'Managing User Data', url: '/guides/auth/managing-user-data' }, + { name: 'Multi-Factor Authentication', url: '/guides/auth/auth-mfa' }, + { name: 'Row Level Security', url: '/guides/auth/row-level-security' }, + { name: 'Server-side Rendering', url: '/guides/auth/server-side-rendering' }, ], }, { name: 'Auth Helpers', url: undefined, items: [ - { name: 'Overview', url: '/guides/auth/auth-helpers', items: [] }, - { name: 'Auth UI', url: '/guides/auth/auth-helpers/auth-ui', items: [] }, - { name: 'Next.js', url: '/guides/auth/auth-helpers/nextjs', items: [] }, + { name: 'Overview', url: '/guides/auth/auth-helpers' }, + { name: 'Auth UI', url: '/guides/auth/auth-helpers/auth-ui' }, + { name: 'Next.js', url: '/guides/auth/auth-helpers/nextjs' }, { name: 'Next.js Server Components', url: '/guides/auth/auth-helpers/nextjs-server-components', - items: [], }, - { name: 'Remix', url: '/guides/auth/auth-helpers/remix', items: [] }, - { name: 'SvelteKit', url: '/guides/auth/auth-helpers/sveltekit', items: [] }, + { name: 'Remix', url: '/guides/auth/auth-helpers/remix' }, + { name: 'SvelteKit', url: '/guides/auth/auth-helpers/sveltekit' }, ], }, { @@ -356,19 +321,16 @@ export const auth = { { name: 'Part One: JWTs', url: '/learn/auth-deep-dive/auth-deep-dive-jwts', - items: [], }, { name: 'Part Two: Row Level Security', url: '/learn/auth-deep-dive/auth-row-level-security', - items: [], }, - { name: 'Part Three: Policies', url: '/learn/auth-deep-dive/auth-policies', items: [] }, - { name: 'Part Four: GoTrue', url: '/learn/auth-deep-dive/auth-gotrue', items: [] }, + { name: 'Part Three: Policies', url: '/learn/auth-deep-dive/auth-policies' }, + { name: 'Part Four: GoTrue', url: '/learn/auth-deep-dive/auth-gotrue' }, { name: 'Part Five: Google OAuth', url: '/learn/auth-deep-dive/auth-google-oauth', - items: [], }, ], }, @@ -380,146 +342,122 @@ export const database = { label: 'Database', url: '/guides/database', items: [ - { name: 'Database Connections', url: '/guides/database/connecting-to-postgres', items: [] }, - { name: 'Tables and Data', url: '/guides/database/tables', items: [] }, - { name: 'Database Functions', url: '/guides/database/functions', items: [] }, - { name: 'Database Webhooks', url: '/guides/database/webhooks', items: [] }, - { name: 'Full Text Search', url: '/guides/database/full-text-search', items: [] }, - { name: 'Database Testing', url: '/guides/database/testing', items: [] }, + { name: 'Database Connections', url: '/guides/database/connecting-to-postgres' }, + { name: 'Tables and Data', url: '/guides/database/tables' }, + { name: 'Database Functions', url: '/guides/database/functions' }, + { name: 'Database Webhooks', url: '/guides/database/webhooks' }, + { name: 'Full Text Search', url: '/guides/database/full-text-search' }, + { name: 'Database Testing', url: '/guides/database/testing' }, { name: 'Serverless APIs', url: undefined, items: [ - { name: 'Overview', url: '/guides/database/api', items: [] }, - { name: 'Generating Types', url: '/guides/database/api/generating-types', items: [] }, + { name: 'Overview', url: '/guides/database/api' }, + { name: 'Generating Types', url: '/guides/database/api/generating-types' }, ], }, { name: 'Extensions', url: undefined, items: [ - { name: 'Overview', url: '/guides/database/extensions', items: [] }, + { name: 'Overview', url: '/guides/database/extensions' }, { name: 'HypoPG: Hypothetical indexes', url: '/guides/database/extensions/hypopg', - items: [], }, { name: 'plv8: Javascript Language', url: '/guides/database/extensions/plv8', - items: [], }, - { name: 'http: RESTful Client', url: '/guides/database/extensions/http', items: [] }, + { name: 'http: RESTful Client', url: '/guides/database/extensions/http' }, { name: 'PGAudit: Postgres Auditing', url: '/guides/database/extensions/pgaudit', - items: [], }, { name: 'pgjwt: JSON Web Tokens', url: '/guides/database/extensions/pgjwt', - items: [], }, { name: 'PGroonga: Multilingual Full Text Search', url: '/guides/database/extensions/pgroonga', - items: [], }, { name: 'pgRouting: Geospatial Routing', url: '/guides/database/extensions/pgrouting', - items: [], }, { name: 'pg_cron: Job Scheduling', url: '/guides/database/extensions/pgcron', - items: [], }, { name: 'pg_graphql: GraphQL Support', url: '/guides/database/extensions/pg_graphql', - items: [], }, { name: 'pg_hashids: Short UIDs', url: '/guides/database/extensions/pg_hashids', - items: [], }, { name: 'pg_jsonschema: JSON Schema Validation', url: '/guides/database/extensions/pg_jsonschema', - items: [], }, { name: 'pg_net: Async Networking', url: '/guides/database/extensions/pgnet', - items: [], }, { name: 'pg_plan_filter: Restrict Total Cost', url: '/guides/database/extensions/pg_plan_filter', - items: [], }, { name: 'pg_stat_monitor: Extended Query Performance Monitoring', url: '/guides/database/extensions/pg_stat_monitor', - items: [], }, { name: 'pgvector: Embeddings and vector similarity', url: '/guides/database/extensions/pgvector', - items: [], }, { name: 'pg_stat_statements: SQL Planning and Execution Statistics', url: '/guides/database/extensions/pg_stat_statements', - items: [], }, { name: 'pg_repack: Storage Optimization', url: '/guides/database/extensions/pgrepack', - items: [], }, { name: 'PostGIS: Geo queries', url: '/guides/database/extensions/postgis', - items: [], }, { name: 'pg-safeupdate: Required Where Clauses', url: '/guides/database/extensions/pg-safeupdate', - items: [], }, { name: 'pgsodium: Encryption Features', url: '/guides/database/extensions/pgsodium', - items: [], }, - { name: 'pgTAP: Unit Testing', url: '/guides/database/extensions/pgtap', items: [] }, + { name: 'pgTAP: Unit Testing', url: '/guides/database/extensions/pgtap' }, { name: 'plpgsql_check: PL/pgSQL Linter', url: '/guides/database/extensions/plpgsql_check', - items: [], }, { name: 'timescaledb: Time-series data', url: '/guides/database/extensions/timescaledb', - items: [], }, { name: 'uuid-ossp: Unique Identifiers', url: '/guides/database/extensions/uuid-ossp', - items: [], }, { name: 'RUM: inverted index for full-text search', url: '/guides/database/extensions/rum', - items: [], }, { name: 'wrappers: 3rd Party Integrations', url: '/guides/database/extensions/wrappers', - items: [], }, ], }, @@ -549,10 +487,10 @@ export const database = { name: 'Configuration', url: undefined, items: [ - { name: 'Timeouts', url: '/guides/database/timeouts', items: [] }, - { name: 'Replication', url: '/guides/database/replication', items: [] }, - { name: 'Passwords', url: '/guides/database/managing-passwords', items: [] }, - { name: 'Timezones', url: '/guides/database/managing-timezones', items: [] }, + { name: 'Timeouts', url: '/guides/database/timeouts' }, + { name: 'Replication', url: '/guides/database/replication' }, + { name: 'Passwords', url: '/guides/database/managing-passwords' }, + { name: 'Timezones', url: '/guides/database/managing-timezones' }, ], }, ], @@ -563,37 +501,63 @@ export const functions = { label: 'Edge Functions', url: '/guides/functions', items: [ - { name: 'Quickstart', url: '/guides/functions/quickstart', items: [] }, - { name: 'Auth', url: '/guides/functions/auth', items: [] }, - { name: 'CI/CD Workflow', url: '/guides/functions/cicd-workflow', items: [] }, - { name: 'CORS', url: '/guides/functions/cors', items: [] }, - { name: 'Debugging', url: '/guides/functions/debugging', items: [] }, - { name: 'Import Maps', url: '/guides/functions/import-maps', items: [] }, - { name: 'Local Development', url: '/guides/functions/local-development', items: [] }, - { name: 'Managing Secrets', url: '/guides/functions/secrets', items: [] }, - { name: 'Schedule Functions', url: '/guides/functions/schedule-functions', items: [] }, + { + name: 'Overview', + url: '/guides/functions', + }, + { + name: 'Quickstart', + url: '/guides/functions/quickstart', + }, + { + name: 'Features', + url: undefined, + items: [ + { name: 'TypeScript Support', url: '/guides/functions/typescript-support' }, + { name: 'Debugging Edge Functions', url: '/guides/functions/debugging' }, + { name: 'Managing packages using Import Maps', url: '/guides/functions/import-maps' }, + { name: 'Globally Distributed Deployments', url: '/guides/functions/global-deployments' }, + ], + }, + { + name: 'Guides', + url: undefined, + items: [ + { name: 'Developing Functions locally', url: '/guides/functions/local-development' }, + { name: 'Deploying with Git', url: '/guides/functions/cicd-workflow' }, + { name: 'Managing Secrets and Environment Variables', url: '/guides/functions/secrets' }, + { name: 'Integrating With Supabase Auth', url: '/guides/functions/auth' }, + { + name: 'Integrating with Supabase Storage', + url: '/guides/functions/storage-caching', + }, + { name: 'CORS support for Invoking from the browser', url: '/guides/functions/cors' }, + { name: 'Scheduling Functions', url: '/guides/functions/schedule-functions' }, + { + name: 'Connecting directly to Postgres', + url: '/guides/functions/connect-to-postgres', + }, + ], + }, { name: 'Examples', url: '/guides/functions/examples', items: [ + { name: 'Generating OpenAI GPT3 completions', url: '/guides/functions/examples/openai' }, + { name: 'Generating OG images ', url: '/guides/functions/examples/og-image' }, { - name: 'Cloudflare Turnstile', + name: 'CAPTCHA support with Cloudflare Turnstile', url: '/guides/functions/examples/cloudflare-turnstile', - items: [], }, + { name: 'Building a Discord Bot', url: '/guides/functions/examples/discord-bot' }, + { name: 'Building a Telegram Bot', url: '/guides/functions/examples/telegram-bot' }, + { name: 'Handling Stripe Webhooks ', url: '/guides/functions/examples/stripe-webhooks' }, + { name: 'Integrating with Upstash Redis', url: '/guides/functions/examples/upstash-redis' }, + { name: 'Rate Limiting Edge Functions', url: '/guides/functions/examples/rate-limiting' }, { - name: 'Connect to Postgres', - url: '/guides/functions/examples/connect-to-postgres', - items: [], + name: 'Taking Screenshots with Puppeteer', + url: '/guides/functions/examples/screenshots', }, - { name: 'Discord Bot', url: '/guides/functions/examples/discord-bot', items: [] }, - { name: 'GitHub Actions', url: '/guides/functions/examples/github-actions', items: [] }, - { name: 'OG Image', url: '/guides/functions/examples/og-image', items: [] }, - { name: 'OpenAI', url: '/guides/functions/examples/openai', items: [] }, - { name: 'Storage Caching', url: '/guides/functions/examples/storage-caching', items: [] }, - { name: 'Stripe Webhooks', url: '/guides/functions/examples/stripe-webhooks', items: [] }, - { name: 'Telegram Bot', url: '/guides/functions/examples/telegram-bot', items: [] }, - { name: 'Upstash Redis', url: '/guides/functions/examples/upstash-redis', items: [] }, ], }, ], @@ -604,16 +568,59 @@ export const realtime = { label: 'Realtime', url: '/guides/realtime', items: [ - { name: 'Overview', url: '/guides/realtime', items: [] }, - { name: 'Quickstart', url: '/guides/realtime/quickstart', items: [] }, { - name: 'Channels', + name: 'Overview', + url: '/guides/realtime', + }, + { + name: 'Quickstart', + url: '/guides/realtime/quickstart', + }, + { + name: 'Features', url: undefined, items: [ - { name: 'Broadcast', url: '/guides/realtime/broadcast', items: [] }, - { name: 'Presence', url: '/guides/realtime/presence', items: [] }, - { name: 'Postgres Changes', url: '/guides/realtime/postgres-changes', items: [] }, - { name: 'Rate Limits', url: '/guides/realtime/rate-limits', items: [] }, + { name: 'Channels', url: '/guides/realtime/channels' }, + { + name: 'Extensions', + url: '/guides/realtime/extensions', + items: [ + { name: 'Broadcast', url: '/guides/realtime/extensions/broadcast' }, + { name: 'Presence', url: '/guides/realtime/extensions/presence' }, + { + name: 'Postgres Changes', + url: '/guides/realtime/extensions/postgres-changes', + }, + ], + }, + ], + }, + { + name: 'Guides', + url: undefined, + items: [ + { + name: 'Subscribing to Database Changes', + url: '/guides/realtime/subscribing-to-database-changes', + }, + { + name: 'Bring Your Own Database', + url: '/guides/realtime/bring-your-own-database', + items: [], + }, + { + name: 'Using Realtime with Next.js', + url: '/guides/realtime/realtime-with-nextjs', + }, + ], + }, + { + name: 'Deep dive', + url: undefined, + items: [ + { name: 'Rate Limits', url: '/guides/realtime/rate-limits' }, + { name: 'Architecture', url: '/guides/realtime/architecture' }, + { name: 'Protocol', url: '/guides/realtime/protocol' }, ], }, ], @@ -624,11 +631,11 @@ export const storage = { label: 'Storage', url: '/guides/storage', items: [ - { name: 'Overview', url: '/guides/storage', items: [] }, - { name: 'Quickstart', url: '/guides/storage/quickstart', items: [] }, - { name: 'Access Control', url: '/guides/storage/access-control', items: [] }, - { name: 'CDN', url: '/guides/storage/cdn', items: [] }, - { name: 'Image Transformations', url: '/guides/storage/image-transformations', items: [] }, + { name: 'Overview', url: '/guides/storage' }, + { name: 'Quickstart', url: '/guides/storage/quickstart' }, + { name: 'Access Control', url: '/guides/storage/access-control' }, + { name: 'CDN', url: '/guides/storage/cdn' }, + { name: 'Image Transformations', url: '/guides/storage/image-transformations' }, ], } @@ -637,15 +644,15 @@ export const supabase_cli = { title: 'Supabase CLI', url: '/guides/cli', items: [ - { name: 'Overview', url: '/guides/cli', items: [] }, - { name: 'Local Development', url: '/guides/cli/local-development', items: [] }, - { name: 'Managing Environments', url: '/guides/cli/managing-environments', items: [] }, + { name: 'Overview', url: '/guides/cli' }, + { name: 'Local Development', url: '/guides/cli/local-development' }, + { name: 'Managing Environments', url: '/guides/cli/managing-environments' }, { name: 'Reference', url: undefined, items: [ - { name: 'Commands', url: '/reference/cli/introduction', items: [] }, - { name: 'Configuration', url: '/reference/cli/config', items: [] }, + { name: 'Commands', url: '/reference/cli/introduction' }, + { name: 'Configuration', url: '/reference/cli/config' }, ], }, ], @@ -660,31 +667,35 @@ export const platform = { name: 'Add-ons', url: undefined, items: [ - { name: 'Compute Add-ons', url: '/guides/platform/compute-add-ons', items: [] }, - { name: 'Custom Domains', url: '/guides/platform/custom-domains', items: [] }, - { name: 'Database Backups', url: '/guides/platform/backups', items: [] }, + { name: 'Compute Add-ons', url: '/guides/platform/compute-add-ons' }, + { name: 'Custom Domains', url: '/guides/platform/custom-domains' }, + { name: 'Database Backups', url: '/guides/platform/backups' }, ], }, { name: 'Platform Management', url: undefined, items: [ - { name: 'Access Control', url: '/guides/platform/access-control', items: [] }, - { name: 'Database Size', url: '/guides/platform/database-size', items: [] }, - { name: 'HTTP Status Codes', url: '/guides/platform/http-status-codes', items: [] }, - { name: 'Logging', url: '/guides/platform/logs', items: [] }, - { name: 'Metrics', url: '/guides/platform/metrics', items: [] }, + { name: 'Access Control', url: '/guides/platform/access-control' }, + { name: 'Database Size', url: '/guides/platform/database-size' }, + { name: 'HTTP Status Codes', url: '/guides/platform/http-status-codes' }, + { name: 'Logging', url: '/guides/platform/logs' }, + { name: 'Metrics', url: '/guides/platform/metrics' }, { name: 'Migrating and Upgrading', url: '/guides/platform/migrating-and-upgrading-projects', - items: [], }, - { name: 'Network Restrictions', url: '/guides/platform/network-restrictions', items: [] }, - { name: 'Performance Tuning', url: '/guides/platform/performance', items: [] }, - { name: 'Permissions', url: '/guides/platform/permissions', items: [] }, - { name: 'SSL Enforcement', url: '/guides/platform/ssl-enforcement', items: [] }, + { name: 'Network Restrictions', url: '/guides/platform/network-restrictions' }, + { name: 'Performance Tuning', url: '/guides/platform/performance' }, + { name: 'Permissions', url: '/guides/platform/permissions' }, + { name: 'SSL Enforcement', url: '/guides/platform/ssl-enforcement' }, ], }, + { + name: 'Billing', + url: undefined, + items: [{ name: 'Spend cap', url: '/guides/platform/spend-cap' }], + }, { name: 'Single sign-on', url: undefined, @@ -692,21 +703,19 @@ export const platform = { { name: 'Enable SSO for your organization', url: '/guides/platform/sso', - items: [], }, - { name: 'SSO with Azure AD', url: '/guides/platform/sso/azure', items: [] }, + { name: 'SSO with Azure AD', url: '/guides/platform/sso/azure' }, { name: 'SSO with Google Workspace', url: '/guides/platform/sso/gsuite', - items: [], }, - { name: 'SSO with Okta', url: '/guides/platform/sso/okta', items: [] }, + { name: 'SSO with Okta', url: '/guides/platform/sso/okta' }, ], }, { name: 'Go-live Checklist', url: undefined, - items: [{ name: 'Production Readiness', url: '/guides/platform/going-into-prod', items: [] }], + items: [{ name: 'Production Readiness', url: '/guides/platform/going-into-prod' }], }, { name: 'Troubleshooting', @@ -715,7 +724,6 @@ export const platform = { { name: 'HTTP and Project Issues', url: '/guides/platform/troubleshooting', - items: [], }, ], }, @@ -727,8 +735,8 @@ export const resources = { label: 'Resources', url: '/guides/resources', items: [ - { name: 'Examples', url: '/guides/resources/examples', items: [] }, - { name: 'Glossary', url: '/guides/resources/glossary', items: [] }, + { name: 'Examples', url: '/guides/resources/examples' }, + { name: 'Glossary', url: '/guides/resources/glossary' }, { name: 'Migrate to Supabase', url: '/guides/resources/migrating-to-supabase', @@ -736,27 +744,22 @@ export const resources = { { name: 'Firebase Auth', url: '/guides/resources/migrating-to-supabase/firebase-auth', - items: [], }, { name: 'Firestore Data', url: '/guides/resources/migrating-to-supabase/firestore-data', - items: [], }, { name: 'Firebase Storage', url: '/guides/resources/migrating-to-supabase/firebase-storage', - items: [], }, { name: 'Heroku', url: '/guides/resources/migrating-to-supabase/heroku', - items: [], }, { name: 'Render', url: '/guides/resources/migrating-to-supabase/render', - items: [], }, ], }, @@ -768,27 +771,27 @@ export const self_hosting = { icon: 'resources', url: '/guides/self-hosting', items: [ - { name: 'Overview', url: '/guides/self-hosting', items: [] }, - { name: 'Self-Hosting with Docker', url: '/guides/self-hosting/docker', items: [] }, + { name: 'Overview', url: '/guides/self-hosting' }, + { name: 'Self-Hosting with Docker', url: '/guides/self-hosting/docker' }, { name: 'Auth Server', items: [ - { name: 'Reference', url: '/reference/self-hosting-auth/introduction', items: [] }, - { name: 'Configuration', url: '/guides/self-hosting/auth/config', items: [] }, + { name: 'Reference', url: '/reference/self-hosting-auth/introduction' }, + { name: 'Configuration', url: '/guides/self-hosting/auth/config' }, ], }, { name: 'Storage Server', items: [ - { name: 'Reference', url: '/reference/self-hosting-storage/introduction', items: [] }, - { name: 'Configuration', url: '/guides/self-hosting/storage/config', items: [] }, + { name: 'Reference', url: '/reference/self-hosting-storage/introduction' }, + { name: 'Configuration', url: '/guides/self-hosting/storage/config' }, ], }, { name: 'Realtime Server', items: [ - { name: 'Reference', url: '/reference/self-hosting-realtime/introduction', items: [] }, - { name: 'Configuration', url: '/guides/self-hosting/realtime/config', items: [] }, + { name: 'Reference', url: '/reference/self-hosting-realtime/introduction' }, + { name: 'Configuration', url: '/guides/self-hosting/realtime/config' }, ], }, ], @@ -798,11 +801,11 @@ export const migrate = { label: 'Migrate to Supabase', url: '/guides/migrate', items: [ - { name: 'Firebase Auth', url: '/guides/migrations/firebase-auth', items: [] }, - { name: 'Firestore Data', url: '/guides/migrations/firestore-data', items: [] }, - { name: 'Firebase Storage', url: '/guides/migrations/firebase-storage', items: [] }, - { name: 'Heroku', url: '/guides/migrations/heroku', items: [] }, - { name: 'Render', url: '/guides/migrations/render', items: [] }, + { name: 'Firebase Auth', url: '/guides/migrations/firebase-auth' }, + { name: 'Firestore Data', url: '/guides/migrations/firestore-data' }, + { name: 'Firebase Storage', url: '/guides/migrations/firebase-storage' }, + { name: 'Heroku', url: '/guides/migrations/heroku' }, + { name: 'Render', url: '/guides/migrations/render' }, ], } @@ -811,7 +814,7 @@ export const integrations = { label: 'Integrations', url: '/guides/integrations', items: [ - { name: 'Overview', url: '/guides/integrations/integrations', items: [] }, + { name: 'Overview', url: '/guides/integrations/integrations' }, { name: 'Auth', url: undefined, @@ -819,53 +822,53 @@ export const integrations = { { name: 'Auth0', url: '/guides/integrations/auth0', - items: [], }, - { name: 'Authsignal', url: '/guides/integrations/authsignal', items: [] }, - { name: 'Clerk', url: '/guides/integrations/clerk', items: [] }, - { name: 'keyri', url: '/guides/integrations/keyri', items: [] }, - { name: 'Stytch', url: '/guides/integrations/stytch', items: [] }, - { name: 'SuperTokens', url: '/guides/integrations/supertokens', items: [] }, + { name: 'Authsignal', url: '/guides/integrations/authsignal' }, + { name: 'Clerk', url: '/guides/integrations/clerk' }, + { name: 'keyri', url: '/guides/integrations/keyri' }, + { name: 'Stytch', url: '/guides/integrations/stytch' }, + { name: 'SuperTokens', url: '/guides/integrations/supertokens' }, ], }, { name: 'Caching / Offline-first', url: undefined, - items: [{ name: 'Polyscale', url: '/guides/integrations/polyscale', items: [] }], + items: [{ name: 'Polyscale', url: '/guides/integrations/polyscale' }], }, { name: 'Developer Tools', url: undefined, items: [ - { name: 'Estuary', url: '/guides/integrations/estuary', items: [] }, - { name: 'OpenAI', url: '/guides/functions/examples/openai', items: [] }, - { name: 'pgMustard', url: '/guides/integrations/pgmustard', items: [] }, - { name: 'Prisma', url: '/guides/integrations/prisma', items: [] }, - { name: 'Sequin', url: '/guides/integrations/sequin', items: [] }, - { name: 'Snaplet', url: '/guides/integrations/snaplet', items: [] }, - { name: 'Vercel', url: '/guides/integrations/vercel', items: [] }, - { name: 'WeWeb', url: '/guides/integrations/weweb', items: [] }, - { name: 'Zuplo', url: '/guides/integrations/zuplo', items: [] }, + { name: 'Estuary', url: '/guides/integrations/estuary' }, + { name: 'OpenAI', url: '/guides/functions/examples/openai' }, + { name: 'pgMustard', url: '/guides/integrations/pgmustard' }, + { name: 'Prisma', url: '/guides/integrations/prisma' }, + { name: 'Sequin', url: '/guides/integrations/sequin' }, + { name: 'Snaplet', url: '/guides/integrations/snaplet' }, + { name: 'Vercel', url: '/guides/integrations/vercel' }, + { name: 'Upstash Redis', url: '/guides/functions/examples/upstash-redis' }, + { name: 'WeWeb', url: '/guides/integrations/weweb' }, + { name: 'Zuplo', url: '/guides/integrations/zuplo' }, ], }, { name: 'Low-code', url: undefined, items: [ - { name: 'Appsmith', url: '/guides/integrations/appsmith', items: [] }, - { name: 'Dashibase', url: '/guides/integrations/dashibase', items: [] }, - { name: 'DhiWise', url: '/guides/integrations/dhiwise', items: [] }, - { name: 'Directus', url: '/guides/integrations/directus', items: [] }, - { name: 'Draftbit', url: '/guides/integrations/draftbit', items: [] }, - { name: 'FlutterFlow', url: '/guides/integrations/flutterflow', items: [] }, - { name: 'Plasmic', url: '/guides/integrations/plasmic', items: [] }, - { name: 'ILLA', url: '/guides/integrations/illa', items: [] }, + { name: 'Appsmith', url: '/guides/integrations/appsmith' }, + { name: 'Dashibase', url: '/guides/integrations/dashibase' }, + { name: 'DhiWise', url: '/guides/integrations/dhiwise' }, + { name: 'Directus', url: '/guides/integrations/directus' }, + { name: 'Draftbit', url: '/guides/integrations/draftbit' }, + { name: 'FlutterFlow', url: '/guides/integrations/flutterflow' }, + { name: 'Plasmic', url: '/guides/integrations/plasmic' }, + { name: 'ILLA', url: '/guides/integrations/illa' }, ], }, { name: 'Messaging', url: undefined, - items: [{ name: 'OneSignal', url: '/guides/integrations/onesignal', items: [] }], + items: [{ name: 'OneSignal', url: '/guides/integrations/onesignal' }], }, ], } @@ -881,35 +884,31 @@ export const reference = { name: 'supabase-js', url: '/reference/javascript/start', level: 'reference_javascript', - items: [], icon: '/img/icons/menu/reference-javascript', }, { name: 'supabase-dart', url: '/reference/dart/start', level: 'reference_dart', - items: [], icon: '/img/icons/menu/reference-dart', }, { name: 'supabase-csharp', url: '/reference/csharp/start', level: 'reference_csharp', - items: [], icon: '/img/icons/menu/reference-csharp', }, { name: 'supbase-python', url: '/reference/python/start', level: 'reference_python', - items: [], icon: '/img/icons/menu/reference-python', }, // { // name: 'supabase-python', // url: '/reference/python/start', // level: 'reference_python', - // items: [], + // // icon: '/img/icons/menu/reference-javascript', // }, ], @@ -920,13 +919,11 @@ export const reference = { { name: 'Supabase CLI', url: '/reference/cli/start', - items: [], icon: '/img/icons/menu/reference-cli', }, { name: 'Management API', url: '/reference/javascript', - items: [], icon: '/img/icons/menu/reference-api', }, ], @@ -1014,25 +1011,25 @@ export const reference_self_hosting_realtime = { // { // label: 'Official' // items: [ -// { name: 'Reference Documentation'; url: '/reference'; items: [] }, -// { name: 'Supabase JavaScript Library'; url: '/reference/javascript'; items: [] }, -// { name: 'Supabase Flutter Library'; url: '/reference/dart'; items: [] }, -// { name: 'Supabase CLI'; url: '/reference/cli'; items: [] }, -// { name: 'Management API'; url: '/reference/api'; items: [] } +// { name: 'Reference Documentation'; url: '/reference'; }, +// { name: 'Supabase JavaScript Library'; url: '/reference/javascript'; }, +// { name: 'Supabase Flutter Library'; url: '/reference/dart'; }, +// { name: 'Supabase CLI'; url: '/reference/cli'; }, +// { name: 'Management API'; url: '/reference/api'; } // ] // }, // { // label: 'Self-hosting' // items: [ -// { name: 'Auth Server'; url: '/reference/auth'; items: [] }, -// { name: 'Storage Server'; url: '/reference/storage'; items: [] } +// { name: 'Auth Server'; url: '/reference/auth'; }, +// { name: 'Storage Server'; url: '/reference/storage'; } // ] // } // { // label: 'Clients', // items: [ -// { name: 'Auth Server', url: '/reference/auth', items: [] }, -// { name: 'Storage Server', url: '/reference/storage', items: [] }, +// { name: 'Auth Server', url: '/reference/auth'}, +// { name: 'Storage Server', url: '/reference/storage'}, // ], // }, // 'reference/javascript': SupabaseJsV2Nav, diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.tsx b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.tsx index 7b6aaf26a64..e585077975f 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.tsx +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.tsx @@ -205,7 +205,11 @@ const NavigationMenu = () => { - + diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenuGuideList.tsx b/apps/docs/components/Navigation/NavigationMenu/NavigationMenuGuideList.tsx index c55ec3b6f0d..28c235ca641 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenuGuideList.tsx +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenuGuideList.tsx @@ -2,14 +2,19 @@ import * as Accordion from '@radix-ui/react-accordion' import { useRouter } from 'next/router' import React from 'react' import NavigationMenuGuideListItems from './NavigationMenuGuideListItems' +import * as NavItems from './NavigationMenu.constants' interface Props { id: string active: boolean + collapsible?: boolean + value?: string[] } -const NavigationMenuGuideList: React.FC = ({ id, active }) => { +const NavigationMenuGuideList: React.FC = ({ id, active, value }) => { const router = useRouter() + const menu = NavItems[id] + // get url const url = router.asPath @@ -26,10 +31,10 @@ const NavigationMenuGuideList: React.FC = ({ id, active }) => { return ( = ({ id, active }) => { !active ? 'opacity-0 invisible absolute h-0 overflow-hidden' : '', ].join(' ')} > - + ) } diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenuGuideListItems.tsx b/apps/docs/components/Navigation/NavigationMenu/NavigationMenuGuideListItems.tsx index ee9bbf30f38..169d6a8f326 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenuGuideListItems.tsx +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenuGuideListItems.tsx @@ -143,9 +143,7 @@ const ContentLink = React.memo(function ContentLink(props: any) { }) const Content = (props) => { - const { id } = props - - const menu = NavItems[id] + const { menu, id } = props return (
      diff --git a/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx b/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx index 5e949d870e1..270269d5085 100644 --- a/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx +++ b/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx @@ -1,13 +1,21 @@ +import { useTheme } from 'common/Providers' import Image from 'next/image' import Link from 'next/link' import { useRouter } from 'next/router' import { FC, useEffect, useState } from 'react' -import { Button, IconCommand, IconMenu, IconMoon, IconSearch, IconSun, Input, Listbox } from 'ui' +import { + Button, + IconCommand, + IconMenu, + IconMoon, + IconSearch, + IconSun, + Listbox, + SearchButton, +} from 'ui' import { REFERENCES } from '~/components/Navigation/Navigation.constants' -import { useTheme } from 'common/Providers' import { getPageType } from '~/lib/helpers' -import SearchButton from '~/components/Search/SearchButton' const TopNavBar: FC = () => { const { isDarkMode, toggleTheme } = useTheme() @@ -36,14 +44,6 @@ const TopNavBar: FC = () => { { text: 'Reference', key: 'reference', link: '/reference' }, ] - const toggleDarkMode = () => { - localStorage.setItem('supabaseDarkMode', (!isDarkMode).toString()) - toggleTheme() - - const key = localStorage.getItem('supabaseDarkMode') - document.documentElement.className = key === 'true' ? 'dark' : '' - } - const onSelectVersion = (version: string) => { // [Joshen] Ideally we use but this works for now if (!library) return @@ -147,7 +147,7 @@ const TopNavBar: FC = () => {
      • -
        +
        toggleTheme()}> {isDarkMode ? ( { @@ -36,15 +35,6 @@ const TopNavBarRef: FC = () => { { text: 'Reference', key: 'reference', link: '/reference' }, ] - const toggleDarkMode = () => { - localStorage.setItem('supabaseDarkMode', (!isDarkMode).toString()) - toggleTheme() - - const key = localStorage.getItem('supabaseDarkMode') - document.documentElement.className = key === 'true' ? 'dark' : '' - document.documentElement.style.colorScheme = key === 'true' ? 'dark' : '' - } - const onSelectVersion = (version: string) => { // [Joshen] Ideally we use but this works for now if (!library) return @@ -149,7 +139,7 @@ const TopNavBarRef: FC = () => {
      • -
        +
        toggleTheme()}> {isDarkMode ? ( , HTMLButtonElement>> -> = ({ children, ...props }) => { - const searchButtonRef = useRef() - const { open, setQuery } = useSearch() - - useEffect(() => { - function onKeyDown(event: KeyboardEvent) { - if (searchButtonRef.current === document.activeElement) { - if (/[a-zA-Z0-9]/.test(String.fromCharCode(event.keyCode))) { - open() - setQuery?.(event.key) - } - } - } - window.addEventListener('keydown', onKeyDown) - return () => { - window.removeEventListener('keydown', onKeyDown) - } - }, [open, setQuery]) - - return ( - - ) -} - -export default SearchButton diff --git a/apps/docs/components/Search/SearchModal.tsx b/apps/docs/components/Search/SearchModal.tsx deleted file mode 100644 index 213035540b1..00000000000 --- a/apps/docs/components/Search/SearchModal.tsx +++ /dev/null @@ -1,423 +0,0 @@ -import type { CreateCompletionResponse } from 'openai' -import { FC, useCallback, useRef, useState } from 'react' -import ReactMarkdown from 'react-markdown' -import remarkGfm from 'remark-gfm' -import { SSE } from 'sse.js' -import clippyImageDark from '../../public/img/clippy-dark.png' -import clippyImage from '../../public/img/clippy.png' - -import { useSupabaseClient } from '@supabase/auth-helpers-react' -import { useTheme } from 'common/Providers' -import Image from 'next/image' -import { - Button, - IconAlertCircle, - IconAlertTriangle, - IconLoader, - IconSearch, - Input, - Loading, - Modal, - Tabs, -} from 'ui' -import components from '~/components' -import { IS_PLATFORM } from '~/lib/constants' -import { useSearch } from './SearchProvider' -import SearchResult, { SearchResultType } from './SearchResult' - -const questions = [ - 'How do I get started with Supabase?', - 'How do I run Supabase locally?', - 'How do I connect to my database?', - 'How do I run migrations? ', - 'How do I listen to changes in a table?', - 'How do I set up authentication?', -] - -function getEdgeFunctionUrl() { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL?.replace(/\/$/, '') - - if (IS_PLATFORM) { - const [schemeAndProjectId, domain, tld] = supabaseUrl.split('.') - return `${schemeAndProjectId}.functions.${domain}.${tld}` - } else { - return `${supabaseUrl}/functions/v1` - } -} - -const edgeFunctionUrl = getEdgeFunctionUrl() - -const SearchModal: FC = () => { - const { isDarkMode } = useTheme() - const { close, query, setQuery } = useSearch() - const [answer, setAnswer] = useState('') - const [results, setResults] = useState() - const [isLoading, setIsLoading] = useState(false) - const [isResponding, setIsResponding] = useState(false) - const [hasClippyError, setHasClippyError] = useState(false) - const [hasSearchError, setHasSearchError] = useState(false) - const [selectedTab, setSelectedTab] = useState('search-panel') - const eventSourceRef = useRef() - const supabaseClient = useSupabaseClient() - - const cantHelp = answer?.trim() === "Sorry, I don't know how to help with that." - const status = isLoading - ? 'Clippy is searching...' - : isResponding - ? 'Clippy is responding...' - : cantHelp || hasClippyError - ? 'Clippy has failed you' - : undefined - - const handleSearchConfirm = useCallback( - async (query: string) => { - setResults(undefined) - setAnswer(undefined) - setIsResponding(false) - setHasClippyError(false) - setHasSearchError(false) - setIsLoading(true) - - const { error, data: pageSections } = await supabaseClient.functions.invoke('search', { - body: { query }, - }) - - setIsLoading(false) - - if (error) { - setIsLoading(false) - setIsResponding(false) - setHasSearchError(true) - console.error(error) - return - } - - if (!Array.isArray(pageSections)) { - setIsLoading(false) - setIsResponding(false) - setHasSearchError(true) - console.error('Malformed response') - return - } - - setResults(pageSections) - }, - [supabaseClient] - ) - - const handleClippyConfirm = useCallback(async (query: string) => { - setResults(undefined) - setAnswer(undefined) - setIsResponding(false) - setHasClippyError(false) - setHasSearchError(false) - setIsLoading(true) - - const eventSource = new SSE(`${edgeFunctionUrl}/clippy-search`, { - headers: { - apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, - Authorization: `Bearer ${process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY}`, - 'Content-Type': 'application/json', - }, - payload: JSON.stringify({ query }), - }) - - function handleError(err: T) { - setIsLoading(false) - setIsResponding(false) - setHasClippyError(true) - console.error(err) - } - - eventSource.addEventListener('error', handleError) - eventSource.addEventListener('message', (e) => { - try { - setIsLoading(false) - - if (e.data === '[DONE]') { - setIsResponding(false) - return - } - - setIsResponding(true) - - const completionResponse: CreateCompletionResponse = JSON.parse(e.data) - const [{ text }] = completionResponse.choices - - setAnswer((answer) => { - return (answer ?? '') + text - }) - } catch (err) { - handleError(err) - } - }) - - eventSource.stream() - - eventSourceRef.current = eventSource - - setIsLoading(true) - }, []) - - const handleConfirm = useCallback( - (selectedTab: string, query: string) => { - switch (selectedTab) { - case 'search-panel': - return handleSearchConfirm(query) - case 'clippy-panel': - return handleClippyConfirm(query) - } - }, - [handleSearchConfirm, handleClippyConfirm] - ) - - function handleResetPrompt() { - eventSourceRef.current?.close() - eventSourceRef.current = undefined - setQuery('') - setResults(undefined) - setAnswer(undefined) - setIsResponding(false) - setHasClippyError(false) - setHasSearchError(false) - } - return ( - -
        e.stopPropagation()} - > -
        - setQuery(e.target.value)} - icon={} - onKeyDown={(e) => { - switch (e.key) { - case 'Enter': - if (!query) { - return - } - handleConfirm(selectedTab, query) - return - default: - return - } - }} - /> -
        - -
        - {!isLoading && answer && ( -
        - -
        - )} -
        - { - setSelectedTab(tabId) - if (!query) { - handleResetPrompt() - return - } - handleConfirm(tabId, query) - }} - > - -
        - {!isLoading && !hasSearchError && !results && ( -
        -

        - Search Supabase guides & reference docs -

        -
        - )} - {results && results.length > 0 && ( -
        - {results.map((page) => { - const pageSections = page.sections.filter((section) => !!section.heading) - return ( -
        - - {pageSections.length > 0 && ( -
        -
        -
        - {pageSections.map((section) => ( - - ))} -
        -
        - )} -
        - ) - })} -
        - )} - {isLoading && ( -
        - {} -

        Searching for results

        -
        - )} - {results && results.length === 0 && ( -
        - -

        No results found.

        - -
        - )} - {hasSearchError && ( -
        - -

        - Sorry, looks like we're having some issues with search! -

        -

        Please try again in a bit.

        - -
        - )} -
        -
        - - {!isLoading && !answer && !hasClippyError && ( -
        -
        -

        Not sure where to start?

        - -
          - {questions.map((question) => { - const key = question.replace(/\s+/g, '_') - return ( -
        • - -
        • - ) - })} -
        -
        -
        - )} - {answer && ( -
        - {cantHelp ? ( -

        -

        - -

        Sorry, I don't know how to help with that.

        -
        - -

        - ) : ( -
        - { - const supabaseUrl = new URL('https://supabase.com') - const linkUrl = new URL(href, 'https://supabase.com') - - if (linkUrl.origin === supabaseUrl.origin) { - return linkUrl.toString() - } - - return href - }} - components={components} - > - {answer} - -
        - )} -
        - )} - {isLoading && ( -
        - {} -

        Searching for results

        -
        - )} - {hasClippyError && ( -
        - -

        - Sorry, looks like Clippy is having a hard time! -

        -

        Please try again in a bit.

        - -
        - )} -
        -
        -
        - Powered by OpenAI. - - Read the blog post - -
        -
        - {status ? ( - - {(isLoading || isResponding) && ( - - )} - {status} - - ) : ( - <> - )} - Clippy -
        -
        -
        -
        -
        -
        -
        - ) -} - -export default SearchModal diff --git a/apps/docs/components/Search/SearchProvider.tsx b/apps/docs/components/Search/SearchProvider.tsx deleted file mode 100644 index 4175c159c29..00000000000 --- a/apps/docs/components/Search/SearchProvider.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { - createContext, - FC, - PropsWithChildren, - useCallback, - useContext, - useEffect, - useState, -} from 'react' -import { createPortal } from 'react-dom' -import SearchModal from './SearchModal' - -export type SearchContextValue = { - isOpen: boolean - open: () => void - close: () => void - query: string - setQuery: (query: string) => void -} - -export const SearchContext = createContext(null) - -export const useSearch = () => { - const { isOpen, open, close, query, setQuery } = useContext(SearchContext) - - return { isOpen, open, close, query, setQuery } -} - -const SearchProvider: FC> = ({ children }) => { - const [isOpen, setIsOpen] = useState(false) - const [query, setQuery] = useState('') - - const open = useCallback(() => { - setIsOpen(true) - document.body.classList.add('DocSearch--active') - }, []) - - const close = useCallback(() => { - setIsOpen(false) - document.body.classList.remove('DocSearch--active') - }, []) - - useSearchKeyboardEvents({ - open, - close, - }) - - return ( - - {children} - {isOpen && createPortal(, document.body)} - - ) -} - -function useSearchKeyboardEvents({ open, close }) { - useEffect(() => { - function onKeyDown(event: KeyboardEvent) { - switch (event.key) { - case 'Escape': - close() - return - case 'k': - case '/': - if (event.metaKey || event.ctrlKey) { - open() - } - return - } - } - - window.addEventListener('keydown', onKeyDown) - - return () => { - window.removeEventListener('keydown', onKeyDown) - } - }, [open, close]) -} - -export default SearchProvider diff --git a/apps/docs/components/Search/SearchResult.tsx b/apps/docs/components/Search/SearchResult.tsx deleted file mode 100644 index d3689024ec5..00000000000 --- a/apps/docs/components/Search/SearchResult.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import Link from 'next/link' -import { FC } from 'react' -import { IconBookOpen, IconHash } from '~/../../packages/ui' -import { useSearch } from './SearchProvider' - -export enum SearchResultType { - Document = 'document', - Section = 'section', -} - -type Props = { - href: string - type: SearchResultType - title: string - chip?: string -} - -const SearchResult: FC = ({ href, type, title, chip }) => { - const { close } = useSearch() - - return ( - - -
        - {getIconByType(type)} -
        -
        - {chip && ( -
        - {chip} -
        - )} -
        {title}
        -
        -
        - - ) -} - -function getIconByType(type: SearchResultType) { - switch (type) { - case SearchResultType.Document: - return - case SearchResultType.Section: - return - default: - throw new Error(`Unknown search result type '${type}'`) - } -} - -export default SearchResult diff --git a/apps/docs/components/index.tsx b/apps/docs/components/index.tsx index 0097630387b..8178b4d51f5 100644 --- a/apps/docs/components/index.tsx +++ b/apps/docs/components/index.tsx @@ -1,30 +1,27 @@ -import { Button, Tabs, Alert, GlassPanel } from 'ui' import Link from 'next/link' +import { Alert, Button, CodeBlock, GlassPanel, markdownComponents, Tabs } from 'ui' +import StepHikeCompact from '~/components/StepHikeCompact' // Common components import Admonition from './Admonition' import ButtonCard from './ButtonCard' -import CodeBlock from './CodeBlock/CodeBlock' -import { parseNumericRange } from './CodeBlock/CodeBlock.utils' import JwtGenerator from './JwtGenerator' // Page specific components -import Frameworks from './Frameworks' import AuthProviders from './AuthProviders' -import FunctionsExamples from './FunctionsExamples' import Extensions from './Extensions' +import Frameworks from './Frameworks' +import FunctionsExamples from './FunctionsExamples' // Other components -import { Heading } from './CustomHTMLElements' -import QuickstartIntro from './MDX/quickstart_intro.mdx' -import ProjectSetup from './MDX/project_setup.mdx' -import SocialProviderSetup from './MDX/social_provider_setup.mdx' -import SocialProviderSettingsSupabase from './MDX/social_provider_settings_supabase.mdx' -import StorageManagement from './MDX/storage_management.mdx' import { Mermaid } from 'mdx-mermaid/lib/Mermaid' -import InlineCodeTag from './CustomHTMLElements/InlineCode' -import React from 'react' import RefSubLayout from '~/layouts/ref/RefSubLayout' +import { Heading } from './CustomHTMLElements' +import ProjectSetup from './MDX/project_setup.mdx' +import QuickstartIntro from './MDX/quickstart_intro.mdx' +import SocialProviderSettingsSupabase from './MDX/social_provider_settings_supabase.mdx' +import SocialProviderSetup from './MDX/social_provider_setup.mdx' +import StorageManagement from './MDX/storage_management.mdx' // import { CH } from '@code-hike/mdx/components' import RefHeaderSection from './reference/RefHeaderSection' @@ -33,9 +30,9 @@ import CliGlobalFlagsHandler from '~/components/reference/enrichments/cli/CliGlo import Options from '~/components/Options' import Param from '~/components/Params' -import Image from 'next/image' const components = { + ...markdownComponents, Admonition, Button, ButtonCard, @@ -50,6 +47,7 @@ const components = { ProjectSetup, SocialProviderSetup, SocialProviderSettingsSupabase, + StepHikeCompact, StorageManagement, Mermaid, Extensions, @@ -70,26 +68,11 @@ const components = { {props.children} ), - // pre: (props: any) => { - // const linesToHighlight = parseNumericRange(props.lines ?? '') - // return - // }, - mono: (props: any) => {props.children}, - // inlineCode: (props: { children: string }) => , RefSubLayout, - // CH, - code: (props: any) => , RefHeaderSection: (props: any) => , CliGlobalFlagsHandler: () => , Options, Param, - img: (props: any) => { - return ( - - - - ) - }, } export default components diff --git a/apps/docs/components/reference/ApiOperationSection.tsx b/apps/docs/components/reference/ApiOperationSection.tsx index f97549b4775..6713809bccf 100644 --- a/apps/docs/components/reference/ApiOperationSection.tsx +++ b/apps/docs/components/reference/ApiOperationSection.tsx @@ -1,5 +1,4 @@ -import { Tabs } from '~/../../packages/ui' -import CodeBlock from '~/components/CodeBlock/CodeBlock' +import { CodeBlock, Tabs } from 'ui' import Param from '~/components/Params' import RefSubLayout from '~/layouts/ref/RefSubLayout' diff --git a/apps/docs/components/reference/CLICommandSection.tsx b/apps/docs/components/reference/CLICommandSection.tsx index 9217dd4980f..d20c1db8086 100644 --- a/apps/docs/components/reference/CLICommandSection.tsx +++ b/apps/docs/components/reference/CLICommandSection.tsx @@ -1,8 +1,7 @@ import ReactMarkdown from 'react-markdown' -import { IconChevronRight, Tabs } from '~/../../packages/ui' +import { CodeBlock, IconChevronRight, Tabs } from 'ui' // @ts-expect-error import spec from '~/../../spec/cli_v1_commands.yaml' assert { type: 'yml' } -import CodeBlock from '~/components/CodeBlock/CodeBlock' import Options from '~/components/Options' import Param from '~/components/Params' import RefSubLayout from '~/layouts/ref/RefSubLayout' diff --git a/apps/docs/components/reference/RefFunctionSection.tsx b/apps/docs/components/reference/RefFunctionSection.tsx index 8b228a6bfa6..ddb35ba930b 100644 --- a/apps/docs/components/reference/RefFunctionSection.tsx +++ b/apps/docs/components/reference/RefFunctionSection.tsx @@ -1,7 +1,6 @@ import ReactMarkdown from 'react-markdown' -import { IconDatabase, Tabs } from 'ui' -import CodeBlock from '~/components/CodeBlock/CodeBlock' +import { CodeBlock, IconDatabase, Tabs } from 'ui' import Options from '~/components/Options' import Param from '~/components/Params' diff --git a/apps/docs/components/reference/RefSectionHandler.tsx b/apps/docs/components/reference/RefSectionHandler.tsx index 76f1e0cc3e1..b1b5bebf134 100644 --- a/apps/docs/components/reference/RefSectionHandler.tsx +++ b/apps/docs/components/reference/RefSectionHandler.tsx @@ -1,3 +1,4 @@ +import Head from 'next/head' import { useRouter } from 'next/router' import { useEffect } from 'react' @@ -30,51 +31,78 @@ const RefSectionHandler = (props: RefSectionHandlerProps) => { } }) - return ( - - {props.sections.map((x, i) => { - switch (x.type) { - case 'markdown': - const markdownData = props.pageProps.docs.find((doc) => doc.id === x.id) + function getPageTitle() { + switch (props.type) { + case 'client-lib': + return props.spec.info.title + case 'cli': + return 'Supabase CLI reference' + case 'api': + return 'Supabase API reference' + default: + return 'Supabase Docs' + } + } - return - break - case 'function': - return ( - - ) - case 'cli-command': - return - break - case 'operation': - return ( - - ) - default: - return ( - - ) - break - } - })} - + const pageTitle = getPageTitle() + + return ( + <> + + {pageTitle} + + + + + + + {props.sections.map((x, i) => { + switch (x.type) { + case 'markdown': + const markdownData = props.pageProps.docs.find((doc) => doc.id === x.id) + + return + break + case 'function': + return ( + + ) + case 'cli-command': + return + break + case 'operation': + return ( + + ) + default: + return ( + + ) + break + } + })} + + ) } diff --git a/apps/docs/layouts/SiteLayout.tsx b/apps/docs/layouts/SiteLayout.tsx index cd9e8ba40b1..d8966e5d668 100644 --- a/apps/docs/layouts/SiteLayout.tsx +++ b/apps/docs/layouts/SiteLayout.tsx @@ -7,6 +7,7 @@ import TopNavBarRef from '~/components/Navigation/NavigationMenu/TopNavBarRef' import { memo, useEffect } from 'react' import Footer from '~/components/Navigation/Footer' import { menuState, useMenuLevelId, useMenuMobileOpen } from '~/hooks/useMenuState' +import Head from 'next/head' const levelsData = { home: { @@ -312,32 +313,37 @@ const SiteLayout = ({ children }) => { }, []) return ( -
        -
        - - -
        - -
        -
        -
        - + <> + + Supabase Docs + +
        +
        + + +
        +
        -
        -
        - {children} -
        -
        - - -
        -
        +
        +
        + +
        +
        +
        + {children} +
        +
        + + +
        + + ) } diff --git a/apps/docs/layouts/guides/index.tsx b/apps/docs/layouts/guides/index.tsx index 2895705bc55..e24008fe864 100644 --- a/apps/docs/layouts/guides/index.tsx +++ b/apps/docs/layouts/guides/index.tsx @@ -67,7 +67,7 @@ const Layout: FC = (props) => { // page type, ie, Auth, Database, Storage etc const ogPageType = asPath.split('/')[2] // open graph image url constructor - const ogImageUrl = encodeURIComponent( + const ogImageUrl = encodeURI( `https://obuldanrptloktxcffvn.functions.supabase.co/og-images?site=docs${ ogPageType ? `&type=${ogPageType}` : '' }&title=${props.meta?.title}&description=${props.meta?.description}` diff --git a/apps/docs/layouts/ref/RefSubLayoutNonFunc.tsx b/apps/docs/layouts/ref/RefSubLayoutNonFunc.tsx index d21aa1d79a4..7aee77fbc50 100644 --- a/apps/docs/layouts/ref/RefSubLayoutNonFunc.tsx +++ b/apps/docs/layouts/ref/RefSubLayoutNonFunc.tsx @@ -37,7 +37,6 @@ const RefSubLayoutNonFunc: FC & RefSubLayoutNonFuncSubC } const Section: FC = (props) => { - // console.log({ props }) return (
        { + return IS_PLATFORM || LOCAL_SUPABASE ? ( + + {props.children} + + ) : ( + {props.children} + ) + } + return ( <> - {IS_PLATFORM || LOCAL_SUPABASE ? ( - - - - - - - - - - ) : ( + - + ( + + )} + > - + - )} + ) } diff --git a/apps/docs/pages/guides/auth/overview.mdx b/apps/docs/pages/guides/auth.mdx similarity index 100% rename from apps/docs/pages/guides/auth/overview.mdx rename to apps/docs/pages/guides/auth.mdx diff --git a/apps/docs/pages/guides/auth/auth-helpers/nextjs-server-components.mdx b/apps/docs/pages/guides/auth/auth-helpers/nextjs-server-components.mdx index 337f655f889..21ec7369f1f 100644 --- a/apps/docs/pages/guides/auth/auth-helpers/nextjs-server-components.mdx +++ b/apps/docs/pages/guides/auth/auth-helpers/nextjs-server-components.mdx @@ -158,7 +158,7 @@ import { createMiddlewareSupabaseClient } from '@supabase/auth-helpers-nextjs' import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' -import type { Database } from '../lib/database.types' +import type { Database } from './lib/database.types' export async function middleware(req: NextRequest) { const res = NextResponse.next() diff --git a/apps/docs/pages/guides/auth/auth-helpers/nextjs.mdx b/apps/docs/pages/guides/auth/auth-helpers/nextjs.mdx index 3b093bd91b4..4d450f7d48b 100644 --- a/apps/docs/pages/guides/auth/auth-helpers/nextjs.mdx +++ b/apps/docs/pages/guides/auth/auth-helpers/nextjs.mdx @@ -185,7 +185,8 @@ export default async (req: NextApiRequest, res: NextApiResponse) => { For [row level security](/docs/learn/auth-deep-dive/auth-row-level-security) to work properly when fetching data client-side, you need to make sure to use the `supabaseClient` from the `useSupabaseClient` hook and only run your query once the user is defined client-side in the `useUser()` hook: ```jsx lines=10-17 -import { Auth, ThemeSupa } from '@supabase/auth-ui-react' +import { Auth } from '@supabase/auth-ui-react' +import { ThemeSupa } from '@supabase/auth-ui-shared' import { useUser, useSupabaseClient } from '@supabase/auth-helpers-react' import { useEffect, useState } from 'react' diff --git a/apps/docs/pages/guides/auth/auth-helpers/sveltekit.mdx b/apps/docs/pages/guides/auth/auth-helpers/sveltekit.mdx index b6a43abeb4b..f4d71380697 100644 --- a/apps/docs/pages/guides/auth/auth-helpers/sveltekit.mdx +++ b/apps/docs/pages/guides/auth/auth-helpers/sveltekit.mdx @@ -91,13 +91,12 @@ export const load: LayoutServerLoad = async ({ locals: { getSession } }) => { }; ``` -#### Shared Load functions and pages +### Shared Load functions and pages To be able to use Supabase in shared load functions and inside pages you need to create a Supabase client in the root layout load: ```ts // src/routes/+layout.ts -import { invalidate } from '$app/navigation'; import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL @@ -113,10 +112,7 @@ export const load: LayoutLoad = async ({ fetch, data, depends }) => { supabaseUrl: PUBLIC_SUPABASE_URL, supabaseKey: PUBLIC_SUPABASE_ANON_KEY, event: { fetch }, - serverSession: data.session, - onAuthStateChange() { - invalidate('supabase:auth'); - } + serverSession: data.session }); const { @@ -129,10 +125,41 @@ export const load: LayoutLoad = async ({ fetch, data, depends }) => { Access the client inside pages by `$page.data.supabase` or `data.supabase` when using `export let data: PageData`. -The usage of `depends` and `invalidate` tells sveltekit that this load function should be executed whenever the session updates to keep the page store in sync. +The usage of `depends` tells sveltekit that this load function should be executed whenever `invalidate` is called to keep the page store in sync. `createSupabaseLoadClient` caches the client when running in a browser environment and therefore does not create a new client for every time the load function runs. +### Setting up the event listener on the client side + +We need to create an event listener in the root `+layout.svelte` file in order catch supabase events being triggered. + +```svelte + + + + +``` + +The usage of `invalidate` tells sveltekit that the root `+layout.ts` load function should be executed whenever the session updates to keep the page store in sync. + ### Generate types from your database In order to get the most out of TypeScript and it's intellisense, you should import the generated Database types into the `app.d.ts` type definition file that comes with your SvelteKit project, where `import('./DatabaseDefinitions')` points to the generated types file outlined in [v2 docs here](https://supabase.com/docs/reference/javascript/release-notes#typescript-support) after you have logged in, linked, and generated types through the Supabase CLI. @@ -183,7 +210,7 @@ You can now determine if a user is authenticated on the client-side by checking For [row level security](https://supabase.com/docs/guides/auth/row-level-security) to work properly when fetching data client-side, you need to use `supabaseClient` from `PageData` and only run your query once the session is defined client-side: ```html - + + +``` + @@ -606,7 +655,7 @@ declare global { ```html title=src/routes/profile/+page.svelte - - - - User Management - - -
        - -
        -``` - -Create a new `src/routes/+layout.ts` file to handle the session and the supabase object on the client-side. - -```ts title=src/routes/+layout.ts -// src/routes/+layout.ts -import { invalidate } from '$app/navigation'; -import { - PUBLIC_SUPABASE_ANON_KEY, - PUBLIC_SUPABASE_URL -} from '$env/static/public'; -import { createSupabaseLoadClient } from '@supabase/auth-helpers-sveltekit'; -import type { LayoutLoad } from './$types'; -import type { Database } from '../DatabaseDefinitions'; - -export const load: LayoutLoad = async ({ fetch, data, depends }) => { - depends('supabase:auth'); - - const supabase = createSupabaseLoadClient({ - supabaseUrl: PUBLIC_SUPABASE_URL, - supabaseKey: PUBLIC_SUPABASE_ANON_KEY, - event: { fetch }, - serverSession: data.session, - onAuthStateChange() { - invalidate('supabase:auth'); - } - }); - - const { - data: { session } - } = await supabase.auth.getSession(); - - return { supabase, session }; -}; -``` - Create a new `src/routes/+layout.server.ts` file to handle the session on the server-side. ```ts title=src/routes/+layout.server.ts @@ -197,6 +135,70 @@ export const load: LayoutServerLoad = async ({ locals: { getSession } }) => { }; ``` +> Start your dev server (`npm run dev`) in order to generate the `./$types` files we are referencing in our project. + +Create a new `src/routes/+layout.ts` file to handle the session and the supabase object on the client-side. + +```ts title=src/routes/+layout.ts +// src/routes/+layout.ts +import { invalidate } from '$app/navigation'; +import { + PUBLIC_SUPABASE_ANON_KEY, + PUBLIC_SUPABASE_URL +} from '$env/static/public'; +import { createSupabaseLoadClient } from '@supabase/auth-helpers-sveltekit'; +import type { LayoutLoad } from './$types'; + +export const load: LayoutLoad = async ({ fetch, data, depends }) => { + depends('supabase:auth'); + + const supabase = createSupabaseLoadClient({ + supabaseUrl: PUBLIC_SUPABASE_URL, + supabaseKey: PUBLIC_SUPABASE_ANON_KEY, + event: { fetch }, + serverSession: data.session + }); + + const { + data: { session } + } = await supabase.auth.getSession(); + + return { supabase, session }; +}; +``` + +Update your `src/routes/+layout.svelte`: + +```svelte title=src/routes/+layout.svelte + + + + + User Management + + +
        + +
        +``` + ### Set up a Login page #### Supabase Auth UI @@ -249,10 +251,10 @@ and send it over to the server. This all happens behind the scenes and isn't som import { redirect } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; -export const load: PageServerLoad = async ({ parent, url }) => { - const { session } = await parent(); +export const load: PageServerLoad = async ({ url, locals: { getSession } }) => { + const session = await getSession(); - // if the user is already logged in return them to the account page + // if the user is already logged in return them to the account page if (session) { throw redirect(303, '/account'); } @@ -558,7 +560,7 @@ And then we can add the widget to the Account page: ```svelte title=src/routes/account/+page.svelte - diff --git a/apps/docs/pages/guides/integrations.mdx b/apps/docs/pages/guides/integrations.mdx index 60e06333e2c..1f491796c81 100644 --- a/apps/docs/pages/guides/integrations.mdx +++ b/apps/docs/pages/guides/integrations.mdx @@ -15,7 +15,7 @@ Explore a variety of integrations from Supabase partners. Need a different integ

        {item.name}

        - {item.items.map((integration) => ( + {item.items?.map((integration) => ( Database backups do not include objects stored via the Storage API, as the database only includes diff --git a/apps/docs/pages/guides/platform/going-into-prod.mdx b/apps/docs/pages/guides/platform/going-into-prod.mdx index 30274b5277a..2e68fbb8998 100644 --- a/apps/docs/pages/guides/platform/going-into-prod.mdx +++ b/apps/docs/pages/guides/platform/going-into-prod.mdx @@ -34,7 +34,7 @@ After developing your project and deciding it's Production Ready, you should run - Perform load testing (preferably on a staging env) - Tools like [k6](https://k6.io/) can simulate traffic from many different users. - Upgrade your database if you require more resources. If you need anything beyond what is listed, contact enterprise@supabase.io. -- If you are expecting a surge in traffic (for a big launch), let the team know by sending your Project Ref to us (support@supabase.io) with more details about your launch. We'll keep an eye on your project. +- If you are expecting a surge in traffic (for a big launch) [contact support](https://app.supabase.com/support/new) with more details about your launch. We'll keep an eye on your project. ## Availability @@ -47,14 +47,14 @@ After developing your project and deciding it's Production Ready, you should run - Database backups are not available for download on the free tier. - You can set up your own backup systems using tools like [pg_dump](https://www.postgresqltutorial.com/postgresql-backup-database/) or [wal-g](https://github.com/wal-g/wal-g). - Nightly backups for Pro tier projects are available on the Supabase dashboard for up to 7 days. -- Upgrading to the Supabase Pro Tier will give you access to email support on support@supabase.io +- Upgrading to the Supabase Pro Tier will give you [access to our support team](https://app.supabase.com/support/new). ## Rate Limiting, Resource Allocation, & Abuse Prevention - Supabase employs a number of safeguards against bursts of incoming traffic to prevent abuse and help maximize stability across the platform - If you're expecting high load events including production launches or heavy load testing, or prolonged high resource usage please give us at least 2 weeks notice. You can do this by opening a ticket via the [support form](https://app.supabase.com/support/new). -### Rate Limits +### Auth Rate Limits - The table below shows the rate limit quotas on the following authentication endpoints: @@ -69,6 +69,11 @@ After developing your project and deciding it's Production Ready, you should run | Token refresh requests | `/auth/v1/token` | IP Address | 360 requests per hour (with bursts up to 30 requests) | | Create or Verify an MFA challenge | `/auth/v1/factors/:id/challenge` `/auth/v1/factors/:id/verify` | IP Address | 15 requests per minute (with bursts up to 30 requests) | +### Realtime Rate Limits + +- Review the [Realtime rate limits](/docs/guides/realtime/rate-limits). +- If you need rate limits increased you can always [contact support](https://app.supabase.com/support/new). + ### Abuse Prevention - Supabase provides CAPTCHA protection on the signup, sign-in and password reset endpoints. Please refer to [our guide](/docs/guides/auth/auth-captcha) on how to protect against abuse using this method. diff --git a/apps/docs/pages/guides/platform/logs.mdx b/apps/docs/pages/guides/platform/logs.mdx index a748b241fbc..897f22cebda 100644 --- a/apps/docs/pages/guides/platform/logs.mdx +++ b/apps/docs/pages/guides/platform/logs.mdx @@ -60,6 +60,12 @@ Supabase provides a logging interface specific to each product. You can use simp [Realtime logs](https://app.supabase.com/project/_/logs/realtime-logs) show all server logs for your [Realtime API usage](../../guides/realtime). + + +Realtime connections are not logged by default. Turn on [Realtime connection logs per client](#logging-realtime-connections) with the `log_level` parameter. + + + ![Realtime Logs](/docs/img/guides/platform/logs/logs-realtime.png) @@ -126,6 +132,21 @@ If any permission errors are encountered when executing `alter role postgres ... +## Logging Realtime Connections + +Realtime doesn't log new WebSocket connections or Channel joins by default. Enable connection logging per client by including an `info` `log_level` parameter when instantiating the Supabase client. + +```javascript +import { createClient } from '@supabase/supabase-js' + +const options = { + realtime: { + log_level: 'info', + }, +} +const supabase = createClient('https://xyzcompany.supabase.co', 'public-anon-key', options) +``` + ## Logs Explorer The [Logs Explorer](https://app.supabase.com/project/_/logs-explorer) exposes logs from each part of the Supabase stack as a separate table that can be queried and joined using SQL. diff --git a/apps/docs/pages/guides/platform/spend-cap.mdx b/apps/docs/pages/guides/platform/spend-cap.mdx new file mode 100644 index 00000000000..f90254db1bb --- /dev/null +++ b/apps/docs/pages/guides/platform/spend-cap.mdx @@ -0,0 +1,55 @@ +import Layout from '~/layouts/DefaultGuideLayout' + +export const meta = { + title: 'Spend cap', + description: 'Learn about the cost control of Supabase.', +} + +Supabase offers a "Spend Cap" on each project to manage your usage and costs. + +This Spend Cap determines whether your project can exceed the free quota allowance of any [line item](#what-can-i-be-charged-for) in a given billing cycle. +If you exceed the free quota allowance of a line item, and the Spend Cap is **"off"**, then you will be billed for any additional usage on that line item. + +## What additional usage can I be charged for? + +Supabase charges for certain usage line items in your project. Those line items include: + +- Database Size +- Storage Size +- Egress +- Asset transformations +- Realtime concurrent peak connections +- Realtime messages +- Edge function invocations +- Number of Edge functions + +You can view the pricing, and free quota allowance of each line item on the [pricing page](/pricing). + +You can monitor your usage and costs using the [Usage section of your project](https://app.supabase.com/project/_/settings/billing/usage) in the dashboard. +This will show you how much you have spent so far in the current billing cycle, as well as an estimate of your expected total cost for the cycle based on your current usage. + +## How can I turn the Spend Cap on or off? + +To set up a Spend Cap in your project, navigate to the [Subscription section](https://app.supabase.com/project/_/settings/billing/subscription) of your project. +From there, you can click on "Change subscription" to select the plan you want to use and, at the same time, turn on or off the Spend Cap. + +## What happens when the Spend Cap is on? + +Once you use all your free quota allowance for a [line item](#what-can-i-be-charged-for), your project will not allow any further usage on that line item until the next billing cycle begins. + +This can help you to avoid unexpected costs and manage your expenses more effectively. + +## What happens when Spend Cap is off? + +Your project will continue to operate after using all the free quota allowance for each [line item](#what-can-i-be-charged-for). +This additional usage will be charged based on the line item's cost per unit outlined on the [pricing page](/pricing). + + + When your Spend Cap is **off**, we recommend monitoring your consumption and costs using the [Usage + section of your project](https://app.supabase.com/project/_/settings/billing/usage) in the + dashboard. + + +export const Page = ({ children }) => + +export default Page diff --git a/apps/docs/pages/guides/realtime/architecture.mdx b/apps/docs/pages/guides/realtime/architecture.mdx new file mode 100644 index 00000000000..5a4173bdb88 --- /dev/null +++ b/apps/docs/pages/guides/realtime/architecture.mdx @@ -0,0 +1,58 @@ +import Layout from '~/layouts/DefaultGuideLayout' + +export const meta = { + id: 'architecture', + title: 'Realtime Architecture', + description: 'Architecture of the Supabase Realtime service', + sidebar_label: 'Architecture', +} + +Realtime is a globally distributed Elixir cluster. Clients can connect to any node in the cluster via WebSockets and send messages to any other client connected to the cluster. + +Realtime is written in [Elixir](https://elixir-lang.org/), which compiles to [Erlang](https://www.erlang.org/), and utilizes many tools the [Phoenix Framework](https://www.phoenixframework.org/) provides out of the box. + +![Global Architecture](/docs/img/guides/realtime/realtime-arch.png) + +## Elixir & Phoenix + +Phoenix is fast and able to handle millions of concurrent connections. + +Phoenix can handle many concurrent connections because Elixir provides lightweight processes (not OS processes) to work with. + +Client-facing WebSocket servers need to handle many concurrent connections. Elixir & Phoenix let the Supabase Realtime cluster do this easily. + +## Global Cluster + +Presence is an in-memory key-value store backed by a CRDT. When a user is connected to the cluster the state of that user is sent to all connected Realtime nodes. + +Broadcast lets you send a message from any connected client to a Channel. Any other client connected to that same Channel will receive that message. + +This works globally. A client connected to a Realtime node in the United States can send a message to another client connected to a node in Singapore. Simply connect two clients to the same Realtime Channel and they'll all receive the same messages. + +Broadcast is useful for getting messages to users in the same location very quickly. If a group of clients are connected to a node in Singapore, the message only needs to go to that Realtime node in Singapore and back down. If users are close to a Realtime node they'll get Broadcast messages in the time it takes to ping the cluster. + +Thanks to the Realtime cluster, you (an amazing Supabase user) don't have to think about which regions your clients are connected to. + +If you're using Broadcast, Presence, or streaming database changes, messages will always get to your users via the shortest path possible. + +## Connecting to a Database + +Realtime allows you to listen to changes from your Postgres database. When a new client connects to Realtime and initializes the `postgres_changes` Realtime Extension the cluster will connect to your Postgres database and start streaming changes from a replication slot. + +Realtime knows the region your database is in, and connects to it from the closest region possible. + +Every Realtime region has at least two nodes so if one node goes offline the other node should reconnect and start streaming changes again. + +## Streaming the Write-Ahead Log + +A Postgres logical replication slot is acquired when connecting to your database. + +Realtime delivers changes by polling the replication slot and appending channel subscription IDs to each wal record. + +Subscription IDs are Erlang processes representing underlying sockets on the cluster. These IDs are globally unique and messages to processes are routed automatically by the Erlang virtual machine. + +After receiving results from the polling query, with subscription IDs appended, Realtime delivers records to those clients. + +export const Page = ({ children }) => + +export default Page diff --git a/apps/docs/pages/guides/realtime/bring-your-own-database.mdx b/apps/docs/pages/guides/realtime/bring-your-own-database.mdx new file mode 100644 index 00000000000..57600f9cf2c --- /dev/null +++ b/apps/docs/pages/guides/realtime/bring-your-own-database.mdx @@ -0,0 +1,107 @@ +import Layout from '~/layouts/DefaultGuideLayout' + +export const meta = { + id: 'bring-your-own-database', + title: 'Bring Your Own Database', + description: 'Bring your own Postgres database to subscribe to Realtime changes', + sidebar_label: 'Bring Your Own Database', +} + +Realtime Database Changes works with any Postgres database that has logical replication enabled and the [wal2json](https://github.com/eulerto/wal2json) extension installed. + + + +After setting up your database for Realtime, [submit a support ticket](https://app.supabase.com/support/new) if you want to use your database with hosted Supabase Realtime. + + + +The following steps will make sure that your database is properly set up to work with Realtime. + +## `wal2json` Extension + +Realtime relies on the `wal2json` Postgres extension to format database changes to JSON which are then sent to Realtime subscribers. + +Postgres databases managed by AWS RDS and Google Cloud SQL should already have `wal2json` installed. +Please check to make sure this is indeed the case with your Postgres database. + +## Logical Replication Configuration + +Realtime relies on Postgres' logical replication functionality to get database changes. Please enable logical replication on your database and configure the following settings: + +- `max_replication_slots`: we recommend `10` because Realtime requires a few slots plus the slots you'll need for your non-Realtime logical replication needs. +- `max_slot_wal_keep_size`: we recommend `1024` (MB) so Realtime can attempt to deliver more database changes stored in Postgres. + +## Realtime Database Setup + +### `supabase_realtime` Publication + +Create `supabase_realtime` publication and add tables you want Realtime to listen to: + +```sql +create publication supabase_realtime with (publish = 'insert, update, delete'); + +alter publication supabase_realtime add table messages, users; +``` + + + +If the payload contains a `401 Unauthorized` then you will need to grant `select` privileges to the table for the database role you want to authorize to receive database changes: + +```sql +grant select on table messages to anon; +``` + + + +### `realtime` Schema + +Create a `realtime` schema: + +```sql +create schema realtime; +``` + +### `supabase_realtime_admin` Role + +Create a `supabase_realtime_admin` database role and grant it replication permissions: + +```sql +create role supabase_realtime_admin with noinherit login password 'secure-password'; +``` + +Make sure to grant `supabase_realtime_admin` role with replication permissions. This step will vary based on your database provider. + +For example, if your database is managed by AWS RDS then you can run: + +```sql +grant rds_replication to supabase_realtime_admin; +``` + +### `supabase_realtime_admin` Privileges + +Grant `supabase_realtime_admin` privileges for `realtime` schema and all related Realtime objects: + +```sql +grant all on schema realtime to supabase_realtime_admin; +grant all on all tables in schema realtime to supabase_realtime_admin; +grant all on all sequences in schema realtime to supabase_realtime_admin; +grant all on all routines in schema realtime to supabase_realtime_admin; +``` + +### `authenticated` Role + +Create an `authenticated` role: + +```sql +create role authenticated nologin noinherit; +``` + + + +This role is hardcoded into Realtime migrations so it's required for the time being. + + + +export const Page = ({ children }) => + +export default Page diff --git a/apps/docs/pages/guides/realtime/channels.mdx b/apps/docs/pages/guides/realtime/channels.mdx new file mode 100644 index 00000000000..b016f4bf6f6 --- /dev/null +++ b/apps/docs/pages/guides/realtime/channels.mdx @@ -0,0 +1,22 @@ +import Layout from '~/layouts/DefaultGuideLayout' + +export const meta = { + id: 'channels', + title: 'Realtime Channels', + description: 'WebSockets are Channels on Supabase Realtime', + sidebar_label: 'Channels', +} + +You can uniquely reference a channel by its `topic` you define when you initialize your Supabase Realtime client. Everyone connected to the same Channel topic will receive the same messages. + +Clients can send and receive messages bi-directionally over a Channel. The Realtime backend can also push messages to all clients connected to the same Channel. + +A single client can receive change records from Postgres, Broadcast messages from other clients and Presence updates all over the same Channel. + +Channels are implemented over [Phoenix Channels](https://hexdocs.pm/phoenix/channels.html) which uses [Phoenix.PubSub](https://hexdocs.pm/phoenix_pubsub/Phoenix.PubSub.html) with the default `Phoenix.PubSub.PG2` adapter. + +The PG2 adapter utilizes Erlang [process groups](https://www.erlang.org/docs/18/man/pg2.html) to implement the PubSub model where a publisher can send messages to many subscribers. + +export const Page = ({ children }) => + +export default Page diff --git a/apps/docs/pages/guides/realtime/extensions.mdx b/apps/docs/pages/guides/realtime/extensions.mdx new file mode 100644 index 00000000000..3f9db9c4702 --- /dev/null +++ b/apps/docs/pages/guides/realtime/extensions.mdx @@ -0,0 +1,27 @@ +import Layout from '~/layouts/DefaultGuideLayout' + +export const meta = { + id: 'extensions', + title: 'Realtime Extensions', + description: + 'Extensions enable powerful Realtime features like Presence, Broadcast and database change streaming', + sidebar_label: 'Extensions', +} + +Supabase Realtime provides multiplayer features so that you can build real-time applications. The server has several built-in extensions to solve specific tasks. + +- [Broadcast](/docs/guides/realtime/broadcast): for sending rapid, ephemeral messages to other connected clients. A typical use-case is tracking mouse movements. +- [Presence](/docs/guides/realtime/presence): for sending user state between connected clients. A typical use-case is simply an "online" status, which will disappear when a user is disconnected. +- [Postgres Changes](/docs/guides/realtime/postgres-changes): for receiving database changes in real-time. + +## Choosing between Broadcast and Presence + +Defaulting to using Broadcast is a good idea, and then use Presence sparingly. The Presence extension merges all changes into a shared state for every client connected to the same "channel". If you _do_ use Presence, it's best to throttle your changes so that you are sending updates less frequently. + +## Future possibilities + +Realtime extensions are still under development. We have many more extensions planned. You can follow our progress by watching the [Realtime](https://github.com/supabase/realtime) repo on GitHub. + +export const Page = ({ children }) => + +export default Page diff --git a/apps/docs/pages/guides/realtime/broadcast.mdx b/apps/docs/pages/guides/realtime/extensions/broadcast.mdx similarity index 100% rename from apps/docs/pages/guides/realtime/broadcast.mdx rename to apps/docs/pages/guides/realtime/extensions/broadcast.mdx diff --git a/apps/docs/pages/guides/realtime/postgres-changes.mdx b/apps/docs/pages/guides/realtime/extensions/postgres-changes.mdx similarity index 93% rename from apps/docs/pages/guides/realtime/postgres-changes.mdx rename to apps/docs/pages/guides/realtime/extensions/postgres-changes.mdx index 0f3b15e6bac..07b819990ac 100644 --- a/apps/docs/pages/guides/realtime/postgres-changes.mdx +++ b/apps/docs/pages/guides/realtime/extensions/postgres-changes.mdx @@ -15,7 +15,9 @@ Clients can choose to receive `INSERT`, `UPDATE`, `DELETE`, or `*` (all) changes Postgres Changes works out of the box for tables in the `public` schema. You can listen to tables in your private schemas by granting table `SELECT` permissions to the database role found in your access token. You can run a query similar to the following: ```sql -GRANT SELECT ON "private_schema"."table" TO authenticated; +grant +select + on "private_schema"."table" to authenticated; ``` @@ -29,15 +31,19 @@ You can do this in the [Replication](https://app.supabase.com/project/_/database ```sql begin; - -- remove the supabase_realtime publication - drop publication if exists supabase_realtime; - -- re-create the supabase_realtime publication with no tables - create publication supabase_realtime; +-- remove the supabase_realtime publication +drop + publication if exists supabase_realtime; + +-- re-create the supabase_realtime publication with no tables +create publication supabase_realtime; + commit; -- add a table to the publication -alter publication supabase_realtime add table messages; +alter + publication supabase_realtime add table messages; ``` ### Full `old` Record @@ -46,9 +52,16 @@ By default, only `new` record changes are sent but if you want to receive the `o you can set the `replica identity` of your table to `full`: ```sql -alter table messages replica identity full; +alter table + messages replica identity full; ``` + + +RLS policies are not applied to `DELETE` statements. When RLS is enabled and `replica identity` is set to `full` on a table, the `old` record contains only the primary key(s). + + + ## Schema Changes To listen to all changes in the `public` schema: diff --git a/apps/docs/pages/guides/realtime/presence.mdx b/apps/docs/pages/guides/realtime/extensions/presence.mdx similarity index 100% rename from apps/docs/pages/guides/realtime/presence.mdx rename to apps/docs/pages/guides/realtime/extensions/presence.mdx diff --git a/apps/docs/pages/guides/realtime/protocol.mdx b/apps/docs/pages/guides/realtime/protocol.mdx new file mode 100644 index 00000000000..488d2975612 --- /dev/null +++ b/apps/docs/pages/guides/realtime/protocol.mdx @@ -0,0 +1,194 @@ +import Layout from '~/layouts/DefaultGuideLayout' + +export const meta = { + id: 'protocol', + title: 'Realtime Protocol', + description: 'Understanding Realtime Protocol', +} + +The Realtime Protocol is a set of message formats used for communication over a WebSocket connection between a Realtime client and server. These messages are used to initiate a connection, update access tokens, receive system status updates, and receive real-time updates from the Postgres database. + +## Connection + +In the initial message, the client sends a message specifying the features they want to use (Broadcast, Presence, Postgres Changes). + +```ts +{ + "event": "phx_join", + "topic": string, + "payload": { + "config": { + "broadcast": { + "self": boolean + }, + "presence": { + "key": string + }, + "postgres_changes": [ + { + "event": "*" | "INSERT" | "UPDATE" | "DELETE", + "schema": string, + "table": string, + "filter": string + '=' + "eq" | "neq" | "gt" | "gte" | "lt" | "lte" | "in" + '.' + string + } + ] + } + }, + "ref": string +} +``` + + +The `in` filter has the format `COLUMN_NAME=in.(value1,value2,value3)`. However, other filters use the format `COLUMN_NAME=FILTER_NAME.value`. + + +In response, the server sends the Postgres configuration with a unique ID. With this ID, the client should route incoming changes to the appropriate callback. + +```ts +{ + "event": "phx_reply", + "topic": string, + "payload": { + "response": { + "postgres_changes": [ + { + "id": number, + "event": "*" | "INSERT" | "UPDATE" | "DELETE", + "schema": string, + "table": string, + "filter": string + '=' + "eq" | "neq" | "gt" | "gte" | "lt" | "lte" | "in" + '.' + string + } + ] + }, + "status": "ok" | "error" + }, + "ref": string +} +``` + +## System messages + +System message are used to inform a client about the status of the Postgres subscription. The `payload.status` indicates if the subscription successful or not. +The body of the `payload.message` can be "Subscribed to PostgreSQL" or "Subscribing to PostgreSQL failed" with subscription params. + +```ts +{ + "event": "system", + "topic": string, + "payload":{ + "channel": string, + "extension": "postgres_changes", + "message": "Subscribed to PostgreSQL" | "Subscribing to PostgreSQL failed", + "status": "ok" | "error" + }, + "ref": null, +} +``` + +## Heartbeat + +The heartbeat message should be sent every 30 seconds to avoid a connection timeout. + +```ts +{ + "event": "heartbeat", + "topic": "phoenix", + "payload": {}, + "ref": string +} +``` + +## Access Token + +To update the access token, you need to send to the server a message specifying a new token in the `payload.access_token` value. + +```ts +{ + "event": "access_token", + "topic": string, + "payload":{ + "access_token": string + }, + "ref": string +} +``` + +## Postgres CDC message + +Realtime sends a message with the following structure + +```ts +{ + "event": "postgres_changes", + "topic": string, + "payload": { + "data": { + "columns": Array<{name: string, type: string}>, + "commit_timestamp": string, + "errors": null | string, + "old_record": {"id": number | string}, + "record": {[key: string]: boolean | number | string | null}, + "type": "*" | "INSERT" | "UPDATE" | "DELETE", + "schema": string, + "table": string + }, + "ids": Array + }, + "ref": null +} +``` + +## Broadcast message + +Structure of the broadcast event + +```ts +{ + "event": "broadcast", + "topic": string, + "payload": { + "event": string, + "payload": {[key: string]: boolean | number | string | null | undefined}, + "type": "broadcast" + }, + "ref": null +} +``` + +## Presence message + +The Presence events allow clients to monitor the online status of other clients in real-time. + +### State Update + +After joining, the server sends a `presence_state` message to a client with presence information. The payload field contains keys in UUID format, where each key represents a client and its value is a JSON object containing information about that client. + +```ts +{ + "event": "presence_state", + "topic": string, + "payload": { + [key: string]: {metas: Array<{phx_ref: string, name: string, t: float}>} + }, + "ref": null +} +``` + +### Diff Update + +After a change to the presence state, such as a client joining or leaving, the server sends a presence_diff message to update the client's view of the presence state. The payload field contains two keys, `joins` and `leaves`, which represent clients that have joined and left, respectively. The values associated with each key are UUIDs of the clients. + +```ts +{ + "event": "presence_diff", + "topic": string, + "payload": { + "joins": {metas: Array<{phx_ref: string, name: string, t: float}>}, + "leaves": {metas: Array<{phx_ref: string, name: string, t: float}>} + }, + "ref": null +} +``` + +export const Page = ({ children }) => +export default Page diff --git a/apps/docs/pages/guides/realtime/realtime-with-nextjs.mdx b/apps/docs/pages/guides/realtime/realtime-with-nextjs.mdx new file mode 100644 index 00000000000..e60afb4f74a --- /dev/null +++ b/apps/docs/pages/guides/realtime/realtime-with-nextjs.mdx @@ -0,0 +1,24 @@ +import Layout from '~/layouts/DefaultGuideLayout' + +export const meta = { + id: 'realtime-with-nextjs', + title: 'Using Realtime with Next.js', + description: 'Client & Server Components in Next.js with Realtime Updates', + sidebar_label: 'Videos', +} + +In this guide we explore the best ways to receive realtime Postgres changes with your Next.js application. +We'll show both client and serverside updates, and explore the which option is best. + +
        + +
        + +export const Page = ({ children }) => + +export default Page diff --git a/apps/docs/pages/guides/realtime/subscribing-to-database-changes.mdx b/apps/docs/pages/guides/realtime/subscribing-to-database-changes.mdx new file mode 100644 index 00000000000..f22764ac7c8 --- /dev/null +++ b/apps/docs/pages/guides/realtime/subscribing-to-database-changes.mdx @@ -0,0 +1,80 @@ +import Layout from '~/layouts/DefaultGuideLayout' + +export const meta = { + id: 'subscribing-to-database-changes', + title: 'Subscribing to Database Changes', + description: 'Listen to database changes in real-time from your website or application.', + sidebar_label: 'Videos', +} + +Supabase allows you to subscribe to real-time changes on your database from your client application. + +You can listen to database changes using the [Postgres Changes](/docs/guides/realtime/postgres-changes) extension. + +## Demo + +
        + +
        + +## Streaming inserts + +You can use the `INSERT` event to stream all new rows. + +```js +const { createClient } = require('@supabase/supabase-js') + +const supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_KEY) + +const channel = supabase + .channel('schema-db-changes') + .on( + 'postgres_changes', + { + event: 'INSERT', + schema: 'public', + }, + (payload) => console.log(payload) + ) + .subscribe() +``` + +## Streaming updates + +You can use the `UPDATE` event to stream all updated rows. + +```js +const { createClient } = require('@supabase/supabase-js') + +const supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_KEY) + +const channel = supabase + .channel('schema-db-changes') + .on( + 'postgres_changes', + { + event: 'UPDATE', + schema: 'public', + }, + (payload) => console.log(payload) + ) + .subscribe() +``` + +## More resources + +- Learn more about the [Postgres Changes](/docs/guides/realtime/postgres-changes) extension. +- Client Libraries: + - [JavaScript](/docs/reference/javascript/subscribe) + - [Flutter](/docs/reference/dart/stream) + - [Python](/docs/reference/python/subscribe) + - [C#](/docs/reference/csharp/subscribe) + +export const Page = ({ children }) => + +export default Page diff --git a/apps/docs/pages/guides/resources/examples.mdx b/apps/docs/pages/guides/resources/examples.mdx index 18c835d8298..ae03bc48105 100644 --- a/apps/docs/pages/guides/resources/examples.mdx +++ b/apps/docs/pages/guides/resources/examples.mdx @@ -64,7 +64,7 @@ Build a basic Todo List with Supabase and your favorite frontend framework: - [Svelte Todo List.](https://github.com/supabase/supabase/tree/master/examples/todo-list/sveltejs-todo-list) - [Vue 3 Todo List (Typescript).](https://github.com/supabase/examples/tree/main/supabase-js-v1/todo-list/vue3-ts-todo-list) - [Angular Todo List.](https://github.com/supabase/examples/tree/main/supabase-js-v1/todo-list/angular-todo-list) -- [Nuxt 3 Todo List.](https://github.com/nuxt-community/supabase-module/tree/main/demo) +- [Nuxt 3 Todo List.](https://github.com/nuxt-modules/supabase/tree/main/demo) ### Auth examples @@ -92,8 +92,8 @@ Build a basic Todo List with Supabase and your favorite frontend framework: - `useSupabase`: Supabase React Hooks. [GitHub](https://github.com/gbibeaul/use-supabase) - `vue-supabase`: Supabase Vue wrapper. [GitHub](https://github.com/supabase/vue-supabase) - `vue-3-supabase`: Supabase Vue 3 wrapper. [GitHub](https://github.com/DidoMarchet/vue-3-supabase) -- `nuxt-supabase`: Supabase Nuxt wrapper. [GitHub](https://github.com/supabase/nuxt-supabase) -- `@nuxtjs/supabase`: Supabase Nuxt 3 module. [GitHub](https://github.com/nuxt-community/supabase-module) +- `nuxt-supabase`: Supabase Nuxt wrapper. [GitHub](https://github.com/supabase-community/nuxt-supabase) +- `@nuxtjs/supabase`: Supabase Nuxt 3 module. [GitHub](https://github.com/nuxt-modules/supabase) - `react-supabase`: Supabase React Hooks. [Docs](https://react-supabase.vercel.app) [GitHub](https://github.com/tmm/react-supabase) - ` @triniwiz/nativescript-supabase`: Supabase NativeScript. [GitHub](https://github.com/triniwiz/nativescript-plugins/tree/master/packages/nativescript-supabase) @@ -133,10 +133,11 @@ Build a basic Todo List with Supabase and your favorite frontend framework: - Supabase + Nuxt3 (Nuxt Starter Kit) [GitHub](https://github.com/one-aalam/nuxt-starter-kit) - Supabase + Remix (Remix Starter Kit) [GitHub](https://github.com/one-aalam/remix-starter-kit) - Supabase + Angular (Angular Starter Kit) [GitHub](https://github.com/one-aalam/ng-starter-kit) -- Supabase + Nuxt3 + nuxtjs/supabase [Github](https://github.com/nuxt-community/supabase-module/tree/main/demo) +- Supabase + Nuxt3 + nuxtjs/supabase [Github](https://github.com/nuxt-modules/supabase/tree/main/demo) - Supabase + Ory Kratos & Ory Oathkeeper [Github](https://github.com/ory/examples/tree/master/kratos-keto-oathkeeper-supabase) - Supabase + Ory Cloud [Github](https://github.com/ory/examples/tree/master/supabase-ory-cloud) - Supabase Auth with React Native + Next.js (Monorepo) [Github](https://github.com/mateoguzmana/react-native-next-supabase-auth-monorepo) +- Supabase Auth with Nuxt3 [Github](https://github.com/zackha/supaAuth) ### Blog Posts diff --git a/apps/docs/pages/guides/self-hosting/realtime/config.mdx b/apps/docs/pages/guides/self-hosting/realtime/config.mdx index a52dc2c111a..f576951b201 100644 --- a/apps/docs/pages/guides/self-hosting/realtime/config.mdx +++ b/apps/docs/pages/guides/self-hosting/realtime/config.mdx @@ -11,6 +11,8 @@ export const meta = {
        {spec.info.description}
        +See [this guide](/docs/guides/realtime/bring-your-own-database) if you want to configure your database to work with Realtime. +
        {spec.info.tags.map(tag => { return ( diff --git a/apps/docs/pages/guides/storage/quickstart.mdx b/apps/docs/pages/guides/storage/quickstart.mdx index ba0f54e6943..118bd2513dd 100644 --- a/apps/docs/pages/guides/storage/quickstart.mdx +++ b/apps/docs/pages/guides/storage/quickstart.mdx @@ -7,9 +7,7 @@ export const meta = { sidebar_label: 'Quickstart', } -This guide shows the basic functionality of Supabase Storage. Find a full [example application on GitHub](https://github.com/supabase/supabase/tree/master/examples/user-management/nextjs-ts-user-management) or deploy it with Vercel for a preview: - -[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/git/external?repository-url=https%3A%2F%2Fgithub.com%2Fsupabase%2Fsupabase%2Ftree%2Fmaster%2Fexamples%2Fuser-management%2Fnextjs-ts-user-management&project-name=supabase-user-management&repository-name=supabase-user-management&demo-title=Supabase%20User%20Management&demo-description=An%20example%20web%20app%20using%20Supabase%20and%20Next.js&demo-url=https%3A%2F%2Fsupabase-nextjs-ts-user-management.vercel.app&demo-image=https%3A%2F%2Fi.imgur.com%2FZ3HkQqe.png&integration-ids=oac_jUduyjQgOyzev1fjrW83NYOv&external-id=nextjs-user-management) +This guide shows the basic functionality of Supabase Storage. Find a full [example application on GitHub](https://github.com/supabase/supabase/tree/master/examples/user-management/nextjs-ts-user-management) or deploy it with [Vercel for a preview](https://vercel.com/new/git/external?repository-url=https%3A%2F%2Fgithub.com%2Fsupabase%2Fsupabase%2Ftree%2Fmaster%2Fexamples%2Fuser-management%2Fnextjs-ts-user-management&project-name=supabase-user-management&repository-name=supabase-user-management&demo-title=Supabase%20User%20Management&demo-description=An%20example%20web%20app%20using%20Supabase%20and%20Next.js&demo-url=https%3A%2F%2Fsupabase-nextjs-ts-user-management.vercel.app&demo-image=https%3A%2F%2Fi.imgur.com%2FZ3HkQqe.png&integration-ids=oac_jUduyjQgOyzev1fjrW83NYOv&external-id=nextjs-user-management). diff --git a/apps/docs/pages/reference/auth/[...slug].tsx b/apps/docs/pages/reference/auth/[...slug].tsx index ad57871576b..68603c86b07 100644 --- a/apps/docs/pages/reference/auth/[...slug].tsx +++ b/apps/docs/pages/reference/auth/[...slug].tsx @@ -1,9 +1,7 @@ +import { CodeBlock, Tabs } from 'ui' import specFile from '~/../../spec/transforms/auth_v1_openapi_deparsed.json' assert { type: 'json' } -import { gen_v3, enrichedOperation } from '~/lib/refGenerator/helpers' -import { Tabs } from '~/../../packages/ui' +import { gen_v3 } from '~/lib/refGenerator/helpers' -// @ts-ignore -import CodeBlock from '~/components/CodeBlock/CodeBlock' import RefSubLayout from '~/layouts/ref/RefSubLayout' export type AcceptedValue = { diff --git a/apps/docs/pages/reference/auth/config.tsx b/apps/docs/pages/reference/auth/config.tsx index 3530cef71c6..c024dbe9ecc 100644 --- a/apps/docs/pages/reference/auth/config.tsx +++ b/apps/docs/pages/reference/auth/config.tsx @@ -2,67 +2,73 @@ import specFile from '~/../../spec/gotrue_v1_config.yaml' assert { type: 'yml' } import { Parameter } from '~/lib/refGenerator/refTypes' import ReactMarkdown from 'react-markdown' +import Head from 'next/head' // Parameters are grouped on the page by tag const TAGS = ['general'] export default function Config() { return ( -
        -
        -
        -

        {specFile.info.title} Configuration

        - {specFile.info.description} -
        - {TAGS.map((tag) => - specFile.parameters - .filter((param: Parameter) => param.tags[0] === tag) - .map((parameter: Parameter, index) => ( -
        - {index === 0 &&

        {tag}

        } -
        -
        -

        - $ - {parameter.title} -

        -
        -
        -
        -

        - {parameter.description} -

        -
        -
        - Required: {parameter.required.toString()} -
        -
        -
        + ) } diff --git a/apps/docs/pages/reference/cli/config.tsx b/apps/docs/pages/reference/cli/config.tsx index ca8c6969f28..a40295b3802 100644 --- a/apps/docs/pages/reference/cli/config.tsx +++ b/apps/docs/pages/reference/cli/config.tsx @@ -4,6 +4,7 @@ import { Parameter } from '~/lib/refGenerator/refTypes' import ReactMarkdown from 'react-markdown' import GuidesTableOfContents from '~/components/GuidesTableOfContents' import { Heading } from '~/components/CustomHTMLElements' +import Head from 'next/head' // Parameters are grouped on the page by tag const TAGS = ['general', 'auth', 'api', 'database', 'dashboard', 'local', 'edge-functions'] @@ -21,69 +22,74 @@ const tocList = TAGS.map((tag) => export default function Config() { return ( -
        -
        -
        -

        CLI configuration

        -
        - {specFile.info.description} -
        - {TAGS.map((tag) => - specFile.parameters - .filter((param: Parameter) => param.tags[0] === tag) - .map((parameter: Parameter, index) => ( -
        - {index === 0 &&

        {tag}

        } -
        -
        - - {parameter.title} - -
        -
        -
        -

        - {parameter.description} -

        -
        -
        - Required: {parameter.required.toString()} -
        -
        -
        -
        - - On this page - - -
        -
        -
        + ) } diff --git a/apps/docs/pages/reference/storage/[...slug].tsx b/apps/docs/pages/reference/storage/[...slug].tsx index 9068c3008c7..bfd42d2719a 100644 --- a/apps/docs/pages/reference/storage/[...slug].tsx +++ b/apps/docs/pages/reference/storage/[...slug].tsx @@ -1,9 +1,7 @@ +import { CodeBlock, Tabs } from 'ui' import specFile from '~/../../spec/transforms/storage_v0_openapi_deparsed.json' assert { type: 'json' } -import { gen_v3, enrichedOperation } from '~/lib/refGenerator/helpers' -import { Tabs } from '~/../../packages/ui' +import { gen_v3 } from '~/lib/refGenerator/helpers' -// @ts-ignore -import CodeBlock from '~/components/CodeBlock/CodeBlock' import RefSubLayout from '~/layouts/ref/RefSubLayout' export type AcceptedValue = { diff --git a/apps/docs/public/img/guides/realtime/realtime-arch.png b/apps/docs/public/img/guides/realtime/realtime-arch.png new file mode 100644 index 00000000000..fca216142b1 Binary files /dev/null and b/apps/docs/public/img/guides/realtime/realtime-arch.png differ diff --git a/apps/docs/public/sitemap.xml b/apps/docs/public/sitemap.xml index 598060f55aa..c8b69531b42 100644 --- a/apps/docs/public/sitemap.xml +++ b/apps/docs/public/sitemap.xml @@ -18,6 +18,24 @@ 0.5 + + https://supabase.com/docs/handbook/contributing + weekly + 0.5 + + + + https://supabase.com/docs/handbook/introduction + weekly + 0.5 + + + + https://supabase.com/docs/guides/auth + weekly + 0.5 + + https://supabase.com/docs/guides/cli weekly @@ -78,24 +96,42 @@ 0.5 - - https://supabase.com/docs/handbook/contributing - weekly - 0.5 - - - - https://supabase.com/docs/handbook/introduction - weekly - 0.5 - - https://supabase.com/docs/reference/index weekly 0.5 + + https://supabase.com/docs/learn/auth-deep-dive/auth-deep-dive-jwts + weekly + 0.5 + + + + https://supabase.com/docs/learn/auth-deep-dive/auth-google-oauth + weekly + 0.5 + + + + https://supabase.com/docs/learn/auth-deep-dive/auth-gotrue + weekly + 0.5 + + + + https://supabase.com/docs/learn/auth-deep-dive/auth-policies + weekly + 0.5 + + + + https://supabase.com/docs/learn/auth-deep-dive/auth-row-level-security + weekly + 0.5 + + https://supabase.com/docs/guides/auth/auth-captcha weekly @@ -133,13 +169,13 @@ - https://supabase.com/docs/guides/auth/managing-user-data + https://supabase.com/docs/guides/auth/enterprise-sso weekly 0.5 - https://supabase.com/docs/guides/auth/overview + https://supabase.com/docs/guides/auth/managing-user-data weekly 0.5 @@ -180,6 +216,84 @@ 0.5 + + https://supabase.com/docs/guides/functions/auth + weekly + 0.5 + + + + https://supabase.com/docs/guides/functions/cicd-workflow + weekly + 0.5 + + + + https://supabase.com/docs/guides/functions/connect-to-postgres + weekly + 0.5 + + + + https://supabase.com/docs/guides/functions/cors + weekly + 0.5 + + + + https://supabase.com/docs/guides/functions/debugging + weekly + 0.5 + + + + https://supabase.com/docs/guides/functions/global-deployments + weekly + 0.5 + + + + https://supabase.com/docs/guides/functions/import-maps + weekly + 0.5 + + + + https://supabase.com/docs/guides/functions/local-development + weekly + 0.5 + + + + https://supabase.com/docs/guides/functions/quickstart + weekly + 0.5 + + + + https://supabase.com/docs/guides/functions/schedule-functions + weekly + 0.5 + + + + https://supabase.com/docs/guides/functions/secrets + weekly + 0.5 + + + + https://supabase.com/docs/guides/functions/storage-caching + weekly + 0.5 + + + + https://supabase.com/docs/guides/functions/typescript-support + weekly + 0.5 + + https://supabase.com/docs/guides/database/api weekly @@ -450,60 +564,6 @@ 0.5 - - https://supabase.com/docs/guides/functions/auth - weekly - 0.5 - - - - https://supabase.com/docs/guides/functions/cicd-workflow - weekly - 0.5 - - - - https://supabase.com/docs/guides/functions/cors - weekly - 0.5 - - - - https://supabase.com/docs/guides/functions/debugging - weekly - 0.5 - - - - https://supabase.com/docs/guides/functions/import-maps - weekly - 0.5 - - - - https://supabase.com/docs/guides/functions/local-development - weekly - 0.5 - - - - https://supabase.com/docs/guides/functions/quickstart - weekly - 0.5 - - - - https://supabase.com/docs/guides/functions/schedule-functions - weekly - 0.5 - - - - https://supabase.com/docs/guides/functions/secrets - weekly - 0.5 - - https://supabase.com/docs/guides/platform/access-control weekly @@ -582,6 +642,12 @@ 0.5 + + https://supabase.com/docs/guides/platform/spend-cap + weekly + 0.5 + + https://supabase.com/docs/guides/platform/ssl-enforcement weekly @@ -601,19 +667,25 @@ - https://supabase.com/docs/guides/realtime/broadcast + https://supabase.com/docs/guides/realtime/architecture weekly 0.5 - https://supabase.com/docs/guides/realtime/postgres-changes + https://supabase.com/docs/guides/realtime/channels weekly 0.5 - https://supabase.com/docs/guides/realtime/presence + https://supabase.com/docs/guides/realtime/extensions + weekly + 0.5 + + + + https://supabase.com/docs/guides/realtime/protocol weekly 0.5 @@ -630,6 +702,18 @@ 0.5 + + https://supabase.com/docs/guides/realtime/realtime-with-nextjs + weekly + 0.5 + + + + https://supabase.com/docs/guides/realtime/subscribing-to-database-changes + weekly + 0.5 + + https://supabase.com/docs/guides/resources/examples weekly @@ -678,36 +762,6 @@ 0.5 - - https://supabase.com/docs/learn/auth-deep-dive/auth-deep-dive-jwts - weekly - 0.5 - - - - https://supabase.com/docs/learn/auth-deep-dive/auth-google-oauth - weekly - 0.5 - - - - https://supabase.com/docs/learn/auth-deep-dive/auth-gotrue - weekly - 0.5 - - - - https://supabase.com/docs/learn/auth-deep-dive/auth-policies - weekly - 0.5 - - - - https://supabase.com/docs/learn/auth-deep-dive/auth-row-level-security - weekly - 0.5 - - https://supabase.com/docs/guides/auth/auth-helpers/auth-ui weekly @@ -738,6 +792,24 @@ 0.5 + + https://supabase.com/docs/guides/auth/phone-login/messagebird + weekly + 0.5 + + + + https://supabase.com/docs/guides/auth/phone-login/twilio + weekly + 0.5 + + + + https://supabase.com/docs/guides/auth/phone-login/vonage + weekly + 0.5 + + https://supabase.com/docs/guides/auth/social-login/auth-apple weekly @@ -840,12 +912,102 @@ 0.5 + + https://supabase.com/docs/guides/auth/sso/auth-sso-saml + weekly + 0.5 + + + + https://supabase.com/docs/guides/functions/examples/cloudflare-turnstile + weekly + 0.5 + + + + https://supabase.com/docs/guides/functions/examples/discord-bot + weekly + 0.5 + + + + https://supabase.com/docs/guides/functions/examples/github-actions + weekly + 0.5 + + + + https://supabase.com/docs/guides/functions/examples/og-image + weekly + 0.5 + + + + https://supabase.com/docs/guides/functions/examples/openai + weekly + 0.5 + + + + https://supabase.com/docs/guides/functions/examples/rate-limiting + weekly + 0.5 + + + + https://supabase.com/docs/guides/functions/examples/screenshots + weekly + 0.5 + + + + https://supabase.com/docs/guides/functions/examples/stripe-webhooks + weekly + 0.5 + + + + https://supabase.com/docs/guides/functions/examples/telegram-bot + weekly + 0.5 + + + + https://supabase.com/docs/guides/functions/examples/upstash-redis + weekly + 0.5 + + https://supabase.com/docs/guides/database/api/generating-types weekly 0.5 + + https://supabase.com/docs/guides/database/postgres/dropping-all-tables-in-schema + weekly + 0.5 + + + + https://supabase.com/docs/guides/database/postgres/first-row-in-group + weekly + 0.5 + + + + https://supabase.com/docs/guides/database/postgres/indexes + weekly + 0.5 + + + + https://supabase.com/docs/guides/database/postgres/which-version-of-postgres + weekly + 0.5 + + https://supabase.com/docs/guides/getting-started/quickstarts/flutter weekly @@ -888,186 +1050,6 @@ 0.5 - - https://supabase.com/docs/guides/getting-started/tutorials/with-angular - weekly - 0.5 - - - - https://supabase.com/docs/guides/getting-started/tutorials/with-expo - weekly - 0.5 - - - - https://supabase.com/docs/guides/getting-started/tutorials/with-flutter - weekly - 0.5 - - - - https://supabase.com/docs/guides/getting-started/tutorials/with-ionic-angular - weekly - 0.5 - - - - https://supabase.com/docs/guides/getting-started/tutorials/with-ionic-react - weekly - 0.5 - - - - https://supabase.com/docs/guides/getting-started/tutorials/with-ionic-vue - weekly - 0.5 - - - - https://supabase.com/docs/guides/getting-started/tutorials/with-nextjs - weekly - 0.5 - - - - https://supabase.com/docs/guides/getting-started/tutorials/with-nuxt-3 - weekly - 0.5 - - - - https://supabase.com/docs/guides/getting-started/tutorials/with-react - weekly - 0.5 - - - - https://supabase.com/docs/guides/getting-started/tutorials/with-redwoodjs - weekly - 0.5 - - - - https://supabase.com/docs/guides/getting-started/tutorials/with-solidjs - weekly - 0.5 - - - - https://supabase.com/docs/guides/getting-started/tutorials/with-svelte - weekly - 0.5 - - - - https://supabase.com/docs/guides/getting-started/tutorials/with-sveltekit - weekly - 0.5 - - - - https://supabase.com/docs/guides/getting-started/tutorials/with-vue-3 - weekly - 0.5 - - - - https://supabase.com/docs/guides/auth/phone-login/messagebird - weekly - 0.5 - - - - https://supabase.com/docs/guides/auth/phone-login/twilio - weekly - 0.5 - - - - https://supabase.com/docs/guides/auth/phone-login/vonage - weekly - 0.5 - - - - https://supabase.com/docs/guides/functions/examples/cloudflare-turnstile - weekly - 0.5 - - - - https://supabase.com/docs/guides/functions/examples/connect-to-postgres - weekly - 0.5 - - - - https://supabase.com/docs/guides/functions/examples/discord-bot - weekly - 0.5 - - - - https://supabase.com/docs/guides/functions/examples/github-actions - weekly - 0.5 - - - - https://supabase.com/docs/guides/functions/examples/og-image - weekly - 0.5 - - - - https://supabase.com/docs/guides/functions/examples/openai - weekly - 0.5 - - - - https://supabase.com/docs/guides/functions/examples/storage-caching - weekly - 0.5 - - - - https://supabase.com/docs/guides/functions/examples/stripe-webhooks - weekly - 0.5 - - - - https://supabase.com/docs/guides/functions/examples/telegram-bot - weekly - 0.5 - - - - https://supabase.com/docs/guides/functions/examples/upstash-redis - weekly - 0.5 - - - - https://supabase.com/docs/guides/platform/sso/azure - weekly - 0.5 - - - - https://supabase.com/docs/guides/platform/sso/gsuite - weekly - 0.5 - - - - https://supabase.com/docs/guides/platform/sso/okta - weekly - 0.5 - - https://supabase.com/docs/guides/database/extensions/http weekly @@ -1224,6 +1206,126 @@ 0.5 + + https://supabase.com/docs/guides/platform/sso/azure + weekly + 0.5 + + + + https://supabase.com/docs/guides/platform/sso/gsuite + weekly + 0.5 + + + + https://supabase.com/docs/guides/platform/sso/okta + weekly + 0.5 + + + + https://supabase.com/docs/guides/getting-started/tutorials/with-angular + weekly + 0.5 + + + + https://supabase.com/docs/guides/getting-started/tutorials/with-expo + weekly + 0.5 + + + + https://supabase.com/docs/guides/getting-started/tutorials/with-flutter + weekly + 0.5 + + + + https://supabase.com/docs/guides/getting-started/tutorials/with-ionic-angular + weekly + 0.5 + + + + https://supabase.com/docs/guides/getting-started/tutorials/with-ionic-react + weekly + 0.5 + + + + https://supabase.com/docs/guides/getting-started/tutorials/with-ionic-vue + weekly + 0.5 + + + + https://supabase.com/docs/guides/getting-started/tutorials/with-nextjs + weekly + 0.5 + + + + https://supabase.com/docs/guides/getting-started/tutorials/with-nuxt-3 + weekly + 0.5 + + + + https://supabase.com/docs/guides/getting-started/tutorials/with-react + weekly + 0.5 + + + + https://supabase.com/docs/guides/getting-started/tutorials/with-redwoodjs + weekly + 0.5 + + + + https://supabase.com/docs/guides/getting-started/tutorials/with-solidjs + weekly + 0.5 + + + + https://supabase.com/docs/guides/getting-started/tutorials/with-svelte + weekly + 0.5 + + + + https://supabase.com/docs/guides/getting-started/tutorials/with-sveltekit + weekly + 0.5 + + + + https://supabase.com/docs/guides/getting-started/tutorials/with-vue-3 + weekly + 0.5 + + + + https://supabase.com/docs/guides/realtime/extensions/broadcast + weekly + 0.5 + + + + https://supabase.com/docs/guides/realtime/extensions/postgres-changes + weekly + 0.5 + + + + https://supabase.com/docs/guides/realtime/extensions/presence + weekly + 0.5 + + https://supabase.com/docs/guides/resources/migrating-to-supabase/firebase-auth weekly @@ -1260,24 +1362,6 @@ 0.5 - - https://supabase.com/docs/guides/resources/postgres/dropping-all-tables-in-schema - weekly - 0.5 - - - - https://supabase.com/docs/guides/resources/postgres/first-row-in-group - weekly - 0.5 - - - - https://supabase.com/docs/guides/resources/postgres/which-version-of-postgres - weekly - 0.5 - - https://supabase.com/docs/guides/self-hosting/realtime/config weekly @@ -3560,6 +3644,24 @@ 0.5 + + https://supabase.com/docs/reference/cli/supabase-ssl-enforcement + weekly + 0.5 + + + + https://supabase.com/docs/reference/cli/supabase-ssl-enforcement-update + weekly + 0.5 + + + + https://supabase.com/docs/reference/cli/supabase-ssl-enforcement-get + weekly + 0.5 + + https://supabase.com/docs/reference/cli/supabase-secrets weekly diff --git a/apps/docs/styles/algolia-search.scss b/apps/docs/styles/algolia-search.scss deleted file mode 100644 index 56ac843d5d2..00000000000 --- a/apps/docs/styles/algolia-search.scss +++ /dev/null @@ -1,962 +0,0 @@ -// ---------------- -// 1. CSS Variables -// 2. Dark Mode -// 3. Autocomplete -// 4. Panel -// 5. Sources -// 6. Hit Layout -// 7. Panel Header -// 8. Panel Footer -// 9. Detached Mode -// 10. Gradients -// 11. Utilities -// ---------------- - -// Note: -// This theme reflects the markup structure of autocomplete with SCSS indentation. -// We use the SASS `@at-root` function to keep specificity low. - -// ---------------- -// 1. CSS Variables -// ---------------- -:root { - // Input - --aa-search-input-height: 38px; - --aa-input-icon-size: 20px; - - // Size and spacing - --aa-base-unit: 16; - --aa-spacing-factor: 1; - --aa-spacing: calc(var(--aa-base-unit) * var(--aa-spacing-factor) * 1px); - --aa-spacing-half: calc(var(--aa-spacing) / 2); - --aa-panel-max-height: 650px; - - // Z-index - --aa-base-z-index: 9999; - - // Font - --aa-font-size: calc(var(--aa-base-unit) * 1px); - --aa-font-family: inherit; - --aa-font-weight-medium: 500; - --aa-font-weight-semibold: 600; - --aa-font-weight-bold: 700; - - // Icons - --aa-icon-size: 20px; - --aa-icon-stroke-width: 1.6; - --aa-icon-color-rgb: 119, 119, 163; - --aa-icon-color-alpha: 1; - --aa-action-icon-size: 20px; - - // Text colors - --aa-text-color-rgb: 38, 38, 39; - --aa-text-color-alpha: 1; - --aa-primary-color-rgb: 62, 52, 211; - --aa-primary-color-alpha: 0.2; - --aa-muted-color-rgb: 128, 126, 163; - --aa-muted-color-alpha: 0.6; - - // Border colors - --aa-panel-border-color-rgb: 128, 126, 163; - --aa-panel-border-color-alpha: 0.3; - --aa-input-border-color-rgb: 128, 126, 163; - --aa-input-border-color-alpha: 0.8; - - // Background colors - --aa-background-color-rgb: 255, 255, 255; - --aa-background-color-alpha: 1; - --aa-input-background-color-rgb: 255, 255, 255; - --aa-input-background-color-alpha: 1; - --aa-selected-color-rgb: 179, 173, 214; - --aa-selected-color-alpha: 0.205; - --aa-description-highlight-background-color-rgb: 245, 223, 77; - --aa-description-highlight-background-color-alpha: 0.5; - - // Detached mode - --aa-detached-media-query: (max-width: 680px); - --aa-detached-modal-media-query: (min-width: 680px); - --aa-detached-modal-max-width: 680px; - --aa-detached-modal-max-height: 500px; - --aa-overlay-color-rgb: 115, 114, 129; - --aa-overlay-color-alpha: 0.4; - - // Shadows - --aa-panel-shadow: 0 0 0 1px rgba(35, 38, 59, 0.1), 0 6px 16px -4px rgba(35, 38, 59, 0.15); - - // Scrollbar - --aa-scrollbar-width: 13px; - --aa-scrollbar-track-background-color-rgb: 234, 234, 234; - --aa-scrollbar-track-background-color-alpha: 1; - --aa-scrollbar-thumb-background-color-rgb: var(--aa-background-color-rgb); - --aa-scrollbar-thumb-background-color-alpha: 1; - - // Touch screens - @media (hover: none) and (pointer: coarse) { - --aa-spacing-factor: 1.2; - --aa-action-icon-size: 22px; - } -} - -// ---------------- -// 2. Dark Mode -// ---------------- -body { - /* stylelint-disable selector-no-qualifying-type, selector-class-pattern */ - &[data-theme='dark'], - &.dark { - // Text colors - --aa-text-color-rgb: 183, 192, 199; - --aa-primary-color-rgb: 146, 138, 255; - --aa-muted-color-rgb: 146, 138, 255; - - // Background colors - --aa-input-background-color-rgb: 0, 3, 9; - --aa-background-color-rgb: 21, 24, 42; - --aa-selected-color-rgb: 146, 138, 255; - --aa-selected-color-alpha: 0.25; - --aa-description-highlight-background-color-rgb: 0 255 255; - --aa-description-highlight-background-color-alpha: 0.25; - - // Icons - --aa-icon-color-rgb: 119, 119, 163; - - // Shadows - --aa-panel-shadow: inset 1px 1px 0 0 rgb(44, 46, 64), 0 3px 8px 0 rgb(0, 3, 9); - - // Scrollbar - --aa-scrollbar-track-background-color-rgb: 44, 46, 64; - --aa-scrollbar-thumb-background-color-rgb: var(--aa-background-color-rgb); - } - /* stylelint-enable selector-no-qualifying-type, selector-class-pattern */ -} - -// Reset for `@extend` -%reset { - box-sizing: border-box; -} - -// Init for `@extend` -%init { - color: rgba(var(--aa-text-color-rgb), var(--aa-text-color-alpha)); - font-family: var(--aa-font-family); - font-size: var(--aa-font-size); - font-weight: normal; - line-height: 1em; - margin: 0; - padding: 0; - text-align: left; -} - -// ---------------- -// 3. Autocomplete -// ---------------- -.aa-Autocomplete, -.aa-DetachedFormContainer { - @extend %init; - * { - @extend %reset; - } - // Search box - @at-root .aa-Form { - @apply transition bg-scale-200 border border-scale-500; - align-items: center; - border-radius: 3px; - display: flex; - line-height: 1em; - margin: 0; - position: relative; - width: 100%; - &:focus-within { - @apply border-brand-900; - outline: currentColor none medium; - } - @at-root .aa-InputWrapperPrefix { - align-items: center; - display: flex; - flex-shrink: 0; - height: var(--aa-search-input-height); - order: 1; - // Container for search and loading icons - @at-root .aa-Label, - .aa-LoadingIndicator { - cursor: initial; - flex-shrink: 0; - height: 100%; - padding: 0; - text-align: left; - svg { - @apply text-brand-900; - height: auto; - max-height: var(--aa-input-icon-size); - stroke-width: var(--aa-icon-stroke-width); - width: var(--aa-input-icon-size); - } - } - @at-root .aa-SubmitButton, - .aa-LoadingIndicator { - height: 100%; - padding-left: calc(var(--aa-spacing) * 0.75 - 1px); - padding-right: var(--aa-spacing-half); - width: calc(var(--aa-spacing) * 1.75 + var(--aa-icon-size) - 1px); - @media (hover: none) and (pointer: coarse) { - padding-left: calc(var(--aa-spacing-half) / 2 - 1px); - width: calc(var(--aa-icon-size) + (var(--aa-spacing) * 1.25) - 1px); - } - } - @at-root .aa-SubmitButton { - appearance: none; - background: none; - border: 0; - margin: 0; - } - @at-root .aa-LoadingIndicator { - align-items: center; - display: flex; - justify-content: center; - &[hidden] { - display: none; - } - } - } - @at-root .aa-InputWrapper { - order: 3; - position: relative; - width: 100%; - // Search box input (with placeholder and query) - @at-root .aa-Input { - @apply text-sm text-scale-1200; - appearance: none; - background: none; - border: 0; - height: var(--aa-search-input-height); - padding: 0; - width: 100%; - &::placeholder { - @apply text-scale-700; - opacity: 1; - } - // Focus is set and styled on the parent, it isn't necessary here - &:focus { - border-color: none; - box-shadow: none; - outline: none; - } - // Remove native appearence - &::-webkit-search-decoration, - &::-webkit-search-cancel-button, - &::-webkit-search-results-button, - &::-webkit-search-results-decoration { - appearance: none; - } - } - } - @at-root .aa-InputWrapperSuffix { - align-items: center; - display: flex; - height: var(--aa-search-input-height); - order: 4; - // Accelerator to clear the query - @at-root .aa-ClearButton { - align-items: center; - background: none; - border: 0; - color: rgba(var(--aa-muted-color-rgb), var(--aa-muted-color-alpha)); - cursor: pointer; - display: flex; - height: 100%; - margin: 0; - padding: 0 calc(var(--aa-spacing) * (5 / 6) - 0.5px); - @media (hover: none) and (pointer: coarse) { - padding: 0 calc(var(--aa-spacing) * (2 / 3) - 0.5px); - } - &:hover, - &:focus { - color: rgba(var(--aa-text-color-rgb), var(--aa-text-color-alpha)); - } - &[hidden] { - display: none; - } - svg { - stroke-width: var(--aa-icon-stroke-width); - width: var(--aa-icon-size); - } - } - } - } -} - -// ---------------- -// 4. Panel -// ---------------- -.aa-Panel { - @extend %init; - - @apply bg-scale-200; - border-radius: calc(var(--aa-spacing) / 4); - box-shadow: var(--aa-panel-shadow); - margin: 8px 0 0; - overflow: hidden; - position: absolute; - transition: opacity 200ms ease-in, filter 200ms ease-in; - @media screen and (prefers-reduced-motion) { - transition: none; - } - * { - @extend %reset; - } - button { - appearance: none; - background: none; - border: 0; - margin: 0; - padding: 0; - } - - @at-root .aa-PanelLayout { - @apply bg-scale-200; - height: 100%; - margin: 0; - max-height: var(--aa-panel-max-height); - overflow-y: auto; - padding: 0; - position: relative; - text-align: left; - - @at-root .aa-PanelLayoutColumns--twoGolden { - display: grid; - grid-template-columns: 39.2% auto; - overflow: hidden; - padding: 0; - } - @at-root .aa-PanelLayoutColumns--two { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - overflow: hidden; - padding: 0; - } - @at-root .aa-PanelLayoutColumns--three { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - overflow: hidden; - padding: 0; - } - } - - // When a request isn't resolved yet - @at-root .aa-Panel--stalled { - .aa-Source { - filter: grayscale(1); - opacity: 0.8; - } - } - - @at-root .aa-Panel--scrollable { - margin: 0; - max-height: var(--aa-panel-max-height); - overflow-x: hidden; - overflow-y: auto; - padding: var(--aa-spacing-half); - scrollbar-color: rgba( - var(--aa-scrollbar-thumb-background-color-rgb), - var(--aa-scrollbar-thumb-background-color-alpha) - ) - rgba( - var(--aa-scrollbar-track-background-color-rgb), - var(--aa-scrollbar-track-background-color-alpha) - ); - scrollbar-width: thin; - - &::-webkit-scrollbar { - width: var(--aa-scrollbar-width); - } - &::-webkit-scrollbar-track { - background-color: rgba( - var(--aa-scrollbar-track-background-color-rgb), - var(--aa-scrollbar-track-background-color-alpha) - ); - } - &::-webkit-scrollbar-thumb { - background-color: rgba( - var(--aa-scrollbar-thumb-background-color-rgb), - var(--aa-scrollbar-thumb-background-color-alpha) - ); - border-color: rgba( - var(--aa-scrollbar-track-background-color-rgb), - var(--aa-scrollbar-track-background-color-alpha) - ); - border-radius: 9999px; - border-style: solid; - border-width: 3px 2px 3px 3px; - } - } -} - -// ---------------- -// 5. Sources -// Each source can be styled independently -// ---------------- -.aa-Source { - margin: 0; - padding: 0; - position: relative; - width: 100%; - &:empty { - // Hide empty section - display: none; - } - @at-root .aa-SourceNoResults { - font-size: 1em; - margin: 0; - padding: var(--aa-spacing); - } - // List of results inside the source - @at-root .aa-List { - list-style: none; - margin: 0; - padding: 0; - position: relative; - } - // Source title - @at-root .aa-SourceHeader { - margin: var(--aa-spacing-half) 0.5em var(--aa-spacing-half) 0; - padding: 0; - position: relative; - // Hide empty header - &:empty { - display: none; - } - // Title typography - @at-root .aa-SourceHeaderTitle { - background: rgba(var(--aa-background-color-rgb), var(--aa-background-color-alpha)); - color: rgba(var(--aa-primary-color-rgb), 1); - display: inline-block; - font-size: 0.8em; - font-weight: var(--aa-font-weight-semibold); - margin: 0; - padding: 0 var(--aa-spacing-half) 0 0; - position: relative; - z-index: var(--aa-base-z-index); - } - // Line separator - @at-root .aa-SourceHeaderLine { - border-bottom: solid 1px rgba(var(--aa-primary-color-rgb), 1); - display: block; - height: 2px; - left: 0; - margin: 0; - opacity: 0.3; - padding: 0; - position: absolute; - right: 0; - top: var(--aa-spacing-half); - z-index: calc(var(--aa-base-z-index) - 1); - } - } - // See all button - @at-root .aa-SourceFooterSeeAll { - background: linear-gradient( - 180deg, - rgba(var(--aa-background-color-rgb), var(--aa-background-color-alpha)), - rgba(128, 126, 163, 0.14) - ); - border: 1px solid rgba(var(--aa-muted-color-rgb), var(--aa-muted-color-alpha)); - border-radius: 5px; - box-shadow: inset 0 0 2px #fff, 0 2px 2px -1px rgba(76, 69, 88, 0.15); - color: inherit; - font-size: 0.95em; - font-weight: var(--aa-font-weight-medium); - padding: 0.475em 1em 0.6em; - text-decoration: none; - &:focus, - &:hover { - border: 1px solid rgba(var(--aa-primary-color-rgb), 1); - color: rgba(var(--aa-primary-color-rgb), 1); - } - } -} - -// ---------------- -// 6. Hit Layout -// ---------------- -.aa-Item { - @apply px-4 py-4; - align-items: center; - border-radius: 3px; - cursor: pointer; - display: grid; - min-height: calc(var(--aa-spacing) * 2.5); - // When the result is active - &[aria-selected='true'] { - @apply bg-scale-400; - .aa-ItemActionButton, - .aa-ActiveOnly { - visibility: visible; - } - } - // The result type icon inlined SVG or image - @at-root .aa-ItemIcon { - align-items: center; - background: rgba(var(--aa-background-color-rgb), var(--aa-background-color-alpha)); - border-radius: 3px; - box-shadow: inset 0 0 0 1px - rgba(var(--aa-panel-border-color-rgb), var(--aa-panel-border-color-alpha)); - color: rgba(var(--aa-icon-color-rgb), var(--aa-icon-color-alpha)); - display: flex; - flex-shrink: 0; - font-size: 0.7em; - height: calc(var(--aa-icon-size) + var(--aa-spacing-half)); - justify-content: center; - overflow: hidden; - stroke-width: var(--aa-icon-stroke-width); - text-align: center; - width: calc(var(--aa-icon-size) + var(--aa-spacing-half)); - img { - height: auto; - max-height: calc(var(--aa-icon-size) + var(--aa-spacing-half) - 8px); - max-width: calc(var(--aa-icon-size) + var(--aa-spacing-half) - 8px); - width: auto; - } - svg { - height: var(--aa-icon-size); - width: var(--aa-icon-size); - } - @at-root .aa-ItemIcon--alignTop { - align-self: flex-start; - } - @at-root .aa-ItemIcon--noBorder { - background: none; - box-shadow: none; - } - @at-root .aa-ItemIcon--picture { - height: 96px; - width: 96px; - img { - max-height: 100%; - max-width: 100%; - padding: var(--aa-spacing-half); - } - } - } - @at-root .aa-ItemContent { - @apply flex flex-col space-y-1; - cursor: pointer; - line-height: 1.25em; - overflow: hidden; - &:empty { - display: none; - } - mark { - @apply text-brand-900; - background: none; - font-style: normal; - font-weight: var(--aa-font-weight-bold); - } - .aa-ItemTitle { - @apply text-scale-1200 text-sm; - } - @at-root .aa-ItemContent--dual { - display: flex; - flex-direction: column; - justify-content: space-between; - text-align: left; - .aa-ItemContentTitle, - .aa-ItemContentSubtitle { - display: block; - } - } - @at-root .aa-ItemContent--indented { - padding-left: calc(var(--aa-icon-size) + var(--aa-spacing)); - } - @at-root .aa-ItemContentBody { - display: grid; - gap: calc(var(--aa-spacing-half) / 2); - } - @at-root .aa-ItemContentTitle { - display: inline-block; - margin: 0 0.5em 0 0; - max-width: 100%; - overflow: hidden; - padding: 0; - text-overflow: ellipsis; - white-space: nowrap; - } - @at-root .aa-ItemContentSubtitle { - @apply text-scale-1000 text-sm truncate; - @at-root .aa-ItemContentSubtitleIcon { - &::before { - border-color: rgba(var(--aa-muted-color-rgb), 0.64); - border-style: solid; - content: ''; - display: inline-block; - left: 1px; - position: relative; - top: -3px; - } - } - @at-root .aa-ItemContentSubtitle--inline { - .aa-ItemContentSubtitleIcon { - &::before { - border-width: 0 0 1.5px; - margin-left: var(--aa-spacing-half); - margin-right: calc(var(--aa-spacing-half) / 2); - width: calc(var(--aa-spacing-half) + 2px); - } - } - } - @at-root .aa-ItemContentSubtitle--standalone { - align-items: center; - color: rgba(var(--aa-text-color-rgb), var(--aa-text-color-alpha)); - display: grid; - gap: var(--aa-spacing-half); - grid-auto-flow: column; - justify-content: start; - .aa-ItemContentSubtitleIcon { - &::before { - border-radius: 0 0 0 3px; - border-width: 0 0 1.5px 1.5px; - height: var(--aa-spacing-half); - width: var(--aa-spacing-half); - } - } - } - @at-root .aa-ItemContentSubtitleCategory { - color: rgba(var(--aa-muted-color-rgb), 1); - font-weight: 500; - } - } - @at-root .aa-ItemContentDescription { - color: rgba(var(--aa-text-color-rgb), var(--aa-text-color-alpha)); - font-size: 0.85em; - max-width: 100%; - overflow-x: hidden; - text-overflow: ellipsis; - &:empty { - display: none; - } - mark { - background: rgba( - var(--aa-description-highlight-background-color-rgb), - var(--aa-description-highlight-background-color-alpha) - ); - color: rgba(var(--aa-text-color-rgb), var(--aa-text-color-alpha)); - font-style: normal; - font-weight: var(--aa-font-weight-medium); - } - } - @at-root .aa-ItemContentDash { - color: rgba(var(--aa-muted-color-rgb), var(--aa-muted-color-alpha)); - display: none; - opacity: 0.4; - } - @at-root .aa-ItemContentTag { - background-color: rgba(var(--aa-primary-color-rgb), var(--aa-primary-color-alpha)); - border-radius: 3px; - margin: 0 0.4em 0 0; - padding: 0.08em 0.3em; - } - } - // wrap hit with url but we don't need to see it - @at-root .aa-ItemWrapper, - .aa-ItemLink { - align-items: center; - color: inherit; - gap: calc(var(--aa-spacing-half) / 2); - grid-auto-flow: column; - justify-content: space-between; - width: 100%; - } - @at-root .aa-ItemLink { - color: inherit; - text-decoration: none; - } - // Secondary click actions - @at-root .aa-ItemActions { - display: grid; - grid-auto-flow: column; - height: 100%; - justify-self: end; - margin: 0 calc(var(--aa-spacing) / -3); - padding: 0 2px 0 0; - } - @at-root .aa-ItemActionButton { - align-items: center; - background: none; - border: 0; - color: rgba(var(--aa-muted-color-rgb), var(--aa-muted-color-alpha)); - cursor: pointer; - display: flex; - flex-shrink: 0; - padding: 0; - &:hover, - &:focus { - svg { - color: rgba(var(--aa-text-color-rgb), var(--aa-text-color-alpha)); - @media (hover: none) and (pointer: coarse) { - color: inherit; - } - } - } - svg { - color: rgba(var(--aa-muted-color-rgb), var(--aa-muted-color-alpha)); - margin: 0; - margin: calc(var(--aa-spacing) / 3); - stroke-width: var(--aa-icon-stroke-width); - width: var(--aa-action-icon-size); - } - } - @at-root .aa-ActiveOnly { - visibility: hidden; - } -} - -//---------------- -// 7. Panel Header -//---------------- -.aa-PanelHeader { - align-items: center; - background: rgba(var(--aa-primary-color-rgb), 1); - color: #fff; - display: grid; - height: var(--aa-modal-header-height); - margin: 0; - padding: var(--aa-spacing-half) var(--aa-spacing); - position: relative; - - &::after { - background-image: linear-gradient( - rgba(var(--aa-background-color-rgb), 1), - rgba(var(--aa-background-color-rgb), 0) - ); - bottom: calc(var(--aa-spacing-half) * -1); - content: ''; - height: var(--aa-spacing-half); - left: 0; - pointer-events: none; - position: absolute; - right: 0; - z-index: var(--aa-base-z-index); - } -} - -//---------------- -// 8. Panel Footer -//---------------- -.aa-PanelFooter { - background-color: rgba(var(--aa-background-color-rgb), var(--aa-background-color-alpha)); - box-shadow: inset 0 1px 0 - rgba(var(--aa-panel-border-color-rgb), var(--aa-panel-border-color-alpha)); - display: flex; - justify-content: space-between; - margin: 0; - padding: var(--aa-spacing); - position: relative; - z-index: var(--aa-base-z-index); - &::after { - background-image: linear-gradient( - rgba(var(--aa-background-color-rgb), 0), - rgba(var(--aa-muted-color-rgb), var(--aa-muted-color-alpha)) - ); - content: ''; - height: var(--aa-spacing); - left: 0; - opacity: 0.12; - pointer-events: none; - position: absolute; - right: 0; - top: calc(var(--aa-spacing) * -1); - z-index: calc(var(--aa-base-z-index) - 1); - } -} - -//---------------- -// 9. Detached Mode -//---------------- -.aa-DetachedContainer { - @apply bg-scale-200 border border-scale-500; - bottom: 0; - box-shadow: var(--aa-panel-shadow); - display: flex; - flex-direction: column; - left: 0; - margin: 0; - overflow: hidden; - padding: 0; - position: fixed; - right: 0; - top: 0; - z-index: var(--aa-base-z-index); - &::after { - height: 32px; - } - .aa-SourceHeader { - margin: var(--aa-spacing-half) 0 var(--aa-spacing-half) 2px; - } - .aa-Panel { - @apply bg-scale-200; - border-radius: 0; - box-shadow: none; - flex-grow: 1; - margin: 0; - padding: 0; - position: relative; - } - .aa-PanelLayout { - bottom: 0; - box-shadow: none; - left: 0; - margin: 0; - max-height: none; - overflow-y: auto; - position: absolute; - right: 0; - top: 0; - width: 100%; - } - @at-root .aa-DetachedFormContainer { - @apply bg-scale-200; - border-bottom: solid 1px - rgba(var(--aa-panel-border-color-rgb), var(--aa-panel-border-color-alpha)); - display: flex; - flex-direction: row; - justify-content: space-between; - margin: 0; - padding: var(--aa-spacing-half); - @at-root .aa-DetachedCancelButton { - @apply text-scale-1100 text-sm; - background: none; - border: 0; - border-radius: 3px; - cursor: pointer; - margin: 0 0 0 var(--aa-spacing-half); - padding: 0 var(--aa-spacing-half); - &:hover, - &:focus { - box-shadow: inset 0 0 0 1px - rgba(var(--aa-panel-border-color-rgb), var(--aa-panel-border-color-alpha)); - } - } - } - @at-root .aa-DetachedContainer--modal { - border-radius: 6px; - bottom: inherit; - height: auto; - margin: 0 auto; - max-width: var(--aa-detached-modal-max-width); - position: absolute; - top: 3%; - .aa-PanelLayout { - max-height: var(--aa-detached-modal-max-height); - padding-bottom: var(--aa-spacing-half); - position: static; - } - } -} -// Search Button -.aa-DetachedSearchButton { - @apply rounded-md bg-scaleA-200 border border-scale-700; - align-items: center; - color: rgba(var(--aa-muted-color-rgb), var(--aa-muted-color-alpha)); - cursor: pointer; - display: flex; - font: inherit; - font-family: var(--aa-font-family); - font-size: var(--aa-font-size); - height: var(--aa-search-input-height); - margin: 0; - padding: 0 calc(var(--aa-search-input-height) / 8); - position: relative; - text-align: left; - width: 100%; - &:focus { - @apply border-brand-900; - outline: currentColor none medium; - } - @at-root .aa-DetachedSearchButtonIcon { - @apply text-scale-1100; - align-items: center; - cursor: initial; - display: flex; - height: 100%; - justify-content: center; - width: calc(var(--aa-icon-size) + var(--aa-spacing)); - } - .aa-DetachedSearchButtonPlaceholder { - @apply text-sm text-scale-800; - } -} - -// Remove scroll on `body` -.aa-Detached { - height: 100vh; - overflow: hidden; -} - -.aa-DetachedOverlay { - @apply backdrop-blur-sm; - background-color: rgba(0, 0, 0, 0.4); - height: 100vh; - left: 0; - margin: 0; - padding: 0; - position: fixed; - right: 0; - top: 0; - z-index: calc(var(--aa-base-z-index) - 1); -} - -//---------------- -// 10. Gradients -//---------------- -.aa-GradientTop, -.aa-GradientBottom { - height: var(--aa-spacing-half); - left: 0; - pointer-events: none; - position: absolute; - right: 0; - z-index: var(--aa-base-z-index); -} - -.aa-GradientTop { - // background-image: linear-gradient( - // rgba(var(--aa-background-color-rgb), 1), - // rgba(var(--aa-background-color-rgb), 0) - // ); - top: 0; -} - -.aa-GradientBottom { - // background-image: linear-gradient( - // rgba(var(--aa-background-color-rgb), 0), - // rgba(var(--aa-background-color-rgb), 1) - // ); - // border-bottom-left-radius: calc(var(--aa-spacing) / 4); - // border-bottom-right-radius: calc(var(--aa-spacing) / 4); - bottom: 0; -} - -//---------------- -// 11. Utilities -//---------------- -.aa-DesktopOnly { - @media (hover: none) and (pointer: coarse) { - display: none; - } -} - -.aa-TouchOnly { - @media (hover: hover) { - display: none; - } -} - -// search bar -input[type='search'].DocSearch-Input { - background-color: var(--colors-scale1); - box-shadow: none; -} - -.dark .DocSearch-Input { - background-color: var(--colors-scale2); - color: white; -} diff --git a/apps/docs/styles/docsearch.scss b/apps/docs/styles/docsearch.scss deleted file mode 100644 index 569965efcd9..00000000000 --- a/apps/docs/styles/docsearch.scss +++ /dev/null @@ -1,535 +0,0 @@ -/* search modal */ - -.DocSearch--active { - @apply overflow-hidden #{!important}; -} - -.DocSearch-Container { - background: rgba(0, 0, 0, 0.3); - @apply fixed top-0 left-0 z-[200] flex h-screen w-screen flex-col p-4 backdrop-blur-sm sm:p-6 md:p-[10vh] lg:p-[12vh]; - @apply cursor-auto #{!important}; -} - -.DocSearch-LoadingIndicator svg { - @apply hidden; -} - -.DocSearch-LoadingIndicator { - display: none; - width: 1.5rem; - height: 1.5rem; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none'%3E%3Ccircle cx='12' cy='12' r='9' stroke-width='2' stroke='%23cffafe' /%3E%3Cpath d='M3,12a9,9 0 1,0 18,0a9,9 0 1,0 -18,0' stroke-width='2' stroke='%2306b6d4' stroke-dasharray='56.5486677646' stroke-dashoffset='37.6991118431' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); - background-size: 100% 100%; -} - -.DocSearch-Container--Stalled .DocSearch-LoadingIndicator { - @apply block; -} - -.DocSearch-Modal { - @apply dark:bg-scale-200 bg-scale-100; - margin: 0 auto; - width: 100%; - max-width: 47.375rem; - display: flex; - flex-direction: column; - min-height: 0; - border-radius: theme('borderRadius.lg'); - box-shadow: theme('boxShadow.lg'); -} - -.DocSearch-SearchBar { - z-index: 1; - padding: 0 1rem; - @apply border-scale-400 relative flex flex-none items-center border-b; -} - -.DocSearch-Form { - @apply flex min-w-0 flex-auto items-center; -} - -.DocSearch-Dropdown-Container { - @apply pb-6; -} - -.DocSearch-Dropdown { - @apply flex-auto overflow-auto; -} - -.DocSearch-Hit--Result { - @apply relative mx-6; -} - -.DocSearch-Hit--Result.DocSearch-Hit--Child { - @apply ml-12; -} - -.DocSearch-Hit--Result.DocSearch-Hit--Child::before { - content: ''; - position: absolute; - top: -0.25rem; - bottom: -0.25rem; - left: -1rem; - width: 1px; - background: theme('colors.scale.400'); - - // @apply dark:bg-slate-700; -} - -.DocSearch-Hit > a { - position: relative; - font-size: 0.875rem; - // border-bottom: 1px solid theme('colors.slate.100'); - - // @apply dark:border-slate-200; -} - -.DocSearch-Hit--Result { - // @apply border-b-0 #{!important}; -} - -.DocSearch-MagnifierLabel { - @apply h-6 w-6 flex-none; - background-image: url("data:image/svg+xml,%3Csvg width='24' height='24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m19 19-3.5-3.5' stroke='%23475569' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3Ccircle cx='11' cy='11' r='6' stroke='%23475569' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); - - .dark & { - background-image: url("data:image/svg+xml,%3Csvg width='24' height='24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m19 19-3.5-3.5' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3Ccircle cx='11' cy='11' r='6' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); - } -} - -.DocSearch-MagnifierLabel svg { - display: none; -} - -.DocSearch-Container--Stalled .DocSearch-MagnifierLabel { - display: none; -} - -.DocSearch-Input { - @apply text-scale-1200 placeholder-scale-900; - appearance: none; - background: transparent; - height: 3.5rem; - font-size: 1rem; - margin-left: 0.75rem; - margin-right: 1rem; - flex: auto; - min-width: 0; - border: none; -} - -@screen sm { - .DocSearch-Input { - font-size: 0.875rem; - } -} - -.DocSearch-Input:focus { - outline: 2px dotted transparent; -} - -.DocSearch-Input::-webkit-search-cancel-button, -.DocSearch-Input::-webkit-search-decoration, -.DocSearch-Input::-webkit-search-results-button, -.DocSearch-Input::-webkit-search-results-decoration { - display: none; -} - -.DocSearch-Reset { - display: none; -} - -button[type='reset'].DocSearch-Cancel { - appearance: none; - flex: none; - font-size: 0; - border-radius: 0.375rem; - padding: 0.25rem 0.375rem; - @apply ring-scale-900 hover:ring-scale-900 dark:bg-scale-600 shadow-sm ring-1 hover:shadow dark:ring-0; - width: 1.75rem; - height: 1.5rem; - /* esc */ - background-image: url("data:image/svg+xml,%3Csvg width='16' height='7' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M.506 6h3.931V4.986H1.736v-1.39h2.488V2.583H1.736V1.196h2.69V.182H.506V6ZM8.56 1.855h1.18C9.721.818 8.87.102 7.574.102c-1.276 0-2.21.705-2.205 1.762-.003.858.602 1.35 1.585 1.585l.634.159c.633.153.986.335.988.727-.002.426-.406.716-1.03.716-.64 0-1.1-.295-1.14-.878h-1.19c.03 1.259.931 1.91 2.343 1.91 1.42 0 2.256-.68 2.259-1.745-.003-.969-.733-1.483-1.744-1.71l-.523-.125c-.506-.117-.93-.304-.92-.722 0-.375.332-.65.934-.65.588 0 .949.267.994.724ZM15.78 2.219C15.618.875 14.6.102 13.254.102c-1.537 0-2.71 1.086-2.71 2.989 0 1.898 1.153 2.989 2.71 2.989 1.492 0 2.392-.992 2.526-2.063l-1.244-.006c-.117.623-.606.98-1.262.98-.883 0-1.483-.656-1.483-1.9 0-1.21.591-1.9 1.492-1.9.673 0 1.159.389 1.253 1.028h1.244Z' fill='%23334155'/%3E%3C/svg%3E"); - background-position: center; - background-repeat: no-repeat; - background-size: 57.1428571429% auto; - - .dark & { - background-image: url("data:image/svg+xml,%3Csvg width='16' height='7' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M.506 6h3.931V4.986H1.736v-1.39h2.488V2.583H1.736V1.196h2.69V.182H.506V6ZM8.56 1.855h1.18C9.721.818 8.87.102 7.574.102c-1.276 0-2.21.705-2.205 1.762-.003.858.602 1.35 1.585 1.585l.634.159c.633.153.986.335.988.727-.002.426-.406.716-1.03.716-.64 0-1.1-.295-1.14-.878h-1.19c.03 1.259.931 1.91 2.343 1.91 1.42 0 2.256-.68 2.259-1.745-.003-.969-.733-1.483-1.744-1.71l-.523-.125c-.506-.117-.93-.304-.92-.722 0-.375.332-.65.934-.65.588 0 .949.267.994.724ZM15.78 2.219C15.618.875 14.6.102 13.254.102c-1.537 0-2.71 1.086-2.71 2.989 0 1.898 1.153 2.989 2.71 2.989 1.492 0 2.392-.992 2.526-2.063l-1.244-.006c-.117.623-.606.98-1.262.98-.883 0-1.483-.656-1.483-1.9 0-1.21.591-1.9 1.492-1.9.673 0 1.159.389 1.253 1.028h1.244Z' fill='%2394a3b8'/%3E%3C/svg%3E"); - } -} - -.DocSearch-Reset svg { - display: none; -} - -.DocSearch-Hit-source { - line-height: 1.5rem; - font-weight: 400; - color: theme('colors.scale.900'); - padding-top: 2.5rem; - margin: 0 1.5rem 1rem; -} - -.DocSearch-Hits:first-child .DocSearch-Hit-source { - padding-top: 1.5rem; -} - -.DocSearch-Hit-Container { - display: flex; - align-items: center; -} - -.DocSearch-Hit-Tree { - display: none; -} - -.DocSearch-Hit-icon { - display: none; -} - -.DocSearch-Hit--Result .DocSearch-Hit-icon { - display: block; - flex: none; - margin-right: 1rem; - width: 1.5rem; - height: 1.5rem; - border-radius: theme('borderRadius.md'); - /* hash icon */ - background-image: url("data:image/svg+xml,%3Csvg width='12' height='12' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M3.75 1v10M8.25 1v10M1 3.75h10M1 8.25h10' stroke='%2394A3B8' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: center center; - - @apply ring-scale-300 bg-white shadow-sm ring-1 dark:bg-slate-700 dark:ring-0; -} - -.DocSearch-Hit--Parent.DocSearch-Hit--Guide .DocSearch-Hit-icon { - /* book icon */ - background-image: url("data:image/svg+xml,%3Csvg width='14' height='10' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M3.5 0c1.093 0 2.117.27 3 .743V10a6.344 6.344 0 0 0-3-.743c-1.093 0-2.617.27-3.5.743V.743C.883.27 2.407 0 3.5 0Z' fill='%23BAE6FD'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M10.5 0c1.093 0 2.617.27 3.5.743V10c-.883-.473-2.407-.743-3.5-.743s-2.117.27-3 .743V.743a6.344 6.344 0 0 1 3-.743Z' fill='%2338BDF8'/%3E%3C/svg%3E"); -} - -.DocSearch-Hit-icon svg { - display: none; -} - -.DocSearch-Hit-content-wrapper { - flex: auto; - display: flex; - flex-direction: column-reverse; - min-width: 0; - z-index: 1; -} - -.DocSearch-Hit-path { - align-self: flex-start; - font-size: 0.75rem; - line-height: 1.5rem; - font-weight: 400; - color: theme('colors.scale.1100'); - border-radius: 999px; - padding: 0 0.75rem; - @apply bg-scale-300 dark:bg-scale-500; -} - -.DocSearch-Hit[aria-selected='true'] .DocSearch-Hit-icon { - // @apply bg-transparent shadow-none ring-1 ring-white/30 dark:ring-white/20; - // background-image: url("data:image/svg+xml,%3Csvg width='12' height='12' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M3.75 1v10M8.25 1v10M1 3.75h10M1 8.25h10' stroke='%23fff' stroke-width='1.5' stroke-linecap='round'/%3E%3C/svg%3E"); -} - -.DocSearch-Hit[aria-selected='true'] .DocSearch-Hit--Parent .DocSearch-Hit-icon { - /* book icon */ - // background-image: url("data:image/svg+xml,%3Csvg width='14' height='10' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M3.5 0c1.093 0 2.117.27 3 .743V10a6.344 6.344 0 0 0-3-.743c-1.093 0-2.617.27-3.5.743V.743C.883.27 2.407 0 3.5 0Z' fill='%23fff'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M10.5 0c1.093 0 2.617.27 3.5.743V10c-.883-.473-2.407-.743-3.5-.743s-2.117.27-3 .743V.743a6.344 6.344 0 0 1 3-.743Z' fill='%23fff'/%3E%3C/svg%3E"); -} - -.DocSearch-Hit-title { - @apply text-scale-1100 dark:text-scale-1100 truncate leading-6; -} - -.DocSearch-Hit-title + .DocSearch-Hit-path { - @apply mb-1; -} - -.DocSearch-Hit-action { - @apply ml-3.5 flex-none; -} - -.DocSearch-Hit--Result { - .DocSearch-Hit-action svg { - @apply hidden; - } - - .DocSearch-Hit-action { - // @apply h-6 w-6; - // background-image: url("data:image/svg+xml,%3Csvg width='24' height='24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m11 9 3 3-3 3' stroke='%23475569' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); - } - - [aria-selected='true'] & .DocSearch-Hit-action { - // background-image: url("data:image/svg+xml,%3Csvg width='24' height='24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m11 9 3 3-3 3' stroke='%23fff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); - } - - .DocSearch-Hit[aria-selected='true'] & .DocSearch-Hit-path { - // @apply bg-scale-400 dark:bg-scale-400; - } - - .DocSearch-Hit[aria-selected='true'] & .DocSearch-Hit-title { - // @apply text-white; - } -} - -.DocSearch-Hit-action-button { - @apply flex; -} - -.DocSearch-Hit-action + .DocSearch-Hit-action { - @apply ml-2; -} - -.DocSearch-Hit-action path { - stroke-width: 2px; - stroke: #71717a; -} - -.DocSearch-Hit[aria-selected='true'] .DocSearch-Hit-action path { - stroke: #777; - - .dark & { - stroke: #aaa; - } -} - -.DocSearch-Hit > a { - @apply block py-4 px-6; -} - -.DocSearch-Hit--Result { - @apply py-3 px-4 #{!important}; -} - -.DocSearch-Hit { - @apply relative; -} - -.DocSearch-Hit:first-child > a { - // @apply border-t border-slate-100 dark:border-slate-200; -} - -.DocSearch-Hit--Result { - // @apply border-t-0 #{!important}; -} - -.DocSearch-Hit + .DocSearch-Hit .DocSearch-Hit--Result { - @apply mt-2; -} - -.DocSearch-Hit--Result { - @apply dark:bg-scale-300 border-scale-400 rounded-lg border bg-white transition-all; -} - -.DocSearch-Hit[aria-selected='true'] > a { - @apply bg-slate-50 dark:bg-slate-700; -} - -.DocSearch-Hit[aria-selected='true'] .DocSearch-Hit--Result { - @apply bg-scale-200 dark:bg-scale-600; -} - -.DocSearch-Hit--FirstChild::before { - @apply top-0 #{!important}; -} - -.DocSearch-Hit--LastChild::before { - @apply bottom-0 #{!important}; -} - -.DocSearch-Hits mark { - background: none; - @apply text-scale-1200 border-scale-900 border-b-2 font-semibold; -} - -.DocSearch-Hit-path mark { - @apply border-0; -} - -.DocSearch-Hit[aria-selected='true'] mark { - @apply text-scale-1200; - border-bottom-color: currentColor; -} - -.DocSearch-Footer { - @apply flex flex-none justify-end border-t border-slate-100 py-4 px-6 dark:border-slate-200; -} - -.DocSearch-Commands { - @apply hidden; -} - -.DocSearch-Logo a { - @apply text-scale-900 flex items-center text-xs font-medium; -} - -.DocSearch-Logo svg { - @apply text-scale-900 ml-3 text-[#5468ff]; -} - -.DocSearch-Hit--deleting, -.DocSearch-Hit--favoriting { - opacity: 0; - transition: all 250ms linear; -} - -.DocSearch-NoResults .DocSearch-Screen-Icon { - @apply hidden; -} - -.DocSearch-Title { - @apply mb-10 text-lg leading-6; -} - -.DocSearch-Title strong { - @apply text-slate-1200 dark:text-scale-1200 font-normal; -} - -.DocSearch-NoResults-Prefill-List { - .DocSearch-Help { - color: var(--colors-scale11) !important; - } -} - -.DocSearch-StartScreen { - .DocSearch-Help { - @apply text-center py-16; - } -} - -.DocSearch-Help { - @apply text-scale-1100; - font-size: 0.875rem !important; - a { - color: var(--colors-brand9) !important; - font-weight: 400 !important; - } -} - -.DocSearch-NoResults { - @apply px-4 pt-10 pb-8; -} - -.DocSearch-NoResults .DocSearch-Title { - text-align: center; - color: theme('colors.scale.1100'); -} - -.DocSearch-NoResults-Prefill-List .DocSearch-Help { - @apply text-scale-900 dark:text-scale-200 mb-3 text-sm leading-6; -} - -.DocSearch-NoResults-Prefill-List ul { - @apply border-scale-300 dark:border-scale-400 dark:bg-scale-300 overflow-hidden rounded-lg border bg-white; -} - -.DocSearch-NoResults-Prefill-List button { - padding: 0.5rem 0.75rem; - display: block; - width: 100%; - text-align: left; - font-size: theme('fontSize.sm'); - line-height: theme('lineHeight.6'); - background-image: url("data:image/svg+xml,%3Csvg width='24' height='24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m11 9 3 3-3 3' stroke='%23475569' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 0.75rem center; - - @apply text-scale-900 hover:text-slate-1200 dark:bg-scale-300 hover:bg-scale-200 dark:hover:bg-scale-400 bg-white; -} - -.DocSearch-NoResults-Prefill-List li + li button { - @apply border-scale-300 border-t dark:border-slate-400; -} - -.DocSearch-NoResults-Prefill-List + .DocSearch-Help { - @apply mt-3 text-xs leading-6; -} - -.DocSearch-NoResults-Prefill-List + .DocSearch-Help a { - @apply text-scale-900 dark:text-scale-900 font-semibold; -} - -.DocSearch-Hit-action [data-cy] { - width: 1.5rem; - height: 1.5rem; -} - -.DocSearch-Hit-action [data-cy] svg { - display: none; -} - -.DocSearch-Hit-action [data-cy='fav-recent'] { - background-image: url("data:image/svg+xml,%3Csvg width='24' height='24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 5 2 5h5l-4 4 2 5-5-3-5 3 2-5-4-4h5l2-5Z' stroke='%23CBD5E1' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); - - .dark & { - background-image: url("data:image/svg+xml,%3Csvg width='24' height='24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 5 2 5h5l-4 4 2 5-5-3-5 3 2-5-4-4h5l2-5Z' stroke='%2364748b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); - } -} - -.DocSearch-Hit-action [data-cy='fav-recent']:hover { - background-image: url("data:image/svg+xml,%3Csvg width='24' height='24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 5 2 5h5l-4 4 2 5-5-3-5 3 2-5-4-4h5l2-5Z' stroke='%230ea5e9' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); -} - -.DocSearch-Hit-action [data-cy='remove-recent'] { - background-image: url("data:image/svg+xml,%3Csvg width='24' height='24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M17 7 7 17M7 7l10 10' stroke='%2394A3B8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); - - .dark & { - background-image: url("data:image/svg+xml,%3Csvg width='24' height='24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M17 7 7 17M7 7l10 10' stroke='%2364748b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); - } -} - -.DocSearch-Hit-action [data-cy='remove-recent']:hover { - background-image: url("data:image/svg+xml,%3Csvg width='24' height='24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M17 7 7 17M7 7l10 10' stroke='%23475569' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); - - .dark & { - background-image: url("data:image/svg+xml,%3Csvg width='24' height='24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M17 7 7 17M7 7l10 10' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); - } -} - -.DocSearch-Hit-action [data-cy='remove-fav']::before { - @apply pointer-events-none absolute inset-0 bg-sky-50 content-[''] dark:bg-slate-700; -} - -.DocSearch-Hit[aria-selected='true'] [data-cy='remove-fav']::before { - @apply bg-sky-100 dark:bg-slate-700; -} - -.DocSearch-Hit-action [data-cy='remove-fav']::after { - content: ''; - position: absolute; - pointer-events: none; - width: calc(4.5rem + 1px); - height: 1.5rem; - margin-left: calc(-3rem - 1px); - background-image: url("data:image/svg+xml,%3Csvg width='24' height='24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 5 2 5h5l-4 4 2 5-5-3-5 3 2-5-4-4h5l2-5Z' fill='%230EA5E9' stroke='%230EA5E9' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"), - url("data:image/svg+xml,%3Csvg width='1' height='1' fill='%23e2e8f0' xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='1' height='1'/%3E%3C/svg%3E"), - url("data:image/svg+xml,%3Csvg width='24' height='24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M17 7 7 17M7 7l10 10' stroke='%2394A3B8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); - background-repeat: no-repeat, repeat-y, no-repeat; - background-position: left, center, right; - - .dark & { - background-image: url("data:image/svg+xml,%3Csvg width='24' height='24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 5 2 5h5l-4 4 2 5-5-3-5 3 2-5-4-4h5l2-5Z' fill='%230EA5E9' stroke='%230EA5E9' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"), - url("data:image/svg+xml,%3Csvg width='1' height='1' fill='%23e2e8f0' opacity='0.05' xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='1' height='1'/%3E%3C/svg%3E"), - url("data:image/svg+xml,%3Csvg width='24' height='24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M17 7 7 17M7 7l10 10' stroke='%2364748b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); - } -} - -.DocSearch-Hit-action [data-cy='remove-fav']:hover::after { - background-image: url("data:image/svg+xml,%3Csvg width='24' height='24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 5 2 5h5l-4 4 2 5-5-3-5 3 2-5-4-4h5l2-5Z' fill='%230EA5E9' stroke='%230EA5E9' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"), - url("data:image/svg+xml,%3Csvg width='1' height='1' fill='%23e2e8f0' xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='1' height='1'/%3E%3C/svg%3E"), - url("data:image/svg+xml,%3Csvg width='24' height='24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M17 7 7 17M7 7l10 10' stroke='%23475569' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); - - .dark & { - background-image: url("data:image/svg+xml,%3Csvg width='24' height='24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 5 2 5h5l-4 4 2 5-5-3-5 3 2-5-4-4h5l2-5Z' fill='%230EA5E9' stroke='%230EA5E9' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"), - url("data:image/svg+xml,%3Csvg width='1' height='1' fill='%23e2e8f0' opacity='0.05' xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='1' height='1'/%3E%3C/svg%3E"), - url("data:image/svg+xml,%3Csvg width='24' height='24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M17 7 7 17M7 7l10 10' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); - } -} - -.DocSearch-Hit-action + .DocSearch-Hit-action { - // @apply ml-3 border-l border-slate-200 pl-3 dark:border-slate-200; -} diff --git a/apps/www/_blog/2023-03-09-supabase-beta-update-february-2023.mdx b/apps/www/_blog/2023-03-09-supabase-beta-update-february-2023.mdx new file mode 100644 index 00000000000..2f6f20dbf6a --- /dev/null +++ b/apps/www/_blog/2023-03-09-supabase-beta-update-february-2023.mdx @@ -0,0 +1,146 @@ +--- +title: Supabase Beta February 2023 +description: There is something for everybody this month - AI, Auth, Database, Edge Functions, GraphQL … you name it! +author: ant_wilson +image: 2023-03-09-beta-update-february/supabase-beta-update-february-2023.jpg +thumb: 2023-03-09-beta-update-february/supabase-beta-update-february-2023.jpg +tags: + - release-notes +date: '2023-03-09' +toc_depth: 3 +--- + +There’s something for everybody this month: AI, Auth, Database, Edge Functions, GraphQL … you name it! + +## GraphiQL editor in the dashboard + +![GraphiQL editor in the dashboard](/images/blog/2023-03-09-beta-update-february/graphiql-dashboard.png) + +The most popular GraphQL IDE/API explorer is now built into the dashboard! You can now explore and query your GraphQL API produced by `pg_graphql`. + +[Try it now](https://app.supabase.com/project/_/api/graphiql). + +## Supabase + OpenAI search + +![Supabase + OpenAI search](/images/blog/2023-03-09-beta-update-february/openai-supabase.png) + +We've updated our Docs search functionality to use `pgvector` + OpenAI. Still no cease and desist from Microsoft, so you can continue to ask Clippy any Supabase-specific questions 📎💚 + +[Ask Clippy.](https://supabase.com/docs) + +## Serve all the functions! + +![Serve all the functions!](/images/blog/2023-03-09-beta-update-february/supabase-cli-edge-functions.png) + +Do you use multiple Edge Functions in your project? Then celebrate! Supabase CLI 1.36+ now supports serving multiple Edge Functions at the same time. + +To enable the feature, just run `supabase functions serve` in your project. + +[Check the docs.](https://supabase.com/docs/reference/cli/supabase-functions-serve) + +## Smaller Postgres docker images for everyone + +![Smaller Postgres docker images for everyone](/images/blog/2023-03-09-beta-update-february/postgres-docker-images.png) + +We rewrote the Postgres Dockerfile with multi-stage builds so that each extension is compiled in its own separate stage. This reduces the size of the image from 1.3GB to 250MB, enabling a much faster boot time. + +[See it yourself](https://hub.docker.com/r/supabase/postgres/tags) + +## New UI for Postgres Roles + +![New UI for Postgres Roles](/images/blog/2023-03-09-beta-update-february/postgres-roles.png) + +We've improved database role management. You can create, update, and delete database roles through the dashboard. Just one small step towards column-level security + +[Check it out.](https://app.supabase.com/project/_/editor) + +## API docs in the table editor + +![API docs in the table editor](/images/blog/2023-03-09-beta-update-february/api-docs.png) + +API docs got a light touchup and were moved to the table editor. You can now look up API methods and generate & download type files right there ✨ + +[Check it out.](https://app.supabase.com/project/_/editor) + +## Quick product updates + +- **Postgres Extensions**: We're rolling out some fixes for several Postgres extensions. Check your Dashboard notifications to see if you need to take any actions. + +- **Auth**: Added full OpenAPI 3.0 spec which provides a comprehensive overview of the API with documentation on each request. [PR](https://github.com/supabase/gotrue/pull/918) + +- **Database**: supabase-js now infers the response type from your query. If the inferred type is incorrect, you can use `.returns()` to override it. [Doc](https://supabase.com/docs/reference/javascript/db-returns) + +- **Dashboard**: Improved database roles management, you can now create, update and delete database roles through the dashboard. [Dashboard](https://app.supabase.com/project/_/database/roles) + +- **Dashboard**: We've provided a reference panel showing all available paths that can be queried from each respective source that improves the Logs Explorer experience. [Dashboard](https://app.supabase.com/project/_/logs/explorer) + +- **Edge Functions**: upgraded to Deno 1.30.3, that supports TypeScript 4.9.x and introduces `satisfies`. Thanks to [Benjamin Dobell](https://github.com/Benjamin-Dobell) 🙏. [PR](https://github.com/supabase/functions-relay/pull/30) + +- Realtime: Broadcast only primary key(s) for deleting records when RLS is enabled and replica identity is full. [PR](https://github.com/supabase/realtime/pull/461) + +## Next.js with Supabase + +![Next.js with Supabase](/images/blog/2023-03-09-beta-update-february/nextjs-supabase.png) + +Building the next big thing with Supabase and Next.js? Then make sure to check Jon's new video series to help get you (and your app directory) off the ground. + +- [Fetching and Caching data in Next.js Async Server Components](https://www.youtube.com/watch?v=GniRj1jIhFw) + +- [Client vs Server Components in Next.js app directory // Merging server state with realtime updates](https://www.youtube.com/watch?v=YR-xP6PPXXA) + +- [Delicious Server Component Cookies // Auth in the Next.js app directory](https://www.youtube.com/watch?v=Bh1TOpOcGJQ) + +![Made with Supabase](/images/blog/2022-12-07-november-beta-update/madewithsupabase.jpg) + +[Domain-specific ChatGPT](https://github.com/gannonh/gpt3.5-turbo-pgvector) | This starter app uses embeddings coupled with vector search to show how the OpenAI API can be used to create a conversational interface to domain-specific knowledge. Built by [Gannon Hall](https://github.com/gannonh) with Next.js, Vercel, gpt-3.5-turbo, pgvector, and Supabase. + +Discover 100+ other apps in this [tweet](https://twitter.com/supabase/status/1628843899996209155). + +## Extended Community Highlights + +![Community](/images/blog/2022-june/community.jpg) + +- Langchain supports our database as a vector store, using pgvector. [Doc](https://hwchase17.github.io/langchainjs/docs/modules/indexes/vector_stores/supabase/)
        + +- refine week: building a CRUD app with refine & Supabase. [Full series](https://refine.dev/week-of-refine/) + +- Build an Instagram Web App with Supabase and Next.js. [Tutorial](https://livecycle.io/blogs/nextjs-supabase-instagram/)
        + +- Supabase GeoQueries with PostGIS and Storage Upload. [Video](https://www.youtube.com/watch?v=-_Wpr7rwU8I)
        + +- How I used Supabase to build a winning hackathon project. [Article](https://blog.matt.lgbt/how-i-used-supabase-to-build-a-winning-hackathon-project) + +- Using a Supabase API within FlutterFlow. [Video](https://www.youtube.com/watch?v=YzSXwlA3vWM)
        + +- Getting started with Supabase in Android. [Part 1](https://www.youtube.com/watch?v=SGr73sWMX6w) | [Part 2](https://www.youtube.com/watch?v=NWaIIRfVpuo) + +- Streaming User Events from PostgreSQL (Supabase) to Serverless Kafka. [Article](https://upstash.com/blog/postgre-supabase-connector)
        + +- Building a CRUD API with FastAPI and Supabase: A Step-by-Step Guide. [Tutorial](https://blog.theinfosecguy.xyz/building-a-crud-api-with-fastapi-and-supabase-a-step-by-step-guide)
        + +- How to build a collaborative SaaS product using Next.js and ZenStack's access control policy. [Tutorial](https://zenstack.dev/blog/saas-demo)
        + +- Nuxt 3, Tailwind, and Supabase Crash Course. [Course](https://dev.to/jacobandrewsky/nuxt-3-tailwind-and-supabase-crash-course-1482)
        + +- A sneak peek at the Supabase and Next.js RSC SaaS kit. [Article](https://makerkit.dev/blog/changelog/nextjs-supabase-kit-update)
        + +- Build a Fullstack serverless application using React, Chakra UI, Hookstate, and Supabase. [Tutorial](https://clx.hashnode.dev/build-a-fullstack-serverless-application-using-react-chakra-ui-hookstate-and-supabase)
        + +- Implement Sign in with Google using Supabase Auth in NextJS. [Tutorial](https://dev.to/irwanphan/implement-sign-in-with-google-using-supabase-auth-in-nextjs-1jj1)
        + +## We're Hiring + +Come join one of the fastest-growing open source projects ever 🤗 + +- [Technical partnerships lead (Next.js, Node,js, Supabase)](https://boards.greenhouse.io/supabase/jobs/4775849004)\ +- [Junior Developer Advocate (SMM & Community)](https://boards.greenhouse.io/supabase/jobs/4777008004) + +## Meme Zone + +As always, one of our favorite memes from last month. [Follow us on Twitter](https://twitter.com/supabase) for more. + +![Best meme from February 2023](/images/blog/2023-03-09-beta-update-february/beta-update-february-meme.jpeg) + +## It's happenning! Coming in April + +![Supabase Launch Week 7](/images/blog/2023-03-09-beta-update-february/supabase-launch-week-7.png) diff --git a/apps/www/components/BuiltWithSupabase/index.tsx b/apps/www/components/BuiltWithSupabase/index.tsx index 48d746c84c1..b467d87e570 100644 --- a/apps/www/components/BuiltWithSupabase/index.tsx +++ b/apps/www/components/BuiltWithSupabase/index.tsx @@ -16,18 +16,22 @@ const BuiltExamples = () => {

        There are many example apps and starter projects to get going

        diff --git a/apps/www/components/Carousels/SplitCodeBlockCarousel.tsx b/apps/www/components/Carousels/SplitCodeBlockCarousel.tsx index c8f6c619d70..51d62b25d1e 100644 --- a/apps/www/components/Carousels/SplitCodeBlockCarousel.tsx +++ b/apps/www/components/Carousels/SplitCodeBlockCarousel.tsx @@ -126,9 +126,7 @@ function SplitCodeBlockCarousel(props: SplitCodeBlockCarousel) { {extension.url && ( - + )} diff --git a/apps/www/components/DarkModeToggle.tsx b/apps/www/components/DarkModeToggle.tsx index 789bdd7a5a4..4b3b03cc7aa 100644 --- a/apps/www/components/DarkModeToggle.tsx +++ b/apps/www/components/DarkModeToggle.tsx @@ -4,14 +4,6 @@ import { IconSun, IconMoon } from 'ui' function DarkModeToggle() { const { isDarkMode, toggleTheme } = useTheme() - const toggleDarkMode = () => { - localStorage.setItem('supabaseDarkMode', (!isDarkMode).toString()) - toggleTheme() - - const key = localStorage.getItem('supabaseDarkMode') - document.documentElement.className = key === 'true' ? 'dark' : '' - } - return (
        @@ -24,7 +16,7 @@ function DarkModeToggle() { isDarkMode ? 'bg-scale-500' : 'bg-scale-900' } mx-5 `} - onClick={() => toggleDarkMode()} + onClick={() => toggleTheme()} > Toggle Themes { const { isDarkMode } = useTheme() + const { pathname } = useRouter() + const isLaunchWeekPage = pathname.includes('launch-week') return (
        diff --git a/apps/www/components/LaunchWeek/LW7BgGraphic.tsx b/apps/www/components/LaunchWeek/LW7BgGraphic.tsx new file mode 100644 index 00000000000..f5b29f01eb6 --- /dev/null +++ b/apps/www/components/LaunchWeek/LW7BgGraphic.tsx @@ -0,0 +1,45 @@ +import { motion, useScroll, useTransform } from 'framer-motion' +import Image from 'next/image' +import { useMobileViewport } from '../../hooks/useMobileViewport' +import styles from './lw7-bg-graphic.module.css' + +export default function LW7BgGraphic() { + const { scrollYProgress } = useScroll() + const isMobile = useMobileViewport(768) + + const graphicsY = useTransform( + scrollYProgress, + // Map scrollYProgress from these values: + [0, 0.5], + // Into these values: + [0, isMobile ? 70 : 200] + ) + + return ( +
        + + + +
        + ) +} diff --git a/apps/www/components/LaunchWeek/LabelBadge.tsx b/apps/www/components/LaunchWeek/LabelBadge.tsx new file mode 100644 index 00000000000..43960ce0a53 --- /dev/null +++ b/apps/www/components/LaunchWeek/LabelBadge.tsx @@ -0,0 +1,14 @@ +import React from 'react' + +interface Props { + text: string + gradientText?: boolean +} + +export default function LabelBadge({ text, gradientText = false }: Props) { + return ( + + {text} + + ) +} diff --git a/apps/www/components/LaunchWeek/LaunchSection/LaunchWeekLogoHeader.tsx b/apps/www/components/LaunchWeek/LaunchSection/LaunchWeekLogoHeader.tsx new file mode 100644 index 00000000000..99880d97369 --- /dev/null +++ b/apps/www/components/LaunchWeek/LaunchSection/LaunchWeekLogoHeader.tsx @@ -0,0 +1,33 @@ +import classNames from 'classnames' +import Image from 'next/image' +import { motion } from 'framer-motion' +import { useMobileViewport } from '../../../hooks/useMobileViewport' + +export function LaunchWeekLogoHeader() { + const isMobile = useMobileViewport(640) + + return ( +
        + +

        + Launch week + + + +

        +

        + April 10th – 14th at 7AM PT | 10AM ET +

        +
        +
        + ) +} diff --git a/apps/www/components/LaunchWeek/LaunchSection/LaunchWeekPrizeCard.tsx b/apps/www/components/LaunchWeek/LaunchSection/LaunchWeekPrizeCard.tsx new file mode 100644 index 00000000000..057e74db4c5 --- /dev/null +++ b/apps/www/components/LaunchWeek/LaunchSection/LaunchWeekPrizeCard.tsx @@ -0,0 +1,68 @@ +import Image from 'next/image' +import React from 'react' +import { motion } from 'framer-motion' + +type animateFromType = 'left' | 'right' | 'up' | 'down' | 'opacityOnly' + +export default function LaunchWeekPrizeCard({ + imageUrl = '', + imageWrapperClassName, + content, + className, + animateFrom, + imgObjectPosition = 'center', +}: { + imageUrl: string + imageWrapperClassName?: string + content: any + className?: string + animateFrom?: animateFromType + imgObjectPosition?: string +}) { + const finalState = { x: 0, y: 0, opacity: 1 } + + function getAnimationFrom(animationDirection: animateFromType) { + switch (animationDirection) { + case 'left': + return { x: -20, opacity: 0 } + case 'up': + return { y: -20, opacity: 0 } + case 'right': + return { x: 20, opacity: 0 } + case 'down': + return { y: 20, opacity: 0 } + case 'opacityOnly': + return { opacity: 0 } + } + } + + return ( + +
        +
        + +
        +
        {content && content}
        +
        +
        + ) +} diff --git a/apps/www/components/LaunchWeek/LaunchSection/LaunchWeekPrizeSection.tsx b/apps/www/components/LaunchWeek/LaunchSection/LaunchWeekPrizeSection.tsx new file mode 100644 index 00000000000..05f73843e1e --- /dev/null +++ b/apps/www/components/LaunchWeek/LaunchSection/LaunchWeekPrizeSection.tsx @@ -0,0 +1,122 @@ +import React from 'react' +import LabelBadge from '../LabelBadge' +import LaunchWeekPrizeCard from './LaunchWeekPrizeCard' +import { motion } from 'framer-motion' + +export default function LaunchWeekPrizeSection({ className }: { className?: string }) { + const finalAnimationState = { y: 0, opacity: 1 } + + return ( +
        +
        +
        + +
        + + + + + + + + + +
        +

        + Get your winning ticket +

        +

        + Join us on April 16th for Launch Week 7's final day +
        and find out if you are one of the lucky + winners. Get sharing! +

        +
        +
        +
        +
        + +

        + Main award +

        +

        + Mechanical Keyboard +

        +

        + Increase your chances of winning limited edition 62-Key ISO Custom Mechanical + Keyboard by sharing your ticket on Twitter and LinkedIn. +

        + + } + /> + +
        +
        + + Supabase T-shirt + + } + animateFrom="up" + /> + + Supabase Socks + + } + animateFrom="right" + /> +
        + + Supabase Sticker Pack + + } + /> +
        +
        +
        +
        +
        + ) +} diff --git a/apps/www/components/LaunchWeek/LaunchSection/TicketBrick.tsx b/apps/www/components/LaunchWeek/LaunchSection/TicketBrick.tsx new file mode 100644 index 00000000000..50e0438868d --- /dev/null +++ b/apps/www/components/LaunchWeek/LaunchSection/TicketBrick.tsx @@ -0,0 +1,62 @@ +import React, { useState } from 'react' +import TicketHeader from '../Ticket/TicketHeader' +import TicketNumber from '../Ticket/TicketNumber' +import { UserData } from '../Ticket/hooks/use-conf-data' +import Image from 'next/image' + +interface Props { + user: UserData +} + +export function TicketBrick({ user }: Props) { + const [isLoading, setLoading] = useState(true) + + const golden = user.sharedOnLinkedIn && user.sharedOnTwitter + + // reg_bg_57.png + const baseImagePath = `https://obuldanrptloktxcffvn.supabase.co/storage/v1/object/public/images/lw7/tickets_bg/` + + const randomNumber = Math.floor(Math.random() * 200) + 1 + const goldRandomNumber = Math.floor(Math.random() * 56) + 1 + + return ( + <> +
        + setLoading(false)} + /> +
        + +
        + {user.username} +
        +

        + {user.name ? user.name : user.username} +

        + {user.name &&

        @{user.username}

        } +
        +
        + +
        +
        + + ) +} diff --git a/apps/www/components/LaunchWeek/LaunchSection/TicketBrickWall.tsx b/apps/www/components/LaunchWeek/LaunchSection/TicketBrickWall.tsx new file mode 100644 index 00000000000..a8e0585f81c --- /dev/null +++ b/apps/www/components/LaunchWeek/LaunchSection/TicketBrickWall.tsx @@ -0,0 +1,47 @@ +import Link from 'next/link' +import React from 'react' +import { Button } from 'ui' +import { useMobileViewport } from '../../../hooks/useMobileViewport' +import { UserData } from '../Ticket/hooks/use-conf-data' +import { TicketBrickWallSlider } from './TicketBrickWallSlider' + +type user = UserData + +interface Props { + users: user[] +} + +export default function TicketBrickWall({ users }: Props) { + const isMobile = useMobileViewport(768) + return ( +
        +
        +
        +

        + Explore the unique tickets generated by our community +

        +
        +
        + + + {isMobile && } +
        +
        + + + +
        +
        +
        + ) +} diff --git a/apps/www/components/LaunchWeek/LaunchSection/TicketBrickWallSlider.tsx b/apps/www/components/LaunchWeek/LaunchSection/TicketBrickWallSlider.tsx new file mode 100644 index 00000000000..7565f658aea --- /dev/null +++ b/apps/www/components/LaunchWeek/LaunchSection/TicketBrickWallSlider.tsx @@ -0,0 +1,92 @@ +import { useEffect, useRef, useState } from 'react' +import Image from 'next/image' +import Link from 'next/link' +import { UserData } from '../Ticket/hooks/use-conf-data' +import { useWindowSize } from 'react-use' + +import { Swiper, SwiperSlide } from 'swiper/react' +import SwiperCore, { Autoplay } from 'swiper' +import 'swiper/swiper.min.css' + +SwiperCore.use([Autoplay]) + +interface Props { + users: UserData[] + reverse?: boolean + speed?: number +} + +export function TicketBrickWallSlider({ users, reverse, speed = 10000 }: Props) { + const ref = useRef(null) + const [swiperInstace, setSwiperInstance] = useState(null) + const { width } = useWindowSize() + const STORAGE_URL = 'https://obuldanrptloktxcffvn.supabase.co/storage/v1/object/public/images/lw7' + const BUCKET_FOLDER_VERSION = 'v3' + const getOgUrl = (username: string, isGold: boolean) => + `${STORAGE_URL}/tickets/gallery/${ + isGold ? 'golden' : 'regular' + }/${BUCKET_FOLDER_VERSION}/${username}.png` + + useEffect(() => { + // trigger autplay if viewport resize + if (swiperInstace) swiperInstace?.autoplay?.run() + }, [width]) + + return ( +
        +
        + setSwiperInstance(swiper)} + centeredSlides={true} + spaceBetween={10} + slidesPerView={5} + speed={speed} + loop={true} + watchOverflow + threshold={2} + updateOnWindowResize + allowTouchMove={false} + autoplay={{ + delay: 0, + disableOnInteraction: true, + reverseDirection: reverse, + }} + breakpoints={{ + 320: { + slidesPerView: 2.5, + }, + 720: { + slidesPerView: 3.5, + }, + 1440: { + slidesPerView: 5, + }, + }} + > + {users.map((user, i) => ( + + + +
        +
        + {user.username} +
        +
        +
        + +
        + ))} +
        +
        +
        + ) +} diff --git a/apps/www/components/LaunchWeek/LaunchSection/brick-wall.module.css b/apps/www/components/LaunchWeek/LaunchSection/brick-wall.module.css new file mode 100644 index 00000000000..c81740ed399 --- /dev/null +++ b/apps/www/components/LaunchWeek/LaunchSection/brick-wall.module.css @@ -0,0 +1,6 @@ +/* mask overlay on left & right */ +.brick-wall-mask--left, +.brick-wall-mask--right { + background: linear-gradient(358.88deg, #1c1c1c 24.57%, #1c1c1c00 87.73%), + linear-gradient(358.88deg, #1c1c1c 24.57%, #1c1c1c00 87.73%); +} diff --git a/apps/www/components/LaunchWeek/LaunchSection/ticket-brick-wall-slider.module.css b/apps/www/components/LaunchWeek/LaunchSection/ticket-brick-wall-slider.module.css new file mode 100644 index 00000000000..164e683b3a2 --- /dev/null +++ b/apps/www/components/LaunchWeek/LaunchSection/ticket-brick-wall-slider.module.css @@ -0,0 +1,3 @@ +.swiper-wrapper .swiper-slide { + width: 300px !important; +} diff --git a/apps/www/components/LaunchWeek/ReferralIndicator.tsx b/apps/www/components/LaunchWeek/ReferralIndicator.tsx new file mode 100644 index 00000000000..fd3a7f4c3fd --- /dev/null +++ b/apps/www/components/LaunchWeek/ReferralIndicator.tsx @@ -0,0 +1,21 @@ +import React from 'react' +import useConfData from '~/components/LaunchWeek/Ticket//hooks/use-conf-data' +import useWinningChances from './Ticket/hooks/useWinningChances' + +export default function ReferralIndicator() { + const winningChances = useWinningChances() + + return ( +
        +
        + {winningChances}x chance +
        +
        + ) +} diff --git a/apps/www/components/LaunchWeek/Ticket/ActualTicket.tsx b/apps/www/components/LaunchWeek/Ticket/ActualTicket.tsx new file mode 100644 index 00000000000..1fe88229da0 --- /dev/null +++ b/apps/www/components/LaunchWeek/Ticket/ActualTicket.tsx @@ -0,0 +1,189 @@ +import cn from 'classnames' +import { useRef, useState } from 'react' +import styles from './ticket.module.css' +import styleUtils from './utils.module.css' +import TicketVisual from './TicketVisual' +import TicketActions from './TicketActions' +import TicketCopy from './ticket-copy' + +import { UserData } from './hooks/use-conf-data' +import ReferralIndicator from '../ReferralIndicator' +import useWinningChances from './hooks/useWinningChances' +import { SITE_URL } from '~/lib/constants' +import { useMobileViewport } from '../../../hooks/useMobileViewport' + +type TicketGenerationState = 'default' | 'loading' + +type Props = { + username: UserData['username'] + ticketNumber: UserData['ticketNumber'] + name: UserData['name'] + golden: UserData['golden'] + bgImageId: UserData['bg_image_id'] + referrals: number + sharePage?: boolean +} + +export default function Ticket({ + username, + name, + ticketNumber, + sharePage, + golden, + bgImageId, + referrals, +}: Props) { + const isMobile = useMobileViewport(1023) + const [ticketGenerationState, setTicketGenerationState] = + useState('default') + const divRef = useRef(null) + const winningChances = useWinningChances() + + return ( +
        +
        +
        + + {username && ( +
        + +
        + )} +
        +
        +
        +
        +

        + {!sharePage ? ( + <> + {name ? ( + <> + {winningChances === 1 && ( + + You're in the draw!
        + Now make it gold. +
        + )} + {winningChances === 2 && ( + + You've doubled your{' '} +
        + chance! +
        Almost{' '} + gold. +
        + )} + {winningChances === 3 && ( + + You're gold!
        + You've maxed your
        chances of winning! +
        + )} + + ) : ( + + Generate your ticket.
        + Win the SupaKeyboard. +
        + )} + + ) : ( + + {name ? name : username}'s
        + unique ticket +
        + )} +

        + +
        + {!sharePage ? ( + <> + {golden ? ( +

        + Join us on April 16th for Launch Week 7's final day and find out if you are one + of the lucky winners. +

        + ) : username ? ( +

        + Why stop there? Increase your chances of winning by sharing your unique ticket. + Get sharing! +

        + ) : ( +

        + We have some fantastic swag up for grabs, including 3 limited-edition mechanical + keyboards that you won't want to miss. +

        + )} + + ) : ( + <> +

        + Get yours and win some fantastic swag, including a limited-edition mechanical + keyboard that you won't want to miss. +

        + + + + )} +
        + + {!sharePage && username && } +
        +
        + {username && ( + + )} +
        +
        +
        + ) +} diff --git a/apps/www/components/LaunchWeek/Ticket/TicketActions.tsx b/apps/www/components/LaunchWeek/Ticket/TicketActions.tsx new file mode 100644 index 00000000000..ee351e58f04 --- /dev/null +++ b/apps/www/components/LaunchWeek/Ticket/TicketActions.tsx @@ -0,0 +1,122 @@ +import { useEffect, useRef, useState } from 'react' +import { SITE_URL, TWEET_TEXT, TWEET_TEXT_GOLDEN } from '~/lib/constants' +import { IconCheckCircle } from 'ui' +import useConfData from '~/components/LaunchWeek/Ticket//hooks/use-conf-data' +import { useParams } from '~/hooks/useParams' +import TicketForm from './TicketForm' + +type TicketGenerationState = 'default' | 'loading' + +type Props = { + username: string + golden?: boolean + ticketGenerationState?: TicketGenerationState + setTicketGenerationState: (ticketGenerationState: TicketGenerationState) => void +} + +export default function TicketActions({ + username, + golden = false, + ticketGenerationState, + setTicketGenerationState, +}: Props) { + const [imgReady, setImgReady] = useState(false) + const [loading, setLoading] = useState(false) + const downloadLink = useRef() + const permalink = (medium: string) => + encodeURIComponent(`${SITE_URL}/tickets/${username}?lw=7${golden ? `&golden=true` : ''}`) + const text = encodeURIComponent(golden ? TWEET_TEXT_GOLDEN : TWEET_TEXT) + const { userData } = useConfData() + const tweetUrl = `https://twitter.com/intent/tweet?url=${permalink( + 'twitter' + )}&via=supabase&text=${text}` + const linkedInUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${permalink('linkedin')}` + const downloadUrl = `https://obuldanrptloktxcffvn.functions.supabase.co/lw7-ticket-og?username=${encodeURIComponent( + username + )}` + const params = useParams() + const sharePage = params.username + + useEffect(() => { + setImgReady(false) + + const img = new Image() + + img.src = downloadUrl + img.onload = () => { + setImgReady(true) + setLoading(false) + if (downloadLink.current) { + downloadLink.current.click() + downloadLink.current = undefined + } + } + }, [downloadUrl]) + + return ( +
        + {!sharePage ? ( + <> +
        +
        +
        + +
        + Connect with Github +
        +
        + + + + ) : ( + !username && ( + + ) + )} +
        + ) +} diff --git a/apps/www/components/LaunchWeek/Ticket/TicketContainer.tsx b/apps/www/components/LaunchWeek/Ticket/TicketContainer.tsx index f658037f136..3eeb644ca6a 100644 --- a/apps/www/components/LaunchWeek/Ticket/TicketContainer.tsx +++ b/apps/www/components/LaunchWeek/Ticket/TicketContainer.tsx @@ -1,7 +1,6 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import { PageState, ConfDataContext, UserData } from './hooks/use-conf-data' -import Ticket from './ticket' -import ConfContainer from './conf-container' +import Ticket from './ActualTicket' import Form from './form' import { SupabaseClient, Session } from '@supabase/supabase-js' @@ -23,6 +22,10 @@ export default function Conf({ const [userData, setUserData] = useState(defaultUserData) const [pageState, setPageState] = useState(defaultPageState) + useEffect(() => { + localStorage.setItem('isGolden', !!userData?.golden ? 'true' : 'false') + }, [userData]) + return ( )} diff --git a/apps/www/components/LaunchWeek/Ticket/TicketForm.tsx b/apps/www/components/LaunchWeek/Ticket/TicketForm.tsx new file mode 100644 index 00000000000..8c46fd7cdfe --- /dev/null +++ b/apps/www/components/LaunchWeek/Ticket/TicketForm.tsx @@ -0,0 +1,174 @@ +import { useState, useRef, useEffect } from 'react' +import { useRouter } from 'next/router' +import cn from 'classnames' +import { SITE_ORIGIN } from '~/lib/constants' +import useConfData from '~/components/LaunchWeek/Ticket//hooks/use-conf-data' +import formStyles from './form.module.css' +import ticketFormStyles from './ticket-form.module.css' +import { Button, IconCheckCircle, IconLoader } from 'ui' + +type FormState = 'default' | 'loading' | 'error' +type TicketGenerationState = 'default' | 'loading' + +type Props = { + defaultUsername?: string + ticketGenerationState?: TicketGenerationState + setTicketGenerationState: any +} + +export default function TicketForm({ defaultUsername = '', setTicketGenerationState }: Props) { + const [username, setUsername] = useState(defaultUsername) + const [formState, setFormState] = useState('default') + const [errorMsg] = useState('') + const { supabase, session, setUserData, setPageState, userData } = useConfData() + const [realtimeChannel, setRealtimeChannel] = useState | null>(null) + const formRef = useRef(null) + const router = useRouter() + + useEffect(() => { + if (session?.user && !userData.id) { + document.body.classList.add('ticket-generated') + const username = session.user.user_metadata.user_name + setUsername(username) + const name = session.user.user_metadata.full_name + const email = session.user.email + supabase + .from('lw7_tickets') + .insert({ email, name, username, referred_by: router.query?.referral ?? null }) + .eq('email', email) + .select() + .single() + .then(async ({ error }) => { + // If error because of duplicate email, ignore and proceed, otherwise sign out. + if (error && error?.code !== '23505') return supabase.auth.signOut() + const { data } = await supabase + .from('lw7_tickets_golden') + .select('*') + .eq('username', username) + .single() + setUserData(data) + setFormState('default') + + // Prefetch GitHub avatar + new Image().src = `https://github.com/${username}.png` + + // Prefetch the twitter share URL to eagerly generate the page + fetch(`/launch-week/tickets/${username}`).catch((_) => {}) + // Prefetch ticket og image. + fetch( + `https://obuldanrptloktxcffvn.functions.supabase.co/lw7-ticket-og?username=${encodeURIComponent( + username ?? '' + )}` + ).catch((_) => {}) + + setPageState('ticket') + + // Listen to realtime changes + if (!realtimeChannel && !data.golden) { + const channel = supabase + .channel('changes') + .on( + 'postgres_changes', + { + event: 'UPDATE', + schema: 'public', + table: 'lw7_tickets', + filter: `username=eq.${username}`, + }, + (payload) => { + const golden = !!payload.new.sharedOnTwitter && !!payload.new.sharedOnLinkedIn + setUserData({ + ...payload.new, + golden, + }) + if (golden) { + channel.unsubscribe() + } + } + ) + .subscribe() + setRealtimeChannel(channel) + } + }) + } + return () => { + // Cleanup realtime subscription on unmount + realtimeChannel?.unsubscribe() + } + }, [session]) + + return formState === 'error' ? ( +
        +
        +
        +
        {errorMsg}
        + +
        +
        +
        + ) : ( +
        { + e.preventDefault() + + if (formState !== 'default') { + setTicketGenerationState('default') + setFormState('default') + return + } + + setFormState('loading') + setTicketGenerationState('loading') + + await supabase.auth.signInWithOAuth({ + provider: 'github', + options: { + redirectTo: `${SITE_ORIGIN}/launch-week/${ + userData.username ? '?referral=' + userData.username : '' + }`, + }, + }) + }} + className="flex flex-col items-center xl:block relative z-20" + id="wayfinding--connect-with-github-form" + > +
        +
        + +
        + {/* {!session &&

        Only public info will be used.

        } */} +
        +
        + ) +} diff --git a/apps/www/components/LaunchWeek/Ticket/TicketHeader.tsx b/apps/www/components/LaunchWeek/Ticket/TicketHeader.tsx new file mode 100644 index 00000000000..da3d1e79401 --- /dev/null +++ b/apps/www/components/LaunchWeek/Ticket/TicketHeader.tsx @@ -0,0 +1,19 @@ +import React from 'react' +import { DATE } from '~/lib/constants' + +interface Props { + size?: 'default' | 'small' +} +export default function TicketHeader({ size }: Props) { + return ( +
        +
        + + {DATE} +
        +
        + ) +} diff --git a/apps/www/components/LaunchWeek/Ticket/TicketNumber.tsx b/apps/www/components/LaunchWeek/Ticket/TicketNumber.tsx new file mode 100644 index 00000000000..f06b8d6fd1c --- /dev/null +++ b/apps/www/components/LaunchWeek/Ticket/TicketNumber.tsx @@ -0,0 +1,95 @@ +type Props = { + number: number | undefined + size?: 'default' | 'small' +} + +export default function TicketNumber({ number, size }: Props) { + const numDigits = `${number}`.length + const prefix = `00000000`.slice(numDigits) + const ticketNumberText = `№ ${prefix}${number}` + + return ( + <> +
        +
        + {/* Mobile line */} +
        + + + + + + + + + + +
        + {/* Vertical line: desktop */} +
        + + + + + + + + + + +
        +
        + {ticketNumberText} +
        +
        +
        + + ) +} diff --git a/apps/www/components/LaunchWeek/Ticket/TicketProfile.tsx b/apps/www/components/LaunchWeek/Ticket/TicketProfile.tsx new file mode 100644 index 00000000000..965171407f5 --- /dev/null +++ b/apps/www/components/LaunchWeek/Ticket/TicketProfile.tsx @@ -0,0 +1,86 @@ +// import GithubIcon from '~/components/LaunchWeek/Ticket/icons/icon-github' +import cn from 'classnames' +import TicketForm from './TicketForm' +// import IconAvatar from '~/components/LaunchWeek/Ticket/icons/icon-avatar' +import styles from './ticket-profile.module.css' +import Image from 'next/image' + +type TicketGenerationState = 'default' | 'loading' +type Props = { + name?: string + username?: string + size?: number + ticketGenerationState?: TicketGenerationState + setTicketGenerationState?: (ticketGenerationState: TicketGenerationState) => void + golden?: boolean +} + +// The middle part of the ticket +// avatar / Yourn Name / Username +export default function TicketProfile({ + name, + username, + size = 1, + ticketGenerationState, + setTicketGenerationState, + golden = false, +}: Props) { + return ( +
        + {username && ( + + {username ? ( + {username} + ) : ( + <> + {/* + { } + */} + + )} + + )} +
        + {username ? ( +

        +

        + {name || username || 'Your Name'} +

        {name &&

        @{username}

        }

        +
        +

        + ) : ( + + )} +
        +
        + ) +} diff --git a/apps/www/components/LaunchWeek/Ticket/TicketVisual.tsx b/apps/www/components/LaunchWeek/Ticket/TicketVisual.tsx new file mode 100644 index 00000000000..be31e8c231c --- /dev/null +++ b/apps/www/components/LaunchWeek/Ticket/TicketVisual.tsx @@ -0,0 +1,130 @@ +import styles from './ticket-visual.module.css' +import TicketProfile from './TicketProfile' +import TicketNumber from './TicketNumber' +import Tilt from 'vanilla-tilt' +import useConfData from '~/components/LaunchWeek/Ticket/hooks/use-conf-data' +import TicketHeader from './TicketHeader' +import { useEffect, useRef, useState } from 'react' +import Image from 'next/image' + +type TicketGenerationState = 'default' | 'loading' +type Props = { + size?: number + name?: string + ticketNumber?: number + bgImageId?: number + username?: string + ticketGenerationState?: TicketGenerationState + setTicketGenerationState?: any + golden?: boolean +} + +export default function TicketVisual({ + size = 1, + name, + username, + bgImageId, + ticketNumber, + ticketGenerationState = 'default', + setTicketGenerationState, + golden = false, +}: Props) { + const { session } = useConfData() + const [imageIsLoading, setImageIsLoading] = useState(true) + const ticketRef = useRef(null) + const storageBaseFilepath = `https://obuldanrptloktxcffvn.supabase.co/storage/v1/object/public/images/lw7/tickets_bg` + + const ticketBg = { + regular: { + image: `${storageBaseFilepath}/blurred/regular/jpg/reg_bg_${bgImageId}.jpg`, + overlay: `/images/launchweek/seven/ticket-overlay-reg.png`, + }, + gold: { + image: `${storageBaseFilepath}/blurred/golden/jpg/gold_bg_${bgImageId}.jpg`, + overlay: `/images/launchweek/seven/ticket-overlay-gold.png`, + }, + } + + useEffect(() => { + if (ticketRef.current && !window.matchMedia('(pointer: coarse)').matches) { + Tilt.init(ticketRef.current, { + glare: !golden, + max: 4, + gyroscope: true, + 'max-glare': 0.3, + 'full-page-listening': true, + }) + } + }, [ticketRef]) + + return ( +
        +
        +
        + {username && } +
        + +
        +
        + +
        + {username && ( + + )} + setImageIsLoading(false)} + /> +
        +
        +
        + ) +} diff --git a/apps/www/components/LaunchWeek/Ticket/TicketsGrid.tsx b/apps/www/components/LaunchWeek/Ticket/TicketsGrid.tsx new file mode 100644 index 00000000000..4377b09b8c8 --- /dev/null +++ b/apps/www/components/LaunchWeek/Ticket/TicketsGrid.tsx @@ -0,0 +1,90 @@ +import { motion } from 'framer-motion' +import Image from 'next/image' +import Link from 'next/link' +import { useMobileViewport } from '../../../hooks/useMobileViewport' +import { UserData } from './hooks/use-conf-data' + +interface Props { + loadedUsers: UserData[] + isLoading: boolean + pageCount: number + offset: number +} + +export default function TicketsGrid({ loadedUsers, isLoading, pageCount, offset }: Props) { + const STORAGE_URL = 'https://obuldanrptloktxcffvn.supabase.co/storage/v1/object/public/images/lw7' + const BUCKET_FOLDER_VERSION = 'v3' + const getOgUrl = (username: string, isGold: boolean) => + `${STORAGE_URL}/tickets/gallery/${ + isGold ? 'golden' : 'regular' + }/${BUCKET_FOLDER_VERSION}/${username}.png` + const isMobile = useMobileViewport(768) + const isTablet = useMobileViewport(1024) + + return ( +
        + {loadedUsers?.map((user, i) => { + const rowTickets = isMobile ? 2 : isTablet ? 3 : 5 + const divider = Math.floor(i / rowTickets) + const isOddRow = divider % 2 === 0 + // Delay should be no more than pageCount for new loaded users + const recalculatedDelay = i >= pageCount * 2 ? (i - pageCount * (offset - 1)) / 15 : i / 15 + + return ( + + +
        + {user.username} +
        +
        + + ) + })} + {isLoading && + Array.from({ length: pageCount }, (_, i) => ( + +
        +
        +
        + + ))} +
        + ) +} diff --git a/apps/www/components/LaunchWeek/Ticket/form.tsx b/apps/www/components/LaunchWeek/Ticket/form.tsx index 59dbcb902ac..d8a9c9dbc3d 100644 --- a/apps/www/components/LaunchWeek/Ticket/form.tsx +++ b/apps/www/components/LaunchWeek/Ticket/form.tsx @@ -1,13 +1,11 @@ -import { useState, useCallback, useEffect } from 'react' +import { useState, useCallback } from 'react' import cn from 'classnames' import useConfData from '~/components/LaunchWeek/Ticket/hooks/use-conf-data' import { useRouter } from 'next/router' -import LoadingDots from './loading-dots' import styleUtils from './utils.module.css' import styles from './form.module.css' import useEmailQueryParam from '~/components/LaunchWeek/Ticket/hooks/use-email-query-param' import { IconLoader } from '~/../../packages/ui' -// import Captcha, { useCaptcha } from './captcha' type FormState = 'default' | 'loading' | 'error' @@ -37,45 +35,7 @@ export default function Form({ sharePage, align = 'Center' }: Props) { const router = useRouter() const isCaptchaEnabled = false - useEffect(() => { - if (session?.user) { - document.body.classList.add('ticket-generated') - const username = session.user.user_metadata.user_name - const name = session.user.user_metadata.full_name - const email = session.user.email - supabase - .from('lw6_tickets') - .upsert({ email, name, username }, { onConflict: 'email', ignoreDuplicates: false }) - .eq('email', email) - .select() - .single() - .then(async ({ error }) => { - if (error) return supabase.auth.signOut() - const { data } = await supabase - .from('lw6_tickets_golden') - .select('*') - .eq('username', username) - .single() - setUserData(data) - setFormState('default') - - // Prefetch GitHub avatar - new Image().src = `https://github.com/${username}.png` - - // Prefetch the twitter share URL to eagerly generate the page - fetch(`/launch-week/tickets/${username}`).catch((_) => {}) - // Prefetch ticket og image. - fetch( - `https://obuldanrptloktxcffvn.functions.supabase.co/launchweek-ticket-og?username=${encodeURIComponent( - username ?? '' - )}` - ).catch((_) => {}) - - setPageState('ticket') - }) - } - }, [session]) - + // NOTE: this is not in use anymore as we're only allowing GitHub signups since LW7! async function register(email: string, token?: string): Promise { const { error } = await supabase!.from('lw6_tickets').insert({ email }) if (error) { diff --git a/apps/www/components/LaunchWeek/Ticket/hooks/use-conf-data.ts b/apps/www/components/LaunchWeek/Ticket/hooks/use-conf-data.ts index 4855f4f9a56..fc8d1df8fbb 100644 --- a/apps/www/components/LaunchWeek/Ticket/hooks/use-conf-data.ts +++ b/apps/www/components/LaunchWeek/Ticket/hooks/use-conf-data.ts @@ -9,6 +9,10 @@ export type UserData = { username?: string name?: string golden?: boolean + referrals?: number + bg_image_id?: number + sharedOnTwitter?: string + sharedOnLinkedIn?: string } type ConfDataContextType = { diff --git a/apps/www/components/LaunchWeek/Ticket/hooks/useWinningChances.ts b/apps/www/components/LaunchWeek/Ticket/hooks/useWinningChances.ts new file mode 100644 index 00000000000..b843fd0f5fe --- /dev/null +++ b/apps/www/components/LaunchWeek/Ticket/hooks/useWinningChances.ts @@ -0,0 +1,25 @@ +import { UserData } from './use-conf-data' +import useConfData from '~/components/LaunchWeek/Ticket//hooks/use-conf-data' + +function calculateWinChances(userData: UserData): number { + // Just signing up gives you 1 + // Then signing up with LinkedIn and Twitter get you one more each for a total of 3 + // When the chance === 3, you have a golden ticket + let numChances = 1 + + if (userData.sharedOnLinkedIn) { + numChances++ + } + if (userData.sharedOnTwitter) { + numChances++ + } + + return numChances +} + +export default function useWinningChances() { + const { userData } = useConfData() + + const chances = calculateWinChances(userData) + return chances +} diff --git a/apps/www/components/LaunchWeek/Ticket/ticket-actions.tsx b/apps/www/components/LaunchWeek/Ticket/ticket-actions.tsx deleted file mode 100644 index fd7c62842a4..00000000000 --- a/apps/www/components/LaunchWeek/Ticket/ticket-actions.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { useEffect, useRef, useState } from 'react' -import { SITE_URL, TWEET_TEXT, TWEET_TEXT_GOLDEN } from '~/lib/constants' -import { IconDownload, IconLinkedin, IconTwitter } from 'ui' -import useConfData from '~/components/LaunchWeek/Ticket//hooks/use-conf-data' -import LoadingDots from './loading-dots' - -type Props = { - username: string - golden?: boolean -} - -export default function TicketActions({ username, golden = false }: Props) { - const [imgReady, setImgReady] = useState(false) - const [loading, setLoading] = useState(false) - const downloadLink = useRef() - const permalink = encodeURIComponent(`${SITE_URL}/tickets/${username}?v=6`) - const text = encodeURIComponent(golden ? TWEET_TEXT_GOLDEN : TWEET_TEXT) - const { userData } = useConfData() - const tweetUrl = `https://twitter.com/intent/tweet?url=${permalink}&via=supabase&text=${text}` - const linkedInUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${permalink}` - const downloadUrl = `https://obuldanrptloktxcffvn.functions.supabase.co/launchweek-ticket-og?username=${encodeURIComponent( - userData.username ?? '' - )}` - - useEffect(() => { - setImgReady(false) - - const img = new Image() - - img.src = downloadUrl - img.onload = () => { - setImgReady(true) - setLoading(false) - if (downloadLink.current) { - downloadLink.current.click() - downloadLink.current = undefined - } - } - }, [downloadUrl]) - - const ActionStyle = ({ children }: any) => { - return ( -
        - {children} -
        - ) - } - - return ( - <> - - -
        - -
        - Tweet it -
        -
        - - -
        - -
        - Share on Linkedin -
        -
        - - { - if (imgReady) return - - e.preventDefault() - downloadLink.current = e.currentTarget - // Wait for the image download to finish - setLoading(true) - }} - download="ticket.png" - className="flex items-center gap-2" - > -
        - -
        - Download - {loading ? : <>{/* Download */}} -
        -
        - - ) -} diff --git a/apps/www/components/LaunchWeek/Ticket/ticket-copy.tsx b/apps/www/components/LaunchWeek/Ticket/ticket-copy.tsx index 997ada1729f..8ac0074d341 100644 --- a/apps/www/components/LaunchWeek/Ticket/ticket-copy.tsx +++ b/apps/www/components/LaunchWeek/Ticket/ticket-copy.tsx @@ -2,8 +2,6 @@ import { useEffect, useState, useRef } from 'react' import cn from 'classnames' import { SITE_URL } from '~/lib/constants' import styleUtils from './utils.module.css' -// import IconCopy from '~/components/LaunchWeek/Ticket/icons/icon-copy' -import styles from './ticket-copy.module.css' import { IconCopy, IconCheck } from 'ui' type Props = { @@ -17,31 +15,18 @@ export default function TicketCopy({ username }: Props) { const [copied, setCopied] = useState(false) const scrollRef = useRef(null) const buttonRef = useRef(null) - const url = `${SITE_URL}/tickets/${username}` + const url = `${SITE_URL}/tickets/${username}?lw=7` useEffect(() => { if (navigator.clipboard) { setCopyEnabled(true) } }, []) - const copiedText = Copied! - - // background: none; - // outline: none; - // border: none; - // z-index: 2; - // border-radius: var(--space-2x); - // width: 40px; - // display: flex; - // align-items: center; - // justify-content: center; - // margin-right: -10px; - const copyButton = ( ) @@ -67,14 +56,14 @@ export default function TicketCopy({ username }: Props) { className={cn( styleUtils.appear, styleUtils['appear-third'], - 'bg-scaleA-200 h-8 rounded border border-scale-400 w-full' + 'bg-scaleA-200 h-8 rounded-full border border-[#E3E3E370] w-full overflow-hidden' )} + id="wayfinding--ticket-copy" > -
        -
        Your ticket URL:
        +

        { if (!scrolling) { @@ -95,10 +84,7 @@ export default function TicketCopy({ username }: Props) { {url}

        -
        - {copied && copiedText} - {copyButton} -
        +
        {copyButton}
        ) diff --git a/apps/www/components/LaunchWeek/Ticket/ticket-form.tsx b/apps/www/components/LaunchWeek/Ticket/ticket-form.tsx deleted file mode 100644 index 8ded0d8cdf1..00000000000 --- a/apps/www/components/LaunchWeek/Ticket/ticket-form.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { useState, useRef } from 'react' -// import { scrollTo } from '@lib/smooth-scroll'; -import cn from 'classnames' -// import GithubIcon from '~/components/LaunchWeek/Ticket/icons/icon-github' -// import CheckIcon from '~/components/LaunchWeek/Ticket/icons/icon-check' -import { SITE_ORIGIN } from '~/lib/constants' -import useConfData from '~/components/LaunchWeek/Ticket//hooks/use-conf-data' -import LoadingDots from './loading-dots' -import formStyles from './form.module.css' -import ticketFormStyles from './ticket-form.module.css' - -type FormState = 'default' | 'loading' | 'error' -type TicketGenerationState = 'default' | 'loading' - -type Props = { - defaultUsername?: string - setTicketGenerationState: React.Dispatch> -} - -export default function Form({ defaultUsername = '', setTicketGenerationState }: Props) { - const [username] = useState(defaultUsername) - const [formState, setFormState] = useState('default') - const [errorMsg] = useState('') - const { supabase } = useConfData() - const formRef = useRef(null) - - return formState === 'error' ? ( -
        -
        -
        -
        {errorMsg}
        - -
        -
        -
        - ) : ( -
        { - e.preventDefault() - - if (formState !== 'default') { - setTicketGenerationState('default') - setFormState('default') - return - } - - setFormState('loading') - setTicketGenerationState('loading') - - await supabase.auth.signInWithOAuth({ - provider: 'github', - options: { redirectTo: `${SITE_ORIGIN}/launch-week/tickets` }, - }) - }} - className="flex flex-col items-center xl:block" - > -
        -
        - -
        - {!username &&

        Only public info will be used.

        } -
        -
        - ) -} diff --git a/apps/www/components/LaunchWeek/Ticket/ticket-image.tsx b/apps/www/components/LaunchWeek/Ticket/ticket-image.tsx index 43fe8a2f68f..b9ba7f91e87 100644 --- a/apps/www/components/LaunchWeek/Ticket/ticket-image.tsx +++ b/apps/www/components/LaunchWeek/Ticket/ticket-image.tsx @@ -1,6 +1,6 @@ import { useRouter } from 'next/router' import Head from 'next/head' -import TicketVisual from './ticket-visual' +import TicketVisual from './TicketVisual' import styles from './ticket-image.module.css' export default function TicketImage() { @@ -16,7 +16,7 @@ export default function TicketImage() { rel="stylesheet" /> - + /> */}
        ) diff --git a/apps/www/components/LaunchWeek/Ticket/ticket-info.tsx b/apps/www/components/LaunchWeek/Ticket/ticket-info-footer.tsx similarity index 80% rename from apps/www/components/LaunchWeek/Ticket/ticket-info.tsx rename to apps/www/components/LaunchWeek/Ticket/ticket-info-footer.tsx index 8437aa6eddd..7dd3b821630 100644 --- a/apps/www/components/LaunchWeek/Ticket/ticket-info.tsx +++ b/apps/www/components/LaunchWeek/Ticket/ticket-info-footer.tsx @@ -5,12 +5,12 @@ import { DATE, SITE_URL } from '~/lib/constants' const siteUrl = new URL(SITE_URL) const siteUrlForTicket = `${siteUrl.host}${siteUrl.pathname}`.replace(/\/$/, '') -export default function TicketInfo({ +export default function TicketInfoFooter({ logoTextSecondaryColor = 'var(--accents-5)', golden = false, }) { return ( -
        + ) diff --git a/apps/www/components/LaunchWeek/Ticket/ticket-info.module.css b/apps/www/components/LaunchWeek/Ticket/ticket-info.module.css index 9611ea8d805..ff1dc0539a2 100644 --- a/apps/www/components/LaunchWeek/Ticket/ticket-info.module.css +++ b/apps/www/components/LaunchWeek/Ticket/ticket-info.module.css @@ -1,8 +1,4 @@ .info { - display: flex; - gap: 0px; - margin-bottom: 64px; - padding: 32px; } @media (min-width: 768px) { diff --git a/apps/www/components/LaunchWeek/Ticket/ticket-mono-mobile.tsx b/apps/www/components/LaunchWeek/Ticket/ticket-mono-mobile.tsx index f55de57df16..2e7663bef28 100644 --- a/apps/www/components/LaunchWeek/Ticket/ticket-mono-mobile.tsx +++ b/apps/www/components/LaunchWeek/Ticket/ticket-mono-mobile.tsx @@ -12,7 +12,7 @@ export default function TicketMonoMobile({ golden = false }: { golden?: boolean diff --git a/apps/www/components/LaunchWeek/Ticket/ticket-mono.tsx b/apps/www/components/LaunchWeek/Ticket/ticket-mono.tsx index c4e5eb46b0f..e867cd8835d 100644 --- a/apps/www/components/LaunchWeek/Ticket/ticket-mono.tsx +++ b/apps/www/components/LaunchWeek/Ticket/ticket-mono.tsx @@ -6,10 +6,11 @@ export default function TicketMono({ golden = false }: { golden?: boolean }) { return ( - ) } diff --git a/apps/www/components/LaunchWeek/Ticket/ticket-number.tsx b/apps/www/components/LaunchWeek/Ticket/ticket-number.tsx deleted file mode 100644 index 473a8749b03..00000000000 --- a/apps/www/components/LaunchWeek/Ticket/ticket-number.tsx +++ /dev/null @@ -1,14 +0,0 @@ -type Props = { - number: number -} - -export default function TicketNumber({ number }: Props) { - const numDigits = `${number}`.length - const prefix = `00000000`.slice(numDigits) - return ( - <> - № {prefix} - {number} - - ) -} diff --git a/apps/www/components/LaunchWeek/Ticket/ticket-profile.module.css b/apps/www/components/LaunchWeek/Ticket/ticket-profile.module.css index b4e44f9f345..ac08447a22a 100644 --- a/apps/www/components/LaunchWeek/Ticket/ticket-profile.module.css +++ b/apps/www/components/LaunchWeek/Ticket/ticket-profile.module.css @@ -1,30 +1,14 @@ -.profile { - display: flex; - align-items: center; -} - .image { - width: 60px; - height: 60px; + width: 50px; + height: 50px; border-radius: 50%; border: 1px solid #999; } @media (min-width: 768px) { .image { - width: calc(80px * var(--size)); - height: calc(80px * var(--size)); - } -} - -.text { - flex: 1; - margin-left: 10px; -} - -@media (min-width: 768px) { - .text { - margin-left: calc(15px * var(--size)); + width: calc(100px * var(--size)); + height: calc(100px * var(--size)); } } @@ -34,7 +18,6 @@ line-height: 1.15; letter-spacing: -0.02em; margin: 0; - max-width: 300px; } .name-golden { @@ -102,12 +85,12 @@ background-position: -200% 0; } } - +/* .skeleton { display: flex; align-items: center; - border-radius: 5px; -} + border-radius: 25px; +} */ .skeleton.loaded { width: unset !important; @@ -152,16 +135,15 @@ .rounded, .rounded.wrapper::before { - width: 62px; - height: 62px; + width: calc(80px * var(--size)); + height: calc(80px * var(--size)); border-radius: 50% !important; } @media (min-width: 768px) { .rounded, .rounded.wrapper::before { - width: calc(82px * var(--size)); - height: calc(82px * var(--size)); - border-radius: 50% !important; + width: calc(110px * var(--size)); + height: calc(110px * var(--size)); } } diff --git a/apps/www/components/LaunchWeek/Ticket/ticket-profile.tsx b/apps/www/components/LaunchWeek/Ticket/ticket-profile.tsx deleted file mode 100644 index 6a920bde73d..00000000000 --- a/apps/www/components/LaunchWeek/Ticket/ticket-profile.tsx +++ /dev/null @@ -1,86 +0,0 @@ -// import GithubIcon from '~/components/LaunchWeek/Ticket/icons/icon-github' -import cn from 'classnames' -// import IconAvatar from '~/components/LaunchWeek/Ticket/icons/icon-avatar' -import styles from './ticket-profile.module.css' - -type TicketGenerationState = 'default' | 'loading' -type Props = { - name?: string - username?: string - size?: number - ticketGenerationState: TicketGenerationState - golden?: boolean -} - -export default function TicketProfile({ - name, - username, - size = 1, - ticketGenerationState, - golden = false, -}: Props) { - return ( -
        - - {username ? ( - {username} - ) : ( - - {/* */} - - )} - -
        -

        - - {name || username || 'Your Name'} - -

        -

        - - - {/* */} - {username ? `@${username}` : <>username} - - - Supabase disconnected badge - -

        -
        -
        - ) -} diff --git a/apps/www/components/LaunchWeek/Ticket/ticket-visual.module.css b/apps/www/components/LaunchWeek/Ticket/ticket-visual.module.css index 83e5f1d92ac..9056698878f 100644 --- a/apps/www/components/LaunchWeek/Ticket/ticket-visual.module.css +++ b/apps/www/components/LaunchWeek/Ticket/ticket-visual.module.css @@ -1,90 +1,62 @@ .visual { - position: relative; transform: translateZ(0); - display: flex; - flex-direction: column; - justify-content: space-between; - /* background-color: black; */ - border: 2px solid rgba(0, 0, 0, 0.1); + background-image: linear-gradient(154.77deg, #cb88ff 29.4%, #cb88ff 120.47%); + box-shadow: 0px 1px 27px #9e44ef10, 0 0 0 1px rgba(121, 89, 134, 0.237), + inset 0 0px 0 -2px rgba(0, 0, 0, 0.55); + /* border: 1px solid rgba(0, 0, 0, 0.05); */ border-radius: 16px; - background-color: rgba(0, 0, 0, 1); -webkit-backdrop-filter: blur(2px); backdrop-filter: blur(2px); - /* background: linear-gradient(#fff000, var(--gold-secondary)); */ - border: 1px solid #1c5f52; - animation: blank-svg-shadow 2s ease-in-out infinite alternate; + /* border: 1px solid #1c1c1c; */ + /* animation: 2s ease-in-out infinite alternate; */ - background-image: url('/images/launchweek/regular-ticket-background.png'); background-repeat: no-repeat; background-size: contain; } .visual--gold { - /* border: 10px solid; - border-image-slice: 1; - border-width: 2px; - border-radius: 16px; */ - background-color: linear-gradient(#fff000, #dea513); - background-color: #dea513; + background: radial-gradient(#fcdf9d, #dbb333); +} + +.visual--gold::before { + content: ' '; + position: absolute; + inset: 0; + filter: drop-shadow(0 0 6px #fff) drop-shadow(0 0 6px #d99f0c) drop-shadow(0 0 9px #dbb333); + background: radial-gradient(#fcdf9d, #dbb333); border: 1px solid rgba(255, 255, 0, 0.629); - animation: svg-shadow 2s ease-in-out infinite alternate; - background-image: url('/images/launchweek/gold-ticket-background.png'); - /* filter: drop-shadow(2.45223px 8.5828px 73.5669px #FFBD14); */ - /* border-image-source: linear-gradient(to left, var(--gold-secondary), var(--gold-accent)); */ + animation: opacity-pulse 2s ease-in-out infinite alternate; } -@media (max-width: 768px) { - .visual { - background-size: cover; - - background-image: url('/images/launchweek/2x-regular-ticket-background.jpg'); +@keyframes opacity-pulse { + from { + opacity: 0.5; } - .visual--gold { - background-image: url('/images/launchweek/2x-gold-ticket-background.jpg'); + to { + opacity: 1; } } -/* .svg-shadow { -} */ - @keyframes svg-shadow { from { filter: drop-shadow(0 0 2px #fff) drop-shadow(0 0 4px #d99f0c) drop-shadow(0 0 6px #d99f0c); } to { - filter: drop-shadow(0 0 3px #fff) drop-shadow(0 0 6px #d99f0c) drop-shadow(0 0 9px #d99f0c); + filter: drop-shadow(0 0 10px #fff) drop-shadow(0 0 6px #d99f0c) drop-shadow(0 0 9px #d99f0c); } } @keyframes blank-svg-shadow { from { - filter: drop-shadow(0 0 2px #1c5f52) drop-shadow(0 0 4px #1c5f52) drop-shadow(0 0 6px #1c5f52); + filter: drop-shadow(0 0 2px #bada55) drop-shadow(0 0 2px #bada55) drop-shadow(0 0 2px #bada55); } to { - filter: drop-shadow(0 0 3px #1c5f52) drop-shadow(0 0 6px #1c5f52) drop-shadow(0 0 9px #1c5f52); - } -} - -.horizontal-ticket { - display: none; -} - -.vertical-ticket { - display: flex; -} - -@media (min-width: 768px) { - .horizontal-ticket { - display: flex; - } - - .vertical-ticket { - display: none; + filter: drop-shadow(0 0 3px #bada55) drop-shadow(0 0 2px #bada55) drop-shadow(0 0 2px #bada55); } } @@ -94,22 +66,18 @@ top: 0; width: 100%; height: 100%; - /* padding: 46px 29px;/ */ } .logo img { - /* margin-top: 64px; */ margin-top: calc(64px * var(--size)); margin-left: calc(32px * var(--size)); margin-left: calc(32px * var(--size)); - /* width: 272px; */ width: calc(272px * var(--size)); } @media (min-width: 768px) { .logo img { margin-top: calc(32px * var(--size)); - /* width: 280px; */ width: calc(280px * var(--size)); margin-left: calc(160px * var(--size)); } @@ -140,48 +108,10 @@ display: flex; flex-direction: column; justify-content: flex-end; - /* padding: calc(46px * var(--size)) calc(29px * var(--size)); */ } @media (min-width: 768px) { .info { bottom: 0; - /* padding: 0 calc(160px * var(--size)) calc(44px * var(--size)) - calc(63px * var(--size)); */ - } -} - -.ticket-number-wrapper { - position: absolute; - bottom: 40px; - right: 0; -} - -@media (min-width: 768px) { - .ticket-number-wrapper { - bottom: 0; - } -} - -.ticket-number { - font-size: calc(40px * var(--size)); - line-height: 1; - width: calc(330px * var(--size)); - text-align: center; - background-clip: text; -} - -.ticket-number-golden { - color: var(--gold-primary); -} - -@media (min-width: 768px) { - .ticket-number { - transform: rotate(90deg) translateY(calc(100px * var(--size))); - transform-origin: bottom right; - } - - .ticket-number-golden { - color: var(--gold-accent); } } diff --git a/apps/www/components/LaunchWeek/Ticket/ticket-visual.tsx b/apps/www/components/LaunchWeek/Ticket/ticket-visual.tsx deleted file mode 100644 index 55278d18b5a..00000000000 --- a/apps/www/components/LaunchWeek/Ticket/ticket-visual.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import styles from './ticket-visual.module.css' -import TicketProfile from './ticket-profile' -import TicketNumber from './ticket-number' -import TicketMono from './ticket-mono' -import TicketInfo from './ticket-info' -import TicketMonoMobile from './ticket-mono-mobile' -import cn from 'classnames' -import { useRouter } from 'next/router' - -type TicketGenerationState = 'default' | 'loading' -type Props = { - size?: number - name?: string - ticketNumber?: number - username?: string - ticketGenerationState?: TicketGenerationState - golden?: boolean -} - -export default function TicketVisual({ - size = 1, - name, - username, - ticketNumber, - ticketGenerationState = 'default', - golden = false, -}: Props) { - // golden = true - - const router = useRouter() - const basePath = router.basePath - - return ( - <> -
        -
        - {/* {username ? : } */} - - -
        -
        - {/* {username ? ( - - ) : ( - - )} */} - -
        -
        - -
        -
        - -
        -
        - -
        - {ticketNumber && ( -
        -
        - -
        -
        - )} -
        - - ) -} diff --git a/apps/www/components/LaunchWeek/Ticket/ticket.module.css b/apps/www/components/LaunchWeek/Ticket/ticket.module.css index aebe6c9c713..aa126cb7070 100644 --- a/apps/www/components/LaunchWeek/Ticket/ticket.module.css +++ b/apps/www/components/LaunchWeek/Ticket/ticket.module.css @@ -1,3 +1,16 @@ +.ticket-hero { + background: #c58cd2; + background: linear-gradient( + 137.46deg, + rgba(255, 255, 255, 0.1) 6.19%, + rgba(255, 255, 255, 0.08) 103.29% + ); + z-index: 10; + box-shadow: 0 1px 1px rgba(131, 6, 184, 0.08), 0 2px 2px rgba(128, 6, 184, 0.08), + inset 0 0.5px 0px 0.5px rgba(255, 255, 255, 0.14), inset 0 -3px 0 -2px rgba(255, 255, 255, 0.08), + 0px 4px 20px rgba(118, 7, 170, 0.15); +} + .hero { line-height: 1.15; letter-spacing: -0.05em; @@ -9,10 +22,6 @@ @media (min-width: 768px) { .hero { line-height: 1; - /* font-weight: 800; */ - /* max-width: 800px; */ - /* margin-left: auto; - margin-right: auto; */ } } @@ -58,26 +67,6 @@ margin-bottom: auto; } -.ticket-visual { - position: relative; - border-radius: calc(16px * var(--size)); - /* overflow: hidden; */ - width: 330px; - margin: 0 auto; -} - -/* .ticket-visual:before, -.ticket-visual:after { */ -/* content: ''; - display: block; - width: 72px; - height: 58px; */ -/* background-color: #1C1C1C; */ -/* position: absolute; - z-index: 100; - border-radius: 50%; */ -/* } */ - .ticket-visual:before { left: 50%; top: -7.5%; @@ -91,10 +80,6 @@ } @media (min-width: 768px) { - .ticket-visual { - width: 650px; - } - .ticket-visual:before { top: 50%; left: -7%; @@ -147,6 +132,16 @@ } .ticket-visual-wrapper { - border-radius: calc(16px * var(--size)); width: 100%; } + +.gold-text { + color: #f4cf72; + text-shadow: 0 0 1px rgba(0, 0, 0, 0.5), 0 0 4px rgba(0, 0, 0, 0.1); +} + +@media (min-width: 768px) { + .gold-text { + color: #e4b641; + } +} diff --git a/apps/www/components/LaunchWeek/Ticket/ticket.tsx b/apps/www/components/LaunchWeek/Ticket/ticket.tsx deleted file mode 100644 index 4cc479bd591..00000000000 --- a/apps/www/components/LaunchWeek/Ticket/ticket.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import cn from 'classnames' -import Tilt from 'vanilla-tilt' -import { useRef, useEffect, useState } from 'react' -// import { TicketGenerationState } from '@lib/constants' -// import {isMobileOrTablet} from '~/lib/helpers' -// import { scrollTo } from '@lib/smooth-scroll' -import styles from './ticket.module.css' -import styleUtils from './utils.module.css' -import TicketForm from './ticket-form' -import TicketVisual from './ticket-visual' -import TicketActions from './ticket-actions' -import TicketCopy from './ticket-copy' -import { DATE, SITE_NAME } from '~/lib/constants' -import Form from './form' -import { UserData } from './hooks/use-conf-data' - -type TicketGenerationState = 'default' | 'loading' -type Props = { - username: UserData['username'] - ticketNumber: UserData['ticketNumber'] - name: UserData['name'] - golden: UserData['golden'] - sharePage?: boolean -} - -export default function Ticket({ username, name, ticketNumber, sharePage, golden }: Props) { - const ticketRef = useRef(null) - const [ticketGenerationState, setTicketGenerationState] = - useState('default') - const divRef = useRef(null) - - useEffect(() => { - if (ticketRef.current && !window.matchMedia('(pointer: coarse)').matches) { - Tilt.init(ticketRef.current, { - glare: true, - max: 5, - 'max-glare': 0.16, - 'full-page-listening': true, - }) - } - }, [ticketRef]) - - // useEffect(() => { - // if (!sharePage && divRef && divRef.current && isMobileOrTablet()) { - // scrollTo(divRef.current, -30) - // } - // }, [divRef, sharePage]) - // golden = true - - return ( -
        -
        -
        -

        - {sharePage ? ( - name ? ( -

        {name}'s ticket

        - ) : ( -

        {SITE_NAME}

        - ) - ) : golden ? ( -

        Congratulations!

        - ) : ( -

        You have a ticket!

        - )} -

        -

        - {sharePage ? ( -

        - Join {name ?? 'us'} on {DATE}. -

        - ) : golden ? ( - <> - You got a Golden ticket. This means you’re in, and you also won a Supabase goodie - bag. Tweet it to redeem your swag pack! - - ) : username ? ( - <>Here is your unique ticket image for bragging on socials! - ) : ( - <> - This means you're in. Generate a unique ticket image with your GitHub profile, cause - a few of the lucky attendees will get a limited edition Supabase goodie bag. Make - sure you don't skip your chance. - - )} -

        -
        -
        - {!sharePage ? ( - <> - - - ) : ( -
        - )} -
        -
        -
        -
        - -
        - {!sharePage && ( - <> - {username ? ( -
        -
        - -
        -
        - -
        -
        - ) : ( -
        - )} - - )} -
        -
        - ) -} diff --git a/apps/www/components/LaunchWeek/lw7-bg-graphic.module.css b/apps/www/components/LaunchWeek/lw7-bg-graphic.module.css new file mode 100644 index 00000000000..04776532987 --- /dev/null +++ b/apps/www/components/LaunchWeek/lw7-bg-graphic.module.css @@ -0,0 +1,5 @@ +.gradient-overlay::before { + background: linear-gradient(0deg, #1c1c1c 0%, #1c1c1c20 60%, transparent 100%), + linear-gradient(0deg, #1c1c1c 0%, #1c1c1c80 20%, #1c1c1c60 40%, #1c1c1c05 75%, transparent 100%), + linear-gradient(0deg, #1c1c1c 0%, #1c1c1c80 15%, #1c1c1c50 40%, #1c1c1c05 65%, transparent 100%); +} diff --git a/apps/www/components/LaunchWeek/lw7_days.json b/apps/www/components/LaunchWeek/lw7_days.json new file mode 100644 index 00000000000..e961beefa62 --- /dev/null +++ b/apps/www/components/LaunchWeek/lw7_days.json @@ -0,0 +1,114 @@ +[ + { + "title": "Docs Updated", + "shipped": true, + "date": "12 Dec 2022", + "description": "Supabase Docs", + "d": 1, + "dd": "Monday", + "youtube_id": "OpPOaJI_Z28", + "blogpost": "https://supabase.com/blog/new-supabase-docs-built-with-nextjs", + "docs": "https://supabase.com/docs", + "steps": [ + { + "title": "Supabase Docs", + "blog": "/blog/new-supabase-docs-built-with-nextjs", + "docs": "/docs", + "isNew": true, + "description": "" + } + ] + }, + { + "title": "Image Processing: Resize V2 + CDN", + "shipped": true, + "date": "13 Dec 2022", + "description": "Image Transformations", + "d": 2, + "dd": "Tuesday", + "youtube_id": "iqZlPtl_b-I", + "blogpost": "https://supabase.com/blog", + "docs": "https://supabase.com/docs", + "steps": [ + { + "title": "Image Transformations", + "blog": "/blog/storage-image-resizing-smart-cdn", + "docs": "/docs/guides/storage/image-transformations", + "isNew": true, + "description": "" + }, + { + "title": "Smart CDN", + "description": "Faster asset delivery, now even faster.", + "blog": "/blog/storage-image-resizing-smart-cdn", + "docs": "/docs/guides/storage/cdn", + "isNew": true + } + ] + }, + { + "title": "Multi-factor Authentication", + "shipped": true, + "date": "14 Dec 2022", + "description": "Multi-factor Authentication", + "d": 3, + "dd": "Wednesday", + "youtube_id": "CGZr5tybW18", + "blogpost": "https://supabase.com/blog", + "docs": "https://supabase.com/docs", + "steps": [ + { + "title": "Multi-factor Authentication", + "blog": "/blog/mfa-auth-via-rls", + "docs": "/docs/guides/auth/auth-mfa", + "isNew": false, + "description": "" + } + ] + }, + { + "title": "Supabase Wrappers - FDW Framework", + "shipped": true, + "date": "15 Dec 2022", + "description": "Supabase Wrappers", + "d": 4, + "dd": "Thursday", + "youtube_id": "QA2qC5F-4OU", + "blogpost": "/blog/postgres-foreign-data-wrappers-rust", + "docs": "https://supabase.com/docs", + "steps": [ + { + "title": "Supabase Wrappers", + "blog": "/blog/postgres-foreign-data-wrappers-rust", + "docs": "https://supabase.github.io/wrappers/", + "isNew": true, + "description": "Connect Postgres to external systems with Postgres Foreign Data Wrappers" + } + ] + }, + { + "title": "Supabase Vault Release", + "shipped": true, + "date": "16 Dec 2022", + "description": "Vault Release", + "d": 5, + "dd": "Friday", + "youtube_id": "6bGQotxisoY", + "blogpost": "https://supabase.com/blog", + "docs": "https://supabase.com/docs", + "steps": [ + { + "title": "Vault Release", + "blog": "/blog/vault-now-in-beta", + "isNew": true, + "description": "" + }, + { + "title": "Transparent Column Encryption", + "description": "Faster asset delivery, now even faster.", + "blog": "/blog/transparent-column-encryption-with-postgres", + "isNew": true + } + ] + } +] diff --git a/apps/www/components/Nav/Developers.tsx b/apps/www/components/Nav/Developers.tsx index 9840c0a060d..94edb6956dc 100644 --- a/apps/www/components/Nav/Developers.tsx +++ b/apps/www/components/Nav/Developers.tsx @@ -68,7 +68,7 @@ const Developers = () => {
        caseStudyThumb { const { isDarkMode } = useTheme() + const { pathname } = useRouter() const [open, setOpen] = useState(false) - const [openProduct, setOpenProduct] = useState(false) const [openDevelopers, setOpenDevelopers] = useState(false) + const isLoggedIn = useIsLoggedIn() + + const isLaunchWeekPage = pathname.includes('launch-week') + const showLaunchWeekNavMode = isLaunchWeekPage && !open && !openProduct && !openDevelopers React.useEffect(() => { if (open) { @@ -47,12 +52,12 @@ const Nav = () => { const { name, description, icon, label, url } = solution const content = ( -
        +
        -
        +
        {/* */} { )}

        -

        {description}

        +

        {description}

        {url && ( -

        +

        )} @@ -85,14 +90,14 @@ const Nav = () => { ) return url ? ( -
        + {content} ) : (
        {content}
        @@ -101,6 +106,7 @@ const Nav = () => { type HamburgerButtonProps = { toggleFlyOut: Function + showLaunchWeekNavMode?: boolean } const HamburgerButton = (props: HamburgerButtonProps) => ( @@ -109,17 +115,20 @@ const Nav = () => { onClick={() => props.toggleFlyOut()} >
        @@ -181,19 +195,39 @@ const Nav = () => { return ( <> {/* */} -
        -
        -
      • @@ -103,9 +103,7 @@ export function Group({ children, icon, title }: GroupProps) { const __styles = styleHandler('menu') const { type } = useMenuContext() return ( -
        +
        {icon && {icon}} {title} {children} diff --git a/packages/ui/src/components/Modal/Modal.tsx b/packages/ui/src/components/Modal/Modal.tsx index c475ab11ff1..0b4cbd29be9 100644 --- a/packages/ui/src/components/Modal/Modal.tsx +++ b/packages/ui/src/components/Modal/Modal.tsx @@ -159,8 +159,9 @@ const Modal = ({ {header &&
        {header}
        } {/*
        ( side="bottom" align="end" size="content" - portalled showClose header={
        diff --git a/packages/ui/src/components/Popover/Popover.tsx b/packages/ui/src/components/Popover/Popover.tsx index 010e5f591ce..5c47d177927 100644 --- a/packages/ui/src/components/Popover/Popover.tsx +++ b/packages/ui/src/components/Popover/Popover.tsx @@ -16,7 +16,6 @@ interface RootProps { onOpenChange?: RadixPopoverTypes.PopoverProps['onOpenChange'] open?: boolean overlay?: React.ReactNode - portalled?: boolean showClose?: boolean side?: RadixPopoverTypes.PopoverContentProps['side'] sideOffset?: RadixPopoverTypes.PopoverContentProps['sideOffset'] @@ -37,7 +36,6 @@ function Popover({ onOpenChange, open, overlay, - portalled, side = 'bottom', sideOffset = 6, style, @@ -67,24 +65,25 @@ function Popover({ {children} - - {arrow && ( - - )} - {header &&
        {header}
        } - {overlay} - {footer &&
        {footer}
        } -
        + + + {arrow && ( + + )} + {header &&
        {header}
        } + {overlay} + {footer &&
        {footer}
        } +
        +
        ) } diff --git a/packages/ui/src/components/Radio/Radio.tsx b/packages/ui/src/components/Radio/Radio.tsx index 86265df382f..72ad375ec43 100644 --- a/packages/ui/src/components/Radio/Radio.tsx +++ b/packages/ui/src/components/Radio/Radio.tsx @@ -137,7 +137,7 @@ function RadioGroup({ interface InputProps extends Omit, 'size'> { label: string afterLabel?: string - beforeLabel?: string + beforeLabel?: string | React.ReactNode description?: string size?: 'tiny' | 'small' | 'medium' | 'large' | 'xlarge' hidden?: boolean diff --git a/packages/ui/src/components/SidePanel/SidePanel.tsx b/packages/ui/src/components/SidePanel/SidePanel.tsx index b9595e7fec3..6dc69c307e9 100644 --- a/packages/ui/src/components/SidePanel/SidePanel.tsx +++ b/packages/ui/src/components/SidePanel/SidePanel.tsx @@ -126,10 +126,16 @@ export function Separator() { return
        } -export function Content({ children }: { children: React.ReactNode }) { +export function Content({ + children, + className, +}: { + children: React.ReactNode + className?: string +}) { let __styles = styleHandler('sidepanel') - return
        {children}
        + return
        {children}
        } SidePanel.Content = Content diff --git a/packages/ui/src/components/Toggle/Toggle.tsx b/packages/ui/src/components/Toggle/Toggle.tsx index 7339c862879..aa2b62c30f1 100644 --- a/packages/ui/src/components/Toggle/Toggle.tsx +++ b/packages/ui/src/components/Toggle/Toggle.tsx @@ -9,7 +9,7 @@ interface Props extends Omit, 'size'> { disabled?: boolean layout?: 'horizontal' | 'vertical' | 'flex' error?: string - descriptionText?: string + descriptionText?: string | React.ReactNode label?: string | React.ReactNode afterLabel?: string beforeLabel?: string diff --git a/packages/ui/src/lib/Markdown/index.tsx b/packages/ui/src/lib/Markdown/index.tsx new file mode 100644 index 00000000000..09274ae7ac9 --- /dev/null +++ b/packages/ui/src/lib/Markdown/index.tsx @@ -0,0 +1,15 @@ +import * as React from 'react' +import { CodeBlock } from '../../components/CodeBlock/CodeBlock' +import Image from 'next/image' + +export const markdownComponents = { + mono: (props: any) => {props.children}, + code: (props: any) => , + img: (props: any) => { + return ( + + + + ) + }, +} diff --git a/packages/ui/src/lib/theme/defaultTheme.ts b/packages/ui/src/lib/theme/defaultTheme.ts index d5bffd27180..b9769a03a46 100644 --- a/packages/ui/src/lib/theme/defaultTheme.ts +++ b/packages/ui/src/lib/theme/defaultTheme.ts @@ -123,7 +123,7 @@ export default { outline-none focus-visible:ring-1 - focus-visible:z-50 + focus-visible:z-10 ring-scale-1100 `, content: ` @@ -161,7 +161,7 @@ export default { outline-none focus-visible:ring-1 - focus-visible:z-50 + focus-visible:z-10 ring-scale-1100 transition-colors @@ -972,6 +972,7 @@ export default { sidepanel: { base: ` + z-40 bg-scale-100 dark:bg-scale-300 flex flex-col fixed @@ -1023,6 +1024,7 @@ export default { bg-scale-300 dark:bg-scale-500 `, overlay: ` + z-40 fixed bg-scale-300 dark:bg-scale-100 @@ -1210,12 +1212,12 @@ export default { data-open:text-scale-1200 `, content: ` + z-40 bg-scale-100 dark:bg-scale-300 border border-scale-300 dark:border-scale-500 rounded shadow-lg py-1.5 - origin-dropdown data-open:animate-dropdown-content-show data-closed:animate-dropdown-content-hide @@ -1297,6 +1299,7 @@ export default { `, content: ` + z-40 bg-scale-100 dark:bg-scale-300 border border-scale-300 dark:border-scale-500 rounded @@ -1469,6 +1472,7 @@ export default { xxxlarge: `sm:align-middle sm:w-full max-w-7xl`, }, overlay: ` + z-40 fixed bg-scale-300 dark:bg-scale-100 @@ -1480,13 +1484,13 @@ export default { data-open:animate-fade-in-overlay-bg `, scroll_overlay: ` + z-40 fixed inset-0 grid place-items-center overflow-y-auto data-open:animate-overlay-show data-closed:animate-overlay-hide - z-50 `, separator: ` w-full diff --git a/packages/ui/src/types/sse.d.ts b/packages/ui/src/types/sse.d.ts new file mode 100644 index 00000000000..dbf97ac0836 --- /dev/null +++ b/packages/ui/src/types/sse.d.ts @@ -0,0 +1,12 @@ +declare module 'sse.js' { + export type SSEOptions = EventSourceInit & { + headers?: Record + payload?: string + method?: string + } + + export class SSE extends EventSource { + constructor(url: string | URL, sseOptions?: SSEOptions) + stream(): void + } +} diff --git a/packages/ui/src/utils/cn.ts b/packages/ui/src/utils/cn.ts new file mode 100644 index 00000000000..5589c557500 --- /dev/null +++ b/packages/ui/src/utils/cn.ts @@ -0,0 +1,6 @@ +import { ClassValue, clsx } from 'clsx' +import { twMerge } from 'tailwind-merge' + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json new file mode 100644 index 00000000000..8637a0ecaf7 --- /dev/null +++ b/packages/ui/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "tsconfig/react-library.json", + "compilerOptions": { + "resolveJsonModule": true + }, + "include": [ + "." + ], + "exclude": [ + "dist", + "build", + "node_modules" + ] +} \ No newline at end of file diff --git a/spec/supabase_csharp_v0.yml b/spec/supabase_csharp_v0.yml index a83d003bd2a..76ce32493e6 100644 --- a/spec/supabase_csharp_v0.yml +++ b/spec/supabase_csharp_v0.yml @@ -2,7 +2,7 @@ openref: 0.1 info: id: reference/csharp - title: Getting started + title: Supabase C# Client description: | Supabase C#. @@ -293,9 +293,9 @@ functions: isSpotlight: true code: | ```c# - var attrs = new UserAttributes - { - Data = new Dictionary { {"example", "data" } } + var attrs = new UserAttributes + { + Data = new Dictionary { {"example", "data" } } }; var response = await supabase.Auth.Update(attrs); ``` @@ -516,10 +516,10 @@ functions: public int CountryId { get; set; } } - var model = new City - { - Name = "The Shire", - CountryId = 554 + var model = new City + { + Name = "The Shire", + CountryId = 554 }; await supabase.From().Insert(model); @@ -603,10 +603,10 @@ functions: isSpotlight: true code: | ```c# - var model = new City - { - Id = 554, - Name = "Middle Earth" + var model = new City + { + Id = 554, + Name = "Middle Earth" }; await supabase.From().Upsert(model); @@ -620,10 +620,10 @@ functions: isSpotlight: true code: | ```c# - var model = new City - { - Id = 554, - Name = "Middle Earth" + var model = new City + { + Id = 554, + Name = "Middle Earth" }; await supabase @@ -637,10 +637,10 @@ functions: Allowed values for count option are `exact`, `planned` and `estimated`. code: | ```c# - var model = new City - { - Id = 554, - Name = "Middle Earth" + var model = new City + { + Id = 554, + Name = "Middle Earth" }; await supabase @@ -702,7 +702,7 @@ functions: isSpotlight: true code: | ```c# - class CursorBroadcast : BaseBroadcast + class CursorBroadcast : BaseBroadcast { [JsonProperty("cursorX")] public int CursorX {get; set;} @@ -714,7 +714,7 @@ functions: var channel = supabase.Realtime.Channel("any"); var broadcast = channel.Register(); - broadcast.OnBroadcast += (sender, _) => + broadcast.OnBroadcast += (sender, _) => { var data = broadcast.Current(); }; @@ -729,7 +729,7 @@ functions: isSpotlight: true code: | ```c# - class UserPresence : BasePresence + class UserPresence : BasePresence { [JsonProperty("cursorX")] public bool IsTyping {get; set;} @@ -742,7 +742,7 @@ functions: var presenceKey = Guid.NewGuid().ToString(); var presence = channel.Register(presenceKey); - presence.OnSync += (sender, _) => + presence.OnSync += (sender, _) => { var state = presence.CurrentState; }; diff --git a/spec/supabase_dart_v0.yml b/spec/supabase_dart_v0.yml index 748edf4edb6..9248299d3ce 100644 --- a/spec/supabase_dart_v0.yml +++ b/spec/supabase_dart_v0.yml @@ -2,7 +2,7 @@ openref: 0.1 info: id: reference/dart - title: Getting started + title: Supabase Dart Client description: | Supabase Dart. @@ -539,15 +539,16 @@ functions: ``` - id: update - title: 'Modify data: update()' description: | Performs an UPDATE on the table. + title: 'Modify data: update()' notes: | - `update()` should always be combined with [Filters](/docs/reference/dart/using-filters) to target the item(s) you wish to update. examples: - - name: Updating your data + - id: updating-your-data + name: Updating your data isSpotlight: true - dart: | + code: | ```dart final res = await supabase .from('cities') @@ -555,13 +556,14 @@ functions: .match({ 'name': 'Auckland' }) .execute(); ``` - - name: Updating JSON data + - id: updating-json-data + name: Updating JSON data description: | Postgres offers a [number of operators](https://www.postgresql.org/docs/current/functions-json.html) for working with JSON data. Right now it is only possible to update an entire JSON document, but we are [working on ideas](https://github.com/PostgREST/postgrest/issues/465) for updating individual keys. - dart: | + code: | ```dart final res = await supabase .from('users') diff --git a/spec/supabase_dart_v1.yml b/spec/supabase_dart_v1.yml index a4767bf34a1..1918a153718 100644 --- a/spec/supabase_dart_v1.yml +++ b/spec/supabase_dart_v1.yml @@ -2,7 +2,7 @@ openref: 0.1 info: id: reference/dart - title: Getting started + title: Supabase Dart Client description: | Supabase Dart. @@ -39,7 +39,7 @@ functions: runApp(MyApp()); } - // Get a reference your Supabase client + // Get a reference your Supabase client final supabase = Supabase.instance.client; ``` - id: for-other-dart-projects diff --git a/spec/supabase_js_v1.yml b/spec/supabase_js_v1.yml index fa03852708b..c1e212226b9 100644 --- a/spec/supabase_js_v1.yml +++ b/spec/supabase_js_v1.yml @@ -2,7 +2,7 @@ openref: 0.1 info: id: reference/supabase-js - title: Supabase Client + title: Supabase Javascript Client description: | Supabase JavaScript. @@ -61,7 +61,7 @@ functions: ``` description: | By default the API server points to the `public` schema. You can enable other database schemas within the Dashboard. - Go to `Settings > API > Schema` and add the schema which you want to expose to the API. You also need to grant `USAGE` on your new schema with the grants you desire, such as `SELECT, INSERT, UPDATE, DELETE`. + Go to `Settings > API > Schema` and add the schema which you want to expose to the API. You also need to grant `USAGE` on your new schema with the grants you desire, such as `SELECT, INSERT, UPDATE, DELETE`. Note: each client connection can only access a single schema, so the code above can access the `other_schema` schema but cannot access the `public` schema. - id: custom-fetch-implementation @@ -567,7 +567,7 @@ functions: notes: | Sends a password reset request to an email address. - When the user clicks the reset link in the email they are redirected back to your application. - You can configure the URL that the user is redirected to via the `redirectTo` param. + You can configure the URL that the user is redirected to via the `redirectTo` param. See [redirect URLs and wildcards](/docs/guides/auth/overview#redirect-urls-and-wildcards) to add additional redirect URLs to your project. - After the user has been redirected successfully, prompt them for a new password and call `updateUser()`: ```js diff --git a/spec/supabase_js_v2.yml b/spec/supabase_js_v2.yml index e59459ce89a..09cbcec793e 100644 --- a/spec/supabase_js_v2.yml +++ b/spec/supabase_js_v2.yml @@ -2,7 +2,7 @@ openref: 0.1 info: id: reference/supabase-js - title: Supabase Client + title: Supabase Javascript Client description: | Supabase JavaScript. @@ -812,9 +812,7 @@ functions: const { data, error } = await supabase.auth.admin.generateLink({ type: 'signup', email: 'email@example.com', - options: { - 'password': 'secret' - } + password: 'secret' }) ``` - id: generate-an-invite-link @@ -1320,21 +1318,21 @@ functions: sql: | ```sql create table countries ( - "id" "uuid" primary key default "extensions"."uuid_generate_v4"() not null, + "id" "uuid" primary key default "extensions"."uuid_generate_v4"() not null, "name" text ); create table cities ( - "id" "uuid" primary key default "extensions"."uuid_generate_v4"() not null, - "name" text, + "id" "uuid" primary key default "extensions"."uuid_generate_v4"() not null, + "name" text, "country_id" "uuid" references public.countries on delete cascade ); - + with country as ( - insert into countries (name) + insert into countries (name) values ('united kingdom') returning id ) - insert into cities (name, country_id) values + insert into cities (name, country_id) values ('London', (select id from country)), ('Manchester', (select id from country)), ('Liverpool', (select id from country)), @@ -3889,7 +3887,7 @@ functions: } ``` description: | - You can use an [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) to abort requests. + You can use an [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) to abort requests. Note that `status` and `statusText` don't mean anything for aborted requests as the request wasn't fulfilled. hideCodeBlock: true isSpotlight: true diff --git a/spec/supabase_py_v2.yml b/spec/supabase_py_v2.yml index 486e429f3e1..997dd443a53 100644 --- a/spec/supabase_py_v2.yml +++ b/spec/supabase_py_v2.yml @@ -2,7 +2,7 @@ openref: 0.1 info: id: reference/supabase-py - title: Supabase Client + title: Supabase Python Client description: | Supabase Python(Community). @@ -519,21 +519,21 @@ functions: sql: | ```sql create table countries ( - "id" "uuid" primary key default "extensions"."uuid_generate_v4"() not null, + "id" "uuid" primary key default "extensions"."uuid_generate_v4"() not null, "name" text ); create table cities ( - "id" "uuid" primary key default "extensions"."uuid_generate_v4"() not null, - "name" text, + "id" "uuid" primary key default "extensions"."uuid_generate_v4"() not null, + "name" text, "country_id" "uuid" references public.countries on delete cascade ); with country as ( - insert into countries (name) + insert into countries (name) values ('united kingdom') returning id ) - insert into cities (name, country_id) values + insert into cities (name, country_id) values ('London', (select id from country)), ('Manchester', (select id from country)), ('Liverpool', (select id from country)), @@ -543,7 +543,7 @@ functions: ``` APIResponse(data=[{'id': 1, 'name': 'Germany', 'cities': [{'count': 1}]}, {'id': 2, 'name': 'Indonesia', 'cities': [{'count': 1}]}], - count=None) + count=None) ``` description: | You can get the number of rows in a related table by using the diff --git a/studio/.env b/studio/.env index e66cc5523ed..66bb50c8cbe 100644 --- a/studio/.env +++ b/studio/.env @@ -8,9 +8,16 @@ SUPABASE_URL=http://localhost:8000 SUPABASE_PUBLIC_URL=http://localhost:8000 SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE SUPABASE_SERVICE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q - +SENTRY_IGNORE_API_RESOLUTION_ERROR=1 +LOGFLARE_URL=http://localhost:54329 +LOGFLARE_API_KEY=api-key # TODO: evaluate these variables at runtime instead # https://nextjs.org/docs/basic-features/environment-variables#loading-environment-variables NEXT_PUBLIC_SITE_URL=http://localhost:3000 NEXT_PUBLIC_GOTRUE_URL=$SUPABASE_PUBLIC_URL/auth/v1 NEXT_PUBLIC_HCAPTCHA_SITE_KEY=10000000-ffff-ffff-ffff-000000000001 +NEXT_PUBLIC_ENABLE_LOGS=true + +# CmdK / AI +NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InhndWloeHV6cWlid3hqbmlteGV2Iiwicm9sZSI6ImFub24iLCJpYXQiOjE2NzUwOTQ4MzUsImV4cCI6MTk5MDY3MDgzNX0.0PMlOxtKL4O9GGZuAP_Xl4f-Tut1qOnW4bNEmAtoB8w +NEXT_PUBLIC_SUPABASE_URL=https://xguihxuzqibwxjnimxev.supabase.co diff --git a/studio/components/grid/SupabaseGrid.tsx b/studio/components/grid/SupabaseGrid.tsx index 60d8da7c426..5318d088f19 100644 --- a/studio/components/grid/SupabaseGrid.tsx +++ b/studio/components/grid/SupabaseGrid.tsx @@ -58,7 +58,15 @@ export const SupabaseGrid = forwardRef((prop }) const SupabaseGridLayout = forwardRef((props, ref) => { - const { editable, storageRef, gridProps, headerActions } = props + const { + editable, + storageRef, + gridProps, + headerActions, + showCustomChildren, + customHeader, + children, + } = props const dispatch = useDispatch() const state = useTrackedState() @@ -194,13 +202,28 @@ const SupabaseGridLayout = forwardRef((props table={table} sorts={sorts} filters={filters} + isRefetching={isRefetching} onAddRow={editable ? props.onAddRow : undefined} onAddColumn={editable ? props.onAddColumn : undefined} headerActions={headerActions} + customHeader={customHeader} /> - -
        - + {showCustomChildren && children !== undefined ? ( + <>{children} + ) : ( + <> + +
        + + + )} + {mounted && createPortal(, document.body)}
        diff --git a/studio/components/grid/SupabaseGrid.utils.ts b/studio/components/grid/SupabaseGrid.utils.ts index 7f13c277e8a..2a0a4f29cb2 100644 --- a/studio/components/grid/SupabaseGrid.utils.ts +++ b/studio/components/grid/SupabaseGrid.utils.ts @@ -215,9 +215,12 @@ export function parseSupaTable( isEncrypted: encryptedColumns.includes(column.name), enum: column.enums, comment: column.comment, - targetTableSchema: null, - targetTableName: null, - targetColumnName: null, + foreignKey: { + targetTableSchema: null, + targetTableName: null, + targetColumnName: null, + deletionAction: undefined, + }, } const primaryKey = primaryKeys.find((pk) => pk.name == column.name) temp.isPrimaryKey = !!primaryKey @@ -230,9 +233,10 @@ export function parseSupaTable( ) }) if (relationship) { - temp.targetTableSchema = relationship.target_table_schema - temp.targetTableName = relationship.target_table_name - temp.targetColumnName = relationship.target_column_name + temp.foreignKey.targetTableSchema = relationship.target_table_schema + temp.foreignKey.targetTableName = relationship.target_table_name + temp.foreignKey.targetColumnName = relationship.target_column_name + temp.foreignKey.deletionAction = relationship.deletion_action } return temp }) diff --git a/studio/components/grid/components/common/ForeignTableModal/FilterHeader.tsx b/studio/components/grid/components/common/ForeignTableModal/FilterHeader.tsx deleted file mode 100644 index 827848222bb..00000000000 --- a/studio/components/grid/components/common/ForeignTableModal/FilterHeader.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import React, { FC, useState } from 'react' -import AwesomeDebouncePromise from 'awesome-debounce-promise' -import { Button, Input, IconChevronDown, IconSearch } from 'ui' -import { DropdownControl } from '../../common' -import { FilterOperatorOptions } from '../../header/filter' -import { Filter, FilterOperator } from '../../../types' - -interface FilterProps { - defaultColumnName?: string - defaultValue?: string - foreignColumnNames: string[] - onChange: (value: Filter) => void -} - -export const FilterHeader: FC = ({ - defaultColumnName, - defaultValue, - foreignColumnNames, - onChange, -}) => { - const [columnName, setColumnName] = useState( - defaultColumnName - ? defaultColumnName - : foreignColumnNames.length > 0 - ? foreignColumnNames[0] - : '' - ) - const [condition, setCondition] = useState(FilterOperatorOptions[0].value) - const [filterText, setFilterText] = useState(defaultValue ?? '') - - const columnOptions = - foreignColumnNames.map((x) => { - return { value: x, label: x } - }) || [] - - function triggerOnChange(columnName: string, condition: string, filterText: string) { - onFilterChangeDebounced( - { - column: columnName, - operator: condition as FilterOperator, - value: filterText, - }, - onChange - ) - } - - function onColumnChange(value: string | number) { - const str = value + '' - setColumnName(str) - triggerOnChange(str, condition, filterText) - } - - function onConditionChange(value: string | number) { - const str = value + '' - setCondition(str) - triggerOnChange(columnName, str, filterText) - } - - function onFilterChange(event: React.ChangeEvent) { - const value = event.target.value - setFilterText(value) - triggerOnChange(columnName, condition, value) - } - - return ( -
        - - - - - - - } - /> -
        - ) -} - -const onFilterChangeDebounced = AwesomeDebouncePromise( - (filter: Filter, onChange: (value: Filter) => void) => { - onChange(filter) - }, - 500 -) diff --git a/studio/components/grid/components/common/ForeignTableModal/RowItem.tsx b/studio/components/grid/components/common/ForeignTableModal/RowItem.tsx deleted file mode 100644 index d27314724bb..00000000000 --- a/studio/components/grid/components/common/ForeignTableModal/RowItem.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import * as React from 'react' -import { Menu } from 'ui' -import { Dictionary } from '../../../types' - -interface RowItemProps { - item: Dictionary - onSelect: (item: Dictionary) => void - columnNames: string[] -} - -export const RowItem: React.FC = ({ item, onSelect, columnNames }) => { - return ( -
        - onSelect(item)} style={{ minWidth: 'min-content' }}> -
        - {columnNames.map((key, j) => { - // - // limit to 5 attributes - // - // this could be improved so the user could pick which attributes to display - // @mildtomato - if (j > 5) return null - - return ( -
        -

        - {key} -

        -

        - {item[key] - ? typeof item[key] === 'object' - ? JSON.stringify(item[key]) - : item[key] - : '[null]'} -

        -
        - ) - })} -
        -
        -
        - ) -} diff --git a/studio/components/grid/components/common/ForeignTableModal/index.tsx b/studio/components/grid/components/common/ForeignTableModal/index.tsx deleted file mode 100644 index 78e1bdb56f0..00000000000 --- a/studio/components/grid/components/common/ForeignTableModal/index.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import * as React from 'react' -import { Button, Modal, Menu, IconExternalLink, IconTrash } from 'ui' - -import { RowItem } from './RowItem' -import { FilterHeader } from './FilterHeader' -import Query from 'components/grid/query' -import { useTrackedState } from 'components/grid/store' -import { Dictionary, Filter } from 'components/grid/types' - -type ForeignTableModalProps = { - columnName?: string - defaultValue?: string - onChange: (value: any | null) => void -} - -export const ForeignTableModal: React.FC = ({ - columnName, - defaultValue, - onChange, -}) => { - const [visible, setVisible] = React.useState(false) - const [foreignColumnNames, setForeignColumnNames] = React.useState([]) - const [rows, setRows] = React.useState[] | null>(null) - const state = useTrackedState() - const columnDefinition = state.table?.columns.find((x) => x.name == columnName) - - React.useEffect(() => { - if (!visible) return - - fetchColumns() - - if (defaultValue && columnDefinition) { - fetchData({ - column: columnDefinition.targetColumnName!, - operator: '=', - value: defaultValue, - }) - } else { - fetchData() - } - }, [visible]) - - async function fetchColumns() { - if ( - !state.metaService || - !columnDefinition?.targetTableName || - !columnDefinition?.targetTableSchema - ) - return - - const { data } = await state.metaService.fetchColumns( - columnDefinition?.targetTableName, - columnDefinition?.targetTableSchema - ) - if (data && data.length > 0) { - const columnNames = data.map((x) => x.name) - setForeignColumnNames(columnNames) - } - } - - async function fetchData(filter?: Filter) { - if (!state.onSqlQuery || !columnDefinition?.targetTableName) return - - const query = new Query() - let queryChains = query - .from(columnDefinition?.targetTableName, columnDefinition?.targetTableSchema ?? undefined) - .select() - - if (filter && filter.value && filter.value != '') { - const { column, operator, value } = filter - queryChains = queryChains.filter(column, operator, value) - } - - // TODO: How to let users know that filter result limit at 20 results - // should we allow a higher value? - const sql = queryChains.range(0, 20).toSql() - const { data, error } = await state.onSqlQuery(sql) - if (error) { - setRows(null) - } else { - setRows(data) - } - } - - function toggle(event: React.MouseEvent) { - if (event && event.stopPropagation) event.stopPropagation() - setVisible(!visible) - } - - function onItemSelect(item: Dictionary) { - if (item && columnDefinition && columnDefinition.targetColumnName) { - const value = item[columnDefinition.targetColumnName] - onChange(value) - } - - setVisible(false) - } - - function renderRows() { - if (!rows) return null - const temp = rows.map((x, i) => { - return ( - - ) - }) - return {temp} - } - - function onFilterChange(filter: Filter) { - if (filter && filter.value && filter.value != '') fetchData(filter) - } - - function onClearValueClick() { - onChange(null) - setVisible(false) - } - - return ( - <> - - } - > - - - - - -
        -
        - {renderRows()} -
        -
        -
        - - - ) -} diff --git a/studio/components/grid/components/editor/BooleanEditor.tsx b/studio/components/grid/components/editor/BooleanEditor.tsx index 8505b5a5901..052ba484a86 100644 --- a/studio/components/grid/components/editor/BooleanEditor.tsx +++ b/studio/components/grid/components/editor/BooleanEditor.tsx @@ -1,30 +1,46 @@ -import * as React from 'react' +import { Select } from 'ui' import { EditorProps } from '@supabase/react-data-grid' +import { useTrackedState } from 'components/grid/store' + +interface Props extends EditorProps { + isNullable?: boolean +} export function BooleanEditor({ row, column, + isNullable, onRowChange, onClose, -}: EditorProps) { - function onChange(event: React.ChangeEvent) { - onRowChange({ ...row, [column.key]: event.target.checked }) - } +}: Props) { + const state = useTrackedState() + const gridColumn = state.gridColumns.find((x) => x.name == column.key) + const value = row[column.key as keyof TRow] as unknown as string - function onBlur() { - onClose(true) + const onBlur = () => onClose(false) + const onChange = (event: any) => { + const value = event.target.value + if (value === 'null') { + onRowChange({ ...row, [column.key]: null }, true) + } else { + onRowChange({ ...row, [column.key]: value === 'true' }, true) + } } return ( -
        - -
        + ) } diff --git a/studio/components/grid/components/editor/JsonEditor.tsx b/studio/components/grid/components/editor/JsonEditor.tsx index d068097412a..93ea44dbc9a 100644 --- a/studio/components/grid/components/editor/JsonEditor.tsx +++ b/studio/components/grid/components/editor/JsonEditor.tsx @@ -104,17 +104,19 @@ export function JsonEditor({
        - - -
        - Expand editor -
        -
        + + + +
        + Expand editor +
        +
        +
        diff --git a/studio/components/grid/components/editor/NullableBooleanEditor.tsx b/studio/components/grid/components/editor/NullableBooleanEditor.tsx deleted file mode 100644 index 22996cf1dc6..00000000000 --- a/studio/components/grid/components/editor/NullableBooleanEditor.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import React, { useEffect } from 'react' -import { EditorProps } from '@supabase/react-data-grid' -import { Button, IconX } from 'ui' - -export function NullableBooleanEditor({ - row, - column, - onRowChange, - onClose, -}: EditorProps) { - const value = row[column.key as keyof TRow] as unknown as boolean | null - - useEffect(() => { - // if value is null, set it to false on initial render - if (value === null) { - onRowChange({ ...row, [column.key]: false }) - } - }, []) - - function onChange(event: React.ChangeEvent) { - onRowChange({ ...row, [column.key]: event.target.checked }) - } - - function onBlur() { - onClose(true) - } - - function onClear() { - onRowChange({ ...row, [column.key]: null }, true) - } - - return ( -
        - -
        - ) -} diff --git a/studio/components/grid/components/editor/TextEditor.tsx b/studio/components/grid/components/editor/TextEditor.tsx index 2b0fa077dc3..09b98506491 100644 --- a/studio/components/grid/components/editor/TextEditor.tsx +++ b/studio/components/grid/components/editor/TextEditor.tsx @@ -2,13 +2,14 @@ import { useState, useCallback } from 'react' import { EditorProps } from '@supabase/react-data-grid' import { useTrackedState } from '../../store' import { BlockKeys, MonacoEditor, NullValue, EmptyValue } from '../common' -import { Popover } from 'ui' +import { Button, Popover } from 'ui' export function TextEditor({ row, column, + isNullable, onRowChange, -}: EditorProps) { +}: EditorProps & { isNullable?: boolean }) { const state = useTrackedState() const [isPopoverOpen, setIsPopoverOpen] = useState(true) const gridColumn = state.gridColumns.find((x) => x.name == column.key) @@ -59,6 +60,13 @@ export function TextEditor({

        Cancel changes

    +
    + {isNullable && ( + + )} +
    } diff --git a/studio/components/grid/components/editor/index.ts b/studio/components/grid/components/editor/index.ts index af37e035773..4869badf6da 100644 --- a/studio/components/grid/components/editor/index.ts +++ b/studio/components/grid/components/editor/index.ts @@ -2,7 +2,6 @@ export * from './BooleanEditor' export * from './DateEditor' export * from './DateTimeEditor' export * from './JsonEditor' -export * from './NullableBooleanEditor' export * from './NumberEditor' export * from './SelectEditor' export * from './TextEditor' diff --git a/studio/components/grid/components/formatter/BooleanFormatter.tsx b/studio/components/grid/components/formatter/BooleanFormatter.tsx index a038fdda957..9255bd8c77b 100644 --- a/studio/components/grid/components/formatter/BooleanFormatter.tsx +++ b/studio/components/grid/components/formatter/BooleanFormatter.tsx @@ -6,5 +6,5 @@ import { NullValue } from '../common' export const BooleanFormatter = (p: React.PropsWithChildren>) => { const value = p.row[p.column.key] as boolean | null if (value === null) return - return <>{value ? 'true' : 'false'} + return <>{value ? 'TRUE' : 'FALSE'} } diff --git a/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx b/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx index e6abb30516f..df6b3e1d1da 100644 --- a/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx +++ b/studio/components/grid/components/formatter/ForeignKeyFormatter.tsx @@ -1,43 +1,74 @@ -import * as React from 'react' +import Link from 'next/link' +import { PropsWithChildren } from 'react' import { FormatterProps } from '@supabase/react-data-grid' import { SupaRow } from '../../types' import { NullValue } from '../common' -import { ForeignTableModal } from '../common/ForeignTableModal' -import { useDispatch, useTrackedState } from '../../store' -import { deepClone } from '../../utils' +import * as Tooltip from '@radix-ui/react-tooltip' +import { Button, IconArrowRight } from 'ui' +import { useParams, useStore } from 'hooks' -export const ForeignKeyFormatter = ( - p: React.PropsWithChildren> -) => { - const state = useTrackedState() - const dispatch = useDispatch() - const value = p.row[p.column.key] +export const ForeignKeyFormatter = (props: PropsWithChildren>) => { + const { ref, id } = useParams() + const { meta } = useStore() - function onRowChange(_value: any | null) { - const rowData = deepClone(p.row) - rowData[p.column.key] = _value + const { row, column } = props + const selectedTable = meta.tables.byId(id as string) + const relationship = (selectedTable?.relationships ?? []).find( + (r) => + r.source_schema === selectedTable?.schema && + r.source_table_name === selectedTable?.name && + r.source_column_name === column.name + ) + const targetTable = meta.tables + .list() + .find( + (table) => + table.schema === relationship?.target_table_schema && + table.name === relationship.target_table_name + ) - const { error } = state.rowService!.update(rowData, p.row, p.column.key) - if (error) { - if (state.onError) state.onError(error) - } else { - dispatch({ - type: 'EDIT_ROW', - payload: { row: rowData, idx: p.row.idx }, - }) - } - } - - function onChange(_value: any | null) { - onRowChange(_value) - } + const value = row[column.key] return (
    {value === null ? : value} - + {relationship !== undefined && targetTable !== undefined && value !== null && ( + + + + +
    ) } diff --git a/studio/components/grid/components/grid/ColumnHeader.tsx b/studio/components/grid/components/grid/ColumnHeader.tsx index 8b945f3fdd4..decdda4da38 100644 --- a/studio/components/grid/components/grid/ColumnHeader.tsx +++ b/studio/components/grid/components/grid/ColumnHeader.tsx @@ -1,12 +1,14 @@ import * as React from 'react' -import { IconKey, IconLink, IconLock } from 'ui' +import { IconArrowRight, IconKey, IconLink, IconLock } from 'ui' import * as Tooltip from '@radix-ui/react-tooltip' import { useDrag, useDrop, DropTargetMonitor } from 'react-dnd' import { XYCoord } from 'dnd-core' import { useDispatch } from '../../store' -import { ColumnHeaderProps, ColumnType, DragItem } from '../../types' +import { ColumnHeaderProps, ColumnType, DragItem, GridForeignKey } from '../../types' import { ColumnMenu } from '../menu' import { useTrackedState } from '../../store' +import { FOREIGN_KEY_DELETION_ACTION } from 'data/database/database-query-constants' +import { getForeignKeyDeletionAction } from 'components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.utils' export function ColumnHeader({ column, @@ -14,6 +16,7 @@ export function ColumnHeader({ isPrimaryKey, isEncrypted, format, + foreignKey, }: ColumnHeaderProps) { const ref = React.useRef(null) const dispatch = useDispatch() @@ -119,7 +122,7 @@ export function ColumnHeader({
    - {renderColumnIcon(columnType)} + {renderColumnIcon(columnType, { name: column.name as string, foreignKey })} {isPrimaryKey && ( @@ -127,17 +130,19 @@ export function ColumnHeader({
    - - -
    - Primary key -
    -
    + + + +
    + Primary key +
    +
    +
    )} @@ -149,17 +154,19 @@ export function ColumnHeader({ - - -
    - Encrypted column -
    -
    + + + +
    + Encrypted column +
    +
    +
    )}
    @@ -169,13 +176,47 @@ export function ColumnHeader({ ) } -function renderColumnIcon(type: ColumnType) { +function renderColumnIcon( + type: ColumnType, + columnMeta: { name?: string; foreignKey?: GridForeignKey } +) { + const { name, foreignKey } = columnMeta switch (type) { case 'foreign_key': return ( -
    - -
    + + + + + + + +
    +
    +

    Foreign key relation:

    +
    +

    {name}

    + +

    + {foreignKey?.targetTableSchema}.{foreignKey?.targetTableName}. + {foreignKey?.targetColumnName} +

    +
    + {foreignKey?.deletionAction !== FOREIGN_KEY_DELETION_ACTION.NO_ACTION && ( +

    + On delete: {getForeignKeyDeletionAction(foreignKey?.deletionAction)} +

    + )} +
    +
    +
    +
    +
    ) default: return null diff --git a/studio/components/grid/components/grid/Grid.tsx b/studio/components/grid/components/grid/Grid.tsx index a5194aa0c9f..98612a258d1 100644 --- a/studio/components/grid/components/grid/Grid.tsx +++ b/studio/components/grid/components/grid/Grid.tsx @@ -6,7 +6,9 @@ import { GridProps, SupaRow } from '../../types' import { useDispatch, useTrackedState } from '../../store' import RowRenderer from './RowRenderer' import AwesomeDebouncePromise from 'awesome-debounce-promise' -import { useTableRowUpdateMutation } from 'data/table-rows/table-row-update-mutation' +import { ForeignRowSelectorProps } from 'components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/ForeignRowSelector' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { useForeignKeyConstraintsQuery } from 'data/database/foreign-key-constraints-query' function rowKeyGetter(row: SupaRow) { return row?.idx ?? -1 @@ -15,13 +17,27 @@ function rowKeyGetter(row: SupaRow) { interface IGrid extends GridProps { rows: any[] updateRow: (previousRow: any, updatedData: any) => void + onEditForeignKeyColumnValue: (args: { + foreignKey: NonNullable + row: any + column: any + }) => void } // [Joshen] Just for visibility this is causing some hook errors in the browser export const Grid = memo( forwardRef( ( - { width, height, containerClass, gridClass, rowClass, rows, updateRow }, + { + width, + height, + containerClass, + gridClass, + rowClass, + rows, + updateRow, + onEditForeignKeyColumnValue, + }, ref: React.Ref | undefined ) => { const dispatch = useDispatch() @@ -61,6 +77,39 @@ export const Grid = memo( }) } + const table = state.table + + const { project } = useProjectContext() + const { data } = useForeignKeyConstraintsQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + schema: table?.schema ?? undefined, + }) + + function getColumnForeignKey(columnName: string) { + const { targetTableSchema, targetTableName, targetColumnName } = + table?.columns.find((x) => x.name == columnName)?.foreignKey ?? {} + + return data?.find( + (key) => + key.target_schema == targetTableSchema && + key.target_table == targetTableName && + key.target_columns == targetColumnName + ) + } + + function onRowDoubleClick(row: any, column: any) { + const foreignKey = getColumnForeignKey(column.name) + + if (foreignKey) { + onEditForeignKeyColumnValue({ + foreignKey, + row, + column, + }) + } + } + if (!columnHeaders || columnHeaders.length == 0) { return (
    void onAddRow?: () => void headerActions?: ReactNode + customHeader: ReactNode } -const Header = ({ table, sorts, filters, onAddColumn, onAddRow, headerActions }: HeaderProps) => { +const Header = ({ + table, + sorts, + filters, + onAddColumn, + onAddRow, + headerActions, + customHeader, + isRefetching, +}: HeaderProps) => { const state = useTrackedState() const { selectedRows } = state return ( -
    - {selectedRows.size > 0 ? ( - - ) : ( - - )} -
    {headerActions}
    +
    +
    + {customHeader ? ( + <>{customHeader} + ) : ( + <> + {selectedRows.size > 0 ? ( + + ) : ( + + )} + + )} +
    {headerActions}
    +
    +
    ) } @@ -52,21 +78,26 @@ export default Header type DefaultHeaderProps = { table: SupaTable + isRefetching: boolean onAddColumn?: () => void onAddRow?: () => void } -const DefaultHeader = ({ table, onAddColumn, onAddRow }: DefaultHeaderProps) => { +const DefaultHeader = ({ table, isRefetching, onAddColumn, onAddRow }: DefaultHeaderProps) => { const canAddNew = onAddRow !== undefined || onAddColumn !== undefined // [Joshen] Using this logic to block both column and row creation/update/delete const canCreateColumns = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'columns') + const [{ filter: filters, sort: sorts }, setParams] = useUrlState({ + arrayKeys: ['sort', 'filter'], + }) + return (
    - - - + + +
    {canAddNew && ( <> diff --git a/studio/components/grid/components/header/RLSBannerWarning.tsx b/studio/components/grid/components/header/RLSBannerWarning.tsx new file mode 100644 index 00000000000..9d5e16f422f --- /dev/null +++ b/studio/components/grid/components/header/RLSBannerWarning.tsx @@ -0,0 +1,86 @@ +import { useState } from 'react' +import { Button, IconAlertCircle, IconExternalLink, Modal } from 'ui' +import Link from 'next/link' +import { useParams, useStore } from 'hooks' +import ConfirmationModal from 'components/ui/ConfirmationModal' +import type { PostgresTable } from '@supabase/postgres-meta' + +const RLS_ACKNOWLEDGED_KEY = 'supabase-acknowledge-rls-warning' + +export default function RLSBannerWarning() { + const { meta } = useStore() + const { ref: projectRef, id: tableID } = useParams() + + const isAcknowledged = localStorage.getItem(`${RLS_ACKNOWLEDGED_KEY}-${tableID}`) === 'true' + + const [isOpen, setIsOpen] = useState(false) + + const tables: PostgresTable[] = meta.tables.list() + const currentTable = tables.find((table) => table.id.toString() === tableID) + const rlsEnabled = currentTable?.rls_enabled + const isPublicTable = currentTable?.schema === 'public' + + function handleDismissWarning() { + localStorage.setItem(`${RLS_ACKNOWLEDGED_KEY}-${tableID}`, 'true') + setIsOpen(false) + } + + return ( + <> + {!isAcknowledged && !rlsEnabled && isPublicTable ? ( +
    +
    + + Warning: This table is publicly + readable and writable.{' '} + + + Enable Row Level Security + + +
    + +
    +
    + + setIsOpen(false)} + onSelectConfirm={handleDismissWarning} + children={ + +
    +

    + Data in this table is publicly readable and writable by anyone with the + anon key. We strongly recommend creating RLS (Row-Level Security) policies to + allow access to this table. +

    + +
    +
    + } + /> +
    + ) : ( + <> + )} + + ) +} diff --git a/studio/components/grid/components/header/RefreshButton.tsx b/studio/components/grid/components/header/RefreshButton.tsx index dea181a3de8..77fb9080fea 100644 --- a/studio/components/grid/components/header/RefreshButton.tsx +++ b/studio/components/grid/components/header/RefreshButton.tsx @@ -1,4 +1,4 @@ -import { useQueryClient, useIsFetching } from '@tanstack/react-query' +import { useQueryClient } from '@tanstack/react-query' import { SupaTable } from 'components/grid/types' import { sqlKeys } from 'data/sql/keys' import { useParams } from 'hooks' @@ -8,16 +8,14 @@ import { SupabaseGridQueue } from '../../constants' export type RefreshButtonProps = { table: SupaTable + isRefetching: boolean } -const RefreshButton = ({ table }: RefreshButtonProps) => { +const RefreshButton = ({ table, isRefetching }: RefreshButtonProps) => { const { ref } = useParams() const queryClient = useQueryClient() const queryKey = sqlKeys.query(ref, [table.schema, table.name]) - - const loading = useIsFetching({ queryKey }) > 0 - const [status, setStatus] = useState() useEffect(() => { @@ -61,7 +59,7 @@ const RefreshButton = ({ table }: RefreshButtonProps) => { onClick={() => { if (!status) onClick() }} - loading={loading || status === 'saving'} + loading={isRefetching || status === 'saving'} > {!status ? 'Refresh' : status === 'saved' ? 'Changes saved' : 'Saving changes'} diff --git a/studio/components/grid/components/header/filter/FilterPopover.tsx b/studio/components/grid/components/header/filter/FilterPopover.tsx index 1cdecf8f3c3..ec2b1a9b507 100644 --- a/studio/components/grid/components/header/filter/FilterPopover.tsx +++ b/studio/components/grid/components/header/filter/FilterPopover.tsx @@ -1,17 +1,21 @@ -import { FC, useState, useMemo, useCallback } from 'react' +import { useState, useMemo, useCallback } from 'react' import { isEqual } from 'lodash' import { Button, IconPlus, IconFilter, Popover } from 'ui' import update from 'immutability-helper' import { useUrlState } from 'hooks' import FilterRow from './FilterRow' -import { useTrackedState } from 'components/grid/store' import { formatFilterURLParams } from 'components/grid/SupabaseGrid.utils' -import { Filter } from 'components/grid/types' +import { Filter, SupaTable } from 'components/grid/types' import { FilterOperatorOptions } from './Filter.constants' -const FilterPopover: FC = () => { - const [{ filter: filters }]: any = useUrlState({ arrayKeys: ['filter'] }) +export interface FilterPopoverProps { + table: SupaTable + filters: string[] + setParams: ReturnType[1] +} + +const FilterPopover = ({ table, filters, setParams }: FilterPopoverProps) => { const btnText = (filters || []).length > 0 ? `Filtered by ${filters.length} rule${filters.length > 1 ? 's' : ''}` @@ -22,7 +26,7 @@ const FilterPopover: FC = () => { size="large" align="start" className="sb-grid-filter-popover" - overlay={} + overlay={} > - {newToken && } +
    +
    +
    + + { + setTokenScope('V0') + setIsOpen(true) + }} + > +
    +

    Generate token for experimental API

    +
    + , + ]} + > +
    +
    +
    + setIsOpen(!isOpen)} header={
    -
    Generate New Token
    +
    + {tokenScope === 'V0' ? 'Generate token for experimental API' : 'Generate New Token'} +
    } > @@ -72,19 +102,47 @@ const NewAccessTokenButton = observer(() => { > {({ isSubmitting }: { isSubmitting: boolean }) => (
    + {tokenScope === 'V0' && ( + + +

    + These include deleting organizations and projects which cannot be undone. As + such, be very careful when using this API. +

    + +
    +
    + )} - +
    + + +
    )} @@ -95,28 +153,3 @@ const NewAccessTokenButton = observer(() => { }) export default NewAccessTokenButton - -interface NewTokenItemProps { - data: NewAccessToken -} - -const NewTokenItem: FC = observer(({ data }) => { - return ( - -
    -

    - Do copy this access token and store it in a secure place - you will not be able to see it - again. -

    - {}} - /> -
    -
    - ) -}) diff --git a/studio/components/interfaces/Account/NewTokenBanner.tsx b/studio/components/interfaces/Account/NewTokenBanner.tsx new file mode 100644 index 00000000000..242cc88f31f --- /dev/null +++ b/studio/components/interfaces/Account/NewTokenBanner.tsx @@ -0,0 +1,30 @@ +import { Alert, Input } from 'ui' +import { observer } from 'mobx-react-lite' +import { NewAccessToken } from 'data/access-tokens/access-tokens-create-mutation' + +interface NewTokenBannerProps { + token: NewAccessToken +} + +const NewTokenBanner = observer(({ token }: NewTokenBannerProps) => { + return ( + +
    +

    + Do copy this access token and store it in a secure place - you will not be able to see it + again. +

    + {}} + /> +
    +
    + ) +}) + +export default NewTokenBanner diff --git a/studio/components/interfaces/Account/index.ts b/studio/components/interfaces/Account/index.ts index 20793536e14..4ce89542872 100644 --- a/studio/components/interfaces/Account/index.ts +++ b/studio/components/interfaces/Account/index.ts @@ -1,4 +1,5 @@ import AccessTokenList from './AccessTokenList' +import NewTokenBanner from './NewTokenBanner' import NewAccessTokenButton from './NewAccessTokenButton' -export { AccessTokenList, NewAccessTokenButton } +export { AccessTokenList, NewTokenBanner, NewAccessTokenButton } diff --git a/studio/components/interfaces/Auth/Auth.constants.ts b/studio/components/interfaces/Auth/Auth.constants.ts index 32f80bc5cb2..4a1d03d04d6 100644 --- a/studio/components/interfaces/Auth/Auth.constants.ts +++ b/studio/components/interfaces/Auth/Auth.constants.ts @@ -12,7 +12,7 @@ const baseDomainRegex = // iOS deep linking scheme https://benoitpasquier.com/deep-linking-url-scheme-ios/ const appRegex = - /^[a-z0-9]+([.][a-z0-9]+)*:\/(\/[-a-z0-9._~!$&'()*+,;=:@%]+)+(?:\.[a-z]+)*(?::\d+)?(?![^<]*(?:<\/\w+>|\/?>))(.*)?\/?(.)*?$/i + /^[a-z0-9-]+([.][a-z0-9]+)*:\/(\/[-a-z0-9._~!$&'()*+,;=:@%]+)+(?:\.[a-z]+)*(?::\d+)?(?![^<]*(?:<\/\w+>|\/?>))(.*)?\/?(.)*?$/i // Regex from https://stackoverflow.com/a/18696953/4807782 const localhostRegex = /^(?:^|\s)((https?:\/\/)?(?:localhost|[\w-]+(?:\.[\w-]+)+)(:\d+)?(\/\S*)?)/i diff --git a/studio/components/interfaces/Auth/AuthProvidersForm/AuthProvidersForm.tsx b/studio/components/interfaces/Auth/AuthProvidersForm/AuthProvidersForm.tsx index 138d1711c28..f9ab8236697 100644 --- a/studio/components/interfaces/Auth/AuthProvidersForm/AuthProvidersForm.tsx +++ b/studio/components/interfaces/Auth/AuthProvidersForm/AuthProvidersForm.tsx @@ -1,6 +1,6 @@ import { observer } from 'mobx-react-lite' -import { useStore } from 'hooks' -import { PROVIDERS_SCHEMAS } from 'stores/authConfig/schema' +import { useStore, useFlag } from 'hooks' +import { PROVIDERS_SCHEMAS, PROVIDER_SAML } from 'stores/authConfig/schema' import { FormHeader } from 'components/ui/Forms' import { HorizontalShimmerWithIcon } from 'components/ui/Shimmers' import ProviderForm from './ProviderForm' @@ -8,7 +8,10 @@ import { ProviderCollapsibleClasses } from './AuthProvidersForm.constants' const AuthProvidersForm = () => { const { authConfig } = useStore() - const providers = PROVIDERS_SCHEMAS + const isSamlEnabled = useFlag('isSamlEnabled') + const providers = isSamlEnabled + ? PROVIDERS_SCHEMAS + : PROVIDERS_SCHEMAS.filter((provider) => provider !== PROVIDER_SAML) return (
    diff --git a/studio/components/interfaces/Auth/AuthProvidersForm/FormField.tsx b/studio/components/interfaces/Auth/AuthProvidersForm/FormField.tsx index f7c7d879784..f9437860cc7 100644 --- a/studio/components/interfaces/Auth/AuthProvidersForm/FormField.tsx +++ b/studio/components/interfaces/Auth/AuthProvidersForm/FormField.tsx @@ -1,3 +1,4 @@ +import { BASE_PATH } from 'lib/constants' import { FC, useState } from 'react' import ReactMarkdown from 'react-markdown' import { Button, Input, InputNumber, Toggle, Listbox, IconEye, IconEyeOff } from 'ui' @@ -102,7 +103,17 @@ const FormField: FC = ({ name, properties, formValues, disabled = false } name={name} disabled={disabled} label={properties.title} - descriptionText={properties.description} + descriptionText={ + properties.description ? ( + + {properties.description} + + ) : null + } /> ) @@ -113,7 +124,17 @@ const FormField: FC = ({ name, properties, formValues, disabled = false } name={name} disabled={disabled} label={properties.title} - descriptionText={properties.description} + descriptionText={ + properties.description ? ( + + {properties.description} + + ) : null + } defaultValue={properties.enum[0]} > {properties.enum.map((option: Enum) => { @@ -123,7 +144,9 @@ const FormField: FC = ({ name, properties, formValues, disabled = false } key={option.value} label={option.label} value={option.value} - addOnBefore={() => } + addOnBefore={() => ( + + )} > {option.label} diff --git a/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx b/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx index c438d96b468..7e70bcebd4d 100644 --- a/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx +++ b/studio/components/interfaces/Auth/AuthProvidersForm/ProviderForm.tsx @@ -5,6 +5,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { Alert, Button, Collapsible, Form, IconCheck, IconChevronUp, Input } from 'ui' import { useStore, checkPermissions } from 'hooks' +import { BASE_PATH } from 'lib/constants' import { Provider } from './AuthProvidersForm.types' import { ProviderCollapsibleClasses } from './AuthProvidersForm.constants' import FormField from './FormField' @@ -25,12 +26,23 @@ const ProviderForm: FC = ({ provider }) => { Object.keys(provider.properties).forEach((key) => { // When the key is a 'double negative' key, we must reverse the boolean before adding it to the form const isDoubleNegative = doubleNegativeKeys.includes(key) - initialValues[key] = isDoubleNegative ? !authConfig.config[key] : authConfig.config[key] ?? '' + + if (provider.title === 'SAML 2.0') { + initialValues[key] = authConfig.config[key] ?? false + } else { + initialValues[key] = isDoubleNegative + ? !authConfig.config[key] + : authConfig.config[key] ?? '' + } }) return initialValues } - const isActive = authConfig.config[`EXTERNAL_${provider?.title?.toUpperCase()}_ENABLED`] + // [Joshen] Doing this check as SAML doesn't follow the same naming structure as the other provider options + const isActive = + provider.title === 'SAML 2.0' + ? authConfig.config['SAML_ENABLED'] || false + : authConfig.config[`EXTERNAL_${provider?.title?.toUpperCase()}_ENABLED`] const INITIAL_VALUES = generateInitialValues() const onSubmit = async (values: any, { setSubmitting, resetForm }: any) => { @@ -78,7 +90,7 @@ const ProviderForm: FC = ({ provider }) => { width={14} /> {`${provider.title} @@ -162,25 +174,27 @@ const ProviderForm: FC = ({ provider }) => { {!canUpdateConfig && ( - - -
    - - You need additional permissions to update provider settings - -
    -
    + + + +
    + + You need additional permissions to update provider settings + +
    +
    +
    )}
    diff --git a/studio/components/interfaces/Auth/Policies/PolicyEditor/PolicyDefinition.tsx b/studio/components/interfaces/Auth/Policies/PolicyEditor/PolicyDefinition.tsx index 187b0576887..5cc064272fc 100644 --- a/studio/components/interfaces/Auth/Policies/PolicyEditor/PolicyDefinition.tsx +++ b/studio/components/interfaces/Auth/Policies/PolicyEditor/PolicyDefinition.tsx @@ -43,28 +43,31 @@ const PolicyDefinition: FC = ({ - - -
    -

    - This expression will be added to queries that refer to the table if row-level - security is enabled. -

    -

    - Rows for which the expression returns true will be visible. Any rows for which - the expression returns false or null will not be visible to the user (in a - SELECT), and will not be available for modification (in an UPDATE or DELETE). -

    -

    - Such rows are silently suppressed - no error is reported. -

    -
    -
    + + + +
    +

    + This expression will be added to queries that refer to the table if + row-level security is enabled. +

    +

    + Rows for which the expression returns true will be visible. Any rows for + which the expression returns false or null will not be visible to the user + (in a SELECT), and will not be available for modification (in an UPDATE or + DELETE). +

    +

    + Such rows are silently suppressed - no error is reported. +

    +
    +
    +

    @@ -87,29 +90,31 @@ const PolicyDefinition: FC = ({ - - -

    -

    - This expression will be used in INSERT and UPDATE queries against the table if - row-level security is enabled. -

    -

    - Only rows for which the expression evaluates to true will be allowed. An error - will be thrown if the expression evaluates to false or null for any of the - records inserted or any of the records that result from the update. -

    -

    - Note that this expression is evaluated against the proposed new contents of - the row, not the original contents. -

    -
    - + + + +
    +

    + This expression will be used in INSERT and UPDATE queries against the table + if row-level security is enabled. +

    +

    + Only rows for which the expression evaluates to true will be allowed. An + error will be thrown if the expression evaluates to false or null for any of + the records inserted or any of the records that result from the update. +

    +

    + Note that this expression is evaluated against the proposed new contents of + the row, not the original contents. +

    +
    +
    +

    diff --git a/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow.tsx b/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow.tsx index b3a00e0d638..41518f2742e 100644 --- a/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow.tsx +++ b/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow.tsx @@ -82,19 +82,21 @@ const PolicyRow: FC = ({ /> {!canUpdatePolicies && ( - - -

    - - You need additional permissions to edit RLS policies - -
    - + + + +
    + + You need additional permissions to edit RLS policies + +
    +
    +
    )} )} diff --git a/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx b/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx index 82cfd727e02..b081c241095 100644 --- a/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx +++ b/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx @@ -52,19 +52,21 @@ const PolicyTableRowHeader: FC = ({ {!canCreatePolicies && ( - - -
    - - You need additional permissions to create RLS policies - -
    -
    + + + +
    + + You need additional permissions to create RLS policies + +
    +
    +
    )} @@ -78,19 +80,21 @@ const PolicyTableRowHeader: FC = ({ {!canToggleRLS && ( - - -
    - - You need additional permissions to toggle RLS - -
    -
    + + + +
    + + You need additional permissions to toggle RLS + +
    +
    +
    )}
    diff --git a/studio/components/interfaces/Auth/Policies/PolicyTableRow/index.tsx b/studio/components/interfaces/Auth/Policies/PolicyTableRow/index.tsx index cc2551da145..4b943903e45 100644 --- a/studio/components/interfaces/Auth/Policies/PolicyTableRow/index.tsx +++ b/studio/components/interfaces/Auth/Policies/PolicyTableRow/index.tsx @@ -53,9 +53,10 @@ const PolicyTableRow: FC = ({ withIcon variant="warning" className="!px-4 !py-3 !mt-3" - title="Warning: RLS is disabled" + title="Warning: RLS is disabled. Your table is publicly readable and writable." > - Anonymous access is allowed to this table + Anyone with the anon. key can modify or delete your data. You should turn on RLS and + create access policies to keep your data secure. )}
    diff --git a/studio/components/interfaces/Auth/RedirectDomains/RedirectDomains.tsx b/studio/components/interfaces/Auth/RedirectDomains/RedirectDomains.tsx index 14354efddd0..ea58410a4b8 100644 --- a/studio/components/interfaces/Auth/RedirectDomains/RedirectDomains.tsx +++ b/studio/components/interfaces/Auth/RedirectDomains/RedirectDomains.tsx @@ -97,19 +97,21 @@ const RedirectDomains = () => { {!canUpdateConfig && ( - - -
    - - You need additional permissions to update redirect URLs - -
    -
    + + + +
    + + You need additional permissions to update redirect URLs + +
    +
    +
    )}
  • diff --git a/studio/components/interfaces/Auth/Users/AddUserDropdown.tsx b/studio/components/interfaces/Auth/Users/AddUserDropdown.tsx new file mode 100644 index 00000000000..3cacf12808d --- /dev/null +++ b/studio/components/interfaces/Auth/Users/AddUserDropdown.tsx @@ -0,0 +1,112 @@ +import * as Tooltip from '@radix-ui/react-tooltip' +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { checkPermissions } from 'hooks' +import { IS_PLATFORM } from 'lib/constants' +import { useState } from 'react' +import semver from 'semver' +import { Button, Dropdown, IconChevronDown, IconMail, IconPlus, IconUserPlus } from 'ui' +import CreateUserModal from './CreateUserModal' +import InviteUserModal from './InviteUserModal' + +export type AddUserDropdownProps = { + projectKpsVersion?: string +} + +const AddUserDropdown = ({ projectKpsVersion }: AddUserDropdownProps) => { + const inviteEnabled = IS_PLATFORM + ? semver.gte( + // @ts-ignore + semver.coerce(projectKpsVersion ?? 'kps-v2.5.4'), + semver.coerce('kps-v2.5.3') + ) + : true + + const canInviteUsers = checkPermissions(PermissionAction.AUTH_EXECUTE, 'invite_user') + const canCreateUsers = checkPermissions(PermissionAction.AUTH_EXECUTE, 'create_user') + + const [inviteVisible, setInviteVisible] = useState(false) + const [createVisible, setCreateVisible] = useState(false) + + return ( + <> + + {inviteEnabled && ( + + + } + disabled={!canInviteUsers} + onClick={() => setInviteVisible(true)} + > + Send Invitation + + + {!canInviteUsers && ( + + + +
    + + You need additional permissions to invite users + +
    +
    +
    + )} +
    + )} + + + + } + disabled={!canCreateUsers} + onClick={() => setCreateVisible(true)} + > + Create New User + + + + {!canCreateUsers && ( + + + +
    + + You need additional permissions to create users + +
    +
    +
    + )} +
    + + } + > + +
    + + {inviteEnabled && } + + + ) +} + +export default AddUserDropdown diff --git a/studio/components/interfaces/Auth/Users/CreateUserModal.tsx b/studio/components/interfaces/Auth/Users/CreateUserModal.tsx new file mode 100644 index 00000000000..94698aca740 --- /dev/null +++ b/studio/components/interfaces/Auth/Users/CreateUserModal.tsx @@ -0,0 +1,164 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { observer } from 'mobx-react-lite' +import { useContext } from 'react' +import { Button, Checkbox, Form, IconLock, IconMail, Input, Loading, Modal } from 'ui' + +import { useUserCreateMutation } from 'data/auth/user-create-mutation' +import { useProjectApiQuery } from 'data/config/project-api-query' +import { checkPermissions, useStore } from 'hooks' +import { PageContext } from 'pages/project/[ref]/auth/users' + +export type CreateUserModalProps = { + visible: boolean + setVisible: (visible: boolean) => void +} + +const CreateUserModal = ({ visible, setVisible }: CreateUserModalProps) => { + const { ui } = useStore() + const PageState: any = useContext(PageContext) + const projectRef = PageState.projectRef + + const { data, isLoading, isSuccess } = useProjectApiQuery({ projectRef }, { enabled: visible }) + + const handleToggle = () => setVisible(!visible) + const canCreateUsers = checkPermissions(PermissionAction.AUTH_EXECUTE, 'create_user') + + const validate = (values: any) => { + const errors: any = {} + const emailValidateRegex = + /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/ + + if (values.email.length === 0) { + errors.email = 'Please enter a valid email' + } else if (!emailValidateRegex.test(values.email)) { + errors.email = `${values.email} is an invalid email` + } + + if (!values.password?.trim()) { + errors.password = 'Please enter a password' + } + + return errors + } + + const { mutateAsync: createUser } = useUserCreateMutation({ + async onSuccess() { + await PageState.fetchData(1) + }, + }) + + const onCreateUser = async (values: any, { setSubmitting }: any) => { + if (!isSuccess) { + ui.setNotification({ + category: 'error', + message: `Failed to create user: Error loading project config`, + }) + + return + } + + const { protocol, endpoint, serviceApiKey } = data.autoApiService + + setSubmitting(true) + + try { + await createUser({ + endpoint, + protocol, + serviceApiKey, + user: values, + }) + + ui.setNotification({ + category: 'success', + message: `Created user: ${values.email}`, + }) + + setVisible(false) + } catch (error: any) { + ui.setNotification({ + category: 'error', + message: `Failed to create user: ${error.message}`, + }) + } + + setSubmitting(false) + } + + return ( + +
    + {({ isSubmitting }: { isSubmitting: boolean }) => ( + +
    + +
    + } + type="email" + name="email" + placeholder="user@example.com" + disabled={isSubmitting || isLoading} + /> + + } + disabled={isSubmitting || isLoading} + autoComplete="new-password" + /> + + +
    +
    + + + + +
    +
    + )} +
    +
    + ) +} + +export default observer(CreateUserModal) diff --git a/studio/components/interfaces/Auth/Users/InviteUserModal.tsx b/studio/components/interfaces/Auth/Users/InviteUserModal.tsx index 5f5898714cf..2ec628eb448 100644 --- a/studio/components/interfaces/Auth/Users/InviteUserModal.tsx +++ b/studio/components/interfaces/Auth/Users/InviteUserModal.tsx @@ -1,18 +1,21 @@ -import { useContext, useState } from 'react' -import { observer } from 'mobx-react-lite' -import * as Tooltip from '@radix-ui/react-tooltip' -import { Button, Modal, Input, IconPlus, IconMail, Form } from 'ui' import { PermissionAction } from '@supabase/shared-types/out/constants' +import { observer } from 'mobx-react-lite' +import { useContext } from 'react' +import { Button, Form, IconMail, Input, Modal } from 'ui' -import { useStore, checkPermissions } from 'hooks' +import { checkPermissions, useStore } from 'hooks' import { post } from 'lib/common/fetch' import { API_URL } from 'lib/constants' import { PageContext } from 'pages/project/[ref]/auth/users' -const InviteUserModal = () => { +export type InviteUserModalProps = { + visible: boolean + setVisible: (visible: boolean) => void +} + +const InviteUserModal = ({ visible, setVisible }: InviteUserModalProps) => { const { ui } = useStore() const PageState: any = useContext(PageContext) - const [visible, setVisible] = useState(false) const handleToggle = () => setVisible(!visible) const canInviteUsers = checkPermissions(PermissionAction.AUTH_EXECUTE, 'invite_user') @@ -31,7 +34,7 @@ const InviteUserModal = () => { return errors } - const onInviteuser = async (values: any, { setSubmitting }: any) => { + const onInviteUser = async (values: any, { setSubmitting }: any) => { setSubmitting(true) const response = await post(`${API_URL}/auth/${PageState.projectRef}/invite`, { @@ -56,28 +59,6 @@ const InviteUserModal = () => { return (
    - - - - - {!canInviteUsers && ( - - -
    - - You need additional permissions to invite users - -
    -
    - )} -
    { onCancel={handleToggle} >
    {({ isSubmitting }: { isSubmitting: boolean }) => (
    { placeholder="User email" /> +
    -
    +
    - {inviteEnabled && } + +
    diff --git a/studio/components/interfaces/Billing/InvoiceStatusBadge.tsx b/studio/components/interfaces/Billing/InvoiceStatusBadge.tsx new file mode 100644 index 00000000000..ecf6a931eef --- /dev/null +++ b/studio/components/interfaces/Billing/InvoiceStatusBadge.tsx @@ -0,0 +1,92 @@ +import { FC } from 'react' +import * as Tooltip from '@radix-ui/react-tooltip' +import { Badge } from 'ui' +import { InvoiceStatus } from './Invoices.types' + +interface Props { + status: InvoiceStatus +} + +export const invoiceStatusMapping: Record = { + [InvoiceStatus.DRAFT]: { + label: 'Upcoming', + badgeColor: 'yellow', + }, + [InvoiceStatus.PAID]: { + label: 'Paid', + badgeColor: 'green', + }, + [InvoiceStatus.VOID]: { + label: 'Forgiven', + badgeColor: 'green', + }, + + // We do not want to overcomplicate it for the user, so we'll treat uncollectible/open the same from a user perspective + // it's an outstanding invoice + [InvoiceStatus.UNCOLLECTIBLE]: { + label: 'Outstanding', + badgeColor: 'red', + }, + [InvoiceStatus.OPEN]: { + label: 'Outstanding', + badgeColor: 'red', + }, +} + +const InvoiceStatusBadge: FC = ({ status }) => { + const statusMapping = invoiceStatusMapping[status] + + return ( + + + + {statusMapping?.label || status} + + + + + +
    + {[InvoiceStatus.OPEN, InvoiceStatus.UNCOLLECTIBLE].includes(status) && ( +

    + We were not able to collect the money. Make sure you have a valid payment method and + enough funds. Outstanding invoices may cause restrictions. You can manually pay the + using the "Pay Now" button. +

    + )} + + {status === InvoiceStatus.DRAFT && ( +

    + The invoice will soon be finalized and charged for. +

    + )} + + {status === InvoiceStatus.PAID && ( +

    + The invoice has been paid successfully. No action is required on your side. +

    + )} + + {status === InvoiceStatus.VOID && ( +

    + This invoice has been forgiven. No action is required on your side. +

    + )} +
    +
    +
    +
    + ) +} + +export default InvoiceStatusBadge diff --git a/studio/components/interfaces/Billing/Invoices.tsx b/studio/components/interfaces/Billing/Invoices.tsx index 6cde250cd9c..fb255c38353 100644 --- a/studio/components/interfaces/Billing/Invoices.tsx +++ b/studio/components/interfaces/Billing/Invoices.tsx @@ -1,11 +1,15 @@ import { FC, useState, useEffect } from 'react' import { Button, Loading, IconFileText, IconDownload, IconChevronLeft, IconChevronRight } from 'ui' +import Link from 'next/link' import { useStore } from 'hooks' import { API_URL } from 'lib/constants' import { get, head } from 'lib/common/fetch' import Table from 'components/to-be-cleaned/Table' +import InvoiceStatusBadge from './InvoiceStatusBadge' +import { Invoice, InvoiceStatus } from './Invoices.types' + const PAGE_LIMIT = 10 interface Props { @@ -18,7 +22,7 @@ const Invoices: FC = ({ projectRef }) => { const [page, setPage] = useState(1) const [count, setCount] = useState(0) - const [invoices, setInvoices] = useState([]) + const [invoices, setInvoices] = useState([]) const offset = (page - 1) * PAGE_LIMIT @@ -85,6 +89,9 @@ const Invoices: FC = ({ projectRef }) => { Date, Amount due, Invoice number, + + Status + , , ]} body={ @@ -98,7 +105,7 @@ const Invoices: FC = ({ projectRef }) => { ) : ( <> - {invoices.map((x: any) => { + {invoices.map((x) => { return ( @@ -117,11 +124,22 @@ const Invoices: FC = ({ projectRef }) => {

    {x.number}

    + + +
    + {[InvoiceStatus.UNCOLLECTIBLE, InvoiceStatus.OPEN].includes(x.status) && ( + + + + + + )} +
    @@ -130,7 +148,7 @@ const Invoices: FC = ({ projectRef }) => { ) })} - +

    Showing {offset + 1} to {offset + invoices.length} out of {count} invoices diff --git a/studio/components/interfaces/Billing/Invoices.types.ts b/studio/components/interfaces/Billing/Invoices.types.ts new file mode 100644 index 00000000000..97b8916c3e3 --- /dev/null +++ b/studio/components/interfaces/Billing/Invoices.types.ts @@ -0,0 +1,15 @@ +export enum InvoiceStatus { + DRAFT = 'draft', + PAID = 'paid', + VOID = 'void', + UNCOLLECTIBLE = 'uncollectible', + OPEN = 'open', +} + +export type Invoice = { + id: string + number: string + period_end: number + subtotal: number + status: InvoiceStatus +} diff --git a/studio/components/interfaces/Billing/PaymentSummaryPanel/PaymentSummaryPanel.tsx b/studio/components/interfaces/Billing/PaymentSummaryPanel/PaymentSummaryPanel.tsx index 8deab10c609..0a3d3688a42 100644 --- a/studio/components/interfaces/Billing/PaymentSummaryPanel/PaymentSummaryPanel.tsx +++ b/studio/components/interfaces/Billing/PaymentSummaryPanel/PaymentSummaryPanel.tsx @@ -1,15 +1,16 @@ -import { FC, useRef, useState } from 'react' +import { FC, useState } from 'react' +import Router from 'next/router' import * as Tooltip from '@radix-ui/react-tooltip' import { Listbox, IconLoader, Button, IconPlus, IconAlertCircle, IconCreditCard } from 'ui' import { PermissionAction } from '@supabase/shared-types/out/constants' import { checkPermissions, useStore } from 'hooks' -import { PRICING_TIER_PRODUCT_IDS, STRIPE_PRODUCT_IDS } from 'lib/constants' +import { BASE_PATH, PRICING_TIER_PRODUCT_IDS, STRIPE_PRODUCT_IDS } from 'lib/constants' import { SubscriptionPreview } from '../Billing.types' import { getProductPrice, validateSubscriptionUpdatePayload } from '../Billing.utils' import PaymentTotal from './PaymentTotal' import InformationBox from 'components/ui/InformationBox' -import { SubscriptionAddon } from '../AddOns/AddOns.types' +import { AddonPrice, SubscriptionAddon } from '../AddOns/AddOns.types' import { getPITRDays } from './PaymentSummaryPanel.utils' import ConfirmPaymentModal from './ConfirmPaymentModal' import { StripeSubscription } from '../Subscription/Subscription.types' @@ -88,6 +89,21 @@ const PaymentSummaryPanel: FC = ({ const [showConfirmModal, setShowConfirmModal] = useState(false) + const selectedPlanCost = + selectedPlan.prices.find( + (price: AddonPrice) => price.id === selectedPlan.metadata.default_price_id + )?.unit_amount ?? 0 + const totalSelectedAddonCost = Object.keys(selectedAddons) + .map((productName) => { + const product = (selectedAddons as any)[productName] + const price = product.prices.find( + (price: AddonPrice) => price.id === product.metadata.default_price_id + ) + return price + }) + .reduce((a, b) => a + b.unit_amount, 0) + const totalMonthlyCost = selectedPlanCost + totalSelectedAddonCost + const currentPITRDays = currentAddons.pitrDuration !== undefined ? getPITRDays(currentAddons.pitrDuration) : 0 const selectedPITRDays = @@ -105,6 +121,11 @@ const PaymentSummaryPanel: FC = ({ const isChangingCustomDomains = currentAddons.customDomains?.id !== selectedAddons.customDomains?.id + const togglingOnSpendCap = + currentPlan.supabase_prod_id === PRICING_TIER_PRODUCT_IDS.PAYG && + selectedPlan && + selectedPlan.metadata.supabase_prod_id === PRICING_TIER_PRODUCT_IDS.PRO + // If it's enterprise we only only changing of add-ons const hasChangesToPlan = isEnterprise ? isChangingComputeSize || isChangingPITRDuration @@ -301,6 +322,16 @@ const PaymentSummaryPanel: FC = ({ description="It will take up to 2 minutes for changes to take place, and your project will be unavailable during that time" /> )} + + {togglingOnSpendCap && ( + } + title="Enabling spend cap" + description="Exceeding your plan's quota will result in service restrictions. With the spend cap disabled, you'll be charged for usage beyond the quota." + /> + )}

    )} @@ -308,6 +339,7 @@ const PaymentSummaryPanel: FC = ({
    = ({ {!canUpdatePaymentMethods && ( - - -
    - - You need additional permissions to add new payment methods to this - organization - -
    -
    + + + +
    + + You need additional permissions to add new payment methods to this + organization + +
    +
    +
    )}
    @@ -369,7 +403,7 @@ const PaymentSummaryPanel: FC = ({ addOnBefore={() => { return ( = ({ {!hasChangesToPlan ? ( - - -
    - - No changes made to your subscription - -
    -
    + + + +
    + + No changes made to your subscription + +
    +
    +
    ) : !selectedPaymentMethod ? ( - - -
    - Please select a payment method -
    -
    + + + +
    + Please select a payment method +
    +
    +
    ) : ( <> )} diff --git a/studio/components/interfaces/Billing/PaymentSummaryPanel/PaymentTotal.tsx b/studio/components/interfaces/Billing/PaymentSummaryPanel/PaymentTotal.tsx index 1966ce0cf4f..7979c53969e 100644 --- a/studio/components/interfaces/Billing/PaymentSummaryPanel/PaymentTotal.tsx +++ b/studio/components/interfaces/Billing/PaymentSummaryPanel/PaymentTotal.tsx @@ -5,18 +5,20 @@ import { SubscriptionPreview } from '../Billing.types' import CostBreakdownModal from './CostBreakdownModal' interface Props { + totalMonthlyCost: number subscriptionPreview?: SubscriptionPreview isRefreshingPreview: boolean isSpendCapEnabled: boolean } const PaymentTotal: FC = ({ + totalMonthlyCost, subscriptionPreview, isRefreshingPreview, isSpendCapEnabled, }) => { const hasChanges = subscriptionPreview?.has_changes ?? false - const totalMonthlyCost = (subscriptionPreview?.base_amount_due_next_billing_cycle ?? 0) / 100 + // const totalMonthlyCost = (subscriptionPreview?.base_amount_due_next_billing_cycle ?? 0) / 100 const amountDueImmediately = (subscriptionPreview?.amount_due_immediately ?? 0) / 100 const billingDate = new Date((subscriptionPreview?.bill_on ?? 0) * 1000) const isBillingToday = @@ -52,17 +54,19 @@ const PaymentTotal: FC = ({ onClick={() => setShowCostBreakdown(true)} /> - - -
    - How is this calculated? -
    -
    + + + +
    + How is this calculated? +
    +
    +
    )}
    diff --git a/studio/components/interfaces/Billing/PlanSelection/Plans/PlanCTAButton.tsx b/studio/components/interfaces/Billing/PlanSelection/Plans/PlanCTAButton.tsx index ca1595eaf6c..316265f8e2b 100644 --- a/studio/components/interfaces/Billing/PlanSelection/Plans/PlanCTAButton.tsx +++ b/studio/components/interfaces/Billing/PlanSelection/Plans/PlanCTAButton.tsx @@ -112,19 +112,23 @@ const PlanCTAButton: FC = ({ plan, currentPlan, onSelectPlan }) => { {!disabled && projectUpdateDisabled && ( - - -
    - - Subscription changes are currently disabled, our engineers are working on a fix - -
    -
    + + + + +
    + + Subscription changes are currently disabled, our engineers are working on a fix + +
    +
    +
    +
    )} diff --git a/studio/components/interfaces/Billing/PlanSelection/Plans/PlanCard.tsx b/studio/components/interfaces/Billing/PlanSelection/Plans/PlanCard.tsx index 2745c65dbcc..44637d482b9 100644 --- a/studio/components/interfaces/Billing/PlanSelection/Plans/PlanCard.tsx +++ b/studio/components/interfaces/Billing/PlanSelection/Plans/PlanCard.tsx @@ -14,7 +14,8 @@ interface Props { const PlanCard: FC = ({ plan, currentPlan, onSelectPlan }) => { // Team tier is enabled when the flag is turned on OR the user is already on the team tier (manually assigned by us) - const teamTierEnabled = currentPlan?.supabase_prod_id === PRICING_TIER_PRODUCT_IDS.TEAM || useFlag('teamTier') + const teamTierEnabled = + currentPlan?.supabase_prod_id === PRICING_TIER_PRODUCT_IDS.TEAM || useFlag('teamTier') const planMeta = PRICING_META[plan.id] const isEnterprise = plan.id === 'Enterprise' @@ -118,8 +119,29 @@ const PlanCard: FC = ({ plan, currentPlan, onSelectPlan }) => { {!isEnterprise && (
    -

    {planMeta.additional}

    {planMeta.scale}

    + + {planMeta.name === 'Pro' && currentPlan?.prod_id === STRIPE_PRODUCT_IDS.PRO && ( +
    +

    + Need more? Turn off your spend cap to Pay As You Grow +

    +

    + Additional fees apply for spend beyond the limits above +

    {' '} +
    + )} + + {planMeta.name === 'Pro' && currentPlan?.prod_id === STRIPE_PRODUCT_IDS.PAYG && ( +
    +

    + Spend cap is currently disabled +

    +

    + You will be charged for usage beyond the plan limits +

    {' '} +
    + )}
    diff --git a/studio/components/interfaces/Billing/PlanSelection/Plans/Plans.Constants.ts b/studio/components/interfaces/Billing/PlanSelection/Plans/Plans.Constants.ts index 025e54ea8a7..c31d72eac83 100644 --- a/studio/components/interfaces/Billing/PlanSelection/Plans/Plans.Constants.ts +++ b/studio/components/interfaces/Billing/PlanSelection/Plans/Plans.Constants.ts @@ -49,8 +49,6 @@ export const PRICING_META = { 'No project pausing', 'Email support', ], - additional: 'Need more? Turn off your spend cap to Pay As You Grow ', - scale: 'Additional fees apply for usage and storage beyond the limits above.', }, [STRIPE_PRODUCT_IDS.TEAM]: { new: true, diff --git a/studio/components/interfaces/Billing/ProUpgrade.tsx b/studio/components/interfaces/Billing/ProUpgrade.tsx index 33919b8a218..897ee2e0005 100644 --- a/studio/components/interfaces/Billing/ProUpgrade.tsx +++ b/studio/components/interfaces/Billing/ProUpgrade.tsx @@ -1,8 +1,7 @@ import { FC, useEffect, useRef, useState } from 'react' import { Transition } from '@headlessui/react' import { useRouter } from 'next/router' -import * as Tooltip from '@radix-ui/react-tooltip' -import { Button, IconHelpCircle, Toggle, Modal } from 'ui' +import { IconHelpCircle, Toggle } from 'ui' import { useStore } from 'hooks' import { post, patch } from 'lib/common/fetch' @@ -30,6 +29,7 @@ import { import BackButton from 'components/ui/BackButton' import SupportPlan from './AddOns/SupportPlan' import HCaptcha from '@hcaptcha/react-hcaptcha' +import SpendCapModal from './SpendCapModal' // Do not allow compute size changes for af-south-1 @@ -263,7 +263,7 @@ const ProUpgrade: FC = ({
    -

    Enable spend cap

    +

    Spend cap

    = ({ />

    - If enabled, additional resources will not be charged on a per-usage basis + By default, Pro projects have a spend cap enabled to control costs and prevent + exceeding limits. This restricts usage upon exhausting the quota. To scale + without restrictions, disable the spend cap and be charged for overusage beyond + the quota.

    = ({ /> {/* Spend caps helper modal */} - setShowSpendCapHelperModal(false)} - > -
    - -
    -

    - A spend cap allows you to restrict your project's resource usage within the limits - of the Pro tier. Disabling the spend cap will then remove those limits and any - additional resources consumed beyond the Pro tier limits will be charged on a - per-usage basis -

    -

    - The table below shows an overview of which resources are chargeable, and how they - are charged: -

    - {/* Maybe instead of a table, show something more interactive like a spend cap playground */} - {/* Maybe ideate this in Figma first but this is good enough for now */} -
    -
    -

    Item

    -

    Limit

    -

    Rate

    -
    -
    -
    -

    Database size

    -

    8GB

    -

    $0.125/GB

    -
    -
    -

    Data transfer

    -

    50GB

    -

    $0.09/GB

    -
    -
    -
    -
    -
    -

    Auth MAUs

    - - - - - - -
    - - Monthly Active Users: A user that has made an API request in the last - month - -
    -
    -
    -
    -

    100,000

    -

    $0.00325/user

    -
    -
    -
    -
    -

    Storage size

    -

    100GB

    -

    $0.021/GB

    -
    -
    -

    Data Transfer

    -

    50GB

    -

    $0.09/GB

    -
    -
    -
    -
    -
    - - -
    - -
    -
    -
    -
    + onHide={() => setShowSpendCapHelperModal(false)} + /> ) } diff --git a/studio/components/interfaces/Billing/SpendCapModal.tsx b/studio/components/interfaces/Billing/SpendCapModal.tsx new file mode 100644 index 00000000000..fddf841e0dd --- /dev/null +++ b/studio/components/interfaces/Billing/SpendCapModal.tsx @@ -0,0 +1,153 @@ +import { FC } from 'react' +import * as Tooltip from '@radix-ui/react-tooltip' +import { Button, IconHelpCircle, Modal } from 'ui' +import Link from 'next/link' + +interface Props { + visible: boolean + onHide: () => void +} + +const SpendCapModal: FC = ({ visible, onHide }) => { + return ( + onHide()}> +
    + +
    +

    + Enabling the spend cap sets a limit on your usage to stay within your plan's quota, + which controls costs but can limit service. Disabling the spend cap removes these + limits, but any extra usage beyond the plan's limit will be charged per usage. +

    +

    + Take a look at the following table to see which resources are chargeable and how they + are charged: +

    + {/* Maybe instead of a table, show something more interactive like a spend cap playground */} + {/* Maybe ideate this in Figma first but this is good enough for now */} +
    +
    +

    Item

    +

    Limit

    +

    Rate

    +
    +
    +
    +

    Database size

    +

    8GB

    +

    $0.125/GB

    +
    +
    +

    Database egress

    +

    50GB

    +

    $0.09/GB

    +
    +
    +
    +
    +
    +

    Auth MAUs

    + + + + + + + + +
    + + Monthly Active Users: A user that has made an API request in the + last month + +
    +
    +
    +
    +
    +
    +

    100,000

    +

    $0.00325/user

    +
    +
    +
    +
    +

    Storage size

    +

    100GB

    +

    $0.021/GB

    +
    +
    +

    Storage egress

    +

    200GB

    +

    $0.09/GB

    +
    +
    +

    Asset transformations

    +

    100 origin images

    +

    $5 per 1000 origin images

    +
    +
    + +
    +
    +

    Realtime concurrent peak connections

    +

    500

    +

    $10/1000

    +
    + +
    +

    Realtime messages

    +

    5 Million

    +

    $2.50/Million

    +
    +
    + +
    +
    +

    Function invocations

    +

    2 Million

    +

    $2 per Million

    +
    + +
    +

    Function count

    +

    100 functions

    +

    $10 per 100 functions

    +
    +
    +
    + +

    + See{' '} + + + pricing page + + {' '} + for a full overview. +

    +
    +
    + + +
    + +
    +
    +
    +
    + ) +} + +export default SpendCapModal diff --git a/studio/components/interfaces/Billing/Subscription/Subscription.tsx b/studio/components/interfaces/Billing/Subscription/Subscription.tsx index 4bf3bf8ad04..8dc90989675 100644 --- a/studio/components/interfaces/Billing/Subscription/Subscription.tsx +++ b/studio/components/interfaces/Billing/Subscription/Subscription.tsx @@ -97,29 +97,31 @@ const Subscription: FC = ({ {!canUpdateSubscription || projectUpdateDisabled ? ( - - -
    - - {projectUpdateDisabled ? ( - <> - Subscription changes are currently disabled. -
    - Our engineers are working on a fix. - - ) : !canUpdateSubscription ? ( - 'You need additional permissions to amend subscriptions' - ) : ( - '' - )} -
    -
    -
    + + + +
    + + {projectUpdateDisabled ? ( + <> + Subscription changes are currently disabled. +
    + Our engineers are working on a fix. + + ) : !canUpdateSubscription ? ( + 'You need additional permissions to amend subscriptions' + ) : ( + '' + )} +
    +
    +
    +
    ) : ( <> )} diff --git a/studio/components/interfaces/Database/Backups/PITR/PITRNotice.tsx b/studio/components/interfaces/Database/Backups/PITR/PITRNotice.tsx index daab4106406..fecbf2eafd1 100644 --- a/studio/components/interfaces/Database/Backups/PITR/PITRNotice.tsx +++ b/studio/components/interfaces/Database/Backups/PITR/PITRNotice.tsx @@ -34,19 +34,21 @@ const PITRNotice = ({}) => { {!canUpdateSubscription && ( - - -
    - - You need additional permissions to amend subscriptions - -
    -
    + + + +
    + + You need additional permissions to amend subscriptions + +
    +
    +
    )}
    diff --git a/studio/components/interfaces/Database/Backups/PITR/PITRSelection.tsx b/studio/components/interfaces/Database/Backups/PITR/PITRSelection.tsx index 6ed84243dd2..bba2f68b3f4 100644 --- a/studio/components/interfaces/Database/Backups/PITR/PITRSelection.tsx +++ b/studio/components/interfaces/Database/Backups/PITR/PITRSelection.tsx @@ -192,19 +192,21 @@ const PITRSelection = ({}) => { {isSelectedOutOfRange && ( - - -
    - - Selected date is out of range where backups are available - -
    -
    + + + +
    + + Selected date is out of range where backups are available + +
    +
    +
    )}
    diff --git a/studio/components/interfaces/Database/Backups/PITR/PITRStatus.tsx b/studio/components/interfaces/Database/Backups/PITR/PITRStatus.tsx index b634e1365c7..86983a22ba6 100644 --- a/studio/components/interfaces/Database/Backups/PITR/PITRStatus.tsx +++ b/studio/components/interfaces/Database/Backups/PITR/PITRStatus.tsx @@ -55,19 +55,21 @@ const PITRStatus: FC = ({ selectedTimezone, onUpdateTimezone, onSetConfig {!canTriggerPhysicalBackup && ( - - -
    - - You need additional permissions to trigger a PITR recovery - -
    -
    + + + +
    + + You need additional permissions to trigger a PITR recovery + +
    +
    +
    )} diff --git a/studio/components/interfaces/Database/Backups/PITR/TimeInput.tsx b/studio/components/interfaces/Database/Backups/PITR/TimeInput.tsx index 3b1d4689e74..90b2b7e6032 100644 --- a/studio/components/interfaces/Database/Backups/PITR/TimeInput.tsx +++ b/studio/components/interfaces/Database/Backups/PITR/TimeInput.tsx @@ -110,17 +110,19 @@ const TimeInput: FC = ({ defaultTime, minimumTime, maximumTime, onChange className="w-full text-sm bg-transparent p-0 text-center outline-none border-none focus:ring-0" /> - - -
    - Hours (HH) -
    -
    + + + +
    + Hours (HH) +
    +
    +
    : @@ -138,17 +140,19 @@ const TimeInput: FC = ({ defaultTime, minimumTime, maximumTime, onChange className="w-full text-sm bg-transparent p-0 text-center outline-none border-none focus:ring-0" /> - - -
    - Minutes (MM) -
    -
    + + + +
    + Minutes (MM) +
    +
    +
    : @@ -166,17 +170,19 @@ const TimeInput: FC = ({ defaultTime, minimumTime, maximumTime, onChange className="w-full text-sm bg-transparent p-0 text-center outline-none border-none focus:ring-0" /> - - -
    - Seconds (SS) -
    -
    + + + +
    + Seconds (SS) +
    +
    +
    {error &&

    {error}

    } diff --git a/studio/components/interfaces/Database/Extensions/Extensions.tsx b/studio/components/interfaces/Database/Extensions/Extensions.tsx index 17000a747ec..d53fc0d3026 100644 --- a/studio/components/interfaces/Database/Extensions/Extensions.tsx +++ b/studio/components/interfaces/Database/Extensions/Extensions.tsx @@ -7,7 +7,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useStore, checkPermissions, useParams } from 'hooks' import ExtensionCard from './ExtensionCard' import { HIDDEN_EXTENSIONS } from './Extensions.constants' -import NoSearchResults from 'components/to-be-cleaned/NoSearchResults' +import NoSearchResults from 'components/ui/NoSearchResults' import InformationBox from 'components/ui/InformationBox' import Link from 'next/link' @@ -69,7 +69,9 @@ const Extensions: FC = ({}) => { - {extensions.length === 0 && } + {extensions.length === 0 && ( + setFilterString('')} /> + )}
    {enabledExtensions.length > 0 && ( diff --git a/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx b/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx index a4d9cdc8423..6987deebd2d 100644 --- a/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx +++ b/studio/components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx @@ -89,19 +89,21 @@ const FunctionList: FC = ({
    diff --git a/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx b/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx index df69111db50..d9036ab8fa6 100644 --- a/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx +++ b/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx @@ -90,19 +90,23 @@ const FunctionsList: FC = ({ {!canCreateFunctions && ( - - -
    - - You need additional permissions to create functions - -
    -
    + + + + +
    + + You need additional permissions to create functions + +
    +
    +
    +
    )} diff --git a/studio/components/interfaces/Database/Hooks/CreateHook/CheckboxEvents.tsx b/studio/components/interfaces/Database/Hooks/CreateHook/CheckboxEvents.tsx deleted file mode 100644 index cf4825e3326..00000000000 --- a/studio/components/interfaces/Database/Hooks/CreateHook/CheckboxEvents.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { FC, useContext } from 'react' -import { observer } from 'mobx-react-lite' -import { Checkbox } from 'ui' -import { union, without } from 'lodash' -import { CreateHookContext } from './' - -const CheckboxEvents: FC = observer(({}) => { - const _localState: any = useContext(CreateHookContext) - return ( - // @ts-ignore - { - const temp = _localState.formState.events.value - const value = e.target.checked - ? union(temp, [e.target.value]) - : without(temp, e.target.value) - _localState.onFormChange({ - key: 'events', - value: value, - }) - }} - error={_localState.formState.events.error} - labelOptional="The type of events that will trigger your function hook" - descriptionText="These are the events that are watched by the function hook, only the events selected above will fire the function hook on the table you've selected." - > - - - - - ) -}) - -export default CheckboxEvents diff --git a/studio/components/interfaces/Database/Hooks/CreateHook/InputName.tsx b/studio/components/interfaces/Database/Hooks/CreateHook/InputName.tsx deleted file mode 100644 index ba2f3b9ee61..00000000000 --- a/studio/components/interfaces/Database/Hooks/CreateHook/InputName.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { FC, useContext } from 'react' -import { observer } from 'mobx-react-lite' -import { Input } from 'ui' -import { CreateHookContext } from './' - -const InputName: FC = observer(({}) => { - const _localState: any = useContext(CreateHookContext) - return ( - - _localState.onFormChange({ - key: 'name', - value: e.target.value, - }) - } - size="medium" - error={_localState.formState.name.error} - /> - ) -}) - -export default InputName diff --git a/studio/components/interfaces/Database/Hooks/CreateHook/RadioGroupHookService/RadioGroupHookService.constants.ts b/studio/components/interfaces/Database/Hooks/CreateHook/RadioGroupHookService/RadioGroupHookService.constants.ts deleted file mode 100644 index 4b292673fb4..00000000000 --- a/studio/components/interfaces/Database/Hooks/CreateHook/RadioGroupHookService/RadioGroupHookService.constants.ts +++ /dev/null @@ -1,41 +0,0 @@ -export const hookServiceOptions: { - id: string - label: string - badge: string - badgeType: 'brand' | 'amber' - description: string - img_url: string -}[] = [ - { - id: 'http_request', - label: 'HTTP Request', - badge: 'Alpha', - badgeType: 'brand', - description: 'Send an HTTP request to any URL.', - img_url: 'http-request.png', - }, - { - id: 'supabase_function', - label: 'Supabase Function', - badge: 'Coming soon', - badgeType: 'amber', - description: 'Choose a Supabase Function to run.', - img_url: 'supabase-severless-function.png', - }, - { - id: 'google_cloud_function', - label: 'Google cloud run', - badge: 'Coming soon', - badgeType: 'amber', - description: 'Choose a google cloud function to run', - img_url: 'google-cloud-run.png', - }, - { - id: 'aws_lambda_function', - label: 'AWS Lambda', - badge: 'Coming soon', - badgeType: 'amber', - description: 'Choose an AWS Lambda function to run.', - img_url: 'aws-lambda-severless-function.png', - }, -] diff --git a/studio/components/interfaces/Database/Hooks/CreateHook/RadioGroupHookService/RadioHookService.tsx b/studio/components/interfaces/Database/Hooks/CreateHook/RadioGroupHookService/RadioHookService.tsx deleted file mode 100644 index 21331ac5ad7..00000000000 --- a/studio/components/interfaces/Database/Hooks/CreateHook/RadioGroupHookService/RadioHookService.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import Image from 'next/image' -import { FC, useContext } from 'react' -import { observer } from 'mobx-react-lite' -import { Radio, Badge } from 'ui' -import { CreateHookContext } from '../' - -interface Props { - id: string - description: string - label: string - img_url: string - badgeType: 'brand' | 'amber' - badge: string -} - -const RadioHookService: FC = observer( - ({ id, description, label, img_url, badgeType, badge }) => { - const _localState: any = useContext(CreateHookContext) - return ( - -
    - {/*
    */} - - {/*
    */} -
    -
    - {label} - {badge} -
    - {description} -
    -
    - - } - /> - ) - } -) - -export default RadioHookService diff --git a/studio/components/interfaces/Database/Hooks/CreateHook/RadioGroupHookService/index.tsx b/studio/components/interfaces/Database/Hooks/CreateHook/RadioGroupHookService/index.tsx deleted file mode 100644 index 17afba4014a..00000000000 --- a/studio/components/interfaces/Database/Hooks/CreateHook/RadioGroupHookService/index.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { FC, useContext } from 'react' -import { observer } from 'mobx-react-lite' -import { Radio } from 'ui' - -import { CreateHookContext } from '../' -import RadioHookService from './RadioHookService' -import { hookServiceOptions } from './RadioGroupHookService.constants' - -const RadioGroupHookService: FC = observer(({}) => { - const _localState: any = useContext(CreateHookContext) - return ( - <> -
    - { - _localState.onFormChange({ - key: 'hookService', - value: event.target.value, - }) - }} - value={_localState.formState.hookService.value} - error={_localState.formState.hookService.error} - > - {hookServiceOptions.map((x) => ( - - ))} - -
    - - ) -}) - -export default RadioGroupHookService diff --git a/studio/components/interfaces/Database/Hooks/CreateHook/SelectEnabledMode.tsx b/studio/components/interfaces/Database/Hooks/CreateHook/SelectEnabledMode.tsx deleted file mode 100644 index 7c114d7d735..00000000000 --- a/studio/components/interfaces/Database/Hooks/CreateHook/SelectEnabledMode.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { FC, useContext } from 'react' -import { observer } from 'mobx-react-lite' -import { Select } from 'ui' -import { CreateHookContext } from './' - -const SelectEnabledMode: FC = observer(({}) => { - const _localState: any = useContext(CreateHookContext) - return ( - - ) -}) - -export default SelectEnabledMode diff --git a/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/InputMultiServiceHeaders.tsx b/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/InputMultiServiceHeaders.tsx deleted file mode 100644 index 84be5e4695b..00000000000 --- a/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/InputMultiServiceHeaders.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { FC, useContext } from 'react' -import { observer } from 'mobx-react-lite' -import { Button, IconPlus } from 'ui' - -import { CreateHookContext } from '../' -import InputServiceHeader from './InputServiceHeader' - -const InputMultiServiceHeaders: FC = observer(({}) => { - const _localState: any = useContext(CreateHookContext) - - function onAddArgument() { - _localState.onFormArrayChange({ - key: 'serviceHeaders', - value: { name: '', value: '' }, - operation: 'add', - }) - } - - return ( -
    -
    -
    HTTP Headers
    -
    -
    - {_localState.formState.serviceHeaders.value.map( - ( - x: { name: string; value: string; error?: { name?: string; value?: string } }, - idx: number - ) => ( - - ) - )} -
    - -
    -
    -
    - ) -}) - -export default InputMultiServiceHeaders diff --git a/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/InputMultiServiceParams.tsx b/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/InputMultiServiceParams.tsx deleted file mode 100644 index 576a1cfd83e..00000000000 --- a/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/InputMultiServiceParams.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { FC, useContext } from 'react' -import { observer } from 'mobx-react-lite' -import { Button, IconPlus } from 'ui' - -import { CreateHookContext } from '../' -import InputServiceParam from './InputServiceParam' - -const InputMultiServiceParams: FC = observer(({}) => { - const _localState: any = useContext(CreateHookContext) - - function onAddArgument() { - _localState.onFormArrayChange({ - key: 'serviceParams', - value: { name: '', value: '' }, - operation: 'add', - }) - } - - return ( -
    -
    -
    HTTP Params
    -
    -
    - {_localState.formState.serviceParams.value.map( - ( - x: { name: string; value: string; error?: { name?: string; value?: string } }, - idx: number - ) => ( - - ) - )} - -
    -
    - ) -}) - -export default InputMultiServiceParams diff --git a/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/InputServiceHeader.tsx b/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/InputServiceHeader.tsx deleted file mode 100644 index d9e587b4a48..00000000000 --- a/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/InputServiceHeader.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { FC, useContext, FormEvent } from 'react' -import { observer } from 'mobx-react-lite' -import { Input, IconTrash, Button } from 'ui' -import { CreateHookContext } from '../' - -interface Props { - idx: number - name: string - value: string - error?: { name?: string; value?: string } -} - -const InputServiceHeader: FC = observer(({ idx, name, value, error }) => { - const _localState: any = useContext(CreateHookContext) - - function onNameChange(e: FormEvent) { - const _value = e.currentTarget.value - _localState.onFormArrayChange({ - key: 'serviceHeaders', - value: { name: _value, value }, - idx, - operation: 'update', - }) - } - - function onValueChange(e: FormEvent) { - const _value = e.currentTarget.value - _localState.onFormArrayChange({ - key: 'serviceHeaders', - value: { name, value: _value }, - idx, - operation: 'update', - }) - } - - function onDelete() { - _localState.onFormArrayChange({ - key: 'serviceHeaders', - idx, - operation: 'delete', - }) - } - - return ( -
    - - -
    -
    -
    - ) -}) - -export default InputServiceHeader diff --git a/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/InputServiceParam.tsx b/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/InputServiceParam.tsx deleted file mode 100644 index 4045966d5ba..00000000000 --- a/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/InputServiceParam.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { FC, useContext, FormEvent } from 'react' -import { observer } from 'mobx-react-lite' -import { Input, IconTrash, Button } from 'ui' -import { CreateHookContext } from '../' - -interface Props { - idx: number - name: string - value: string - error?: { name?: string; value?: string } -} - -const InputServiceParam: FC = observer(({ idx, name, value, error }) => { - const _localState: any = useContext(CreateHookContext) - - function onNameChange(e: FormEvent) { - const _value = e.currentTarget.value - _localState.onFormArrayChange({ - key: 'serviceParams', - value: { name: _value, value }, - idx, - operation: 'update', - }) - } - - function onValueChange(e: FormEvent) { - const _value = e.currentTarget.value - _localState.onFormArrayChange({ - key: 'serviceParams', - value: { name, value: _value }, - idx, - operation: 'update', - }) - } - - function onDelete() { - _localState.onFormArrayChange({ - key: 'serviceParams', - idx, - operation: 'delete', - }) - } - - return ( -
    - - -
    -
    -
    - ) -}) - -export default InputServiceParam diff --git a/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/InputServiceTimeout.tsx b/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/InputServiceTimeout.tsx deleted file mode 100644 index ae255fb0be6..00000000000 --- a/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/InputServiceTimeout.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { FC, useContext } from 'react' -import { observer } from 'mobx-react-lite' -import { Input } from 'ui' -import { CreateHookContext } from '../' - -// Not used currently - -const InputServiceTimeout: FC = observer(({}) => { - const _localState: any = useContext(CreateHookContext) - return ( - - _localState.onFormChange({ - key: 'serviceTimeoutMs', - value: e.target.value, - }) - } - size="medium" - error={_localState.formState.serviceTimeoutMs.error} - /> - ) -}) - -export default InputServiceTimeout diff --git a/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/InputServiceUrl.tsx b/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/InputServiceUrl.tsx deleted file mode 100644 index f65aaa19efe..00000000000 --- a/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/InputServiceUrl.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { FC, useContext } from 'react' -import { observer } from 'mobx-react-lite' -import { Input } from 'ui' -import { CreateHookContext } from '../' - -const InputServiceUrl: FC = observer(({}) => { - const _localState: any = useContext(CreateHookContext) - return ( - - _localState.onFormChange({ - key: 'serviceUrl', - value: e.target.value, - }) - } - size="medium" - error={_localState.formState.serviceUrl.error} - /> - ) -}) - -export default InputServiceUrl diff --git a/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/SelectServiceMethod.tsx b/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/SelectServiceMethod.tsx deleted file mode 100644 index fca6069eb39..00000000000 --- a/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/SelectServiceMethod.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { FC, useContext } from 'react' -import { observer } from 'mobx-react-lite' -import { Select } from 'ui' -import { CreateHookContext } from '../' - -const SelectServiceMethod: FC = observer(({}) => { - const _localState: any = useContext(CreateHookContext) - return ( - - ) -}) - -export default SelectServiceMethod diff --git a/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/ServiceUnavailableBox.tsx b/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/ServiceUnavailableBox.tsx deleted file mode 100644 index 59e5ae128a8..00000000000 --- a/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/ServiceUnavailableBox.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { FC, useContext } from 'react' -import { observer } from 'mobx-react-lite' -import { Alert, Button } from 'ui' -import { CreateHookContext } from '../' - -const ServiceUnavailableBox: FC = observer(({}) => { - const _localState: any = useContext(CreateHookContext) - return ( - -
    -
    We currently do not support this service.
    - -
    -
    - ) -}) - -export default ServiceUnavailableBox diff --git a/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/index.tsx b/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/index.tsx deleted file mode 100644 index 7570f170706..00000000000 --- a/studio/components/interfaces/Database/Hooks/CreateHook/ServiceConfigForm/index.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { FC, useContext } from 'react' -import { observer } from 'mobx-react-lite' -import { SidePanel } from 'ui' - -import { CreateHookContext } from '../' -import SelectServiceMethod from './SelectServiceMethod' -import InputServiceUrl from './InputServiceUrl' -import InputMultiServiceHeaders from './InputMultiServiceHeaders' -import InputMultiServiceParams from './InputMultiServiceParams' -import ServiceUnavailableBox from './ServiceUnavailableBox' - -const ServiceConfigForm: FC = observer(({}) => { - const _localState: any = useContext(CreateHookContext) - return ( - <> - {_localState.formState.hookService.value === 'http_request' ? ( - <> - -
    -
    -
    HTTP Request
    - - -
    - -
    - -
    - -
    - -
    -
    - - ) : ( -
    - -
    - )} - - ) -}) - -export default ServiceConfigForm diff --git a/studio/components/interfaces/Database/Hooks/CreateHook/TableSelection.tsx b/studio/components/interfaces/Database/Hooks/CreateHook/TableSelection.tsx deleted file mode 100644 index e1382da2597..00000000000 --- a/studio/components/interfaces/Database/Hooks/CreateHook/TableSelection.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import Link from 'next/link' -import SVG from 'react-inlinesvg' -import { FC, useContext } from 'react' -import { observer } from 'mobx-react-lite' -import { Input, Listbox } from 'ui' - -import { CreateHookContext } from './' -import { useStore } from 'hooks' - -const TableSelection: FC = observer(({}) => { - const { ui } = useStore() - const projectRef = ui.selectedProject?.ref - const _localState: any = useContext(CreateHookContext) - - if (_localState.tables.length === 0) { - return ( - - - This is the table the trigger will watch for changes. There's currently no tables - created - please create one{' '} - - - here - - first. -

    - } - /> - ) - } - - return ( - { - const _table = _localState.tables.find((x: any) => x.id === id) - if (_table) { - _localState.onFormChange({ - key: 'schema', - value: _table.schema, - }) - _localState.onFormChange({ - key: 'table', - value: _table.name, - }) - _localState.onFormChange({ - key: 'tableId', - value: id, - }) - } - }} - size="medium" - error={_localState.formState.table.error} - descriptionText="This is the table the trigger will watch for changes. You can only select 1 table for a trigger." - > - {_localState.tables.map((x: any) => { - return ( - ( -
    - - code.replace(/svg/, 'svg class="m-auto text-color-inherit"') - } - /> -
    - )} - > -
    -

    {x.name}

    -

    {x.schema}

    -
    -
    - ) - })} -
    - ) -}) - -export default TableSelection diff --git a/studio/components/interfaces/Database/Hooks/CreateHook/index.tsx b/studio/components/interfaces/Database/Hooks/CreateHook/index.tsx deleted file mode 100644 index 5de69a46e61..00000000000 --- a/studio/components/interfaces/Database/Hooks/CreateHook/index.tsx +++ /dev/null @@ -1,512 +0,0 @@ -import { makeAutoObservable } from 'mobx' -import { FC, useEffect, createContext } from 'react' -import { observer, useLocalObservable } from 'mobx-react-lite' -import { isEmpty, mapValues, has, filter, keyBy, isUndefined } from 'lodash' -import { Dictionary } from 'components/grid' -import { Badge, Input, SidePanel } from 'ui' -import { useStore } from 'hooks' - -import InputName from './InputName' -import SelectEnabledMode from './SelectEnabledMode' -import CheckboxEvents from './CheckboxEvents' -import TableSelection from './TableSelection' -import RadioGroupHookService from './RadioGroupHookService' -import ServiceConfigForm from './ServiceConfigForm' - -import { formatArguments, convertKeyValue, hasWhitespace, isValidHttpUrl } from '../Hooks.utils' - -// [Joshen] TODO: Refactor to use Form component - -class CreateHookFormState { - id: number | undefined - originalName: string | undefined - // @ts-ignore - enabledMode: { value: string } - // @ts-ignore - events: { value: string[]; error?: string } - // @ts-ignore - name: { value: string; error?: string } - // @ts-ignore - schema: { value: string } - // @ts-ignore - table: { value: string; error?: string } - // @ts-ignore - tableId: { value: number; error?: string } - // @ts-ignore - hookService: { value: string; error?: string } - // @ts-ignore - serviceUrl: { value: string; error?: string } - // @ts-ignore - serviceMethod: { value: string; error?: string } - // @ts-ignore - serviceHeaders: { - value: { name: string; value: string; error?: { name?: string; value?: string } }[] - } - // @ts-ignore - serviceParams: { - value: { name: string; value: string; error?: { name?: string; value?: string } }[] - } - // @ts-ignore - serviceTimeoutMs: { value: string; error?: string } - - constructor() { - makeAutoObservable(this) - this.reset() - } - - get requestBody() { - return { - id: this.id, - activation: 'AFTER', - enabled_mode: this.enabledMode.value, - events: this.events.value, - function_args: [ - this.serviceUrl.value.replaceAll(' ', ''), - this.serviceMethod.value, - JSON.stringify(mapValues(keyBy(this.serviceHeaders.value, 'name'), 'value')), - formatArguments(this.serviceParams.value), - this.serviceTimeoutMs.value, - ], - function_name: 'http_request', - function_schema: 'supabase_functions', - orientation: 'ROW', - name: this.name.value, - schema: this.schema.value, - table: this.table.value, - table_id: this.tableId.value, - } - } - - reset(hook?: Dictionary) { - this.id = hook?.id - this.originalName = hook?.name - this.enabledMode = { value: hook?.enabled_mode ?? 'ORIGIN' } - this.events = { value: hook?.events ?? [] } - this.name = { value: hook?.name ?? '' } - this.schema = { value: hook?.schema ?? '' } - this.table = { value: hook?.table ?? '' } - this.tableId = { value: hook?.table_id ?? '' } - // TODO: hook obj doesn't have service field - this.hookService = { value: 'http_request' } - /** - * hook.function_args contains 5 params in order - * [url, method, headers, params, timeout] - */ - this.serviceUrl = { value: hook?.function_args?.length == 5 ? hook?.function_args[0] : '' } - this.serviceMethod = { - value: hook?.function_args?.length == 5 ? hook?.function_args[1] : 'GET', - } - this.serviceHeaders = - hook?.function_args?.length == 5 - ? convertKeyValue(hook?.function_args[2]) - : { value: [{ name: 'Content-type', value: 'application/json' }] } - - this.serviceParams = - hook?.function_args?.length == 5 ? convertKeyValue(hook?.function_args[3]) : { value: [] } - - this.serviceTimeoutMs = { - value: hook?.function_args?.length == 5 ? hook?.function_args[4] : '1000', - } - } - - update(state: Dictionary) { - this.enabledMode = state.enabledMode - this.events = state.events - this.name = state.name - this.schema = state.schema - this.table = state.table - this.tableId = state.tableId - this.hookService = state.hookService - this.serviceUrl = state.serviceUrl - this.serviceMethod = state.serviceMethod - this.serviceHeaders = state.serviceHeaders - this.serviceParams = state.serviceParams - this.serviceTimeoutMs = state.serviceTimeoutMs - } -} - -interface ICreateHookStore { - loading: boolean - formState: CreateHookFormState - meta: any - project: any - tables: Dictionary[] - onFormChange: (value: { key: string; value: any }) => void - onFormArrayChange: (value: { - operation: 'add' | 'delete' | 'update' - key: string - idx?: number - value?: any - }) => void - setDefaultSelectedTable: () => void - setLoading: (value: boolean) => void - setTables: (value: Dictionary[]) => void - validateForm: () => boolean -} - -class CreateHookStore implements ICreateHookStore { - loading = false - formState = new CreateHookFormState() - meta = null - project = null - tables = [] - - constructor() { - makeAutoObservable(this) - } - - get title() { - return this.formState.id - ? `Edit '${this.formState.originalName}' database webhook` - : 'Add a new database webhook' - } - - get isEditing() { - return this.formState.id != undefined - } - - // set first table as default selection - setDefaultSelectedTable = () => { - if (this.tables?.length != 0) { - this.formState.schema.value = (this.tables[0] as any).schema - this.formState.table.value = (this.tables[0] as any).name - this.formState.tableId.value = (this.tables[0] as any).id - } - } - - setLoading = (value: boolean) => { - this.loading = value - } - - setTables = (value: Dictionary[]) => { - this.tables = value as any - this.setDefaultSelectedTable() - } - - onFormChange = ({ key, value }: { key: string; value: any }) => { - if (has(this.formState, key)) { - const temp = (this.formState as any)[key] as any - ;(this.formState as any)[key] = { ...temp, value, error: undefined } - } else { - ;(this.formState as any)[key] = { value } - } - } - - onFormArrayChange = ({ - operation, - key, - idx, - value, - }: { - operation: 'add' | 'delete' | 'update' - key: string - idx?: number - value?: any - }) => { - switch (operation) { - case 'add': { - if (has(this.formState, key)) { - ;(this.formState as any)[key].value.push(value) - } else { - ;(this.formState as any)[key] = { value: [value] } - } - break - } - case 'delete': { - if (has(this.formState, key)) { - const temp = filter( - (this.formState as any)[key].value, - (_: any, index: number) => index != idx - ) as any - ;(this.formState as any)[key].value = temp - } - break - } - default: { - if (has(this.formState, key) && !isUndefined(idx)) { - ;(this.formState as any)[key].value[idx] = value - } else { - ;(this.formState as any)[key] = { value: [{ value }] } - } - } - } - } - - validateForm = () => { - let isValidated = true - const _state = mapValues(this.formState, (x: { value: any }, key: string) => { - switch (key) { - case 'name': { - if (isEmpty(x.value) || hasWhitespace(x.value)) { - isValidated = false - return { ...x, error: 'Invalid hook name' } - } else { - return x - } - } - case 'events': { - if (isEmpty(x.value)) { - isValidated = false - return { ...x, error: 'Select at least 1 event' } - } else { - return x - } - } - case 'table': { - if (isEmpty(x.value)) { - isValidated = false - return { ...x, error: 'You must choose a table' } - } else { - return x - } - } - case 'hookService': { - if (x.value != 'http_request') { - isValidated = false - return { ...x, error: 'Not supported service' } - } else { - return x - } - } - case 'serviceTimeoutMs': { - if (isEmpty(x.value) || isNaN(x.value)) { - isValidated = false - return { ...x, error: 'Invalid input' } - } else { - return x - } - } - case 'serviceUrl': { - if (isEmpty(x.value) || !isValidHttpUrl(x.value)) { - isValidated = false - return { ...x, error: 'Invalid input' } - } else { - return x - } - } - case 'serviceMethod': { - if (isEmpty(x.value)) { - isValidated = false - return { ...x, error: 'Invalid input' } - } else { - return x - } - } - case 'serviceHeaders': - case 'serviceParams': { - const temp = x.value?.map((i: Dictionary) => { - const error: any = { name: undefined, value: undefined } - if (isEmpty(i.name) || hasWhitespace(i.name)) { - isValidated = false - error.name = 'Invalid name' - } - if (isEmpty(i.value)) { - isValidated = false - error.value = 'Missing value' - } - return { ...i, error } - }) - x.value = temp - return x - } - default: - return x - } - }) - if (!isValidated) { - this.formState.update(_state) - } - return isValidated - } -} - -export const CreateHookContext = createContext(null) - -interface CreateHookProps { - hook?: any - visible: boolean - setVisible: (value: boolean) => void -} - -const CreateHook: FC = ({ hook, visible = true, setVisible }) => { - const { meta, ui } = useStore() - const project = ui.selectedProject - - const _localState: any = useLocalObservable(() => new CreateHookStore()) - _localState.meta = meta - _localState.project = project - - useEffect(() => { - const fetchSchemas = async () => { - await _localState.meta.tables.load() - const tables = _localState!.meta!.tables.list( - (table: any) => !meta.excludedSchemas.includes(table.schema) - ) - _localState.setTables(tables) - } - - fetchSchemas() - }, []) - - useEffect(() => { - if (hook) { - _localState.formState.reset(hook) - } else { - _localState.formState.reset() - _localState.setDefaultSelectedTable() - } - }, [visible, hook]) - - async function handleSubmit() { - try { - if (_localState.validateForm()) { - _localState.setLoading(true) - - const body = _localState.formState.requestBody - - const response: any = _localState.isEditing - ? await _localState.meta.hooks.update(body.id, body) - : await _localState.meta.hooks.create(body) - - if (response.error) { - ui.setNotification({ - category: 'error', - message: `Failed to create hook: ${response.error.message ?? 'Submit request failed'}`, - }) - _localState.setLoading(false) - } else { - ui.setNotification({ - category: 'success', - message: `${_localState.isEditing ? 'Updated' : 'Created new'} hook called ${ - response.name - }`, - }) - _localState.setLoading(false) - setVisible(!visible) - } - } - } catch (error: any) { - ui.setNotification({ category: 'error', message: `Failed to create hook: ${error.message}` }) - _localState.setLoading(false) - } - } - - return ( - setVisible(!visible)} - > - -
    - {_localState.isEditing ? ( - <> -
    - - -
    - - {_localState.formState.serviceHeaders.value.length > 0 && ( - <> - -
    -
    -
    HTTP Headers
    - Read only -
    -
    - {_localState.formState.serviceHeaders.value.map( - (serviceHeader: { name: string; value: string }) => ( -
    - - -
    - ) - )} -
    -
    -

    - Note: You can only edit a webhook's name and mode. To change - other settings, you'll need to delete and recreate the webhook. -

    - - )} - - {_localState.formState.serviceParams.value.length > 0 && ( - <> - -
    -
    -
    HTTP Parameters
    - Read only -
    -
    - {_localState.formState.serviceParams.value.map( - (serviceParam: { name: string; value: string }) => ( -
    - - -
    - ) - )} -
    -
    - - )} - - ) : ( - <> -
    - -
    - -
    -
    Conditions to fire hook
    - - -
    - -
    -
    Type of hook
    - -
    - - - )} -
    -
    -
    - ) -} - -export default observer(CreateHook) diff --git a/studio/components/interfaces/Database/Hooks/DeleteHook.tsx b/studio/components/interfaces/Database/Hooks/DeleteHook.tsx deleted file mode 100644 index 14efa7f8f5b..00000000000 --- a/studio/components/interfaces/Database/Hooks/DeleteHook.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import * as React from 'react' -import { observer } from 'mobx-react-lite' -import { useStore } from 'hooks' -import TextConfirmModal from 'components/ui/Modals/TextConfirmModal' - -type DeleteHookProps = { - hook?: any - visible: boolean - setVisible: (value: boolean) => void -} & any - -const DeleteHook: React.FC = ({ hook, visible, setVisible }) => { - const { ui, meta } = useStore() - const [loading, setLoading] = React.useState(false) - const { id, name, schema } = hook ?? {} - - async function handleDelete() { - try { - setLoading(true) - if (!id) { - throw Error('Invalid hook info') - } - const response: any = await meta.hooks.del(id) - if (response.error) { - throw response.error - } else { - ui.setNotification({ category: 'success', message: `Successfully removed ${name}` }) - setVisible(false) - } - } catch (error: any) { - ui.setNotification({ - category: 'error', - message: `Failed to delete ${name}: ${error.message}`, - }) - } finally { - setLoading(false) - } - } - - return ( - setVisible(!visible)} - onConfirm={handleDelete} - title="Delete this hook" - loading={loading} - confirmLabel={`Delete hook ${name}`} - confirmPlaceholder="Type in name of hook" - confirmString={name} - text={`This will delete your hook called ${name} of schema ${schema}.`} - alert="You cannot recover this hook once it is deleted!" - /> - ) -} - -export default observer(DeleteHook) diff --git a/studio/components/interfaces/Database/Hooks/DeleteHookModal.tsx b/studio/components/interfaces/Database/Hooks/DeleteHookModal.tsx new file mode 100644 index 00000000000..36bf3bbd156 --- /dev/null +++ b/studio/components/interfaces/Database/Hooks/DeleteHookModal.tsx @@ -0,0 +1,67 @@ +import { useState } from 'react' +import { observer } from 'mobx-react-lite' +import { useStore } from 'hooks' +import TextConfirmModal from 'components/ui/Modals/TextConfirmModal' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { useDatabaseTriggerDeleteMutation } from 'data/database-triggers/database-trigger-delete-mutation' +import { PostgresTrigger } from '@supabase/postgres-meta' + +interface DeleteHookModalProps { + visible: boolean + selectedHook?: PostgresTrigger + onClose: () => void +} + +const DeleteHookModal = ({ selectedHook, visible, onClose }: DeleteHookModalProps) => { + const { ui } = useStore() + const [loading, setLoading] = useState(false) + const { id, name, schema } = selectedHook ?? {} + + const { project } = useProjectContext() + const { mutateAsync: deleteDatabaseTrigger } = useDatabaseTriggerDeleteMutation() + + async function handleDelete() { + if (!project) { + return console.error('Project ref is required') + } + if (!id) { + return ui.setNotification({ category: 'error', message: 'Unable find selected hook' }) + } + + try { + setLoading(true) + await deleteDatabaseTrigger({ + id, + projectRef: project.ref, + connectionString: project.connectionString, + }) + ui.setNotification({ category: 'success', message: `Successfully deleted ${name}` }) + onClose() + } catch (error: any) { + ui.setNotification({ + category: 'error', + message: `Failed to delete ${name}: ${error.message}`, + }) + } finally { + setLoading(false) + } + } + + return ( + onClose()} + onConfirm={handleDelete} + title="Delete database webhook" + loading={loading} + confirmLabel={`Delete ${name}`} + confirmPlaceholder="Type in name of webhook" + confirmString={name || ''} + text={`This will delete the webhook "${name}" from the schema "${schema}".`} + alert="You cannot recover this webhook once it is deleted!" + /> + ) +} + +export default observer(DeleteHookModal) diff --git a/studio/components/interfaces/Database/Hooks/EditHookPanel.tsx b/studio/components/interfaces/Database/Hooks/EditHookPanel.tsx new file mode 100644 index 00000000000..bc77c37b07f --- /dev/null +++ b/studio/components/interfaces/Database/Hooks/EditHookPanel.tsx @@ -0,0 +1,482 @@ +import Image from 'next/image' +import { useState, useRef, useEffect } from 'react' +import { PostgresTrigger } from '@supabase/postgres-meta' +import { Button, SidePanel, Form, Input, Listbox, Checkbox, Radio, Badge, Modal } from 'ui' + +import { useParams, useStore } from 'hooks' +import { tryParseJson, uuidv4 } from 'lib/helpers' +import HTTPRequestFields from './HTTPRequestFields' +import { isValidHttpUrl } from './Hooks.utils' +import { AVAILABLE_WEBHOOK_TYPES, HOOK_EVENTS } from './Hooks.constants' +import ConfirmationModal from 'components/ui/ConfirmationModal' +import { FormSection, FormSectionLabel, FormSectionContent } from 'components/ui/Forms' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' + +import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query' +import { useDatabaseTriggerCreateMutation } from 'data/database-triggers/database-trigger-create-mutation' +import { useDatabaseTriggerUpdateMutation } from 'data/database-triggers/database-trigger-update-transaction-mutation' + +export interface EditHookPanelProps { + visible: boolean + selectedHook?: PostgresTrigger + onClose: () => void +} + +export type HTTPArgument = { id: string; name: string; value: string } + +const EditHookPanel = ({ visible, selectedHook, onClose }: EditHookPanelProps) => { + // [Joshen] Need to change to use RQ once Alaister's PR goes in + const { ref } = useParams() + const { meta, ui } = useStore() + const submitRef: any = useRef() + const [isEdited, setIsEdited] = useState(false) + const [isSubmitting, setIsSubmitting] = useState(false) + const [isClosingPanel, setIsClosingPanel] = useState(false) + + // [Joshen] There seems to be some bug between Checkbox.Group within the Form component + // hence why this external state as a temporary workaround + const [events, setEvents] = useState([]) + const [eventsError, setEventsError] = useState() + + // For HTTP request + const [httpHeaders, setHttpHeaders] = useState([]) + const [httpParameters, setHttpParameters] = useState([]) + + const { project } = useProjectContext() + const { data: functions } = useEdgeFunctionsQuery({ projectRef: ref }) + const { mutateAsync: createDatabaseTrigger } = useDatabaseTriggerCreateMutation() + const { mutateAsync: updateDatabaseTrigger } = useDatabaseTriggerUpdateMutation() + + const tables = meta.tables.list().sort((a, b) => (a.schema > b.schema ? 0 : -1)) + const restUrl = ui.selectedProject?.restUrl + const restUrlTld = new URL(restUrl as string).hostname.split('.').pop() + + const isEdgeFunction = (url: string) => + url.includes(`https://${ref}.functions.supabase.${restUrlTld}/`) + + const initialValues = { + name: selectedHook?.name ?? '', + table_id: selectedHook?.table_id ?? '', + http_url: selectedHook?.function_args?.[0] ?? '', + http_method: selectedHook?.function_args?.[1] ?? 'POST', + function_type: isEdgeFunction(selectedHook?.function_args?.[0] ?? '') + ? 'supabase_function' + : 'http_request', + } + + useEffect(() => { + if (visible) { + setIsEdited(false) + setIsSubmitting(false) + setIsClosingPanel(false) + + // Reset form fields outside of the Form context + if (selectedHook !== undefined) { + setEvents(selectedHook.events) + + const [url, method, headers, parameters] = selectedHook.function_args + const formattedHeaders = tryParseJson(headers) || {} + setHttpHeaders( + Object.keys(formattedHeaders).map((key) => { + return { id: uuidv4(), name: key, value: formattedHeaders[key] } + }) + ) + const formattedParameters = tryParseJson(parameters) || {} + setHttpParameters( + Object.keys(formattedParameters).map((key) => { + return { id: uuidv4(), name: key, value: formattedParameters[key] } + }) + ) + } else { + setEvents([]) + setHttpHeaders([{ id: uuidv4(), name: 'Content-type', value: 'application/json' }]) + setHttpParameters([{ id: uuidv4(), name: '', value: '' }]) + } + } + }, [visible, selectedHook]) + + const onClosePanel = () => { + if (isEdited) setIsClosingPanel(true) + else onClose() + } + + const onUpdateSelectedEvents = (event: string) => { + if (events.includes(event)) { + setEvents(events.filter((e) => e !== event)) + } else { + setEvents(events.concat(event)) + } + setEventsError(undefined) + } + + const validate = (values: any) => { + const errors: any = {} + + if (!values.name) { + errors['name'] = 'Please provide a name for your webhook' + } + if (!values.table_id) { + errors['table_id'] = 'Please select a table for which your webhook will trigger from' + } + + if (values.function_type === 'http_request') { + // For HTTP requests + if (!values.http_url) { + errors['http_url'] = 'Please provide a URL' + } else if (!values.http_url.startsWith('http')) { + errors['http_url'] = 'Please include HTTP/HTTPs to your URL' + } else if (!isValidHttpUrl(values.http_url)) { + errors['http_url'] = 'Please provide a valid URL' + } + } else if (values.function_type === 'supabase_function') { + // For Supabase Edge Functions + if (values.http_url.includes('undefined')) { + errors['http_url'] = 'No edge functions available for selection' + } + } + + if (JSON.stringify(values) !== JSON.stringify(initialValues)) setIsEdited(true) + return errors + } + + const onSubmit = async (values: any) => { + if (!project?.ref) { + return console.error('Project ref is required') + } + if (events.length === 0) { + return setEventsError('Please select at least one event') + } + + const selectedTable = meta.tables.byId(values.table_id) + if (!selectedTable) { + return ui.setNotification({ category: 'error', message: 'Unable to find selected table' }) + } + + const serviceTimeoutMs = '1000' + const headers = httpHeaders + .filter((header) => header.name && header.value) + .reduce((a: any, b: any) => { + a[b.name] = b.value + return a + }, {}) + const parameters = httpParameters + .filter((param) => param.name && param.value) + .reduce((a: any, b: any) => { + a[b.name] = b.value + return a + }, {}) + + const payload: any = { + events, + activation: 'AFTER', + orientation: 'ROW', + enabled_mode: 'ORIGIN', + name: values.name, + table: selectedTable.name, + schema: selectedTable.schema, + table_id: values.table_id, + function_name: 'http_request', + function_schema: 'supabase_functions', + function_args: [ + values.http_url, + values.http_method, + JSON.stringify(headers), + JSON.stringify(parameters), + serviceTimeoutMs, + ], + } + + if (selectedHook === undefined) { + try { + setIsSubmitting(true) + await createDatabaseTrigger({ + projectRef: project?.ref, + connectionString: project?.connectionString, + payload, + }) + ui.setNotification({ + category: 'success', + message: `Successfully created new webhook "${values.name}"`, + }) + onClose() + } catch (error: any) { + ui.setNotification({ + error, + category: 'error', + message: `Failed to create webhook: ${error.message}`, + }) + } finally { + setIsSubmitting(false) + } + } else { + try { + setIsSubmitting(true) + await updateDatabaseTrigger({ + projectRef: project?.ref, + connectionString: project?.connectionString, + originalTrigger: selectedHook, + updatedTrigger: payload, + }) + ui.setNotification({ + category: 'success', + message: `Successfully updated webhook "${values.name}"`, + }) + onClose() + } catch (error: any) { + ui.setNotification({ + error, + category: 'error', + message: `Failed to update webhook: ${error.message}`, + }) + } finally { + setIsSubmitting(false) + } + } + } + + return ( + <> + + Update webhook {selectedHook.name} + + ) + } + className="hooks-sidepanel mr-0 transform transition-all duration-300 ease-in-out" + onConfirm={() => {}} + onCancel={() => onClosePanel()} + customFooter={ +
    + + +
    + } + > + + {({ values, resetForm, errors }: any) => { + useEffect(() => { + if (values.function_type === 'http_request') { + if (selectedHook !== undefined) { + const [url, method] = selectedHook.function_args + const updatedValues = { ...values, http_url: url, http_method: method } + resetForm({ values: updatedValues, initialValues: updatedValues }) + } else { + const updatedValues = { ...values, http_url: '' } + resetForm({ values: updatedValues, initialValues: updatedValues }) + } + } else if (values.function_type === 'supabase_function') { + const fnSlug = (functions ?? [])[0]?.slug + const defaultFunctionUrl = `https://${ref}.functions.supabase.${restUrlTld}/${fnSlug}` + const updatedValues = { + ...values, + http_url: isEdgeFunction(values.http_url) ? values.http_url : defaultFunctionUrl, + } + resetForm({ values: updatedValues, initialValues: updatedValues }) + } + }, [values.function_type]) + + return ( +
    + General} + > + + + + + + + Select which table and events will trigger your webhook +

    + } + > + Conditions to fire webhook + + } + > + + + + --- + + {tables.map((table) => ( + +
    +

    {table.schema}

    +

    {table.name}

    +
    +
    + ))} +
    + + {HOOK_EVENTS.map((event) => ( + onUpdateSelectedEvents(event.value)} + /> + ))} + +
    +
    + + + Webhook configuration + + } + > + + + {AVAILABLE_WEBHOOK_TYPES.map((webhook) => ( + + +
    +
    +

    {webhook.label}

    +
    +

    {webhook.description}

    +
    +
    + } + /> + ))} + + + + + + { + if (header) setHttpHeaders(httpHeaders.concat(header)) + else setHttpHeaders(httpHeaders.concat({ id: uuidv4(), name: '', value: '' })) + }} + onUpdateHeader={(idx, property, value) => + setHttpHeaders( + httpHeaders.map((header, i) => { + if (idx === i) return { ...header, [property]: value } + else return header + }) + ) + } + onRemoveHeader={(idx) => setHttpHeaders(httpHeaders.filter((_, i) => idx !== i))} + onAddParameter={() => + setHttpParameters(httpParameters.concat({ id: uuidv4(), name: '', value: '' })) + } + onUpdateParameter={(idx, property, value) => + setHttpParameters( + httpParameters.map((param, i) => { + if (idx === i) return { ...param, [property]: value } + else return param + }) + ) + } + onRemoveParameter={(idx) => + setHttpParameters(httpParameters.filter((_, i) => idx !== i)) + } + /> + + + + + + {errors.http_url &&

    {errors.http_url}

    } + + ) : type === 'supabase_function' && edgeFunctions.length > 0 ? ( + + {edgeFunctions.map((fn) => { + const restUrl = ui.selectedProject?.restUrl + const restUrlTld = new URL(restUrl as string).hostname.split('.').pop() + const functionUrl = `https://${ref}.functions.supabase.${restUrlTld}/${fn.slug}` + + return ( + + {fn.name} + + ) + })} + + ) : null} + + + + HTTP Headers} + > + +
    + {httpHeaders.map((header, idx: number) => ( +
    + onUpdateHeader(idx, 'name', event.target.value)} + /> + onUpdateHeader(idx, 'value', event.target.value)} + /> +
    + ))} +
    + + {type === 'supabase_function' && ( + + onAddHeader({ + id: uuidv4(), + name: ' Authorization', + value: `Bearer ${anonKey}`, + }) + } + > +
    +

    + Add authorization header with anon key +

    +

    + Required if your edge function enforces JWT verification +

    +
    + , + , + + onAddHeader({ + id: uuidv4(), + name: ' x-supabase-webhook-source', + value: `[Use a secret value]`, + }) + } + > +
    +

    Add custom source header

    +

    + Useful to verify that the edge function was triggered from this webhook +

    +
    +
    , + ]} + > +
    +
    +
    +
    + + HTTP Parameters} + > + +
    + {httpParameters.map((parameter, idx: number) => ( +
    + onUpdateParameter(idx, 'name', event.target.value)} + /> + onUpdateParameter(idx, 'value', event.target.value)} + /> +
    + ))} +
    + +
    +
    +
    +
    + + ) +} + +export default HTTPRequestFields diff --git a/studio/components/interfaces/Database/Hooks/Hooks.constants.ts b/studio/components/interfaces/Database/Hooks/Hooks.constants.ts new file mode 100644 index 00000000000..1fe98a85928 --- /dev/null +++ b/studio/components/interfaces/Database/Hooks/Hooks.constants.ts @@ -0,0 +1,49 @@ +import { BASE_PATH } from 'lib/constants' + +export const ENABLED_MODE_OPTIONS = [ + { label: 'Origin', value: 'ORIGIN', description: 'This is the default behaviour' }, + { + label: 'Replica', + value: 'REPLICA', + description: 'Will only fire if the session is in “replica” mode', + }, + { + label: 'Always', + value: 'ALWAYS', + description: 'Will fire regardless of the current replication role', + }, + { label: 'Disabled', value: 'DISABLED', description: 'Will not fire' }, +] + +export const HOOK_EVENTS = [ + { + label: 'Insert', + value: 'INSERT', + description: 'Any insert operation on the table', + }, + { + label: 'Update', + value: 'UPDATE', + description: 'Any update operation, of any column in the table', + }, + { + label: 'Delete', + value: 'DELETE', + description: 'Any deletion of a record', + }, +] + +export const AVAILABLE_WEBHOOK_TYPES = [ + { + value: 'http_request', + icon: `${BASE_PATH}/img/function-providers/http-request.png`, + label: 'HTTP Request', + description: 'Send an HTTP request to any URL.', + }, + { + value: 'supabase_function', + icon: `${BASE_PATH}/img/function-providers/supabase-severless-function.png`, + label: 'Supabase Edge Functions', + description: 'Choose a Supabase edge function to run.', + }, +] diff --git a/studio/components/interfaces/Database/Hooks/HooksList/HookList.tsx b/studio/components/interfaces/Database/Hooks/HooksList/HookList.tsx index e28a7e373d3..afccb7f5b5e 100644 --- a/studio/components/interfaces/Database/Hooks/HooksList/HookList.tsx +++ b/studio/components/interfaces/Database/Hooks/HooksList/HookList.tsx @@ -1,3 +1,4 @@ +import Image from 'next/image' import { FC } from 'react' import { includes } from 'lodash' import { observer } from 'mobx-react-lite' @@ -5,8 +6,11 @@ import * as Tooltip from '@radix-ui/react-tooltip' import { PermissionAction } from '@supabase/shared-types/out/constants' import { Badge, Button, Dropdown, IconMoreVertical, IconTrash, IconEdit3 } from 'ui' -import { checkPermissions, useStore } from 'hooks' +import { checkPermissions, useParams, useStore } from 'hooks' +import { BASE_PATH } from 'lib/constants' import Table from 'components/to-be-cleaned/Table' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { useDatabaseHooks } from 'data/database-triggers/database-triggers-query' interface Props { schema: string @@ -21,89 +25,108 @@ const HookList: FC = ({ editHook = () => {}, deleteHook = () => {}, }) => { - const { meta } = useStore() - const hooks = meta.hooks.list() - const filteredHooks = hooks.filter((x: any) => - includes(x.name.toLowerCase(), filterString.toLowerCase()) + const { ui } = useStore() + const { ref } = useParams() + const { project } = useProjectContext() + const { data: hooks } = useDatabaseHooks({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + + const restUrl = ui.selectedProject?.restUrl + const restUrlTld = new URL(restUrl as string).hostname.split('.').pop() + + const filteredHooks = (hooks ?? []).filter( + (x: any) => includes(x.name.toLowerCase(), filterString.toLowerCase()) && x.schema === schema ) - const _hooks = filteredHooks.filter((x: any) => x.schema == schema) const canUpdateWebhook = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'triggers') - function onEdit(trigger: any) { - editHook(trigger) - } - - function onDelete(trigger: any) { - deleteHook(trigger) - } - return ( <> - {_hooks.map((x: any) => ( - - -

    {x.name}

    -
    - -

    {x.table}

    -
    - -

    + {filteredHooks.map((x: any) => { + const isEdgeFunction = (url: string) => + url.includes(`https://${ref}.functions.supabase.${restUrlTld}/`) + const [url, method] = x.function_args + + return ( + + +

    + +

    {x.name}

    +
    +
    + +

    {x.table}

    +
    + {x.events.map((event: string) => ( {event} ))} -

    -
    - -

    - {x.function_args[0]} -

    -
    - -
    - {canUpdateWebhook ? ( - - } onClick={() => onEdit(x)}> - Edit hook - - } - onClick={() => onDelete(x)} - > - Delete hook - - - } - > -
    -
    -
    - ))} + + +

    + {method}: {url} +

    +
    + +
    + {canUpdateWebhook ? ( + + } onClick={() => editHook(x)}> + Edit hook + + } + onClick={() => deleteHook(x)} + > + Delete hook + + + } + > +
    +
    + + ) + })} ) } diff --git a/studio/components/interfaces/Database/Hooks/HooksList/HooksList.tsx b/studio/components/interfaces/Database/Hooks/HooksList/HooksList.tsx index 87dd212199b..bd87e81dd5c 100644 --- a/studio/components/interfaces/Database/Hooks/HooksList/HooksList.tsx +++ b/studio/components/interfaces/Database/Hooks/HooksList/HooksList.tsx @@ -1,81 +1,65 @@ -import { FC } from 'react' +import Link from 'next/link' +import { useState } from 'react' import { includes, uniqBy, map as lodashMap } from 'lodash' import { observer } from 'mobx-react-lite' import * as Tooltip from '@radix-ui/react-tooltip' -import { Button, Input, IconSearch, IconLoader } from 'ui' +import { Button, Input, IconSearch, IconExternalLink } from 'ui' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useStore, checkPermissions } from 'hooks' +import { checkPermissions } from 'hooks' import SchemaTable from './SchemaTable' import AlphaPreview from 'components/to-be-cleaned/AlphaPreview' import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState' -function isHooksEnabled(schemas: any): boolean { - return schemas.some((schema: any) => schema.name === 'supabase_functions') +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { useDatabaseHooks } from 'data/database-triggers/database-triggers-query' +import { FormHeader } from 'components/ui/Forms' +import ShimmeringLoader from 'components/ui/ShimmeringLoader' +import ClientLoadingError from 'components/ui/ClientLoadingError' +import NoSearchResults from 'components/ui/NoSearchResults' + +export interface HooksListProps { + createHook: () => void + editHook: (hook: any) => void + deleteHook: (hook: any) => void } -const HooksList: FC = ({ - hooksEnabled, - filterString, - setFilterString = () => {}, +const HooksList = ({ createHook = () => {}, editHook = () => {}, deleteHook = () => {}, - enableHooks = () => {}, -}) => { - const { meta } = useStore() - const hooks = meta.hooks.list() - const schemas = meta.schemas.list() - const filteredHooks = hooks.filter((x: any) => +}: HooksListProps) => { + const { project } = useProjectContext() + const { + data: hooks, + isLoading, + isError, + } = useDatabaseHooks({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + const [filterString, setFilterString] = useState('') + + const filteredHooks = (hooks || []).filter((x: any) => includes(x.name.toLowerCase(), filterString.toLowerCase()) ) const filteredHookSchemas = lodashMap(uniqBy(filteredHooks, 'schema'), 'schema') const canCreateWebhooks = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'triggers') - const isEnabled = hooksEnabled || isHooksEnabled(schemas) - - if (!isEnabled) { - return ( - <> - {schemas.length === 0 ? ( - Checking if hooks are enabled... - ) : ( - enableHooks()} - disabled={!canCreateWebhooks} - disabledMessage="You need additional permissions to enable hooks" - > - -

    Enable hooks on your project.

    -
    - )} - - ) - } - - if (meta.hooks.isLoading) { - return ( -
    - -

    Loading hooks...

    -
    - ) - } - - if (meta.hooks.hasError) { - return ( -

    -

    Error connecting to API

    -

    {`${meta.hooks.error?.message ?? 'Unknown error'}`}

    -

    - ) - } - return ( <> - {hooks.length == 0 ? ( + {isLoading ? ( +
    + + + +
    + ) : isError ? ( + + ) : (hooks || []).length == 0 ? (
    = ({
    ) : ( -
    -
    - } - value={filterString} - onChange={(e) => setFilterString(e.target.value)} + <> +
    + - - - - - {!canCreateWebhooks && ( - - -
    - - You need additional permissions to create webhooks - -
    -
    - )} -
    - {filteredHooks.length <= 0 && ( -
    -

    No results match your filter query

    - +
    +
    + } + value={filterString} + onChange={(e) => setFilterString(e.target.value)} + /> +
    + + + + + + + + + + {!canCreateWebhooks && ( + + + +
    + + You need additional permissions to create webhooks + +
    +
    +
    + )} +
    +
    - )} - {filteredHookSchemas.map((schema: any) => ( - - ))} -
    + {filteredHooks.length <= 0 && ( + setFilterString('')} + /> + )} + {filteredHookSchemas.map((schema: any) => ( + + ))} +
    + )} ) diff --git a/studio/components/interfaces/Database/Hooks/HooksList/SchemaTable.tsx b/studio/components/interfaces/Database/Hooks/HooksList/SchemaTable.tsx index fd8f1927aac..7175735cc79 100644 --- a/studio/components/interfaces/Database/Hooks/HooksList/SchemaTable.tsx +++ b/studio/components/interfaces/Database/Hooks/HooksList/SchemaTable.tsx @@ -20,28 +20,28 @@ const SchemaTable: FC = ({ return (
    -
    +
    schema

    {schema}

    - - Name + +

    Name

    - + Table - + Events Webhook - + } body={ diff --git a/studio/components/interfaces/Database/Publications/PublicationsList.tsx b/studio/components/interfaces/Database/Publications/PublicationsList.tsx index 082a40cf158..2969c3ced58 100644 --- a/studio/components/interfaces/Database/Publications/PublicationsList.tsx +++ b/studio/components/interfaces/Database/Publications/PublicationsList.tsx @@ -6,7 +6,7 @@ import { Button, Input, Toggle, IconSearch, IconAlertCircle } from 'ui' import { checkPermissions, useStore } from 'hooks' import { confirmAlert } from 'components/to-be-cleaned/ModalsDeprecated/ConfirmModal' import Table from 'components/to-be-cleaned/Table' -import NoSearchResults from 'components/to-be-cleaned/NoSearchResults' +import NoSearchResults from 'components/ui/NoSearchResults' import InformationBox from 'components/ui/InformationBox' interface Props { @@ -83,7 +83,7 @@ const PublicationsList: FC = ({ onSelectPublication = () => {} }) => { {publications.length === 0 ? ( - + setFilterString('')} /> ) : (
    = ({ role, disabled = false, onSelectDelete }) => { className="ml-2 relative top-[3px]" /> - - -
    - - This privilege cannot be updated via the dashboard - -
    -
    + + + +
    + + This privilege cannot be updated via the dashboard + +
    +
    +
    ) } diff --git a/studio/components/interfaces/Database/Roles/RolesList.tsx b/studio/components/interfaces/Database/Roles/RolesList.tsx index c5059c26e05..09b4d1d24d2 100644 --- a/studio/components/interfaces/Database/Roles/RolesList.tsx +++ b/studio/components/interfaces/Database/Roles/RolesList.tsx @@ -1,5 +1,5 @@ import { partition } from 'lodash' -import { FC, useState, useEffect } from 'react' +import { useState, useEffect } from 'react' import { observer } from 'mobx-react-lite' import * as Tooltip from '@radix-ui/react-tooltip' import { PostgresRole } from '@supabase/postgres-meta' @@ -13,12 +13,9 @@ import RoleRow from './RoleRow' import { SUPABASE_ROLES } from './Roles.constants' import CreateRolePanel from './CreateRolePanel' import DeleteRoleModal from './DeleteRoleModal' +import NoSearchResults from 'components/ui/NoSearchResults' -interface Props { - onSelectRole: (role: any) => void -} - -const RolesList: FC = ({}) => { +const RolesList = ({}) => { const { meta } = useStore() const [maxConnectionLimit, setMaxConnectionLimit] = useState(0) @@ -66,7 +63,7 @@ const RolesList: FC = ({}) => { /> -
    +
    = ({}) => {
    {filterString.length > 0 && filteredRoles.length === 0 && ( -
    -

    No results found

    -

    - Your search for "{filterString}" did not return any results -

    -
    + setFilterString('')} /> )}
    diff --git a/studio/components/interfaces/Database/Roles/RolesSettings.tsx b/studio/components/interfaces/Database/Roles/RolesSettings.tsx deleted file mode 100644 index 2ff4c1da8a6..00000000000 --- a/studio/components/interfaces/Database/Roles/RolesSettings.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { FC } from 'react' -import { observer } from 'mobx-react-lite' -import { Button, IconChevronLeft } from 'ui' -import Divider from 'components/ui/Divider' - -import Panel from 'components/ui/Panel' - -interface Props { - selectedRole: any - onSelectBack: () => void -} - -const RolesSettings: FC = ({ selectedRole, onSelectBack = () => {} }) => { - return ( - <> -
    -
    - -
    -
    - - - -

    Super user

    - {selectedRole.is_superuser ? 'true' : 'false'} -
    - - -

    User can login

    - {selectedRole.can_login ? 'true' : 'false'} -
    - - -

    User can create databases

    - {selectedRole.can_create_db ? 'true' : 'false'} -
    - - -

    - User can initiate streaming replication and put the system in and out of backup mode -

    - {selectedRole.is_replication_role ? 'true' : 'false'} -
    - - -

    User bypasses every row level security policy

    - {selectedRole.can_bypass_rls ? 'true' : 'false'} -
    -
    - - ) -} - -export default observer(RolesSettings) diff --git a/studio/components/interfaces/Database/Tables/ColumnList.tsx b/studio/components/interfaces/Database/Tables/ColumnList.tsx index e53ed442863..447e4a82750 100644 --- a/studio/components/interfaces/Database/Tables/ColumnList.tsx +++ b/studio/components/interfaces/Database/Tables/ColumnList.tsx @@ -72,19 +72,21 @@ const ColumnList: FC = ({ {!canUpdateColumns && ( - - -
    - - You need additional permissions to create columns - -
    -
    + + + +
    + + You need additional permissions to create columns + +
    +
    +
    )}
    @@ -140,19 +142,21 @@ const ColumnList: FC = ({ /> {!canUpdateColumns && ( - - -
    - - You need additional permissions to edit columns - -
    -
    + + + +
    + + You need additional permissions to edit columns + +
    +
    +
    )} @@ -167,19 +171,21 @@ const ColumnList: FC = ({ /> {!canUpdateColumns && ( - - -
    - - You need additional permissions to delete columns - -
    -
    + + + +
    + + You need additional permissions to delete columns + +
    +
    +
    )} diff --git a/studio/components/interfaces/Database/Tables/TableList.tsx b/studio/components/interfaces/Database/Tables/TableList.tsx index b058f9ce6f4..bfb97d7e2ac 100644 --- a/studio/components/interfaces/Database/Tables/TableList.tsx +++ b/studio/components/interfaces/Database/Tables/TableList.tsx @@ -20,6 +20,7 @@ import { useStore, checkPermissions } from 'hooks' import Table from 'components/to-be-cleaned/Table' import NoSearchResults from 'components/to-be-cleaned/NoSearchResults' import type { PostgresTable, PostgresSchema } from '@supabase/postgres-meta' +import ShimmeringLoader from 'components/ui/ShimmeringLoader' interface Props { selectedSchema: string @@ -39,6 +40,7 @@ const TableList: FC = ({ onOpenTable = () => {}, }) => { const { meta } = useStore() + const { isLoading } = meta.tables const [filterString, setFilterString] = useState('') const canUpdateTables = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') @@ -132,25 +134,34 @@ const TableList: FC = ({ {!canUpdateTables && ( - - -
    - - You need additional permissions to create tables - -
    -
    + + + +
    + + You need additional permissions to create tables + +
    +
    +
    )} )} - {tables.length === 0 ? ( + + {isLoading ? ( +
    + + + +
    + ) : tables.length === 0 ? ( ) : (
    @@ -219,19 +230,21 @@ const TableList: FC = ({ /> {!canUpdateTables && ( - - -
    - - You need additional permissions to edit tables - -
    -
    + + + +
    + + You need additional permissions to edit tables + +
    +
    +
    )} @@ -246,19 +259,21 @@ const TableList: FC = ({ /> {!canUpdateTables && ( - - -
    - - You need additional permissions to delete tables - -
    -
    + + + +
    + + You need additional permissions to delete tables + +
    +
    +
    )} diff --git a/studio/components/interfaces/Database/Triggers/CreateTrigger.tsx b/studio/components/interfaces/Database/Triggers/CreateTrigger.tsx index fbe743151c9..335f05f6021 100644 --- a/studio/components/interfaces/Database/Triggers/CreateTrigger.tsx +++ b/studio/components/interfaces/Database/Triggers/CreateTrigger.tsx @@ -21,6 +21,7 @@ import ChooseFunctionForm from './ChooseFunctionForm' import FormEmptyBox from 'components/ui/FormBoxEmpty' import NoTableState from 'components/ui/States/NoTableState' import { useStore } from 'hooks' +import { BASE_PATH } from 'lib/constants' class CreateTriggerFormState { id: number | undefined @@ -545,7 +546,7 @@ const ListboxTable: FC = observer(({}) => { addOnBefore={() => (
    code.replace(/svg/, 'svg class="m-auto text-color-inherit"') diff --git a/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx b/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx index f4411eff581..f459c145aa0 100644 --- a/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx +++ b/studio/components/interfaces/Database/Triggers/TriggersList/TriggerList.tsx @@ -82,19 +82,21 @@ const TriggerList: FC = ({ filterString, schema, editTrigger, deleteTrigg } /> - - -
    - - You need additional permissions to update triggers - -
    - + + + +
    + + You need additional permissions to update triggers + +
    +
    +
    )}
    diff --git a/studio/components/interfaces/Database/Triggers/TriggersList/TriggersList.tsx b/studio/components/interfaces/Database/Triggers/TriggersList/TriggersList.tsx index 2bb4b194b49..75fa2be96dc 100644 --- a/studio/components/interfaces/Database/Triggers/TriggersList/TriggersList.tsx +++ b/studio/components/interfaces/Database/Triggers/TriggersList/TriggersList.tsx @@ -80,19 +80,21 @@ const TriggersList: FC = ({ {!canCreateTriggers && ( - - -
    - - You need additional permissions to create triggers - -
    -
    + + + +
    + + You need additional permissions to create triggers + +
    +
    +
    )}
    diff --git a/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx b/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx index 896cd11d45e..9628bde820c 100644 --- a/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx +++ b/studio/components/interfaces/Database/Wrappers/WrapperRow.tsx @@ -181,19 +181,21 @@ const WrapperRow: FC = ({ wrappers = [], wrapperMeta, isOpen, onOpen }) = /> {!canManageWrappers && ( - - -
    - - You need additional permissions to edit wrappers - -
    -
    + + + +
    + + You need additional permissions to edit wrappers + +
    +
    +
    )} )} @@ -208,19 +210,21 @@ const WrapperRow: FC = ({ wrappers = [], wrapperMeta, isOpen, onOpen }) = /> {!canManageWrappers && ( - - -
    - - You need additional permissions to add wrappers - -
    -
    + + + +
    + + You need additional permissions to add wrappers + +
    +
    +
    )} diff --git a/studio/components/interfaces/Database/Wrappers/Wrappers.constants.ts b/studio/components/interfaces/Database/Wrappers/Wrappers.constants.ts index cbb51c09408..fe05d8bb3ab 100644 --- a/studio/components/interfaces/Database/Wrappers/Wrappers.constants.ts +++ b/studio/components/interfaces/Database/Wrappers/Wrappers.constants.ts @@ -1,3 +1,4 @@ +import { BASE_PATH } from 'lib/constants' import { WrapperMeta } from './Wrappers.types' export const WRAPPERS: WrapperMeta[] = [ @@ -5,7 +6,7 @@ export const WRAPPERS: WrapperMeta[] = [ name: 'stripe_wrapper', handlerName: 'stripe_fdw_handler', validatorName: 'stripe_fdw_validator', - icon: '/img/icons/stripe-icon.svg', + icon: `${BASE_PATH}/img/icons/stripe-icon.svg`, extensionName: 'StripeFdw', label: 'Stripe', docsUrl: 'https://supabase.com/docs/guides/database/wrappers/stripe', @@ -409,7 +410,7 @@ export const WRAPPERS: WrapperMeta[] = [ name: 'firebase_wrapper', handlerName: 'firebase_fdw_handler', validatorName: 'firebase_fdw_validator', - icon: '/img/icons/firebase-icon.svg', + icon: `${BASE_PATH}/img/icons/firebase-icon.svg`, extensionName: 'FirebaseFdw', label: 'Firebase', docsUrl: 'https://supabase.com/docs/guides/database/wrappers/firebase', diff --git a/studio/components/interfaces/Database/Wrappers/WrappersDisabledState.tsx b/studio/components/interfaces/Database/Wrappers/WrappersDisabledState.tsx index 54e4fa6a8b1..07ce73df7b4 100644 --- a/studio/components/interfaces/Database/Wrappers/WrappersDisabledState.tsx +++ b/studio/components/interfaces/Database/Wrappers/WrappersDisabledState.tsx @@ -6,6 +6,7 @@ import * as Tooltip from '@radix-ui/react-tooltip' import { PermissionAction } from '@supabase/shared-types/out/constants' import { checkPermissions, useParams, useStore } from 'hooks' +import { BASE_PATH } from 'lib/constants' const WrappersDisabledState = () => { const { ui, meta } = useStore() @@ -65,8 +66,8 @@ const WrappersDisabledState = () => { backgroundSize: '45%', backgroundPosition: '105% 40%', backgroundImage: ui.isDarkTheme - ? 'url("/img/wrappers-dark.png")' - : 'url("/img/wrappers-light.png")', + ? `url("${BASE_PATH}/img/wrappers-dark.png")` + : `url("${BASE_PATH}/img/wrappers-light.png")`, }} >
    @@ -130,19 +131,21 @@ const WrappersDisabledState = () => { {!canToggleWrappers && ( - - -
    - - You need additional permissions to enable Wrappers for this project - -
    -
    + + + +
    + + You need additional permissions to enable Wrappers for this project + +
    +
    +
    )}
    diff --git a/studio/components/interfaces/Database/Wrappers/WrappersDropdown.tsx b/studio/components/interfaces/Database/Wrappers/WrappersDropdown.tsx index 76a5ce5b6b4..ba76b237de1 100644 --- a/studio/components/interfaces/Database/Wrappers/WrappersDropdown.tsx +++ b/studio/components/interfaces/Database/Wrappers/WrappersDropdown.tsx @@ -26,19 +26,21 @@ const WrapperDropdown: FC = ({ buttonText = 'Add wrapper', align = 'end' {buttonText} - - -
    - - You need additional permissions to add wrappers - -
    -
    + + + +
    + + You need additional permissions to add wrappers + +
    +
    +
    ) } diff --git a/studio/components/interfaces/Database/index.ts b/studio/components/interfaces/Database/index.ts index 94c664c2854..f2dabff52f7 100644 --- a/studio/components/interfaces/Database/index.ts +++ b/studio/components/interfaces/Database/index.ts @@ -2,7 +2,6 @@ import TableList from './Tables/TableList' import ColumnList from './Tables/ColumnList' import RolesList from './Roles/RolesList' -import RolesSettings from './Roles/RolesSettings' import Extensions from './Extensions/Extensions' @@ -27,7 +26,6 @@ export { TableList, ColumnList, RolesList, - RolesSettings, Extensions, Wrappers, CreateWrapper, diff --git a/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx b/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx index 66e2676672a..1da604d200e 100644 --- a/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx +++ b/studio/components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx @@ -13,7 +13,6 @@ import { Modal, Form, Toggle, - IconHelpCircle, Input, IconExternalLink, } from 'ui' @@ -127,7 +126,7 @@ const EdgeFunctionDetails: FC = () => { if (selectedFunction !== undefined) { const formValues = { name: selectedFunction?.name, - verifyJwt: selectedFunction?.verify_jwt, + verify_jwt: selectedFunction?.verify_jwt, } resetForm({ values: formValues, initialValues: formValues }) } @@ -184,8 +183,8 @@ const EdgeFunctionDetails: FC = () => { Function Configuration}> @@ -277,19 +276,21 @@ const EdgeFunctionDetails: FC = () => { {!canUpdateEdgeFunction && ( - - -
    - - You need additional permissions to delete an edge function - -
    -
    + + + +
    + + You need additional permissions to delete an edge function + +
    +
    +
    )} diff --git a/studio/components/interfaces/Functions/EdgeFunctionsListItem.tsx b/studio/components/interfaces/Functions/EdgeFunctionsListItem.tsx index bb1b1f86edd..2c4c666e417 100644 --- a/studio/components/interfaces/Functions/EdgeFunctionsListItem.tsx +++ b/studio/components/interfaces/Functions/EdgeFunctionsListItem.tsx @@ -78,19 +78,21 @@ const EdgeFunctionsListItem: FC = ({ function: item }) => {

    {dayjs(item.updated_at).fromNow()}

    - - -
    - - Last updated on {dayjs(item.updated_at).format('DD MMM, YYYY HH:mm')} - -
    -
    + + + +
    + + Last updated on {dayjs(item.updated_at).format('DD MMM, YYYY HH:mm')} + +
    +
    +
    diff --git a/studio/components/interfaces/Functions/TerminalInstructions.tsx b/studio/components/interfaces/Functions/TerminalInstructions.tsx index a5770812ef0..0a933c1d0b9 100644 --- a/studio/components/interfaces/Functions/TerminalInstructions.tsx +++ b/studio/components/interfaces/Functions/TerminalInstructions.tsx @@ -143,7 +143,7 @@ const TerminalInstructions: FC = ({ closable = false, removeBorder = fals href="https://github.com/supabase/supabase/tree/master/examples/edge-functions/supabase/functions" > - diff --git a/studio/components/interfaces/Home/ClientLibrary.tsx b/studio/components/interfaces/Home/ClientLibrary.tsx index 7e2bd3d8cae..59f1d0b4d5f 100644 --- a/studio/components/interfaces/Home/ClientLibrary.tsx +++ b/studio/components/interfaces/Home/ClientLibrary.tsx @@ -1,5 +1,6 @@ import { FC } from 'react' import { IconBookOpen, IconGitHub, Button, Badge } from 'ui' +import { BASE_PATH } from 'lib/constants' interface Props { language: string @@ -13,7 +14,7 @@ const ClientLibrary: FC = ({ language, releaseState, officialSupport, doc return (
    {`${language} diff --git a/studio/components/interfaces/Home/ExampleProject.tsx b/studio/components/interfaces/Home/ExampleProject.tsx index a5f6e652796..32f76b8bfaf 100644 --- a/studio/components/interfaces/Home/ExampleProject.tsx +++ b/studio/components/interfaces/Home/ExampleProject.tsx @@ -2,6 +2,7 @@ import { FC } from 'react' import { IconChevronRight } from 'ui' import { useStore } from 'hooks' import Link from 'next/link' +import { BASE_PATH } from 'lib/constants' interface Props { framework: string @@ -29,8 +30,12 @@ const ExampleProject: FC = ({ framework, title, description, url }) => {
    {`${framework} = ({}) => { type="default" icon={ code.replace(/svg/, 'svg class="m-auto text-color-inherit"') @@ -65,7 +65,7 @@ const NewProjectPanel: FC = ({}) => { type="default" icon={ code.replace(/svg/, 'svg class="m-auto text-color-inherit"') diff --git a/studio/components/interfaces/Home/ProjectList/ProjectList.tsx b/studio/components/interfaces/Home/ProjectList/ProjectList.tsx index 1199e598a24..c973699deeb 100644 --- a/studio/components/interfaces/Home/ProjectList/ProjectList.tsx +++ b/studio/components/interfaces/Home/ProjectList/ProjectList.tsx @@ -10,6 +10,7 @@ import { makeRandomString } from 'lib/helpers' import ProjectCard from './ProjectCard' import ShimmeringCard from './ShimmeringCard' import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useOverdueInvoicesQuery } from 'data/invoices/invoices-query' interface Props { rewriteHref?: (projectRef: string) => string @@ -20,6 +21,8 @@ const ProjectList: FC = ({ rewriteHref }) => { const { organizations, projects } = app const { isLoading: isLoadingProjects } = projects + const { data: allOverdueInvoices } = useOverdueInvoicesQuery({ enabled: IS_PLATFORM }) + const isLoadingPermissions = IS_PLATFORM ? (ui?.permissions ?? []).length === 0 : false return ( @@ -32,9 +35,24 @@ const ProjectList: FC = ({ rewriteHref }) => { const isEmpty = sortedProjects?.length == 0 const canReadProjects = checkPermissions(PermissionAction.READ, 'projects', undefined, id) + const overdueInvoices = (allOverdueInvoices || []).filter((it) => it.organization_id === id) + return (
    -

    {name}

    +
    +

    {name}

    + + {!!overdueInvoices.length && ( + + )} +
    + {isLoadingPermissions || isLoadingProjects ? (
      diff --git a/studio/components/interfaces/Organization/BillingSettings/PaymentMethods.tsx b/studio/components/interfaces/Organization/BillingSettings/PaymentMethods.tsx index 799445695b1..9963a537053 100644 --- a/studio/components/interfaces/Organization/BillingSettings/PaymentMethods.tsx +++ b/studio/components/interfaces/Organization/BillingSettings/PaymentMethods.tsx @@ -17,7 +17,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { checkPermissions, useStore } from 'hooks' import { delete_, patch } from 'lib/common/fetch' import { getURL } from 'lib/helpers' -import { API_URL } from 'lib/constants' +import { API_URL, BASE_PATH } from 'lib/constants' import Panel from 'components/ui/Panel' import { AddNewPaymentMethodModal } from 'components/interfaces/Billing' import NoPermission from 'components/ui/NoPermission' @@ -158,7 +158,7 @@ const PaymentMethods: FC = ({
      = ({ + + + )} +
      @@ -137,7 +154,7 @@ const InvoicesSettings = () => { ) })} - +

      Showing {offset + 1} to {offset + invoices.length} out of {count} invoices diff --git a/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx b/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx index 8dd437ebe3d..1be69a28efd 100644 --- a/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx +++ b/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx @@ -101,19 +101,21 @@ const InviteMemberButton = ({ {!canInviteMembers && ( - - -

      - - You need additional permissions to invite a member to this organization - -
      - + + + +
      + + You need additional permissions to invite a member to this organization + +
      +
      +
      )} = ({ member, roles }) => {
      ) diff --git a/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx b/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx index f9e5e0b40ed..ffbb54d6f65 100644 --- a/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx +++ b/studio/components/interfaces/Organization/TeamSettings/MembersView.tsx @@ -1,19 +1,22 @@ import Image from 'next/image' import * as Tooltip from '@radix-ui/react-tooltip' -import { useState, FC, Fragment } from 'react' +import { useState, Fragment } from 'react' import { observer } from 'mobx-react-lite' -import { Badge, Button, Loading, Listbox, IconUser, Modal, IconAlertCircle } from 'ui' +import { Badge, Button, Loading, Listbox, IconUser, Modal, IconAlertCircle, IconLoader } from 'ui' -import { Member, Role } from 'types' +import { Member } from 'types' import { useStore, useParams } from 'hooks' -import { isInviteExpired, getUserDisplayName } from '../Organization.utils' -import { useProfileQuery } from 'data/profile/profile-query' -import { useOrganizationMemberUpdateMutation } from 'data/organizations/organization-member-update-mutation' +import ShimmeringLoader from 'components/ui/ShimmeringLoader' import Table from 'components/to-be-cleaned/Table' import MemberActions from './MemberActions' import RolesHelperModal from './RolesHelperModal/RolesHelperModal' import { getRolesManagementPermissions } from './TeamSettings.utils' +import { isInviteExpired, getUserDisplayName } from '../Organization.utils' +import { useProfileQuery } from 'data/profile/profile-query' +import { useOrganizationRolesQuery } from 'data/organizations/organization-roles-query' +import { useOrganizationDetailQuery } from 'data/organizations/organization-detail-query' +import { useOrganizationMemberUpdateMutation } from 'data/organizations/organization-member-update-mutation' interface SelectedMember extends Member { oldRoleId: number @@ -21,24 +24,38 @@ interface SelectedMember extends Member { } export interface MembersViewProps { - roles: Role[] - members: Member[] searchString: string } -const MembersView = ({ searchString, roles, members }: MembersViewProps) => { +const MembersView = ({ searchString }: MembersViewProps) => { const { ui } = useStore() const { slug } = useParams() const { data: profile } = useProfileQuery() + const { data: detailData, isLoading: isLoadingOrgDetails } = useOrganizationDetailQuery({ slug }) + const { data: rolesData, isLoading: isLoadingRoles } = useOrganizationRolesQuery({ slug }) + const { mutate: updateOrganizationMember, isLoading } = useOrganizationMemberUpdateMutation({ + onSuccess() { + ui.setNotification({ + category: 'success', + message: `Successfully updated role for ${getUserDisplayName(selectedMember)}`, + }) + }, + onError() { + ui.setNotification({ + category: 'error', + message: `Failed to update role for ${getUserDisplayName(selectedMember)}`, + }) + }, + }) + const roles = rolesData?.roles ?? [] + const members = detailData?.members ?? [] const { rolesAddable, rolesRemovable } = getRolesManagementPermissions(roles) const [selectedMember, setSelectedMember] = useState() const [userRoleChangeModalVisible, setUserRoleChangeModalVisible] = useState(false) - if (!members) return
      - const filteredMembers = ( !searchString ? members @@ -59,21 +76,6 @@ const MembersView = ({ searchString, roles, members }: MembersViewProps) => { return roles.find((x: any) => x.id === id)?.name } - const { isLoading, mutate } = useOrganizationMemberUpdateMutation({ - onSuccess() { - ui.setNotification({ - category: 'success', - message: `Successfully updated role for ${getUserDisplayName(selectedMember)}`, - }) - }, - onError() { - ui.setNotification({ - category: 'error', - message: `Failed to update role for ${getUserDisplayName(selectedMember)}`, - }) - }, - }) - const handleRoleChange = async () => { if (!selectedMember) return @@ -86,11 +88,20 @@ const MembersView = ({ searchString, roles, members }: MembersViewProps) => { throw new Error('gotrue_id is required') } - mutate({ slug, gotrueId: gotrue_id, roleId: newRoleId }) - + updateOrganizationMember({ slug, gotrueId: gotrue_id, roleId: newRoleId }) setUserRoleChangeModalVisible(false) } + if (isLoadingOrgDetails) { + return ( +
      + + + +
      + ) + } + return ( <>
      @@ -176,10 +187,13 @@ const MembersView = ({ searchString, roles, members }: MembersViewProps) => { - {!role &&

      {x.is_owner ? 'Owner' : 'Developer'}

      } - {role && ( + {isLoadingRoles ? ( +
      + +
      + ) : role !== undefined ? ( - + { {memberIsPendingInvite ? ( - - -
      - - Role can only be changed after the user has accepted the invite - -
      -
      + + + +
      + + Role can only be changed after the user has accepted the + invite + +
      +
      +
      ) : !memberIsUser && !canRemoveRole ? ( - - -
      - - You need additional permissions to manage this team member - -
      -
      + + + +
      + + You need additional permissions to manage this team member + +
      +
      +
      ) : ( <> )}
      + ) : ( +
      +

      Invalid role

      + + + + + + + +
      + + This user has an invalid role, please reach out to us via + support + +
      +
      +
      +
      +
      )}
      diff --git a/studio/components/interfaces/Organization/TeamSettings/RolesHelperModal/RolesHelperModal.tsx b/studio/components/interfaces/Organization/TeamSettings/RolesHelperModal/RolesHelperModal.tsx index 6c322bf60f3..9f08f55e877 100644 --- a/studio/components/interfaces/Organization/TeamSettings/RolesHelperModal/RolesHelperModal.tsx +++ b/studio/components/interfaces/Organization/TeamSettings/RolesHelperModal/RolesHelperModal.tsx @@ -17,17 +17,19 @@ const RolesHelperModal: FC = ({}) => { - - -
      - Only available in Team/Enterprise plan. -
      -
      + + + +
      + Only available in Team/Enterprise plan. +
      +
      +
      ) diff --git a/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx b/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx index 9289a210943..42140783088 100644 --- a/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx +++ b/studio/components/interfaces/Organization/TeamSettings/TeamSettings.tsx @@ -97,19 +97,21 @@ const TeamSettings = () => { {!canLeave && ( - - -
      - - An organization requires at least 1 owner - -
      -
      + + + +
      + + An organization requires at least 1 owner + +
      +
      +
      )}
      @@ -117,7 +119,7 @@ const TeamSettings = () => {
      - +
      ) diff --git a/studio/components/interfaces/Reports/ReportFilterBar.tsx b/studio/components/interfaces/Reports/ReportFilterBar.tsx index 6b0dba60aca..15bc75c22ec 100644 --- a/studio/components/interfaces/Reports/ReportFilterBar.tsx +++ b/studio/components/interfaces/Reports/ReportFilterBar.tsx @@ -99,7 +99,7 @@ const ReportFilterBar: FC = ({ ] const [showAdder, setShowAdder] = useState(false) const [currentProductFilter, setCurrentProductFilter] = useState< - null | typeof PRODUCT_FILTERS[number] + null | (typeof PRODUCT_FILTERS)[number] >(null) const [addFilterValues, setAddFilterValues] = useState({ key: filterKeys[0], @@ -116,7 +116,7 @@ const ReportFilterBar: FC = ({ } const handleProductFilterChange = async ( - nextProductFilter: null | typeof PRODUCT_FILTERS[number] + nextProductFilter: null | (typeof PRODUCT_FILTERS)[number] ) => { const toRemove = PRODUCT_FILTERS.map( (productFilter) => @@ -156,6 +156,7 @@ const ReportFilterBar: FC = ({ All Requests + {PRODUCT_FILTERS.map((productFilter) => { const Icon = productFilter.icon return ( @@ -163,7 +164,6 @@ const ReportFilterBar: FC = ({ key={productFilter.key} disabled={productFilter.key === currentProductFilter?.key} onClick={() => handleProductFilterChange(productFilter)} - className="hover:bg-scale-600" icon={} >

      = (props) => { - - -

      - {props.tooltip} -
      - + + + +
      + {props.tooltip} +
      +
      +
      )}
    @@ -80,17 +82,19 @@ const ReportWidget: React.FC = (props) => { }} /> - - -
    - Open in Logs Explorer -
    -
    + + + +
    + Open in Logs Explorer +
    +
    +
    diff --git a/studio/components/interfaces/Reports/Reports.tsx b/studio/components/interfaces/Reports/Reports.tsx index ec43b8041ec..ecf0223e60c 100644 --- a/studio/components/interfaces/Reports/Reports.tsx +++ b/studio/components/interfaces/Reports/Reports.tsx @@ -275,7 +275,7 @@ const Reports = () => { return ( x.attribute === metric.key)} + checked={config.layout?.some((x: any) => x.attribute === metric.key)} onChange={(e) => handleChartSelection({ metric, value: e })} >
    @@ -360,19 +360,21 @@ const Reports = () => { Add / Remove charts - - -
    - - You need additional permissions to update this project's report - -
    -
    + + + +
    + + You need additional permissions to update this project's report + +
    +
    +
    )}
    diff --git a/studio/components/interfaces/SQLEditor/TabSqlQuery/FavouriteButton.tsx b/studio/components/interfaces/SQLEditor/TabSqlQuery/FavouriteButton.tsx index 59cd87f4141..eb9389602fc 100644 --- a/studio/components/interfaces/SQLEditor/TabSqlQuery/FavouriteButton.tsx +++ b/studio/components/interfaces/SQLEditor/TabSqlQuery/FavouriteButton.tsx @@ -112,20 +112,22 @@ const FavouriteButton = () => { return ( <> - - -
    - - {isFavorite ? 'Remove' : 'Add'} this query {isFavorite ? 'from' : 'to'} your - Favourites - -
    -
    + + + +
    + + {isFavorite ? 'Remove' : 'Add'} this query {isFavorite ? 'from' : 'to'} your + Favourites + +
    +
    +
    - - -
    - - You need additional permissions to generate a new JWT secret - -
    -
    + + + +
    + + You need additional permissions to generate a new JWT secret + +
    +
    +
    ) : ( = observer( ) const [updates, setUpdates] = useState({ - pgbouncer_enabled: bouncerInfo.pgbouncer_enabled, pool_mode: bouncerInfo.pool_mode || 'transaction', default_pool_size: bouncerInfo.default_pool_size || '', ignore_startup_parameters: bouncerInfo.ignore_startup_parameters || '', @@ -159,11 +158,6 @@ export const PgbouncerConfig: FC = observer( const formSchema = { properties: { - pgbouncer_enabled: { - title: 'Enabled', - type: 'boolean', - help: 'Activates / deactivates Connection Pooling.', - }, pool_mode: { title: 'Pool Mode', type: 'string', @@ -176,10 +170,6 @@ export const PgbouncerConfig: FC = observer( label: 'Session', value: 'session', }, - { - label: 'Statement', - value: 'statement', - }, ], }, ignore_startup_parameters: { @@ -208,16 +198,12 @@ export const PgbouncerConfig: FC = observer( disabledMessage="You need additional permissions to update connection pooling settings" >
    - - - - - {updates.pgbouncer_enabled && ( + {bouncerInfo.pgbouncer_enabled && ( <>

    diff --git a/studio/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPassword.tsx b/studio/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPassword.tsx index 3d77f371349..40c076a6b10 100644 --- a/studio/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPassword.tsx +++ b/studio/components/interfaces/Settings/Database/DatabaseSettings/ResetDbPassword.tsx @@ -107,19 +107,21 @@ const ResetDbPassword: FC = ({ disabled = false }) => { {!canResetDbPassword && ( - - -

    - - You need additional permissions to reset the database password - -
    - + + + +
    + + You need additional permissions to reset the database password + +
    +
    +
    )}
    diff --git a/studio/components/interfaces/Settings/Database/NetworkRestrictions/AddRestrictionModal.tsx b/studio/components/interfaces/Settings/Database/NetworkRestrictions/AddRestrictionModal.tsx index c2cfd9755f4..b79d99fa3c6 100644 --- a/studio/components/interfaces/Settings/Database/NetworkRestrictions/AddRestrictionModal.tsx +++ b/studio/components/interfaces/Settings/Database/NetworkRestrictions/AddRestrictionModal.tsx @@ -154,22 +154,24 @@ const AddRestrictionModal: FC = ({ - - -
    - - Classless inter-domain routing (CIDR) notation is the notation - used to identify networks and hosts in the networks. The block - size tells us how many bits we need to take for the network - prefix, and is a value between 0 to 32. - -
    -
    + + + +
    + + Classless inter-domain routing (CIDR) notation is the notation + used to identify networks and hosts in the networks. The block + size tells us how many bits we need to take for the network + prefix, and is a value between 0 to 32. + +
    +
    +
    } diff --git a/studio/components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions.tsx b/studio/components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions.tsx index b3c47c59119..35242df3cb8 100644 --- a/studio/components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions.tsx +++ b/studio/components/interfaces/Settings/Database/NetworkRestrictions/NetworkRestrictions.tsx @@ -25,19 +25,21 @@ const AllowAllAccessButton: FC<{ disabled: boolean; onClick: (value: boolean) => {disabled && ( - - -
    - - You need additional permissions to update network restrictions - -
    -
    + + + +
    + + You need additional permissions to update network restrictions + +
    +
    +
    )} ) @@ -53,19 +55,21 @@ const DisallowAllAccessButton: FC<{ disabled: boolean; onClick: (value: boolean) {disabled && ( - - -
    - - You need additional permissions to update network restrictions - -
    -
    + + + +
    + + You need additional permissions to update network restrictions + +
    +
    +
    )} ) @@ -82,7 +86,7 @@ const NetworkRestrictions = ({}) => { const canUpdateNetworkRestrictions = checkPermissions(PermissionAction.UPDATE, 'projects') const hasAccessToRestrictions = data?.entitlement === 'allowed' - const restrictedIps = data?.config.dbAllowedCidrs ?? [] + const restrictedIps = data?.config?.dbAllowedCidrs ?? [] const restrictionStatus = data?.status ?? '' const hasApplyError = restrictedIps.length === 0 && restrictionStatus === 'stored' @@ -118,19 +122,21 @@ const NetworkRestrictions = ({}) => { {!canUpdateNetworkRestrictions && ( - - -
    - - You need additional permissions to update network restrictions - -
    -
    + + + +
    + + You need additional permissions to update network restrictions + +
    +
    +
    )}
    diff --git a/studio/components/interfaces/Settings/Database/SSLConfiguration.tsx b/studio/components/interfaces/Settings/Database/SSLConfiguration.tsx index 9f1f8596905..92e146e911e 100644 --- a/studio/components/interfaces/Settings/Database/SSLConfiguration.tsx +++ b/studio/components/interfaces/Settings/Database/SSLConfiguration.tsx @@ -152,20 +152,22 @@ const SSLConfiguration = () => { {!hasSSLCertificate && ( - - -
    - - Projects before 15:08 (GMT+08), 29th April 2021 do not have SSL certificates - installed - -
    -
    + + + +
    + + Projects before 15:08 (GMT+08), 29th April 2021 do not have SSL certificates + installed + +
    +
    +
    )} diff --git a/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx b/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx index 261746b733d..6ce9eb610cb 100644 --- a/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx +++ b/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx @@ -36,46 +36,42 @@ const CustomDomainConfig = () => { return (
    - {data?.status === '0_no_hostname_configured' ? ( + {isLoading ? ( + + + + + + ) : isError ? ( + + +
    + +

    + Failed to retrieve custom domain configuration. Please try again later or{' '} + + contact support + + . +

    +
    +
    +
    + ) : data?.status === '0_no_hostname_configured' ? ( + ) : data?.status === '0_not_allowed' ? ( + } + primaryText="Custom domains are a Pro plan add-on" + projectRef={ref as string} + secondaryText={ + tier === PRICING_TIER_PRODUCT_IDS.FREE + ? 'To configure a custom domain for your project, please upgrade to the Pro plan with the custom domains add-on selected' + : 'To configure a custom domain for your project, please enable the add-on' + } + /> ) : ( - {isLoading && ( - - - - )} - - {data?.status === '0_not_allowed' && ( - - } - primaryText="Custom domains are a Pro plan add-on" - projectRef={ref as string} - secondaryText={ - tier === PRICING_TIER_PRODUCT_IDS.FREE - ? 'To configure a custom domain for your project, please upgrade to the Pro plan with the custom domains add-on selected' - : 'To configure a custom domain for your project, please enable the add-on' - } - /> - - )} - - {isError && ( - -
    - -

    - Failed to retrieve custom domain configuration. Please try again later or{' '} - - contact support - - . -

    -
    -
    - )} - {isSuccess && (
    {(data.status === '2_initiated' || data.status === '3_challenge_verified') && ( diff --git a/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectButton.tsx b/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectButton.tsx index a316a3b1bf6..7a80c2d4b9f 100644 --- a/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectButton.tsx +++ b/studio/components/interfaces/Settings/General/DeleteProjectPanel/DeleteProjectButton.tsx @@ -81,9 +81,9 @@ const DeleteProjectButton: FC = ({ type = 'danger' }) => { if (!isFree) { const feedbackRes = await post(`${API_URL}/feedback/downgrade`, { projectRef, - reasons: selectedReasons.reduce((a, b) => `${a}- ${b}\n`, ''), - additionalFeedback: cancellationMessage, - exitAction: 'delete', + reasons: selectedReasons.reduce((a, b) => `${a}- ${b}\n`, ''), + additionalFeedback: cancellationMessage, + exitAction: 'delete', }) if (feedbackRes.error) throw feedbackRes.error } @@ -98,19 +98,21 @@ const DeleteProjectButton: FC = ({ type = 'danger' }) => { {!canDeleteProject && ( - - -
    - - You need additional permissions to delete this project - -
    -
    + + + +
    + + You need additional permissions to delete this project + +
    +
    +
    )} = ({}) => { Latest - - -
    - - Project is on the latest version of Postgres that Supabase supports - -
    -
    + + + +
    + + Project is on the latest version of Postgres that Supabase supports + +
    +
    +
    ), ]} diff --git a/studio/components/interfaces/Settings/General/Infrastructure/PauseProjectButton.tsx b/studio/components/interfaces/Settings/General/Infrastructure/PauseProjectButton.tsx index 9c198671b9e..449c6f1fb13 100644 --- a/studio/components/interfaces/Settings/General/Infrastructure/PauseProjectButton.tsx +++ b/studio/components/interfaces/Settings/General/Infrastructure/PauseProjectButton.tsx @@ -73,31 +73,35 @@ const PauseProjectButton: FC = observer(({ projectRef, projectId }) => { {isPaused ? ( - - -
    - Your project is already paused -
    -
    + + + +
    + Your project is already paused +
    +
    +
    ) : !canPauseProject ? ( - - -
    - - You need additional permissions to pause this project - -
    -
    + + + +
    + + You need additional permissions to pause this project + +
    +
    +
    ) : ( <> )} diff --git a/studio/components/interfaces/Settings/General/Infrastructure/ProjectUpgradeAlert/ProjectUpgradeAlert.tsx b/studio/components/interfaces/Settings/General/Infrastructure/ProjectUpgradeAlert/ProjectUpgradeAlert.tsx index 559e60eaafa..453770469a2 100644 --- a/studio/components/interfaces/Settings/General/Infrastructure/ProjectUpgradeAlert/ProjectUpgradeAlert.tsx +++ b/studio/components/interfaces/Settings/General/Infrastructure/ProjectUpgradeAlert/ProjectUpgradeAlert.tsx @@ -79,6 +79,10 @@ const ProjectUpgradeAlert: FC = ({}) => { >
    {({ values, isSubmitting }: { values: any; isSubmitting: boolean }) => { + const selectedVersion = (data?.target_upgrade_versions ?? []).find( + (x) => x.postgres_version === values.version + ) + return ( <>
    @@ -130,7 +134,10 @@ const ProjectUpgradeAlert: FC = ({}) => { id="version" name="version" label="Select the version of Postgres to upgrade to" - descriptionText={`Postgres will be upgraded from ${currentPgVersion} to ${values.version}`} + descriptionText={`Postgres will be upgraded from ${currentPgVersion} to ${ + selectedVersion?.app_version?.split('supabase-postgres-')[1] ?? + values.version + }`} > {data?.target_upgrade_versions.map((version) => ( = ({ project }) => {
    {!canRestartProject && ( - - -
    - - You need additional permissions to restart this project - -
    -
    + + + +
    + + You need additional permissions to restart this project + +
    +
    +
    )} = ({ )} } - portalled showClose side="bottom" > diff --git a/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx b/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx index 3707cf06f8d..0168c0234b2 100644 --- a/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx +++ b/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx @@ -14,6 +14,7 @@ import Link from 'next/link' import React from 'react' import { checkPermissions } from 'hooks' import { useProfileQuery } from 'data/profile/profile-query' +import { IS_PLATFORM } from 'lib/constants' export interface LogsQueryPanelProps { templates?: LogTemplate[] @@ -102,7 +103,6 @@ const LogsQueryPanel = ({ }`} >
    @@ -131,7 +131,7 @@ const LogsQueryPanel = ({ - {onSave && ( + {IS_PLATFORM && onSave && ( - - - + + + +
    diff --git a/studio/components/interfaces/Settings/ProjectUsageBars/ProjectUsageBars.tsx b/studio/components/interfaces/Settings/ProjectUsageBars/ProjectUsageBars.tsx index c24fbe5a685..a5439c7878b 100644 --- a/studio/components/interfaces/Settings/ProjectUsageBars/ProjectUsageBars.tsx +++ b/studio/components/interfaces/Settings/ProjectUsageBars/ProjectUsageBars.tsx @@ -14,7 +14,7 @@ import { IconBookOpen, } from 'ui' -import { checkPermissions, useStore } from 'hooks' +import { checkPermissions, useFlag, useStore } from 'hooks' import { formatBytes } from 'lib/helpers' import { PRICING_TIER_PRODUCT_IDS, USAGE_APPROACHING_THRESHOLD } from 'lib/constants' import SparkBar from 'components/ui/SparkBar' @@ -24,6 +24,7 @@ import { USAGE_BASED_PRODUCTS } from 'components/interfaces/Billing/Billing.cons import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { useProjectReadOnlyQuery } from 'data/config/project-read-only-query' import { ProjectUsageResponseUsageKeys, useProjectUsageQuery } from 'data/usage/project-usage-query' +import { getResourcesExceededLimits } from 'components/ui/OveragesBanner/OveragesBanner.utils' interface Props { projectRef?: string @@ -40,6 +41,8 @@ const ProjectUsage: FC = ({ projectRef }) => { connectionString: project?.connectionString, }) + const overusageBadgeEnabled = useFlag('overusageBadge') + const canUpdateSubscription = checkPermissions( PermissionAction.BILLING_WRITE, 'stripe.subscriptions' @@ -51,6 +54,8 @@ const ProjectUsage: FC = ({ projectRef }) => { subscriptionTier === PRICING_TIER_PRODUCT_IDS.TEAM || subscriptionTier === PRICING_TIER_PRODUCT_IDS.ENTERPRISE + const resourcesExceededLimits = getResourcesExceededLimits(usage) + const showUsageExceedMessage = subscriptionTier !== undefined && !projectHasNoLimits const planNames = { @@ -106,16 +111,58 @@ const ProjectUsage: FC = ({ projectRef }) => { {usage && (
    - {USAGE_BASED_PRODUCTS.map((product) => { - const isExceededUsage = - showUsageExceedMessage && - product.features - .map((feature) => { - const featureUsage = usage[feature.key as ProjectUsageResponseUsageKeys] - return (featureUsage.usage ?? 0) / featureUsage.limit > 1 - }) - .some((x) => x === true) + {resourcesExceededLimits.length > 0 && + showUsageExceedMessage && + overusageBadgeEnabled && ( +
    + } + title="You are exceeding your plans quota" + description={ +
    + {subscriptionTier === PRICING_TIER_PRODUCT_IDS.FREE ? ( +
    +

    + Your project is currently on the Free tier - upgrade to the Pro tier for + a greatly increased quota and continue to scale. +

    +

    + See{' '} + + pricing page + {' '} + for a full breakdown of available plans. +

    + + + + + +
    + ) : ( +
    +

    + By default, Pro projects have spend caps to control costs. When enabled, + usage is limited to the plan's quota, with restrictions when limits are + exceeded. To scale beyond Pro limits without restrictions, disable the + spend cap and pay for over-usage beyond the quota. +

    + + + + + +
    + )} +
    + } + /> +
    + )} + {USAGE_BASED_PRODUCTS.map((product) => { return (
    = ({ projectRef }) => {
    {product.title}
    - {/* Plan Limits */} -
    - {/* Usage */} - {/* Line items */} @@ -226,21 +267,23 @@ const ProjectUsage: FC = ({ projectRef }) => { - - -
    - - {feature.tooltip} - -
    -
    + + + +
    + + {feature.tooltip} + +
    +
    +
    )} diff --git a/studio/components/interfaces/Settings/Vault/Keys/EncryptionKeysManagement.tsx b/studio/components/interfaces/Settings/Vault/Keys/EncryptionKeysManagement.tsx index 63cac0d9820..6010ec432ca 100644 --- a/studio/components/interfaces/Settings/Vault/Keys/EncryptionKeysManagement.tsx +++ b/studio/components/interfaces/Settings/Vault/Keys/EncryptionKeysManagement.tsx @@ -157,19 +157,21 @@ const EncryptionKeysManagement: FC = ({}) => { {!canManageKeys && ( - - -
    - - You need additional permissions to add keys - -
    -
    + + + +
    + + You need additional permissions to add keys + +
    +
    +
    )} @@ -215,19 +217,21 @@ const EncryptionKeysManagement: FC = ({}) => { /> {!canManageKeys && ( - - -
    - - You need additional permissions to delete keys - -
    -
    + + + +
    + + You need additional permissions to delete keys + +
    +
    +
    )} diff --git a/studio/components/interfaces/Settings/Vault/Secrets/SecretRow.tsx b/studio/components/interfaces/Settings/Vault/Secrets/SecretRow.tsx index c3c08a02e92..9ff849dd7ae 100644 --- a/studio/components/interfaces/Settings/Vault/Secrets/SecretRow.tsx +++ b/studio/components/interfaces/Settings/Vault/Secrets/SecretRow.tsx @@ -121,19 +121,21 @@ const SecretRow: FC = ({ secret, onSelectEdit, onSelectRemove }) => { {!canManageSecrets && ( - - -
    - - You need additional permissions to edit secrets - -
    -
    + + + +
    + + You need additional permissions to edit secrets + +
    +
    +
    )} @@ -147,19 +149,21 @@ const SecretRow: FC = ({ secret, onSelectEdit, onSelectRemove }) => { {!canManageSecrets && ( - - -
    - - You need additional permissions to delete secrets - -
    -
    + + + +
    + + You need additional permissions to delete secrets + +
    +
    +
    )}
    diff --git a/studio/components/interfaces/Settings/Vault/Secrets/SecretsManagement.tsx b/studio/components/interfaces/Settings/Vault/Secrets/SecretsManagement.tsx index 05ca9c5d11c..b3adab646d4 100644 --- a/studio/components/interfaces/Settings/Vault/Secrets/SecretsManagement.tsx +++ b/studio/components/interfaces/Settings/Vault/Secrets/SecretsManagement.tsx @@ -120,19 +120,21 @@ const SecretsManagement: FC = ({}) => { {!canManageSecrets && ( - - -
    - - You need additional permissions to add secrets - -
    -
    + + + +
    + + You need additional permissions to add secrets + +
    +
    +
    )} diff --git a/studio/components/interfaces/Settings/Vault/VaultToggle.tsx b/studio/components/interfaces/Settings/Vault/VaultToggle.tsx index 1b6647afd05..f03f88633c7 100644 --- a/studio/components/interfaces/Settings/Vault/VaultToggle.tsx +++ b/studio/components/interfaces/Settings/Vault/VaultToggle.tsx @@ -6,6 +6,7 @@ import { Button, IconExternalLink } from 'ui' import { PermissionAction } from '@supabase/shared-types/out/constants' import { checkPermissions, useParams, useStore } from 'hooks' +import { BASE_PATH } from 'lib/constants' interface Props {} @@ -63,8 +64,8 @@ const VaultToggle: FC = () => { backgroundSize: isNotAvailable ? '50%' : '40%', backgroundPosition: '100% 24%', backgroundImage: ui.isDarkTheme - ? 'url("/img/vault-dark.png")' - : 'url("/img/vault-light.png")', + ? `url("${BASE_PATH}/img/vault-dark.png")` + : `url("${BASE_PATH}/img/vault-light.png")`, }} >
    @@ -127,19 +128,21 @@ const VaultToggle: FC = () => { {!canToggleVault && ( - - -
    - - You need additional permissions to enable Vault for this project - -
    -
    + + + +
    + + You need additional permissions to enable Vault for this project + +
    +
    +
    )}
    diff --git a/studio/components/interfaces/SignIn/ForgotPasswordForm.tsx b/studio/components/interfaces/SignIn/ForgotPasswordForm.tsx index 3cdcde52d25..309b739a03e 100644 --- a/studio/components/interfaces/SignIn/ForgotPasswordForm.tsx +++ b/studio/components/interfaces/SignIn/ForgotPasswordForm.tsx @@ -1,7 +1,7 @@ import HCaptcha from '@hcaptcha/react-hcaptcha' import { useStore } from 'hooks' import { post } from 'lib/common/fetch' -import { API_URL } from 'lib/constants' +import { API_URL, BASE_PATH } from 'lib/constants' import { useRouter } from 'next/router' import { useRef, useState } from 'react' import { Button, Form, Input } from 'ui' @@ -35,9 +35,9 @@ const ForgotPasswordForm = () => { hcaptchaToken: token ?? undefined, redirectTo: `${ process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview' - ? process.env.NEXT_PUBLIC_VERCEL_URL + ? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}` : process.env.NEXT_PUBLIC_SITE_URL - }/reset-password`, + }${BASE_PATH}/reset-password`, }) const error = response.error @@ -45,7 +45,7 @@ const ForgotPasswordForm = () => { ui.setNotification({ id: toastId, category: 'success', - message: `Password reset email sent successfully! Please check your email`, + message: `If you registered using your email and password, you will receive a password reset email.`, }) await router.push('/sign-in') @@ -71,7 +71,7 @@ const ForgotPasswordForm = () => { > {({ isSubmitting }: { isSubmitting: boolean }) => { return ( -
    +
    { />
    -
    +
    - + + +
    diff --git a/studio/components/interfaces/TableGridEditor/APIDocumentationPanel.tsx b/studio/components/interfaces/TableGridEditor/APIDocumentationPanel.tsx new file mode 100644 index 00000000000..0557b5e4d02 --- /dev/null +++ b/studio/components/interfaces/TableGridEditor/APIDocumentationPanel.tsx @@ -0,0 +1,142 @@ +import { useState } from 'react' +import { SidePanel, IconBookOpen } from 'ui' + +import { useParams, useStore } from 'hooks' +import { GeneralContent, ResourceContent } from '../Docs' +import LangSelector from '../Docs/LangSelector' +import GeneratingTypes from '../Docs/GeneratingTypes' +import ActionBar from './SidePanelEditor/ActionBar' +import { useProjectApiQuery } from 'data/config/project-api-query' +import { useProjectJsonSchemaQuery } from 'data/docs/project-json-schema-query' +import { snakeToCamel } from 'lib/helpers' + +interface APIDocumentationPanelProps { + visible: boolean + onClose: () => void +} + +const APIDocumentationPanel = ({ visible, onClose }: APIDocumentationPanelProps) => { + const { meta } = useStore() + const { ref, page, id } = useParams() + const { + data: jsonSchema, + error: jsonSchemaError, + refetch, + } = useProjectJsonSchemaQuery({ projectRef: ref }) + const { data: settings } = useProjectApiQuery({ projectRef: ref }) + + const DEFAULT_KEY = { name: 'hide', key: 'SUPABASE_KEY' } + const [selectedLang, setSelectedLang] = useState('js') + const [showApiKey, setShowApiKey] = useState(DEFAULT_KEY) + + const tables = meta.tables.list() + const autoApiService = { + ...settings?.autoApiService, + endpoint: `${settings?.autoApiService.protocol ?? 'https'}://${ + settings?.autoApiService.endpoint ?? '-' + }`, + } + const apiService = settings?.autoApiService + const anonKey = apiService?.service_api_keys.find((x) => x.name === 'anon key') + ? apiService.defaultApiKey + : undefined + const resources = getResourcesFromJsonSchema(jsonSchema) + + function getResourcesFromJsonSchema(value: any) { + const { paths } = value || {} + const functionPath = 'rpc/' + let resources: any = {} + + Object.entries(paths || []).forEach(([name, val]) => { + let trimmed = name.slice(1) + let id = trimmed.replace(functionPath, '') + let displayName = id.replace(/_/g, ' ') + let camelCase = snakeToCamel(id) + let enriched = { id, displayName, camelCase } + if (!trimmed.length) return + else resources[id] = enriched + }) + + return resources + } + + return ( + onClose()} + header={ + + + API + + } + customFooter={ + onClose()} + /> + } + > +
    + + {jsonSchemaError ? ( +
    +
    +

    Error connecting to API

    +

    {`${jsonSchemaError}`}

    +
    +
    + ) : ( + <> + {jsonSchema ? ( + <> +
    + +
    + + + + + {jsonSchema?.definitions && ( + table.id === Number(id))?.name} + resources={resources} + definitions={jsonSchema.definitions} + paths={jsonSchema.paths} + showApiKey={showApiKey.key} + refreshDocs={async () => await refetch()} + /> + )} + + ) : ( +
    +

    Building docs ...

    +
    + )} + + )} +
    +
    +
    + ) +} + +export default APIDocumentationPanel diff --git a/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx b/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx index 9996fc88488..543b78c669c 100644 --- a/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx +++ b/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx @@ -55,25 +55,27 @@ const GridHeaderActions: FC = ({ Viewing as read-only
    - - -
    - - You need additional permissions to manage your project's data - -
    -
    + + + +
    + + You need additional permissions to manage your project's data + +
    +
    +
    )}
    - + + } @@ -195,8 +210,8 @@ const ColumnForeignKeyRemoved: FC<{ const ColumnForeignKeyUpdated: FC<{ columnName: string - originalForeignKey?: PostgresRelationship - updatedForeignKey?: PostgresRelationship + originalForeignKey?: ExtendedPostgresRelationship + updatedForeignKey?: ExtendedPostgresRelationship onSelectEditRelation: () => void onSelectRemoveRelation: () => void }> = ({ @@ -206,6 +221,12 @@ const ColumnForeignKeyUpdated: FC<{ onSelectEditRelation, onSelectRemoveRelation, }) => { + const originalKey = `${originalForeignKey?.target_table_schema}.${originalForeignKey?.target_table_name}.${originalForeignKey?.target_column_name}` + const updatedKey = `${updatedForeignKey?.target_table_schema}.${updatedForeignKey?.target_table_name}.${updatedForeignKey?.target_column_name}` + + const originalDeletionAction = getForeignKeyDeletionAction(originalForeignKey?.deletion_action) + const updatedDeletionAction = getForeignKeyDeletionAction(updatedForeignKey?.deletion_action) + return (
    - {columnName} + {columnName}
    -

    - - {originalForeignKey?.target_table_schema}. - {originalForeignKey?.target_table_name}.{originalForeignKey?.target_column_name} - -

    - - {updatedForeignKey?.target_table_schema}.{updatedForeignKey?.target_table_name}. - {updatedForeignKey?.target_column_name} - + {originalKey !== updatedKey && ( +

    + {originalKey} +

    + )} + {updatedKey} +
    +
    + {originalDeletionAction !== undefined && + originalDeletionAction !== updatedDeletionAction && ( + + On delete: {originalDeletionAction} + + )} + {updatedDeletionAction !== undefined && ( +
    + On delete: {updatedDeletionAction} +
    + )}
    diff --git a/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/InputWithSuggestions.tsx b/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/InputWithSuggestions.tsx index 59da15bb67b..af4232ea154 100644 --- a/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/InputWithSuggestions.tsx +++ b/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/InputWithSuggestions.tsx @@ -112,19 +112,21 @@ const InputWithSuggestions: FC = ({ icon={} /> - - -
    - - {suggestionsTooltip || 'Suggestions'} - -
    -
    + + + +
    + + {suggestionsTooltip || 'Suggestions'} + +
    +
    +
    ) diff --git a/studio/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.constants.ts b/studio/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.constants.ts new file mode 100644 index 00000000000..411938bbc4e --- /dev/null +++ b/studio/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.constants.ts @@ -0,0 +1,9 @@ +import { FOREIGN_KEY_DELETION_ACTION } from 'data/database/database-query-constants' + +export const FOREIGN_KEY_DELETION_OPTIONS = [ + { key: 'no-action', label: 'No action', value: FOREIGN_KEY_DELETION_ACTION.NO_ACTION }, + { key: 'cascade', label: 'Cascade', value: FOREIGN_KEY_DELETION_ACTION.CASCADE }, + { key: 'restrict', label: 'Restrict', value: FOREIGN_KEY_DELETION_ACTION.RESTRICT }, + { key: 'set-default', label: 'Set default', value: FOREIGN_KEY_DELETION_ACTION.SET_DEFAULT }, + { key: 'set-null', label: 'Set NULL', value: FOREIGN_KEY_DELETION_ACTION.SET_NULL }, +] diff --git a/studio/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.tsx b/studio/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.tsx index 823ad2382d4..bcf7dac9cd0 100644 --- a/studio/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.tsx +++ b/studio/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.tsx @@ -1,7 +1,7 @@ import React, { FC, useEffect, useState } from 'react' import { get, find, isEmpty, sortBy } from 'lodash' import { Dictionary } from 'components/grid' -import { SidePanel, Input, Listbox, IconHelpCircle, IconDatabase } from 'ui' +import { SidePanel, Input, Listbox, IconHelpCircle, IconDatabase, Toggle } from 'ui' import type { PostgresTable, PostgresColumn, PostgresSchema } from '@supabase/postgres-meta' import { useStore } from 'hooks' @@ -9,13 +9,18 @@ import ActionBar from '../ActionBar' import { ForeignKey } from './ForeignKeySelector.types' import { ColumnField } from '../SidePanelEditor.types' import InformationBox from 'components/ui/InformationBox' +import { FOREIGN_KEY_DELETION_ACTION } from 'data/database/database-query-constants' +import { FOREIGN_KEY_DELETION_OPTIONS } from './ForeignKeySelector.constants' +import { generateDeletionActionDescription } from './ForeignKeySelector.utils' interface Props { column: ColumnField metadata?: any visible: boolean closePanel: () => void - saveChanges: (value: { table: PostgresTable; column: PostgresColumn } | undefined) => void + saveChanges: ( + value: { table: PostgresTable; column: PostgresColumn; deletionAction: string } | undefined + ) => void } const ForeignKeySelector: FC = ({ column, visible = false, closePanel, saveChanges }) => { @@ -25,6 +30,7 @@ const ForeignKeySelector: FC = ({ column, visible = false, closePanel, sa schema: 'public', table: '', column: '', + deletionAction: FOREIGN_KEY_DELETION_ACTION.NO_ACTION, }) const schemas = meta.schemas.list() @@ -51,12 +57,14 @@ const ForeignKeySelector: FC = ({ column, visible = false, closePanel, sa schema: foreignKey.target_table_schema, table: foreignKey.target_table_name, column: foreignKey.target_column_name, + deletionAction: foreignKey.deletion_action, }) } else { setSelectedForeignKey({ schema: 'public', table: '', column: '', + deletionAction: FOREIGN_KEY_DELETION_ACTION.NO_ACTION, }) } } @@ -64,7 +72,12 @@ const ForeignKeySelector: FC = ({ column, visible = false, closePanel, sa const updateSelectedSchema = (schema: string) => { meta.tables.loadBySchema(schema) - const updatedForeignKey = { ...selectedForeignKey, schema, table: '', column: '' } as ForeignKey + const updatedForeignKey = { + schema, + table: '', + column: '', + deletionAction: FOREIGN_KEY_DELETION_ACTION.NO_ACTION, + } setSelectedForeignKey(updatedForeignKey) } @@ -75,6 +88,7 @@ const ForeignKeySelector: FC = ({ column, visible = false, closePanel, sa schema: '', table: '', column: '', + deletionAction: FOREIGN_KEY_DELETION_ACTION.NO_ACTION, }) } const table = find(tables, { id: tableId }) @@ -83,6 +97,7 @@ const ForeignKeySelector: FC = ({ column, visible = false, closePanel, sa schema: table.schema, table: table.name, column: table.columns?.length ? table.columns[0].name : undefined, + deletionAction: FOREIGN_KEY_DELETION_ACTION.NO_ACTION, }) } } @@ -96,6 +111,11 @@ const ForeignKeySelector: FC = ({ column, visible = false, closePanel, sa } } + const updateDeletionAction = (value: string) => { + setErrors({}) + setSelectedForeignKey({ ...selectedForeignKey, deletionAction: value }) + } + const onSaveChanges = (resolve: () => void) => { const errors = {} as Dictionary if (!selectedForeignKey?.table) { @@ -110,7 +130,11 @@ const ForeignKeySelector: FC = ({ column, visible = false, closePanel, sa // Remove foreign key since no table selected saveChanges(undefined) } else { - saveChanges({ table: selectedTable, column: selectedColumn }) + saveChanges({ + table: selectedTable, + column: selectedColumn, + deletionAction: selectedForeignKey.deletionAction, + }) } } resolve() @@ -211,7 +235,11 @@ const ForeignKeySelector: FC = ({ column, visible = false, closePanel, sa disabled label={
    - Select a column from {selectedForeignKey?.table} to reference to + Select a column from{' '} + + {selectedForeignKey?.schema}.{selectedForeignKey?.table} + {' '} + to reference to
    } error={errors.column} @@ -224,7 +252,11 @@ const ForeignKeySelector: FC = ({ column, visible = false, closePanel, sa // @ts-ignore label={
    - Select a column from {selectedForeignKey?.table} to reference to + Select a column from{' '} + + {selectedForeignKey?.schema}.{selectedForeignKey?.table} + {' '} + to reference to
    } error={errors.column} @@ -240,6 +272,60 @@ const ForeignKeySelector: FC = ({ column, visible = false, closePanel, sa ))} )} + + } + title="Which action is most appropriate?" + description={ + <> +

    + The choice of the action depends on what kinds of objects the related tables + represent: +

    +
      +
    • + Cascade: if the referencing table + represents something that is a component of what is represented by the + referenced table and cannot exist independently +
    • +
    • + Restrict or{' '} + No action: if the two tables represent + independent objects +
    • +
    • + Set NULL or{' '} + Set default: if a foreign-key relationship + represents optional information +
    • +
    +

    + Typically, restricting and cascading deletes are the most common options, but + the default behavior is no action +

    + + } + url="https://www.postgresql.org/docs/current/ddl-constraints.html#DDL-CONSTRAINTS-FK" + urlLabel="More information" + /> + updateDeletionAction(value)} + > + {FOREIGN_KEY_DELETION_OPTIONS.map((option) => ( + +

    {option.label}

    +
    + ))} +
    )} diff --git a/studio/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.types.ts b/studio/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.types.ts index a5a73bf717f..3451d63707f 100644 --- a/studio/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.types.ts +++ b/studio/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.types.ts @@ -2,4 +2,5 @@ export interface ForeignKey { schema: string table: string column?: string + deletionAction: string } diff --git a/studio/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.utils.tsx b/studio/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.utils.tsx new file mode 100644 index 00000000000..1e5a32085d2 --- /dev/null +++ b/studio/components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.utils.tsx @@ -0,0 +1,77 @@ +import * as Tooltip from '@radix-ui/react-tooltip' +import { FOREIGN_KEY_DELETION_ACTION } from 'data/database/database-query-constants' +import { IconHelpCircle } from 'ui' +import { getForeignKeyDeletionAction } from '../ColumnEditor/ColumnEditor.utils' + +export const generateDeletionActionDescription = (deletionAction: string, reference: string) => { + const actionName = getForeignKeyDeletionAction(deletionAction) ?? 'No action' + + switch (deletionAction) { + case FOREIGN_KEY_DELETION_ACTION.NO_ACTION: + return ( + <> + {actionName}: Deleting a record from{' '} + {reference} will{' '} + raise an error if there are records + existing in this table that reference it + + ) + case FOREIGN_KEY_DELETION_ACTION.CASCADE: + return ( + <> + {actionName}: Deleting a record from{' '} + {reference} will{' '} + also delete any records that reference + it in this table + + ) + case FOREIGN_KEY_DELETION_ACTION.RESTRICT: + return ( + <> + {actionName} + + + + + + + +
    +

    + This is similar to no action, but the restrict check cannot be deferred till + later in the transaction +

    +
    +
    +
    +
    + : Deleting a record from {reference} will{' '} + prevent deletion of existing + referencing rows from this table. + + ) + case FOREIGN_KEY_DELETION_ACTION.SET_DEFAULT: + return ( + <> + {actionName}: Deleting a record from{' '} + {reference} will set the value of any + existing records in this table referencing it to their{' '} + default value + + ) + case FOREIGN_KEY_DELETION_ACTION.SET_NULL: + return ( + <> + {actionName}: Deleting a record from{' '} + {reference} will set the value of any + existing records in this table referencing it{' '} + to NULL + + ) + } +} diff --git a/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/ForeignRowSelector.tsx b/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/ForeignRowSelector.tsx new file mode 100644 index 00000000000..add2ac309c1 --- /dev/null +++ b/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/ForeignRowSelector.tsx @@ -0,0 +1,180 @@ +import { PostgresTable } from '@supabase/postgres-meta' +import { DndProvider } from 'react-dnd' +import { HTML5Backend } from 'react-dnd-html5-backend' +import { parseSupaTable } from 'components/grid' +import RefreshButton from 'components/grid/components/header/RefreshButton' +import FilterPopover from 'components/grid/components/header/filter' +import SortPopover from 'components/grid/components/header/sort' +import { formatFilterURLParams, formatSortURLParams } from 'components/grid/SupabaseGrid.utils' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { useTableRowsQuery } from 'data/table-rows/table-rows-query' +import { ForeignKeyConstraint } from 'data/database/foreign-key-constraints-query' +import { useStore } from 'hooks' +import { IconLoader, SidePanel } from 'ui' + +import ActionBar from '../../ActionBar' +import { useEncryptedColumns } from './ForeignRowSelector.utils' +import SelectorGrid from './SelectorGrid' +import { useEffect, useState } from 'react' +import Pagination from './Pagination' + +export interface ForeignRowSelectorProps { + visible: boolean + foreignKey?: ForeignKeyConstraint + onSelect: (value: any) => void + closePanel: () => void +} + +const ForeignRowSelector = ({ + visible, + foreignKey, + onSelect, + closePanel, +}: ForeignRowSelectorProps) => { + const { meta } = useStore() + const { project } = useProjectContext() + + const { + target_id: tableId, + target_schema: schemaName, + target_table: tableName, + target_columns: columnName, + } = foreignKey ?? {} + + const table = tableId ? meta.tables.byId(tableId) : undefined + + useEffect(() => { + if (!table && tableId) { + meta.tables.loadById(tableId) + } + }, [table, tableId]) + + const encryptedColumns = useEncryptedColumns({ schemaName, tableName }) + + const supaTable = + table && + parseSupaTable( + { + table: table as PostgresTable, + columns: (table as PostgresTable).columns ?? [], + primaryKeys: (table as PostgresTable).primary_keys, + relationships: (table as PostgresTable).relationships, + }, + encryptedColumns + ) + + const [params, setParams] = useState({ filter: [], sort: [] }) + + const sorts = formatSortURLParams(params.sort ?? []) + const filters = formatFilterURLParams(params.filter ?? []) + + const rowsPerPage = 100 + const [page, setPage] = useState(1) + + const { data, isLoading, isSuccess, isError, isRefetching } = useTableRowsQuery( + { + queryKey: [schemaName, tableName], + projectRef: project?.ref, + connectionString: project?.connectionString, + table: supaTable, + sorts, + filters, + page, + limit: rowsPerPage, + }, + { + keepPreviousData: true, + } + ) + + return ( + + Select a record to reference from{' '} + + {schemaName}.{tableName} + + + } + onCancel={closePanel} + customFooter={} + > + +
    + {isLoading && ( +
    + +

    Loading rows

    +
    + )} + + {isError && ( +
    +

    + Unable to load rows from{' '} + + {schemaName}.{tableName} + + . Please try again or contact support. +

    +
    + )} + + {isSuccess && supaTable && ( +
    +
    +
    + + { + // Reset page to 1 when filters change + if (page > 1) { + setPage(1) + } + + setParams(...args) + }} + /> + + + +
    + + +
    + + {data.rows.length > 0 ? ( + onSelect(row[columnName ?? ''])} + /> + ) : ( +
    + No Rows Found +
    + )} +
    + )} +
    +
    +
    + ) +} + +export default ForeignRowSelector diff --git a/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/ForeignRowSelector.utils.ts b/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/ForeignRowSelector.utils.ts new file mode 100644 index 00000000000..7bb0a05c154 --- /dev/null +++ b/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/ForeignRowSelector.utils.ts @@ -0,0 +1,36 @@ +import { useFlag, useStore } from 'hooks' +import { useEffect, useState } from 'react' + +export interface UseEncryptedColumnsArgs { + schemaName?: string + tableName?: string +} + +export function useEncryptedColumns({ schemaName, tableName }: UseEncryptedColumnsArgs) { + const isVaultEnabled = useFlag('vaultExtension') + const { vault } = useStore() + + const [encryptedColumns, setEncryptedColumns] = useState([]) + + useEffect(() => { + let isMounted = true + + const getEncryptedColumns = async () => { + if (schemaName !== undefined && tableName !== undefined && isVaultEnabled) { + const columns = await vault.listEncryptedColumns(schemaName, tableName) + + if (isMounted) { + setEncryptedColumns(columns) + } + } + } + + getEncryptedColumns() + + return () => { + isMounted = false + } + }, [schemaName, tableName, isVaultEnabled]) + + return encryptedColumns +} diff --git a/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/Pagination.tsx b/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/Pagination.tsx new file mode 100644 index 00000000000..4e01b3063d4 --- /dev/null +++ b/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/Pagination.tsx @@ -0,0 +1,53 @@ +import { Button, IconArrowLeft, IconArrowRight, IconLoader } from 'ui' + +export interface PaginationProps { + page: number + setPage: (setter: (prev: number) => number) => void + rowsPerPage: number + currentPageRowsCount?: number + isLoading?: boolean +} + +const Pagination = ({ + page, + setPage, + rowsPerPage, + currentPageRowsCount = 0, + isLoading = false, +}: PaginationProps) => { + const onPreviousPage = () => { + setPage((prev) => prev - 1) + } + + const onNextPage = () => { + setPage((prev) => prev + 1) + } + + const hasRunOutOfRows = currentPageRowsCount < rowsPerPage + + return ( +
    + {isLoading && } + +
    + ) +} + +export default Pagination diff --git a/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/SelectorGrid.tsx b/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/SelectorGrid.tsx new file mode 100644 index 00000000000..a77e38a9039 --- /dev/null +++ b/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/SelectorGrid.tsx @@ -0,0 +1,82 @@ +import DataGrid, { Column } from '@supabase/react-data-grid' +import * as Tooltip from '@radix-ui/react-tooltip' +import { SupaRow, SupaTable } from 'components/grid' +import { IconKey } from 'ui' +import { + ESTIMATED_CHARACTER_PIXEL_WIDTH, + getColumnDefaultWidth, +} from 'components/grid/utils/gridColumns' +import { COLUMN_MIN_WIDTH } from 'components/grid/constants' + +export interface SelectorGridProps { + table: SupaTable + rows: SupaRow[] + onRowSelect: (row: SupaRow) => void +} + +const columnRender = (name: string, isPrimaryKey = false) => { + return ( +
    + {isPrimaryKey && ( + + +
    + +
    +
    + + + +
    + Primary key +
    +
    +
    +
    + )} + + {name} +
    + ) +} + +const formatter = (column: string, row: any) => { + const formattedValue = typeof row[column] === 'object' ? JSON.stringify(row[column]) : row[column] + return ( +
    + {formattedValue} +
    + ) +} + +const SelectorGrid = ({ table, rows, onRowSelect }: SelectorGridProps) => { + const columns: Column[] = table.columns.map((column) => { + const columnDefaultWidth = getColumnDefaultWidth(column) + const columnWidthBasedOnName = + (column.name.length + column.format.length) * ESTIMATED_CHARACTER_PIXEL_WIDTH + const columnWidth = + columnDefaultWidth < columnWidthBasedOnName ? columnWidthBasedOnName : columnDefaultWidth + + return { + key: column.name, + name: column.name, + formatter: ({ row }: any) => formatter(column.name, row), + headerRenderer: () => columnRender(column.name, column.isPrimaryKey), + resizable: true, + width: columnWidth, + minWidth: COLUMN_MIN_WIDTH, + } + }) + + return ( + 'cursor-pointer'} + /> + ) +} + +export default SelectorGrid diff --git a/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/HeaderTitle.tsx b/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/HeaderTitle.tsx index b9b2823c7c9..38d4e20d5f3 100644 --- a/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/HeaderTitle.tsx +++ b/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/HeaderTitle.tsx @@ -11,7 +11,7 @@ const HeaderTitle: FC = ({ isNewRecord, tableName }) => { return ( <> {header} - {tableName && {tableName}} + {tableName && {tableName}} ) } diff --git a/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/InputField.tsx b/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/InputField.tsx index 5c3b2e6fead..cabdefd6fea 100644 --- a/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/InputField.tsx +++ b/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/InputField.tsx @@ -1,28 +1,28 @@ import { FC } from 'react' -import { isUndefined, includes } from 'lodash' -import { Button, Select, Input, IconLink, IconArrowRight, IconEdit2 } from 'ui' +import { isUndefined, includes, noop } from 'lodash' +import { Button, Select, Input, IconLink, IconArrowRight, IconEdit2, Listbox } from 'ui' import { RowField } from './RowEditor.types' import DateTimeInput from './DateTimeInput' import { TEXT_TYPES, JSON_TYPES, DATETIME_TYPES } from '../SidePanelEditor.constants' -interface Props { +export interface InputFieldProps { field: RowField errors: any isEditable?: boolean onUpdateField?: (changes: object) => void onEditJson?: (data: any) => void - onViewForeignKey?: () => void + onSelectForeignKey?: () => void } -const InputField: FC = ({ +const InputField = ({ field, errors, isEditable = true, - onUpdateField = () => {}, - onEditJson = () => {}, - onViewForeignKey = () => {}, -}) => { + onUpdateField = noop, + onEditJson = noop, + onSelectForeignKey = noop, +}: InputFieldProps) => { if (field.enums.length > 0) { const isArray = field.format[0] === '_' if (isArray) { @@ -42,7 +42,7 @@ const InputField: FC = ({ field.defaultValue === null ? '' : typeof field.defaultValue === 'string' && field.defaultValue.length === 0 - ? 'Default: Empty string' + ? 'EMPTY' : `Default: ${field.defaultValue}` } onChange={(event: any) => onUpdateField({ [field.name]: event.target.value })} @@ -73,26 +73,24 @@ const InputField: FC = ({ } } - if (!isUndefined(field.foreignKey)) { + if (field.foreignKey !== undefined) { return ( element as a parent descriptionText={ -
    - {field.comment &&

    {field.comment}

    } -

    ({field.name}

    -

    - -

    -

    + <> + {field.comment && {field.comment} } + + {field.comment && '('}Has a foreign key relation to + + {field.foreignKey.target_table_schema}.{field.foreignKey.target_table_name}. - {field.foreignKey.target_column_name}) -

    -
    + {field.foreignKey.target_column_name} + + {field.comment && {`)`}} + } labelOptional={field.format} disabled={!isEditable} @@ -100,13 +98,13 @@ const InputField: FC = ({ onChange={(event: any) => onUpdateField({ [field.name]: event.target.value })} actions={ } /> @@ -127,11 +125,26 @@ const InputField: FC = ({ rows={5} value={field.value ?? ''} placeholder={ - field.defaultValue === null - ? '' + field.value === null && field.defaultValue === null + ? 'NULL' + : field.value === '' + ? 'EMPTY' : typeof field.defaultValue === 'string' && field.defaultValue.length === 0 - ? 'Default: Empty string' - : `Default: ${field.defaultValue}` + ? 'EMPTY' + : `NULL (Default: ${field.defaultValue})` + } + actions={ +
    + {(field.isNullable || (!field.isNullable && field.defaultValue)) && ( + + )} +
    } onChange={(event: any) => onUpdateField({ [field.name]: event.target.value })} /> @@ -148,7 +161,7 @@ const InputField: FC = ({ descriptionText={field.comment} labelOptional={field.format} disabled={!isEditable} - placeholder={field?.defaultValue ?? ''} + placeholder={field?.defaultValue ?? 'NULL'} error={errors[field.name]} onChange={(event: any) => onUpdateField({ [field.name]: event.target.value })} actions={ @@ -182,6 +195,40 @@ const InputField: FC = ({ ) } + if (field.format === 'bool') { + const options = [ + { value: 'true', label: 'TRUE' }, + { value: 'false', label: 'FALSE' }, + ...(field.isNullable ? [{ value: 'null', label: 'NULL' }] : []), + ] + return ( + { + if (value === 'null') onUpdateField({ [field.name]: null }) + else onUpdateField({ [field.name]: value }) + }} + > + {options.map((option) => ( + + {option.label} + + ))} + + ) + } + return ( = ({ ? 'Automatically generated as identity' : field.defaultValue !== null ? `Default: ${field.defaultValue}` - : '' + : 'NULL' } disabled={!isEditable} onChange={(event: any) => onUpdateField({ [field.name]: event.target.value })} diff --git a/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/JsonEditor.tsx b/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/JsonEditor.tsx index ee2af239228..9e0329af84d 100644 --- a/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/JsonEditor.tsx +++ b/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/JsonEditor/JsonEditor.tsx @@ -3,7 +3,7 @@ import { SidePanel } from 'ui' import { useStore } from 'hooks' import JsonEditor from './JsonCodeEditor' -import TwoOptionToggle from './TwoOptionToggle' +import TwoOptionToggle from 'components/ui/TwoOptionToggle' import DrilldownViewer from './DrilldownViewer' import ActionBar from '../../ActionBar' import { minifyJSON, prettifyJSON, tryParseJson } from 'lib/helpers' diff --git a/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ReferenceRowViewer.tsx b/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ReferenceRowViewer.tsx deleted file mode 100644 index 8760f7b0385..00000000000 --- a/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ReferenceRowViewer.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import React, { FC, useEffect, useState } from 'react' -import { SidePanel, IconLoader, IconXCircle } from 'ui' -import type { PostgresRelationship } from '@supabase/postgres-meta' - -import ActionBar from '../ActionBar' -import InputField from './InputField' -import { RowField } from './RowEditor.types' -import { generateRowFieldsWithoutColumnMeta } from './RowEditor.utils' - -interface Props { - visible: boolean - referenceRow?: { loading: boolean; foreignKey: any; row: any } - closePanel: () => void -} - -const ReferenceRowViewer: FC = ({ visible, referenceRow, closePanel }) => { - const loading = referenceRow?.loading ?? true - const foreignKey: PostgresRelationship = referenceRow?.foreignKey ?? undefined - const row = referenceRow?.row ?? undefined - const [rowFields, setRowFields] = useState([]) - - useEffect(() => { - if (visible) { - if (!foreignKey) { - setRowFields([]) - } else if (row) { - const rowFields = generateRowFieldsWithoutColumnMeta(row) - setRowFields(rowFields) - } - } - }, [visible, referenceRow]) - - return ( - - Viewing reference row from{' '} - - {foreignKey?.target_table_schema ?? ''}.{foreignKey?.target_table_name ?? ''} - - - } - hideFooter={false} - onCancel={closePanel} - customFooter={} - > - -
    - {loading ? ( -
    - -

    Loading reference row

    -
    - ) : !row ? ( -
    - -

    - Unable to find the corresponding row in {foreignKey?.target_table_schema}. - {foreignKey?.target_table_name} -

    -
    - ) : ( -
    - {rowFields.map((field: RowField) => { - return - })} -
    - )} -
    -
    -
    - ) -} - -export default ReferenceRowViewer diff --git a/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.tsx b/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.tsx index 4ebd6a4f6bc..5866545dff8 100644 --- a/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.tsx +++ b/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.tsx @@ -1,25 +1,25 @@ -import { FC, useEffect, useState } from 'react' -import { isUndefined, partition, isEmpty } from 'lodash' +import { useEffect, useMemo, useState } from 'react' +import { isUndefined, partition, isEmpty, noop } from 'lodash' import { SidePanel } from 'ui' import { Dictionary } from 'components/grid' -import { Query } from 'components/grid/query/Query' import type { PostgresTable } from '@supabase/postgres-meta' -import { useStore } from 'hooks' +import { useForeignKeyConstraintsQuery } from 'data/database/foreign-key-constraints-query' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import ActionBar from '../ActionBar' import HeaderTitle from './HeaderTitle' import InputField from './InputField' import JsonEdit from './JsonEditor' -import ReferenceRowViewer from './ReferenceRowViewer' +import ForeignRowSelector from './ForeignRowSelector/ForeignRowSelector' import { generateRowFields, validateFields, generateRowObjectFromFields, generateUpdateRowPayload, } from './RowEditor.utils' -import { JsonEditValue, ReferenceRow, RowField } from './RowEditor.types' +import { JsonEditValue, RowField } from './RowEditor.types' -interface Props { +export interface RowEditorProps { row?: Dictionary selectedTable: PostgresTable visible: boolean @@ -28,21 +28,20 @@ interface Props { updateEditorDirty: () => void } -const RowEditor: FC = ({ +const RowEditor = ({ row, selectedTable, visible = false, - closePanel = () => {}, - saveChanges = () => {}, - updateEditorDirty = () => {}, -}) => { - const { meta, ui } = useStore() + closePanel = noop, + saveChanges = noop, + updateEditorDirty = noop, +}: RowEditorProps) => { const [errors, setErrors] = useState>({}) const [rowFields, setRowFields] = useState([]) const [selectedValueForJsonEdit, setSelectedValueForJsonEdit] = useState() - const [isViewingReferenceRow, setIsViewingReferenceRow] = useState(false) - const [referenceRow, setReferenceRow] = useState() + const [isSelectingForeignKey, setIsSelectingForeignKey] = useState(false) + const [referenceRow, setReferenceRow] = useState() const isNewRecord = isUndefined(row) const isEditingJson = !isUndefined(selectedValueForJsonEdit) @@ -54,6 +53,21 @@ const RowEditor: FC = ({ (rowField: any) => !rowField.isNullable ) + const { project } = useProjectContext() + const { data } = useForeignKeyConstraintsQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + schema: selectedTable.schema, + }) + + const foreignKey = useMemo( + () => + data && referenceRow?.foreignKey?.id + ? data.find((key) => key.id === referenceRow.foreignKey?.id) + : undefined, + [data, referenceRow?.foreignKey?.id] + ) + useEffect(() => { if (visible) { setErrors({}) @@ -75,45 +89,18 @@ const RowEditor: FC = ({ updateEditorDirty() } - const onViewForeignKey = async (row: RowField) => { - // Possible low prio refactor: Shift fetching reference row retrieval to ReferenceRowViewer - // in a useEffect, rather than trying to manage a loading state in this method - if (!row.value) { - ui.setNotification({ - category: 'error', - message: `Please enter a value in the ${row.name} field first`, - duration: 4000, - }) - } - const foreignKey = row.foreignKey - setReferenceRow({ loading: true, foreignKey, row: undefined }) - setIsViewingReferenceRow(true) + const onOpenForeignRowSelector = async (row: RowField) => { + setIsSelectingForeignKey(true) + setReferenceRow(row) + } - if (foreignKey) { - const schema = foreignKey.target_table_schema - const table = foreignKey.target_table_name - const column = foreignKey.target_column_name + const onSelectForeignRowValue = (value: any) => { + if (!referenceRow) return - const query = new Query() - .from(table, schema) - .select() - .match({ [column]: row.value }) - .toSql() - const res = await meta.query(query) - if (res.error) { - setReferenceRow({ loading: false, foreignKey, row: undefined }) - return ui.setNotification({ category: 'error', message: res.error.message }) - } - if (res.length === 0) { - setReferenceRow({ loading: false, foreignKey, row: undefined }) - return ui.setNotification({ - category: 'error', - message: `Unable to find the corresponding row in ${foreignKey.target_table_schema}.${foreignKey.target_table_name} where ${foreignKey.target_column_name} equals ${row.value}`, - duration: 4000, - }) - } - setReferenceRow({ loading: false, foreignKey, row: res[0] }) - } + onUpdateField({ [referenceRow.name]: value }) + + setIsSelectingForeignKey(false) + setReferenceRow(undefined) } const onSaveChanges = (e: React.FormEvent) => { @@ -153,14 +140,12 @@ const RowEditor: FC = ({ visible={visible} header={} className={`transition-all duration-100 ease-in ${ - isEditingJson || isViewingReferenceRow ? ' mr-32' : '' + isEditingJson || isSelectingForeignKey ? ' mr-32' : '' }`} onCancel={closePanel} onInteractOutside={(event) => { const isToast = (event.target as Element)?.closest('#toast') - if (isToast) { - event.preventDefault() - } + if (isToast) event.preventDefault() }} >
    onSaveChanges(e)} className="h-full"> @@ -176,7 +161,7 @@ const RowEditor: FC = ({ errors={errors} onUpdateField={onUpdateField} onEditJson={setSelectedValueForJsonEdit} - onViewForeignKey={() => onViewForeignKey(field)} + onSelectForeignKey={() => onOpenForeignRowSelector(field)} /> ) })} @@ -201,7 +186,7 @@ const RowEditor: FC = ({ errors={errors} onUpdateField={onUpdateField} onEditJson={setSelectedValueForJsonEdit} - onViewForeignKey={() => onViewForeignKey(field)} + onSelectForeignKey={() => onOpenForeignRowSelector(field)} /> ) })} @@ -220,15 +205,6 @@ const RowEditor: FC = ({ setSelectedValueForJsonEdit(undefined) }} /> - - { - setIsViewingReferenceRow(false) - setReferenceRow(undefined) - }} - />
    = ({
    + + { + setIsSelectingForeignKey(false) + setReferenceRow(undefined) + }} + /> ) } diff --git a/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.types.ts b/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.types.ts index fe7eade0c1f..b9093798754 100644 --- a/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.types.ts +++ b/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.types.ts @@ -6,12 +6,6 @@ export interface JsonEditValue { jsonString: string } -export interface ReferenceRow { - loading: boolean - foreignKey: any // ForeignKey - row: any // RowField -} - export interface RowField { id: string name: string diff --git a/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils.ts b/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils.ts index 7bbe183f5f7..ac195962610 100644 --- a/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils.ts +++ b/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.utils.ts @@ -3,9 +3,14 @@ import { find, isUndefined, compact, isEqual, omitBy, isNull, isString } from 'l import { Dictionary } from 'components/grid' import type { PostgresTable } from '@supabase/postgres-meta' -import { uuidv4, minifyJSON, tryParseJson } from 'lib/helpers' +import { minifyJSON, tryParseJson } from 'lib/helpers' import { RowField } from './RowEditor.types' -import { DATETIME_TYPES, TIME_TYPES, TIMESTAMP_TYPES } from '../SidePanelEditor.constants' +import { + DATETIME_TYPES, + TIME_TYPES, + TIMESTAMP_TYPES, + TEXT_TYPES, +} from '../SidePanelEditor.constants' export const generateRowFields = ( row: Dictionary | undefined, @@ -16,11 +21,18 @@ export const generateRowFields = ( const primaryKeyColumns = primary_keys.map((key) => key.name) return table.columns!.map((column) => { - const value = isUndefined(row) - ? '' - : DATETIME_TYPES.includes(column.format) - ? convertPostgresDatetimeToInputDatetime(column.format, row[column.name]) - : parseValue(row[column.name], column.format) + const value = + isUndefined(row) && TEXT_TYPES.includes(column.format) + ? null + : isUndefined(row) && column.format === 'bool' && !column.is_nullable + ? 'true' + : isUndefined(row) && column.format === 'bool' && column.is_nullable + ? 'null' + : isUndefined(row) + ? '' + : DATETIME_TYPES.includes(column.format) + ? convertPostgresDatetimeToInputDatetime(column.format, row[column.name]) + : parseValue(row[column.name], column.format) const foreignKey = find(relationships, (relationship) => { return ( @@ -70,27 +82,7 @@ export const validateFields = (fields: RowField[]) => { return errors } -// Currently only used in ReferenceRowViewer for rows outside of public schema -export const generateRowFieldsWithoutColumnMeta = (row: any): RowField[] => { - const properties = Object.keys(row) - return properties.map((property) => { - return { - id: uuidv4(), - value: row[property] || '', - name: property, - comment: '', - format: '', - enums: [], - defaultValue: '', - foreignKey: undefined, - isNullable: false, - isIdentity: false, - isPrimaryKey: false, - } - }) -} - -const parseValue = (originalValue: string, format: string) => { +const parseValue = (originalValue: any, format: string) => { try { if (originalValue === null || originalValue.length === 0) { return originalValue @@ -99,7 +91,7 @@ const parseValue = (originalValue: string, format: string) => { } else if (typeof originalValue === 'object') { return JSON.stringify(originalValue) } else if (typeof originalValue === 'boolean') { - return (originalValue as any).toString() + return originalValue.toString() } else { return originalValue } @@ -162,7 +154,14 @@ export const generateRowObjectFromFields = ( const rowObject = {} as any fields.forEach((field) => { const isArray = field.format.startsWith('_') - const value = (field?.value ?? '').length === 0 ? null : field.value + + // Do not convert empty field inputs to NULL for text types + // so that we discern NULL and EMPTY + const value = TEXT_TYPES.includes(field.format) + ? field.value + : (field?.value ?? '').length === 0 + ? null + : field.value if (isArray && value !== null) { rowObject[field.name] = tryParseJson(value) @@ -173,7 +172,8 @@ export const generateRowObjectFromFields = ( rowObject[field.name] = tryParseJson(value) } } else if (field.format === 'bool' && value) { - rowObject[field.name] = value === 'true' + if (value === 'null') rowObject[field.name] = null + else rowObject[field.name] = value === 'true' } else if (DATETIME_TYPES.includes(field.format)) { // Seconds are missing if they are set to 0 in the HTML input // Need to format that first, before passing to dayjs diff --git a/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx b/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx index 93411f0a8cf..99ce84b03f4 100644 --- a/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx +++ b/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx @@ -1,28 +1,44 @@ -import { FC, useState } from 'react' -import { find, isEmpty, isUndefined } from 'lodash' -import { Query, Dictionary } from 'components/grid' +import { useState } from 'react' +import { QueryKey, useQueryClient } from '@tanstack/react-query' +import { find, isEmpty, isUndefined, noop } from 'lodash' +import { Dictionary } from 'components/grid' import { Modal } from 'ui' -import type { PostgresRelationship, PostgresTable, PostgresColumn } from '@supabase/postgres-meta' +import type { PostgresTable, PostgresColumn } from '@supabase/postgres-meta' import { useStore } from 'hooks' +import { entityTypeKeys } from 'data/entity-types/keys' import { useTableRowCreateMutation } from 'data/table-rows/table-row-create-mutation' import { useTableRowUpdateMutation } from 'data/table-rows/table-row-update-mutation' import { RowEditor, ColumnEditor, TableEditor } from '.' import { ImportContent } from './TableEditor/TableEditor.types' -import { ColumnField, CreateColumnPayload, UpdateColumnPayload } from './SidePanelEditor.types' +import { + ColumnField, + CreateColumnPayload, + ExtendedPostgresRelationship, + UpdateColumnPayload, +} from './SidePanelEditor.types' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import ConfirmationModal from 'components/ui/ConfirmationModal' import JsonEdit from './RowEditor/JsonEditor/JsonEditor' import { JsonEditValue } from './RowEditor/RowEditor.types' +import { sqlKeys } from 'data/sql/keys' +import ForeignRowSelector, { + ForeignRowSelectorProps, +} from './RowEditor/ForeignRowSelector/ForeignRowSelector' -interface Props { +export interface SidePanelEditorProps { selectedSchema: string selectedTable?: PostgresTable selectedRowToEdit?: Dictionary selectedColumnToEdit?: PostgresColumn selectedTableToEdit?: PostgresTable selectedValueForJsonEdit?: JsonEditValue - sidePanelKey?: 'row' | 'column' | 'table' | 'json' + selectedForeignKeyToEdit?: { + foreignKey: NonNullable + row: any + column: any + } + sidePanelKey?: 'row' | 'column' | 'table' | 'json' | 'foreign-row-selector' isDuplicating?: boolean closePanel: () => void onRowCreated?: (row: Dictionary) => void @@ -34,22 +50,24 @@ interface Props { onColumnSaved?: (hasEncryptedColumns?: boolean) => void } -const SidePanelEditor: FC = ({ +const SidePanelEditor = ({ selectedSchema, selectedTable, selectedRowToEdit, selectedColumnToEdit, selectedTableToEdit, selectedValueForJsonEdit, + selectedForeignKeyToEdit, sidePanelKey, isDuplicating = false, closePanel, - onRowCreated = () => {}, - onRowUpdated = () => {}, - onTableCreated = () => {}, - onColumnSaved = () => {}, -}) => { + onRowCreated = noop, + onRowUpdated = noop, + onTableCreated = noop, + onColumnSaved = noop, +}: SidePanelEditorProps) => { const { meta, ui } = useStore() + const queryClient = useQueryClient() const [isEdited, setIsEdited] = useState(false) const [isClosingPanel, setIsClosingPanel] = useState(false) @@ -58,7 +76,63 @@ const SidePanelEditor: FC = ({ const { project } = useProjectContext() const { mutateAsync: createTableRow } = useTableRowCreateMutation() - const { mutateAsync: updateTableRow } = useTableRowUpdateMutation() + const { mutateAsync: updateTableRow } = useTableRowUpdateMutation({ + async onMutate({ projectRef, table, configuration, payload }) { + closePanel() + + const primaryKeyColumns = new Set(Object.keys(configuration.identifiers)) + + const queryKey = sqlKeys.query(projectRef, [ + table.schema, + table.name, + { table: { name: table.name, schema: table.schema } }, + ]) + + await queryClient.cancelQueries(queryKey) + + const previousRowsQueries = queryClient.getQueriesData<{ result: any[] }>(queryKey) + + queryClient.setQueriesData<{ result: any[] }>(queryKey, (old) => { + return { + result: + old?.result.map((row) => { + // match primary keys + if ( + Object.entries(row) + .filter(([key]) => primaryKeyColumns.has(key)) + .every(([key, value]) => value === configuration.identifiers[key]) + ) { + return { ...row, ...payload } + } + + return row + }) ?? [], + } + }) + + return { previousRowsQueries } + }, + onError(error, _variables, context) { + const { previousRowsQueries } = context as { + previousRowsQueries: [ + QueryKey, + ( + | { + result: any[] + } + | undefined + ) + ][] + } + + previousRowsQueries.forEach(([queryKey, previousRows]) => { + if (previousRows) { + queryClient.setQueriesData(queryKey, previousRows) + } + queryClient.invalidateQueries(queryKey) + }) + }, + }) const saveRow = async ( payload: any, @@ -148,9 +222,25 @@ const SidePanelEditor: FC = ({ } catch (error: any) {} } + const onSaveForeignRow = async (value: any) => { + if (selectedTable === undefined || selectedForeignKeyToEdit === undefined) return + + try { + const { row, column } = selectedForeignKeyToEdit + const payload = { [column.name]: value } + const identifiers = {} as Dictionary + selectedTable.primary_keys.forEach((column) => (identifiers[column.name] = row![column.name])) + + const isNewRecord = false + const configuration = { identifiers, rowIdx: row.idx } + + saveRow(payload, isNewRecord, configuration, () => {}) + } catch (error) {} + } + const saveColumn = async ( payload: CreateColumnPayload | UpdateColumnPayload, - foreignKey: Partial | undefined, + foreignKey: ExtendedPostgresRelationship | undefined, isNewRecord: boolean, configuration: { columnId?: string; isEncrypted: boolean; keyId?: string; keyName?: string }, resolve: any @@ -173,7 +263,13 @@ const SidePanelEditor: FC = ({ if (response?.error) { ui.setNotification({ category: 'error', message: response.error.message }) } else { - await meta.tables.loadById(selectedTable!.id) + queryClient.invalidateQueries(sqlKeys.query(project?.ref, ['foreign-key-constraints'])) + await Promise.all([ + meta.tables.loadById(selectedTable!.id), + queryClient.invalidateQueries( + sqlKeys.query(project?.ref, [selectedTable!.schema, selectedTable!.name]) + ), + ]) onColumnSaved(configuration.isEncrypted) setIsEdited(false) closePanel() @@ -207,21 +303,27 @@ const SidePanelEditor: FC = ({ try { if (isDuplicating) { const duplicateTable = find(tables, { id: tableId }) as PostgresTable + toastId = ui.setNotification({ category: 'loading', message: `Duplicating table: ${duplicateTable.name}...`, }) + const table: any = await meta.duplicateTable(payload, { isRLSEnabled, isRealtimeEnabled, isDuplicateRows, duplicateTable, }) + + await queryClient.invalidateQueries(entityTypeKeys.list(project?.ref)) + ui.setNotification({ id: toastId, category: 'success', message: `Table ${duplicateTable.name} has been successfully duplicated into ${table.name}!`, }) + onTableCreated(table) } else if (isNewRecord) { toastId = ui.setNotification({ @@ -237,17 +339,22 @@ const SidePanelEditor: FC = ({ isRealtimeEnabled, importContent ) + + await queryClient.invalidateQueries(entityTypeKeys.list(project?.ref)) + ui.setNotification({ id: toastId, category: 'success', message: `Table ${table.name} is good to go!`, }) + onTableCreated(table) } else if (selectedTableToEdit) { toastId = ui.setNotification({ category: 'loading', message: `Updating table: ${selectedTableToEdit?.name}...`, }) + const { table, hasError }: any = await meta.updateTable( toastId, selectedTableToEdit, @@ -255,6 +362,7 @@ const SidePanelEditor: FC = ({ columns, isRealtimeEnabled ) + if (hasError) { ui.setNotification({ id: toastId, @@ -262,6 +370,8 @@ const SidePanelEditor: FC = ({ message: `Table ${table.name} has been updated, but there were some errors`, }) } else { + await queryClient.invalidateQueries(entityTypeKeys.list(project?.ref)) + ui.setNotification({ id: toastId, category: 'success', @@ -269,6 +379,8 @@ const SidePanelEditor: FC = ({ }) } } + + queryClient.invalidateQueries(sqlKeys.query(project?.ref, ['foreign-key-constraints'])) } catch (error: any) { saveTableError = true ui.setNotification({ id: toastId, category: 'error', message: error.message }) @@ -304,7 +416,6 @@ const SidePanelEditor: FC = ({ )} {!isUndefined(selectedTable) && ( = ({ closePanel={onClosePanel} onSaveJSON={onSaveJSON} /> + = ({ }) => { const suggestions: Suggestion[] = typeExpressionSuggestions?.[column.format] ?? [] - let settingsCount = 0 - - column.isNullable ? (settingsCount += 1) : null - column.isIdentity ? (settingsCount += 1) : null - column.isUnique ? (settingsCount += 1) : null - column.isArray ? (settingsCount += 1) : null + const settingsCount = [ + column.isNullable ? 1 : 0, + column.isIdentity ? 1 : 0, + column.isUnique ? 1 : 0, + column.isArray ? 1 : 0, + ].reduce((a, b) => a + b, 0) return (
    @@ -80,13 +92,53 @@ const Column: FC = ({
    - + + + + + + + +
    + {column.foreignKey === undefined ? ( + Edit foreign key relation + ) : ( +
    +

    Foreign key relation:

    +
    +

    + {column.foreignKey.source_schema}.{column.foreignKey.source_table_name}. + {column.foreignKey.source_column_name} +

    + +

    + {column.foreignKey.target_table_schema}. + {column.foreignKey.target_table_name}.{column.foreignKey.target_column_name} +

    +
    + {column.foreignKey.deletion_action !== + FOREIGN_KEY_DELETION_ACTION.NO_ACTION && ( +

    + On delete: {getForeignKeyDeletionAction(column.foreignKey.deletion_action)} +

    + )} +
    + )} +
    +
    +
    +
    @@ -96,7 +148,7 @@ const Column: FC = ({ size="small" showLabel={false} className="table-editor-column-type lg:gap-0 " - disabled={!isUndefined(column.foreignKey)} + disabled={column.foreignKey !== undefined} onOptionSelect={(format: string) => { onUpdateColumn({ format, defaultValue: null }) }} @@ -108,7 +160,7 @@ const Column: FC = ({ = ({
    Extra options
    diff --git a/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/ColumnManagement.tsx b/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/ColumnManagement.tsx index a834c258255..d01b213b3ab 100644 --- a/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/ColumnManagement.tsx +++ b/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/ColumnManagement.tsx @@ -1,14 +1,9 @@ import Link from 'next/link' -import React, { FC, useState } from 'react' -import { partition, isEmpty, isUndefined } from 'lodash' +import { FC, useState } from 'react' +import { partition, isEmpty } from 'lodash' import { Alert, Button, IconEdit, IconHelpCircle, IconKey, IconTrash, IconExternalLink } from 'ui' -import type { - PostgresTable, - PostgresColumn, - PostgresRelationship, - PostgresType, -} from '@supabase/postgres-meta' +import type { PostgresTable, PostgresColumn, PostgresType } from '@supabase/postgres-meta' import { DragDropContext, Droppable, @@ -23,12 +18,11 @@ import InformationBox from 'components/ui/InformationBox' import ForeignKeySelector from '../ForeignKeySelector/ForeignKeySelector' import { ImportContent } from './TableEditor.types' import { generateColumnField } from '../ColumnEditor/ColumnEditor.utils' -import { ColumnField } from '../SidePanelEditor.types' +import { ColumnField, ExtendedPostgresRelationship } from '../SidePanelEditor.types' import { TEXT_TYPES } from '../SidePanelEditor.constants' interface Props { table?: Partial - tables: PostgresTable[] columns?: ColumnField[] enumTypes: PostgresType[] importContent?: ImportContent @@ -40,7 +34,6 @@ interface Props { const ColumnManagement: FC = ({ table, - tables = [], columns = [], enumTypes = [], importContent, @@ -57,24 +50,28 @@ const ColumnManagement: FC = ({ (column: ColumnField) => column.isPrimaryKey ) - const saveColumnForeignKey = ( - foreignKeyConfiguration: { table: PostgresTable; column: PostgresColumn } | undefined - ) => { - if (!isUndefined(selectedColumnToEditRelation)) { + const saveColumnForeignKey = (foreignKeyConfiguration?: { + table: PostgresTable + column: PostgresColumn + deletionAction: string + }) => { + if (selectedColumnToEditRelation !== undefined) { onUpdateColumn(selectedColumnToEditRelation, { - foreignKey: !isUndefined(foreignKeyConfiguration) - ? { - id: 0, - constraint_name: '', - source_schema: table?.schema ?? '', - source_table_name: table?.name ?? '', - source_column_name: selectedColumnToEditRelation?.name, - target_table_schema: foreignKeyConfiguration.table.schema, - target_table_name: foreignKeyConfiguration.table.name, - target_column_name: foreignKeyConfiguration.column.name, - } - : undefined, - ...(!isUndefined(foreignKeyConfiguration) && { + foreignKey: + foreignKeyConfiguration !== undefined + ? { + id: 0, + constraint_name: '', + source_schema: table?.schema ?? '', + source_table_name: table?.name ?? '', + source_column_name: selectedColumnToEditRelation?.name, + target_table_schema: foreignKeyConfiguration.table.schema, + target_table_name: foreignKeyConfiguration.table.name, + target_column_name: foreignKeyConfiguration.column.name, + deletion_action: foreignKeyConfiguration.deletionAction, + } + : undefined, + ...(foreignKeyConfiguration !== undefined && { format: foreignKeyConfiguration.column.format, defaultValue: null, }), @@ -91,8 +88,8 @@ const ColumnManagement: FC = ({ changes.defaultValue = null } - if ('name' in changes && !isUndefined(column.foreignKey)) { - const foreignKey: PostgresRelationship = { + if ('name' in changes && column.foreignKey !== undefined) { + const foreignKey: ExtendedPostgresRelationship = { ...column.foreignKey, source_column_name: changes?.name ?? '', } @@ -205,20 +202,22 @@ const ColumnManagement: FC = ({ - - -
    - - Recommended to use lowercase and use an underscore to separate words e.g. - column_name - -
    -
    + + + +
    + + Recommended to use lowercase and use an underscore to separate words e.g. + column_name + +
    +
    +
    @@ -233,19 +232,21 @@ const ColumnManagement: FC = ({ - - -
    - - Can be either a value or a SQL expression - -
    -
    + + + +
    + + Can be either a value or a SQL expression + +
    +
    +
    @@ -354,7 +355,7 @@ const ColumnManagement: FC = ({
    setSelectedColumnToEditRelation(undefined)} saveChanges={saveColumnForeignKey} /> diff --git a/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx b/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx index 0d20cee9e41..caec9de9950 100644 --- a/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx +++ b/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx @@ -1,6 +1,6 @@ import { FC, useEffect, useState } from 'react' import { isUndefined, isEmpty } from 'lodash' -import { Badge, Checkbox, SidePanel, Input, Alert } from 'ui' +import { Badge, Checkbox, SidePanel, Input, Alert, IconBookOpen, Button } from 'ui' import type { PostgresTable, PostgresType } from '@supabase/postgres-meta' import { useStore } from 'hooks' @@ -17,7 +17,9 @@ import { generateTableFieldFromPostgresTable, formatImportedContentToColumnFields, } from './TableEditor.utils' -import InformationBox from 'components/ui/InformationBox' +import { useForeignKeyConstraintsQuery } from 'data/database/foreign-key-constraints-query' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import Link from 'next/link' interface Props { table?: PostgresTable @@ -51,9 +53,9 @@ const TableEditor: FC = ({ updateEditorDirty = () => {}, }) => { const { ui, meta } = useStore() + const { project } = useProjectContext() const isNewRecord = isUndefined(table) - const tables = meta.tables.list() const enumTypes = meta.types.list( (type: PostgresType) => !meta.excludedSchemas.includes(type.schema) ) @@ -73,6 +75,13 @@ const TableEditor: FC = ({ const [importContent, setImportContent] = useState() const [isImportingSpreadsheet, setIsImportingSpreadsheet] = useState(false) + const { data } = useForeignKeyConstraintsQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + schema: table?.schema, + }) + const foreignKeyMeta = data || [] + useEffect(() => { if (visible) { setErrors({}) @@ -84,6 +93,7 @@ const TableEditor: FC = ({ } else { const tableFields = generateTableFieldFromPostgresTable( table!, + foreignKeyMeta, isDuplicating, isRealtimeEnabled ) @@ -216,18 +226,46 @@ const TableEditor: FC = ({

    Restrict access to your table by enabling RLS and writing Postgres policies.

    -

    - RLS is secure by default - all normal access to this table must be allowed by a - policy. -

    - {!tableFields.isRLSEnabled && ( + + {tableFields.isRLSEnabled ? ( +

    + You need to write a policy before you can query data from this table. + Without a policy, querying this table will result in an empty array{' '} + of results. +

    +

    + + + + + +

    + + ) : ( + - As such, anyone with the anonymous key can modify or delete your data. +

    As such, anyone with the anonymous key can modify or delete your data.

    +

    + + + + + +

    )} @@ -253,7 +291,6 @@ const TableEditor: FC = ({ {!isDuplicating && ( { const errors = {} as any @@ -36,6 +37,7 @@ export const generateTableField = (): TableField => { export const generateTableFieldFromPostgresTable = ( table: PostgresTable, + foreignKeys: ForeignKeyConstraint[], isDuplicating = false, isRealtimeEnabled = false ): TableField => { @@ -45,7 +47,7 @@ export const generateTableFieldFromPostgresTable = ( comment: isDuplicating ? `This is a duplicate of ${table.name}` : table?.comment ?? '', // @ts-ignore columns: table.columns.map((column: PostgresColumn) => { - return generateColumnFieldFromPostgresColumn(column, table) + return generateColumnFieldFromPostgresColumn(column, table, foreignKeys) }), isRLSEnabled: table.rls_enabled, isRealtimeEnabled, diff --git a/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx b/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx index ea5c3c52af5..7014e0fe8e4 100644 --- a/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx +++ b/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx @@ -1,8 +1,8 @@ -import { FC, useRef, useEffect, useState } from 'react' +import { useRef, useEffect, useState } from 'react' import { observer } from 'mobx-react-lite' import { useRouter } from 'next/router' -import { find, isUndefined } from 'lodash' -import type { PostgresColumn, PostgresTable } from '@supabase/postgres-meta' +import { find, isUndefined, noop } from 'lodash' +import type { PostgresColumn, PostgresRelationship, PostgresTable } from '@supabase/postgres-meta' import { PermissionAction } from '@supabase/shared-types/out/constants' import { QueryKey, useQueryClient } from '@tanstack/react-query' @@ -18,20 +18,22 @@ import { SupabaseGridRef, SupaTable, } from 'components/grid' -import { IconBookOpen, SidePanel } from 'ui' -import ActionBar from './SidePanelEditor/ActionBar' -import { GeneralContent, ResourceContent } from '../Docs' import { sqlKeys } from 'data/sql/keys' -import { useProjectApiQuery } from 'data/config/project-api-query' import { useProjectJsonSchemaQuery } from 'data/docs/project-json-schema-query' import { useTableRowUpdateMutation } from 'data/table-rows/table-row-update-mutation' -import { snakeToCamel } from 'lib/helpers' import { JsonEditValue } from './SidePanelEditor/RowEditor/RowEditor.types' -import LangSelector from '../Docs/LangSelector' -import GeneratingTypes from '../Docs/GeneratingTypes' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { + ForeignKeyConstraint, + useForeignKeyConstraintsQuery, +} from 'data/database/foreign-key-constraints-query' +import { FOREIGN_KEY_DELETION_ACTION } from 'data/database/database-query-constants' +import { ForeignRowSelectorProps } from './SidePanelEditor/RowEditor/ForeignRowSelector/ForeignRowSelector' +import TwoOptionToggle from 'components/ui/TwoOptionToggle' +import ViewDefinition from './ViewDefinition' +import APIDocumentationPanel from './APIDocumentationPanel' -interface Props { +export interface TableGridEditorProps { /** Theme for the editor */ theme?: 'dark' | 'light' @@ -39,7 +41,7 @@ interface Props { selectedTable: any // PostgresTable | SchemaView /** Determines what side panel editor to show */ - sidePanelKey?: 'row' | 'column' | 'table' | 'json' + sidePanelKey?: 'row' | 'column' | 'table' | 'json' | 'foreign-row-selector' /** Toggles if we're duplicating a table */ isDuplicating: boolean /** Selected entities if we're editing a row, column or table */ @@ -47,6 +49,11 @@ interface Props { selectedColumnToEdit?: PostgresColumn selectedTableToEdit?: PostgresTable selectedValueForJsonEdit?: JsonEditValue + selectedForeignKeyToEdit?: { + foreignKey: NonNullable + row: any + column: any + } onAddRow: () => void onEditRow: (row: Dictionary) => void @@ -54,10 +61,15 @@ interface Props { onEditColumn: (column: PostgresColumn) => void onDeleteColumn: (column: PostgresColumn) => void onExpandJSONEditor: (column: string, row: any) => void + onEditForeignKeyColumnValue: (args: { + foreignKey: NonNullable + row: any + column: any + }) => void onClosePanel: () => void } -const TableGridEditor: FC = ({ +const TableGridEditor = ({ theme = 'dark', selectedSchema, @@ -68,39 +80,29 @@ const TableGridEditor: FC = ({ selectedColumnToEdit, selectedTableToEdit, selectedValueForJsonEdit, + selectedForeignKeyToEdit, - onAddRow = () => {}, - onEditRow = () => {}, - onAddColumn = () => {}, - onEditColumn = () => {}, - onDeleteColumn = () => {}, - onExpandJSONEditor = () => {}, - onClosePanel = () => {}, -}) => { - const { project } = useProjectContext() + onAddRow = noop, + onEditRow = noop, + onAddColumn = noop, + onEditColumn = noop, + onDeleteColumn = noop, + onExpandJSONEditor = noop, + onEditForeignKeyColumnValue = noop, + onClosePanel = noop, +}: TableGridEditorProps) => { const { meta, ui, vault } = useStore() const router = useRouter() - const { ref: projectRef, page, id } = useParams() + const { ref: projectRef, id } = useParams() const gridRef = useRef(null) - const tables = meta.tables.list() - - const { data: settings } = useProjectApiQuery({ projectRef: projectRef }) - - const autoApiService = { - ...settings?.autoApiService, - endpoint: `${settings?.autoApiService.protocol ?? 'https'}://${ - settings?.autoApiService.endpoint ?? '-' - }`, - } - const DEFAULT_KEY = { name: 'hide', key: 'SUPABASE_KEY' } - + const { project } = useProjectContext() const isVaultEnabled = useFlag('vaultExtension') const [encryptedColumns, setEncryptedColumns] = useState([]) const [apiPreviewPanelOpen, setApiPreviewPanelOpen] = useState(false) - const [selectedLang, setSelectedLang] = useState('js') - const [showApiKey, setShowApiKey] = useState(DEFAULT_KEY) + // When rendering a view in the grid + const [selectedView, setSelectedView] = useState<'data' | 'definition'>('data') const isReadOnly = !checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') && @@ -170,42 +172,15 @@ const TableGridEditor: FC = ({ }, }) - function getResourcesFromJsonSchema(value: any) { - const { paths } = value || {} - const functionPath = 'rpc/' - let resources: any = {} + const { refetch } = useProjectJsonSchemaQuery({ projectRef }) + const refreshDocs = async () => await refetch() - Object.entries(paths || []).forEach(([name, val]) => { - let trimmed = name.slice(1) - let id = trimmed.replace(functionPath, '') - let displayName = id.replace(/_/g, ' ') - let camelCase = snakeToCamel(id) - let enriched = { id, displayName, camelCase } - if (!trimmed.length) return - else resources[id] = enriched - }) - - return resources - } - - const apiService = settings?.autoApiService - const anonKey = apiService?.service_api_keys.find((x) => x.name === 'anon key') - ? apiService.defaultApiKey - : undefined - - const { - data: jsonSchema, - error: jsonSchemaError, - refetch, - } = useProjectJsonSchemaQuery({ projectRef }) - - if (jsonSchemaError) console.log('jsonSchemaError', jsonSchemaError) - - const resources = getResourcesFromJsonSchema(jsonSchema) - - const refreshDocs = async () => { - await refetch() - } + const { data } = useForeignKeyConstraintsQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + schema: selectedTable?.schema, + }) + const foreignKeyMeta = data || [] useEffect(() => { if (selectedTable !== undefined && selectedTable.id !== undefined && isVaultEnabled) { @@ -228,6 +203,20 @@ const TableGridEditor: FC = ({ const canUpdateTables = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') const canEditViaTableEditor = !isViewSelected && !isForeignTableSelected && !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 = (selectedTable?.relationships ?? []).map( + (relationship: PostgresRelationship) => { + const relationshipMeta = foreignKeyMeta.find( + (fk: ForeignKeyConstraint) => fk.id === relationship.id + ) + return { + ...relationship, + deletion_action: relationshipMeta?.deletion_action ?? FOREIGN_KEY_DELETION_ACTION.NO_ACTION, + } + } + ) + const gridTable = !isViewSelected && !isForeignTableSelected ? parseSupaTable( @@ -235,7 +224,7 @@ const TableGridEditor: FC = ({ table: selectedTable as PostgresTable, columns: (selectedTable as PostgresTable).columns ?? [], primaryKeys: (selectedTable as PostgresTable).primary_keys, - relationships: (selectedTable as PostgresTable).relationships, + relationships: formattedRelationships, }, encryptedColumns ) @@ -329,6 +318,11 @@ const TableGridEditor: FC = ({ }) } + /** [Joshen] We're going to need to refactor SupabaseGrid eventually to make the code here more readable + * For context we previously built the SupabaseGrid as a reusable npm component, but eventually decided + * to just integrate it directly into the dashboard. The header, and body (+footer) should be decoupled. + */ + return ( <> = ({ table={gridTable} refreshDocs={refreshDocs} headerActions={ - canEditViaTableEditor && ( + isViewSelected ? ( +
    + +
    + ) : canEditViaTableEditor ? ( - ) + ) : null } onAddColumn={onAddColumn} onEditColumn={onSelectEditColumn} @@ -360,7 +364,22 @@ const TableGridEditor: FC = ({ onError={onError} onSqlQuery={onSqlQuery} onExpandJSONEditor={onExpandJSONEditor} - /> + onEditForeignKeyColumnValue={onEditForeignKeyColumnValue} + showCustomChildren={isViewSelected && selectedView === 'definition'} + customHeader={ + isViewSelected && selectedView === 'definition' ? ( +
    +

    + SQL Definition of {selectedTable.name}{' '} +

    +

    (Read only)

    +
    + ) : null + } + > + {isViewSelected && } +
    + {!isUndefined(selectedSchema) && ( = ({ selectedColumnToEdit={selectedColumnToEdit} selectedTableToEdit={selectedTableToEdit} selectedValueForJsonEdit={selectedValueForJsonEdit} + selectedForeignKeyToEdit={selectedForeignKeyToEdit} sidePanelKey={sidePanelKey} onRowCreated={onRowCreated} onRowUpdated={onRowUpdated} @@ -379,81 +399,10 @@ const TableGridEditor: FC = ({ /> )} - setApiPreviewPanelOpen(false)} - header={ - - - API - - } - customFooter={ - setApiPreviewPanelOpen(false)} - /> - } - > -
    - - {jsonSchemaError ? ( -
    -
    -

    Error connecting to API

    -

    {`${jsonSchemaError}`}

    -
    -
    - ) : ( - <> - {jsonSchema ? ( - <> -
    - -
    - - - - - {jsonSchema?.definitions && ( - table.id === Number(id))?.name} - resources={resources} - definitions={jsonSchema.definitions} - paths={jsonSchema.paths} - showApiKey={showApiKey.key} - refreshDocs={refreshDocs} - /> - )} - - ) : ( -
    -

    Building docs ...

    -
    - )} - - )} -
    -
    -
    + onClose={() => setApiPreviewPanelOpen(false)} + /> ) } diff --git a/studio/components/interfaces/TableGridEditor/ViewDefinition.tsx b/studio/components/interfaces/TableGridEditor/ViewDefinition.tsx new file mode 100644 index 00000000000..6df9807aa53 --- /dev/null +++ b/studio/components/interfaces/TableGridEditor/ViewDefinition.tsx @@ -0,0 +1,78 @@ +import { useRef } from 'react' +import Editor from '@monaco-editor/react' +import { timeout } from 'lib/helpers' +import { useStore } from 'hooks' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { useViewDefinitionQuery } from 'data/database/view-definition-query' +import ShimmeringLoader from 'components/ui/ShimmeringLoader' + +export interface ViewDefinitionProps { + name: string +} + +const ViewDefinition = ({ name }: ViewDefinitionProps) => { + const editorRef = useRef(null) + const monacoRef = useRef(null) + + const { ui } = useStore() + const { isDarkTheme } = ui + + const { project } = useProjectContext() + const { data: definition, isLoading } = useViewDefinitionQuery({ + name, + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + + const handleEditorOnMount = async (editor: any, monaco: any) => { + editorRef.current = editor + monacoRef.current = monaco + + // add margin above first line + editor.changeViewZones((accessor: any) => { + accessor.addZone({ + afterLineNumber: 0, + heightInPx: 4, + domNode: document.createElement('div'), + }) + }) + + // when editor did mount, it will need a delay before focus() works properly + await timeout(500) + editor?.focus() + } + + if (isLoading) { + return ( +
    + + + +
    + ) + } + + return ( +
    + +
    + ) +} + +export default ViewDefinition diff --git a/studio/components/layouts/AccountLayout/AccountLayout.tsx b/studio/components/layouts/AccountLayout/AccountLayout.tsx index d070b44eff8..7f627e5f9fe 100644 --- a/studio/components/layouts/AccountLayout/AccountLayout.tsx +++ b/studio/components/layouts/AccountLayout/AccountLayout.tsx @@ -75,14 +75,14 @@ const AccountLayout: FC = ({ children, title, breadcrumbs }) => { links: [ { isActive: router.pathname === `/account/me`, - icon: '/img/user.svg', + icon: `${router.basePath}/img/user.svg`, label: 'Preferences', href: `/account/me`, key: `/account/me`, }, { isActive: router.pathname === `/account/tokens`, - icon: '/img/user.svg', + icon: `${router.basePath}/img/user.svg`, label: 'Access Tokens', href: `/account/tokens`, key: `/account/tokens`, @@ -97,14 +97,14 @@ const AccountLayout: FC = ({ children, title, breadcrumbs }) => { links: [ { key: 'ext-guides', - icon: '/img/book.svg', + icon: `${router.basePath}/img/book.svg`, label: 'Guides', href: 'https://supabase.com/docs', isExternal: true, }, { key: 'ext-guides', - icon: '/img/book-open.svg', + icon: `${router.basePath}/img/book-open.svg`, label: 'API Reference', href: 'https://supabase.com/docs/guides/database/api', isExternal: true, diff --git a/studio/components/layouts/DatabaseLayout/DatabaseLayout.tsx b/studio/components/layouts/DatabaseLayout/DatabaseLayout.tsx index f7fa11e4234..5b45d0b9064 100644 --- a/studio/components/layouts/DatabaseLayout/DatabaseLayout.tsx +++ b/studio/components/layouts/DatabaseLayout/DatabaseLayout.tsx @@ -16,7 +16,8 @@ interface Props { const DatabaseLayout: FC = ({ title, children }) => { const { meta, ui, vault, backups } = useStore() - const { isInitialized, isLoading, error } = meta.tables + const { isLoading } = meta.schemas + const { isInitialized, error } = meta.tables const project = ui.selectedProject const router = useRouter() diff --git a/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.ts b/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.ts index 774ad0fb2a7..48961f0397a 100644 --- a/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.ts +++ b/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.ts @@ -26,7 +26,6 @@ export const generateDatabaseMenu = (project?: Project): ProductMenuGroup[] => { name: 'Functions', key: 'functions', url: `/project/${ref}/database/functions`, - label: 'ALPHA', items: [], }, { @@ -49,7 +48,6 @@ export const generateDatabaseMenu = (project?: Project): ProductMenuGroup[] => { key: 'hooks', url: `/project/${ref}/database/hooks`, items: [], - label: 'ALPHA', }, ] : []), diff --git a/studio/components/layouts/DocsLayout/DocsLayout.tsx b/studio/components/layouts/DocsLayout/DocsLayout.tsx index 4a1a2addce8..f4d677f2669 100644 --- a/studio/components/layouts/DocsLayout/DocsLayout.tsx +++ b/studio/components/layouts/DocsLayout/DocsLayout.tsx @@ -2,7 +2,7 @@ import { ReactElement, useEffect } from 'react' import { observer } from 'mobx-react-lite' import { useRouter } from 'next/router' -import { useFlag, useStore, withAuth } from 'hooks' +import { useStore, withAuth } from 'hooks' import Error from 'components/ui/Error' import ProductMenu from 'components/ui/ProductMenu' import ProjectLayout from 'components/layouts/ProjectLayout/ProjectLayout' @@ -13,8 +13,6 @@ function DocsLayout({ title, children }: { title: string; children: ReactElement const { meta, ui } = useStore() const { data, isLoading, error } = meta.openApi - const graphiql = useFlag('graphiql') - const getPage = () => { if (router.pathname.endsWith('graphiql')) return 'graphiql' @@ -49,7 +47,7 @@ function DocsLayout({ title, children }: { title: string; children: ReactElement productMenu={ } > diff --git a/studio/components/layouts/DocsLayout/DocsLayout.utils.tsx b/studio/components/layouts/DocsLayout/DocsLayout.utils.tsx index 155f14f6d57..8f74b748afe 100644 --- a/studio/components/layouts/DocsLayout/DocsLayout.utils.tsx +++ b/studio/components/layouts/DocsLayout/DocsLayout.utils.tsx @@ -1,12 +1,12 @@ -import { ProductMenuGroup } from 'components/ui/ProductMenu/ProductMenu.types' import SVG from 'react-inlinesvg' +import { ProductMenuGroup } from 'components/ui/ProductMenu/ProductMenu.types' import { IconBook, IconBookOpen } from 'ui' +import { BASE_PATH } from 'lib/constants' export const generateDocsMenu = ( ref: string, tables: string[], - functions: string[], - showGraphiql: boolean + functions: string[] ): ProductMenuGroup[] => { return [ { @@ -60,31 +60,25 @@ export const generateDocsMenu = ( }), ], }, - ...(showGraphiql - ? [ - { - title: 'GraphQL', - items: [ - { - name: 'GraphiQL', - key: 'graphiql', - url: `/project/${ref}/api/graphiql`, - icon: ( - - code.replace(/svg/, 'svg class="m-auto text-color-inherit"') - } - /> - ), - items: [], - }, - ], - }, - ] - : []), + { + title: 'GraphQL', + items: [ + { + name: 'GraphiQL', + key: 'graphiql', + url: `/project/${ref}/api/graphiql`, + icon: ( + code.replace(/svg/, 'svg class="m-auto text-color-inherit"')} + /> + ), + items: [], + }, + ], + }, { title: 'More Resources', items: [ diff --git a/studio/components/layouts/LogsLayout/LogsMenu.utils.ts b/studio/components/layouts/LogsLayout/LogsMenu.utils.ts index 26a7eaf0748..64920f230fb 100644 --- a/studio/components/layouts/LogsLayout/LogsMenu.utils.ts +++ b/studio/components/layouts/LogsLayout/LogsMenu.utils.ts @@ -1,75 +1,75 @@ import { Project } from 'types' -import { ProductMenuGroup } from 'components/ui/ProductMenu/ProductMenu.types' +import { ProductMenuGroup, ProductMenuGroupItem } from 'components/ui/ProductMenu/ProductMenu.types' import { IS_PLATFORM } from 'lib/constants' export const generateLogsMenu = (project?: Project): ProductMenuGroup[] => { const ref = project?.ref ?? 'default' return [ - ...(IS_PLATFORM - ? [ - { - title: 'Logs Explorer', - items: [ - { key: 'explorer', name: 'Query', root: true }, - { key: 'saved', name: 'Saved Queries' }, - { key: 'recent', name: 'Recent Queries' }, - { key: 'templates', name: 'Templates' }, - ].map(({ key, name, root }) => ({ - name, - key, - url: `/project/${ref}/logs/explorer${root ? '' : '/' + key}`, + { + title: 'Logs Explorer', + items: ( + [ + { key: 'explorer', name: 'Query', root: true }, + IS_PLATFORM ? { key: 'saved', name: 'Saved Queries' } : null, + { key: 'recent', name: 'Recent Queries' }, + { key: 'templates', name: 'Templates' }, + ].filter((item) => item) as { name: string; key: string; root: boolean }[] + ).map(({ key, name, root }) => ({ + name, + key, + url: `/project/${ref}/logs/explorer${root ? '' : '/' + key}`, + items: [], + })), + }, + { + items: [ + { + name: IS_PLATFORM ? 'API Edge Network' : 'Kong API', + key: 'edge-logs', + url: `/project/${ref}/logs/edge-logs`, + items: [], + }, + { + name: 'Postgres', + key: 'postgres-logs', + url: `/project/${ref}/logs/postgres-logs`, + items: [], + }, + { + name: 'PostgREST', + key: 'postgrest-logs', + url: `/project/${ref}/logs/postgrest-logs`, + items: [], + }, + IS_PLATFORM + ? { + name: 'PgBouncer', + key: 'pgbouncer-logs', + url: `/project/${ref}/logs/pgbouncer-logs`, items: [], - })), - }, - { - items: [ - { - name: 'API Edge Network', - key: 'edge-logs', - url: `/project/${ref}/logs/edge-logs`, - items: [], - }, - { - name: 'Postgres', - key: 'postgres-logs', - url: `/project/${ref}/logs/postgres-logs`, - items: [], - }, - { - name: 'PostgREST', - key: 'postgrest-logs', - url: `/project/${ref}/logs/postgrest-logs`, - items: [], - }, - { - name: 'PgBouncer', - key: 'pgbouncer-logs', - url: `/project/${ref}/logs/pgbouncer-logs`, - items: [], - }, - { - name: 'Auth', - key: 'auth-logs', - url: `/project/${ref}/logs/auth-logs`, - items: [], - }, - { - name: 'Storage', - key: 'storage-logs', - url: `/project/${ref}/logs/storage-logs`, - items: [], - }, - - { - name: 'Realtime', - key: 'realtime-logs', - url: `/project/${ref}/logs/realtime-logs`, - items: [], - }, - ], - }, - ] - : []), + } + : null, + , + { + name: 'Auth', + key: 'auth-logs', + url: `/project/${ref}/logs/auth-logs`, + items: [], + }, + { + name: 'Storage', + key: 'storage-logs', + url: `/project/${ref}/logs/storage-logs`, + items: [], + }, + { + name: 'Realtime', + key: 'realtime-logs', + url: `/project/${ref}/logs/realtime-logs`, + items: [], + }, + ].filter((item) => item) as ProductMenuGroupItem[], + }, ] } diff --git a/studio/components/layouts/ProjectLayout/BuildingState.tsx b/studio/components/layouts/ProjectLayout/BuildingState.tsx index 01b4af13c10..06c7c221db9 100644 --- a/studio/components/layouts/ProjectLayout/BuildingState.tsx +++ b/studio/components/layouts/ProjectLayout/BuildingState.tsx @@ -111,7 +111,9 @@ const BuildingState: FC = ({ project }) => { support ticket.

    - + + + } diff --git a/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPopover.tsx b/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPopover.tsx index fe837159d1c..7f8bdc90e39 100644 --- a/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPopover.tsx +++ b/studio/components/layouts/ProjectLayout/LayoutHeader/HelpPopover.tsx @@ -37,9 +37,11 @@ const HelpPopover: FC = () => {

    - + + + @@ -83,14 +85,16 @@ const HelpPopover: FC = () => { > @@ -106,7 +110,7 @@ const HelpPopover: FC = () => { > 0 + resourcesExceededLimits.length > 0 && + useFlag('overusageBadge') return (
    )} - {/* [Joshen TODO] Temporarily hidden until usage endpoint is sorted out */} - {/* {showOverUsageBadge && ( + {showOverUsageBadge && ( - )} */} + )} )} diff --git a/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopover/NotificationRow.tsx b/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopover/NotificationRow.tsx index 450722f5a8c..0b3fe098097 100644 --- a/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopover/NotificationRow.tsx +++ b/studio/components/layouts/ProjectLayout/LayoutHeader/NotificationsPopover/NotificationRow.tsx @@ -97,17 +97,19 @@ const NotificationRow: FC = ({ onClick={() => dismissNotification(notification.id)} /> - - -
    - Dismiss -
    -
    + + + +
    + Dismiss +
    +
    +
    diff --git a/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.tsx b/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.tsx index 913d8d9010d..599436d22ac 100644 --- a/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.tsx +++ b/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.tsx @@ -42,7 +42,7 @@ const NavigationBar: FC = ({}) => { Supabase diff --git a/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx b/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx index 5a2c1a7bac5..783e7b4aeb9 100644 --- a/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx +++ b/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx @@ -12,7 +12,8 @@ import SVG from 'react-inlinesvg' import { ProjectBase } from 'types' import { Route } from 'components/ui/ui.types' -import { IS_PLATFORM, PROJECT_STATUS } from 'lib/constants' +import { BASE_PATH, IS_PLATFORM, PROJECT_STATUS } from 'lib/constants' +import { SHOW_O11Y } from 'components/interfaces/Settings/Logs' export const generateToolRoutes = (ref?: string, project?: ProjectBase): Route[] => { const isProjectBuilding = project?.status === PROJECT_STATUS.COMING_UP @@ -27,7 +28,7 @@ export const generateToolRoutes = (ref?: string, project?: ProjectBase): Route[] label: 'Table Editor', icon: ( code.replace(/svg/, 'svg class="m-auto text-color-inherit"')} /> @@ -41,7 +42,7 @@ export const generateToolRoutes = (ref?: string, project?: ProjectBase): Route[] label: 'SQL Editor', icon: ( code.replace(/svg/, 'svg class="m-auto text-color-inherit"')} /> @@ -139,7 +140,7 @@ export const generateOtherRoutes = (ref?: string, project?: ProjectBase): Route[ }, ] : []), - ...(IS_PLATFORM + ...(SHOW_O11Y ? [ { key: 'logs', diff --git a/studio/components/layouts/ProjectLayout/NavigationBar/NavigationIconButton.tsx b/studio/components/layouts/ProjectLayout/NavigationBar/NavigationIconButton.tsx index e1d751dd6b4..078addfffa2 100644 --- a/studio/components/layouts/ProjectLayout/NavigationBar/NavigationIconButton.tsx +++ b/studio/components/layouts/ProjectLayout/NavigationBar/NavigationIconButton.tsx @@ -31,17 +31,19 @@ const NavigationIconButton: FC = ({ route, isActive = false }) => { - - -
    - {route.label} -
    -
    + + + +
    + {route.label} +
    +
    +
    ) } diff --git a/studio/components/layouts/ProjectLayout/ProjectPausedState.tsx b/studio/components/layouts/ProjectLayout/ProjectPausedState.tsx index 8181045826d..0733a2fbc81 100644 --- a/studio/components/layouts/ProjectLayout/ProjectPausedState.tsx +++ b/studio/components/layouts/ProjectLayout/ProjectPausedState.tsx @@ -72,19 +72,21 @@ const ProjectPausedState: FC = ({ project }) => { {!canResumeProject && ( - - -
    - - You need additional permissions to resume this project - -
    -
    + + + +
    + + You need additional permissions to resume this project + +
    +
    +
    )} diff --git a/studio/components/layouts/ProjectLayout/UpgradingState/UpgradingState.tsx b/studio/components/layouts/ProjectLayout/UpgradingState/UpgradingState.tsx index ddee8e564a7..561f596c4c0 100644 --- a/studio/components/layouts/ProjectLayout/UpgradingState/UpgradingState.tsx +++ b/studio/components/layouts/ProjectLayout/UpgradingState/UpgradingState.tsx @@ -1,6 +1,6 @@ import dayjs from 'dayjs' import Link from 'next/link' -import { useState, useEffect } from 'react' +import { useState } from 'react' import * as Tooltip from '@radix-ui/react-tooltip' import { Button, @@ -16,6 +16,7 @@ import { import { DatabaseUpgradeStatus } from '@supabase/shared-types/out/events' import { useParams, useStore } from 'hooks' +import { IS_PLATFORM } from 'lib/constants' import { DATABASE_UPGRADE_MESSAGES } from './UpgradingState.constants' import { useProjectUpgradingStatusQuery } from 'data/config/project-upgrade-status-query' @@ -24,10 +25,15 @@ const UpgradingState = () => { const { app, ui, meta } = useStore() const [loading, setLoading] = useState(false) const [isExpanded, setIsExpanded] = useState(false) - const { data } = useProjectUpgradingStatusQuery({ - projectRef: ref, - projectStatus: ui.selectedProject?.status, - }) + const { data } = useProjectUpgradingStatusQuery( + { + projectRef: ref, + projectStatus: ui.selectedProject?.status, + }, + { + enabled: IS_PLATFORM, + } + ) const project = ui.selectedProject const { initiated_at, status, progress, target_version, error } = @@ -51,12 +57,12 @@ const UpgradingState = () => { const message = `Upgrade information:%0A• Initiated at: ${initiated_at}%0A• Target Version: ${target_version}%0A• Error: ${error}` return ( -
    +

    {project?.name}

    -
    +
    {isCompleted ? (
    @@ -104,14 +110,14 @@ const UpgradingState = () => { ) : (
    -
    +

    Upgrading in progress

    -

    +

    Upgrades can take from a few minutes up to several hours depending on the size of your database. Your project will be offline while it is being upgraded.

    @@ -124,14 +130,14 @@ const UpgradingState = () => { setIsExpanded(false)} /> ) : ( setIsExpanded(true)} /> )} @@ -155,7 +161,7 @@ const UpgradingState = () => { return (
    {isCurrent ? ( -
    +
    { />
    ) : isCompleted ? ( -
    +
    ) : ( -
    +
    )}

    { {initiated_at !== undefined && ( -

    +

    Started on: {initiatedAtUTC} (UTC)

    - - -
    - {initiatedAt} -
    -
    + + + +
    + {initiatedAt} +
    +
    +
    )}
    diff --git a/studio/components/layouts/ReportsLayout/ReportsMenu.utils.ts b/studio/components/layouts/ReportsLayout/ReportsMenu.utils.ts index 56dbac921b8..4e088fd0ae6 100644 --- a/studio/components/layouts/ReportsLayout/ReportsMenu.utils.ts +++ b/studio/components/layouts/ReportsLayout/ReportsMenu.utils.ts @@ -1,10 +1,8 @@ import { Project } from 'types' import { ProductMenuGroup } from 'components/ui/ProductMenu/ProductMenu.types' -import { useFlag } from 'hooks' export const generateReportsMenu = (project?: Project): ProductMenuGroup[] => { const ref = project?.ref ?? 'default' - const productReports = useFlag('productReports') return [ { @@ -20,17 +18,13 @@ export const generateReportsMenu = (project?: Project): ProductMenuGroup[] => { }, { items: [ - ...(productReports - ? [ - { - name: 'API', - key: 'api-overview', - url: `/project/${ref}/reports/api-overview`, - items: [], - label: 'NEW', - }, - ] - : []), + { + name: 'API', + key: 'api-overview', + url: `/project/${ref}/reports/api-overview`, + items: [], + label: 'NEW', + }, { name: 'Database', key: 'database', diff --git a/studio/components/layouts/SignInLayout/ForgotPasswordLayout.tsx b/studio/components/layouts/SignInLayout/ForgotPasswordLayout.tsx index 6e6f4ef2eb5..7de8e7cfaf3 100644 --- a/studio/components/layouts/SignInLayout/ForgotPasswordLayout.tsx +++ b/studio/components/layouts/SignInLayout/ForgotPasswordLayout.tsx @@ -1,8 +1,9 @@ -import { useStore } from 'hooks' import { observer } from 'mobx-react-lite' import Image from 'next/image' import Link from 'next/link' import { PropsWithChildren } from 'react' +import { useStore } from 'hooks' +import { BASE_PATH } from 'lib/constants' type ForgotPasswordLayoutProps = { heading: string @@ -31,7 +32,11 @@ const ForgotPasswordLayout = ({ - - {getLayout()} - + + + {getLayout()} + + diff --git a/studio/pages/account/tokens.tsx b/studio/pages/account/tokens.tsx index 1ccd1c420f8..a7f85064b93 100644 --- a/studio/pages/account/tokens.tsx +++ b/studio/pages/account/tokens.tsx @@ -1,13 +1,51 @@ -import { AccountLayout } from 'components/layouts' -import { AccessTokenList, NewAccessTokenButton } from 'components/interfaces/Account' +import { useState } from 'react' import { NextPageWithLayout } from 'types' +import { FormHeader } from 'components/ui/Forms' +import { AccountLayout } from 'components/layouts' +import { + AccessTokenList, + NewTokenBanner, + NewAccessTokenButton, +} from 'components/interfaces/Account' +import { NewAccessToken } from 'data/access-tokens/access-tokens-create-mutation' + +import Link from 'next/link' +import { Button, IconExternalLink } from 'ui' const UserAccessTokens: NextPageWithLayout = () => { + const [newToken, setNewToken] = useState() + return ( -
    -

    Access Tokens

    - - +
    ) } @@ -15,12 +53,7 @@ const UserAccessTokens: NextPageWithLayout = () => { UserAccessTokens.getLayout = (page) => ( {page} diff --git a/studio/pages/api/constants.ts b/studio/pages/api/constants.ts index 7d40e62c010..e798f1e5693 100644 --- a/studio/pages/api/constants.ts +++ b/studio/pages/api/constants.ts @@ -1,5 +1,8 @@ const PUBLIC_URL = new URL(process.env.SUPABASE_PUBLIC_URL || 'http://localhost:8000') +// Use LOGFLARE_URL until analytics/v1/ routing is supported +export const PROJECT_ANALYTICS_URL = `${process.env.LOGFLARE_URL}/api/` + export const PROJECT_REST_URL = `${PUBLIC_URL.origin}/rest/v1/` export const PROJECT_ENDPOINT = PUBLIC_URL.host export const PROJECT_ENDPOINT_PROTOCOL = PUBLIC_URL.protocol.replace(':', '') diff --git a/studio/pages/api/projects/[ref]/analytics/endpoints/[name].ts b/studio/pages/api/projects/[ref]/analytics/endpoints/[name].ts new file mode 100644 index 00000000000..2b7ab5ec113 --- /dev/null +++ b/studio/pages/api/projects/[ref]/analytics/endpoints/[name].ts @@ -0,0 +1,46 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import apiWrapper from 'lib/api/apiWrapper' +import { PROJECT_ANALYTICS_URL } from 'pages/api/constants' +import { get } from 'lib/common/fetch' + +export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) + +async function handler(req: NextApiRequest, res: NextApiResponse) { + const { method } = req + + switch (method) { + case 'GET': + const missingEnvVars = [ + process.env.LOGFLARE_API_KEY ? null : 'LOGFLARE_API_KEY', + process.env.LOGFLARE_URL ? null : 'LOGFLARE_URL', + ].filter((v) => v) + if (missingEnvVars.length == 0) { + const result = await proxyRequest(req) + return res.status(200).json(result) + } else { + return res + .status(500) + .json({ error: { message: `${missingEnvVars.join(', ')} env variables are not set` } }) + } + + default: + res.setHeader('Allow', ['GET']) + res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } }) + } +} + +const proxyRequest = async (req: NextApiRequest) => { + const { name, ...toForward } = req.query + const payload = { ...toForward, project_tier: 'ENTERPRISE' } + const search = '?' + new URLSearchParams(payload as any).toString() + const apiKey = process.env.LOGFLARE_API_KEY + const url = `${PROJECT_ANALYTICS_URL}endpoints/query/name/${name}${search}` + const result = await get(url, { + headers: { + 'x-api-key': apiKey, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + }) + return result +} diff --git a/studio/pages/api/projects/[ref]/api/graphql.ts b/studio/pages/api/projects/[ref]/api/graphql.ts new file mode 100644 index 00000000000..73f206a218c --- /dev/null +++ b/studio/pages/api/projects/[ref]/api/graphql.ts @@ -0,0 +1,40 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import apiWrapper from 'lib/api/apiWrapper' + +export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) + +async function handler(req: NextApiRequest, res: NextApiResponse) { + const { method } = req + + switch (method) { + case 'POST': + return handleGet(req, res) + + default: + res.setHeader('Allow', ['POST']) + res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } }) + } +} + +const handleGet = async (req: NextApiRequest, res: NextApiResponse) => { + const authorizationHeader = req.headers['x-graphql-authorization'] + + const response = await fetch(`${process.env.SUPABASE_URL}/graphql/v1`, { + method: 'POST', + headers: { + apikey: process.env.SUPABASE_SERVICE_KEY!, + Authorization: + (Array.isArray(authorizationHeader) ? authorizationHeader[0] : authorizationHeader) ?? + `Bearer ${process.env.SUPABASE_ANON_KEY}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(req.body), + }) + if (response.ok) { + const data = await response.json() + + return res.status(200).json(data) + } + + return res.status(500).json({ error: { message: 'Internal Server Error' } }) +} diff --git a/studio/pages/api/projects/[ref]/api/rest.ts b/studio/pages/api/projects/[ref]/api/rest.ts new file mode 100644 index 00000000000..d4c2a519ac3 --- /dev/null +++ b/studio/pages/api/projects/[ref]/api/rest.ts @@ -0,0 +1,38 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import apiWrapper from 'lib/api/apiWrapper' + +export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) + +async function handler(req: NextApiRequest, res: NextApiResponse) { + const { method } = req + + switch (method) { + case 'GET': + return handleGet(req, res) + case 'HEAD': + return handleHead(req, res) + default: + res.setHeader('Allow', ['GET', 'HEAD']) + res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } }) + } +} + +const handleGet = async (req: NextApiRequest, res: NextApiResponse) => { + const response = await fetch(`${process.env.SUPABASE_URL}/rest/v1/`, { + method: 'GET', + headers: { + apikey: process.env.SUPABASE_SERVICE_KEY!, + }, + }) + if (response.ok) { + const data = await response.json() + + return res.status(200).json(data) + } + + return res.status(500).json({ error: { message: 'Internal Server Error' } }) +} + +const handleHead = async (req: NextApiRequest, res: NextApiResponse) => { + res.status(200).end() +} diff --git a/studio/pages/api/storage/[ref]/buckets/[id]/empty.ts b/studio/pages/api/storage/[ref]/buckets/[id]/empty.ts new file mode 100644 index 00000000000..9b162f6a637 --- /dev/null +++ b/studio/pages/api/storage/[ref]/buckets/[id]/empty.ts @@ -0,0 +1,30 @@ +import { createClient } from '@supabase/supabase-js' +import apiWrapper from 'lib/api/apiWrapper' +import { NextApiRequest, NextApiResponse } from 'next' + +const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!) + +export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) + +async function handler(req: NextApiRequest, res: NextApiResponse) { + const { method } = req + + switch (method) { + case 'POST': + return handlePost(req, res) + default: + res.setHeader('Allow', ['POST']) + res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } }) + } +} + +const handlePost = async (req: NextApiRequest, res: NextApiResponse) => { + const { id } = req.query + + const { data, error } = await supabase.storage.emptyBucket(id as string) + if (error) { + return res.status(400).json({ error: { message: error.message } }) + } + + return res.status(200).json(data) +} diff --git a/studio/pages/api/storage/[ref]/buckets/[id]/index.ts b/studio/pages/api/storage/[ref]/buckets/[id]/index.ts new file mode 100644 index 00000000000..72af258757b --- /dev/null +++ b/studio/pages/api/storage/[ref]/buckets/[id]/index.ts @@ -0,0 +1,46 @@ +import { createClient } from '@supabase/supabase-js' +import apiWrapper from 'lib/api/apiWrapper' +import { NextApiRequest, NextApiResponse } from 'next' + +const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!) + +export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) + +async function handler(req: NextApiRequest, res: NextApiResponse) { + const { method } = req + + switch (method) { + case 'PATCH': + return handlePatch(req, res) + case 'DELETE': + return handleDelete(req, res) + default: + res.setHeader('Allow', ['PATCH', 'DELETE']) + res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } }) + } +} + +const handlePatch = async (req: NextApiRequest, res: NextApiResponse) => { + const { id } = req.query + const { public: isPublicBucket } = req.body + + const { data, error } = await supabase.storage.updateBucket(id as string, { + public: isPublicBucket, + }) + if (error) { + return res.status(400).json({ error: { message: error.message } }) + } + + return res.status(200).json(data) +} + +const handleDelete = async (req: NextApiRequest, res: NextApiResponse) => { + const { id } = req.query + + const { data, error } = await supabase.storage.deleteBucket(id as string) + if (error) { + return res.status(400).json({ error: { message: error.message } }) + } + + return res.status(200).json(data) +} diff --git a/studio/pages/api/storage/[ref]/buckets/[id]/objects/download.ts b/studio/pages/api/storage/[ref]/buckets/[id]/objects/download.ts new file mode 100644 index 00000000000..fa51a404ed0 --- /dev/null +++ b/studio/pages/api/storage/[ref]/buckets/[id]/objects/download.ts @@ -0,0 +1,34 @@ +import { createClient } from '@supabase/supabase-js' +import apiWrapper from 'lib/api/apiWrapper' +import { NextApiRequest, NextApiResponse } from 'next' + +const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!) + +export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) + +async function handler(req: NextApiRequest, res: NextApiResponse) { + const { method } = req + + switch (method) { + case 'POST': + return handlePost(req, res) + default: + res.setHeader('Allow', ['POST']) + res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } }) + } +} + +const handlePost = async (req: NextApiRequest, res: NextApiResponse) => { + const { id } = req.query + const { path } = req.body + + const { data, error } = await supabase.storage.from(id as string).download(path) + if (error) { + return res.status(400).json({ error: { message: error.message } }) + } + + return res + .status(200) + .setHeader('Content-Type', 'application/octet-stream') + .send(Buffer.from(await data.arrayBuffer())) +} diff --git a/studio/pages/api/storage/[ref]/buckets/[id]/objects/index.ts b/studio/pages/api/storage/[ref]/buckets/[id]/objects/index.ts new file mode 100644 index 00000000000..d80425dd173 --- /dev/null +++ b/studio/pages/api/storage/[ref]/buckets/[id]/objects/index.ts @@ -0,0 +1,31 @@ +import { createClient } from '@supabase/supabase-js' +import apiWrapper from 'lib/api/apiWrapper' +import { NextApiRequest, NextApiResponse } from 'next' + +const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!) + +export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) + +async function handler(req: NextApiRequest, res: NextApiResponse) { + const { method } = req + + switch (method) { + case 'DELETE': + return handleDelete(req, res) + default: + res.setHeader('Allow', ['DELETE']) + res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } }) + } +} + +const handleDelete = async (req: NextApiRequest, res: NextApiResponse) => { + const { id } = req.query + const { paths } = req.body + + const { data, error } = await supabase.storage.from(id as string).remove(paths as string[]) + if (error) { + return res.status(400).json({ error: { message: error.message } }) + } + + return res.status(200).json(data) +} diff --git a/studio/pages/api/storage/[ref]/buckets/[id]/objects/list.ts b/studio/pages/api/storage/[ref]/buckets/[id]/objects/list.ts new file mode 100644 index 00000000000..d4dc551668b --- /dev/null +++ b/studio/pages/api/storage/[ref]/buckets/[id]/objects/list.ts @@ -0,0 +1,32 @@ +import { createClient } from '@supabase/supabase-js' +import apiWrapper from 'lib/api/apiWrapper' +import { NextApiRequest, NextApiResponse } from 'next' + +const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!) + +export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) + +async function handler(req: NextApiRequest, res: NextApiResponse) { + const { method } = req + + switch (method) { + case 'POST': + return handlePost(req, res) + + default: + res.setHeader('Allow', ['POST']) + res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } }) + } +} + +const handlePost = async (req: NextApiRequest, res: NextApiResponse) => { + const { id } = req.query + const { path, ...params } = req.body + + const { data, error } = await supabase.storage.from(id as string).list(path, params) + if (error) { + return res.status(500).json({ error: error.message }) + } + + return res.status(200).json(data) +} diff --git a/studio/pages/api/storage/[ref]/buckets/[id]/objects/move.ts b/studio/pages/api/storage/[ref]/buckets/[id]/objects/move.ts new file mode 100644 index 00000000000..d4c8b5485f0 --- /dev/null +++ b/studio/pages/api/storage/[ref]/buckets/[id]/objects/move.ts @@ -0,0 +1,31 @@ +import { createClient } from '@supabase/supabase-js' +import apiWrapper from 'lib/api/apiWrapper' +import { NextApiRequest, NextApiResponse } from 'next' + +const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!) + +export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) + +async function handler(req: NextApiRequest, res: NextApiResponse) { + const { method } = req + + switch (method) { + case 'POST': + return handlePost(req, res) + default: + res.setHeader('Allow', ['POST']) + res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } }) + } +} + +const handlePost = async (req: NextApiRequest, res: NextApiResponse) => { + const { id } = req.query + const { from, to } = req.body + + const { data, error } = await supabase.storage.from(id as string).move(from, to) + if (error) { + return res.status(400).json({ error: { message: error.message } }) + } + + return res.status(200).json(data) +} diff --git a/studio/pages/api/storage/[ref]/buckets/[id]/objects/public-url.ts b/studio/pages/api/storage/[ref]/buckets/[id]/objects/public-url.ts new file mode 100644 index 00000000000..41efb4e8107 --- /dev/null +++ b/studio/pages/api/storage/[ref]/buckets/[id]/objects/public-url.ts @@ -0,0 +1,28 @@ +import { createClient } from '@supabase/supabase-js' +import apiWrapper from 'lib/api/apiWrapper' +import { NextApiRequest, NextApiResponse } from 'next' + +const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!) + +export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) + +async function handler(req: NextApiRequest, res: NextApiResponse) { + const { method } = req + + switch (method) { + case 'POST': + return handlePost(req, res) + default: + res.setHeader('Allow', ['POST']) + res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } }) + } +} + +const handlePost = async (req: NextApiRequest, res: NextApiResponse) => { + const { id } = req.query + const { path } = req.body + + const { data } = supabase.storage.from(id as string).getPublicUrl(path) + + return res.status(200).json(data) +} diff --git a/studio/pages/api/storage/[ref]/buckets/[id]/objects/sign.ts b/studio/pages/api/storage/[ref]/buckets/[id]/objects/sign.ts new file mode 100644 index 00000000000..8677334f21b --- /dev/null +++ b/studio/pages/api/storage/[ref]/buckets/[id]/objects/sign.ts @@ -0,0 +1,31 @@ +import { createClient } from '@supabase/supabase-js' +import apiWrapper from 'lib/api/apiWrapper' +import { NextApiRequest, NextApiResponse } from 'next' + +const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!) + +export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) + +async function handler(req: NextApiRequest, res: NextApiResponse) { + const { method } = req + + switch (method) { + case 'POST': + return handlePost(req, res) + default: + res.setHeader('Allow', ['POST']) + res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } }) + } +} + +const handlePost = async (req: NextApiRequest, res: NextApiResponse) => { + const { id } = req.query + const { path, expiresIn = 60 * 60 * 24 } = req.body + + const { data, error } = await supabase.storage.from(id as string).createSignedUrl(path, expiresIn) + if (error) { + return res.status(400).json({ error: { message: error.message } }) + } + + return res.status(200).json(data) +} diff --git a/studio/pages/api/storage/[ref]/buckets/index.ts b/studio/pages/api/storage/[ref]/buckets/index.ts new file mode 100644 index 00000000000..e426db1810c --- /dev/null +++ b/studio/pages/api/storage/[ref]/buckets/index.ts @@ -0,0 +1,42 @@ +import { createClient } from '@supabase/supabase-js' +import apiWrapper from 'lib/api/apiWrapper' +import { NextApiRequest, NextApiResponse } from 'next' + +const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!) + +export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) + +async function handler(req: NextApiRequest, res: NextApiResponse) { + const { method } = req + + switch (method) { + case 'GET': + return handleGet(req, res) + case 'POST': + return handlePost(req, res) + + default: + res.setHeader('Allow', ['GET', 'POST']) + res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } }) + } +} + +const handleGet = async (req: NextApiRequest, res: NextApiResponse) => { + const { data, error } = await supabase.storage.listBuckets() + if (error) { + return res.status(500).json({ error: { message: 'Internal Server Error' } }) + } + + return res.status(200).json(data) +} + +const handlePost = async (req: NextApiRequest, res: NextApiResponse) => { + const { id, public: isPublicBucket } = req.body + + const { data, error } = await supabase.storage.createBucket(id, { public: isPublicBucket }) + if (error) { + return res.status(400).json({ error: { message: error.message } }) + } + + return res.status(200).json(data) +} diff --git a/studio/pages/join/index.tsx b/studio/pages/join/index.tsx index 120db3cb51b..2f462f2f535 100644 --- a/studio/pages/join/index.tsx +++ b/studio/pages/join/index.tsx @@ -212,14 +212,14 @@ const JoinOrganizationPage = () => {

    - + + + - + + +
    @@ -240,7 +240,7 @@ const JoinOrganizationPage = () => { Supabase diff --git a/studio/pages/new/[slug].tsx b/studio/pages/new/[slug].tsx index 9702dca4aac..52b630ad0a2 100644 --- a/studio/pages/new/[slug].tsx +++ b/studio/pages/new/[slug].tsx @@ -4,7 +4,7 @@ import { debounce, isUndefined, values } from 'lodash' import { toJS } from 'mobx' import { observer } from 'mobx-react-lite' import generator from 'generate-password' -import { Button, Listbox, IconUsers, Input, Alert } from 'ui' +import { Button, Listbox, IconUsers, Input, Alert, IconHelpCircle, Toggle } from 'ui' import { PermissionAction } from '@supabase/shared-types/out/constants' import { NextPageWithLayout } from 'types' @@ -33,6 +33,7 @@ import { NotOrganizationOwnerWarning, EmptyPaymentMethodWarning, } from 'components/interfaces/Organization/NewProject' +import SpendCapModal from 'components/interfaces/Billing/SpendCapModal' const Wizard: NextPageWithLayout = () => { const router = useRouter() @@ -45,6 +46,7 @@ const Wizard: NextPageWithLayout = () => { useFreeProjectLimitCheckQuery({ slug }) const [projectName, setProjectName] = useState('') + const [postgresVersion, setPostgresVersion] = useState('') const [dbPass, setDbPass] = useState('') const [dbRegion, setDbRegion] = useState(REGIONS_DEFAULT) const [dbPricingTierKey, setDbPricingTierKey] = useState(PRICING_TIER_DEFAULT_KEY) @@ -54,6 +56,10 @@ const Wizard: NextPageWithLayout = () => { const [passwordStrengthScore, setPasswordStrengthScore] = useState(0) const [paymentMethods, setPaymentMethods] = useState(undefined) + const [showSpendCapHelperModal, setShowSpendCapHelperModal] = useState(false) + + const [isSpendCapEnabled, setIsSpendCapEnabled] = useState(true) + const organizations = values(toJS(app.organizations.list())) const currentOrg = organizations.find((o: any) => o.slug === slug) const stripeCustomerId = currentOrg?.stripe_customer_id @@ -66,6 +72,8 @@ const Wizard: NextPageWithLayout = () => { const isSelectFreeTier = dbPricingTierKey === PRICING_TIER_FREE_KEY const hasMembersExceedingFreeTierLimit = (membersExceededLimit || []).length > 0 + const showCustomVersionInput = process.env.NEXT_PUBLIC_ENVIRONMENT !== 'prod' + const canCreateProject = isAdmin && (!isSelectFreeTier || (isSelectFreeTier && !hasMembersExceedingFreeTierLimit)) @@ -147,15 +155,23 @@ const Wizard: NextPageWithLayout = () => { const onClickNext = async () => { setNewProjectLoading(true) - const data = { + + const dbTier = dbPricingTierKey === 'PRO' && !isSpendCapEnabled ? 'PAYG' : dbPricingTierKey + + const data: Record = { cloud_provider: PROVIDERS.AWS.id, // hardcoded for DB instances to be under AWS org_id: currentOrg?.id, name: projectName, db_pass: dbPass, db_region: dbRegion, - db_pricing_tier_id: (PRICING_TIER_PRODUCT_IDS as any)[dbPricingTierKey], + db_pricing_tier_id: (PRICING_TIER_PRODUCT_IDS as any)[dbTier], kps_enabled: kpsEnabled, } + if (postgresVersion) { + data['custom_supabase_internal_requests'] = { + ami: { search_tags: { 'tag:postgresVersion': postgresVersion } }, + } + } const response = await post(`${API_URL}/projects`, data) if (response.error) { setNewProjectLoading(false) @@ -235,7 +251,7 @@ const Wizard: NextPageWithLayout = () => { <> @@ -266,7 +282,7 @@ const Wizard: NextPageWithLayout = () => { <> @@ -282,7 +298,33 @@ const Wizard: NextPageWithLayout = () => { /> - + {showCustomVersionInput && ( + + + Specify a custom version of Postgres (Defaults to the latest) +
    + This is only applicable for local/staging projects +

    + } + type="text" + placeholder="Postgres Version" + value={postgresVersion} + onChange={(event: any) => setPostgresVersion(event.target.value)} + /> +
    + )} + + 0} @@ -304,7 +346,7 @@ const Wizard: NextPageWithLayout = () => { /> - + { addOnBefore={({ active, selected }: any) => ( )} > @@ -338,7 +382,7 @@ const Wizard: NextPageWithLayout = () => { )} {isAdmin && ( - + { )} )} + + {!isSelectFreeTier && ( + <> + + + Spend Cap + setShowSpendCapHelperModal(true)} + /> +
    + } + placeholder="Project name" + checked={isSpendCapEnabled} + onChange={() => setIsSpendCapEnabled(!isSpendCapEnabled)} + descriptionText={ +
    +

    + By default, Pro projects have spend caps to control costs. When enabled, + usage is limited to the plan's quota, with restrictions when limits are + exceeded. To scale beyond Pro limits without restrictions, disable the + spend cap and pay for over-usage beyond the quota. +

    +
    + } + /> + + + setShowSpendCapHelperModal(false)} + /> + + )} )} diff --git a/studio/pages/org/_/[[...routeSlug]].tsx b/studio/pages/org/_/[[...routeSlug]].tsx index 69c379f35b3..1e53059a44d 100644 --- a/studio/pages/org/_/[[...routeSlug]].tsx +++ b/studio/pages/org/_/[[...routeSlug]].tsx @@ -5,6 +5,7 @@ import { observer } from 'mobx-react-lite' import { Button, IconPlus } from 'ui' import { withAuth, useStore } from 'hooks' +import { BASE_PATH } from 'lib/constants' import CardButton from 'components/ui/CardButton' import ShimmeringCard from 'components/interfaces/Home/ProjectList/ShimmeringCard' @@ -15,7 +16,7 @@ const Header = () => {
    Supabase { const { ref: projectRef } = useParams() @@ -17,7 +18,14 @@ const GraphiQLPage: NextPageWithLayout = () => { const isExtensionsLoading = meta.extensions.isLoading const pgGraphqlExtension = meta.extensions.byId('pg_graphql') - const { data: accessToken } = useSessionAccessTokenQuery() + + const { data: accessToken } = useSessionAccessTokenQuery({ enabled: IS_PLATFORM }) + const { data: settings, isFetched } = useProjectApiQuery({ projectRef }) + + const apiService = settings?.autoApiService + const anonKey = apiService?.service_api_keys.find((x) => x.name === 'anon key') + ? apiService.defaultApiKey + : undefined useEffect(() => { if (ui.selectedProject?.ref) { @@ -30,20 +38,33 @@ const GraphiQLPage: NextPageWithLayout = () => { const graphqlUrl = `${API_URL}/projects/${projectRef}/api/graphql` const fetcher = useMemo(() => { - return createGraphiQLFetcher({ url: graphqlUrl, fetch }) - }, [graphqlUrl]) + const fetcherFn = createGraphiQLFetcher({ url: graphqlUrl, fetch }) + const customFetcher: Fetcher = (graphqlParams, opts) => { + return fetcherFn(graphqlParams, { + ...opts, + headers: { + ...opts?.headers, + Authorization: `Bearer ${accessToken}`, + 'x-graphql-authorization': + opts?.headers?.['Authorization'] ?? opts?.headers?.['authorization'], + }, + }) + } - if (!accessToken || (isExtensionsLoading && !pgGraphqlExtension)) { + return customFetcher + }, [graphqlUrl, accessToken]) + + if ((IS_PLATFORM && !accessToken) || !isFetched || (isExtensionsLoading && !pgGraphqlExtension)) { return } if (pgGraphqlExtension?.installed_version === null) { return ( -
    -
    +
    +
    -

    Enable the GraphQL Extension

    -

    +

    Enable the GraphQL Extension

    +

    Toggle the switch below to enable the GraphQL extension. You can then use the GraphQL API with your Supabase Database.

    @@ -55,7 +76,7 @@ const GraphiQLPage: NextPageWithLayout = () => { ) } - return + return } GraphiQLPage.getLayout = (page) => {page} diff --git a/studio/pages/project/[ref]/database/backups/scheduled.tsx b/studio/pages/project/[ref]/database/backups/scheduled.tsx index 18cd5658a7b..71694a0c630 100644 --- a/studio/pages/project/[ref]/database/backups/scheduled.tsx +++ b/studio/pages/project/[ref]/database/backups/scheduled.tsx @@ -8,6 +8,7 @@ import { checkPermissions, useStore } from 'hooks' import { DatabaseLayout } from 'components/layouts' import { BackupsList } from 'components/interfaces/Database' import NoPermission from 'components/ui/NoPermission' +import { FormsContainer } from 'components/ui/Forms' const DatabaseScheduledBackups: NextPageWithLayout = () => { const { ui } = useStore() @@ -17,33 +18,35 @@ const DatabaseScheduledBackups: NextPageWithLayout = () => { const canReadScheduledBackups = checkPermissions(PermissionAction.READ, 'back_ups') return ( -
    -

    Backups

    + +
    +

    Backups

    - { - if (id === 'pitr') router.push(`/project/${ref}/database/backups/pitr`) - }} - > - - - + { + if (id === 'pitr') router.push(`/project/${ref}/database/backups/pitr`) + }} + > + + + -
    -

    - Projects are backed up daily around midnight of your project's region and can be restored - at any time. -

    - {canReadScheduledBackups ? ( - - ) : ( - - )} +
    +

    + Projects are backed up daily around midnight of your project's region and can be + restored at any time. +

    + {canReadScheduledBackups ? ( + + ) : ( + + )} +
    -
    +
    ) } diff --git a/studio/pages/project/[ref]/database/hooks/index.tsx b/studio/pages/project/[ref]/database/hooks/index.tsx index 67f796cd866..9b96f0f9390 100644 --- a/studio/pages/project/[ref]/database/hooks/index.tsx +++ b/studio/pages/project/[ref]/database/hooks/index.tsx @@ -1,3 +1,4 @@ +import clsx from 'clsx' import { useEffect, useState } from 'react' import { useRouter } from 'next/router' import { observer } from 'mobx-react-lite' @@ -9,43 +10,46 @@ import { API_URL } from 'lib/constants' import { post } from 'lib/common/fetch' import { DatabaseLayout } from 'components/layouts' import NoPermission from 'components/ui/NoPermission' -import CreateHook from 'components/interfaces/Database/Hooks/CreateHook' -import DeleteHook from 'components/interfaces/Database/Hooks/DeleteHook' import HooksList from 'components/interfaces/Database/Hooks/HooksList/HooksList' +import DeleteHookModal from 'components/interfaces/Database/Hooks/DeleteHookModal' +import EditHookPanel from 'components/interfaces/Database/Hooks/EditHookPanel' +import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState' +import AlphaPreview from 'components/to-be-cleaned/AlphaPreview' +import { IconLoader } from 'ui' const HooksPage: NextPageWithLayout = () => { const { meta, ui } = useStore() const router = useRouter() const { ref } = router.query + const schemas = meta.schemas.list() + const { isLoading: isLoadingSchemas } = meta.schemas - const [hooksEnabled, setHooksEnabled] = useState(false) - const [filterString, setFilterString] = useState('') const [selectedHook, setSelectedHook] = useState() const [showCreateHookForm, setShowCreateHookForm] = useState(false) const [showDeleteHookForm, setShowDeleteHookForm] = useState(false) + const isHooksEnabled = schemas.some((schema: any) => schema.name === 'supabase_functions') const canReadWebhooks = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'triggers') + const canCreateWebhooks = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'triggers') useEffect(() => { - if (ui.selectedProject?.ref) { - fetchHooks() - } + if (ui.selectedProject?.ref) meta.hooks.load() }, [ui.selectedProject?.ref]) - const fetchHooks = async () => { - meta.hooks.load() - } - const enableHooksForProject = async () => { - const headers: any = {} - const connectionString = ui.selectedProject?.connectionString - if (connectionString) headers['x-connection-encrypted'] = connectionString - try { - await post(`${API_URL}/database/${ref}/hook-enable`, {}) - setHooksEnabled(true) - } catch (error) { - console.error(error) + const res = await post(`${API_URL}/database/${ref}/hook-enable`, {}) + if (res.error) { + ui.setNotification({ + category: 'error', + message: `Failed to enable webhooks: ${res.error.message}`, + }) + } else { + meta.schemas.load() + ui.setNotification({ + category: 'success', + message: `Successfully enabled webhooks`, + }) } } @@ -68,28 +72,56 @@ const HooksPage: NextPageWithLayout = () => { return } + if (isLoadingSchemas) { + return ( +
    + +

    Checking if hooks are enabled

    +
    + ) + } + + if (!isHooksEnabled) { + return ( +
    + enableHooksForProject()} + disabled={!canCreateWebhooks} + disabledMessage="You need additional permissions to enable webhooks" + > + +

    + Database Webhooks can be used to trigger serverless functions or send requests to an + HTTP endpoint. +

    +

    Enable database webhooks on your project.

    +
    +
    + ) + } + return ( - <> - - + + setShowCreateHookForm(false)} /> - setShowDeleteHookForm(false)} /> - +
    ) } diff --git a/studio/pages/project/[ref]/database/roles/index.tsx b/studio/pages/project/[ref]/database/roles/index.tsx index 9057f0cfa8e..ef584cc1668 100644 --- a/studio/pages/project/[ref]/database/roles/index.tsx +++ b/studio/pages/project/[ref]/database/roles/index.tsx @@ -1,24 +1,18 @@ -import { useState } from 'react' import { observer } from 'mobx-react-lite' -import { isUndefined } from 'lodash' - import { DatabaseLayout } from 'components/layouts' -import { RolesList, RolesSettings } from 'components/interfaces/Database' +import { RolesList } from 'components/interfaces/Database' import { NextPageWithLayout } from 'types' +import clsx from 'clsx' const DatabaseRoles: NextPageWithLayout = () => { - const [selectedRole, setSelectedRole] = useState() - return ( -
    - {isUndefined(selectedRole) ? ( - - ) : ( - setSelectedRole(undefined)} - /> +
    +
    ) } diff --git a/studio/pages/project/[ref]/database/wrappers/index.tsx b/studio/pages/project/[ref]/database/wrappers/index.tsx index d8df5feae34..c5454dff1ff 100644 --- a/studio/pages/project/[ref]/database/wrappers/index.tsx +++ b/studio/pages/project/[ref]/database/wrappers/index.tsx @@ -1,3 +1,4 @@ +import clsx from 'clsx' import { observer } from 'mobx-react-lite' import { PermissionAction } from '@supabase/shared-types/out/constants' diff --git a/studio/pages/project/[ref]/editor/[id].tsx b/studio/pages/project/[ref]/editor/[id].tsx index a6dc7cab4d3..9fd9ca0e2dc 100644 --- a/studio/pages/project/[ref]/editor/[id].tsx +++ b/studio/pages/project/[ref]/editor/[id].tsx @@ -1,3 +1,4 @@ +import { useQueryClient } from '@tanstack/react-query' import { useEffect, useState } from 'react' import Link from 'next/link' import { useRouter } from 'next/router' @@ -7,6 +8,8 @@ import { Alert, Button, Checkbox, IconExternalLink, Modal } from 'ui' import type { PostgresTable, PostgresColumn } from '@supabase/postgres-meta' import { useStore, withAuth, useUrlState, useParams } from 'hooks' +import { entityTypeKeys } from 'data/entity-types/keys' +import { Entity } from 'data/entity-types/entity-type-query' import { Dictionary } from 'components/grid' import { TableEditorLayout } from 'components/layouts' import { TableGridEditor } from 'components/interfaces' @@ -14,12 +17,15 @@ import ConfirmationModal from 'components/ui/ConfirmationModal' import { NextPageWithLayout, SchemaView } from 'types' import { JsonEditValue } from 'components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/RowEditor.types' import { ProjectContextFromParamsProvider } from 'components/layouts/ProjectLayout/ProjectContext' +import { ForeignRowSelectorProps } from 'components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/ForeignRowSelector' const TableEditorPage: NextPageWithLayout = () => { const router = useRouter() const { id, ref: projectRef } = useParams() const [_, setParams] = useUrlState({ arrayKeys: ['filter', 'sort'] }) + const queryClient = useQueryClient() + const { meta, ui } = useStore() const [selectedSchema, setSelectedSchema] = useState() @@ -30,11 +36,18 @@ const TableEditorPage: NextPageWithLayout = () => { const [selectedColumnToDelete, setSelectedColumnToDelete] = useState() const [selectedTableToDelete, setSelectedTableToDelete] = useState() - const [sidePanelKey, setSidePanelKey] = useState<'row' | 'column' | 'table' | 'json'>() + const [sidePanelKey, setSidePanelKey] = useState< + 'row' | 'column' | 'table' | 'json' | 'foreign-row-selector' + >() const [selectedRowToEdit, setSelectedRowToEdit] = useState>() const [selectedColumnToEdit, setSelectedColumnToEdit] = useState() const [selectedTableToEdit, setSelectedTableToEdit] = useState() const [selectedValueForJsonEdit, setSelectedValueForJsonEdit] = useState() + const [selectedForeignKeyToEdit, setSelectedForeignKeyToEdit] = useState<{ + foreignKey: NonNullable + row: any + column: any + }>() const tables: PostgresTable[] = meta.tables.list() const views: SchemaView[] = meta.views.list() @@ -88,21 +101,27 @@ const TableEditorPage: NextPageWithLayout = () => { setSelectedTableToEdit(undefined) } - const onEditTable = (table: PostgresTable) => { + const onEditTable = (entity: Entity) => { setSidePanelKey('table') setIsDuplicating(false) + + const table = meta.tables.byId(entity.id) setSelectedTableToEdit(table) } - const onDeleteTable = (table: PostgresTable) => { + const onDeleteTable = (entity: Entity) => { setIsDeleting(true) + + const table = meta.tables.byId(entity.id) setSelectedTableToDelete(table) setIsDeleteWithCascade(false) } - const onDuplicateTable = (table: PostgresTable) => { + const onDuplicateTable = (entity: Entity) => { setSidePanelKey('table') setIsDuplicating(true) + + const table = meta.tables.byId(entity.id) setSelectedTableToEdit(table) } @@ -111,6 +130,23 @@ const TableEditorPage: NextPageWithLayout = () => { setSelectedValueForJsonEdit({ column, row, jsonString: JSON.stringify(row[column]) || '' }) } + const onEditForeignKeyColumnValue = ({ + foreignKey, + row, + column, + }: { + foreignKey: NonNullable + row: any + column: any + }) => { + setSidePanelKey('foreign-row-selector') + setSelectedForeignKeyToEdit({ + foreignKey, + row, + column, + }) + } + const onClosePanel = () => { setSidePanelKey(undefined) } @@ -170,6 +206,8 @@ const TableEditorPage: NextPageWithLayout = () => { const tables = meta.tables.list((table: PostgresTable) => table.schema === selectedSchema) + await queryClient.invalidateQueries(entityTypeKeys.list(projectRef)) + // For simplicity for now, we just open the first table within the same schema if (tables.length > 0) { router.push(`/project/${projectRef}/editor/${tables[0].id}`) @@ -211,12 +249,14 @@ const TableEditorPage: NextPageWithLayout = () => { selectedColumnToEdit={selectedColumnToEdit} selectedTableToEdit={selectedTableToEdit} selectedValueForJsonEdit={selectedValueForJsonEdit} + selectedForeignKeyToEdit={selectedForeignKeyToEdit} onAddRow={onAddRow} onEditRow={onEditRow} onAddColumn={onAddColumn} onEditColumn={onEditColumn} onDeleteColumn={onDeleteColumn} onExpandJSONEditor={onExpandJSONEditor} + onEditForeignKeyColumnValue={onEditForeignKeyColumnValue} onClosePanel={onClosePanel} theme={ui.themeOption == 'dark' ? 'dark' : 'light'} /> diff --git a/studio/pages/project/[ref]/editor/index.tsx b/studio/pages/project/[ref]/editor/index.tsx index f6e8d064eb4..23384fd610d 100644 --- a/studio/pages/project/[ref]/editor/index.tsx +++ b/studio/pages/project/[ref]/editor/index.tsx @@ -6,6 +6,7 @@ import type { PostgresTable } from '@supabase/postgres-meta' import { NextPageWithLayout } from 'types' import { useStore, withAuth } from 'hooks' +import { Entity } from 'data/entity-types/entity-type-query' import { TableEditorLayout } from 'components/layouts' import { EmptyState, SidePanelEditor } from 'components/interfaces/TableGridEditor' import ConfirmationModal from 'components/ui/ConfirmationModal' @@ -27,20 +28,26 @@ const TableEditorPage: NextPageWithLayout = () => { setSelectedTableToEdit(undefined) } - const onEditTable = (table: PostgresTable) => { + const onEditTable = (entity: Entity) => { setSidePanelKey('table') setIsDuplicating(false) + + const table = meta.tables.byId(entity.id) setSelectedTableToEdit(table) } - const onDeleteTable = (table: PostgresTable) => { + const onDeleteTable = (entity: Entity) => { setIsDeleting(true) + + const table = meta.tables.byId(entity.id) setSelectedTableToDelete(table) } - const onDuplicateTable = (table: PostgresTable) => { + const onDuplicateTable = (entity: Entity) => { setSidePanelKey('table') setIsDuplicating(true) + + const table = meta.tables.byId(entity.id) setSelectedTableToEdit(table) } diff --git a/studio/pages/project/[ref]/index.tsx b/studio/pages/project/[ref]/index.tsx index 38d0c25de3e..b6bd9ded124 100644 --- a/studio/pages/project/[ref]/index.tsx +++ b/studio/pages/project/[ref]/index.tsx @@ -41,26 +41,30 @@ const Home: NextPageWithLayout = () => { {IS_PLATFORM && project?.status !== PROJECT_STATUS.INACTIVE && }
    -
    -
    -

    Client libraries

    -
    -
    - {CLIENT_LIBRARIES.map((library) => ( - - ))} -
    -
    -
    -
    -

    Example projects

    -
    -
    - {EXAMPLE_PROJECTS.sort((a, b) => a.title.localeCompare(b.title)).map((project) => ( - - ))} -
    -
    + {project?.status !== PROJECT_STATUS.INACTIVE && ( + <> +
    +
    +

    Client libraries

    +
    +
    + {CLIENT_LIBRARIES.map((library) => ( + + ))} +
    +
    +
    +
    +

    Example projects

    +
    +
    + {EXAMPLE_PROJECTS.sort((a, b) => a.title.localeCompare(b.title)).map((project) => ( + + ))} +
    +
    + + )}
    ) } diff --git a/studio/pages/project/[ref]/reports/query-performance.tsx b/studio/pages/project/[ref]/reports/query-performance.tsx index 2fcbea23763..f475188813c 100644 --- a/studio/pages/project/[ref]/reports/query-performance.tsx +++ b/studio/pages/project/[ref]/reports/query-performance.tsx @@ -15,6 +15,7 @@ import { NextPageWithLayout } from 'types' import { Accordion, Button, IconAlertCircle, IconCheckCircle, Tabs } from 'ui' import ReportPadding from 'components/interfaces/Reports/ReportPadding' import ReportHeader from 'components/interfaces/Reports/ReportHeader' +import Link from 'next/link' const QueryPerformanceReport: NextPageWithLayout = () => { const { project } = useProjectContext() @@ -62,13 +63,19 @@ const QueryPerformanceReport: NextPageWithLayout = () => { const showIndexWarning = indexHitRate && tableHitRate && (indexHitRate <= 0.99 || tableHitRate <= 0.99) - const HeaderText = `Identify the queries that consume the most time and database resources. - - It relies on the \`pg_stat_statements\` table. Read more about [examining query performance]((https://supabase.com/docs/guides/platform/performance#examining-query-performance)). - - Consider resetting the analysis after optimizing any queries. - -` + const headerText = ( +

    + Identify the queries that consume the most time and database resources. + {'\n\n'}It relies on the pg_stat_statements table. Read more about{' '} + + examining query performance + + .{'\n\n'}Consider resetting the analysis after optimizing any queries. +

    + ) const TimeConsumingHelperText = `This table lists queries ordered by their cumulative total execution time. @@ -84,7 +91,6 @@ const QueryPerformanceReport: NextPageWithLayout = () => { Look for queries with high or mean execution times. These are often good candidates for optimization. ` - const panelClassNames = 'text-sm max-w-none flex flex-col gap-8 py-4' const helperTextClassNames = 'prose text-sm max-w-2xl text-scale-1000' @@ -156,7 +162,7 @@ const QueryPerformanceReport: NextPageWithLayout = () => { )} - + {headerText}
    - {isExceededUsage && Exceeded usage} -