Merge branch 'master' of github.com:supabase/supabase

This commit is contained in:
Terry Sutton committed 2022-06-29 13:19:21 -02:30
commit 28f2e27bc6
61 files changed
+7305 -3051

No files matched your search

@@ -0,0 +1,73 @@
import { ReactElement, useState } from 'react'
import Head from 'next/head'
import NavBar from '../nav/NavBar'
import SideBar from '../nav/SideBar'
import Footer from '../Footer'
const DocsLayout = ({
meta,
children,
toc,
menuItems,
currentPage,
}: {
meta: { title: string; description: string }
children: string
toc: any
menuItems: any
currentPage: string
}) => {
const theme = 'okaidia'
return (
<>
<Head>
<title>{meta?.title} | Supabase</title>
<meta name="description" content={meta?.description} />
<meta content="width=device-width, initial-scale=1" name="viewport" />
<link rel="icon" href="/favicon.ico" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content={meta?.title} />
<meta property="og:description" content={meta?.description} />
<meta property="og:title" content={meta?.title} />
<link
rel="preload"
href="https://unpkg.com/prismjs@0.0.1/themes/prism-okaidia.css"
as="script"
/>
<link href={`https://unpkg.com/prismjs@0.0.1/themes/prism-${theme}.css`} rel="stylesheet" />
</Head>
<main>
<NavBar currentPage={currentPage} />
<div className="flex w-full flex-row">
<SideBar menuItems={menuItems} />
<div className="docs-width flex w-full grid-cols-12 justify-between p-4 pb-8">
<div className="w-full p-4">
<article className="prose dark:prose-dark dark:bg-scale-200 width-full">
{children}
</article>
</div>
{toc && (
<div className="prose border-scale-400 dark:bg-scale-200 thin-scrollbar table-of-contents-height w-1/4 border-l px-4">
<h5>On this page</h5>
<ul className="list-none pl-2 text-[0.8rem]">
{toc.json.map((item: any, i: number) => {
return (
<li key={i}>
<a href={`#${item.slug}`}>{item.content}</a>
</li>
)
})}
</ul>
</div>
)}
</div>
</div>
<Footer />
</main>
</>
)
}
export default DocsLayout
+7 -43
View File
@@ -1,24 +1,16 @@
import { ReactElement } from 'react'
import Head from 'next/head'
import NavBar from '../nav/NavBar'
import SideBar from '../nav/SideBar'
import Footer from '../Footer'
const DocsLayout = ({
meta,
const Layout = ({
children,
toc,
menuItems,
meta,
currentPage,
}: {
meta: { title: string; description: string }
children: ReactElement
toc?: any
menuItems: any
children: React.ReactNode
currentPage: string
}) => {
const theme = 'okaidia'
return (
<>
<Head>
@@ -30,44 +22,16 @@ const DocsLayout = ({
<meta property="og:site_name" content={meta?.title} />
<meta property="og:description" content={meta?.description} />
<meta property="og:title" content={meta?.title} />
<link
rel="preload"
href="https://unpkg.com/prismjs@0.0.1/themes/prism-okaidia.css"
as="script"
/>
<link href={`https://unpkg.com/prismjs@0.0.1/themes/prism-${theme}.css`} rel="stylesheet" />
</Head>
<main>
<NavBar currentPage={currentPage} />
<div className="flex w-full flex-row">
<SideBar menuItems={menuItems} />
<div className="docs-width flex w-full grid-cols-12 justify-between p-4 pb-8">
<div className="w-full p-4">
<article className="prose dark:prose-dark dark:bg-scale-200 width-full">
{children}
</article>
</div>
{toc && (
<div className="prose border-scale-400 dark:bg-scale-200 thin-scrollbar table-of-contents-height w-1/4 border-l px-4">
<h5>On this page</h5>
<ul className="list-none pl-2 text-[0.8rem]">
{toc.json.map((item: any, i: number) => {
return (
<li key={i}>
<a href={`#${item.slug}`}>{item.content}</a>
</li>
)
})}
</ul>
</div>
)}
</div>
</div>
<article className="text-scale-1200 mx-4 md:mx-8 xl:mx-auto xl:max-w-7xl">
{children}
</article>
<Footer />
</main>
</>
)
}
export default DocsLayout
export default Layout
+1
View File
@@ -29,6 +29,7 @@ const NavBar = ({ currentPage }: { currentPage: string }) => {
active: currentPage == 'Reference',
link: '/reference/javascript/supabase-client',
},
{ text: 'Discussions', active: currentPage == 'Discussions', link: '/discussions' },
]
return (
+53
View File
@@ -0,0 +1,53 @@
import Link from 'next/link'
import { IconArrowLeft, IconArrowRight } from '@supabase/ui'
const pagination = ({ currentPage, totalCount }: { currentPage: number; totalCount: number }) => {
// TODO: not sure if this is the most efficient way to do this. may need to refactor.
const totalArray = Array.from({ length: totalCount }, (_, i: number) => i + 1)
const pages = totalArray.filter((page: number) => {
return page >= currentPage - 2 && page <= currentPage + 2
})
return (
<ul className="flex justify-center space-x-1 text-xs font-medium">
<li>
<Link href={`/discussions?page=${currentPage - 1}`}>
<a className="border-scale-600 bg-scale-300 inline-flex h-8 w-8 items-center justify-center rounded border">
<IconArrowLeft
className="stroke-2 transition group-hover:-translate-x-1"
height={12.5}
/>
</a>
</Link>
</li>
{pages.map((page: number, i: number) => {
i = i + 1
return (
<li key={i}>
<Link href={`/discussions?page=${page}`}>
<a
className={`border-scale-600 inline-flex h-8 w-8 items-center justify-center rounded border ${
currentPage === page ? 'bg-brand-900' : 'bg-scale-300'
}`}
>
{page}
</a>
</Link>
</li>
)
})}
<li>
<Link href={`/discussions?page=${currentPage + 1}`}>
<a className="border-scale-600 bg-scale-300 inline-flex h-8 w-8 items-center justify-center rounded border">
<IconArrowRight
className="stroke-2 transition group-hover:-translate-x-1"
height={12.5}
/>
</a>
</Link>
</li>
</ul>
)
}
export default pagination
+3
View File
@@ -2,4 +2,7 @@ const withTM = require('next-transpile-modules')(['common'])
module.exports = withTM({
reactStrictMode: true,
images: {
domains: ['avatars.githubusercontent.com'],
},
})
+3
View File
@@ -9,12 +9,14 @@
"lint": "next lint"
},
"dependencies": {
"@apollo/client": "^3.6.6",
"@mdx-js/loader": "^1.6.22",
"@mdx-js/react": "^1.6.22",
"@next/mdx": "^12.0.4",
"@supabase/ui": "v0.37.0-alpha.50",
"@tailwindcss/typography": "^0.4.1",
"common": "*",
"graphql": "^16.5.0",
"gray-matter": "^4.0.3",
"markdown-toc": "^1.2.0",
"next": "12.1.0",
@@ -23,6 +25,7 @@
"react": "17.0.2",
"react-copy-to-clipboard": "^5.0.2",
"react-dom": "17.0.2",
"react-markdown": "^8.0.3",
"react-syntax-highlighter": "^15.3.1",
"remark": "^14.0.2",
"remark-html": "^15.0.0",
+1 -1
View File
@@ -1,5 +1,5 @@
import { getAllDocs, getDocsBySlug } from '../lib/docs'
import Layout from '../components/layouts/Layout'
import Layout from '../components/layouts/DocsLayout'
import { serialize } from 'next-mdx-remote/serialize'
import { MDXRemote } from 'next-mdx-remote'
import { MDXProvider } from '@mdx-js/react'
+287
View File
@@ -0,0 +1,287 @@
import { ApolloClient, createHttpLink, InMemoryCache, gql } from '@apollo/client'
import { setContext } from '@apollo/client/link/context'
import Layout from '../../components/layouts/Layout'
import { IconArrowLeft, Badge, Button, IconArrowUp, IconCheck } from '@supabase/ui'
import ReactMarkdown from 'react-markdown'
import Image from 'next/image'
import Link from 'next/link'
export async function getServerSideProps(context: any) {
const prams = context.params
const httpLink = createHttpLink({
uri: 'https://api.github.com/graphql',
})
const authLink = setContext((_, { Headers }) => {
return {
headers: {
Headers,
authorization: `Bearer ${process.env.GITHUB_ACCESS_TOKEN}`,
},
}
})
const client = new ApolloClient({
link: authLink.concat(httpLink),
cache: new InMemoryCache(),
})
const { data } = await client.query({
query: gql`
{
repository(owner: "supabase", name: "supabase") {
discussion(number: ${prams!.id}) {
id
number
title
upvoteCount
body
createdAt
answer {
isAnswer
}
author {
url
login
avatarUrl
}
category {
name
}
comments(first: 10) {
totalCount
nodes {
id
isAnswer
replies(first: 10) {
nodes {
author {
avatarUrl
login
url
}
body
createdAt
}
totalCount
}
author {
avatarUrl
login
url
}
body
createdAt
upvoteCount
}
}
}
}
}
`,
})
return {
props: {
data,
prams,
},
}
}
const discussion = ({ data, prams }: any) => {
const meta_description = 'Discuss Supabase with other developers and users.'
const getTimeSinceCreated = (createdAt: string) => {
const date = new Date(createdAt)
const now = new Date()
const timeSinceCreated = now.getTime() - date.getTime()
const days = Math.floor(timeSinceCreated / (1000 * 60 * 60 * 24))
const hours = Math.floor((timeSinceCreated % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
const minutes = Math.floor((timeSinceCreated % (1000 * 60 * 60)) / (1000 * 60))
const seconds = Math.floor((timeSinceCreated % (1000 * 60)) / 1000)
if (days < 7) {
if (days > 1) {
return `${days} days ago`
} else {
if (hours > 1) {
return `${hours} hours ago`
} else {
if (minutes > 1) {
return `${minutes} minutes ago`
} else {
return `${seconds} seconds ago`
}
}
}
} else {
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})
}
}
return (
<Layout
meta={{
title: data.repository.discussion.title,
description: meta_description,
}}
currentPage={'Discussions'}
>
<div className="my-8 space-y-8">
<div>
<div className="flex justify-between">
<Link href="/discussions">
<a className="text-scale-1100 group flex w-fit items-center text-sm">
<IconArrowLeft
className="stroke-2 transition group-hover:-translate-x-1"
height={15}
/>
Go Back
</a>
</Link>
<Link href={`https://github.com/supabase/supabase/discussions/${prams.id}`}>
<a target="_blank">
<Button className="text-base text-white">Open in Github</Button>
</a>
</Link>
</div>
<div className="mt-3 flex items-center text-3xl">
<span>{data.repository.discussion.title}</span>
<span className="text-scale-1000 ml-1">#{data.repository.discussion.number}</span>
</div>
<div className="text-scale-1100 mt-1 flex items-center space-x-2">
<Badge color={data.repository.discussion.answer?.isAnswer ? 'green' : 'gray'}>
{data.repository.discussion.answer?.isAnswer ? 'Answered' : 'Unanswered'}
</Badge>
<span>
<Link href={data.repository.discussion.author.url}>
<a target="_blank">{data.repository.discussion.author.login}</a>
</Link>
{` started this conversation in ${data.repository.discussion.category.name}`}
</span>
</div>
</div>
<div className="bg-scale-400 border-scale-600 space-y-4 rounded-lg border px-6 py-4">
<div className="flex items-center">
<Link href={data.repository.discussion.author.url}>
<a target="_blank" className="flex items-center">
<Image
className="rounded-full"
src={data.repository.discussion.author.avatarUrl}
width={30}
height={30}
/>
<span className="ml-2">{data.repository.discussion.author.login}</span>
</a>
</Link>
<span className="text-scale-1100 ml-2">{`on ${getTimeSinceCreated(
data.repository.discussion.createdAt
)}`}</span>
</div>
<div className="mt-4">
<ReactMarkdown>{data.repository.discussion.body}</ReactMarkdown>
</div>
<div className="item-center bg-scale-400 border-scale-600 flex h-fit w-fit items-center space-x-1 rounded-xl border py-1 pl-1 pr-2">
<IconArrowUp className="stroke-2" height={10} />
<span className="text-sm">{data.repository.discussion.upvoteCount}</span>
</div>
</div>
<div>{`${data.repository.discussion.comments.totalCount} comments`}</div>
<div className="space-y-8">
{data.repository.discussion.comments.nodes.map((comment: any) => {
return (
<div key={comment.id}>
<div
className={`bg-scale-400 space-y-4 border px-6 py-4 ${
comment.replies.totalCount > 0 ? 'rounded-t-lg' : 'rounded-lg'
} ${comment.isAnswer === true ? 'border-brand-900' : 'border-scale-600'}`}
>
<div className="flex items-center">
<Link href={comment.author.url}>
<a target="_blank" className="flex items-center">
<Image
className="rounded-full"
src={comment.author.avatarUrl}
width={30}
height={30}
/>
<span className="ml-2">{comment.author.login}</span>
</a>
</Link>
<span className="text-scale-1100 ml-2">{`on ${getTimeSinceCreated(
comment.createdAt
)}`}</span>
</div>
<div>
<ReactMarkdown>{comment.body}</ReactMarkdown>
</div>
<div className="flex items-center space-x-2">
{comment.isAnswer === true && (
<div className="bg-brand-900 rounded-full px-1.5 py-2 text-white">
<IconCheck className="stroke-2" height={15} />
</div>
)}
<div className="item-center bg-scale-400 border-scale-600 flex h-fit items-center space-x-1 rounded-xl border py-1 pl-1 pr-2">
<IconArrowUp className="stroke-2" height={10} />
<span className="text-sm">{comment.upvoteCount}</span>
</div>
</div>
</div>
{comment.replies.totalCount > 0 && (
<div className="border-scale-600 bg-scale-100 space-y-4 rounded-b-lg border p-4">
{comment.replies.nodes.map((reply: any, i: number) => {
const lastReply = i === comment.replies.nodes.length - 1
return (
<div
className={`space-y-4 pb-4 ${
lastReply === true ? null : 'border-scale-600 border-b'
}`}
key={i}
>
<div className="flex items-center">
<Link href={reply.author.url}>
<a target="_blank" className="flex items-center">
<Image
className="rounded-full"
src={reply.author.avatarUrl}
width={30}
height={30}
/>
<span className="ml-2">{reply.author.login}</span>
</a>
</Link>
<span className="text-scale-1100 ml-2">{`on ${getTimeSinceCreated(
reply.createdAt
)}`}</span>
</div>
<div>
<ReactMarkdown>{reply.body}</ReactMarkdown>
</div>
</div>
)
})}
</div>
)}
</div>
)
})}
</div>
<div className="mt-8">
<Link href={`https://github.com/supabase/supabase/discussions/${prams.id}`}>
<a target="_blank">
<Button className="text-base text-white">comment on Github</Button>
</a>
</Link>
</div>
</div>
</Layout>
)
}
export default discussion
+273
View File
@@ -0,0 +1,273 @@
import { ApolloClient, createHttpLink, InMemoryCache, gql } from '@apollo/client'
import { setContext } from '@apollo/client/link/context'
import Layout from '../../components/layouts/Layout'
import { IconArrowUp, IconCheckCircle } from '@supabase/ui'
import Image from 'next/image'
import Link from 'next/link'
import Pagination from '../../components/pagination'
export async function getServerSideProps(context: { query: { page: number } }) {
const Page: any = context.query?.page || 1
const page: number = parseInt(Page)
const httpLink = createHttpLink({
uri: 'https://api.github.com/graphql',
})
const authLink = setContext((_, { Headers }) => {
return {
headers: {
Headers,
authorization: `Bearer ${process.env.GITHUB_ACCESS_TOKEN}`,
},
}
})
const client = new ApolloClient({
link: authLink.concat(httpLink),
cache: new InMemoryCache(),
})
const { data } = await client.query({
query: gql`
{
repository(owner: "supabase", name: "supabase") {
pinnedDiscussions(first: 3) {
edges {
node {
id
discussion {
id
number
title
author {
url
avatarUrl
login
}
category {
id
emoji
name
}
}
}
}
totalCount
}
discussions(first: ${page}0) {
nodes {
id
number
createdAt
upvoteCount
title
body
comments(first: 3) {
totalCount
nodes {
id
author {
avatarUrl
login
}
}
}
category {
id
name
emoji
}
author {
login
url
avatarUrl
}
answer {
isAnswer
author {
avatarUrl
login
}
}
}
totalCount
}
}
}
`,
})
return {
props: {
data,
page,
},
}
}
const discussions = ({ data, page }: { data: any; page: number }) => {
const meta_title = 'Discussions'
const meta_description = 'Discuss Supabase with other developers and users.'
// TODO: This was a quick hack to show emojis in discussions. should be replaced with a proper solution.
const emojiInput = [':zap:', ':ship:', ':bulb:', ':question:', ':raised_hands:']
const emojiOutput = ['⚡️', '🚢', '💡', '❓', '🙌']
const getEmoji = (category: string) => {
const index = emojiInput.indexOf(category)
return emojiOutput[index]
}
const getTimeSinceCreated = (createdAt: string) => {
const date = new Date(createdAt)
const now = new Date()
const timeSinceCreated = now.getTime() - date.getTime()
const days = Math.floor(timeSinceCreated / (1000 * 60 * 60 * 24))
const hours = Math.floor((timeSinceCreated % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
const minutes = Math.floor((timeSinceCreated % (1000 * 60 * 60)) / (1000 * 60))
const seconds = Math.floor((timeSinceCreated % (1000 * 60)) / 1000)
if (days < 7) {
if (days > 1) {
return `${days} days ago`
} else {
if (hours > 1) {
return `${hours} hours ago`
} else {
if (minutes > 1) {
return `${minutes} minutes ago`
} else {
return `${seconds} seconds ago`
}
}
}
} else {
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})
}
}
return (
<Layout meta={{ title: meta_title, description: meta_description }} currentPage={'Discussions'}>
<div className="mb-16 space-y-8">
<div className="mt-4 flex items-center justify-between">
<h2 className="text-3xl md:text-4xl">Discussions</h2>
<Link href="https://github.com/supabase/supabase/discussions/new">
<a
target="_blank"
className="rounded-lg bg-[#34b17b] px-4 py-2 text-xs text-white transition duration-150 hover:bg-[#40cf8e] md:text-sm"
>
New Discussion
</a>
</Link>
</div>
<div className="justify-around space-y-4 md:flex md:space-y-0 md:space-x-4">
{data.repository.pinnedDiscussions.edges.map((discussion: any) => {
const item = discussion.node
return (
<div
className="border-scale-600 bg-scale-400 rounded-xl border px-4 py-4 md:w-1/3"
key={item.id}
>
<Link href={`/discussions/${item.discussion.number}`}>
<a className="space-y-4">
<div className="flex justify-end text-2xl">
{getEmoji(item.discussion.category.emoji)}
</div>
<div>
<p className="text-sm font-normal">{item.discussion.category.name}</p>
<h2 className="max-w-[350px] truncate text-xl font-semibold">
{item.discussion.title}
</h2>
</div>
<Link href={item.discussion.author.url}>
<a className="flex w-fit items-center space-x-2">
<Image
className="rounded-full"
src={item.discussion.author.avatarUrl}
height={30}
width={30}
/>
<span className="text-sm">{item.discussion.author.login}</span>
</a>
</Link>
</a>
</Link>
</div>
)
})}
</div>
<div className="space-y-6">
{data.repository.discussions.nodes
.slice(page * 10 - 10, page * 10)
.map((discussion: any) => {
return (
<div className="border-scale-600 border-b pb-6 md:pb-0" key={discussion.id}>
<Link href={`/discussions/${discussion.number}`}>
<a className="mb-6 items-center justify-between md:mx-6 md:flex">
<div className="flex items-center">
<div className="item-center flex items-center md:space-x-6">
<div className="item-center bg-scale-400 border-scale-600 hidden h-fit items-center space-x-1 rounded-xl border py-1 pl-1 pr-2 md:flex">
<IconArrowUp className="stroke-2" height={15} />
<span>{discussion.upvoteCount}</span>
</div>
<div className="rounded-lg bg-gray-400 px-3 py-2 md:px-5 md:py-4">
{getEmoji(discussion.category.emoji)}
</div>
</div>
<div className="ml-6">
<h2 className="text-base font-semibold md:max-w-sm md:text-lg lg:max-w-4xl">
{discussion.title}
</h2>
<span className="text-scale-1000 text-xs">
<Link href={discussion.author.url}>
<a>{discussion.author.login}</a>
</Link>
<span>{` asked ${getTimeSinceCreated(discussion.createdAt)} • `}</span>
<span className={discussion.answer?.isAnswer ? 'text-brand-900' : ''}>
{discussion.answer?.isAnswer ? 'Answered' : 'Unanswered'}
</span>
</span>
</div>
</div>
<div className="ml-16 mt-2 flex items-center space-x-2 md:ml-0 md:mt-0">
<div className="flex items-center -space-x-2">
<Link href={discussion.author.url}>
<a>
<Image
className="rounded-full"
src={discussion.author.avatarUrl}
height={30}
width={30}
/>
</a>
</Link>
</div>
<div
className={`item-center flex items-center space-x-1 ${
discussion.answer?.isAnswer ? 'text-brand-900' : ''
}`}
>
<IconCheckCircle className="stroke-2" height={15} />
<span>{discussion.comments.totalCount}</span>
</div>
</div>
</a>
</Link>
</div>
)
})}
</div>
<div>
<Pagination currentPage={page} totalCount={data.repository.discussions.totalCount} />
</div>
</div>
</Layout>
)
}
export default discussions
+1 -1
View File
@@ -1,5 +1,5 @@
import { getDocsBySlug } from '../lib/docs'
import Layout from '../components/layouts/Layout'
import Layout from '../components/layouts/DocsLayout'
import { serialize } from 'next-mdx-remote/serialize'
import { MDXRemote } from 'next-mdx-remote'
import { MDXProvider } from '@mdx-js/react'
@@ -14,7 +14,7 @@ It's been another packed month of shipping like crazy at Supabase, here's the up
## Wildcard Auth Redirects
Wildcard redirects are especially useful for Jamstack platforms with preview deployments, like Vercel and Netlify. Now you can add a wildcard domain, like `*.mydomain.com/welcome` and both `x.mydomain.com/welcome` and `y.mydomain.com/welcome` will be be accepted. [Read the Pull Request](https://github.com/supabase/gotrue/pull/334).
Wildcard redirects are especially useful for Jamstack platforms with preview deployments, like Vercel and Netlify. Now you can add a wildcard domain, like `*.mydomain.com/welcome` and both `x.mydomain.com/welcome` and `y.mydomain.com/welcome` will be accepted. [Read the Pull Request](https://github.com/supabase/gotrue/pull/334).
[![Wildcard Auth Redirects](/images/blog/2022-may/wildcard.jpg)](https://github.com/supabase/gotrue/pull/334)
@@ -30,7 +30,7 @@ Before we dive in, let’s look at some prerequisites you'll need:
## Loading data into Supabase using Python
Supabase is built for developers, and you can [get started for free](https://app.supabase.io) using your existing Github account. Once your Supabase account is set up, you will access the Supabase dashboard. From here, go to All Project > New Project.
Supabase is built for developers, and you can [get started for free](https://app.supabase.com) using your existing Github account. Once your Supabase account is set up, you will access the Supabase dashboard. From here, go to All Project > New Project.
![screen shot of supabase dashboard](/images/blog/python-1/1.png)
@@ -173,6 +173,6 @@ Data can also be viewed directly from the Supabase dashboard. To do this, go to
Note: In case you cannot see any of the data, you should hit the Refresh button.
## Conclusion
With Python, data loading into Supabase is easy. It just takes a few easy steps to get started with the Python SDK and Supabase. In the next part of this blog series, we will learn how to visualize the data that we just loaded into Supabase using Metabase. Stay tuned!
With Python, data loading into Supabase is easy. It just takes a few easy steps to get started with the Python SDK and Supabase. In the next part of this blog series, we will learn [how to visualize the data that we just loaded into Supabase using Metabase](blog/2022/06/29/visualizing-supabase-data-using-metabase). Stay tuned!
If you have any questions please reach out via [Twitter](https://twitter.com/supabase) or join our [Discord](https://discord.supabase.com).
@@ -0,0 +1,135 @@
---
title: Visualizing Supabase Data using Metabase
description: How to create different kinds of charts out of data stored in Supabase using Metabase.
author: ant_wilson
image: python-1/supabase-python-metabase.png
thumb: python-1/supabase-python-metabase.png
tags:
- python
- open-source
- community
date: '2022-06-29'
toc_depth: 3
---
Data helps organizations make better decisions. With a programming language like Python to analyze your data and transform data into visual representations, you can effortlessly tell the story of your business. One way to create customized visuals from your data would be to use data visualization libraries in Python like [Matplotlib](https://matplotlib.org/), [Seaborn](https://seaborn.pydata.org/), [Ggplot2](https://ggplot2.tidyverse.org/index.html), [Plotly](https://plotly.com/), or [Pandas](https://pandas.pydata.org/). When you want to accomplish this task with little or no code (not even SQL), you might consider using tools like [Metabase](https://www.metabase.com/).
With Metabase, a powerful visualization tool, you can quickly turn your data into easy-to-understand visuals like graphs, pie charts, flow diagrams, and much more. Then, using Metabase’s intuitive interface, you can cut through the data noise and focus on what’s essential for your business.
In the previous blog of this series**,** we explained [how to use Python to load data into Supabase](https://supabase.com/blog/2022/06/15/loading-data-supabase-python). In this blog, we will create different kinds of charts out of the data stored in Supabase using Metabase.
## Prerequisites
Before we dive in, let’s look at some prerequisites that you will need:
- **Supabase project with data**
Based on our [previous article](https://supabase.com/blog/2022/06/15/loading-data-supabase-python), we assume we already have a Supabase project setup and have data loaded into it.
- **Metabase Docker Container**
To take advantage of the open-source version of Metabase, you can use the Metabase docker container [here](https://docs.docker.com/engine/install/).
## Visualizing data in Supabase with Metabase
### Launching Metabase
To launch Metabase, simply go to "<http://localhost:3000/setup/>" which is the default port that the Metabase server will be listening to.
After Metabase is launched, select your preferred language and add your contact information. In the _Add your data_ markdown, you will need to choose PostgreSQL.
![screen shot of adding postgres data to metabase](/images/blog/python-1/adding-postgresql-data-metabase.png)
You will be prompted to add the necessary connection information to your Supabase project. Go to your [Supabase project](https://app.supabase.com/) and hit _Settings > Database to get the database info_.
![screen shot of supabase dashboard database connection information](/images/blog/python-1/supabase-dashboard-connect-database-info.png)
Enter the necessary information on Metabase and hit next. Finally, select your data preference, after which you will land on the Metabase homepage.
### View Database and Tables
We can now see the "Supabase DB" Supabase project under "Our data".
![screen shot of metabase dashboard](/images/blog/python-1/metabase-dashboard-with-supabase-db.png)
To view the tables, go to _SupabaseDB > public_
![screen shot of metabase dashboard table view](/images/blog/python-1/metabase-dashboard-02.png)
### View Table Data Insights
Go back to the home page and select public schema under "Try these x-rays based on your data"
![screen shot of metabase dashboard schema view](/images/blog/python-1/metabase-dashboard-03.png)
Here is the output of the product table.
![screen shot of metabase data visualization](/images/blog/python-1/metabase-visualization-supabase-db.png)
As you can see, we can get some handy information from this like:
- How many products are present with a given range of inventory count.
- How many products are present for a given range of price.
- The ratio between the number of employees to the number of products.
- How many products each vendor has created.
If you have column-specific views, you can select the _zoom-in_ option under _More x-rays_.
![screen shot of metabase data visualization](/images/blog/python-1/metabase-visualization-supabase-db-02.png)
For example, let's select the total employees field.
![screen shot of metabase data visualization](/images/blog/python-1/metabase-visualization-supabase-data.png)
With information like this, you will be able to answer some key questions like
- What are some common statistics for company employees like average, minimum, maximum, and standard deviation?
- What is the distribution of the employees across different geo locations?
- What is the distribution of the employees across different vendors?
## Using custom SQL queries
We can also use custom queries to set up our dashboards. To do this, go to _New > SQL query._
![screen shot of metabase custom queries dashboard](/images/blog/python-1/metabase-dashboard-04.png)
Next, under the database, select "SupabaseDB".
![screen shot of metabase custom queries dashboard](/images/blog/python-1/metabase-dashboard-05.png)
We will be using the following SQL query:
```sql
SELECT "Vendor".vendor_name, product_name, "Vendor".total_employees
FROM "Product"
LEFT JOIN "Vendor" on "Product".vendor_id = "Vendor".vendor_id
WHERE "Vendor".total_employees
```
This query should fetch us the vendor name and the product where the number of employees for a given vendor is less than 110.
To run the SQL query, hit the play button.
![screen shot of metabase data visualization](/images/blog/python-1/metabase-dashboard-06.png)
This will be shown below in the output window. To visualize the data, hit the visualization button.
![screen shot of metabase data visualization](/images/blog/python-1/metabase-dashboard-07.png)
Next, select the type of visualizer you want. Let us choose _Bar_.
![screen shot of metabase data visualization](/images/blog/python-1/metabase-dashboard-08.png)
Choose the appropriate x-axis and y-axis fields, and you will be able to view the data in bar format.
![screen shot of metabase data visualization](/images/blog/python-1/metabase-report.png)
## Conclusion
Data visualization empowers organizations to turn unused data into actionable insights, leading to faster and better decision-making. Why wait?
![screen shot of a meme saying visualization works every time](/images/blog/python-1/final-meme.png)
With our [free tier Supabase account](https://app.supabase.com/), you can start a new project today and use Metabase to visualize your app data.
If you have any questions please reach out via [Twitter](https://twitter.com/supabase) or join our [Discord](https://discord.supabase.com).
@@ -84,6 +84,7 @@ function AuthPage() {
<img
src="https://app.supabase.com/img/supabase-dark.svg"
width="96"
alt="supabase logo"
/>
<h3>{companyName}</h3>
</Space>
+1
View File
@@ -12,6 +12,7 @@ export default function Avatar(props: Props) {
src={'/images/blog/avatars/' + img}
className="h-8 w-8 rounded-full object-cover text-center"
style={{ margin: 0 }}
alt={`${caption} avatar`}
/>
<figcaption style={{ marginTop: 0 }} className="text-scale-1000">
<p>{caption}</p>
@@ -42,6 +42,7 @@ const BlogListItem = ({ post }: Props) => {
}
objectFit="cover"
className="scale-100 transform duration-100 ease-in group-hover:scale-105"
alt="case study thumbnail"
/>
</div>
+1 -1
View File
@@ -56,7 +56,7 @@ function ExampleCard(props: any) {
<div className="mt-3 flex items-center gap-2">
{props.vercel_deploy_url && (
<a target="_blank" href={props.vercel_deploy_url}>
<img className="h-6" src="https://vercel.com/button" />
<img className="h-6" src="https://vercel.com/button" alt="vercel button" />
</a>
)}
{props.demo_url && (
@@ -12,30 +12,37 @@ function FloatingIcons() {
<img
className={`${Styles['icon-react-one']}`}
src={`${basePath}/images/product/auth/react-icon.svg`}
alt="react icon"
/>
<img
src={`${basePath}/images/product/auth/electron-icon.svg`}
className={`${Styles['icon-electron']}`}
alt="electron icon"
/>
<img
src={`${basePath}/images/product/auth/vue-icon.svg`}
className={`${Styles['icon-vue']}`}
alt="vue icon"
/>
<img
src={`${basePath}/images/product/auth/angular-icon.svg`}
className={`${Styles['icon-angular']}`}
alt="angular icon"
/>
<img
src={`${basePath}/images/product/auth/flutter-icon.svg`}
className={`${Styles['icon-flutter']}`}
alt="flutter icon"
/>
<img
src={`${basePath}/images/product/auth/nuxt-icon.svg`}
className={`${Styles['icon-nuxt']}`}
alt="nuxt icon"
/>
<img
src={`${basePath}/images/product/auth/redwood-icon.svg`}
className={`${Styles['icon-redwood']}`}
alt="redwood icon"
/>
</div>
</div>
+7 -1
View File
@@ -23,7 +23,13 @@ function TweetCard(props: TweetCard) {
<div className="relative">
<div className="flex items-center gap-2">
<div className="h-10 w-10 overflow-hidden rounded-full border dark:border-gray-600">
<Image src={props.img_url} layout="responsive" width="64" height="64" />
<Image
src={props.img_url}
layout="responsive"
width="64"
height="64"
alt={`${props.handle} twitter image`}
/>
</div>
<p className="text-scale-1200 mt-3 text-sm font-medium">{props.handle}</p>
<div
+6
View File
@@ -48,6 +48,12 @@ module.exports = withMDX({
source: '/docs',
destination: `${process.env.NEXT_PUBLIC_DOCS_URL}`,
},
{
// redirect /docs/
// trailing slash caused by docusaurus issue with multizone
source: '/docs/',
destination: `${process.env.NEXT_PUBLIC_DOCS_URL}`,
},
{
source: '/docs/:path*',
destination: `${process.env.NEXT_PUBLIC_DOCS_URL}/:path*`,
+1
View File
@@ -109,6 +109,7 @@ function Database() {
src={`${basePath}/images/product/database/postgresql-icon.svg`}
width={30}
height={30}
alt="postgresql icon"
/>
</div>
</div>
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 451 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 610 KiB

+9 -1
View File
@@ -5,9 +5,17 @@
<link>https://supabase.com</link>
<description>Latest news from Supabase</description>
<language>en</language>
<lastBuildDate>Wed, 22 Jun 2022 00:00:00 GMT</lastBuildDate>
<lastBuildDate>Wed, 29 Jun 2022 00:00:00 GMT</lastBuildDate>
<atom:link href="https://supabase.com/blog/rss.xml" rel="self" type="application/rss+xml"/>
<item>
<guid>https://supabase.com/blog/2022/06/29/visualizing-supabase-data-using-metabase</guid>
<title>Visualizing Supabase Data using Metabase</title>
<link>https://supabase.com/blog/2022/06/29/visualizing-supabase-data-using-metabase</link>
<description>How to create different kinds of charts out of data stored in Supabase using Metabase.</description>
<pubDate>Wed, 29 Jun 2022 00:00:00 GMT</pubDate>
</item>
<item>
<guid>https://supabase.com/blog/2022/06/15/loading-data-supabase-python</guid>
<title>Python data loading with Supabase</title>
+10
View File
@@ -8,6 +8,7 @@ JWT_SECRET=your-super-secret-jwt-token-with-at-least-32-characters-long
ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE
SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q
############
# Database - You can change these to any PostgreSQL database that has logical replication enabled.
############
@@ -17,6 +18,7 @@ POSTGRES_DB=postgres
POSTGRES_USER=postgres
POSTGRES_PORT=5432
############
# API Proxy - Configuration for the Kong Reverse proxy.
############
@@ -25,6 +27,13 @@ KONG_HTTP_PORT=8000
KONG_HTTPS_PORT=8443
############
# API - Configuration for PostgREST.
############
PGRST_DB_SCHEMAS=public,storage,graphql_public
############
# Auth - Configuration for the GoTrue authentication server.
############
@@ -49,6 +58,7 @@ SMTP_SENDER_NAME=fake_sender
ENABLE_PHONE_SIGNUP=true
ENABLE_PHONE_AUTOCONFIRM=true
############
# Studio - Configuration for the Dashboard
############
+1 -1
View File
@@ -85,7 +85,7 @@ services:
restart: unless-stopped
environment:
PGRST_DB_URI: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
PGRST_DB_SCHEMAS: public,storage, graphql_public
PGRST_DB_SCHEMAS: ${PGRST_DB_SCHEMAS}
PGRST_DB_ANON_ROLE: anon
PGRST_JWT_SECRET: ${JWT_SECRET}
PGRST_DB_USE_LEGACY_GUCS: "false"
File diff suppressed because it is too large. Load diff
@@ -11,7 +11,7 @@ export default function Footer({}) {
target="_blank"
href="https://vercel.com/new/git/external?repository-url=https%3A%2F%2Fgithub.com%2Fsupabase%2Fsupabase%2Ftree%2Fmaster%2Fexamples%2user-management%2Fnextjs-ts-user-management&project-name=supabase-user-management&repository-name=supabase-user-management&demo-title=Supabase%20User%20Management&demo-description=An%20example%20web%20app%20using%20Supabase%20and%20Next.js&demo-url=https%3A%2F%2Fsupabase-nextjs-ts-user-management.vercel.app&demo-image=https%3A%2F%2Fi.imgur.com%2FZ3HkQqe.png&integration-ids=oac_jUduyjQgOyzev1fjrW83NYOv&external-id=nextjs-user-management"
>
<img src="https://vercel.com/button" />
<img src="https://vercel.com/button" alt="vercel button" />
</a>
</div>
</div>
+1213 -44
View File
File diff suppressed because it is too large. Load diff
@@ -23,6 +23,7 @@ const RouteValidationWrapper: FC = ({ children }) => {
// or prompt the user to create an organaization
// this is used by database.dev, usually as /new/new-project
'/new/[slug]',
'/join',
]
/**
@@ -12,9 +12,9 @@ const InviteUserModal = () => {
const inputRef = useRef<any>(null)
const { ui } = useStore()
const [visible, setVisible] = useState<boolean>(false)
const [loading, setLoading] = useState<boolean>(false)
const [emailValue, setEmailValue] = useState<string>('')
const [visible, setVisible] = useState(false)
const [loading, setLoading] = useState(false)
const [emailValue, setEmailValue] = useState('')
function handleToggle() {
// reset data before showing modal again
@@ -1,7 +1,8 @@
import dayjs from 'dayjs'
import Link from 'next/link'
import { FC, FormEvent, useState } from 'react'
import { useRouter } from 'next/router'
import { IconSearch, Input, Listbox, Popover, Button, Modal } from '@supabase/ui'
import { IconSearch, IconInfo, Input, Listbox, Popover, Button, Modal } from '@supabase/ui'
import { post } from 'lib/common/fetch'
import { API_URL, PROJECT_STATUS } from 'lib/constants'
@@ -26,10 +27,22 @@ const PITRBackupSelection: FC<Props> = () => {
const { app, ui, backups } = useStore()
const isAvailable = useFlag('pitrBackups')
const projectId = ui.selectedProject?.id ?? -1
const projectRef = ui.selectedProject?.ref ?? 'default'
if (!isAvailable) {
return (
<div className="flex items-center justify-center rounded border border-gray-500 bg-gray-300 py-8">
<p className="text-scale-1000 text-sm">Coming soon</p>
<div className="block w-full rounded border border-gray-400 border-opacity-50 bg-gray-300 p-3">
<div className="flex space-x-3">
<IconInfo size={20} strokeWidth={1.5} />
<p className="text-scale-1100 text-sm">
Coming soon - Point in time backups is an Enterprise feature. Reach out to us{' '}
<Link href={`/support/new?ref=${projectRef}&category=sales`}>
<a className="text-brand-900">here</a>
</Link>{' '}
if you're interested!
</p>
</div>
</div>
)
}
@@ -42,8 +55,6 @@ const PITRBackupSelection: FC<Props> = () => {
const [recoveryPoint, setRecoveryPoint] = useState<string>()
const { configuration } = backups
const projectId = ui.selectedProject?.id ?? -1
const projectRef = ui.selectedProject?.ref ?? 'default'
if (backups.isLoading) {
return <Loading />
@@ -58,13 +69,19 @@ const PITRBackupSelection: FC<Props> = () => {
configuration.physicalBackupData.latestPhysicalBackupDateUnix
if (!configuration.walg_enabled) {
// Using this check as opposed to checking price tier to allow enabling PITR for our own internal projects
return (
<UpgradeToPro
primaryText="Free Plan does not include project backups."
projectRef={projectRef}
secondaryText="Please upgrade to Pro plan for access to point in time recovery backups."
/>
<div className="block w-full rounded border border-gray-400 border-opacity-50 bg-gray-300 p-3">
<div className="flex space-x-3">
<IconInfo size={20} strokeWidth={1.5} />
<p className="text-sm">
Point in time backups is an Enterprise feature. Reach out to us{' '}
<Link href={`/support/new?ref=${projectRef}&category=sales`}>
<a className="text-brand-900">here</a>
</Link>{' '}
if you're interested!
</p>
</div>
</div>
)
} else if (!hasPhysicalBackups) {
return <BackupsEmpty />
@@ -96,7 +96,7 @@ const TableList: FC<{
<Table.th key="buttons"></Table.th>,
]}
body={tables.map((x: any, i: any) => (
<Table.tr key={x.id}>
<Table.tr key={x.id} hoverable>
<Table.td>
<Typography.Text>{x.name}</Typography.Text>
</Table.td>
@@ -0,0 +1,126 @@
import { useState, useEffect } from 'react'
import { isNil } from 'lodash'
import { post } from 'lib/common/fetch'
import { Button, IconKey, IconMail, Form, Input, Modal } from '@supabase/ui'
import { API_URL } from 'lib/constants'
import { useOrganizationDetail, useStore } from 'hooks'
import { toJS } from 'mobx'
import { Member } from 'types'
function InviteMemberModal({ organization, members = [], user }: any) {
const initialValues = { email: '' }
const { ui } = useStore()
const { mutateOrgMembers } = useOrganizationDetail(ui.selectedOrganization?.slug || '')
const [isOpen, setIsOpen] = useState(false)
const [emailAddress, setEmailAddress] = useState('')
const [memberList, setMemberList] = useState<Member[]>([])
const [addMemberLoading, setAddMemberLoading] = useState(false)
const { slug: orgSlug } = organization
useEffect(() => {
setMemberList(members)
}, [members])
function toggle() {
// reset data before showing modal again
if (!isOpen) {
setAddMemberLoading(false)
setEmailAddress('')
}
setIsOpen(!isOpen)
}
async function addMember() {
setAddMemberLoading(true)
const response = await post(`${API_URL}/organizations/${orgSlug}/members/invite`, {
invited_email: emailAddress,
owner_id: toJS(user.id),
})
if (isNil(response)) {
ui.setNotification({ category: 'error', message: 'Failed to add member' })
} else if (response?.error) {
ui.setNotification({
category: 'error',
message: `Failed to add member: ${response.error.message}`,
})
setAddMemberLoading(false)
} else {
const newMember: Member = {
// [Joshen] Setting a random id for now to fit the Member interface
id: 0,
invited_at: response.invited_at,
is_owner: false,
profile: {
id: 0,
primary_email: response.invited_email,
username: response.invited_email[0],
},
}
mutateOrgMembers([...memberList, newMember])
ui.setNotification({
category: 'success',
message: 'Successfully added new member.',
})
toggle()
}
}
function onEmailInputChange(e: any) {
setEmailAddress(e.target.value)
}
return (
<>
<Button onClick={toggle}>Invite</Button>
<Modal
size="small"
className="!overflow-visible"
visible={isOpen}
onCancel={toggle}
header="Invite a member to this organization"
description="Members you'd like to invite must already be registered on Supabase"
layout="vertical"
hideFooter
>
<Modal.Content>
<Form
validate={(values) => {
const errors: any = {}
if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,4}$/i.test(values.email)) {
errors.email = 'Please enter a valid email address'
}
return errors
}}
initialValues={initialValues}
onSubmit={addMember}
>
{() => (
<div className="w-full py-4">
<div className="space-y-4">
<Input
id="email"
icon={<IconMail />}
autoFocus
placeholder="Enter email address"
onChange={onEmailInputChange}
label="Email address"
/>
<Button block size="medium" htmlType="submit" loading={addMemberLoading}>
Invite new member
</Button>
</div>
</div>
)}
</Form>
</Modal.Content>
</Modal>
</>
)
}
export default InviteMemberModal
@@ -67,7 +67,7 @@ const TableGridEditor: FC<Props> = ({
// @ts-ignore
const schema = meta.schemas.list().find((schema) => schema.name === selectedSchema)
const isViewSelected = Object.keys(selectedTable).length === 2
const isLocked = schema?.owner === 'supabase_admin'
const isLocked = meta.excludedSchemas.includes(schema?.name ?? '')
const gridTable = !isViewSelected
? parseSupaTable({
@@ -93,11 +93,10 @@ const TableEditorMenu: FC<Props> = ({
? schemaViews
: schemaViews.filter((view) => view.name.includes(searchText))
const isLocked = schema?.owner === 'supabase_admin'
const [protectedSchemas, openSchemas] = partition(
schemas,
(schema) => schema.owner === 'supabase_admin'
const [protectedSchemas, openSchemas] = partition(schemas, (schema) =>
meta.excludedSchemas.includes(schema?.name ?? '')
)
const isLocked = protectedSchemas.some((s) => s.id === schema?.id)
return (
<div className="my-6 mx-4 flex flex-grow flex-col space-y-6">
@@ -1,323 +0,0 @@
import { createContext, useContext, useCallback, useState, useEffect } from 'react'
import { debounce, isNil } from 'lodash'
import { observer, useLocalObservable } from 'mobx-react-lite'
import { post } from 'lib/common/fetch'
import { Button, IconKey, IconLoader, IconUser, IconX, Input, Typography } from '@supabase/ui'
import { Modal } from '@supabase/ui'
import { API_URL } from 'lib/constants'
import { Transition } from '@headlessui/react'
import { useOrganizationDetail, useStore } from 'hooks'
/**
* Modal to invite member to Organization
*
* @param {Object} organization // organization detail
* @param {Array} members // organization members list
*/
const PageContext = createContext(null)
function InviteMemberModal({ organization, members = [] }) {
const PageState = useLocalObservable(() => ({
members: [],
keywords: '',
profiles: null,
selectedProfile: null,
addMemberLoading: false,
get addBtnText() {
if (this.selectedProfile) return `Add ${this.selectedProfile.username} to organization`
return 'Select a member above'
},
get addBtnDisable() {
return !this.selectedProfile || this.addMemberLoading
},
setProfiles(profiles) {
let temp = profiles
if (profiles) {
temp = profiles
.map((profile) => {
const foundMember = this.members.find((x) => x.profile.id == profile.id)
return { ...profile, isMember: foundMember !== undefined }
})
.sort((a, b) => a.username.localeCompare(b.username))
}
this.profiles = temp
},
}))
const { ui } = useStore()
const { mutateOrgMembers } = useOrganizationDetail(ui.selectedOrganization?.slug || '')
const [isOpen, setIsOpen] = useState(false)
const { id: orgId, slug: orgSlug } = organization
useEffect(() => {
PageState.members = members
}, [members])
function toggle() {
// reset data before showing modal again
if (!isOpen) {
PageState.keywords = ''
PageState.selectedProfile = null
PageState.profiles = null
PageState.addMemberLoading = false
}
setIsOpen(!isOpen)
}
async function addMember() {
PageState.addMemberLoading = true
const response = await post(`${API_URL}/organizations/${orgSlug}/members/add`, {
org_id: orgId,
user_id: PageState.selectedProfile.id,
})
if (isNil(response)) {
ui.setNotification({ category: 'error', message: 'Failed to add member' })
} else if (response?.error) {
ui.setNotification({
category: 'error',
message: `Failed to add member: ${response.error.message}`,
})
PageState.addMemberLoading = false
} else {
const newMember = response
mutateOrgMembers([...PageState.members, newMember], false)
toggle()
ui.setNotification({ category: 'success', message: 'Successfully added new member' })
}
}
return (
<PageContext.Provider value={PageState}>
<Button onClick={toggle}>Invite</Button>
<Modal
size="small"
className="!overflow-visible"
icon={<IconKey size="xlarge" background="brand" />}
visible={isOpen}
onCancel={toggle}
header="Invite a member to organization"
description="Members you'd like to invite must already be registered on Supabase"
layout="vertical"
hideFooter
>
<div className="w-full py-4 space-y-4">
<Modal.Content>
<div className="text-center">
<InputSearchWithResults className="" />
</div>
</Modal.Content>
<Modal.Seperator />
<Modal.Content>
<div className="">
<Button
onClick={addMember}
loading={PageState.addMemberLoading}
disabled={PageState.addBtnDisable}
block
>
{PageState.addBtnText}
</Button>
</div>
</Modal.Content>
</div>
</Modal>
</PageContext.Provider>
)
}
export default observer(InviteMemberModal)
const InputSearchWithResults = observer(({ className }) => {
const PageState = useContext(PageContext)
const { ui } = useStore()
const [loading, setLoading] = useState(false)
const debounceSearchProfile = useCallback(debounce(searchProfile, 500), [PageState.keywords])
useEffect(() => {
debounceSearchProfile()
// Cancel the debounce on useEffect cleanup.
return debounceSearchProfile.cancel
}, [PageState.keywords, debounceSearchProfile])
async function searchProfile() {
if (PageState.keywords.length === 0) {
PageState.setProfiles(null)
return
}
setLoading(true)
const response = await post(`${API_URL}/profile/search`, { keywords: PageState.keywords })
if (isNil(response)) {
ui.setNotification({ category: 'error', message: 'Failed to search profile' })
} else if (response.error) {
ui.setNotification({
category: 'error',
message: `Failed to search profile: ${response.error.message}`,
})
setLoading(false)
} else {
PageState.setProfiles(response)
setLoading(false)
}
}
function onInputChange(e) {
PageState.keywords = e.target.value
}
function reset() {
PageState.keywords = ''
PageState.setProfiles(null)
PageState.selectedProfile = null
}
if (PageState.selectedProfile) {
const fullName = `${PageState.selectedProfile.first_name || ''} ${
PageState.selectedProfile.last_name || ''
}`
return (
<div className={className}>
<div className="flex px-6 py-2 items-center rounded bg-scale-400 border">
<div className="flex-grow text-left">
<p className="text-scale-1200 font-medium">{PageState.selectedProfile.username}</p>
<p className="text-sm leading-5 text-scale-1100">
{fullName !== ' ' ? `${fullName} • Invite collaborator` : 'Invite collaborator'}
</p>
</div>
<button className="text-scale-900 hover:text-scale-1200">
<IconX strokeWidth={2} onClick={reset} />
</button>
</div>
</div>
)
}
const onSelectDropdownOption = (e, option) => {
if (!option.isMember) PageState.selectedProfile = option
}
return (
<div className={className}>
<div className="relative rounded-md shadow-sm">
<Input
icon={<IconUser />}
autoFocus
id="email"
type="text"
className="form-input"
onChange={onInputChange}
autoComplete="off"
value={PageState.keywords}
placeholder="search by username or email"
/>
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
{loading && <IconLoader className="animate-spin" size={16} />}
</div>
</div>
{PageState.profiles && PageState.profiles.length !== 0 ? (
<>
<div className="flex flex-col gap-1 py-4">
{PageState.profiles.map((profile, i) => {
const { first_name, last_name, username, isMember } = profile
const fullName = `${first_name || ''} ${last_name || ''}`
let subText = isMember ? 'Already in this organization' : 'Invite collaborator'
if (fullName !== ' ') subText = `${fullName} • ${subText}`
return (
<Button
block
key={`option_${i}`}
className={`px-3 py-1 ${
isMember
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer hover:bg-bg-alt-light dark:hover:bg-bg-alt-dark'
} first:rounded-t-md last:rounded-b-md`}
onClick={(e) => onSelectDropdownOption(e, profile)}
type="default"
style={{ justifyContent: 'flex-start' }}
size="medium"
>
<div className="flex items-center gap-2">
<span className="block truncate">{username}</span>
<span className="text-scale-900 block truncate" type="secondary">
{subText}
</span>
</div>
</Button>
)
})}
</div>
</>
) : PageState.profiles &&
PageState.profiles.length === 0 &&
!loading &&
PageState.keywords ? (
<>
<p className="text-sm text-scale-1200 mt-4">Could not find account.</p>
<p className="text-sm text-scale-1100">Has the user already signed up?</p>
</>
) : null}
</div>
)
})
const ProfileDropdown = observer(({ className }) => {
const PageState = useContext(PageContext)
const onSelectDropdownOption = (e, option) => {
if (!option.isMember) PageState.selectedProfile = option
}
if (!PageState.profiles) return null
return (
<div className={className}>
<Transition
show={!PageState.selectedProfile}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<div className="origin-top-right w-full max-h-48 rounded-md shadow-lg overflow-y-auto z-100 fixed">
<div className="rounded-md bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 text-left">
<div
className="py-1"
role="menu"
aria-orientation="vertical"
aria-labelledby="options-menu"
>
{PageState.profiles.length === 0 && (
<div className="px-3 py-2">
<p className="text-base leading-6 font-medium">
Could not find account. Has the user already signed up?
</p>
</div>
)}
{PageState.profiles.map((profile, i) => {
const { first_name, last_name, username, isMember } = profile
const fullName = `${first_name || ''} ${last_name || ''}`
let subText = isMember ? 'Already in this organization' : 'Invite collaborator'
if (fullName !== ' ') subText = `${fullName} • ${subText}`
return (
<div
key={`option_${i}`}
className={`px-3 py-1 ${
isMember
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer hover:bg-bg-alt-light dark:hover:bg-bg-alt-dark'
} first:rounded-t-md last:rounded-b-md`}
onClick={(e) => onSelectDropdownOption(e, profile)}
>
<Typography.Text className="block">{username}</Typography.Text>
<Typography.Text className="block" type="secondary">
{subText}
</Typography.Text>
</div>
)
})}
</div>
</div>
</div>
</Transition>
</div>
)
})
+25 -2
View File
@@ -4,13 +4,36 @@ import { Member } from 'types'
import { API_URL } from 'lib/constants'
export function useOrganizationDetail(slug: string) {
// Get org members
const url = `${API_URL}/props/org/${slug}`
const { data, error } = useSWR<any>(url, get)
const { members, products } = data ?? []
const anyError = data?.error || error
let { members, products } = data ?? []
// Get pending invite users
const pendingInviteUrl = `${API_URL}/organizations/${slug}/members/invite`
const { data: inviteData, error: inviteError } = useSWR<any>(pendingInviteUrl, get)
if (data && inviteData && inviteData.length > 0) {
// remap invite data to look like existing members data
const invitedMembers = inviteData.map((x: any) => ({
is_owner: false,
invited_at: x.invited_at,
invited_id: x.invited_id,
profile: {
// Use the first letter of the email to allow for member list sorting
username: x.invited_email.slice(0, 1),
primary_email: x.invited_email,
},
}))
members = [...members, ...invitedMembers]
}
const anyError = data?.error || error || inviteError
function mutateOrgMembers(updatedMembers: Member[], revalidate?: boolean) {
mutate(url, { members: updatedMembers }, revalidate ?? true)
mutate(pendingInviteUrl, {}, revalidate ?? true)
}
return {
+3
View File
@@ -59,6 +59,9 @@ const nextConfig = {
},
]
},
images: {
domains: ['github.com']
},
}
// Export all config
+2
View File
@@ -146,6 +146,8 @@ const IndexLayout = withAuth(
router.push(`/vercel/integrate?${params.toString()}`)
} else if (router.query?.next?.includes('new-project')) {
router.push('/new/project')
} else if (router.query?.next?.includes('join')) {
router.push(`/${router.query.next}`)
} else if (
typeof router.query?.next === 'string' &&
router.query?.next?.startsWith('project/_/')
+263
View File
@@ -0,0 +1,263 @@
import Link from 'next/link'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/router'
import { Button, IconCheckSquare, Loading } from '@supabase/ui'
import { useProfile, useStore } from 'hooks'
import { auth } from 'lib/gotrue'
import { API_URL } from 'lib/constants'
import { get, post, delete_ } from 'lib/common/fetch'
interface ITokenInfo {
organization_name?: string | undefined
token_does_not_exist?: boolean
email_match?: boolean
authorized_user?: boolean
expired_token?: boolean
invite_id?: number
}
type TokenInfo = ITokenInfo | undefined
const JoinOrganizationPage = () => {
const router = useRouter()
const { slug, token } = router.query
const { ui, app } = useStore()
const { profile } = useProfile()
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState(false)
const [tokenValidationInfo, setTokenValidationInfo] = useState<TokenInfo>(undefined)
const [tokenInfoLoaded, setTokenInfoLoaded] = useState(false)
const { token_does_not_exist, email_match, expired_token, organization_name, invite_id } =
tokenValidationInfo || {}
const loginRedirectLink = `/?next=${encodeURIComponent(`/join?token=${token}&slug=${slug}`)}`
useEffect(() => {
const fetchTokenInfo = async () => {
await ui.load()
const response = await get(`${API_URL}/organizations/${slug}/members/join?token=${token}`)
if (response.error) {
setError(response.error)
setTokenInfoLoaded(true)
} else {
setTokenInfoLoaded(true)
setTokenValidationInfo(response)
}
}
if (!tokenInfoLoaded && token) {
fetchTokenInfo()
}
/**
* if params are empty then redirect
* user to the homepage of app
*/
// if (!slug && !token) router.push('/')
}, [token, router.asPath])
async function handleJoinOrganization() {
setIsSubmitting(true)
const response = await post(`${API_URL}/organizations/${slug}/members/join?token=${token}`, {})
if (response.error) {
ui.setNotification({
category: 'error',
message: `Failed to join organization: ${response.error.message}`,
})
setIsSubmitting(false)
} else {
setIsSubmitting(false)
app.onOrgAdded(response)
router.push('/')
}
}
async function handleDeclineJoinOrganization() {
setIsSubmitting(true)
const response = await delete_(
`${API_URL}/organizations/${slug}/members/invite?invited_id=${invite_id}`,
{}
)
if (response.error) {
ui.setNotification({
category: 'error',
message: `Failed to decline invitation: ${response.error.message}`,
})
setIsSubmitting(false)
} else {
setIsSubmitting(false)
router.push('/')
}
}
const isError =
error ||
!!(tokenInfoLoaded && token_does_not_exist) ||
(tokenInfoLoaded && !email_match) ||
(tokenInfoLoaded && expired_token)
const ErrorMessage = () => {
const Container = ({ children }: { children: React.ReactNode }) => (
<div
className={[
'flex flex-col items-center justify-center gap-3 text-sm',
isError ? 'text-scale-1100' : 'text-scale-1200',
].join(' ')}
>
{children}
</div>
)
const message = error ? (
<p>There was an error requesting details for this invitation.</p>
) : token_does_not_exist ? (
<>
<p>The invite token is invalid.</p>
<p className="text-scale-900">
Try copying and pasting the link from the invite email, or ask the organization owner to
invite you again.
</p>
</>
) : !email_match ? (
<>
<p>
Your email address {profile?.primary_email} does not match the email address this
invitation was sent to.
</p>
<p className="text-scale-900">
To accept this invitation, you will need to{' '}
<a
className="text-brand-900 cursor-pointer"
onClick={async () => {
await auth.signOut()
router.reload()
}}
>
sign out
</a>{' '}
and then sign in or create a new account using the same email address used in the
invitation.
</p>
</>
) : expired_token ? (
<>
<p>The invite token has expired.</p>
<p className="text-scale-900">Please request a new one from the organization owner.</p>
</>
) : (
''
)
return isError ? <Container>{message}</Container> : null
}
const Content = () => (
<>
<div className="flex flex-col gap-2 px-6 py-8">
<>
<p className="text-scale-1200 text-sm">You have been invited to join </p>
{organization_name ? (
<>
<p className="text-scale-1200 text-3xl">
{organization_name ? `${organization_name}` : 'an organization'}{' '}
</p>
{!token_does_not_exist && (
<p className="text-scale-900 text-sm">an organization on Supabase</p>
)}
</>
) : (
<>
<p className="text-scale-1200 text-3xl">{'an organization'}</p>
</>
)}
{slug && <p className="text-scale-900 text-xs">{`organization slug: ${slug}`}</p>}
</>
</div>
<div
className={['border-scale-400 border-t', isError ? 'bg-sand-100' : 'bg-transparent'].join(
' '
)}
>
<div className="flex flex-col gap-4 px-6 py-4 ">
{profile && !isError && (
<div className="flex flex-row items-center justify-center gap-3">
<Button onClick={handleDeclineJoinOrganization} htmlType="submit" type="default">
Decline
</Button>
<Button
onClick={handleJoinOrganization}
htmlType="submit"
loading={isSubmitting}
type="primary"
icon={<IconCheckSquare />}
>
Join organization
</Button>
</div>
)}
{tokenInfoLoaded && <ErrorMessage />}
{!profile && (
<div className="flex flex-col gap-3">
<p className="text-scale-900 text-xs">
You will need to sign in to accept this invitation
</p>
<div className="flex justify-center gap-3">
<Link passHref href={loginRedirectLink}>
<Button as="a" type="default">
Sign in
</Button>
</Link>
<Link passHref href={loginRedirectLink}>
<Button as="a" type="default">
Create an account
</Button>
</Link>
</div>
</div>
)}
</div>
</div>
</>
)
return (
<div
className={[
'bg-scale-200 flex h-full min-h-screen',
'w-full flex-col place-items-center',
'items-center justify-center gap-8 px-5',
].join(' ')}
>
<Link href="/">
<a className="flex items-center justify-center gap-4">
<img
src="/img/supabase-logo.svg"
alt="Supabase"
className="block h-[24px] cursor-pointer rounded"
/>
</a>
</Link>
<div
className="
bg-scale-100 border-scale-400 mx-auto overflow-hidden
rounded-md border text-center shadow
md:w-[400px]
"
>
<Loading active={!tokenInfoLoaded}>
<Content />
</Loading>
</div>
</div>
)
}
export default JoinOrganizationPage
+143 -32
View File
@@ -8,6 +8,7 @@ import { organizations } from 'stores/jsonSchema'
import {
Loading,
Button,
Badge,
IconMoreHorizontal,
Tabs,
Typography,
@@ -17,6 +18,7 @@ import {
Dropdown,
Modal,
IconSearch,
IconUser,
Form,
} from '@supabase/ui'
@@ -29,15 +31,26 @@ import { BillingSettings, InvoicesSettings } from 'components/interfaces/Organiz
import Table from 'components/to-be-cleaned/Table'
import Panel from 'components/ui/Panel'
import { confirmAlert } from 'components/to-be-cleaned/ModalsDeprecated/ConfirmModal'
import InviteMemberModal from 'components/to-be-cleaned/ModalsDeprecated/InviteMemberModal'
import InviteMemberModal from 'components/interfaces/Organization/InviteMemberModal'
import TextConfirmModal from 'components/ui/Modals/TextConfirmModal'
import SchemaFormPanel from 'components/to-be-cleaned/forms/SchemaFormPanel'
import { NextPageWithLayout, Project } from 'types'
import { Member, NextPageWithLayout, Project } from 'types'
import Image from 'next/image'
// [Joshen] Low prio refactor: Bring out general and team settings into their own components too
const PageContext = createContext(null)
// Invite is expired if older than 24hrs
function inviteExpired(timestamp: Date) {
const inviteDate = new Date(timestamp)
const now = new Date()
var timeBetween = now.valueOf() - inviteDate.valueOf()
if (timeBetween / 1000 / 60 / 60 < 24) {
return true
}
}
const OrgSettingsLayout = withAuth(
observer(({ children }) => {
const { app, ui } = useStore()
@@ -57,10 +70,15 @@ const OrgSettingsLayout = withAuth(
get filteredMembers() {
const temp = this.members.filter((x: any) => {
let profile = x.profile
return (
profile.username.includes(this.membersFilterString) ||
profile.primary_email.includes(this.membersFilterString)
)
if (profile) {
return (
profile.username.includes(this.membersFilterString) ||
profile.primary_email.includes(this.membersFilterString)
)
}
if (x.invited_email) {
return x.invited_email.includes(this.membersFilterString)
}
})
return temp.sort((a: any, b: any) => a.profile.username.localeCompare(b.profile.username))
},
@@ -123,6 +141,7 @@ export default observer(OrgSettings)
const OrganizationSettings = observer(() => {
const PageState: any = useContext(PageContext)
const { ui } = useStore()
const {
members,
products,
@@ -426,6 +445,7 @@ const TeamSettings = observer(() => {
<InviteMemberModal
organization={PageState.organization}
members={PageState.members}
user={PageState.user}
/>
</div>
) : (
@@ -473,6 +493,7 @@ const MembersView = observer(() => {
<Table
head={[
<Table.th key="header-user">User</Table.th>,
<Table.th key="header-status"></Table.th>,
<Table.th key="header-role">Role</Table.th>,
<Table.th key="header-action"></Table.th>,
]}
@@ -482,19 +503,41 @@ const MembersView = observer(() => {
<Table.td>
<div className="flex items-center space-x-4">
<div>
<img
src={`https://github.com/${x.profile.username}.png?size=80`}
width="40"
className="border-border-secondary-light dark:border-border-secondary-dark rounded-full border"
/>
{x.invited_id ? (
<span className="border-border-secondary-light dark:border-border-secondary-dark flex rounded-full border-2 p-2">
<IconUser size={18} strokeWidth={2} />
</span>
) : (
<Image
src={`https://github.com/${x.profile?.username}.png?size=80`}
width="40"
height="40"
className="border-border-secondary-light dark:border-border-secondary-dark rounded-full border"
/>
)}
</div>
<div>
<Typography.Text>{x.profile.username}</Typography.Text>
<br />
<Typography.Text type="secondary">{x.profile.primary_email}</Typography.Text>
{x.profile?.username && !x.invited_id && (
<>
<Typography.Text>{x.profile?.username}</Typography.Text>
<br />
</>
)}
<Typography.Text type="secondary">
{x.profile ? x.profile.primary_email : ''}
</Typography.Text>
</div>
</div>
</Table.td>
<Table.td>
{x.invited_id && (
<Badge color={inviteExpired(x.invited_at) ? 'yellow' : 'red'}>
{inviteExpired(x.invited_at) ? 'Invited' : 'Expired'}
</Badge>
)}
</Table.td>
<Table.td>
<Typography.Text type="secondary">
{x.is_owner ? 'Owner' : 'Developer'}
@@ -515,7 +558,7 @@ const MembersView = observer(() => {
className="bg-panel-secondary-light dark:bg-panel-secondary-dark"
>
{/* @ts-ignore */}
<Table.td colSpan="3">
<Table.td colSpan="4">
<Typography.Text type="secondary">
{PageState.membersFilterString ? `${PageState.filteredMembers.length} of ` : ''}
{PageState.members.length || '0'}{' '}
@@ -542,17 +585,13 @@ const OwnerDropdown = observer(({ members, member }: any) => {
const { id: orgId, slug: orgSlug, stripe_customer_id, name: orgName } = PageState.organization
function toggleDropDown() {
setIsOpen(!isOpen)
}
async function handleMemberDelete() {
setIsOpen(false)
await timeout(200)
confirmAlert({
title: 'Confirm to remove',
message: `This is permanent! Are you sure you want to remove ${member.profile.username} ?`,
message: `This is permanent! Are you sure you want to remove ${member.profile.primary_email}?`,
onAsyncConfirm: async () => {
setLoading(true)
const response = await delete_(`${API_URL}/organizations/${orgSlug}/members/remove`, {
@@ -579,7 +618,6 @@ const OwnerDropdown = observer(({ members, member }: any) => {
const response = await post(`${API_URL}/organizations/${orgSlug}/transfer`, {
org_id: orgId,
member_id: member.id,
stripe_customer_id,
})
if (response.error) {
ui.setNotification({
@@ -599,6 +637,49 @@ const OwnerDropdown = observer(({ members, member }: any) => {
}
}
async function handleResendInvite(member: Member) {
setLoading(true)
const response = await post(`${API_URL}/organizations/${orgSlug}/members/invite`, {
invited_email: member.profile.primary_email,
owner_id: member.invited_id,
})
if (response.error) {
ui.setNotification({
category: 'error',
message: `Failed to resend invitation: ${response.error.message}`,
})
setLoading(false)
} else {
const updatedMembers = [...members]
mutateOrgMembers(updatedMembers)
ui.setNotification({ category: 'success', message: 'Resent the invitation.' })
setLoading(false)
}
}
async function handleRevokeInvitation(id: number) {
setLoading(true)
const response = await delete_(
`${API_URL}/organizations/${orgSlug}/members/invite?invited_id=${id}`,
{}
)
if (response.error) {
ui.setNotification({
category: 'error',
message: `Failed to revoke invitation: ${response.error.message}`,
})
setLoading(false)
} else {
const updatedMembers = [...members]
mutateOrgMembers(updatedMembers)
ui.setNotification({ category: 'success', message: 'Successfully revoked the invitation.' })
}
}
return (
<div className="flex items-center justify-end">
<Dropdown
@@ -606,16 +687,46 @@ const OwnerDropdown = observer(({ members, member }: any) => {
align="end"
overlay={
<>
<Dropdown.Item onClick={() => setOwnerTransferIsVisble(!ownerTransferIsVisble)}>
<div className="flex flex-col">
<p>Make owner</p>
<p className="block opacity-50">Transfer ownership of "{orgName}"</p>
</div>
</Dropdown.Item>
<Dropdown.Seperator />
<Dropdown.Item icon={<IconTrash size="tiny" />} onClick={handleMemberDelete}>
Remove member
</Dropdown.Item>
{!member.invited_at && (
<Dropdown.Item onClick={() => setOwnerTransferIsVisble(!ownerTransferIsVisble)}>
<div className="flex flex-col">
<p>Make owner</p>
<p className="block opacity-50">Transfer ownership of "{orgName}"</p>
</div>
</Dropdown.Item>
)}
{member.invited_at && (
<>
<Dropdown.Item onClick={() => handleRevokeInvitation(member.invited_id)}>
<div className="flex flex-col">
<p>Cancel invitation</p>
<p className="block opacity-50">Revoke this invitation.</p>
</div>
</Dropdown.Item>
{!inviteExpired(member.invited_at) && (
<>
<Dropdown.Seperator />
<Dropdown.Item onClick={() => handleResendInvite(member)}>
<div className="flex flex-col">
<p>Resend invitation</p>
<p className="block opacity-50">Invites expire after 24hrs.</p>
</div>
</Dropdown.Item>
</>
)}
</>
)}
{!member.invited_at && (
<>
<Dropdown.Seperator />
<Dropdown.Item icon={<IconTrash size="tiny" />} onClick={handleMemberDelete}>
Remove member
</Dropdown.Item>
</>
)}
</>
}
>
@@ -641,7 +752,7 @@ const OwnerDropdown = observer(({ members, member }: any) => {
text={
<span>
By transferring this organization, it will be solely owned by{' '}
<span className="font-medium dark:text-white">{member.profile.username}</span>, they
<span className="font-medium dark:text-white">{member.profile?.username}</span>, they
will also be able to remove you from the organization as a member
</span>
}
+3
View File
@@ -135,6 +135,8 @@ export default class MetaStore implements IMetaStore {
projectRef?: string
connectionString?: string
baseUrl: string
// [Joshen] I'm going to treat this as a list of system schemas
excludedSchemas = [
'auth',
'extensions',
@@ -146,6 +148,7 @@ export default class MetaStore implements IMetaStore {
'storage',
'supabase_functions',
'graphql',
'graphql_public',
]
constructor(rootStore: IRootStore, options: { projectRef: string; connectionString: string }) {
+2
View File
@@ -55,6 +55,8 @@ export interface Member {
primary_email: string
username: string
}
invited_id?: number
invited_at?: string
}
export interface ResponseError {
+31
View File
@@ -160,6 +160,37 @@ Error querying the database: db error: ERROR: prepared statement “s0” alread
This is a known issue and it is being worked on, you can follow the progress on this [GitHub issue](https://github.com/prisma/prisma/issues/6485).
If you want to learn more about Prisma, check out the [docs](https://www.prisma.io/docs). Also in case you have any questions or run into any issue, feel free to start a discussion in the repo’s [discussions section](https://github.com/prisma/prisma/discussions).
## Troubleshooting
If you run `prisma migrate dev --name init` multiple times, it sometimes asks if you want to recreate the whole schema. If you chose yes, it will delete the public schema and recreates it. The default grants are missing after this. If you run into this problem, add a helper SQL for fixing the grants:
```sql
CREATE SCHEMA IF NOT EXISTS "auth";
CREATE SCHEMA IF NOT EXISTS "extensions";
create extension if not exists "uuid-ossp" with schema extensions;
create extension if not exists pgcrypto with schema extensions;
create extension if not exists pgjwt with schema extensions;
grant usage on schema public to postgres, anon, authenticated, service_role;
grant usage on schema extensions to postgres, anon, authenticated, service_role;
alter user supabase_admin SET search_path TO public, extensions; -- don't include the "auth" schema
grant all privileges on all tables in schema public to postgres, anon, authenticated, service_role, supabase_admin;
grant all privileges on all functions in schema public to postgres, anon, authenticated, service_role, supabase_admin;
grant all privileges on all sequences in schema public to postgres, anon, authenticated, service_role, supabase_admin;
alter default privileges in schema public grant all on tables to postgres, anon, authenticated, service_role;
alter default privileges in schema public grant all on functions to postgres, anon, authenticated, service_role;
alter default privileges in schema public grant all on sequences to postgres, anon, authenticated, service_role;
alter default privileges for user supabase_admin in schema public grant all on sequences to postgres, anon, authenticated, service_role;
alter default privileges for user supabase_admin in schema public grant all on tables to postgres, anon, authenticated, service_role;
alter default privileges for user supabase_admin in schema public grant all on functions to postgres, anon, authenticated, service_role;
alter role anon set statement_timeout = '3s';
alter role authenticated set statement_timeout = '8s';
```
## Resources
- [Prisma](https://prisma.io) official website.
+7 -7
View File
@@ -13,7 +13,7 @@
"@kiwicopple/prism-react-renderer": "https://github.com/kiwicopple/prism-react-renderer.git",
"@octokit/core": "^3.5.1",
"docusaurus-theme-search-typesense": "^0.4.0",
"jsrsasign": "^10.4.1",
"jsrsasign": "^10.5.25",
"mermaid": "^8.13.8",
"react": "^17.0.2",
"react-dom": "^17.0.2",
@@ -8609,9 +8609,9 @@
}
},
"node_modules/jsrsasign": {
"version": "10.5.24",
"resolved": "https://registry.npmjs.org/jsrsasign/-/jsrsasign-10.5.24.tgz",
"integrity": "sha512-0i/UHRgJZifp/YmoXHyNQXUY4eKWiSd7YxuD7oKEw9mlqgr51hg9lZQw2nlEDvwHDh7pyj6ZjYlxldlW27xb/Q==",
"version": "10.5.25",
"resolved": "https://registry.npmjs.org/jsrsasign/-/jsrsasign-10.5.25.tgz",
"integrity": "sha512-N7zxHaCwYvFlXsybq4p4RxRwn4AbEq3cEiyjbCrWmwA7g8aS4LTKDJ9AJmsXxwtYesYx0imJ+ITtkyyxLCgeIg==",
"funding": {
"url": "https://github.com/kjur/jsrsasign#donations"
}
@@ -20289,9 +20289,9 @@
}
},
"jsrsasign": {
"version": "10.5.24",
"resolved": "https://registry.npmjs.org/jsrsasign/-/jsrsasign-10.5.24.tgz",
"integrity": "sha512-0i/UHRgJZifp/YmoXHyNQXUY4eKWiSd7YxuD7oKEw9mlqgr51hg9lZQw2nlEDvwHDh7pyj6ZjYlxldlW27xb/Q=="
"version": "10.5.25",
"resolved": "https://registry.npmjs.org/jsrsasign/-/jsrsasign-10.5.25.tgz",
"integrity": "sha512-N7zxHaCwYvFlXsybq4p4RxRwn4AbEq3cEiyjbCrWmwA7g8aS4LTKDJ9AJmsXxwtYesYx0imJ+ITtkyyxLCgeIg=="
},
"keyv": {
"version": "3.1.0",
+1 -1
View File
@@ -20,7 +20,7 @@
"@kiwicopple/prism-react-renderer": "https://github.com/kiwicopple/prism-react-renderer.git",
"@octokit/core": "^3.5.1",
"docusaurus-theme-search-typesense": "^0.4.0",
"jsrsasign": "^10.4.1",
"jsrsasign": "^10.5.25",
"mermaid": "^8.13.8",
"react": "^17.0.2",
"react-dom": "^17.0.2",