diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index 71029382f99..8018687be8b 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -663,10 +663,23 @@ export const database: NavMenuConstant = { name: 'RUM: inverted index for full-text search', url: '/guides/database/extensions/rum', }, + ], + }, + { + name: 'Foreign Data Wrappers', + url: undefined, + items: [ + { name: 'Overview', url: '/guides/database/extensions/wrappers/overview' }, + { name: 'Connecting to Airtable', url: '/guides/database/extensions/wrappers/airtable' }, + { name: 'Connecting to AWS S3', url: '/guides/database/extensions/wrappers/s3' }, + { name: 'Connecting to BigQuery', url: '/guides/database/extensions/wrappers/bigquery' }, { - name: 'wrappers: 3rd Party Integrations', - url: '/guides/database/extensions/wrappers', + name: 'Connecting to ClickHouse', + url: '/guides/database/extensions/wrappers/clickhouse', }, + { name: 'Connecting to Firebase', url: '/guides/database/extensions/wrappers/firebase' }, + { name: 'Connecting to Logflare', url: '/guides/database/extensions/wrappers/logflare' }, + { name: 'Connecting to Stripe', url: '/guides/database/extensions/wrappers/stripe' }, ], }, { diff --git a/apps/docs/lib/mdx/plugins/remarkTabs.ts b/apps/docs/lib/mdx/plugins/remarkTabs.ts new file mode 100644 index 00000000000..3562c2a6eca --- /dev/null +++ b/apps/docs/lib/mdx/plugins/remarkTabs.ts @@ -0,0 +1,138 @@ +import { Content, Paragraph, Parent } from 'mdast' +import { MdxJsxFlowElement } from 'mdast-util-mdx' +import { Node } from 'unist' +import { visit } from 'unist-util-visit' + +/** + * Transforms PyMdown Tabs to Supabase Tabs. + * + * https://facelessuser.github.io/pymdown-extensions/extensions/tabbed/ + */ +const remarkPyMdownTabs = function () { + return function transformer(root: Parent) { + visit(root, 'paragraph', (node: Paragraph, nodeIndex: number, parent: Parent) => { + for (let i = 0; i < node.children.length; i++) { + const child = node.children[i] + if (child.type !== 'text') { + continue + } + + // Look for 3 '=', followed by an optionally quoted title, + // followed by optional newlines of text + const match = child.value.match(/^\n*=== ("?)(.+)\1\n?((?:.|\n)*)/) + + if (!match) { + continue + } + + // Extract the tab title along with the remaining text + const [, , title, value] = match + + // Rewrite the node's value to remove the tab syntax + child.value = value + + // Extract sibling nodes that should be linked to this tab + const siblingsToNest = extractLinkedSiblings(parent, node, nodeIndex) + + const children: any[] = [...node.children.slice(i), ...siblingsToNest] + + const tabPanelElement: MdxJsxFlowElement = { + type: 'mdxJsxFlowElement', + name: 'TabPanel', + attributes: [ + { + type: 'mdxJsxAttribute', + name: 'id', + value: title, + }, + { + type: 'mdxJsxAttribute', + name: 'label', + value: title, + }, + ], + children, + } + + let nodesAdded = 0 + const previousNode = parent.children[nodeIndex - 1] + + if (previousNode?.type === 'mdxJsxFlowElement' && previousNode.name === 'Tabs') { + // Add TabPanel to existing Tabs component + previousNode.children.push(tabPanelElement) + + // Remove this node + parent.children.splice(nodeIndex, 1) + nodesAdded-- + } else { + // Create new Tabs components and add TabPanel + const tabsElement: MdxJsxFlowElement = { + type: 'mdxJsxFlowElement', + name: 'Tabs', + attributes: [ + { + type: 'mdxJsxAttribute', + name: 'scrollable', + }, + { + type: 'mdxJsxAttribute', + name: 'size', + value: 'small', + }, + { + type: 'mdxJsxAttribute', + name: 'type', + value: 'underlined', + }, + ], + children: [tabPanelElement], + } + + // Overwrite this paragraph node with Tabs component + parent.children.splice(nodeIndex, 1, tabsElement) + } + + // If this wasn't the first child of the paragraph, create + // a new paragraph before this with the previous text children + if (i > 0) { + const previousChildren = node.children.slice(0, i) + const paragraph: Paragraph = { + type: 'paragraph', + children: previousChildren, + } + parent.children.splice(nodeIndex, 0, paragraph) + + nodesAdded++ + } + + // Return the correct index for the next visit, since + // we may have added or removed an element in the array + return nodeIndex + nodesAdded + } + }) + } +} + +/** + * Identifies sibling nodes that should be linked to this admonition + * based on their indent level (ie. 4 spaces). + * + * Iterates through proceeding siblings until one is found that is + * not indented relative to the original node. + * + * Splices the discovered siblings out of the original parent and returns them. + */ +function extractLinkedSiblings(parent: Parent, node: Node, index: number, indentAmount = 4) { + const { column } = node.position.start + + let nextSibling: Content + let i = index + + do { + nextSibling = parent.children[++i] + } while (nextSibling?.position && nextSibling.position.start.column === column + indentAmount) + + return parent.children.splice(index + 1, i - index - 1) +} + +export default remarkPyMdownTabs diff --git a/apps/docs/package.json b/apps/docs/package.json index 5d79d59dddd..e3710ea9ba6 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -97,6 +97,7 @@ "remark-admonitions": "^1.2.1", "remark-gfm": "^3.0.1", "remark-slug": "^7.0.1", + "remark-emoji": "^3.1.2", "shiki": "^0.11.1", "sse.js": "^0.6.1", "ui": "*", diff --git a/apps/docs/pages/guides/auth/social-login/auth-google.mdx b/apps/docs/pages/guides/auth/social-login/auth-google.mdx index 41d4fdb0f24..a4d2728207b 100644 --- a/apps/docs/pages/guides/auth/social-login/auth-google.mdx +++ b/apps/docs/pages/guides/auth/social-login/auth-google.mdx @@ -72,76 +72,87 @@ When the user provides consent, Google issues an identity token (commonly abbrev If you are building a Flutter app, you can use [flutter_appauth](https://pub.dev/packages/flutter_appauth) package to sign a user into your Supabase project: ````dart +import 'dart:convert'; +import 'dart:math'; import 'package:crypto/crypto.dart'; import 'package:flutter_appauth/flutter_appauth.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; -// Generate a random string -final rawNonce = generateRandomString(); -final hashedNonce = sha256.convert(utf8.encode(rawNonce)).toString(); - -// Your registered Google client ID here. -// This will be different for iOS and Android -const clientId = 'YOUR_GOODLE_CLIENT_ID'; - -// bundle ID for iOS, package name for Android here -final packageName = 'io.supabase.example'; - -/// fixed for google login -final redirectUrl = '$packageName:/google_auth'; - -/// fixed for google login -const discoveryUrl = - 'https://accounts.google.com/.well-known/openid-configuration'; - -final appAuth = FlutterAppAuth(); - -// authorize the user by opening the consent page -final result = await appAuth.authorize( - AuthorizationRequest( - clientId, - redirectUrl, - discoveryUrl: discoveryUrl, - nonce: hashedNonce, - scopes: [ - 'openid', - 'email', - ], - ), -); - -if (result == null) { - throw AuthException( - 'Could not find AuthorizationResponse after authorizing'); +/// Function to generate a random 16 character string. +String _generateRandomString() { + final random = Random.secure(); + return base64Url.encode(List.generate(16, (_) => random.nextInt(256))); } -// Request the access and id token to google -final tokenResponse = await appAuth.token( - TokenRequest( - clientId, - redirectUrl, - authorizationCode: result.authorizationCode, - discoveryUrl: discoveryUrl, - codeVerifier: result.codeVerifier, - nonce: result.nonce, - scopes: [ - 'openid', - 'email', - ], - ), -); +Future signInWithGoogle() { + // Just a random string + final rawNonce = _generateRandomString(); + final hashedNonce = + sha256.convert(utf8.encode(rawNonce)).toString(); -final idToken = tokenResponse?.idToken; + /// TODO: update the client ID with your own + /// + /// Client ID that you registered with Google Cloud. + /// You will have two different values for iOS and Android. + const clientId = 'YOUR_CLIENT_ID_HERE'; -if (idToken == null) { - throw AuthException('Could not find idToken from the token response'); + /// reverse DNS form of the client ID + `:/` is set as the redirect URL + final redirectUrl = '${clientId.split('.').reversed.join('.')}:/'; + + /// Fixed value for google login + const discoveryUrl = + 'https://accounts.google.com/.well-known/openid-configuration'; + + final appAuth = FlutterAppAuth(); + + // authorize the user by opening the consent page + final result = await appAuth.authorize( + AuthorizationRequest( + clientId, + redirectUrl, + discoveryUrl: discoveryUrl, + nonce: hashedNonce, + scopes: [ + 'openid', + 'email', + ], + ), + ); + + if (result == null) { + throw 'Could not find AuthorizationResponse after authorizing'; + } + + // Request the access and id token to google + final tokenResult = await appAuth.token( + TokenRequest( + clientId, + redirectUrl, + authorizationCode: result.authorizationCode, + discoveryUrl: discoveryUrl, + codeVerifier: result.codeVerifier, + nonce: result.nonce, + scopes: [ + 'openid', + 'email', + ], + ), + ); + + final idToken = tokenResult?.idToken; + + if (idToken == null) { + throw 'Could not find idToken from the token response'; + } + + return supabase.auth.signInWithIdToken( + provider: Provider.google, + idToken: idToken, + accessToken: tokenResponse?.accessToken, + nonce: rawNonce, + ); } - -await signInWithIdToken( - provider: Provider.google, - idToken: idToken, - accessToken: tokenResponse?.accessToken, - nonce: rawNonce, -);``` +``` In the Supabase JavaScript library, which you can use with web-based native frameworks like React Native or Expo, you can invoke this functionality like so: diff --git a/apps/docs/pages/guides/cli/github-action/[slug].tsx b/apps/docs/pages/guides/cli/github-action/[slug].tsx index 181f3acb409..fe30a5f3f2a 100644 --- a/apps/docs/pages/guides/cli/github-action/[slug].tsx +++ b/apps/docs/pages/guides/cli/github-action/[slug].tsx @@ -11,6 +11,7 @@ import Layout from '~/layouts/DefaultGuideLayout' import { UrlTransformFunction, linkTransform } from '~/lib/mdx/plugins/rehypeLinkTransform' import remarkMkDocsAdmonition from '~/lib/mdx/plugins/remarkAdmonition' import { removeTitle } from '~/lib/mdx/plugins/remarkRemoveTitle' +import remarkPyMdownTabs from '~/lib/mdx/plugins/remarkTabs' // We fetch these docs at build time from an external repo const org = 'supabase' @@ -31,6 +32,13 @@ const pageMap = [ }, remoteFile: 'generating-types.md', }, + { + slug: 'api', + meta: { + title: 'GraphQL API', + }, + remoteFile: 'api.md', + }, ] interface ActionDocsProps { @@ -116,6 +124,7 @@ export const getStaticProps: GetStaticProps = async ({ params } remarkPlugins: [ remarkGfm, remarkMkDocsAdmonition, + remarkPyMdownTabs, [removeTitle, meta.title], [remarkCodeHike, codeHikeOptions], ], diff --git a/apps/docs/pages/guides/database/extensions/pg_graphql.mdx b/apps/docs/pages/guides/database/extensions/pg_graphql.mdx index eee0cfb1103..1684776d492 100644 --- a/apps/docs/pages/guides/database/extensions/pg_graphql.mdx +++ b/apps/docs/pages/guides/database/extensions/pg_graphql.mdx @@ -62,7 +62,7 @@ insert into "Blog"(name) values ('My Blog'); ``` -The reflected GraphQL schema can be queries immediately as +The reflected GraphQL schema can be queried immediately as {/* prettier-ignore */} ```sql diff --git a/apps/docs/pages/guides/database/extensions/wrappers/[slug].tsx b/apps/docs/pages/guides/database/extensions/wrappers/[slug].tsx new file mode 100644 index 00000000000..2363a4ce7d0 --- /dev/null +++ b/apps/docs/pages/guides/database/extensions/wrappers/[slug].tsx @@ -0,0 +1,181 @@ +import { CodeHikeConfig, remarkCodeHike } from '@code-hike/mdx' +import { GetStaticPaths, GetStaticProps } from 'next' +import { MDXRemote, MDXRemoteSerializeResult } from 'next-mdx-remote' +import { serialize } from 'next-mdx-remote/serialize' +import { relative } from 'path' +import rehypeSlug from 'rehype-slug' +import remarkGfm from 'remark-gfm' +import emoji from 'remark-emoji' +import codeHikeTheme from 'config/code-hike.theme.json' assert { type: 'json' } +import components from '~/components' +import Layout from '~/layouts/DefaultGuideLayout' +import { UrlTransformFunction, linkTransform } from '~/lib/mdx/plugins/rehypeLinkTransform' +import remarkMkDocsAdmonition from '~/lib/mdx/plugins/remarkAdmonition' +import { removeTitle } from '~/lib/mdx/plugins/remarkRemoveTitle' +import remarkPyMdownTabs from '~/lib/mdx/plugins/remarkTabs' + +// We fetch these docs at build time from an external repo +const org = 'supabase' +const repo = 'wrappers' +const branch = 'main' +const docsDir = 'docs' +const externalSite = 'https://supabase.github.io/wrappers' + +// Each external docs page is mapped to a local page +const pageMap = [ + { + slug: 'airtable', + meta: { + title: 'Airtable', + }, + remoteFile: 'airtable.md', + }, + { + slug: 's3', + meta: { + title: 'AWS S3', + }, + remoteFile: 's3.md', + }, + { + slug: 'bigquery', + meta: { + title: 'BigQuery', + }, + remoteFile: 'bigquery.md', + }, + { + slug: 'clickhouse', + meta: { + title: 'ClickHouse', + }, + remoteFile: 'clickhouse.md', + }, + { + slug: 'firebase', + meta: { + title: 'Firebase', + }, + remoteFile: 'firebase.md', + }, + { + slug: 'logflare', + meta: { + title: 'Logflare', + }, + remoteFile: 'logflare.md', + }, + { + slug: 'stripe', + meta: { + title: 'Stripe', + }, + remoteFile: 'stripe.md', + }, +] + +interface WrappersDocsProps { + source: MDXRemoteSerializeResult + meta: { + title: string + description?: string + } +} + +export default function WrappersDocs({ source, meta }: WrappersDocsProps) { + return ( + + + + ) +} + +/** + * Fetch markdown from external repo and transform links + */ +export const getStaticProps: GetStaticProps = async ({ params }) => { + const page = pageMap.find(({ slug }) => slug === params.slug) + + if (!page) { + throw new Error(`No page mapping found for slug '${params.slug}'`) + } + + const { remoteFile, meta } = page + + const response = await fetch( + `https://raw.githubusercontent.com/${org}/${repo}/${branch}/${docsDir}/${remoteFile}` + ) + + const source = await response.text() + + const urlTransform: UrlTransformFunction = (url) => { + try { + const externalSiteUrl = new URL(externalSite) + + const placeholderHostname = 'placeholder' + const { hostname, pathname, hash } = new URL(url, `http://${placeholderHostname}`) + + // Don't modify a url with a FQDN or a url that's only a hash + if (hostname !== placeholderHostname || pathname === '/') { + return url + } + + const relativePage = ( + pathname.endsWith('.md') + ? pathname.replace(/\.md$/, '') + : relative(externalSiteUrl.pathname, pathname) + ).replace(/^\//, '') + + const page = pageMap.find(({ remoteFile }) => `${relativePage}.md` === remoteFile) + + // If we have a mapping for this page, use the mapped path + if (page) { + return page.slug + hash + } + + // If we don't have this page in our docs, link to original docs + return `${externalSite}/${relativePage}${hash}` + } catch (err) { + console.error('Error transforming markdown URL', err) + return url + } + } + + const codeHikeOptions: CodeHikeConfig = { + theme: codeHikeTheme, + lineNumbers: true, + showCopyButton: true, + skipLanguages: [], + autoImport: false, + } + + const mdxSource = await serialize(source, { + scope: { + chCodeConfig: codeHikeOptions, + }, + mdxOptions: { + remarkPlugins: [ + remarkGfm, + remarkMkDocsAdmonition, + emoji, + remarkPyMdownTabs, + [removeTitle, meta.title], + [remarkCodeHike, codeHikeOptions], + ], + rehypePlugins: [[linkTransform, urlTransform], rehypeSlug], + }, + }) + + return { props: { source: mdxSource, meta } } +} + +export const getStaticPaths: GetStaticPaths = async () => { + return { + paths: pageMap.map(({ slug }) => ({ + params: { + slug, + }, + })), + fallback: false, + } +} diff --git a/apps/docs/pages/guides/database/extensions/wrappers.mdx b/apps/docs/pages/guides/database/extensions/wrappers/overview.mdx similarity index 78% rename from apps/docs/pages/guides/database/extensions/wrappers.mdx rename to apps/docs/pages/guides/database/extensions/wrappers/overview.mdx index 5d99f88ea08..16373b0a30e 100644 --- a/apps/docs/pages/guides/database/extensions/wrappers.mdx +++ b/apps/docs/pages/guides/database/extensions/wrappers/overview.mdx @@ -2,7 +2,7 @@ import Layout from '~/layouts/DefaultGuideLayout' export const meta = { id: 'wrappers', - title: 'wrappers: 3rd Party Integrations', + title: 'Wrappers: 3rd Party Integrations', description: '3rd party integrations for PostgreSQL ', } @@ -60,12 +60,13 @@ A web interface for connecting to external data is coming to Supabase Studio in ## Integrations -- [Firebase](https://supabase.github.io/wrappers/firebase/) -- [Stripe](https://supabase.github.io/wrappers/stripe/) -- [BigQuery](https://supabase.github.io/wrappers/bigquery/) -- [AWS S3](https://supabase.github.io/wrappers/s3/) -- Airtable - Coming Soon -- Clickhouse - Coming Soon +- [Airtable](/docs/guides/database/extensions/wrappers/airtable) +- [AWS S3](/docs/guides/database/extensions/wrappers/s3) +- [BigQuery](/docs/guides/database/extensions/wrappers/bigquery) +- [ClickHouse](/docs/guides/database/extensions/wrappers/clickhouse) +- [Firebase](/docs/guides/database/extensions/wrappers/firebase) +- [Logflare](/docs/guides/database/extensions/wrappers/logflare) +- [Stripe](/docs/guides/database/extensions/wrappers/stripe) ## Resources diff --git a/apps/docs/pages/guides/platform/custom-postgres-config.mdx b/apps/docs/pages/guides/platform/custom-postgres-config.mdx index be25220006c..c5888bf6a6e 100644 --- a/apps/docs/pages/guides/platform/custom-postgres-config.mdx +++ b/apps/docs/pages/guides/platform/custom-postgres-config.mdx @@ -73,3 +73,15 @@ max_parallel_workers |3 | export const Page = ({ children }) => export default Page + +## Pooler Config + +You can also [customize some parameters](https://supabase.com/dashboard/project/_/settings/database) for the Connection Pooler: + +1. [Pooling Mode](https://devcenter.heroku.com/articles/best-practices-pgbouncer-configuration#pgbouncer-s-connection-pooling-modes) +1. [Default Pool Size](https://www.pgbouncer.org/config.html) +1. [Max Client Connections](https://www.pgbouncer.org/config.html) + +The default pool size, and the maximum number of clients allowed to connect concurrently is automatically optimized based on the compute add-on being used. At the moment, the Dashboard only reflects any custom configuration being used, and does not include the default optimized numbers used for your project. + +Custom Pooler Config may also require manual updates to any relevant overrides when changing compute add-ons. diff --git a/apps/docs/pages/guides/self-hosting/docker.mdx b/apps/docs/pages/guides/self-hosting/docker.mdx index d3d326c5c11..3122ba067d3 100644 --- a/apps/docs/pages/guides/self-hosting/docker.mdx +++ b/apps/docs/pages/guides/self-hosting/docker.mdx @@ -48,7 +48,7 @@ We can use your JWT Secret to generate new `anon` and `service` API keys using t ### Update API Keys -Replace the values the `.env` file: +Replace the values in the `.env` file: - `ANON_KEY` - replace with an `anon` key - `SERVICE_ROLE_KEY` - replace with a `service` key diff --git a/apps/www/_blog/2023-07-25-supabase-lw8-hackathon.mdx b/apps/www/_blog/2023-07-25-supabase-lw8-hackathon.mdx new file mode 100644 index 00000000000..e11d5569661 --- /dev/null +++ b/apps/www/_blog/2023-07-25-supabase-lw8-hackathon.mdx @@ -0,0 +1,107 @@ +--- +title: 'Supabase Launch Week 8 Hackathon' +description: Build an Open Source Project over 10 days. 5 prize categories. +author: ant_wilson +image: lw8-hackathon/thumbnail.png +thumb: lw8-hackathon/thumbnail.png +launchweek: 8 +tags: + - launch-week + - hackathon +date: '2023-07-25' +toc_depth: 2 +--- + +[Supabase Launch Week 8](https://supabase.com/launch-week) is about to start and we're running an async hackathon alongside it. + +The hackathon starts Friday 4th August at 09:00 am PT and ends Sunday 13th August at 11:59 pm PT. You can win extremely limited edition Launch Week swag and add your name to the Supabase Hackathon Hall of Fame. + +For some inspiration check out all the [winners from previous hackathons](https://supabase.com/blog/tags/hackathon). + +This is the perfect excuse to "Build in a weekend, scale to millions". Since you retain all the rights to your submissions, you can use the hackathon as a launch pad for your new Startup ideas, side-project, or indie hack. + +![Supabase Hackathon Meme](/images/blog/lw8-hackathon/supabase-hackathon-meme.png) + +## Key Facts + +- You have 10 days to build a new **Open Source** project using Supabase in some capacity. + - Starting 09:00 am PT Friday 4th August 2023 + - The submission deadline is 11:59 pm Sunday midnight PT 14th August 2023 +- Enter as an individual, or as a team of up to 5 people +- Build whatever you want - a project, app, tool, library. Anything. + +## Prizes + +There are 5 categories, there will be prizes for: + +- Best overall project +- Best use of AI +- Most fun / best easter egg +- Most technically impressive +- Most visually pleasing + +There will be a winner and a runner-up prize for each category. Every team member on winning/runner-up teams gets a Supabase Launch Week swag kit. + +![Supabase LW8 Swag](/images/blog/lw8-hackathon/swag.png) + +## Submission + +You should submit your project using [this form](https://madewithsupabase.com/hackathons/launch-week-8) before 11:59 pm Sunday midnight PT 14th August 2023. Extra points if you include a simple video demoing the app in the description. + +## Judges + +The Supabase team will judge the winners for each category. +We will be looking for: + +- creativity/inventiveness +- functions correctly/smoothly +- visually pleasing +- technically impressive +- use of Supabase features +- FUN! 😃 + +## Rules + +- Team size 1-5 (all team members on winning teams will receive a prize) +- You cannot be on multiple teams +- One submission per team +- It's not a requirement to use AI +- All design elements, code, etc. for your project must be created **during** the event +- All entries must be Open Source (link to source code required in entry) +- Must use Supabase in some capacity +- Can be any language or framework +- You must submit before the deadline (no late entries) +- (optional) Include a link to a demo video along with the submission for extra points + +## Resources + +### Supabase resources to get you started + +- [OpenAI with Edge Functions YouTube video](https://www.youtube.com/watch?v=29p8kIqyU_Y) +- [Streaming Data in Edge Functions](https://www.youtube.com/watch?v=9N66JBRLNYU) +- [Storing OpenAI embeddings in Postgres with pgvector article](https://supabase.com/blog/openai-embeddings-postgres-vector) +- [pgvector guide](https://supabase.com/docs/guides/database/extensions/pgvector) + +### Community help + +The Supabase Team will be taking part in the Hackathon and you'll find us live to build in our discord all week. Please join us by building in public: + +- Text channel: [#hackathon](https://discord.gg/UYyweApy) +- Audio channel: [#hackathon](https://discord.gg/Vj3mTPwH) + +If you need help or advice when building, find other people to join your team, or if you just want to chill and watch people build, come and join us! + +[Join our Discord](https://discord.supabase.com/) + +![Supabase Discord Channel](/images/blog/lw7-hackathon/discord.png) + +## Launch Week + +Don't forget to check out the new features being announced as part of [Launch Week](https://supabase.com/launch-week). + +- [Previous Hackathon Prize Winners](https://supabase.com/blog/tags/hackathon) + +## Additional Info + +- Any intellectual property developed during the hackathon will belong to the team that developed it. We expect that each team will have an agreement between themselves regarding the IP, but this is not required. +- By making a submission you grant Supabase permission to use screenshots, code snippets, and/or links to your project or content of your README on our Twitter, blog, website, email updates, and in the Supabase discord server. Supabase does not make any claims over your IP. diff --git a/apps/www/_blog/2023-08-01-react-native-storage.mdx b/apps/www/_blog/2023-08-01-react-native-storage.mdx new file mode 100644 index 00000000000..361c57be6ab --- /dev/null +++ b/apps/www/_blog/2023-08-01-react-native-storage.mdx @@ -0,0 +1,625 @@ +--- +title: 'React Native file upload with Supabase Storage' +description: Learn how to implement authentication and file upload in a React Native app. +author: simon_grimm +image: 2023-08-01-react-native-storage/react-native-storage.png +thumb: 2023-08-01-react-native-storage/react-native-storage.png +tags: + - react-native + - storage +date: '2023-08-01' +toc_depth: 2 +--- + +If you want to upload files from your React Native app, you need a backend to store the files, [Supabase Storage](https://supabase.com/docs/guides/storage) is a great choice for this as it provides a simple API to upload files, and we can easily combine this with authentication to build a powerful app. + +This means you can quickly build your own image-sharing app, a file-sharing app, or any other app that needs to upload files to a backend! + +![Supabase File Upload React Native](/images/blog/2023-08-01-react-native-storage/supabase-file-upload-react-native.gif) + +In this tutorial you will learn to: + +- Set up a React Native app with [Expo SDK49](https://expo.dev/) +- Use [Supabase Authentication](https://supabase.com/docs/guides/auth) +- Work with Expo Router v2 and protected routes +- Upload files to [Supabase Storage](https://supabase.com/docs/guides/storage) + +You can also directly check out the [full source code on Github](https://github.com/saimon24/react-native-resumable-upload-supabase) so you can get started with Supabase fast! + +Before we get into the app, let's quickly [start a new Supabase project](https://supabase.com/dashboard). + +## Creating the Supabase Project + +To use authentication and storage we need a new Supabase project. If you don't have a Supabase account yet, you can [get started for free](https://supabase.com/dashboard)! + +In your dashboard, click "New Project" and leave it to the default settings, but make sure you keep a copy o your Database password! + +![Supabase new project](/images/blog/2023-08-01-react-native-storage/new-project.png) + +After a minute your project should be ready, and we can configure the authentication and storage. + +## Setting up Authentication + +Authentication will be enabled by default, but we want to turn off email confirmation for this tutorial. + +Select **Authentication** from the menu, go to the **Providers** section, and expand Email. + +![Email Authentication settings](/images/blog/2023-08-01-react-native-storage/email-auth.png) + +Here you can disable the confirmation email, and apply other changes later if you want to! + +## Setting up Storage + +Now we want to create a **bucket** under storage where we will upload our files, and also add some security rules to protect the files of a user. + +First, select **Storage** from the menu, then click **New bucket** and call it `files`. + +![Supabase new bucket](/images/blog/2023-08-01-react-native-storage/new-bucket.png) + +Make sure that this is not a public bucket, otherwise, even unauthenticated users can upload or read the files! + +To protect that bucket and allow users only access to their own folder, we need to add some [Storage policies](https://supabase.com/docs/guides/storage/access-control). + +You can either do this through the UI and pick from examples, or simply run my SQL script in the **SQL Editor** which you can select from the menu: + +```sql +CREATE POLICY "Enable storage access for users based on user_id" ON "storage"."objects" +AS PERMISSIVE FOR ALL +TO public +USING (bucket_id = 'files' AND auth.uid()::text = (storage.foldername(name))[1]) +WITH CHECK (bucket_id = 'files' AND auth.uid()::text = (storage.foldername(name))[1]) +``` + +This will allow users to only access their own folder, and not any other files in the bucket. + +## Setting up the React Native app + +Now that we have our Supabase project ready, we can start building the React Native app! + +Get started by setting up a new Expo app with the tabs template and install some dependencies: + +```sh +# Create a new Expo app +npx create-expo-app@latest cloudApp --template tabs@49 + +# Install dependencies +npm i @supabase/supabase-js +npm i react-native-url-polyfill base64-arraybuffer react-native-loading-spinner-overlay + +# Install Expo packages +npx expo install expo-secure-store +npx expo install expo-image-picker +npx expo install expo-file-system +``` + +We will use the [Expo Secure Store](https://docs.expo.dev/versions/latest/sdk/securestore/) to store the Supabase session, and the [Expo Image Picker](https://docs.expo.dev/versions/latest/sdk/imagepicker/) to select images from the device. We also need the [Expo File System](https://docs.expo.dev/versions/latest/sdk/filesystem/) to read the image from the device and upload its data. + +You can now already run your project with `npx expo` and then select a platform to run on. + +However, the tabs template contains a lot of code that we don't need, so to simplify things we can remove the **app**, **constants** and **components** folder. + +This gives us a much cleaner project structure. + +## Connecting to Supabase from React Native + +To use Supabase we need to initialize the client with our project URL and the public key, which you can find in the **Settings** of your project under **API**. + +You can put both of them in a `.env` file at the root of your project: + +```env +EXPO_PUBLIC_SUPABASE_URL= +EXPO_PUBLIC_SUPABASE_ANON_KEY= +``` + +We can now simply read those values from the environment variables and initialize the Supabase client, so create a file at `config/initSupabase.ts` and add the following code: + +```ts +import * as SecureStore from 'expo-secure-store' +import 'react-native-url-polyfill/auto' + +import { createClient } from '@supabase/supabase-js' + +// Use a custom secure storage solution for the Supabase client to store the JWT +const ExpoSecureStoreAdapter = { + getItem: (key: string) => { + return SecureStore.getItemAsync(key) + }, + setItem: (key: string, value: string) => { + SecureStore.setItemAsync(key, value) + }, + removeItem: (key: string) => { + SecureStore.deleteItemAsync(key) + }, +} + +const url = process.env.EXPO_PUBLIC_SUPABASE_URL +const key = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY + +// Initialize the Supabase client +export const supabase = createClient(url, key, { + auth: { + storage: ExpoSecureStoreAdapter as any, + detectSessionInUrl: false, + }, +}) +``` + +We are using the SecureStore from Expo to handle the session of our Supabase client and add in the `createClient` function. + +Later we can import the `supabase` client from this file and use it in our app whenever we need to access Supabase. + +## Building the authentication flow + +Currently, the app won't work as we have no entry point. Because we are using the Expon Router and file-based routing, we can create a new file at `app/index.tsx` which will be the first page that comes up in our app. + +On this page we will handle both login and registration, so let's start by creating a simple form with a few inputs and buttons inside the `app/index.tsx` file: + +```tsx +import { Alert, View, Button, TextInput, StyleSheet, Text, TouchableOpacity } from 'react-native' +import { useState } from 'react' +import React from 'react' +import Spinner from 'react-native-loading-spinner-overlay' +import { supabase } from '../config/initSupabase' + +const Login = () => { + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [loading, setLoading] = useState(false) + + // Sign in with email and password + const onSignInPress = async () => { + setLoading(true) + + const { error } = await supabase.auth.signInWithPassword({ + email, + password, + }) + + if (error) Alert.alert(error.message) + setLoading(false) + } + + // Create a new user + const onSignUpPress = async () => { + setLoading(true) + const { error } = await supabase.auth.signUp({ + email: email, + password: password, + }) + + if (error) Alert.alert(error.message) + setLoading(false) + } + + return ( + + + + My Cloud + + + + + + Sign in + + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + paddingTop: 200, + padding: 20, + backgroundColor: '#151515', + }, + header: { + fontSize: 30, + textAlign: 'center', + margin: 50, + color: '#fff', + }, + inputField: { + marginVertical: 4, + height: 50, + borderWidth: 1, + borderColor: '#2b825b', + borderRadius: 4, + padding: 10, + color: '#fff', + backgroundColor: '#363636', + }, + button: { + marginVertical: 15, + alignItems: 'center', + backgroundColor: '#2b825b', + padding: 12, + borderRadius: 4, + }, +}) + +export default Login +``` + +There's nothing fancy going on here, but this is all we need to use Supabase Authentication in our app! + +![Login page](/images/blog/2023-08-01-react-native-storage/login-page.png) + +You can try it out right now and create a new user account or sign in with an existing one and log the values to the console to see what's going on. + +However, we are not handling the authentication state yet so let's create a **Context** to listen to changes. + +We will wrap a Provider around our app, which will use the `onAuthStateChange` function from Supabase to listen to changes in the authentication state and accordingly set our state. + +For this, create a new file at `provider/AuthProvider.tsx` and add the following code: + +```tsx +import React, { useState, useEffect, createContext, PropsWithChildren } from 'react' +import { Session, User } from '@supabase/supabase-js' +import { supabase } from '../config/initSupabase' + +type AuthProps = { + user: User | null + session: Session | null + initialized?: boolean + signOut?: () => void +} + +export const AuthContext = createContext>({}) + +// Custom hook to read the context values +export function useAuth() { + return React.useContext(AuthContext) +} + +export const AuthProvider = ({ children }: PropsWithChildren) => { + const [user, setUser] = useState() + const [session, setSession] = useState(null) + const [initialized, setInitialized] = useState(false) + + useEffect(() => { + // Listen for changes to authentication state + const { data } = supabase.auth.onAuthStateChange(async (event, session) => { + setSession(session) + setUser(session ? session.user : null) + setInitialized(true) + }) + return () => { + data.subscription.unsubscribe() + } + }, []) + + // Log out the user + const signOut = async () => { + await supabase.auth.signOut() + } + + const value = { + user, + session, + initialized, + signOut, + } + + return {children} +} +``` + +To use the context we can now wrap it around our app, and while we do this we can also take care of the navigation: + +In the topmost layout file we can check whether a user has an active session or not, and either directly sign the user into the inside area (that we will create soon) or automatically bring her back to the login screen. + +To make this work with the Expo Router we can create a file at `app/_layout.tsx` and add the following code: + +```tsx +import { Slot, useRouter, useSegments } from 'expo-router' +import { useEffect } from 'react' +import { AuthProvider, useAuth } from '../provider/AuthProvider' + +// Makes sure the user is authenticated before accessing protected pages +const InitialLayout = () => { + const { session, initialized } = useAuth() + const segments = useSegments() + const router = useRouter() + + useEffect(() => { + if (!initialized) return + + // Check if the path/url is in the (auth) group + const inAuthGroup = segments[0] === '(auth)' + + if (session && !inAuthGroup) { + // Redirect authenticated users to the list page + router.replace('/list') + } else if (!session) { + // Redirect unauthenticated users to the login page + router.replace('/') + } + }, [session, initialized]) + + return +} + +// Wrap the app with the AuthProvider +const RootLayout = () => { + return ( + + + + ) +} + +export default RootLayout +``` + +Whenever the `initialized` or `session` state changes, we check if the user is authenticated and redirect her to the correct page. + +This also means we don't have to worry about the authentication state in our pages anymore, we can just assume that the user is authenticated and use the `useAuth` hook to access the user and session data later on. + +Your app might show an error right now because the `/list` route doesn't exist yet, but we will create it in the next step. + +## File Upload to Supabase Storage + +Now that we have the authentication set up, we can start working on the file upload. + +First, let's define another layout for this inside area so create a file at `/app/(auth)/_layout.tsx` and add the following code: + +```tsx +import { Stack } from 'expo-router' +import { useAuth } from '../../provider/AuthProvider' +import React from 'react' +import { TouchableOpacity } from 'react-native' +import { Ionicons } from '@expo/vector-icons' + +// Simple stack layout within the authenticated area +const StackLayout = () => { + const { signOut } = useAuth() + + return ( + + ( + + + + ), + }} + > + + ) +} + +export default StackLayout +``` + +This defines a simple stack navigation and adds a button to trigger the logout, so we can now also fully test the authentication flow. + +Next, we create the page for uploading and displaying all files of a user from Supabase Storage. + +You won't have any files to show yet, but loading the files of a user is as easy as calling `list()` on the storage bucket and passing the user id as the folder name. + +Additionally, we add a little FAB (floating action button) to trigger the file picker, so create a file at `/app/(auth)/list.tsx` and add the following code: + +```tsx +import { View, StyleSheet, TouchableOpacity, ScrollView } from 'react-native' +import React, { useEffect, useState } from 'react' +import { Ionicons } from '@expo/vector-icons' +import * as ImagePicker from 'expo-image-picker' +import { useAuth } from '../../provider/AuthProvider' +import * as FileSystem from 'expo-file-system' +import { decode } from 'base64-arraybuffer' +import { supabase } from '../../config/initSupabase' +import { FileObject } from '@supabase/storage-js' + +const list = () => { + const { user } = useAuth() + const [files, setFiles] = useState([]) + + useEffect(() => { + if (!user) return + + // Load user images + loadImages() + }, [user]) + + const loadImages = async () => { + const { data } = await supabase.storage.from('files').list(user!.id) + if (data) { + setFiles(data) + } + } + + const onSelectImage = async () => { + // TODO + } + + return ( + + {/* FAB to add images */} + + + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + padding: 20, + backgroundColor: '#151515', + }, + fab: { + borderWidth: 1, + alignItems: 'center', + justifyContent: 'center', + width: 70, + position: 'absolute', + bottom: 40, + right: 30, + height: 70, + backgroundColor: '#2b825b', + borderRadius: 100, + }, +}) + +export default list +``` + +This should give us a nice and clean UI. + +![Files page](/images/blog/2023-08-01-react-native-storage/files-page.png) + +Now we can implement the image picker and upload the selected image to Supabase Storage. + +Using the image picker from Expo gives us a **URI**, which we can use to read the file from the file system and convert it to a base64 string. + +We can then use the `upload()` method from the storage client to upload the file to the storage bucket. Life can be easy. + +At this point, you should be able to upload files to Supabase Storage, and you can already see them in your UI (or log them to the console). + +To finally display them we will add a `ScrollView` component, which will render one item for every file of a user. + +Let's start by creating those component rows in another file, so create a `components/ImageItem.tsx` file and add the following code: + +```tsx +import { FileObject } from '@supabase/storage-js' +import { Image, View, Text, TouchableOpacity } from 'react-native' +import { supabase } from '../config/initSupabase' +import { useState } from 'react' +import { Ionicons } from '@expo/vector-icons' + +// Image item component that displays the image from Supabase Storage and a delte button +const ImageItem = ({ + item, + userId, + onRemoveImage, +}: { + item: FileObject + userId: string + onRemoveImage: () => void +}) => { + const [image, setImage] = useState('') + + supabase.storage + .from('files') + .download(`${userId}/${item.name}`) + .then(({ data }) => { + const fr = new FileReader() + fr.readAsDataURL(data!) + fr.onload = () => { + setImage(fr.result as string) + } + }) + + return ( + + {image ? ( + + ) : ( + + )} + {item.name} + {/* Delete image button */} + + + + + ) +} + +export default ImageItem +``` + +This component will display the image from Supabase Storage, the name of the file and a delete button. + +To display the image we use the `download()` method from the storage client, which returns a `FileObject` with the file data. We can then use the `FileReader` to convert the file data to a base64 string, which we can use as the image source. + +Now let's use this component in our `list.tsx` file to render the list of images by updating the `return` statement: + +```tsx +return ( + + + {files.map((item, index) => ( + onRemoveImage(item, index)} + /> + ))} + + + {/* FAB to add images */} + + + + +) +``` + +Don't forget to also include the import to the `ImageItem` component! + +![Supabase Storage Files List](/images/blog/2023-08-01-react-native-storage/supabase-storage-files.png) + +Finally, we can also include the delete functionality by adding the following code to the `list.tsx`: + +```tsx +const onRemoveImage = async (item: FileObject, listIndex: number) => { + supabase.storage.from('files').remove([`${user!.id}/${item.name}`]) + const newFiles = [...files] + newFiles.splice(listIndex, 1) + setFiles(newFiles) +} +``` + +We are handling the deletion here so we can accordingly update the state of the files list after removing an item. + +And with that in place, you have a fully functional image gallery app with React Native and Supabase Storage! + +## What about resumable uploads? + +Initially, I wanted to include [resumable uploads](https://supabase.com/blog/storage-v3-resumable-uploads) in this tutorial, but apparently, the [Uppy](https://uppy.io/) client didn't work 100% for React Native yet. + +You can still see an [initial implementation of resumable downloads with Supabase and React Native](https://github.com/saimon24/react-native-resumable-upload-supabase/commit/bb45455dc2d4d3fdd42089a8a26a521cc5b5e60f#diff-2d97fcb7ad2b1ac426a8d5391cfd0ab970e525ede1f11e14885f65e6dcd116a1) in the repository of this tutorial. + +However, ultimately the uploaded file was always 0 bytes, so I decided to leave it out for now. + +The Supabas team is investigating this issue, so I'm very sure that we will have resumable uploads working with React Native soon. + +## Conclusion + +It's almost too easy to use Supabase Storage, and it's a great way to store files for your apps. + +You now have a fully functional image gallery app with React Native and Supabase Storage including user authentication without writing a line of backend code! + +You can [find the full code of this tutorial on Github](https://github.com/saimon24/react-native-resumable-upload-supabase) where you just need to insert your own Supabase URL and API key. + +If you enjoyed the tutorial, you can [learn React Native on Galaxies.dev](https://galaxies.dev) where I help developers build awesome React Native apps. + +Until next time and happy coding with Supabase! diff --git a/apps/www/components/Nav/Developers.tsx b/apps/www/components/Nav/Developers.tsx index 46976fe44bd..01cd98ebedf 100644 --- a/apps/www/components/Nav/Developers.tsx +++ b/apps/www/components/Nav/Developers.tsx @@ -11,27 +11,31 @@ type Props = { description?: string url?: string icon?: string + svg?: any } const Developers = () => { const { basePath } = useRouter() const iconSections = links.map((link: Props) => { - const { text, description, url, icon } = link + const { text, description, url, icon, svg: Svg } = link const content = (
{/* */} - + {icon && ( + + )} + {Svg && }
{text}

{description}

diff --git a/apps/www/components/Partners/TileGrid.tsx b/apps/www/components/Partners/TileGrid.tsx index cf31b25e439..cca3d822f3c 100644 --- a/apps/www/components/Partners/TileGrid.tsx +++ b/apps/www/components/Partners/TileGrid.tsx @@ -33,7 +33,7 @@ export default function TileGrid({ className=" bg-scale-100 dark:bg-scale-300 hover:bg-scale-200 hover:dark:bg-scale-400 - group flex h-full w-full flex-col rounded border px-6 + group flex h-full w-full flex-col rounded-xl border px-6 py-6 shadow transition-all hover:shadow-lg" @@ -79,7 +79,7 @@ export default function TileGrid({ className=" bg-scale-100 dark:bg-scale-300 hover:bg-scale-200 hover:dark:bg-scale-400 - group flex h-full w-full flex-col rounded border px-6 + group flex h-full w-full flex-col rounded-xl border px-6 py-6 shadow transition-all hover:shadow-lg" diff --git a/apps/www/components/Sections/ProductHeaderCentered.tsx b/apps/www/components/Sections/ProductHeaderCentered.tsx index 194d000f1b5..98de8020142 100644 --- a/apps/www/components/Sections/ProductHeaderCentered.tsx +++ b/apps/www/components/Sections/ProductHeaderCentered.tsx @@ -8,6 +8,7 @@ interface Types { h1: string | React.ReactNode subheader: string | React.ReactNode icon?: string + className?: string title?: string image?: string | React.ReactNode footer?: React.ReactNode @@ -23,7 +24,12 @@ interface Types { } const ProductHeaderCentered = (props: Types) => ( -
+
{props.image && typeof props.image === 'string' ? (
@@ -40,7 +46,7 @@ const ProductHeaderCentered = (props: Types) => ( {props.image}
)} -
+
{props.icon || props.title ? (
@@ -57,7 +63,7 @@ const ProductHeaderCentered = (props: Types) => ( ) : null}
-

+

{props.h1}

{props.subheader}

@@ -65,7 +71,7 @@ const ProductHeaderCentered = (props: Types) => (
{props.cta && ( - + diff --git a/apps/www/data/Developers.ts b/apps/www/data/Developers.ts deleted file mode 100644 index a459ec5260d..00000000000 --- a/apps/www/data/Developers.ts +++ /dev/null @@ -1,26 +0,0 @@ -export const links = [ - { - text: 'Resources', - description: 'View examples, curated content from the community, migration guides, and more.', - url: '/docs/guides/resources', - icon: 'M19.4994 17.9996V7.50107L19.4864 3L14.0723 3L4.5 3L4.5 18.75M4.5 18.75C4.5 19.9926 5.50736 21 6.75 21H19.5V18.5255L19.4871 16.5H14.0728H6.75C5.50736 16.5 4.5 17.5074 4.5 18.75ZM7.50724 3V9L9.75771 6.45713L12.0072 9V3H7.50724Z', - }, - { - text: 'Changelog', - description: 'See the latest updates and product improvements.', - url: '/changelog', - icon: 'M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z', - }, - { - text: 'Careers', - description: 'Join the Supabase team and get involved.', - url: '/careers', - icon: 'M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z', - }, - { - text: 'Open Source', - description: 'We support existing open source tools and communities wherever possible.', - url: '/open-source', - icon: 'M10.7607 3.08859C15.7723 2.38592 20.0295 5.94789 20.8525 10.7642C21.5964 15.1122 19.4634 19.1692 15.8778 20.9967L14.0736 16.084C15.129 15.4346 15.8232 14.4223 16.0198 13.0819C16.3702 10.7219 14.7442 8.47426 12.3801 8.25648C10.2228 8.04047 8.33304 9.61601 7.99823 11.7702L7.99761 11.7743C7.73427 13.5096 8.47082 15.1847 9.92827 16.0841L8.12693 21C6.34571 20.1066 4.88125 18.6129 3.97963 16.7387L3.97879 16.7369C3.15872 15.0395 3.07406 13.7081 3.01524 12.783C3.01012 12.7024 3.0052 12.6249 3 12.5506C3.10941 7.30353 6.59243 3.6764 10.7607 3.08859Z', - }, -] diff --git a/apps/www/data/Developers.tsx b/apps/www/data/Developers.tsx new file mode 100644 index 00000000000..db4134c663d --- /dev/null +++ b/apps/www/data/Developers.tsx @@ -0,0 +1,38 @@ +export const links = [ + { + text: 'Resources', + description: 'View examples, curated content from the community, migration guides, and more.', + url: '/docs/guides/resources', + icon: 'M19.4994 17.9996V7.50107L19.4864 3L14.0723 3L4.5 3L4.5 18.75M4.5 18.75C4.5 19.9926 5.50736 21 6.75 21H19.5V18.5255L19.4871 16.5H14.0728H6.75C5.50736 16.5 4.5 17.5074 4.5 18.75ZM7.50724 3V9L9.75771 6.45713L12.0072 9V3H7.50724Z', + }, + { + text: 'Changelog', + description: 'See the latest updates and product improvements.', + url: '/changelog', + icon: 'M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z', + }, + { + text: 'Careers', + description: 'Join the Supabase team and get involved.', + url: '/careers', + icon: 'M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z', + }, + { + text: 'Open Source', + description: 'We support existing open source tools and communities wherever possible.', + url: '/open-source', + icon: 'M10.7607 3.08859C15.7723 2.38592 20.0295 5.94789 20.8525 10.7642C21.5964 15.1122 19.4634 19.1692 15.8778 20.9967L14.0736 16.084C15.129 15.4346 15.8232 14.4223 16.0198 13.0819C16.3702 10.7219 14.7442 8.47426 12.3801 8.25648C10.2228 8.04047 8.33304 9.61601 7.99823 11.7702L7.99761 11.7743C7.73427 13.5096 8.47082 15.1847 9.92827 16.0841L8.12693 21C6.34571 20.1066 4.88125 18.6129 3.97963 16.7387L3.97879 16.7369C3.15872 15.0395 3.07406 13.7081 3.01524 12.783C3.01012 12.7024 3.0052 12.6249 3 12.5506C3.10941 7.30353 6.59243 3.6764 10.7607 3.08859Z', + }, + { + text: 'Partners', + description: 'Become a Supabase Partner and enable new business opportunities.', + url: '/partners', + icon: 'M12.0003 6.1488L10.5812 4.76334C8.68921 2.91613 5.62161 2.91613 3.72957 4.76334C1.98628 6.46532 1.84923 9.14165 3.31842 10.9968C3.44378 11.1551 3.77166 11.514 3.92041 11.6592M12.0003 6.1488L13.4193 4.76334C15.3113 2.91613 18.3789 2.91613 20.271 4.76334C22.163 6.61054 22.163 9.60546 20.271 11.4527L19.2017 12.5186M12.0003 6.1488L10.7542 7.39725C10.0214 8.13141 10.0219 9.32043 10.7554 10.0539C11.4894 10.7879 12.6793 10.7879 13.4133 10.0539L16.0693 7.39787M14.9131 13.4531L17.4954 16.1809M17.4954 16.1809C18.2089 16.9347 18.1764 18.1242 17.4226 18.8378C16.6693 19.5509 15.4807 19.5189 14.7669 18.7663L13.3188 17.2217M17.4954 16.1809C18.2086 16.9344 19.3982 16.9676 20.1516 16.2543C20.9051 15.5411 20.9376 14.3521 20.2244 13.5987L17.6415 10.8703M6.13555 14.7589L7.46387 13.4306M6.13555 14.7589C5.40193 15.4925 4.21251 15.4925 3.47889 14.7589C2.74528 14.0253 2.74528 12.8358 3.47889 12.1022L4.80722 10.7739C5.54084 10.0403 6.73026 10.0403 7.46388 10.7739C8.19749 11.5075 8.19749 12.6969 7.46387 13.4306M6.13555 14.7589C5.40193 15.4925 5.40193 16.6819 6.13555 17.4155C6.86916 18.1492 8.05859 18.1492 8.7922 17.4155M7.46387 13.4306C8.19749 12.6969 9.38691 12.6969 10.1205 13.4306C10.8541 14.1642 10.8541 15.3536 10.1205 16.0872M8.7922 17.4155L10.1205 16.0872M8.7922 17.4155C8.05859 18.1492 8.05859 19.3386 8.7922 20.0722C9.52582 20.8058 10.7152 20.8058 11.4489 20.0722L12.7772 18.7439C13.5108 18.0102 13.5108 16.8208 12.7772 16.0872C12.0436 15.3536 10.8541 15.3536 10.1205 16.0872', + }, + { + text: 'Integrations', + description: 'Use your favorite tools with Supabase.', + url: '/partners/integrations', + icon: 'M6.87232 21.5743C9.09669 21.5743 10.8999 19.7711 10.8999 17.5467C10.8999 15.3223 9.09669 13.5191 6.87232 13.5191C4.64794 13.5191 2.84473 15.3223 2.84473 17.5467C2.84473 19.7711 4.64794 21.5743 6.87232 21.5743Z M17.127 3.67236V11.1724M20.877 7.42274H13.377M3.12305 3.67236H10.6231V11.1724H3.12305V3.67236ZM13.377 13.7966H20.877V21.2966H13.377V13.7966ZM10.8999 17.5467C10.8999 19.7711 9.09669 21.5743 6.87232 21.5743C4.64794 21.5743 2.84473 19.7711 2.84473 17.5467C2.84473 15.3223 4.64794 13.5191 6.87232 13.5191C9.09669 13.5191 10.8999 15.3223 10.8999 17.5467Z', + }, +] diff --git a/apps/www/data/Footer.json b/apps/www/data/Footer.json index 877b6568777..cef2e1e8201 100644 --- a/apps/www/data/Footer.json +++ b/apps/www/data/Footer.json @@ -47,6 +47,10 @@ "text": "System Status", "url": "https://status.supabase.com/" }, + { + "text": "Become a Partner", + "url": "/partners" + }, { "text": "Integrations", "url": "/partners/integrations" diff --git a/apps/www/data/partners/index.tsx b/apps/www/data/partners/index.tsx new file mode 100644 index 00000000000..317e95ae89f --- /dev/null +++ b/apps/www/data/partners/index.tsx @@ -0,0 +1,140 @@ +import { IconAward, IconCompass, IconDollarSign, IconGlobe, IconTrendingUp } from 'ui' + +export default { + metaTitle: 'Partner with Supabase', + metaDescription: 'Become a Supabase Partner and enable new business opportunities.', + heroSection: { + title: 'Partners', + h1: Partner with Supabase, + subheader: ( + <> + Apply to the Partners program to list your integration
in + our marketplace and grow your business. + + ), + image: ( +
+
+ +
+
+ ), + cta: { + label: 'Become a Partner', + link: 'https://forms.supabase.com/partner', + }, + }, + oAuthApp: { + steps: [ + { + title: 'Register App', + text: 'An OAuth app first needs to be registered with Supabase', + }, + { + title: 'Add OAuth2 Support', + text: 'Use the OAuth2 protocol to access a users organization or project', + }, + { + title: 'Receive Tokens', + text: "You'll receive a new access and refresh token", + }, + { + title: 'Control Projects', + text: 'Use Supabase REST API to control projects and other settings', + }, + ], + }, + featureBlocks: [ + { + title: 'Technical support', + description: 'Access technical support to back your integrations and customer projects.', + icon: , + }, + { + title: 'Expand your ecosystem', + description: 'Offer your own products and services to Supabase customers.', + icon: , + }, + { + title: 'Business growth', + description: 'Explore new revenue streams and growth potential.', + icon: , + }, + { + title: 'Scale with us', + description: 'Scale automatically with the power of open-source Postgres technology.', + icon: , + }, + ], + featuredApps: [ + { + name: 'Arengu', + type: 'integration', + logo: 'https://obuldanrptloktxcffvn.supabase.co/storage/v1/object/public/images/integrations/arengu/arengu_logo.jpeg', + }, + { + name: 'Auth0', + type: 'integration', + logo: 'https://obuldanrptloktxcffvn.supabase.co/storage/v1/object/public/images/integrations/auth0/auth0_dark.png?t=2023-07-19T19%3A13%3A04.189Z', + }, + { + name: 'Appsmith', + type: 'integration', + logo: 'https://obuldanrptloktxcffvn.supabase.co/storage/v1/object/public/images/integrations/appsmith/appsmith-logo.png', + }, + { + name: 'CALDA', + type: 'experts', + logo: 'https://obuldanrptloktxcffvn.supabase.co/storage/v1/object/public/images/experts/calda/calda_logo.jpeg', + }, + { + name: 'Morrow', + type: 'experts', + logo: 'https://obuldanrptloktxcffvn.supabase.co/storage/v1/object/public/images/experts/morrow/morrow-logo.png', + }, + { + name: 'Voypost', + type: 'experts', + logo: 'https://obuldanrptloktxcffvn.supabase.co/storage/v1/object/public/images/experts/voypost/33024474.png', + }, + { + name: 'Vercel', + type: 'integration', + logo: 'https://obuldanrptloktxcffvn.supabase.co/storage/v1/object/public/images/integrations/vercel/vercel-icon.jpeg', + }, + { + name: 'Prisma', + type: 'integration', + logo: 'https://obuldanrptloktxcffvn.supabase.co/storage/v1/object/public/images/integrations/prisma/prisma-icon.png', + }, + { + name: 'Cloudflare-workers', + type: 'integration', + logo: 'https://obuldanrptloktxcffvn.supabase.co/storage/v1/object/public/images/integrations/cloudflare-integration/cloudflare_workers_logo.png?t=2023-07-21T11%3A07%3A47.005Z', + }, + { + name: 'Codesandbox', + type: 'integration', + logo: 'https://obuldanrptloktxcffvn.supabase.co/storage/v1/object/public/images/integrations/codesandbox/codesandbox_logo.jpeg', + }, + ], +} diff --git a/apps/www/lib/redirects.js b/apps/www/lib/redirects.js index 642862ff73d..3bac4663636 100644 --- a/apps/www/lib/redirects.js +++ b/apps/www/lib/redirects.js @@ -590,11 +590,6 @@ module.exports = [ source: '/docs/guides/database/introduction', destination: '/docs/guides/database', }, - { - permanent: false, - source: '/partners', - destination: '/partners/integrations', - }, { permanent: false, source: '/learn-more', diff --git a/apps/www/pages/partners/experts/index.tsx b/apps/www/pages/partners/experts/index.tsx index c9de3a7cdc4..d1f152a074b 100644 --- a/apps/www/pages/partners/experts/index.tsx +++ b/apps/www/pages/partners/experts/index.tsx @@ -52,7 +52,7 @@ function ExpertPartnersPage(props: Props) { ], }} /> - +

{meta_title}

diff --git a/apps/www/pages/partners/index.tsx b/apps/www/pages/partners/index.tsx new file mode 100644 index 00000000000..c6063980417 --- /dev/null +++ b/apps/www/pages/partners/index.tsx @@ -0,0 +1,255 @@ +import React from 'react' +import Link from 'next/link' +import Image from 'next/image' +import { useRouter } from 'next/router' +import { NextSeo } from 'next-seo' +import { range } from 'lodash' + +import { useBreakpoint } from 'common' +import { Badge, Button, IconCode, TextLink } from 'ui' +import DefaultLayout from '~/components/Layouts/Default' +import InteractiveShimmerCard from '~/components/InteractiveShimmerCard' +import ProductHeaderCentered from '~/components/Sections/ProductHeaderCentered' +import SectionContainer from '~/components/Layouts/SectionContainer' + +import pageData from '~/data/partners' + +const Partners = () => { + const router = useRouter() + const isSm = useBreakpoint() + + return ( + <> + + +
+ + + +
+
+
+ + + + + + + + + + +
+ + Supabase icon +
+ {range(0, 3).map((_) => ( +
+ {pageData.featuredApps.map((app) => ( +
+ {app.name} +
+ ))} +
+ ))} +
+
+
+ +
+ + +
+ + +

+ Partner benefits +

+
+ {pageData.featureBlocks.map((item, i) => { + return ( +
+
+ {item.icon ? item.icon : } +
+ +
+

{item.title}

+

{item.description}

+
+
+ ) + })} +
+
+
+ +
+
+ Beta +

+ Publish an OAuth App +

+

+ Supabase lets you build a third-party app that can control organizations or + projects programmatically. +

+ +
+
+ Register app via API +
+
+
+
+ +
+ + + + ) +} + +export default Partners diff --git a/apps/www/pages/partners/integrations/index.tsx b/apps/www/pages/partners/integrations/index.tsx index e5ef37e11b1..eccd6d0ba2c 100644 --- a/apps/www/pages/partners/integrations/index.tsx +++ b/apps/www/pages/partners/integrations/index.tsx @@ -86,6 +86,8 @@ function IntegrationPartnersPage(props: Props) { }) }, [debouncedSearchTerm, router]) + console.log(partners) + return ( <> - +

{meta_title}

diff --git a/apps/www/public/images/blog/2023-08-01-react-native-storage/email-auth.png b/apps/www/public/images/blog/2023-08-01-react-native-storage/email-auth.png new file mode 100644 index 00000000000..77c4dbc3f6f Binary files /dev/null and b/apps/www/public/images/blog/2023-08-01-react-native-storage/email-auth.png differ diff --git a/apps/www/public/images/blog/2023-08-01-react-native-storage/files-page.png b/apps/www/public/images/blog/2023-08-01-react-native-storage/files-page.png new file mode 100644 index 00000000000..400c4638c82 Binary files /dev/null and b/apps/www/public/images/blog/2023-08-01-react-native-storage/files-page.png differ diff --git a/apps/www/public/images/blog/2023-08-01-react-native-storage/login-page.png b/apps/www/public/images/blog/2023-08-01-react-native-storage/login-page.png new file mode 100644 index 00000000000..191a3c391f8 Binary files /dev/null and b/apps/www/public/images/blog/2023-08-01-react-native-storage/login-page.png differ diff --git a/apps/www/public/images/blog/2023-08-01-react-native-storage/new-bucket.png b/apps/www/public/images/blog/2023-08-01-react-native-storage/new-bucket.png new file mode 100644 index 00000000000..7c036bdc399 Binary files /dev/null and b/apps/www/public/images/blog/2023-08-01-react-native-storage/new-bucket.png differ diff --git a/apps/www/public/images/blog/2023-08-01-react-native-storage/new-project.png b/apps/www/public/images/blog/2023-08-01-react-native-storage/new-project.png new file mode 100644 index 00000000000..7207bca170d Binary files /dev/null and b/apps/www/public/images/blog/2023-08-01-react-native-storage/new-project.png differ diff --git a/apps/www/public/images/blog/2023-08-01-react-native-storage/react-native-storage.png b/apps/www/public/images/blog/2023-08-01-react-native-storage/react-native-storage.png new file mode 100644 index 00000000000..452144f645a Binary files /dev/null and b/apps/www/public/images/blog/2023-08-01-react-native-storage/react-native-storage.png differ diff --git a/apps/www/public/images/blog/2023-08-01-react-native-storage/supabase-file-upload-react-native.gif b/apps/www/public/images/blog/2023-08-01-react-native-storage/supabase-file-upload-react-native.gif new file mode 100644 index 00000000000..27b87cba44b Binary files /dev/null and b/apps/www/public/images/blog/2023-08-01-react-native-storage/supabase-file-upload-react-native.gif differ diff --git a/apps/www/public/images/blog/2023-08-01-react-native-storage/supabase-storage-files.png b/apps/www/public/images/blog/2023-08-01-react-native-storage/supabase-storage-files.png new file mode 100644 index 00000000000..8ef9944ab74 Binary files /dev/null and b/apps/www/public/images/blog/2023-08-01-react-native-storage/supabase-storage-files.png differ diff --git a/apps/www/public/images/blog/lw8-hackathon/supabase-hackathon-meme.png b/apps/www/public/images/blog/lw8-hackathon/supabase-hackathon-meme.png new file mode 100644 index 00000000000..52210768a68 Binary files /dev/null and b/apps/www/public/images/blog/lw8-hackathon/supabase-hackathon-meme.png differ diff --git a/apps/www/public/images/blog/lw8-hackathon/swag.png b/apps/www/public/images/blog/lw8-hackathon/swag.png new file mode 100644 index 00000000000..5c46becb23d Binary files /dev/null and b/apps/www/public/images/blog/lw8-hackathon/swag.png differ diff --git a/apps/www/public/images/blog/lw8-hackathon/thumbnail.png b/apps/www/public/images/blog/lw8-hackathon/thumbnail.png new file mode 100644 index 00000000000..ebaf73e870a Binary files /dev/null and b/apps/www/public/images/blog/lw8-hackathon/thumbnail.png differ diff --git a/apps/www/public/images/partners/bg-soft-blur.svg b/apps/www/public/images/partners/bg-soft-blur.svg new file mode 100644 index 00000000000..14e18f501c6 --- /dev/null +++ b/apps/www/public/images/partners/bg-soft-blur.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/www/public/images/partners/heartshake-icon.svg b/apps/www/public/images/partners/heartshake-icon.svg new file mode 100644 index 00000000000..29a11f344f0 --- /dev/null +++ b/apps/www/public/images/partners/heartshake-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/www/public/images/partners/partners-grid-2.jpg b/apps/www/public/images/partners/partners-grid-2.jpg new file mode 100644 index 00000000000..dd68c776e50 Binary files /dev/null and b/apps/www/public/images/partners/partners-grid-2.jpg differ diff --git a/apps/www/public/images/partners/partners-grid-2.png b/apps/www/public/images/partners/partners-grid-2.png new file mode 100644 index 00000000000..85e0b06239f Binary files /dev/null and b/apps/www/public/images/partners/partners-grid-2.png differ diff --git a/apps/www/public/images/partners/partners-grid.jpg b/apps/www/public/images/partners/partners-grid.jpg new file mode 100644 index 00000000000..d7a232025b3 Binary files /dev/null and b/apps/www/public/images/partners/partners-grid.jpg differ diff --git a/apps/www/public/images/partners/partners-grid.png b/apps/www/public/images/partners/partners-grid.png new file mode 100644 index 00000000000..60db369fa6d Binary files /dev/null and b/apps/www/public/images/partners/partners-grid.png differ diff --git a/apps/www/public/images/partners/register-oauth-app.svg b/apps/www/public/images/partners/register-oauth-app.svg new file mode 100644 index 00000000000..89755c0eb2c --- /dev/null +++ b/apps/www/public/images/partners/register-oauth-app.svg @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/www/public/images/partners/soft-blur-02.png b/apps/www/public/images/partners/soft-blur-02.png new file mode 100644 index 00000000000..f015873b5d3 Binary files /dev/null and b/apps/www/public/images/partners/soft-blur-02.png differ diff --git a/apps/www/public/images/supabase-logo-icon.svg b/apps/www/public/images/supabase-logo-icon.svg new file mode 100644 index 00000000000..ad802ac16ae --- /dev/null +++ b/apps/www/public/images/supabase-logo-icon.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/apps/www/styles/index.css b/apps/www/styles/index.css index a2cbaec35c7..9ec7db5d20f 100644 --- a/apps/www/styles/index.css +++ b/apps/www/styles/index.css @@ -490,6 +490,15 @@ launch week } } +@keyframes transformSpin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + /* recharts */ .recharts-surface { diff --git a/apps/www/tailwind.config.js b/apps/www/tailwind.config.js index d3445abd9d2..f7eafa21e30 100644 --- a/apps/www/tailwind.config.js +++ b/apps/www/tailwind.config.js @@ -25,11 +25,16 @@ module.exports = config({ spin: { '0%, 100%': { transform: 'rotate(360deg)' }, }, + marquee: { + '0%': { transform: 'translateX(0%)' }, + '100%': { transform: 'translateX(-100%)' }, + }, }, animation: { 'flash-code': 'flash-code 1s forwards', 'flash-code-slow': 'flash-code 2s forwards', spin: 'spin 1s both infinite', + marquee: 'marquee 35s linear infinite', }, }, }, diff --git a/i18n/README.fi.md b/i18n/README.fi.md index 70bd873e5e1..5ffe5c18acc 100644 --- a/i18n/README.fi.md +++ b/i18n/README.fi.md @@ -31,10 +31,10 @@ Jos haluat nähdä, miten osallistuminen tapahtuu, käy osoitteessa [Getting Sta ## Yhteisö ja tuki -- [Yhteisön foorumi](https://github.com/supabase/supabase/discussions). Paras: Apua rakentamiseen, keskustelua tietokannan parhaista käytännöistä. -- [GitHub Issues](https://github.com/supabase/supabase/issues). Parasta: Supabasea käytettäessä kohdatut viat ja virheet. -- [Sähköpostituki](https://supabase.com/docs/support#business-support). Paras: tietokantaan tai infrastruktuuriin liittyvät ongelmat. -- [Discord](https://discord.supabase.com). Paras: sovellusten jakamiseen ja yhteisön kanssa hengailuun. +- [Yhteisön foorumi](https://github.com/supabase/supabase/discussions). Sopii parhaiten: Apua rakentamiseen, keskustelua tietokannan parhaista käytännöistä. +- [GitHub Issues](https://github.com/supabase/supabase/issues). Sopii parhaiten: Supabasea käytettäessä kohdatut viat ja virheet. +- [Sähköpostituki](https://supabase.com/docs/support#business-support). Sopii parhaiten: Tietokantaan tai infrastruktuuriin liittyvät ongelmat. +- [Discord](https://discord.supabase.com). Sopii parhaiten: Sovellusten jakamiseen ja yhteisön kanssa hengailuun. ## Status diff --git a/package-lock.json b/package-lock.json index b0c58cc3eca..5a328304632 100644 --- a/package-lock.json +++ b/package-lock.json @@ -87,6 +87,7 @@ "rehype-slug": "^5.1.0", "remark": "^14.0.2", "remark-admonitions": "^1.2.1", + "remark-emoji": "^3.1.2", "remark-gfm": "^3.0.1", "remark-slug": "^7.0.1", "shiki": "^0.11.1", @@ -20160,6 +20161,15 @@ "node": ">= 4" } }, + "node_modules/emoticon": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/emoticon/-/emoticon-4.0.1.tgz", + "integrity": "sha512-dqx7eA9YaqyvYtUhJwT4rC1HIp82j5ybS1/vQ42ur+jBe17dJMwZE4+gvL1XadSFfxaPFFGt3Xsw+Y8akThDlw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/encodeurl": { "version": "1.0.2", "dev": true, @@ -29753,6 +29763,14 @@ "node": ">=10.5.0" } }, + "node_modules/node-emoji": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz", + "integrity": "sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==", + "dependencies": { + "lodash": "^4.17.21" + } + }, "node_modules/node-fetch": { "version": "2.6.11", "license": "MIT", @@ -34592,6 +34610,19 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-emoji": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/remark-emoji/-/remark-emoji-3.1.2.tgz", + "integrity": "sha512-QwhAzNk27Ol64uV4z/3n55MKrNz9bhr8wg+mO5aGqIYDS+jUarS1d8Y0ZIeEBVhfGkXj6gGYM+727sOgAPvV/A==", + "dependencies": { + "emoticon": "^4.0.1", + "mdast-util-find-and-replace": "^2.2.2", + "node-emoji": "^1.11.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/remark-external-links": { "version": "8.0.0", "dev": true, diff --git a/packages/ui/src/components/shadcn/stories/Select.stories.tsx b/packages/ui/src/components/shadcn/stories/Select.stories.tsx index da44645c8b6..3a63c0598fc 100644 --- a/packages/ui/src/components/shadcn/stories/Select.stories.tsx +++ b/packages/ui/src/components/shadcn/stories/Select.stories.tsx @@ -51,7 +51,7 @@ export function Default() { ) } -export function withSeperators() { +export function withSeparators() { return ( } /> +
+ +
+
+ {isLoading && } + {isError && } + {isSuccess && ( + <> + {}} /> + + + + )} +
+
+ + + + setSelectedBranchToDelete(undefined)} + onConfirm={() => onConfirmDeleteBranch()} + title="Delete branch" + loading={isDeleting} + confirmLabel={`Delete branch`} + confirmPlaceholder="Type in name of branch" + confirmString={selectedBranchToDelete?.name ?? ''} + text={`This will delete your branch "${selectedBranchToDelete?.name}"`} + alert="You cannot recover this branch once it is deleted!" + /> + + setShowCreateBranch(false)} + /> + + setSelectedBranchToUpdate(undefined)} + /> + + ) +} + +export default BranchManagement diff --git a/studio/components/interfaces/BranchManagement/BranchPanels.tsx b/studio/components/interfaces/BranchManagement/BranchPanels.tsx new file mode 100644 index 00000000000..383902267ca --- /dev/null +++ b/studio/components/interfaces/BranchManagement/BranchPanels.tsx @@ -0,0 +1,160 @@ +import clsx from 'clsx' +import dayjs from 'dayjs' +import { noop } from 'lodash' +import { PropsWithChildren, forwardRef, useState } from 'react' +import { + Badge, + Button, + DropdownMenuContent_Shadcn_, + DropdownMenuItem_Shadcn_, + DropdownMenuTrigger_Shadcn_, + DropdownMenu_Shadcn_, + IconEdit, + IconGitBranch, + IconGitHub, + IconMoreVertical, + IconShield, + IconTrash, + cn, +} from 'ui' + +import { useParams } from 'common' +import { Markdown } from 'components/interfaces/Markdown' +import { Branch } from 'data/branches/branches-query' + +interface BranchPanelProps { + branch: Branch + onSelectUpdate?: () => void + onSelectDelete?: () => void +} + +const MainBranchPanel = ({ branch, onSelectUpdate = noop }: BranchPanelProps) => { + const { ref } = useParams() + const [open, setOpen] = useState(false) + const isActive = ref === branch.project_ref + + return ( +
+
+
+
+ +
+

Github branch workflow

+
+
+
+
+
+ +
+

{branch.name}

+ Production + {isActive && Selected} +
+
+ setOpen(!open)} modal={false}> + + + + + onSelectUpdate()}> + + Edit branch + + + +
+
+
+ ) +} + +const BranchContainer = ({ className, children }: PropsWithChildren<{ className?: string }>) => { + return ( +
+
+
+ {children} +
+
+ ) +} + +const BranchPanel = ({ + branch, + onSelectUpdate = noop, + onSelectDelete = noop, +}: BranchPanelProps) => { + const { ref } = useParams() + const [open, setOpen] = useState(false) + + const isActive = ref === branch.project_ref + const daysFromNow = dayjs().diff(dayjs(branch.created_at), 'day') + const formattedTimeFromNow = dayjs(branch.created_at).fromNow() + const formattedCreatedAt = dayjs(branch.created_at).format('DD MMM YYYY, HH:mm:ss (ZZ)') + + return ( + +
+ +

{branch.name}

+ {isActive && Selected} +

+ {daysFromNow > 1 ? `Created on ${formattedCreatedAt}` : `Created ${formattedTimeFromNow}`} +

+
+
+ setOpen(!open)} modal={false}> + + + + + onSelectUpdate()}> + + Edit branch + + onSelectDelete()}> + + Delete branch + + + +
+
+ ) +} + +interface BranchHeader extends React.HTMLAttributes { + name?: string + markdown?: string +} + +const BranchHeader = forwardRef( + ({ className, name, markdown = '', ...props }, ref) => { + return ( +
+ +
+ ) + } +) + +BranchHeader.displayName = 'BranchHeader' +export { BranchHeader, BranchContainer, BranchPanel, MainBranchPanel } diff --git a/studio/components/interfaces/BranchManagement/CreateBranchSidePanel.tsx b/studio/components/interfaces/BranchManagement/CreateBranchSidePanel.tsx new file mode 100644 index 00000000000..7ca6d3f87af --- /dev/null +++ b/studio/components/interfaces/BranchManagement/CreateBranchSidePanel.tsx @@ -0,0 +1,126 @@ +import { useRef } from 'react' +import { Button, Form, Input, Listbox, SidePanel } from 'ui' + +import { useParams } from 'common' +import { useBranchCreateMutation } from 'data/branches/branch-create-mutation' +import { useSelectedProject, useStore } from 'hooks' + +interface CreateBranchSidePanelProps { + visible: boolean + onClose: () => void +} + +const MOCK_BRANCHES = [ + { id: 1, name: 'feat/branch-01' }, + { id: 2, name: 'feat/branch-02' }, +] + +const CreateBranchSidePanel = ({ visible, onClose }: CreateBranchSidePanelProps) => { + const { ui } = useStore() + const submitRef: any = useRef() + const { ref } = useParams() + const projectDetails = useSelectedProject() + + const isBranch = projectDetails?.parent_project_ref !== undefined + const projectRef = + projectDetails !== undefined ? (isBranch ? projectDetails.parent_project_ref : ref) : undefined + + const formId = 'create-branch-form' + const initialValues = { branchName: '', gitBranch: 'no-selection' } + const { mutate: createBranch, isLoading: isCreating } = useBranchCreateMutation({ + onSuccess: () => { + ui.setNotification({ category: 'success', message: `Successfully created new branch` }) + onClose() + }, + }) + + const validate = (values: any) => { + const errors: any = {} + if (values.branchName.length === 0) + errors.branchName = 'Please provide a name for your preview branch' + if (values.gitBranch.length === 0 || values.gitBranch === 'no-selection') + errors.gitBranch = 'Please select a Git branch to link to this database preview branch' + return errors + } + + const onConfirmCreate = (values: any) => { + if (!projectRef) return console.error('Project ref is required') + createBranch({ projectRef, ...values }) + } + + return ( + + + +
+ } + > +
+ +
+ {() => { + return ( +
+ + + + --- + + {MOCK_BRANCHES.map((branch) => ( + +

{branch.name}

+
+ ))} +
+ +
+ ) + }} +
+
+
+ + ) +} + +export default CreateBranchSidePanel diff --git a/studio/components/interfaces/BranchManagement/PreviewBranches.tsx b/studio/components/interfaces/BranchManagement/PreviewBranches.tsx new file mode 100644 index 00000000000..4bd7f567b27 --- /dev/null +++ b/studio/components/interfaces/BranchManagement/PreviewBranches.tsx @@ -0,0 +1,61 @@ +import { Branch } from 'data/branches/branches-query' +import { BranchContainer, BranchHeader, BranchPanel } from './BranchPanels' +import { Button, IconExternalLink, IconGitBranch } from 'ui' + +interface PreviewBranchesProps { + previewBranches: Branch[] + onSelectUpdateBranch: (branch: Branch) => void + onSelectDeleteBranch: (branch: Branch) => void +} + +const PreviewBranches = ({ + previewBranches, + onSelectUpdateBranch, + onSelectDeleteBranch, +}: PreviewBranchesProps) => { + return ( + <> + 0 ? `#### Preview branches` : undefined} /> + {previewBranches.length === 0 ? ( + +
+

No database preview branches

+

Database preview branches will be shown here

+
+
+
+ +
+

Create a preview branch

+

Start developing in preview

+
+
+ +
+
+
+

Not sure what to do?

+

Browse our documentation

+
+ +
+
+
+
+ ) : ( + previewBranches.map((branch) => ( + onSelectUpdateBranch(branch)} + onSelectDelete={() => onSelectDeleteBranch(branch)} + /> + )) + )} + + ) +} + +export default PreviewBranches diff --git a/studio/components/interfaces/BranchManagement/PullRequests.tsx b/studio/components/interfaces/BranchManagement/PullRequests.tsx new file mode 100644 index 00000000000..0e1fd0aeefd --- /dev/null +++ b/studio/components/interfaces/BranchManagement/PullRequests.tsx @@ -0,0 +1,59 @@ +import { Branch } from 'data/branches/branches-query' +import { BranchContainer, BranchHeader } from './BranchPanels' +import { Button, IconExternalLink, IconGitBranch } from 'ui' + +interface PullRequestsProps { + previewBranches: Branch[] +} + +const PullRequests = ({ previewBranches }: PullRequestsProps) => { + const pullRequests = [] + + return ( + <> + 0 && previewBranches.length > 0 + ? `#### Open pull requests` + : undefined + } + /> + {previewBranches.length === 0 || pullRequests.length === 0 ? ( + +
+

No pull requests made yet for this repository

+

+ Only pull requests with the ./migration directory changes will show here. +

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

Create a pull request

+
+ +
+
+
+

Not sure what to do?

+

Browse our documentation

+
+ +
+
+ )} +
+
+ ) : ( + State 3 + )} + + ) +} + +export default PullRequests diff --git a/studio/components/interfaces/BranchManagement/UpdateBranchSidePanel.tsx b/studio/components/interfaces/BranchManagement/UpdateBranchSidePanel.tsx new file mode 100644 index 00000000000..4899a922c11 --- /dev/null +++ b/studio/components/interfaces/BranchManagement/UpdateBranchSidePanel.tsx @@ -0,0 +1,97 @@ +import { useParams } from 'common' +import { useBranchUpdateMutation } from 'data/branches/branch-update-mutation' +import { Branch } from 'data/branches/branches-query' +import { useStore } from 'hooks' +import { useRef } from 'react' +import { Button, Form, Input, SidePanel } from 'ui' + +interface UpdateBranchSidePanelProps { + selectedBranch?: Branch + onClose: () => void +} + +const UpdateBranchSidePanel = ({ selectedBranch, onClose }: UpdateBranchSidePanelProps) => { + const { ui } = useStore() + const submitRef: any = useRef() + const { ref: projectRef } = useParams() + const { mutate: updateBranch, isLoading: isUpdating } = useBranchUpdateMutation({ + onSuccess: () => { + onClose() + ui.setNotification({ category: 'success', message: 'Successfully updated branch' }) + }, + }) + + const formId = 'update-branch-form' + const initialValues = { + branchName: selectedBranch?.name, + gitBranch: selectedBranch?.git_branch, + } + + const validate = (values: any) => { + const errors: any = {} + if (values.branchName.length === 0) errors.branchName = 'Please provide a branch name' + if (values.gitBranch.length === 0) errors.gitBranch = 'Please provide a Git branch' + return errors + } + + const onConfirmUpdate = (values: any) => { + if (selectedBranch === undefined) return console.error('No branch selected') + if (projectRef === undefined) return console.error('Project ref is required') + updateBranch({ ...values, id: selectedBranch.id, projectRef }) + } + + return ( + + + +
+ } + > +
+ +
+ {() => { + return ( +
+ + +
+ ) + }} +
+
+
+ + ) +} + +export default UpdateBranchSidePanel diff --git a/studio/components/interfaces/Database/Backups/BackupsList.tsx b/studio/components/interfaces/Database/Backups/BackupsList.tsx index fd379d00293..4bf28b80dee 100644 --- a/studio/components/interfaces/Database/Backups/BackupsList.tsx +++ b/studio/components/interfaces/Database/Backups/BackupsList.tsx @@ -36,6 +36,7 @@ const BackupsList = () => { primaryText="Free Plan does not include project backups." projectRef={projectRef} secondaryText="Upgrade to the Pro plan for up to 7 days of scheduled backups." + addon="pitr" /> ) } diff --git a/studio/components/interfaces/Integrations/IntegrationPanels.tsx b/studio/components/interfaces/Integrations/IntegrationPanels.tsx index ad89a42d0ab..202d712da00 100644 --- a/studio/components/interfaces/Integrations/IntegrationPanels.tsx +++ b/studio/components/interfaces/Integrations/IntegrationPanels.tsx @@ -6,8 +6,8 @@ import { Markdown } from 'components/interfaces/Markdown' import { Integration, IntegrationProjectConnection } from 'data/integrations/integrations.types' import { useProjectsQuery } from 'data/projects/projects-query' import { BASE_PATH } from 'lib/constants' -import { getVercelConfigurationUrl } from 'lib/integration-utils' import { Badge, Button, IconArrowRight, IconExternalLink, IconGitHub, IconSquare, cn } from 'ui' +import { getIntegrationConfigurationUrl } from 'lib/integration-utils' const ICON_STROKE_WIDTH = 2 const ICON_SIZE = 14 @@ -24,9 +24,9 @@ const HandleIcon = ({ type, className }: { type: HandleIconType; className?: str case 'GitHub': return break - case 'Netlify': - return - break + // case 'Netlify': + // return + // break case 'Vercel': return (
- {/* {title} integration connection •{' '} */} - {integration.metadata?.account.name || integration.metadata?.gitHubConnectionOwner} + {integration.metadata?.account.name || + (integration.metadata !== undefined && + 'gitHubConnectionOwner' in integration.metadata && + integration.metadata?.gitHubConnectionOwner)} @@ -112,9 +114,8 @@ const IntegrationInstallation = React.forwardRef + + + + + + No results found. + + {organizationsData?.map((org) => { + return ( + { + const org = organizationsData?.find( + (org) => org.slug.toLowerCase() === slug.toLowerCase() + ) + if (org) { + onSelectedOrgChange(org) + } + + setOpen(false) + }} + > + + {org.name}{' '} + {configurationId && installed[org.slug] && ( + + Integration Installed + + )} + + ) + })} + + + + + + + ) +} + +export default OrganizationPicker diff --git a/studio/components/interfaces/Integrations/ProjectLinker.tsx b/studio/components/interfaces/Integrations/ProjectLinker.tsx index e220c54f1d2..9365d763103 100644 --- a/studio/components/interfaces/Integrations/ProjectLinker.tsx +++ b/studio/components/interfaces/Integrations/ProjectLinker.tsx @@ -1,14 +1,9 @@ -import { ENV_VAR_RAW_KEYS } from 'components/interfaces/Integrations/Integrations-Vercel.constants' -import { Markdown } from 'components/interfaces/Markdown' -import { vercelIcon } from 'components/to-be-cleaned/ListIcons' -import { useIntegrationConnectionsCreateMutation } from 'data/integrations/integration-connections-create-mutation' -import { useIntegrationsVercelConnectionSyncEnvsMutation } from 'data/integrations/integrations-vercel-connection-sync-envs-mutation' -import { VercelProjectsResponse } from 'data/integrations/integrations-vercel-projects-query' +import { ReactNode, useRef, useState } from 'react' + import { IntegrationProjectConnection } from 'data/integrations/integrations.types' +import { IntegrationConnectionsCreateVariables } from 'data/integrations/types' import { useSelectedOrganization } from 'hooks' import { BASE_PATH } from 'lib/constants' -import { useRef, useState } from 'react' -import { toast } from 'react-hot-toast' import { Button, CommandEmpty_Shadcn_, @@ -24,20 +19,28 @@ import { cn, } from 'ui' -interface Project { +export interface Project { id: string name: string ref: string } +export interface ForeignProject { + id: string + name: string +} + export interface ProjectLinkerProps { organizationIntegrationId: string | undefined - foreignProjects: VercelProjectsResponse[] + foreignProjects: ForeignProject[] supabaseProjects: Project[] - onCreateConnections?: () => void + onCreateConnections: (variables: IntegrationConnectionsCreateVariables) => void installedConnections: IntegrationProjectConnection[] | undefined - setLoading?: (x: boolean) => void - showSkip?: boolean + isLoading?: boolean + integrationIcon: ReactNode + getForeignProjectIcon?: (project: ForeignProject) => ReactNode + choosePrompt?: string + onSkip?: () => void loadingForeignProjects?: boolean loadingSupabaseProjects?: boolean } @@ -48,77 +51,54 @@ const ProjectLinker = ({ supabaseProjects, onCreateConnections: _onCreateConnections, installedConnections = [], - setLoading, - showSkip = false, + isLoading, + integrationIcon, + getForeignProjectIcon, + choosePrompt = 'Choose a project', + onSkip, loadingForeignProjects, loadingSupabaseProjects, }: ProjectLinkerProps) => { const [supabaseProjectsComboBoxOpen, setSupabaseProjectsComboboxOpen] = useState(false) - const [vercelProjectsComboBoxOpen, setVercelProjectsComboboxOpen] = useState(false) + const [foreignProjectsComboBoxOpen, setForeignProjectsComboboxOpen] = useState(false) const supabaseProjectsComboBoxRef = useRef(null) - const vercelProjectsComboBoxRef = useRef(null) + const foreignProjectsComboBoxRef = useRef(null) const selectedOrganization = useSelectedOrganization() const [supabaseProjectRef, setSupabaseProjectRef] = useState(undefined) - const [vercelProjectId, setVercelProjectId] = useState(undefined) - - const { mutateAsync: syncEnvs } = useIntegrationsVercelConnectionSyncEnvsMutation() - const { mutate: createConnections, isLoading } = useIntegrationConnectionsCreateMutation({ - async onSuccess({ id }) { - try { - await syncEnvs({ connectionId: id }) - } catch (error: any) { - toast.error('Failed to sync environment variables: ', error.message) - } - - if (setLoading) setLoading(false) - _onCreateConnections?.() - }, - onError() { - if (setLoading) setLoading(false) - }, - }) + const [foreignProjectId, setForeignProjectId] = useState(undefined) // create a flat array of foreign project ids. ie, ["prj_MlkO6AiLG5ofS9ojKrkS3PhhlY3f", ..] const flatInstalledConnectionsIds = new Set(installedConnections.map((x) => x.foreign_project_id)) - // check that vercel project is not already installed - const filteredForeignProjects: VercelProjectsResponse[] = foreignProjects.filter( - (foreignProject) => { - return !flatInstalledConnectionsIds.has(foreignProject.id) - } - ) + // check that foreign project is not already installed + const filteredForeignProjects = foreignProjects.filter((foreignProject) => { + return !flatInstalledConnectionsIds.has(foreignProject.id) + }) const selectedSupabaseProject = supabaseProjectRef ? supabaseProjects.find((x) => x.ref?.toLowerCase() === supabaseProjectRef?.toLowerCase()) : undefined - const selectedVercelProject = vercelProjectId - ? filteredForeignProjects.find((x) => x.id?.toLowerCase() === vercelProjectId?.toLowerCase()) + const selectedForeignProject = foreignProjectId + ? filteredForeignProjects.find((x) => x.id?.toLowerCase() === foreignProjectId?.toLowerCase()) : undefined function onCreateConnections() { - const projectDetails = selectedVercelProject + const projectDetails = selectedForeignProject if (!organizationIntegrationId) return console.error('No integration ID set') - if (!selectedVercelProject?.id) return console.error('No Vercel project ID set') + if (!selectedForeignProject?.id) return console.error('No Foreign project ID set') if (!selectedSupabaseProject?.ref) return console.error('No Supabase project ref set') - if (setLoading) setLoading(true) - - createConnections({ + _onCreateConnections({ organizationIntegrationId, connection: { - foreign_project_id: selectedVercelProject?.id, + foreign_project_id: selectedForeignProject?.id, supabase_project_ref: selectedSupabaseProject?.ref, metadata: { ...projectDetails, - supabaseConfig: { - projectEnvVars: { - write: true, - }, - }, }, }, orgSlug: selectedOrganization?.slug, @@ -225,23 +205,16 @@ const ProjectLinker = ({
- - - + {integrationIcon}
@@ -292,20 +253,11 @@ const ProjectLinker = ({ key={project.id} className="flex gap-2 items-center" onSelect={(id) => { - if (id) setVercelProjectId(id) - setVercelProjectsComboboxOpen(false) + if (id) setForeignProjectId(id) + setForeignProjectsComboboxOpen(false) }} > - {!project?.framework ? ( - vercelIcon - ) : ( - {`icon`} - )} + {getForeignProjectIcon?.(project) ?? integrationIcon} {project.name} ) @@ -319,12 +271,12 @@ const ProjectLinker = ({
- {showSkip && ( + {onSkip !== undefined && (
- { - return ` - \n - - \`${x}\` -` -})} -`} - />
) } diff --git a/studio/components/interfaces/Markdown.tsx b/studio/components/interfaces/Markdown.tsx index 1755f86c843..8aff7074cd5 100644 --- a/studio/components/interfaces/Markdown.tsx +++ b/studio/components/interfaces/Markdown.tsx @@ -9,7 +9,11 @@ interface Props extends Omit { const Markdown = ({ className, content, ...props }: Props) => { return ( - +

{children}

}} + {...props} + className={cn('prose text-sm', className)} + > {content}
) diff --git a/studio/components/interfaces/Organization/IntegrationSettings/Integration.tsx b/studio/components/interfaces/Organization/IntegrationSettings/Integration.tsx index c0a351aff61..ffd8d671337 100644 --- a/studio/components/interfaces/Organization/IntegrationSettings/Integration.tsx +++ b/studio/components/interfaces/Organization/IntegrationSettings/Integration.tsx @@ -11,42 +11,37 @@ import { ScaffoldSectionContent, ScaffoldSectionDetail, } from 'components/layouts/Scaffold' -import { useIntegrationsVercelInstalledConnectionDeleteMutation } from 'data/integrations/integrations-vercel-installed-connection-delete-mutation' +import ConfirmationModal from 'components/ui/ConfirmationModal' import { + IntegrationName, IntegrationProjectConnection, Integration as TIntegration, } from 'data/integrations/integrations.types' -import { useSelectedOrganization } from 'hooks' import { BASE_PATH } from 'lib/constants' import { pluralize } from 'lib/helpers' import { EMPTY_ARR } from 'lib/void' -import { useGithubConnectionConfigPanelSnapshot } from 'state/github-connection-config-panel' -import { Button, Dropdown, IconChevronDown, IconTrash } from 'ui' +import { useCallback, useState } from 'react' +import { Button, Dropdown, IconChevronDown, IconTrash, Modal } from 'ui' export interface IntegrationProps { title: string - orgName?: string description?: string note?: string detail?: string integrations?: TIntegration[] + onAddConnection: (integrationId: string) => void + onDeleteConnection: (connection: IntegrationProjectConnection) => void | Promise } const Integration = ({ title, - orgName, description, note, detail, integrations = EMPTY_ARR, + onAddConnection, + onDeleteConnection, }: IntegrationProps) => { - const snapshot = useGithubConnectionConfigPanelSnapshot() - - const selectedOrganization = useSelectedOrganization() - - const { mutate: deleteMutate, isLoading: isDeleteLoading } = - useIntegrationsVercelInstalledConnectionDeleteMutation() - const ConnectionHeading = ({ integration }: { integration: TIntegration }) => { return ( { - const variables = { - organization_integration_id: organizationIntegrationId, - id: projectIntegrationId, - orgSlug: selectedOrganization?.slug, - } - - deleteMutate(variables) - } - return ( <> @@ -89,60 +73,28 @@ Repository connections for ${title?.toLowerCase()} {integrations.length > 0 && integrations.map((integration, i) => { return ( -
- +
+ + {integration.connections.length > 0 ? ( <>
    - {integration.connections.map( - (connection: IntegrationProjectConnection, i) => ( - - } - onSelect={() => - handleDelete(integration.id, connection.id) - } - > - Delete - - - } - > - - - } - /> - ) - )} + {integration.connections.map((connection) => ( + + ))}
) : ( )} - { - snapshot.setOrganizationIntegrationId(integration.id) - snapshot.setOpen(true) - }} - > + onAddConnection(integration.id)}> Add new project connection
@@ -158,3 +110,71 @@ Repository connections for ${title?.toLowerCase()} } export default Integration + +type IntegrationConnectionItemProps = { + connection: IntegrationProjectConnection + type: IntegrationName + onDeleteConnection: (connection: IntegrationProjectConnection) => void | Promise +} + +const IntegrationConnectionItem = ({ + connection, + type, + onDeleteConnection, +}: IntegrationConnectionItemProps) => { + const [isOpen, setIsOpen] = useState(false) + + const onConfirm = useCallback(async () => { + try { + await onDeleteConnection(connection) + } finally { + setIsOpen(false) + } + }, [connection, onDeleteConnection]) + + const onCancel = useCallback(() => { + setIsOpen(false) + }, []) + + return ( + <> + + } onSelect={() => setIsOpen(true)}> + Delete + + + } + > + + + } + /> + + + +

+ {`This action cannot be undone. Are you sure you want to delete this connection?`} +

+
+
+ + ) +} diff --git a/studio/components/interfaces/Organization/IntegrationSettings/IntegrationSettings.tsx b/studio/components/interfaces/Organization/IntegrationSettings/IntegrationSettings.tsx index b80cd93b40a..a0be0c4b15c 100644 --- a/studio/components/interfaces/Organization/IntegrationSettings/IntegrationSettings.tsx +++ b/studio/components/interfaces/Organization/IntegrationSettings/IntegrationSettings.tsx @@ -1,19 +1,25 @@ +import { useCallback, useState } from 'react' + +import { useParams } from 'common' import { ScaffoldDivider } from 'components/layouts/Scaffold' +import { useIntegrationsGitHubInstalledConnectionDeleteMutation } from 'data/integrations/integrations-github-connection-delete-mutation' import { useOrgIntegrationsQuery } from 'data/integrations/integrations-query-org-only' +import { useIntegrationsVercelInstalledConnectionDeleteMutation } from 'data/integrations/integrations-vercel-installed-connection-delete-mutation' import { useVercelProjectsQuery } from 'data/integrations/integrations-vercel-projects-query' -import { useSelectedOrganization } from 'hooks' -import { getVercelConfigurationUrl } from 'lib/integration-utils' +import { IntegrationProjectConnection } from 'data/integrations/integrations.types' +import { getIntegrationConfigurationUrl } from 'lib/integration-utils' import Integration from './Integration' +import SidePanelGitHubRepoLinker from './SidePanelGitHubRepoLinker' import SidePanelVercelProjectLinker from './SidePanelVercelProjectLinker' const IntegrationSettings = () => { - const org = useSelectedOrganization() - const { data } = useOrgIntegrationsQuery({ orgSlug: org?.slug }) + const { slug } = useParams() + const { data } = useOrgIntegrationsQuery({ orgSlug: slug }) const vercelIntegrations = data ?.filter((integration) => integration.integration.name === 'Vercel') .map((integration) => { - if (integration.metadata) { + if (integration.metadata && integration.integration.name === 'Vercel') { const avatarSrc = !integration.metadata.account.avatar && integration.metadata.account.type === 'Team' ? `https://vercel.com/api/www/avatar?teamId=${integration.metadata.account.team_id}&s=48` @@ -39,11 +45,56 @@ const IntegrationSettings = () => { ) const vercelProjectCount = vercelProjectsData?.length ?? 0 + const [addPanelOpen, setAddPanelOpen] = useState(undefined) + const [addPanelIntegrationId, setAddPanelIntegrationId] = useState(undefined) + + const onAddVercelConnection = useCallback((integrationId: string) => { + setAddPanelIntegrationId(integrationId) + setAddPanelOpen('VERCEL') + }, []) + + const onAddGitHubConnection = useCallback((integrationId: string) => { + setAddPanelIntegrationId(integrationId) + setAddPanelOpen('GITHUB') + }, []) + + const onCloseAddPanel = useCallback(() => { + setAddPanelOpen(undefined) + setAddPanelIntegrationId(undefined) + }, []) + + const { mutateAsync: deleteVercelConnection } = + useIntegrationsVercelInstalledConnectionDeleteMutation() + + const onDeleteVercelConnection = useCallback( + async (connection: IntegrationProjectConnection) => { + await deleteVercelConnection({ + id: connection.id, + organization_integration_id: connection.organization_integration_id, + orgSlug: slug, + }) + }, + [deleteVercelConnection, slug] + ) + + const { mutateAsync: deleteGitHubConnection } = + useIntegrationsGitHubInstalledConnectionDeleteMutation() + + const onDeleteGitHubConnection = useCallback( + async (connection: IntegrationProjectConnection) => { + await deleteGitHubConnection({ + connectionId: connection.id, + integrationId: connection.organization_integration_id, + orgSlug: slug, + }) + }, + [deleteGitHubConnection, slug] + ) + return ( <> 0 && vercelIntegration !== undefined ? ` Your Vercel connection has access to ${vercelProjectCount} Vercel Projects. -You can change the scope of the access for Supabase by configuring [here](${getVercelConfigurationUrl( +You can change the scope of the access for Supabase by configuring [here](${getIntegrationConfigurationUrl( vercelIntegration )}). ` : undefined } integrations={vercelIntegrations} + onAddConnection={onAddVercelConnection} + onDeleteConnection={onDeleteVercelConnection} /> + + - ) } diff --git a/studio/components/interfaces/Organization/IntegrationSettings/SidePanelGitHubRepoLinker.tsx b/studio/components/interfaces/Organization/IntegrationSettings/SidePanelGitHubRepoLinker.tsx new file mode 100644 index 00000000000..48b4fc77db7 --- /dev/null +++ b/studio/components/interfaces/Organization/IntegrationSettings/SidePanelGitHubRepoLinker.tsx @@ -0,0 +1,125 @@ +import { useMemo } from 'react' + +import ProjectLinker from 'components/interfaces/Integrations/ProjectLinker' +import { Markdown } from 'components/interfaces/Markdown' +import { useIntegrationGitHubConnectionsCreateMutation } from 'data/integrations/integrations-github-connections-create-mutation' +import { useGitHubReposQuery } from 'data/integrations/integrations-github-repos-query' +import { useOrgIntegrationsQuery } from 'data/integrations/integrations-query-org-only' +import { useProjectsQuery } from 'data/projects/projects-query' +import { useSelectedOrganization } from 'hooks' +import { EMPTY_ARR } from 'lib/void' +import { SidePanel } from 'ui' + +const GITHUB_ICON = ( + + + +) + +export type SidePanelGitHubRepoLinkerProps = { + isOpen?: boolean + onClose?: () => void + organizationIntegrationId?: string +} + +const SidePanelGitHubRepoLinker = ({ + isOpen = false, + onClose, + organizationIntegrationId, +}: SidePanelGitHubRepoLinkerProps) => { + const selectedOrganization = useSelectedOrganization() + + const { data: integrationData } = useOrgIntegrationsQuery({ + orgSlug: selectedOrganization?.slug, + }) + const githubIntegrations = integrationData?.filter( + (integration) => integration.integration.name === 'GitHub' + ) // github + + /** + * Find the right integration + * + * we use the snapshot.organizationIntegrationId which should be set whenever this sidepanel is opened + */ + const selectedIntegration = githubIntegrations?.find((x) => x.id === organizationIntegrationId) + + /** + * Supabase projects available + */ + const { data: supabaseProjectsData } = useProjectsQuery({ + enabled: organizationIntegrationId !== undefined, + }) + + const supabaseProjects = useMemo( + () => + supabaseProjectsData + ?.filter((project) => project.organization_id === selectedOrganization?.id) + .map((project) => ({ id: project.id.toString(), name: project.name, ref: project.ref })) ?? + EMPTY_ARR, + [selectedOrganization?.id, supabaseProjectsData] + ) + + const { data: githubProjectsData } = useGitHubReposQuery( + { + integrationId: organizationIntegrationId, + }, + { enabled: organizationIntegrationId !== undefined } + ) + + const githubRepos = useMemo( + () => + githubProjectsData?.map((repo) => ({ + id: repo.id.toString(), + name: repo.full_name, + })) ?? EMPTY_ARR, + [githubProjectsData] + ) + + const { mutate: createConnections, isLoading: isCreatingConnection } = + useIntegrationGitHubConnectionsCreateMutation({ + onSuccess() { + onClose?.() + }, + }) + + return ( + onClose?.()} + > +
+ + + + + + +
+
+ ) +} + +export default SidePanelGitHubRepoLinker diff --git a/studio/components/interfaces/Organization/IntegrationSettings/SidePanelGitHubRepoSelection.tsx b/studio/components/interfaces/Organization/IntegrationSettings/SidePanelGitHubRepoSelection.tsx index 4b978dd0ae2..53a4bb42ed8 100644 --- a/studio/components/interfaces/Organization/IntegrationSettings/SidePanelGitHubRepoSelection.tsx +++ b/studio/components/interfaces/Organization/IntegrationSettings/SidePanelGitHubRepoSelection.tsx @@ -3,7 +3,6 @@ import { Markdown } from 'components/interfaces/Markdown' // import { useIntegrationsQuery } from 'data/integrations/integrations-query-org-only' import { useStore } from 'hooks' import { observer } from 'mobx-react-lite' -import { useGithubConnectionConfigPanelSnapshot } from 'state/github-connection-config-panel' import { IconGitHub, IconSearch, Input, Select, SidePanel } from 'ui' // TO DO @@ -13,8 +12,6 @@ const GitHubRepoSelection = () => { const { slug } = useParams() // const { data } = useIntegrationsQuery({ orgSlug: slug }) - const githubConnectionConfigPanelSnapshot = useGithubConnectionConfigPanelSnapshot() - return ( { +const VERCEL_ICON = ( + + + +) + +export type SidePanelVercelProjectLinkerProps = { + isOpen?: boolean + onClose?: () => void + organizationIntegrationId?: string +} + +const SidePanelVercelProjectLinker = ({ + isOpen = false, + onClose, + organizationIntegrationId, +}: SidePanelVercelProjectLinkerProps) => { const selectedOrganization = useSelectedOrganization() const { data: integrationData } = useOrgIntegrationsQuery({ @@ -20,19 +40,12 @@ const SidePanelVercelProjectLinker = () => { (integration) => integration.integration.name === 'Vercel' ) // vercel - const snapshot = useGithubConnectionConfigPanelSnapshot() - /** * Find the right integration * * we use the snapshot.organizationIntegrationId which should be set whenever this sidepanel is opened */ - const selectedIntegration = vercelIntegrations?.find( - (x) => x.id === snapshot.organizationIntegrationId - ) - - const organizationIntegrationId = snapshot.organizationIntegrationId - const open = snapshot.open + const selectedIntegration = vercelIntegrations?.find((x) => x.id === organizationIntegrationId) /** * Supabase projects available @@ -58,14 +71,60 @@ const SidePanelVercelProjectLinker = () => { ) const vercelProjects = useMemo(() => vercelProjectsData ?? EMPTY_ARR, [vercelProjectsData]) + const vercelProjectsById = useMemo(() => keyBy(vercelProjects, 'id'), [vercelProjects]) + + const getForeignProjectIcon = useCallback( + (_project: ForeignProject) => { + const project = vercelProjectsById[_project.id] + + return !project?.framework ? ( + vercelIcon + ) : ( + {`icon`} + ) + }, + [vercelProjectsById] + ) + + const { mutate: createConnections, isLoading: isCreatingConnection } = + useIntegrationVercelConnectionsCreateMutation({ + onSuccess() { + onClose?.() + }, + }) + + const onCreateConnections = useCallback( + (vars) => { + createConnections({ + ...vars, + connection: { + ...vars.connection, + metadata: { + ...vars.connection.metadata, + supabaseConfig: { + projectEnvVars: { + write: true, + }, + }, + }, + }, + }) + }, + [createConnections] + ) return ( snapshot.setOpen(false)} + onCancel={() => onClose?.()} >
@@ -82,10 +141,24 @@ Check the details below before proceeding organizationIntegrationId={selectedIntegration?.id} foreignProjects={vercelProjects} supabaseProjects={supabaseProjects} - onCreateConnections={() => { - snapshot.setOpen(false) - }} + onCreateConnections={onCreateConnections} installedConnections={selectedIntegration?.connections} + isLoading={isCreatingConnection} + integrationIcon={VERCEL_ICON} + getForeignProjectIcon={getForeignProjectIcon} + choosePrompt="Choose Vercel Project" + /> + { + return ` + \n + - \`${x}\` +` +})} +`} /> diff --git a/studio/components/interfaces/Settings/Addons/Addons.tsx b/studio/components/interfaces/Settings/Addons/Addons.tsx index bfc323c04b7..88035a87b70 100644 --- a/studio/components/interfaces/Settings/Addons/Addons.tsx +++ b/studio/components/interfaces/Settings/Addons/Addons.tsx @@ -20,11 +20,20 @@ import AlertError from 'components/ui/AlertError' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useInfraMonitoringQuery } from 'data/analytics/infra-monitoring-query' import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query' -import { useFlag } from 'hooks' +import { useFlag, useProjectByRef } from 'hooks' import { getCloudProviderArchitecture } from 'lib/cloudprovider-utils' -import { BASE_PATH, PROJECT_STATUS } from 'lib/constants' +import { BASE_PATH } from 'lib/constants' import { SUBSCRIPTION_PANEL_KEYS, useSubscriptionPageStateSnapshot } from 'state/subscription-page' -import { Alert, Button, IconChevronRight, IconExternalLink } from 'ui' +import { + Alert, + AlertDescription_Shadcn_, + AlertTitle_Shadcn_, + Alert_Shadcn_, + Button, + IconAlertCircle, + IconChevronRight, + IconExternalLink, +} from 'ui' import { ComputeInstanceSidePanel, CustomDomainSidePanel, PITRSidePanel } from './' const Addons = () => { @@ -34,6 +43,8 @@ const Addons = () => { const projectUpdateDisabled = useFlag('disableProjectCreationAndUpdate') const { project: selectedProject } = useProjectContext() + const parentProject = useProjectByRef(selectedProject?.parent_project_ref) + const isBranch = parentProject !== undefined const isProjectActive = useIsProjectActive() const allowedPanelValues = ['computeInstance', 'pitr', 'customDomain'] if (panel && typeof panel === 'string' && allowedPanelValues.includes(panel)) { @@ -78,6 +89,25 @@ const Addons = () => { + {isBranch && ( + + + + + You are currently on a preview branch of your project + + + Updating addons are not available while you're on a preview branch. To manage your + addons, you may return to your{' '} + + main branch + + . + + + + )} + {isLoading && ( @@ -161,7 +191,7 @@ const Addons = () => { type="default" className="mt-2 pointer-events-auto" onClick={() => snap.setPanelKey('computeInstance')} - disabled={!isProjectActive || projectUpdateDisabled} + disabled={isBranch || !isProjectActive || projectUpdateDisabled} > Change optimized compute @@ -356,7 +386,7 @@ const Addons = () => { type="default" className="mt-2 pointer-events-auto" onClick={() => snap.setPanelKey('pitr')} - disabled={!isProjectActive || projectUpdateDisabled} + disabled={isBranch || !isProjectActive || projectUpdateDisabled} > Change point in time recovery @@ -420,7 +450,7 @@ const Addons = () => { type="default" className="mt-2 pointer-events-auto" onClick={() => snap.setPanelKey('customDomain')} - disabled={!isProjectActive || projectUpdateDisabled} + disabled={isBranch || !isProjectActive || projectUpdateDisabled} > Change custom domain diff --git a/studio/components/interfaces/Settings/Database/ConnectionPooling.tsx b/studio/components/interfaces/Settings/Database/ConnectionPooling.tsx index 6792ab6557e..cfb85ffcccb 100644 --- a/studio/components/interfaces/Settings/Database/ConnectionPooling.tsx +++ b/studio/components/interfaces/Settings/Database/ConnectionPooling.tsx @@ -34,6 +34,8 @@ const ConnectionPooling = () => { 'ignore_startup_parameters', 'pool_mode', 'pgbouncer_enabled', + 'max_client_conn', + 'connectionString', ] const bouncerInfo = isSuccess ? pluckObjectFields(formModel, BOUNCER_FIELDS) : {} @@ -105,6 +107,8 @@ interface ConfigProps { ignore_startup_parameters: 'string' pool_mode: string pgbouncer_enabled: boolean + max_client_conn: number + connectionString: string } connectionInfo: { db_host: string @@ -123,11 +127,12 @@ export const PgbouncerConfig: FC = ({ projectRef, bouncerInfo, conn 'projects' ) - const [updates, setUpdates] = useState({ + const [updates, setUpdates] = useState({ pool_mode: bouncerInfo.pool_mode || 'transaction', default_pool_size: bouncerInfo.default_pool_size || undefined, ignore_startup_parameters: bouncerInfo.ignore_startup_parameters || '', pgbouncer_enabled: bouncerInfo.pgbouncer_enabled, + max_client_conn: bouncerInfo.max_client_conn || undefined, }) const updateConfig = async (updatedConfig: any) => { @@ -174,6 +179,16 @@ export const PgbouncerConfig: FC = ({ projectRef, bouncerInfo, conn type: 'string', help: 'Defaults are either blank or "extra_float_digits"', }, + max_client_conn: { + title: 'Max Client Connections', + oneOf: [{ type: 'integer' }, { type: 'null' }], + help: 'The maximum number of concurrent client connections allowed. Overrides default optimizations; refer to https://supabase.com/docs/guides/platform/custom-postgres-config#pooler-config', + }, + default_pool_size: { + title: 'Default Pool Size', + oneOf: [{ type: 'integer' }, { type: 'null' }], + help: 'The maximum number of connections made to the underlying Postgres cluster, per user+db combination. Overrides default optimizations; refer to https://supabase.com/docs/guides/platform/custom-postgres-config#pooler-config', + }, }, required: ['pool_mode'], type: 'object', @@ -182,7 +197,7 @@ export const PgbouncerConfig: FC = ({ projectRef, bouncerInfo, conn return (
= ({ projectRef, bouncerInfo, conn
+ + + + )} @@ -239,11 +258,7 @@ export const PgbouncerConfig: FC = ({ projectRef, bouncerInfo, conn copy disabled label="Connection string" - value={ - `postgres://${connectionInfo.db_user}:[YOUR-PASSWORD]@` + - `${connectionInfo.db_host}:${connectionInfo.db_port}` + - `/${connectionInfo.db_name}` - } + value={bouncerInfo.connectionString} />
diff --git a/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx b/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx index abb91c8a96e..d2e68280594 100644 --- a/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx +++ b/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx @@ -69,6 +69,7 @@ const CustomDomainConfig = () => { ? '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' } + addon="customDomain" /> ) : ( diff --git a/studio/components/interfaces/Settings/General/General.tsx b/studio/components/interfaces/Settings/General/General.tsx index a4f2ac6b4dd..af429ec80fc 100644 --- a/studio/components/interfaces/Settings/General/General.tsx +++ b/studio/components/interfaces/Settings/General/General.tsx @@ -1,6 +1,15 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import Link from 'next/link' -import { Button, Form, IconBarChart2, Input } from 'ui' +import { + AlertDescription_Shadcn_, + AlertTitle_Shadcn_, + Alert_Shadcn_, + Button, + Form, + IconAlertCircle, + IconBarChart2, + Input, +} from 'ui' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { @@ -14,7 +23,7 @@ import { import Panel from 'components/ui/Panel' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' import { useProjectUpdateMutation } from 'data/projects/project-update-mutation' -import { useCheckPermissions, useSelectedOrganization, useStore } from 'hooks' +import { useCheckPermissions, useProjectByRef, useSelectedOrganization, useStore } from 'hooks' import PauseProjectButton from './Infrastructure/PauseProjectButton' import RestartServerButton from './Infrastructure/RestartServerButton' @@ -23,6 +32,9 @@ const General = () => { const { project } = useProjectContext() const organization = useSelectedOrganization() + const parentProject = useProjectByRef(project?.parent_project_ref) + const isBranch = parentProject !== undefined + const isOrgBilling = !!organization?.subscription_id const formId = 'project-general-settings' const initialValues = { name: project?.name ?? '', ref: project?.ref ?? '' } @@ -41,6 +53,24 @@ const General = () => { return (
+ + {isBranch && ( + + + + You are currently on a preview branch of your project + + + Certain settings are not available while you're on a preview branch. To adjust your + project settings, you may return to your{' '} + + main branch + + . + + + )} + {project === undefined ? ( ) : ( @@ -72,7 +102,7 @@ const General = () => { id="name" size="small" label="Project name" - disabled={!canUpdateProject} + disabled={isBranch || !canUpdateProject} /> @@ -82,7 +112,7 @@ const General = () => { }} )} - {isOrgBilling && ( + {!isBranch && isOrgBilling && ( <>
diff --git a/studio/components/interfaces/Settings/Logs/LogTable.tsx b/studio/components/interfaces/Settings/Logs/LogTable.tsx index 99900a05372..08133ed0dec 100644 --- a/studio/components/interfaces/Settings/Logs/LogTable.tsx +++ b/studio/components/interfaces/Settings/Logs/LogTable.tsx @@ -1,21 +1,23 @@ -import { useEffect, useState, useMemo, useCallback } from 'react' -import { Alert, Button, IconEye, IconEyeOff } from 'ui' import DataGrid, { Row, RowRendererProps } from '@supabase/react-data-grid' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Alert, Button, IconClipboard, IconEye, IconEyeOff } from 'ui' -import LogSelection, { LogSelectionProps } from './LogSelection' -import { LogData, QueryType } from './Logs.types' -import { isDefaultLogPreviewFormat } from './Logs.utils' import CSVButton from 'components/ui/CSVButton' +import { useStore } from 'hooks' +import { copyToClipboard } from 'lib/helpers' +import { isEqual } from 'lodash' +import { LogQueryError } from '.' +import AuthColumnRenderer from './LogColumnRenderers/AuthColumnRenderer' import DatabaseApiColumnRender from './LogColumnRenderers/DatabaseApiColumnRender' import DatabasePostgresColumnRender from './LogColumnRenderers/DatabasePostgresColumnRender' import DefaultPreviewColumnRenderer from './LogColumnRenderers/DefaultPreviewColumnRenderer' -import { LogQueryError } from '.' -import ResourcesExceededErrorRenderer from './LogsErrorRenderers/ResourcesExceededErrorRenderer' -import DefaultErrorRenderer from './LogsErrorRenderers/DefaultErrorRenderer' -import FunctionsLogsColumnRender from './LogColumnRenderers/FunctionsLogsColumnRender' import FunctionsEdgeColumnRender from './LogColumnRenderers/FunctionsEdgeColumnRender' -import AuthColumnRenderer from './LogColumnRenderers/AuthColumnRenderer' -import { isEqual } from 'lodash' +import FunctionsLogsColumnRender from './LogColumnRenderers/FunctionsLogsColumnRender' +import LogSelection, { LogSelectionProps } from './LogSelection' +import { LogData, QueryType } from './Logs.types' +import { isDefaultLogPreviewFormat } from './Logs.utils' +import DefaultErrorRenderer from './LogsErrorRenderers/DefaultErrorRenderer' +import ResourcesExceededErrorRenderer from './LogsErrorRenderers/ResourcesExceededErrorRenderer' interface Props { data?: Array @@ -64,6 +66,7 @@ const LogTable = ({ projectRef, params, }: Props) => { + const { ui } = useStore() const [focusedLog, setFocusedLog] = useState(null) const firstRow: LogData | undefined = data?.[0] as LogData const columnNames = Object.keys(data[0] || {}) @@ -170,6 +173,12 @@ const LogTable = ({ [] ) + const copyResultsToClipboard = () => { + copyToClipboard(stringData, () => { + ui.setNotification({ category: 'success', message: 'Results copied to clipboard.' }) + }) + } + const LogsExplorerTableHeader = () => (
@@ -192,6 +201,9 @@ const LogTable = ({ Histogram )} + Download
diff --git a/studio/components/layouts/AccountLayout/WithSidebar.tsx b/studio/components/layouts/AccountLayout/WithSidebar.tsx index cdf48e39a65..eecb7d020e5 100644 --- a/studio/components/layouts/AccountLayout/WithSidebar.tsx +++ b/studio/components/layouts/AccountLayout/WithSidebar.tsx @@ -1,7 +1,7 @@ import { useFlag } from 'hooks' import { isUndefined } from 'lodash' import Link from 'next/link' -import { FC, ReactNode } from 'react' +import { ReactNode } from 'react' import { Badge, IconArrowUpRight, IconLogOut, Menu } from 'ui' import LayoutHeader from '../ProjectLayout/LayoutHeader' import { SidebarLink, SidebarSection } from './AccountLayout.types' @@ -104,11 +104,7 @@ interface SectionWithHeadersProps { subitemsParentKey?: number } -const SectionWithHeaders: FC = ({ - section, - subitems, - subitemsParentKey, -}) => ( +const SectionWithHeaders = ({ section, subitems, subitemsParentKey }: SectionWithHeadersProps) => (
{section.heading && } {section.versionLabel && ( @@ -131,7 +127,7 @@ interface SidebarItemProps { subitemsParentKey?: number } -const SidebarItem: FC = ({ links, subitems, subitemsParentKey }) => { +const SidebarItem = ({ links, subitems, subitemsParentKey }: SidebarItemProps) => { return (
    {links.map((link, i: number) => { @@ -174,7 +170,7 @@ interface SidebarLinkProps extends SidebarLink { isSubitem?: boolean } -const SidebarLinkItem: FC = ({ +const SidebarLinkItem = ({ id, label, href, @@ -182,7 +178,7 @@ const SidebarLinkItem: FC = ({ isSubitem, isExternal, onClick, -}) => { +}: SidebarLinkProps) => { if (isUndefined(href)) { let icon if (isExternal) { diff --git a/studio/components/layouts/AppLayout/AppHeader.tsx b/studio/components/layouts/AppLayout/AppHeader.tsx index 5ea83c6e56a..e6bcbe9bca9 100644 --- a/studio/components/layouts/AppLayout/AppHeader.tsx +++ b/studio/components/layouts/AppLayout/AppHeader.tsx @@ -2,7 +2,7 @@ import Link from 'next/link' import { useRouter } from 'next/router' import { useParams } from 'common' -import { useSelectedOrganization } from 'hooks' +import { useFlag, useSelectedOrganization } from 'hooks' import FeedbackDropdown from '../ProjectLayout/LayoutHeader/FeedbackDropdown' import HelpPopover from '../ProjectLayout/LayoutHeader/HelpPopover' import NotificationsPopover from '../ProjectLayout/LayoutHeader/NotificationsPopover' @@ -10,11 +10,13 @@ import OrganizationDropdown from './OrganizationDropdown' import ProjectDropdown from './ProjectDropdown' import SettingsButton from './SettingsButton' import UserSettingsDropdown from './UserSettingsDropdown' +import BranchDropdown from './BranchDropdown' const AppHeader = () => { const router = useRouter() const { ref } = useParams() const organization = useSelectedOrganization() + const enableBranchManagement = useFlag('branchManagement') return (
    @@ -30,6 +32,7 @@ const AppHeader = () => { {ref !== undefined && } + {ref !== undefined && enableBranchManagement && }
    diff --git a/studio/components/layouts/AppLayout/BranchDropdown.tsx b/studio/components/layouts/AppLayout/BranchDropdown.tsx new file mode 100644 index 00000000000..4e7e3378cb1 --- /dev/null +++ b/studio/components/layouts/AppLayout/BranchDropdown.tsx @@ -0,0 +1,166 @@ +import { useRouter } from 'next/router' +import { useRef, useState } from 'react' +import Link from 'next/link' + +import { useParams } from 'common' +import ShimmeringLoader from 'components/ui/ShimmeringLoader' +import { Branch, useBranchesQuery } from 'data/branches/branches-query' +import { + Badge, + Button, + CommandEmpty_Shadcn_, + CommandGroup_Shadcn_, + CommandInput_Shadcn_, + CommandItem_Shadcn_, + CommandList_Shadcn_, + Command_Shadcn_, + IconAlertCircle, + IconCheck, + IconCode, + IconGitBranch, + IconPlus, + PopoverContent_Shadcn_, + PopoverTrigger_Shadcn_, + Popover_Shadcn_, +} from 'ui' +import { useSelectedProject } from 'hooks' +import { sanitizeRoute } from './ProjectDropdown' + +const BranchLink = ({ + branch, + isSelected, + setOpen, +}: { + branch: Branch + isSelected: boolean + setOpen: (value: boolean) => void +}) => { + const router = useRouter() + const sanitizedRoute = sanitizeRoute(router.route, router.query) + const href = + sanitizedRoute?.replace('[ref]', branch.project_ref) ?? `/project/${branch.project_ref}` + + return ( + + { + setOpen(false) + router.push(href) + }} + onClick={() => { + setOpen(false) + }} + > + + {branch.name} + {isSelected && } + + + + ) +} + +const BranchDropdown = () => { + const router = useRouter() + const { ref } = useParams() + const projectDetails = useSelectedProject() + const branchNameRef = useRef(null) + + const isBranch = projectDetails?.parent_project_ref !== undefined + const projectRef = + projectDetails !== undefined ? (isBranch ? projectDetails.parent_project_ref : ref) : undefined + const { data: branches, isLoading, isError, isSuccess } = useBranchesQuery({ projectRef }) + + const [open, setOpen] = useState(false) + const popoverOffset = (branchNameRef.current?.offsetWidth ?? 0) + 12 + const selectedBranch = branches?.find((branch) => branch.project_ref === ref) + + return ( + <> + {isLoading && } + + {isError && ( +
    + +

    Failed to load branches

    +
    + )} + + {isSuccess && branches.length > 0 && ( +
    + + + {selectedBranch?.name} + + + + + + {selectedBranch?.is_default ? ( + Production + ) : ( + Preview Branch + )} + + + + + +
    + )} + + ) +} + +export default BranchDropdown diff --git a/studio/components/layouts/AppLayout/OrganizationDropdown.tsx b/studio/components/layouts/AppLayout/OrganizationDropdown.tsx index 035e4073307..79fe2d975e5 100644 --- a/studio/components/layouts/AppLayout/OrganizationDropdown.tsx +++ b/studio/components/layouts/AppLayout/OrganizationDropdown.tsx @@ -76,8 +76,6 @@ const OrganizationDropdown = () => { {organizations?.map((org) => { const href = router.pathname.includes('[slug]') ? router.pathname.replace('[slug]', org.slug) - : router.pathname.includes('[ref]/settings') - ? `/org/${org.slug}/general` : `/org/${org.slug}` return ( diff --git a/studio/components/layouts/AppLayout/ProjectDropdown.tsx b/studio/components/layouts/AppLayout/ProjectDropdown.tsx index 9a882441d23..db802d7fe65 100644 --- a/studio/components/layouts/AppLayout/ProjectDropdown.tsx +++ b/studio/components/layouts/AppLayout/ProjectDropdown.tsx @@ -106,6 +106,11 @@ const ProjectDropdown = () => { const projectNameRef = useRef(null) const { data: allProjects, isLoading: isLoadingProjects } = useProjectsQuery() + const isBranch = projectDetails?.parent_project_ref !== undefined + const parentProject = isBranch + ? allProjects?.find((project) => project.ref === projectDetails.parent_project_ref) + : undefined + const isOrgBilling = !!selectedOrganization?.subscription_id const { data: subscription, isSuccess } = useProjectSubscriptionV2Query( { projectRef: ref }, @@ -115,7 +120,9 @@ const ProjectDropdown = () => { ?.filter((x) => x.status !== PROJECT_STATUS.INACTIVE) .filter((x) => x.organization_id === selectedOrganization?.id) .sort((a, b) => a.name.localeCompare(b.name)) - const selectedProject = projectDetails || projects?.find((project) => project.ref === ref) + const selectedProject = isBranch + ? parentProject + : projectDetails || projects?.find((project) => project.ref === ref) const [open, setOpen] = useState(false) const popoverOffset = (projectNameRef.current?.offsetWidth ?? 0) + 12 @@ -126,7 +133,7 @@ const ProjectDropdown = () => { return IS_PLATFORM ? (
    - +

    {selectedProject?.name}

    {isSuccess && !isOrgBilling && {subscription?.plan.name}} diff --git a/studio/components/layouts/AppLayout/UserSettingsDropdown.tsx b/studio/components/layouts/AppLayout/UserSettingsDropdown.tsx index b1e37ff0ef9..51bba7ff4d7 100644 --- a/studio/components/layouts/AppLayout/UserSettingsDropdown.tsx +++ b/studio/components/layouts/AppLayout/UserSettingsDropdown.tsx @@ -1,7 +1,6 @@ import Link from 'next/link' import { useRouter } from 'next/router' import { - DropdownMenuCheckboxItem_Shadcn_, DropdownMenuContent_Shadcn_, DropdownMenuGroup_Shadcn_, DropdownMenuItem_Shadcn_, @@ -10,12 +9,8 @@ import { DropdownMenuRadioItem_Shadcn_, DropdownMenuSeparator_Shadcn_, DropdownMenuShortcut_Shadcn_, - DropdownMenuSubContent_Shadcn_, - DropdownMenuSubTrigger_Shadcn_, - DropdownMenuSub_Shadcn_, DropdownMenuTrigger_Shadcn_, DropdownMenu_Shadcn_, - IconCheck, IconUser, useCommandMenu, } from 'ui' diff --git a/studio/components/layouts/AuthLayout/AuthLayout.tsx b/studio/components/layouts/AuthLayout/AuthLayout.tsx index 6ffb6073271..72456fe6fc2 100644 --- a/studio/components/layouts/AuthLayout/AuthLayout.tsx +++ b/studio/components/layouts/AuthLayout/AuthLayout.tsx @@ -14,7 +14,7 @@ export interface AuthLayoutProps { } const AuthLayout = ({ title, children }: PropsWithChildren) => { - const { ui, meta } = useStore() + const { ui, meta, authConfig } = useStore() const { isInitialized, isLoading, error } = meta.tables const { ref: projectRef = 'default' } = useParams() @@ -29,6 +29,7 @@ const AuthLayout = ({ title, children }: PropsWithChildren) => meta.tables.load() meta.roles.load() meta.schemas.load() + authConfig.load() } }, [ui.selectedProjectRef]) diff --git a/studio/components/layouts/IntegrationsLayout/GitHubIntegrationWindowLayout.tsx b/studio/components/layouts/IntegrationsLayout/GitHubIntegrationWindowLayout.tsx new file mode 100644 index 00000000000..f08a6ca6c0e --- /dev/null +++ b/studio/components/layouts/IntegrationsLayout/GitHubIntegrationWindowLayout.tsx @@ -0,0 +1,24 @@ +import { PropsWithChildren } from 'react' + +import { BASE_PATH } from 'lib/constants' +import IntegrationWindowLayout from './IntegrationWindowLayout' + +const GITHUB_ICON = ( +
    + GitHub Icon +
    +) + +const GitHubIntegrationWindowLayout = ({ children }: PropsWithChildren<{}>) => { + return ( + + {children} + + ) +} + +export default GitHubIntegrationWindowLayout diff --git a/studio/components/layouts/IntegrationWindowLayout.tsx b/studio/components/layouts/IntegrationsLayout/IntegrationWindowLayout.tsx similarity index 54% rename from studio/components/layouts/IntegrationWindowLayout.tsx rename to studio/components/layouts/IntegrationsLayout/IntegrationWindowLayout.tsx index d84d961c57e..4aa3e3a6a81 100644 --- a/studio/components/layouts/IntegrationWindowLayout.tsx +++ b/studio/components/layouts/IntegrationsLayout/IntegrationWindowLayout.tsx @@ -1,20 +1,27 @@ -import { FC } from 'react' -import Link from 'next/link' -import { IconChevronRight, IconX, cn } from 'ui' -import { withAuth, useFlag } from 'hooks' -import { observer } from 'mobx-react-lite' -import { BASE_PATH } from 'lib/constants' -import React from 'react' -import { ScaffoldContainer } from './Scaffold' import { useParams } from 'common' +import { useFlag, withAuth } from 'hooks' +import { BASE_PATH } from 'lib/constants' +import { PropsWithChildren, ReactNode, forwardRef } from 'react' +import { IconX, cn } from 'ui' +import { ScaffoldContainer } from '../Scaffold' -const IntegrationWindowLayout: FC = ({ organization, project, children }) => { +export type IntegrationWindowLayoutProps = { + title: string + integrationIcon: ReactNode +} + +const IntegrationWindowLayout = ({ + title, + integrationIcon, + children, +}: PropsWithChildren) => { const ongoingIncident = useFlag('ongoingIncident') const maxHeight = ongoingIncident ? 'calc(100vh - 44px)' : '100vh' return (
    -
    +
    + {children}
    ) @@ -22,18 +29,16 @@ const IntegrationWindowLayout: FC = ({ organization, project, children }) = const INTEGRATION_LAYOUT_MAX_WIDTH = '' // 'max-w-[720px]' -export default withAuth(observer(IntegrationWindowLayout)) +export default withAuth(IntegrationWindowLayout) -export const IntegrationWindowLayoutWithoutAuth = observer(IntegrationWindowLayout) +export const IntegrationWindowLayoutWithoutAuth = IntegrationWindowLayout -const Header: FC = ({ organization, project }) => { - let stepNumber = organization ? 1 : project ? 2 : 0 - const { externalId } = useParams() - - const title = externalId - ? 'Supabase + Vercel Deploy Button' - : 'Supabase + Vercel Integration Marketplace Connector' +export type HeaderProps = { + title: string + integrationIcon: ReactNode +} +const Header = ({ title, integrationIcon }: HeaderProps) => { return (
    @@ -43,16 +48,7 @@ const Header: FC = ({ organization, project }) => { Supabase
    -
    - - - -
    + {integrationIcon}
    {title} @@ -66,7 +62,7 @@ const Header: FC = ({ organization, project }) => { const maxWidthClasses = 'mx-auto w-full max-w-[1600px]' const paddingClasses = 'px-6 lg:px-14 xl:px-28 2xl:px-32' -const IntegrationScaffoldContainer = React.forwardRef< +const IntegrationScaffoldContainer = forwardRef< HTMLDivElement, React.HTMLAttributes >(({ className, ...props }, ref) => { diff --git a/studio/components/layouts/IntegrationsLayout/Integrations.utils.ts b/studio/components/layouts/IntegrationsLayout/Integrations.utils.ts new file mode 100644 index 00000000000..0d19f996f5c --- /dev/null +++ b/studio/components/layouts/IntegrationsLayout/Integrations.utils.ts @@ -0,0 +1,54 @@ +import { IntegrationsData } from 'data/integrations/integrations-query' +import { IntegrationName } from 'data/integrations/integrations.types' +import { Organization } from 'types' + +export function getHasInstalledObject({ + integrationName, + integrationData, + organizationsData, + installationId, +}: { + integrationName: IntegrationName + integrationData: IntegrationsData + organizationsData: Organization[] + installationId?: string | number +}): { [orgSlug: string]: boolean } { + if (integrationName === 'Vercel') { + return Object.fromEntries( + organizationsData + .map((org) => [ + org.slug, + Boolean( + integrationData.find( + (integration) => + integration.organization.slug === org.slug && + integration.integration.name === 'Vercel' + ) + ), + ]) + .filter(([, v]) => Boolean(v)) + ) + } + + if (integrationName === 'GitHub') { + return Object.fromEntries( + organizationsData + .map((org) => [ + org.slug, + Boolean( + integrationData.find( + (integration) => + integration.organization.slug === org.slug && + integration.integration.name === 'GitHub' && + integration.metadata !== undefined && + 'installation_id' in integration.metadata && + String(integration.metadata.installation_id) === String(installationId) + ) + ), + ]) + .filter(([, v]) => Boolean(v)) + ) + } + + return {} +} diff --git a/studio/components/layouts/IntegrationsLayout/VercelIntegrationWindowLayout.tsx b/studio/components/layouts/IntegrationsLayout/VercelIntegrationWindowLayout.tsx new file mode 100644 index 00000000000..a70e1ef9bae --- /dev/null +++ b/studio/components/layouts/IntegrationsLayout/VercelIntegrationWindowLayout.tsx @@ -0,0 +1,27 @@ +import { PropsWithChildren } from 'react' + +import { useParams } from 'common' +import { BASE_PATH } from 'lib/constants' +import IntegrationWindowLayout from './IntegrationWindowLayout' + +const VERCEL_ICON = ( +
    + Vercel Icon +
    +) + +const VercelIntegrationWindowLayout = ({ children }: PropsWithChildren<{}>) => { + const { externalId } = useParams() + + const title = externalId + ? 'Supabase + Vercel Deploy Button' + : 'Supabase + Vercel Integration Marketplace Connector' + + return ( + + {children} + + ) +} + +export default VercelIntegrationWindowLayout diff --git a/studio/components/layouts/ProjectLayout/LayoutHeader/ProjectDropdown.tsx b/studio/components/layouts/ProjectLayout/LayoutHeader/ProjectDropdown.tsx index 232e27d29f5..d8c59401f7c 100644 --- a/studio/components/layouts/ProjectLayout/LayoutHeader/ProjectDropdown.tsx +++ b/studio/components/layouts/ProjectLayout/LayoutHeader/ProjectDropdown.tsx @@ -1,15 +1,14 @@ import Link from 'next/link' import { useRouter } from 'next/router' import { ParsedUrlQuery } from 'querystring' -import { Badge, Button, Dropdown, IconCode, IconPlus, Popover } from 'ui' +import { Button, Dropdown, IconPlus, Popover } from 'ui' +import ShimmeringLoader from 'components/ui/ShimmeringLoader' +import { useOrganizationsQuery } from 'data/organizations/organizations-query' import { useProjectsQuery } from 'data/projects/projects-query' import { useSelectedOrganization, useSelectedProject } from 'hooks' import { IS_PLATFORM, PROJECT_STATUS } from 'lib/constants' -import ShimmeringLoader from 'components/ui/ShimmeringLoader' import { Organization, Project } from 'types' -import { useOrganizationsQuery } from 'data/organizations/organizations-query' -import { useProjectSubscriptionV2Query } from 'data/subscriptions/project-subscription-v2-query' // [Fran] the idea is to let users change projects without losing the current page, // but at the same time we need to redirect correctly between urls that might be @@ -73,20 +72,14 @@ const ProjectLink = ({ ) } -const ProjectDropdown = ({ alt }: { alt?: boolean }) => { +const ProjectDropdown = () => { const selectedProject = useSelectedProject() const selectedOrganization = useSelectedOrganization() const { data: allProjects, isLoading: isLoadingProjects } = useProjectsQuery() const { data: allOrganizations } = useOrganizationsQuery() const selectedOrganizationSlug = selectedOrganization?.slug - const isOrgBilling = !!selectedOrganization?.subscription_id - const { data: subscription, isSuccess } = useProjectSubscriptionV2Query( - { projectRef: selectedProject?.ref }, - { enabled: alt && !isOrgBilling } - ) - - if (isLoadingProjects && alt) { + if (isLoadingProjects) { return } @@ -112,23 +105,13 @@ const ProjectDropdown = ({ alt }: { alt?: boolean }) => { } > - ) : ( ) } diff --git a/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.tsx b/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.tsx index 4efb7bf31a4..e71688941c1 100644 --- a/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.tsx +++ b/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.tsx @@ -49,8 +49,8 @@ const NavigationBar = ({}) => { ].join(' ')} >
      - {!navLayoutV2 && ( - + {(!navLayoutV2 || !IS_PLATFORM) && ( + code.replace(/svg/, 'svg class="m-auto text-color-inherit"')} /> ), - link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/sql`), + link: !IS_PLATFORM + ? `/project/${ref}/sql/1` + : ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/sql`), }, ] } diff --git a/studio/components/layouts/ProjectLayout/ProjectLayout.tsx b/studio/components/layouts/ProjectLayout/ProjectLayout.tsx index 4b23582c1ef..0ce4030e476 100644 --- a/studio/components/layouts/ProjectLayout/ProjectLayout.tsx +++ b/studio/components/layouts/ProjectLayout/ProjectLayout.tsx @@ -5,7 +5,7 @@ import { Fragment, PropsWithChildren, ReactNode } from 'react' import { useParams } from 'common/hooks' import Connecting from 'components/ui/Loading' import { useFlag, useSelectedOrganization, useSelectedProject, withAuth } from 'hooks' -import { PROJECT_STATUS } from 'lib/constants' +import { PROJECT_STATUS, IS_PLATFORM } from 'lib/constants' import BuildingState from './BuildingState' import ConnectingState from './ConnectingState' import LayoutHeader from './LayoutHeader' @@ -29,6 +29,8 @@ const routesToIgnoreProjectDetailsRequest = [ '/project/[ref]/settings/billing/invoices', ] +const routesToIgnoreDBConnection = ['/project/[ref]/branches'] + const routesToIgnorePostgrestConnection = [ '/project/[ref]/reports', '/project/[ref]/settings/general', @@ -101,7 +103,7 @@ const ProjectLayout = ({ )}
      - {!navLayoutV2 && !hideHeader && } + {!navLayoutV2 && !hideHeader && IS_PLATFORM && } {showPausedState ? (
      @@ -161,8 +163,9 @@ const ContentWrapper = ({ isLoading, children }: ContentWrapperProps) => { const router = useRouter() const requiresDbConnection: boolean = - !router.pathname.includes('/project/[ref]/settings') || - router.pathname.includes('/project/[ref]/settings/vault') + (!router.pathname.includes('/project/[ref]/settings') && + !routesToIgnoreDBConnection.includes(router.pathname)) || + router.pathname === '/project/[ref]/settings/vault' const requiresPostgrestConnection = !routesToIgnorePostgrestConnection.includes(router.pathname) const requiresProjectDetails = !routesToIgnoreProjectDetailsRequest.includes(router.pathname) @@ -236,7 +239,7 @@ export const ProjectLayoutNonBlocking = ({ )}
      - {!navLayoutV2 && !hideHeader && } + {!navLayoutV2 && !hideHeader && IS_PLATFORM && } {showPausedState ? (
      diff --git a/studio/components/ui/OrganizationPicker.tsx b/studio/components/ui/OrganizationPicker.tsx deleted file mode 100644 index c6308ef4a6b..00000000000 --- a/studio/components/ui/OrganizationPicker.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { useIntegrationsQuery } from 'data/integrations/integrations-query' -import { IntegrationName } from 'data/integrations/integrations.types' -import { useOrganizationsQuery } from 'data/organizations/organizations-query' -import { useState } from 'react' -import { Organization } from 'types' -import { Badge, IconHexagon, Listbox } from 'ui' - -export interface OrganizationPickerProps { - label?: string - onSelectedOrgChange?: (org: Organization) => void - integrationName: IntegrationName -} - -const OrganizationPicker = ({ - label = 'Choose an organization', - onSelectedOrgChange, - integrationName, -}: OrganizationPickerProps) => { - const [selectedOrg, setSelectedOrg] = useState(null) - - /** - * array of integrations installed on all - */ - const { data: integrationData, isLoading: integrationDataLoading } = useIntegrationsQuery() - - /** - * filter integrations to match integrationName - */ - const vercelIntegrationsInstalled = integrationData?.filter( - (integration) => integration.integration.name === 'Vercel' - ) - - const { data, isLoading } = useOrganizationsQuery({ - onSuccess(organizations) { - const firstOrg = organizations?.[0] - if (firstOrg && selectedOrg === null) { - setSelectedOrg(firstOrg) - onSelectedOrgChange?.(firstOrg) - } - }, - }) - - function _onSelectedOrgChange(slug: string) { - const org = data?.find((org) => org.slug === slug) - - if (org) { - setSelectedOrg(org) - onSelectedOrgChange?.(org) - } - } - - if (isLoading || integrationDataLoading) { - return ( - - - Loading... - - - ) - } - - /** - * Organization type with `installationInstalled` added - */ - interface OrganizationsResponseWithInstalledData extends Organization { - installationInstalled?: boolean - } - - /** - * A flat array of org slugs that have integration installed - */ - const flatInstalledConnectionsIds = - integrationData && integrationData.length > 0 - ? integrationData?.map((x) => x.organization.slug) - : [] - - /** - * Organizations with extra `installationInstalled` attribute - * Used to show label/badge and allow/disallow installing - */ - const organizationsWithInstalledData: OrganizationsResponseWithInstalledData[] = data - ? data.map((org) => { - return { - ...org, - installationInstalled: !flatInstalledConnectionsIds.includes(org.slug) ? true : false, - } - }) - : [] - - return ( - - {organizationsWithInstalledData?.map((org) => { - const label = ( -
      - {org.name} - {org.installationInstalled && Integration Installed} -
      - ) - return ( - } - > - {label} - - ) - })} -
      - ) -} - -export default OrganizationPicker diff --git a/studio/components/ui/UpgradeToPro.tsx b/studio/components/ui/UpgradeToPro.tsx index 9c36fb4b2cf..3436f9cc8a2 100644 --- a/studio/components/ui/UpgradeToPro.tsx +++ b/studio/components/ui/UpgradeToPro.tsx @@ -12,9 +12,10 @@ interface Props { primaryText: string projectRef: string secondaryText: string + addon?: 'pitr' | 'customDomain' | 'computeInstance' } -const UpgradeToPro: FC = ({ icon, primaryText, projectRef, secondaryText }) => { +const UpgradeToPro: FC = ({ icon, primaryText, projectRef, secondaryText, addon }) => { const { data: subscription } = useProjectSubscriptionV2Query({ projectRef }) const plan = subscription?.plan?.id @@ -45,8 +46,8 @@ const UpgradeToPro: FC = ({ icon, primaryText, projectRef, secondaryText
      +
      + + + + + + + + + + )} + + +
      + +
      + Docs +
      +
      + Support +
      +
      + + ) +} + +GitHubIntegration.getLayout = (page) => ( + {page} +) + +export default GitHubIntegration diff --git a/studio/pages/integrations/vercel.tsx b/studio/pages/integrations/vercel.tsx deleted file mode 100644 index 9b2a5ac0c5a..00000000000 --- a/studio/pages/integrations/vercel.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { useEffect } from 'react' - -import IntegrationWindowLayout from 'components/layouts/IntegrationWindowLayout' -import { useRouter } from 'next/router' -import { NextPageWithLayout } from 'types' - -const VercelIntegration: NextPageWithLayout = () => { - const router = useRouter() - - useEffect(() => { - router.push({ pathname: '/integrations/vercel/install', query: router.query }) - }, [router]) - - return <> -} - -VercelIntegration.getLayout = (page) => {page} - -export default VercelIntegration diff --git a/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx b/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx index 77755e83f76..248126f21ff 100644 --- a/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx +++ b/studio/pages/integrations/vercel/[slug]/deploy-button/new-project.tsx @@ -5,15 +5,14 @@ import { ChangeEvent, useRef, useState } from 'react' import { useParams } from 'common' import { Markdown } from 'components/interfaces/Markdown' -import IntegrationWindowLayout from 'components/layouts/IntegrationWindowLayout' +import { VercelIntegrationLayout } from 'components/layouts' import { ScaffoldContainer, ScaffoldDivider } from 'components/layouts/Scaffold' import PasswordStrengthBar from 'components/ui/PasswordStrengthBar' import { useProjectApiQuery } from 'data/config/project-api-query' -import { useIntegrationConnectionsCreateMutation } from 'data/integrations/integration-connections-create-mutation' import { useIntegrationsQuery } from 'data/integrations/integrations-query' import { useIntegrationsVercelConnectionSyncEnvsMutation } from 'data/integrations/integrations-vercel-connection-sync-envs-mutation' +import { useIntegrationVercelConnectionsCreateMutation } from 'data/integrations/integrations-vercel-connections-create-mutation' import { useVercelProjectsQuery } from 'data/integrations/integrations-vercel-projects-query' -import { Integration } from 'data/integrations/integrations.types' import { useOrganizationsQuery } from 'data/organizations/organizations-query' import { useProjectCreateMutation } from 'data/projects/project-create-mutation' import { useSelectedOrganization, useStore } from 'hooks' @@ -62,7 +61,7 @@ const VercelIntegration: NextPageWithLayout = () => { ) } -VercelIntegration.getLayout = (page) => {page} +VercelIntegration.getLayout = (page) => {page} const CreateProject = ({ loading, @@ -94,7 +93,7 @@ const CreateProject = ({ } = useParams() const { mutateAsync: createConnections, isLoading: isLoadingCreateConnections } = - useIntegrationConnectionsCreateMutation() + useIntegrationVercelConnectionsCreateMutation() const { mutateAsync: syncEnvs } = useIntegrationsVercelConnectionSyncEnvsMutation() const { data: organizationData, isLoading: isLoadingOrganizationsQuery } = useOrganizationsQuery() @@ -108,9 +107,7 @@ const CreateProject = ({ /** * the vercel integration installed for organization chosen */ - const organizationIntegration: Integration | undefined = integrationData?.find( - (x) => x.organization.slug === slug - ) + const organizationIntegration = integrationData?.find((x) => x.organization.slug === slug) /** * Vercel projects available for this integration diff --git a/studio/pages/integrations/vercel/[slug]/marketplace/choose-project.tsx b/studio/pages/integrations/vercel/[slug]/marketplace/choose-project.tsx index fa67ca265bb..9e302c67622 100644 --- a/studio/pages/integrations/vercel/[slug]/marketplace/choose-project.tsx +++ b/studio/pages/integrations/vercel/[slug]/marketplace/choose-project.tsx @@ -1,23 +1,32 @@ -import { useMemo, useState } from 'react' +import { keyBy } from 'lodash' +import { useCallback, useMemo } from 'react' +import { toast } from 'react-hot-toast' import { useParams } from 'common' -import ProjectLinker from 'components/interfaces/Integrations/ProjectLinker' +import { ENV_VAR_RAW_KEYS } from 'components/interfaces/Integrations/Integrations-Vercel.constants' +import ProjectLinker, { ForeignProject } from 'components/interfaces/Integrations/ProjectLinker' import { Markdown } from 'components/interfaces/Markdown' -import IntegrationWindowLayout from 'components/layouts/IntegrationWindowLayout' +import { VercelIntegrationLayout } from 'components/layouts' import { ScaffoldColumn, ScaffoldContainer, ScaffoldDivider } from 'components/layouts/Scaffold' +import { vercelIcon } from 'components/to-be-cleaned/ListIcons' import { useOrgIntegrationsQuery } from 'data/integrations/integrations-query-org-only' +import { useIntegrationsVercelConnectionSyncEnvsMutation } from 'data/integrations/integrations-vercel-connection-sync-envs-mutation' +import { useIntegrationVercelConnectionsCreateMutation } from 'data/integrations/integrations-vercel-connections-create-mutation' import { useVercelProjectsQuery } from 'data/integrations/integrations-vercel-projects-query' import { useOrganizationsQuery } from 'data/organizations/organizations-query' import { useProjectsQuery } from 'data/projects/projects-query' +import { BASE_PATH } from 'lib/constants' import { EMPTY_ARR } from 'lib/void' import { NextPageWithLayout, Organization } from 'types' import { IconBook, IconLifeBuoy, LoadingLine } from 'ui' +const VERCEL_ICON = ( + Vercel Icon +) + const VercelIntegration: NextPageWithLayout = () => { const { slug, configurationId, next, organizationSlug } = useParams() - const [loading, setLoading] = useState(false) - /** * Fetch the list of organization based integration installations for Vercel. * @@ -33,7 +42,12 @@ const VercelIntegration: NextPageWithLayout = () => { const organization = data?.find((organization: Organization) => organization.slug === slug) - const integration = integrationData?.find((x) => x.metadata?.configuration_id === configurationId) + const integration = integrationData?.find( + (x) => + x.metadata !== undefined && + 'configuration_id' in x.metadata && + x.metadata?.configuration_id === configurationId + ) const { data: supabaseProjectsData, isLoading: isLoadingSupabaseProjectsData } = useProjectsQuery( { @@ -47,7 +61,7 @@ const VercelIntegration: NextPageWithLayout = () => { ?.filter((project) => project.organization_id === organization?.id) .map((project) => ({ id: project.id.toString(), name: project.name, ref: project.ref })) ?? EMPTY_ARR, - [organizationSlug, supabaseProjectsData] + [organization?.id, supabaseProjectsData] ) const { data: vercelProjectsData, isLoading: isLoadingVercelProjectsData } = @@ -59,10 +73,65 @@ const VercelIntegration: NextPageWithLayout = () => { ) const vercelProjects = useMemo(() => vercelProjectsData ?? EMPTY_ARR, [vercelProjectsData]) + const vercelProjectsById = useMemo(() => keyBy(vercelProjects, 'id'), [vercelProjects]) + + const getForeignProjectIcon = useCallback( + (_project: ForeignProject) => { + const project = vercelProjectsById[_project.id] + + return !project?.framework ? ( + vercelIcon + ) : ( + {`icon`} + ) + }, + [vercelProjectsById] + ) + + const { mutateAsync: syncEnvs } = useIntegrationsVercelConnectionSyncEnvsMutation() + const { mutate: createConnections, isLoading: isCreatingConnection } = + useIntegrationVercelConnectionsCreateMutation({ + async onSuccess({ id }) { + try { + await syncEnvs({ connectionId: id }) + } catch (error: any) { + toast.error('Failed to sync environment variables: ', error.message) + } + + if (next) { + window.location.href = next + } + }, + }) + + const onCreateConnections = useCallback( + (vars) => { + createConnections({ + ...vars, + connection: { + ...vars.connection, + metadata: { + ...vars.connection.metadata, + supabaseConfig: { + projectEnvVars: { + write: true, + }, + }, + }, + }, + }) + }, + [createConnections] + ) return ( <> - +
      <> @@ -78,21 +147,37 @@ This Supabase integration manages your environment variables automatically to pr `} /> + { + onCreateConnections={onCreateConnections} + installedConnections={integration?.connections} + isLoading={isCreatingConnection} + integrationIcon={VERCEL_ICON} + getForeignProjectIcon={getForeignProjectIcon} + choosePrompt="Choose Vercel Project" + onSkip={() => { if (next) { window.location.href = next } }} - installedConnections={integration?.connections} - setLoading={setLoading} - showSkip={true} loadingForeignProjects={isLoadingVercelProjectsData} loadingSupabaseProjects={isLoadingSupabaseProjectsData} /> + { + return ` + \n + - \`${x}\` +` +})} +`} + /> @@ -111,6 +196,6 @@ This Supabase integration manages your environment variables automatically to pr ) } -VercelIntegration.getLayout = (page) => {page} +VercelIntegration.getLayout = (page) => {page} export default VercelIntegration diff --git a/studio/pages/integrations/vercel/install.tsx b/studio/pages/integrations/vercel/install.tsx index a323420f3d5..95419081f5b 100644 --- a/studio/pages/integrations/vercel/install.tsx +++ b/studio/pages/integrations/vercel/install.tsx @@ -1,47 +1,30 @@ +import { useRouter } from 'next/router' +import { useEffect, useMemo, useState } from 'react' +import { toast } from 'react-hot-toast' + import { useParams } from 'common' +import OrganizationPicker from 'components/interfaces/Integrations/OrganizationPicker' import { Markdown } from 'components/interfaces/Markdown' -import IntegrationWindowLayout from 'components/layouts/IntegrationWindowLayout' +import { getHasInstalledObject } from 'components/layouts/IntegrationsLayout/Integrations.utils' +import VercelIntegrationWindowLayout from 'components/layouts/IntegrationsLayout/VercelIntegrationWindowLayout' import { ScaffoldColumn, ScaffoldContainer, ScaffoldDivider } from 'components/layouts/Scaffold' import { useIntegrationsQuery } from 'data/integrations/integrations-query' -import { IntegrationName } from 'data/integrations/integrations.types' import { useVercelIntegrationCreateMutation } from 'data/integrations/vercel-integration-create-mutation' import { useOrganizationsQuery } from 'data/organizations/organizations-query' import { useStore } from 'hooks' -import { useRouter } from 'next/router' -import { useEffect, useRef, useState } from 'react' -import { useVercelIntegrationInstallationState } from 'state/vercel-integration-installation' import { NextPageWithLayout, Organization } from 'types' import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, - Badge, Button, - CommandEmpty_Shadcn_, - CommandGroup_Shadcn_, - CommandInput_Shadcn_, - CommandItem_Shadcn_, - CommandList_Shadcn_, - Command_Shadcn_, IconAlertTriangle, IconBook, - IconChevronDown, - IconHexagon, IconInfo, IconLifeBuoy, LoadingLine, - PopoverContent_Shadcn_, - PopoverTrigger_Shadcn_, - Popover_Shadcn_, } from 'ui' -/** - * Organization type with `installationInstalled` added - */ -interface OrganizationsResponseWithInstalledData extends Organization { - installationInstalled?: boolean -} - /** * Variations of the Vercel integration flow. * They require different UI and logic. @@ -55,71 +38,49 @@ export type VercelIntegrationFlow = 'deploy-button' | 'marketing' const VercelIntegration: NextPageWithLayout = () => { const router = useRouter() const { ui } = useStore() - const { code, configurationId, next, teamId, source, externalId } = useParams() - const [selectedOrg, setSelectedOrg] = useState( - null - ) + const { code, configurationId, teamId, source, externalId } = useParams() + const [selectedOrg, setSelectedOrg] = useState(null) const [organizationIntegrationId, setOrganizationIntegrationId] = useState(null) - const snapshot = useVercelIntegrationInstallationState() - - const flow: VercelIntegrationFlow = externalId ? 'marketing' : 'deploy-button' - /** * Fetch the list of organization based integration installations for Vercel. * * Array of integrations installed on all */ - const { data: integrationData, isLoading: integrationDataLoading } = useIntegrationsQuery() + const { data: integrationData } = useIntegrationsQuery() const { data: organizationsData, isLoading: isLoadingOrganizationsQuery } = useOrganizationsQuery() useEffect(() => { if (organizationsData !== undefined && integrationData !== undefined) { - const firstOrg = organizationsWithInstalls(organizationsData)?.[0] + const firstOrg = organizationsData[0] if (firstOrg && selectedOrg === null) { setSelectedOrg(firstOrg) - snapshot.setSelectedOrganizationSlug(firstOrg.slug) router.query.organizationSlug = firstOrg.slug } } }, [organizationsData, integrationData]) - /** - * Find installedConnections - */ - function organizationsWithInstalls(organizations?: OrganizationsResponseWithInstalledData[]) { - /** - * Flat array of org slugs that have integration installed - * - */ - const flatInstalledConnectionsIds: string[] | [] = - integrationData && integrationData.length > 0 - ? integrationData - .filter((x) => x.integration.name === 'Vercel') - .map((x) => x.organization.slug) - : [] - - return organizations - ? organizations?.map((org) => { - return { - ...org, - installationInstalled: flatInstalledConnectionsIds.includes(org.slug) ? true : false, - } - }) - : [] - } - /** * Organizations with extra `installationInstalled` attribute * * Used to show label/badge and allow/disallow installing * */ - const organizationsWithInstalledData: OrganizationsResponseWithInstalledData[] = - organizationsWithInstalls(organizationsData) + const installed = useMemo( + () => + integrationData && organizationsData + ? getHasInstalledObject({ + integrationName: 'Vercel', + integrationData, + organizationsData, + installationId: configurationId, + }) + : {}, + [configurationId, integrationData, organizationsData] + ) /** * Handle the correct route change based on whether the vercel integration @@ -145,26 +106,19 @@ const VercelIntegration: NextPageWithLayout = () => { const { mutate, isLoading: isLoadingVercelIntegrationCreateMutation } = useVercelIntegrationCreateMutation({ onSuccess({ id }) { - const orgSlug = selectedOrg?.slug - setOrganizationIntegrationId(id) handleRouteChange() }, - onError(error: any) { - ui.setNotification({ - category: 'error', - message: `Creating Vercel integration failed: ${error.message}`, - }) + onError(error) { + toast.error(`Creating Vercel integration failed: ${error.message}`) }, }) function onInstall() { const orgSlug = selectedOrg?.slug - const isIntegrationInstalled = organizationsWithInstalledData.some( - (x) => x.slug === orgSlug && x.installationInstalled - ) + const isIntegrationInstalled = orgSlug ? installed[orgSlug] : false if (!orgSlug) { return ui.setNotification({ category: 'error', message: 'Please select an organization' }) @@ -186,7 +140,7 @@ const VercelIntegration: NextPageWithLayout = () => { } /** - * Only install if instgration hasn't already been installed + * Only install if integration hasn't already been installed */ if (!isIntegrationInstalled) { mutate({ @@ -207,13 +161,13 @@ const VercelIntegration: NextPageWithLayout = () => { const disableInstallationForm = (isLoadingVercelIntegrationCreateMutation && !dataLoading) || // disables installation button if integration is already installed and it is Marketplace flow - (selectedOrg && selectedOrg?.installationInstalled && source === 'marketplace' && !dataLoading) + (selectedOrg && installed[selectedOrg.slug] && source === 'marketplace' && !dataLoading) ? true : false return ( <> -
      +
      {organizationIntegrationId === null && ( <> @@ -224,17 +178,12 @@ const VercelIntegration: NextPageWithLayout = () => { { - const org = organizationsWithInstalledData?.find((org) => org.slug === e) - - if (org) { - setSelectedOrg(org) - router.query.organizationSlug = org.slug - } - }} selectedOrg={selectedOrg} - dataLoading={dataLoading} + onSelectedOrgChange={(org) => { + setSelectedOrg(org) + router.query.organizationSlug = org.slug + }} + configurationId={configurationId} /> {disableInstallationForm && ( @@ -251,7 +200,7 @@ const VercelIntegration: NextPageWithLayout = () => { - - - - - - No results found. - - {organizationsWithInstalledData?.map((org) => { - return ( - { - if (slug) onSelectedOrgChange(slug) - setOpen(false) - }} - > - - {org.name}{' '} - {org?.installationInstalled && ( - - Integration Installed - - )} - - ) - })} - - - - - - - ) -} +VercelIntegration.getLayout = (page) => ( + {page} +) export default VercelIntegration diff --git a/studio/pages/project/[ref]/auth/providers.tsx b/studio/pages/project/[ref]/auth/providers.tsx index 982b2a7e4e8..1ddd4ccc705 100644 --- a/studio/pages/project/[ref]/auth/providers.tsx +++ b/studio/pages/project/[ref]/auth/providers.tsx @@ -1,10 +1,8 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { observer } from 'mobx-react-lite' -import { useEffect } from 'react' import { AuthProvidersForm } from 'components/interfaces' import { AuthLayout } from 'components/layouts' -import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { FormsContainer } from 'components/ui/Forms' import NoPermission from 'components/ui/NoPermission' import { useCheckPermissions, useStore } from 'hooks' @@ -12,12 +10,6 @@ import { NextPageWithLayout } from 'types' const PageLayout: NextPageWithLayout = () => { const { authConfig } = useStore() - const { project } = useProjectContext() - - useEffect(() => { - authConfig.load() - }, [project?.ref]) - const canReadAuthSettings = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue') if (!canReadAuthSettings) { diff --git a/studio/pages/project/[ref]/auth/templates.tsx b/studio/pages/project/[ref]/auth/templates.tsx index da945c6e87c..fe0f5d01b77 100644 --- a/studio/pages/project/[ref]/auth/templates.tsx +++ b/studio/pages/project/[ref]/auth/templates.tsx @@ -12,12 +12,6 @@ import { NextPageWithLayout } from 'types' const PageLayout: NextPageWithLayout = () => { const { authConfig } = useStore() - const { project } = useProjectContext() - - useEffect(() => { - authConfig.load() - }, [project?.ref]) - const canReadAuthSettings = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue') if (!canReadAuthSettings) { diff --git a/studio/pages/project/[ref]/auth/url-configuration.tsx b/studio/pages/project/[ref]/auth/url-configuration.tsx index a855a371b95..896c81a5225 100644 --- a/studio/pages/project/[ref]/auth/url-configuration.tsx +++ b/studio/pages/project/[ref]/auth/url-configuration.tsx @@ -1,11 +1,9 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { observer } from 'mobx-react-lite' -import { useEffect } from 'react' import RedirectUrls from 'components/interfaces/Auth/RedirectUrls/RedirectUrls' import SiteUrl from 'components/interfaces/Auth/SiteUrl/SiteUrl' import { AuthLayout } from 'components/layouts' -import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { FormsContainer } from 'components/ui/Forms' import NoPermission from 'components/ui/NoPermission' import { useCheckPermissions, useStore } from 'hooks' @@ -13,12 +11,6 @@ import { NextPageWithLayout } from 'types' const URLConfiguration: NextPageWithLayout = () => { const { authConfig } = useStore() - const { project } = useProjectContext() - - useEffect(() => { - authConfig.load() - }, [project?.ref]) - const canReadAuthSettings = useCheckPermissions(PermissionAction.READ, 'custom_config_gotrue') if (!canReadAuthSettings) { @@ -31,7 +23,7 @@ const URLConfiguration: NextPageWithLayout = () => { ) } else { - return
      + return null } } diff --git a/studio/pages/project/[ref]/branches.tsx b/studio/pages/project/[ref]/branches.tsx new file mode 100644 index 00000000000..bee6a18c20a --- /dev/null +++ b/studio/pages/project/[ref]/branches.tsx @@ -0,0 +1,17 @@ +import BranchManagement from 'components/interfaces/BranchManagement/BranchManagement' +import { ProjectLayoutWithAuth } from 'components/layouts' +import { NextPageWithLayout } from 'types' + +const BranchManagementPage: NextPageWithLayout = () => { + return +} + +BranchManagementPage.getLayout = (page) => ( + +
      + {page} +
      +
      +) + +export default BranchManagementPage diff --git a/studio/pages/project/[ref]/database/backups/pitr.tsx b/studio/pages/project/[ref]/database/backups/pitr.tsx index 7b6a865f9d1..10021383c5e 100644 --- a/studio/pages/project/[ref]/database/backups/pitr.tsx +++ b/studio/pages/project/[ref]/database/backups/pitr.tsx @@ -83,6 +83,7 @@ const PITR = observer(() => { ? 'Upgrade to the Pro plan with the PITR add-on selected to enable point in time recovery for your project.' : 'Please enable the add-on to enable point in time recovery for your project.' } + addon="pitr" /> ) } diff --git a/studio/pages/project/[ref]/index.tsx b/studio/pages/project/[ref]/index.tsx index 0653c6a1261..06b56fd2a10 100644 --- a/studio/pages/project/[ref]/index.tsx +++ b/studio/pages/project/[ref]/index.tsx @@ -2,14 +2,14 @@ import { ClientLibrary, ExampleProject } from 'components/interfaces/Home' import { CLIENT_LIBRARIES, EXAMPLE_PROJECTS } from 'components/interfaces/Home/Home.constants' import ProjectUsageSection from 'components/interfaces/Home/ProjectUsageSection' import { ProjectLayoutWithAuth } from 'components/layouts' -import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import ProjectPausedState from 'components/layouts/ProjectLayout/ProjectPausedState' import ProjectUpgradeFailedBanner from 'components/ui/ProjectUpgradeFailedBanner' +import { useSelectedProject } from 'hooks' import { IS_PLATFORM, PROJECT_STATUS } from 'lib/constants' import { NextPageWithLayout } from 'types' const Home: NextPageWithLayout = () => { - const { project } = useProjectContext() + const project = useSelectedProject() const projectName = project?.ref !== 'default' && project?.name !== undefined diff --git a/studio/pages/project/[ref]/settings/billing/invoices.tsx b/studio/pages/project/[ref]/settings/billing/invoices.tsx index fbb9b8ee7ea..e44997f57bc 100644 --- a/studio/pages/project/[ref]/settings/billing/invoices.tsx +++ b/studio/pages/project/[ref]/settings/billing/invoices.tsx @@ -5,29 +5,21 @@ import { SettingsLayout } from 'components/layouts' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { useSelectedOrganization } from 'hooks' import { NextPageWithLayout } from 'types' -import { Alert } from 'ui' +import { useRouter } from 'next/router' +import { useEffect } from 'react' const ProjectBilling: NextPageWithLayout = () => { const organization = useSelectedOrganization() const isOrgBilling = !!organization?.subscription_id + const router = useRouter() - if (isOrgBilling) { - return ( -
      - - You might be looking for the{' '} - - organization's invoices - {' '} - page instead. - -
      - ) - } + useEffect(() => { + if (isOrgBilling) { + router.push(`/org/${organization.slug}/invoices`) + } + }, [router, organization?.slug, isOrgBilling]) + + if (isOrgBilling) return null return (
      diff --git a/studio/pages/project/[ref]/settings/billing/subscription.tsx b/studio/pages/project/[ref]/settings/billing/subscription.tsx index dd61dbffd76..b1c2a7a5f2c 100644 --- a/studio/pages/project/[ref]/settings/billing/subscription.tsx +++ b/studio/pages/project/[ref]/settings/billing/subscription.tsx @@ -1,31 +1,44 @@ -import Link from 'next/link' - import SubscriptionV2 from 'components/interfaces/BillingV2/Subscription/Subscription' import { SettingsLayout } from 'components/layouts' import { useSelectedOrganization } from 'hooks' import { NextPageWithLayout } from 'types' -import { Alert } from 'ui' +import { useRouter } from 'next/router' +import { useEffect } from 'react' const ProjectBilling: NextPageWithLayout = () => { const organization = useSelectedOrganization() const isOrgBilling = !!organization?.subscription_id + const router = useRouter() + useEffect(() => { + if (isOrgBilling) { + const { ref, panel } = router.query + let redirectUri = `/org/${organization.slug}/billing` + switch (panel) { + case 'subscriptionPlan': + redirectUri = `/org/${organization.slug}/billing?panel=subscriptionPlan` + break + case 'costControl': + redirectUri = `/org/${organization.slug}/billing?panel=costControl` + break + case 'computeInstance': + redirectUri = `/project/${ref}/settings/addons?panel=computeInstance` + break + case 'pitr': + redirectUri = `/project/${ref}/settings/addons?panel=pitr` + break + case 'customDomain': + redirectUri = `/project/${ref}/settings/addons?panel=customDomain` + break + } + + router.push(redirectUri) + } + }, [router, organization?.slug, isOrgBilling]) + + // No need to bother rendering, we'll redirect anyway if (isOrgBilling) { - return ( -
      - - Subscription management can be found on the{' '} - - organization's billing - {' '} - page instead. - -
      - ) + return null } return diff --git a/studio/pages/project/[ref]/settings/general.tsx b/studio/pages/project/[ref]/settings/general.tsx index ca94e4d7bbd..9db217b9dff 100644 --- a/studio/pages/project/[ref]/settings/general.tsx +++ b/studio/pages/project/[ref]/settings/general.tsx @@ -1,16 +1,19 @@ -import { NextPageWithLayout } from 'types' -import { SettingsLayout } from 'components/layouts' import { - General, - Infrastructure, CustomDomainConfig, DeleteProjectPanel, + General, + Infrastructure, TransferProjectPanel, } from 'components/interfaces/Settings/General' +import { SettingsLayout } from 'components/layouts' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { useFlag, useSelectedOrganization } from 'hooks' +import { NextPageWithLayout } from 'types' const ProjectSettings: NextPageWithLayout = () => { + const { project } = useProjectContext() const organization = useSelectedOrganization() + const isBranch = !!project?.parent_project_ref const isOrgBilling = !!organization?.subscription_id const transferProjectEnabled = useFlag('transferProject') @@ -18,10 +21,14 @@ const ProjectSettings: NextPageWithLayout = () => { return (
      - {!isOrgBilling && } - - {transferProjectEnabled && } - + {!isBranch ? ( + <> + {!isOrgBilling && } + {} + {transferProjectEnabled && } + + + ) : null}
      ) } diff --git a/studio/public/img/icons/vercel-icon.svg b/studio/public/img/icons/vercel-icon.svg new file mode 100644 index 00000000000..ff2881f0c67 --- /dev/null +++ b/studio/public/img/icons/vercel-icon.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/studio/state/github-connection-config-panel.ts b/studio/state/github-connection-config-panel.ts deleted file mode 100644 index 78feb1a3615..00000000000 --- a/studio/state/github-connection-config-panel.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { proxy, snapshot, useSnapshot } from 'valtio' - -export const githubConnectionConfigPanelState = proxy({ - open: false as boolean, - setOpen: (open: boolean) => { - githubConnectionConfigPanelState.open = open - }, - organizationIntegrationId: undefined as string | undefined, - setOrganizationIntegrationId: (organizationIntegrationId: string) => { - githubConnectionConfigPanelState.organizationIntegrationId = organizationIntegrationId - }, -}) - -export const getGithubConnectionConfigPanelSnapshot = () => - snapshot(githubConnectionConfigPanelState) - -export const useGithubConnectionConfigPanelSnapshot = ( - options?: Parameters[1] -) => useSnapshot(githubConnectionConfigPanelState, options) diff --git a/studio/state/vercel-integration-installation.ts b/studio/state/vercel-integration-installation.ts deleted file mode 100644 index 737879f6fe1..00000000000 --- a/studio/state/vercel-integration-installation.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { proxy, snapshot, useSnapshot } from 'valtio' - -export const vercelIntegrationInstallationState = proxy({ - selectedOrganizationSlug: '' as string, - setSelectedOrganizationSlug: (selectedOrganizationSlug: string) => { - vercelIntegrationInstallationState.selectedOrganizationSlug = selectedOrganizationSlug - }, -}) - -export const getVercelIntegrationInstallationState = () => - snapshot(vercelIntegrationInstallationState) - -export const useVercelIntegrationInstallationState = ( - options?: Parameters[1] -) => useSnapshot(vercelIntegrationInstallationState, options) diff --git a/studio/stores/authConfig/ProjectAuthConfigStore.ts b/studio/stores/authConfig/ProjectAuthConfigStore.ts index 090b3d645d5..92c43762a59 100644 --- a/studio/stores/authConfig/ProjectAuthConfigStore.ts +++ b/studio/stores/authConfig/ProjectAuthConfigStore.ts @@ -60,13 +60,10 @@ export default class ProjectAuthConfigStore implements IProjectAuthConfigStore { } async fetchData() { - const headers = { - 'Content-Type': 'application/json', - } + const headers = { 'Content-Type': 'application/json' } const response = await get(this.baseUrl, { headers }) - if (response.error) { - throw response.error - } + + if (response.error) throw response.error this.data = response return response } @@ -80,7 +77,6 @@ export default class ProjectAuthConfigStore implements IProjectAuthConfigStore { this.state = LOADED return this.data } catch (e: any) { - console.error('Failed to load auth config', e.message) this.error = e this.state = ERROR } diff --git a/studio/tests/pages/projects/LogTable.test.js b/studio/tests/pages/projects/LogTable.test.js index 5e90b4b5482..ebedfab0bdf 100644 --- a/studio/tests/pages/projects/LogTable.test.js +++ b/studio/tests/pages/projects/LogTable.test.js @@ -26,7 +26,7 @@ test('can display log data', async () => { await screen.findByText(/something_value/) // render copy button - userEvent.click(await screen.findByText(/Copy/)) + userEvent.click(await screen.findByText('Copy')) await screen.findByText(/Copied/) }) diff --git a/studio/tests/pages/projects/logs-query.test.js b/studio/tests/pages/projects/logs-query.test.js index edb1c28c0f3..ef81e455226 100644 --- a/studio/tests/pages/projects/logs-query.test.js +++ b/studio/tests/pages/projects/logs-query.test.js @@ -178,7 +178,7 @@ test('bug: can edit query after selecting a log', async () => { const rowValue = await screen.findByText(/12345/) // row value // open up an show selection panel await userEvent.click(rowValue) - await screen.findByText(/Copy/) + await screen.findByText('Copy') // change the query let editor = container.querySelector('.monaco-editor') @@ -197,7 +197,7 @@ test('bug: can edit query after selecting a log', async () => { { timeout: 1000 } ) // closes the selection panel - await expect(screen.findByText(/Copy/)).rejects.toThrow() + await expect(screen.findByText('Copy')).rejects.toThrow() }) test('query warnings', async () => { diff --git a/studio/tests/unit/get-return-to-path.test.ts b/studio/tests/unit/get-return-to-path.test.ts index f7951444c17..5b11a40ae13 100644 --- a/studio/tests/unit/get-return-to-path.test.ts +++ b/studio/tests/unit/get-return-to-path.test.ts @@ -1,8 +1,9 @@ import { getReturnToPath } from 'lib/gotrue' +import { DEFAULT_HOME } from 'lib/constants' describe(`getReturnToPath`, () => { it(`returns to /projects when no fallback is provided`, () => { - expect(getReturnToPath()).toBe('/projects') + expect(getReturnToPath()).toBe(DEFAULT_HOME) }) it(`returns to /custom when fallback is provided`, () => { @@ -33,7 +34,7 @@ describe(`getReturnToPath`, () => { // @ts-ignore window.location = { search: `?returnTo=https://google.com` } - expect(getReturnToPath()).toBe('/projects') + expect(getReturnToPath()).toBe(DEFAULT_HOME) }) it(`does not allow XSS`, () => { @@ -42,7 +43,7 @@ describe(`getReturnToPath`, () => { // @ts-ignore window.location = { search: `?returnTo=javascript:alert(1)` } - expect(getReturnToPath()).toBe('/projects') + expect(getReturnToPath()).toBe(DEFAULT_HOME) }) it(`does not allow XSS with encoded characters`, () => { @@ -51,6 +52,6 @@ describe(`getReturnToPath`, () => { // @ts-ignore window.location = { search: `?returnTo=javascript%3Aalert%281%29` } - expect(getReturnToPath()).toBe('/projects') + expect(getReturnToPath()).toBe(DEFAULT_HOME) }) }) diff --git a/studio/types/base.ts b/studio/types/base.ts index 706ea36740d..dd506a691b1 100644 --- a/studio/types/base.ts +++ b/studio/types/base.ts @@ -22,6 +22,7 @@ export interface ProjectBase { region: string inserted_at: string subscription_id: string + preview_branch_refs: string[] } export interface Project extends ProjectBase { @@ -32,6 +33,7 @@ export interface Project extends ProjectBase { restUrl?: string lastDatabaseResizeAt?: string | null maxDatabasePreprovisionGb?: string | null + parent_project_ref?: string /** * postgrestStatus is available on client side only.