mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 18:05:11 +03:00
135 files changed
+4502
-39450
No files matched your search
+1
-1
@@ -1 +1 @@
|
||||
apps/reference/static/.well-known/security.txt
|
||||
apps/docs/public/.well-known/security.txt
|
||||
@@ -5,26 +5,26 @@ from `public.profiles` and the older versions getting orphaned.
|
||||
To automatically remove obsolete storage objects, extend the database
|
||||
triggers. Note that it is not sufficient to delete the objects from the
|
||||
`storage.objects` table because that would orphan and leak the actual storage objects in
|
||||
the S3 backend. Instead, invoke the storage API within Postgres via the `http` extension.
|
||||
the S3 backend. Instead, invoke the storage API within Postgres via the `http` extension.
|
||||
|
||||
Enable the [http extension for the `extensions` schema](https://app.supabase.com/project/_/database/extensions) in the Dashboard.
|
||||
Then, define the following SQL functions in the SQL Editor to delete
|
||||
storage objects via the API:
|
||||
|
||||
```SQL
|
||||
create or replace function delete_storage_object(bucket text, object text, out status int, out content varchar)
|
||||
create or replace function delete_storage_object(bucket text, object text, out status int, out content text)
|
||||
returns record
|
||||
language 'plpgsql'
|
||||
security definer
|
||||
as $$
|
||||
declare
|
||||
project_url varchar := '<YOURPROJECTURL>';
|
||||
service_role_key varchar := '<YOURSERVICEROLEKEY>'; -- full access needed
|
||||
url varchar := project_url||'/storage/v1/object/'||bucket||'/'||object;
|
||||
project_url text := '<YOURPROJECTURL>';
|
||||
service_role_key text := '<YOURSERVICEROLEKEY>'; -- full access needed
|
||||
url text := project_url||'/storage/v1/object/'||bucket||'/'||object;
|
||||
begin
|
||||
select
|
||||
into status, content
|
||||
result.status::int, result.content::varchar
|
||||
result.status::int, result.content::text
|
||||
FROM extensions.http((
|
||||
'DELETE',
|
||||
url,
|
||||
@@ -34,7 +34,7 @@ begin
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function delete_avatar(avatar_url text, out status int, out content varchar)
|
||||
create or replace function delete_avatar(avatar_url text, out status int, out content text)
|
||||
returns record
|
||||
language 'plpgsql'
|
||||
security definer
|
||||
@@ -60,7 +60,7 @@ security definer
|
||||
as $$
|
||||
declare
|
||||
status int;
|
||||
content varchar;
|
||||
content text;
|
||||
begin
|
||||
if coalesce(old.avatar_url, '') <> ''
|
||||
and (tg_op = 'DELETE' or (old.avatar_url <> new.avatar_url)) then
|
||||
|
||||
@@ -685,6 +685,11 @@ export const platform = {
|
||||
{ name: 'SSL Enforcement', url: '/guides/platform/ssl-enforcement', items: [] },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Billing',
|
||||
url: undefined,
|
||||
items: [{ name: 'Spend cap', url: '/guides/platform/spend-cap', items: [] }],
|
||||
},
|
||||
{
|
||||
name: 'Single sign-on',
|
||||
url: undefined,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Button, Tabs, Alert, GlassPanel } from 'ui'
|
||||
import Link from 'next/link'
|
||||
import StepHikeCompact from '~/components/StepHikeCompact'
|
||||
|
||||
// Common components
|
||||
import Admonition from './Admonition'
|
||||
@@ -50,6 +51,7 @@ const components = {
|
||||
ProjectSetup,
|
||||
SocialProviderSetup,
|
||||
SocialProviderSettingsSupabase,
|
||||
StepHikeCompact,
|
||||
StorageManagement,
|
||||
Mermaid,
|
||||
Extensions,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Head from 'next/head'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
@@ -30,51 +31,78 @@ const RefSectionHandler = (props: RefSectionHandlerProps) => {
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<RefSubLayout>
|
||||
{props.sections.map((x, i) => {
|
||||
switch (x.type) {
|
||||
case 'markdown':
|
||||
const markdownData = props.pageProps.docs.find((doc) => doc.id === x.id)
|
||||
function getPageTitle() {
|
||||
switch (props.type) {
|
||||
case 'client-lib':
|
||||
return props.spec.info.title
|
||||
case 'cli':
|
||||
return 'Supabase CLI reference'
|
||||
case 'api':
|
||||
return 'Supabase API reference'
|
||||
default:
|
||||
return 'Supabase Docs'
|
||||
}
|
||||
}
|
||||
|
||||
return <RefEducationSection key={x.id + i} item={x} markdownContent={markdownData} />
|
||||
break
|
||||
case 'function':
|
||||
return (
|
||||
<RefFunctionSection
|
||||
key={x.id + i}
|
||||
funcData={x}
|
||||
commonFuncData={x}
|
||||
spec={props.spec}
|
||||
typeSpec={props.typeSpec}
|
||||
/>
|
||||
)
|
||||
case 'cli-command':
|
||||
return <CliCommandSection key={x.id + i} funcData={x} commonFuncData={x} />
|
||||
break
|
||||
case 'operation':
|
||||
return (
|
||||
<ApiOperationSection
|
||||
key={x.id + i}
|
||||
funcData={x}
|
||||
commonFuncData={x}
|
||||
spec={props.spec}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<RefFunctionSection
|
||||
key={x.id + i}
|
||||
funcData={x}
|
||||
commonFuncData={x}
|
||||
spec={props.spec}
|
||||
typeSpec={props.typeSpec}
|
||||
/>
|
||||
)
|
||||
break
|
||||
}
|
||||
})}
|
||||
</RefSubLayout>
|
||||
const pageTitle = getPageTitle()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{pageTitle}</title>
|
||||
<meta name="description" content={pageTitle} />
|
||||
<meta property="og:image" content={`https://supabase.com/docs/img/supabase-og-image.png`} />
|
||||
<meta
|
||||
name="twitter:image"
|
||||
content={`https://supabase.com/docs/img/supabase-og-image.png`}
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
</Head>
|
||||
<RefSubLayout>
|
||||
{props.sections.map((x, i) => {
|
||||
switch (x.type) {
|
||||
case 'markdown':
|
||||
const markdownData = props.pageProps.docs.find((doc) => doc.id === x.id)
|
||||
|
||||
return <RefEducationSection key={x.id + i} item={x} markdownContent={markdownData} />
|
||||
break
|
||||
case 'function':
|
||||
return (
|
||||
<RefFunctionSection
|
||||
key={x.id + i}
|
||||
funcData={x}
|
||||
commonFuncData={x}
|
||||
spec={props.spec}
|
||||
typeSpec={props.typeSpec}
|
||||
/>
|
||||
)
|
||||
case 'cli-command':
|
||||
return <CliCommandSection key={x.id + i} funcData={x} commonFuncData={x} />
|
||||
break
|
||||
case 'operation':
|
||||
return (
|
||||
<ApiOperationSection
|
||||
key={x.id + i}
|
||||
funcData={x}
|
||||
commonFuncData={x}
|
||||
spec={props.spec}
|
||||
/>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<RefFunctionSection
|
||||
key={x.id + i}
|
||||
funcData={x}
|
||||
commonFuncData={x}
|
||||
spec={props.spec}
|
||||
typeSpec={props.typeSpec}
|
||||
/>
|
||||
)
|
||||
break
|
||||
}
|
||||
})}
|
||||
</RefSubLayout>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import TopNavBarRef from '~/components/Navigation/NavigationMenu/TopNavBarRef'
|
||||
import { memo, useEffect } from 'react'
|
||||
import Footer from '~/components/Navigation/Footer'
|
||||
import { menuState, useMenuLevelId, useMenuMobileOpen } from '~/hooks/useMenuState'
|
||||
import Head from 'next/head'
|
||||
|
||||
const levelsData = {
|
||||
home: {
|
||||
@@ -312,32 +313,37 @@ const SiteLayout = ({ children }) => {
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="flex flex-row h-screen">
|
||||
<NavContainer />
|
||||
<Container>
|
||||
<div className={['lg:sticky top-0 z-10 overflow-hidden'].join(' ')}>
|
||||
<TopNavBarRef />
|
||||
</div>
|
||||
<div
|
||||
className={[
|
||||
'sticky transition-all top-0',
|
||||
'z-10',
|
||||
'backdrop-blur backdrop-filter bg-white-1200 dark:bg-blackA-300',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className={['lg:hidden', 'px-5 ', 'border-b z-10'].join(' ')}>
|
||||
<MobileHeader />
|
||||
<>
|
||||
<Head>
|
||||
<title>Supabase Docs</title>
|
||||
</Head>
|
||||
<main>
|
||||
<div className="flex flex-row h-screen">
|
||||
<NavContainer />
|
||||
<Container>
|
||||
<div className={['lg:sticky top-0 z-10 overflow-hidden'].join(' ')}>
|
||||
<TopNavBarRef />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grow px-5 max-w-7xl mx-auto py-16">
|
||||
{children}
|
||||
<Footer />
|
||||
</div>
|
||||
<MobileMenuBackdrop />
|
||||
</Container>
|
||||
</div>
|
||||
</main>
|
||||
<div
|
||||
className={[
|
||||
'sticky transition-all top-0',
|
||||
'z-10',
|
||||
'backdrop-blur backdrop-filter bg-white-1200 dark:bg-blackA-300',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className={['lg:hidden', 'px-5 ', 'border-b z-10'].join(' ')}>
|
||||
<MobileHeader />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grow px-5 max-w-7xl mx-auto py-16">
|
||||
{children}
|
||||
<Footer />
|
||||
</div>
|
||||
<MobileMenuBackdrop />
|
||||
</Container>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ const Layout: FC<Props> = (props) => {
|
||||
// page type, ie, Auth, Database, Storage etc
|
||||
const ogPageType = asPath.split('/')[2]
|
||||
// open graph image url constructor
|
||||
const ogImageUrl = encodeURIComponent(
|
||||
const ogImageUrl = encodeURI(
|
||||
`https://obuldanrptloktxcffvn.functions.supabase.co/og-images?site=docs${
|
||||
ogPageType ? `&type=${ogPageType}` : ''
|
||||
}&title=${props.meta?.title}&description=${props.meta?.description}`
|
||||
|
||||
@@ -37,7 +37,6 @@ const RefSubLayoutNonFunc: FC<RefSubLayoutNonFuncType> & RefSubLayoutNonFuncSubC
|
||||
}
|
||||
|
||||
const Section: FC<ISectionContainer> = (props) => {
|
||||
// console.log({ props })
|
||||
return (
|
||||
<article
|
||||
key={props.id}
|
||||
|
||||
@@ -158,7 +158,7 @@ import { createMiddlewareSupabaseClient } from '@supabase/auth-helpers-nextjs'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
import type { NextRequest } from 'next/server'
|
||||
import type { Database } from '../lib/database.types'
|
||||
import type { Database } from './lib/database.types'
|
||||
|
||||
export async function middleware(req: NextRequest) {
|
||||
const res = NextResponse.next()
|
||||
|
||||
@@ -185,7 +185,8 @@ export default async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
For [row level security](/docs/learn/auth-deep-dive/auth-row-level-security) to work properly when fetching data client-side, you need to make sure to use the `supabaseClient` from the `useSupabaseClient` hook and only run your query once the user is defined client-side in the `useUser()` hook:
|
||||
|
||||
```jsx lines=10-17
|
||||
import { Auth, ThemeSupa } from '@supabase/auth-ui-react'
|
||||
import { Auth } from '@supabase/auth-ui-react'
|
||||
import { ThemeSupa } from '@supabase/auth-ui-shared'
|
||||
import { useUser, useSupabaseClient } from '@supabase/auth-helpers-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
|
||||
@@ -91,13 +91,12 @@ export const load: LayoutServerLoad = async ({ locals: { getSession } }) => {
|
||||
};
|
||||
```
|
||||
|
||||
#### Shared Load functions and pages
|
||||
### Shared Load functions and pages
|
||||
|
||||
To be able to use Supabase in shared load functions and inside pages you need to create a Supabase client in the root layout load:
|
||||
|
||||
```ts
|
||||
// src/routes/+layout.ts
|
||||
import { invalidate } from '$app/navigation';
|
||||
import {
|
||||
PUBLIC_SUPABASE_ANON_KEY,
|
||||
PUBLIC_SUPABASE_URL
|
||||
@@ -113,10 +112,7 @@ export const load: LayoutLoad = async ({ fetch, data, depends }) => {
|
||||
supabaseUrl: PUBLIC_SUPABASE_URL,
|
||||
supabaseKey: PUBLIC_SUPABASE_ANON_KEY,
|
||||
event: { fetch },
|
||||
serverSession: data.session,
|
||||
onAuthStateChange() {
|
||||
invalidate('supabase:auth');
|
||||
}
|
||||
serverSession: data.session
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -129,10 +125,41 @@ export const load: LayoutLoad = async ({ fetch, data, depends }) => {
|
||||
|
||||
Access the client inside pages by `$page.data.supabase` or `data.supabase` when using `export let data: PageData`.
|
||||
|
||||
The usage of `depends` and `invalidate` tells sveltekit that this load function should be executed whenever the session updates to keep the page store in sync.
|
||||
The usage of `depends` tells sveltekit that this load function should be executed whenever `invalidate` is called to keep the page store in sync.
|
||||
|
||||
`createSupabaseLoadClient` caches the client when running in a browser environment and therefore does not create a new client for every time the load function runs.
|
||||
|
||||
### Setting up the event listener on the client side
|
||||
|
||||
We need to create an event listener in the root `+layout.svelte` file in order catch supabase events being triggered.
|
||||
|
||||
```svelte
|
||||
<!-- src/routes/+layout.svelte -->
|
||||
<script lang="ts">
|
||||
import { invalidate } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import type { LayoutData } from './$types';
|
||||
|
||||
export let data: LayoutData;
|
||||
|
||||
$: ({ supabase } = data);
|
||||
|
||||
onMount(() => {
|
||||
const {
|
||||
data: { subscription },
|
||||
} = supabase.auth.onAuthStateChange(() => {
|
||||
invalidate('supabase:auth');
|
||||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
});
|
||||
</script>
|
||||
|
||||
<slot />
|
||||
```
|
||||
|
||||
The usage of `invalidate` tells sveltekit that the root `+layout.ts` load function should be executed whenever the session updates to keep the page store in sync.
|
||||
|
||||
### Generate types from your database
|
||||
|
||||
In order to get the most out of TypeScript and it's intellisense, you should import the generated Database types into the `app.d.ts` type definition file that comes with your SvelteKit project, where `import('./DatabaseDefinitions')` points to the generated types file outlined in [v2 docs here](https://supabase.com/docs/reference/javascript/release-notes#typescript-support) after you have logged in, linked, and generated types through the Supabase CLI.
|
||||
@@ -183,7 +210,7 @@ You can now determine if a user is authenticated on the client-side by checking
|
||||
For [row level security](https://supabase.com/docs/guides/auth/row-level-security) to work properly when fetching data client-side, you need to use `supabaseClient` from `PageData` and only run your query once the session is defined client-side:
|
||||
|
||||
```html
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
|
||||
export let data: PageData;
|
||||
@@ -272,7 +299,7 @@ Wrap an Action to check that the user has a valid session. If they're not logged
|
||||
```ts
|
||||
// src/routes/posts/+page.server.ts
|
||||
import type { Actions } from './$types';
|
||||
import { error, invalid } from '@sveltejs/kit';
|
||||
import { error, fail } from '@sveltejs/kit';
|
||||
|
||||
export const actions: Actions = {
|
||||
createPost: async ({ request, locals: { supabase, getSession } }) => {
|
||||
@@ -291,7 +318,7 @@ export const actions: Actions = {
|
||||
.insert({ content });
|
||||
|
||||
if (createPostError) {
|
||||
return invalid(500, {
|
||||
return fail(500, {
|
||||
supabaseErrorMessage: createPostError.message
|
||||
});
|
||||
}
|
||||
@@ -308,7 +335,7 @@ If you try to submit a form with the action `?/createPost` without a valid sessi
|
||||
|
||||
```ts
|
||||
import type { Actions } from './$types';
|
||||
import { invalid, redirect } from '@sveltejs/kit';
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { AuthApiError } from '@supabase/supabase-js';
|
||||
|
||||
export const actions: Actions = {
|
||||
@@ -325,14 +352,14 @@ export const actions: Actions = {
|
||||
|
||||
if (error) {
|
||||
if (error instanceof AuthApiError && error.status === 400) {
|
||||
return invalid(400, {
|
||||
return fail(400, {
|
||||
error: 'Invalid credentials.',
|
||||
values: {
|
||||
email
|
||||
}
|
||||
});
|
||||
}
|
||||
return invalid(500, {
|
||||
return fail(500, {
|
||||
error: 'Server error. Try again later.',
|
||||
values: {
|
||||
email
|
||||
@@ -461,7 +488,7 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
|
||||
### Initialize the client [#migration-initialize-client]
|
||||
|
||||
In order to use the Supabase library in your client code you will need to setup a shared load function inside the root `+layout.ts` file.
|
||||
In order to use the Supabase library in your client code you will need to setup a shared load function inside the root `+layout.ts` and create a `+layout.svelte` to handle our event listening for Auth events.
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
@@ -473,7 +500,7 @@ In order to use the Supabase library in your client code you will need to setup
|
||||
|
||||
```html title=src/routes/+layout.svelte
|
||||
<!-- src/routes/+layout.svelte -->
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { supabaseClient } from '$lib/db'
|
||||
import { invalidate } from '$app/navigation'
|
||||
import { onMount } from 'svelte'
|
||||
@@ -515,10 +542,7 @@ export const load: LayoutLoad = async ({ fetch, data, depends }) => {
|
||||
supabaseUrl: PUBLIC_SUPABASE_URL,
|
||||
supabaseKey: PUBLIC_SUPABASE_ANON_KEY,
|
||||
event: { fetch },
|
||||
serverSession: data.session,
|
||||
onAuthStateChange() {
|
||||
invalidate('supabase:auth');
|
||||
}
|
||||
serverSession: data.session
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -529,6 +553,31 @@ export const load: LayoutLoad = async ({ fetch, data, depends }) => {
|
||||
};
|
||||
```
|
||||
|
||||
```svelte title=src/routes/+layout.svelte
|
||||
<!-- src/routes/+layout.svelte -->
|
||||
<script lang="ts">
|
||||
import { invalidate } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import type { LayoutData } from './$types';
|
||||
|
||||
export let data: LayoutData;
|
||||
|
||||
$: ({ supabase } = data);
|
||||
|
||||
onMount(() => {
|
||||
const {
|
||||
data: { subscription },
|
||||
} = supabase.auth.onAuthStateChange(() => {
|
||||
invalidate('supabase:auth');
|
||||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
});
|
||||
</script>
|
||||
|
||||
<slot />
|
||||
```
|
||||
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
@@ -606,7 +655,7 @@ declare global {
|
||||
|
||||
```html title=src/routes/profile/+page.svelte
|
||||
<!-- src/routes/profile/+page.svelte -->
|
||||
<script>
|
||||
<script lang="ts">
|
||||
/** @type {import('./$types').PageData} */
|
||||
export let data
|
||||
$: ({ user, tableData } = data)
|
||||
|
||||
@@ -328,6 +328,9 @@ Tip: Make sure to enable RLS for all your tables, so that your tables are inacce
|
||||
|
||||
Supabase provides special "Service" keys, which can be used to bypass all RLS.
|
||||
These should never be used in the browser or exposed to customers, but they are useful for administrative tasks.
|
||||
<Admonition type="caution">
|
||||
Initializing a client with a Service Key will not override RLS if a user token is set. If a user is signed in with a client, Supabase will adhere to the RLS policy of the user.
|
||||
</Admonition>
|
||||
|
||||
### Testing policies
|
||||
|
||||
|
||||
@@ -160,7 +160,7 @@ curl -X POST \
|
||||
-H 'apikey: <anon-jwt>' \
|
||||
-H 'Authorization: Bearer <service_role-jwt>' \
|
||||
--data-binary '@/tmp/body.json' \
|
||||
'https://<project>.supabase.co/auth/v1/admin/sso/providers/<provider-uuid>'
|
||||
'https://<project>.supabase.co/auth/v1/admin/sso/providers'
|
||||
```
|
||||
|
||||
To create the `/tmp/body.json` file you can use this:
|
||||
|
||||
@@ -7,7 +7,23 @@ export const meta = {
|
||||
video: 'https://www.youtube.com/v/vyHyYpvjaks',
|
||||
}
|
||||
|
||||
Learn how to use the Supabase CLI to develop your project locally and deploy to the Supabase Platform.
|
||||
Supabase is a flexible platform that lets you decide how you want to build your projects. You can use the Dashboard directly to get up and running quickly, or use a proper local setup. We suggest you work locally and deploy your changes to a linked project on the [Supabase Platform](https://app.supabase.io/).
|
||||
|
||||
Doing things directly on the platform via the [Dashboard](https://app.supabase.io/) is fine when you're getting started, but it's a good idea to move to a proper local workflow before you get too far. Working locally, generating migrations as you change your tables, and appling those migrations to a linked project on the [Platform](https://app.supabase.io/) keeps everything nicely organized as you grow.
|
||||
|
||||
## Why develop locally?
|
||||
|
||||
The Dashboard provides a wide range of features for setting up your project: creating tables, adding columns, changing existing columns, creating views, setting up RLS policies, and more. Given all of the Dashboard's capabilities, you might question the need to work locally. Here's a few advantages to working this way:
|
||||
|
||||
1. **Faster Development**: Developing locally allows you to work without any network latency or internet disruptions.
|
||||
|
||||
2. **Easier Collaboration**: Developing locally can make it easier to collaborate with others on the same project.
|
||||
|
||||
3. **Cost-Effective**: Supabase provides a generous free tier and gives you two free projects to get started. But what if you need more than two? When you develop locally, you can spin up unlimited local projects and link them with live projects when you're ready to launch.
|
||||
|
||||
4. **Configuration in code**: If you directly change your tables via the Dashboard, none of that gets captured in code. If you follow these local development practices, you'll store all of your table schemas in code.
|
||||
|
||||
5. **Work offline**: Need to work from a train? A plain? An automobile? No problem. Developing your project locally allows you to work offline.
|
||||
|
||||
<div className="video-container">
|
||||
<iframe
|
||||
@@ -49,7 +65,7 @@ mkdir your-project
|
||||
# move into the new folder
|
||||
cd your-project
|
||||
|
||||
# start a new git repository
|
||||
# start a new git repository — important, don't skip this step
|
||||
git init
|
||||
```
|
||||
|
||||
@@ -68,12 +84,26 @@ This command may take a while to run if this is the first time using the CLI.
|
||||
supabase start
|
||||
```
|
||||
|
||||
Once all of the Supabase services are running, you'll see output containing your local Supabase credentials.
|
||||
You can use the [stop](/docs/reference/cli/usage#supabase-stop) command at any time to stop all services.
|
||||
Once all of the Supabase services are running, you'll see output containing your local Supabase credentials. It should look like this, with urls and keys that you'll use in your local project:
|
||||
|
||||
## Access services
|
||||
```
|
||||
|
||||
You can access services directly with any Postgres client or through the API Gateway ([Kong](https://github.com/Kong/kong)).
|
||||
Started supabase local development setup.
|
||||
|
||||
API URL: http://localhost:54321
|
||||
DB URL: postgresql://postgres:postgres@localhost:54322/postgres
|
||||
Studio URL: http://localhost:54323
|
||||
Inbucket URL: http://localhost:54324
|
||||
anon key: eyJh......
|
||||
service_role key: eyJh......
|
||||
|
||||
```
|
||||
|
||||
You can use the [supabase stop](/docs/reference/cli/usage#supabase-stop) command at any time to stop all services.
|
||||
|
||||
## Access your project's services
|
||||
|
||||
You can now visit your local Dashboard at [http://localhost:54323](http://localhost:54323), and access the database directly with any Postgres client via `postgresql://postgres:postgres@localhost:54322/postgres`.
|
||||
|
||||
<Tabs
|
||||
scrollable
|
||||
@@ -147,95 +177,218 @@ Database changes are managed through "migrations." Database migrations are a com
|
||||
></iframe>
|
||||
</div>
|
||||
|
||||
### Make database changes
|
||||
For this guide, we'll create a table called `employees` and see how we can make changes to it.
|
||||
|
||||
For this guide, create a table called `employees`. In Supabase Studio, navigate to the **SQL Editor** page and run the following SQL command:
|
||||
<StepHikeCompact>
|
||||
|
||||
<StepHikeCompact.Step step={1}>
|
||||
<StepHikeCompact.Details title="Create your first migration file">
|
||||
|
||||
To get started, generate a [new migration](https://supabase.com/docs/reference/cli/supabase-migration-new) to store the SQL needed to create our `employees` table
|
||||
|
||||
</StepHikeCompact.Details>
|
||||
|
||||
<StepHikeCompact.Code>
|
||||
|
||||
```bash
|
||||
supabase migration new create_employees_table
|
||||
```
|
||||
|
||||
</StepHikeCompact.Code>
|
||||
|
||||
</StepHikeCompact.Step>
|
||||
</StepHikeCompact>
|
||||
|
||||
<StepHikeCompact>
|
||||
|
||||
<StepHikeCompact.Step step={2}>
|
||||
<StepHikeCompact.Details title="Add the SQL to your migration file">
|
||||
This creates a new migration: supabase/migrations/\<timestamp\>
|
||||
_create_employees_table.sql.
|
||||
|
||||
To that file, add the SQL to create this `employees` table
|
||||
</StepHikeCompact.Details>
|
||||
|
||||
<StepHikeCompact.Code>
|
||||
```sql
|
||||
|
||||
```sql
|
||||
create table
|
||||
employees (
|
||||
id integer primary key generated always as identity,
|
||||
name text
|
||||
);
|
||||
```
|
||||
employees (
|
||||
id bigint primary key generated always as identity,
|
||||
name text,
|
||||
email text,
|
||||
created_at timestampz default now()
|
||||
);
|
||||
|
||||
<Admonition type="note">
|
||||
````
|
||||
|
||||
You can execute any SQL using the `DB URL` shown by [`supabase status`](/docs/reference/cli/usage#supabase-status).
|
||||
</StepHikeCompact.Code>
|
||||
|
||||
</Admonition>
|
||||
</StepHikeCompact.Step>
|
||||
</StepHikeCompact>
|
||||
|
||||
Run the [`db diff`](/docs/reference/cli/usage#supabase-db-diff) command to detect changes in the local database:
|
||||
|
||||
```sh
|
||||
supabase db diff create_employees -f create_employees
|
||||
```
|
||||
|
||||
This creates a new migration named `supabase/migrations/<timestamp>_create_employees.sql`, representing any changes made to the local database since [`supabase start`](/docs/reference/cli/usage#supabase-start).
|
||||
|
||||
<StepHikeCompact>
|
||||
|
||||
<StepHikeCompact.Step step={3}>
|
||||
<StepHikeCompact.Details title="Apply your migration">
|
||||
Now that you have a migration file, you can run this migration and create the `employees` table.
|
||||
|
||||
Use the `reset` command here to reset the database to the current migrations
|
||||
</StepHikeCompact.Details>
|
||||
|
||||
<StepHikeCompact.Code>
|
||||
```bash
|
||||
supabase db reset
|
||||
````
|
||||
|
||||
</StepHikeCompact.Code>
|
||||
|
||||
</StepHikeCompact.Step>
|
||||
</StepHikeCompact>
|
||||
|
||||
<StepHikeCompact>
|
||||
|
||||
<StepHikeCompact.Step step={4}>
|
||||
<StepHikeCompact.Details title="Modify your employees table">
|
||||
Now you can visit your new `employees` table in the Dashboard.
|
||||
|
||||
Next, modify your `employees` table by adding a column for department. Create a new migration file for that.
|
||||
</StepHikeCompact.Details>
|
||||
|
||||
<StepHikeCompact.Code>
|
||||
```bash
|
||||
supabase migration new add_department_to_employees_table
|
||||
|
||||
````
|
||||
</StepHikeCompact.Code>
|
||||
|
||||
</StepHikeCompact.Step>
|
||||
</StepHikeCompact>
|
||||
|
||||
|
||||
|
||||
<StepHikeCompact>
|
||||
|
||||
<StepHikeCompact.Step step={5}>
|
||||
<StepHikeCompact.Details title="Add a new column to your table">
|
||||
This creates a new migration file: supabase/migrations/\<timestamp\>
|
||||
_add_department_to_employees_table.sql.
|
||||
|
||||
To that file, add the SQL to create a new department column
|
||||
</StepHikeCompact.Details>
|
||||
|
||||
<StepHikeCompact.Code>
|
||||
```sql
|
||||
alter table
|
||||
if exists public.employees add department text default 'Hooli';
|
||||
````
|
||||
|
||||
</StepHikeCompact.Code>
|
||||
|
||||
</StepHikeCompact.Step>
|
||||
</StepHikeCompact>
|
||||
|
||||
### Add sample data
|
||||
|
||||
Use the seed script in `supabase/seed.sql` (created with [`supabase init`](/docs/reference/cli/usage#supabase-init)) to add sample data to the table.
|
||||
Now that you are managing your database with migrations scripts, it would be great have some seed data to use every time you reset the database.
|
||||
|
||||
For this, you can use the seed script in `supabase/seed.sql`. This file was automatically created when you ran [`supabase init`](/docs/reference/cli/usage#supabase-init)) at the beginning.
|
||||
|
||||
<StepHikeCompact>
|
||||
|
||||
<StepHikeCompact.Step step={1}>
|
||||
<StepHikeCompact.Details title="Populate your table">
|
||||
Insert data into your `employees` table with your `supabase/seed.sql` file.
|
||||
</StepHikeCompact.Details>
|
||||
|
||||
<StepHikeCompact.Code>
|
||||
```sql
|
||||
|
||||
```sql
|
||||
-- in supabase/seed.sql
|
||||
insert into public.employees
|
||||
(name)
|
||||
insert into
|
||||
public.employees (name)
|
||||
values
|
||||
('Erlich Bachman'),
|
||||
('Richard Hendricks'),
|
||||
('Monica Hall');
|
||||
```
|
||||
('Erlich Bachman'),
|
||||
('Richard Hendricks'),
|
||||
('Monica Hall');
|
||||
|
||||
Rerun the migration and seed scripts:
|
||||
````
|
||||
</StepHikeCompact.Code>
|
||||
|
||||
```bash
|
||||
</StepHikeCompact.Step>
|
||||
</StepHikeCompact>
|
||||
|
||||
|
||||
<StepHikeCompact>
|
||||
|
||||
<StepHikeCompact.Step step={2}>
|
||||
<StepHikeCompact.Details title="Reset your database">
|
||||
Reset your database (apply current migrations), and populate with seed data
|
||||
</StepHikeCompact.Details>
|
||||
|
||||
<StepHikeCompact.Code>
|
||||
```bash
|
||||
supabase db reset
|
||||
````
|
||||
|
||||
</StepHikeCompact.Code>
|
||||
|
||||
</StepHikeCompact.Step>
|
||||
</StepHikeCompact>
|
||||
|
||||
You should now see the `employees` table, along with your seed data in the Dashboard! All of your database changes are captured in code, and you can reset to a known state at any time, complete with seed data.
|
||||
|
||||
### Diffing changes
|
||||
|
||||
This workflow is great if you know SQL and are comfortable creating tables and columns. If not, you can still use the Dashboard to create tables and columns, and then use the CLI to diff your changes and create migrations.
|
||||
|
||||
Create a new table called `cities`, with columns `id`, `name` and `population`. To see the corresponding SQL for this, you can use the `supabase db diff --schema public` command. This will show you the SQL that will be run to create the table and columns. The output of `supabase db diff` will look something like this:
|
||||
|
||||
```
|
||||
Diffing schemas: public
|
||||
Finished supabase db diff on branch main.
|
||||
|
||||
create table "public"."cities" (
|
||||
"id" bigint primary key generated always as identity,
|
||||
"name" text,
|
||||
"population" bigint
|
||||
);
|
||||
|
||||
```
|
||||
|
||||
You should now see the contents of `employees` in Studio.
|
||||
You can then copy this SQL into a new migration file, and run `supabase db reset` to apply the changes.
|
||||
|
||||
### Reset database changes
|
||||
|
||||
Use the [`reset`](/docs/reference/cli/usage#supabase-db-reset) command to revert any changes to the local database.
|
||||
|
||||
```sql
|
||||
-- run on local database to make a change
|
||||
alter table employees
|
||||
add department text default 'Hooli';
|
||||
```
|
||||
|
||||
Run the following command to reset the local database:
|
||||
|
||||
```sh
|
||||
supabase db reset
|
||||
```
|
||||
The last step is deploying these changes to a live Supabase project.
|
||||
|
||||
## Deploy your project
|
||||
|
||||
Go to the [Supabase Dashboard](https://app.supabase.com) and create a project to deploy the changes.
|
||||
You've been developing your project locally, making changes to your tables via migrations. It's time to deploy your project to the Supabase Platform and start scaling up to millions of users! Head over to [Supabase](https://app.supabase.com) and create a new project to deploy to.
|
||||
|
||||
### Link your project
|
||||
|
||||
Associate your project with your remote project using [`supabase link`](/docs/reference/cli/usage#supabase-link).
|
||||
|
||||
```bash
|
||||
supabase link --project-ref <project-id>
|
||||
# You can get <project-id> from your project's dashboard URL: https://app.supabase.com/project/<project-id>
|
||||
|
||||
supabase db remote commit
|
||||
# Capture any changes that you have made to your remote database before you went through the steps above
|
||||
# If you have not made any changes to the remote database, skip this step
|
||||
```
|
||||
|
||||
`supabase/migrations` is now populated with a migration in `..._remote_commit.sql`.
|
||||
This migration captures any changes required for your local database to match the schema of your remote Supabase project.
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
There are a few commands required to link your project. We are in the process of consolidating these commands into a single command. Bear with us!
|
||||
|
||||
</Admonition>
|
||||
|
||||
Associate your project with your remote project using [`supabase link`](/docs/reference/cli/usage#supabase-link).
|
||||
|
||||
```bash
|
||||
supabase link --project-ref <project-id>
|
||||
# You can get <project-id> from your project’s dashboard URL: https://app.supabase.com/project/<project-id>
|
||||
|
||||
supabase db remote commit
|
||||
# Capture any changes that you have made to your database before setting up the CLI
|
||||
```
|
||||
|
||||
`supabase/migrations` is now populated with a migration in `..._remote_commit.sql`.
|
||||
This migration captures any changes required for your local database to match the schema of your remote Supabase project.
|
||||
|
||||
### Deploy database changes
|
||||
|
||||
Deploy any local database migrations using [`db push`](/docs/reference/cli/usage#supabase-db-push):
|
||||
@@ -244,21 +397,23 @@ Deploy any local database migrations using [`db push`](/docs/reference/cli/usage
|
||||
supabase db push
|
||||
```
|
||||
|
||||
Visiting your live project on [Supabase](https://app.supabase.com), you'll see a new `employees` table, complete with the `department` column you added in the second migration above.
|
||||
|
||||
### Deploy Edge Functions
|
||||
|
||||
Deploy any Edge Functions using [`functions deploy`](/docs/reference/cli/usage#supabase-functions-deploy):
|
||||
If your project uses Edge Functions, you can deploy these using [`functions deploy`](/docs/reference/cli/usage#supabase-functions-deploy):
|
||||
|
||||
```sh
|
||||
supabase functions deploy <function_name>
|
||||
```
|
||||
|
||||
## Limitations
|
||||
## Limitations and considerations
|
||||
|
||||
The local development environment is not as feature-complete as the Supabase Platform. Here are some of the differences:
|
||||
The local development environment is not as feature-complete as the Supabase Platform. We're working towards parity between the hosted platform and the local environment. Here are some of the differences:
|
||||
|
||||
- The Functions interface is coming soon.
|
||||
- Logs are not supported through the interface (however you can access them through the Docker containers).
|
||||
- You cannot update your project settings in the Dashboard—this must be done using the CLI.
|
||||
- Logs are not supported through the interface, but you can access them through the Docker containers.
|
||||
- You cannot update your project settings in the Dashboard. This must be done using the CLI.
|
||||
|
||||
export const Page = ({ children }) => <Layout meta={meta} children={children} />
|
||||
|
||||
|
||||
@@ -54,8 +54,8 @@ Given a table
|
||||
```sql
|
||||
create table "Blog"(
|
||||
id serial primary key,
|
||||
name varchar(255) not null,
|
||||
description varchar(255),
|
||||
name text not null,
|
||||
description text,
|
||||
);
|
||||
|
||||
insert into "Blog"(name)
|
||||
|
||||
@@ -22,8 +22,8 @@ Supabase Edge Functions allow you to go beyond HTTP and can connect to your Post
|
||||
import * as postgres from 'https://deno.land/x/postgres@v0.14.2/mod.ts'
|
||||
import { serve } from 'https://deno.land/std@0.177.0/http/server.ts'
|
||||
|
||||
// Get the connection string from the environment variable "DATABASE_URL"
|
||||
const databaseUrl = Deno.env.get('DATABASE_URL')!
|
||||
// Get the connection string from the environment variable "SUPABASE_DB_URL"
|
||||
const databaseUrl = Deno.env.get('SUPABASE_DB_URL')!
|
||||
|
||||
// Create a database pool with three connections that are lazily established
|
||||
const pool = new postgres.Pool(databaseUrl, 3, true)
|
||||
|
||||
@@ -50,7 +50,7 @@ This will register a Slash Command named `hello` that accepts a parameter named
|
||||
import { json, serve, validateRequest } from 'sift'
|
||||
// TweetNaCl is a cryptography library that we use to verify requests
|
||||
// from Discord.
|
||||
import nacl from 'nacl'
|
||||
import nacl from 'tweetnacl'
|
||||
|
||||
enum DiscordCommandType {
|
||||
Ping = 1,
|
||||
|
||||
@@ -128,8 +128,8 @@ Edge Functions supports `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, and `OPTIONS`.
|
||||
- Deno Deploy limitations
|
||||
- Deno does not support outgoing connections to ports `25`, `465`, and `587`.
|
||||
- Cannot read or write to File System
|
||||
- NPM modules are not supported.
|
||||
- Edge Functions
|
||||
- Local development - only one function at a time
|
||||
- Serving of HTML content is not supported (`GET` requests that return `text/html` will be rewritten to `text/plain`).
|
||||
|
||||
export const Page = ({ children }) => <Layout meta={meta} children={children} />
|
||||
|
||||
@@ -55,7 +55,7 @@ We use this with our [pg_graphql](https://github.com/supabase/pg_graphql) extens
|
||||
- Official Docs: [postgrest.org](https://postgrest.org/)
|
||||
- Source code: [github.com/PostgREST/postgrest](https://github.com/PostgREST/postgrest)
|
||||
- License: [MIT](https://github.com/PostgREST/postgrest/blob/main/LICENSE)
|
||||
- Language: Haskel
|
||||
- Language: Haskell
|
||||
|
||||
### Realtime (API & multiplayer)
|
||||
|
||||
|
||||
@@ -24,8 +24,8 @@ export const meta = {
|
||||
```sql SQL_EDITOR
|
||||
-- Create the table
|
||||
CREATE TABLE countries (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL
|
||||
);
|
||||
-- Insert some sample data into the table
|
||||
INSERT INTO countries (name) VALUES ('United States');
|
||||
@@ -41,14 +41,14 @@ export const meta = {
|
||||
|
||||
<StepHikeCompact.Details title="Create a React app">
|
||||
|
||||
Create a React app using the `create-react-app` command.
|
||||
Create a React app using a [Vite](https://vitejs.dev/guide/) template.
|
||||
|
||||
</StepHikeCompact.Details>
|
||||
|
||||
<StepHikeCompact.Code>
|
||||
|
||||
```bash Terminal
|
||||
npx create-react-app my-app
|
||||
npm create vite@latest my-app -- --template react
|
||||
```
|
||||
|
||||
</StepHikeCompact.Code>
|
||||
@@ -77,25 +77,42 @@ export const meta = {
|
||||
<StepHikeCompact.Step step={4}>
|
||||
<StepHikeCompact.Details title="Query data from the app">
|
||||
|
||||
In `index.js`, create a Supabase client using your [Project URL and public API (anon) key](https://app.supabase.com/project/_/settings/api).
|
||||
In `App.jsx`, create a Supabase client using your [Project URL and public API (anon) key](https://app.supabase.com/project/_/settings/api).
|
||||
|
||||
Add a `getCountries` function to fetch the data and log the query result to the browser console.
|
||||
Add a `getCountries` function to fetch the data and display the query result to the page.
|
||||
|
||||
</StepHikeCompact.Details>
|
||||
<StepHikeCompact.Code>
|
||||
|
||||
|
||||
```js src/index.js
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
```js src/App.jsx
|
||||
import { useEffect, useState } from "react";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
const supabase = createClient('https://<project>.supabase.co', '<your-anon-key>')
|
||||
const supabase = createClient("https://<project>.supabase.co", "<your-anon-key>");
|
||||
|
||||
async function getCountries() {
|
||||
const countries = await supabase.from('countries').select()
|
||||
console.log(countries)
|
||||
function App() {
|
||||
const [countries, setCountries] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
getCountries();
|
||||
}, []);
|
||||
|
||||
async function getCountries() {
|
||||
const { data } = await supabase.from("countries").select();
|
||||
setCountries(data);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul>
|
||||
{countries.map((country) => (
|
||||
<li key={country.name}>{country.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
getCountries()
|
||||
export default App;
|
||||
```
|
||||
|
||||
</StepHikeCompact.Code>
|
||||
@@ -105,14 +122,14 @@ export const meta = {
|
||||
<StepHikeCompact.Step step={5}>
|
||||
<StepHikeCompact.Details title="Start the app">
|
||||
|
||||
Start the app, go to http://localhost:3000 in a browser, and open the browser console and you should see the list of countries.
|
||||
Start the app, go to http://localhost:5173 in a browser, and open the browser console and you should see the list of countries.
|
||||
|
||||
</StepHikeCompact.Details>
|
||||
|
||||
<StepHikeCompact.Code>
|
||||
|
||||
```bash Terminal
|
||||
npm start
|
||||
npm run dev
|
||||
```
|
||||
|
||||
</StepHikeCompact.Code>
|
||||
|
||||
@@ -22,11 +22,11 @@ Let's start building the React app from scratch.
|
||||
|
||||
### Initialize a React app
|
||||
|
||||
We can use [Create React App](https://create-react-app.dev/docs/getting-started/) to initialize
|
||||
We can use [Vite](https://vitejs.dev/guide/) to initialize
|
||||
an app called `supabase-react`:
|
||||
|
||||
```bash
|
||||
npx create-react-app supabase-react
|
||||
npm create vite@latest supabase-react -- --template react
|
||||
cd supabase-react
|
||||
```
|
||||
|
||||
@@ -40,8 +40,8 @@ And finally we want to save the environment variables in a `.env.local` file.
|
||||
All we need are the API URL and the `anon` key that you copied [earlier](#get-the-api-keys).
|
||||
|
||||
```bash title=.env
|
||||
REACT_APP_SUPABASE_URL=YOUR_SUPABASE_URL
|
||||
REACT_APP_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY
|
||||
VITE_SUPABASE_URL=YOUR_SUPABASE_URL
|
||||
VITE_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY
|
||||
```
|
||||
|
||||
Now that we have the API credentials in place, let's create a helper file to initialize the Supabase client. These variables will be exposed
|
||||
@@ -52,8 +52,8 @@ Create and edit `src/supabaseClient.js`:
|
||||
```js title=src/supabaseClient.js
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
const supabaseUrl = process.env.REACT_APP_SUPABASE_URL
|
||||
const supabaseAnonKey = process.env.REACT_APP_SUPABASE_ANON_KEY
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
|
||||
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY
|
||||
|
||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey)
|
||||
```
|
||||
@@ -65,9 +65,9 @@ You can find the full contents of this file [here](https://raw.githubusercontent
|
||||
|
||||
Let's set up a React component to manage logins and sign ups. We'll use Magic Links, so users can sign in with their email without using passwords.
|
||||
|
||||
Create and edit `src/Auth.js`:
|
||||
Create and edit `src/Auth.jsx`:
|
||||
|
||||
```jsx title=src/Auth.js
|
||||
```jsx title=src/Auth.jsx
|
||||
import { useState } from 'react'
|
||||
import { supabase } from './supabaseClient'
|
||||
|
||||
@@ -75,44 +75,42 @@ export default function Auth() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [email, setEmail] = useState('')
|
||||
|
||||
const handleLogin = async (e) => {
|
||||
e.preventDefault()
|
||||
const handleLogin = async (event) => {
|
||||
event.preventDefault()
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
const { error } = await supabase.auth.signInWithOtp({ email })
|
||||
if (error) throw error
|
||||
alert('Check your email for the login link!')
|
||||
} catch (error) {
|
||||
setLoading(true)
|
||||
const { error } = await supabase.auth.signInWithOtp({ email })
|
||||
|
||||
if (error) {
|
||||
alert(error.error_description || error.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
} else {
|
||||
alert('Check your email for the login link!')
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="row flex-center flex">
|
||||
<div className="col-6 form-widget" aria-live="polite">
|
||||
<div className="row flex flex-center">
|
||||
<div className="col-6 form-widget">
|
||||
<h1 className="header">Supabase + React</h1>
|
||||
<p className="description">Sign in via magic link with your email below</p>
|
||||
{loading ? (
|
||||
'Sending magic link...'
|
||||
) : (
|
||||
<form onSubmit={handleLogin}>
|
||||
<label htmlFor="email">Email</label>
|
||||
<form className="form-widget" onSubmit={handleLogin}>
|
||||
<div>
|
||||
<input
|
||||
id="email"
|
||||
className="inputField"
|
||||
type="email"
|
||||
placeholder="Your email"
|
||||
value={email}
|
||||
required={true}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<button className="button block" aria-live="polite">
|
||||
Send magic link
|
||||
</div>
|
||||
<div>
|
||||
<button className={'button block'} disabled={loading}>
|
||||
{loading ? <span>Loading</span> : <span>Send magic link</span>}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -123,130 +121,119 @@ export default function Auth() {
|
||||
|
||||
After a user is signed in we can allow them to edit their profile details and manage their account.
|
||||
|
||||
Let's create a new component for that called `src/Account.js`.
|
||||
Let's create a new component for that called `src/Account.jsx`.
|
||||
|
||||
```jsx title=src/Account.js
|
||||
```jsx title=src/Account.jsx
|
||||
import { useState, useEffect } from 'react'
|
||||
import { supabase } from './supabaseClient'
|
||||
|
||||
const Account = ({ session }) => {
|
||||
export default function Account({ session }) {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [username, setUsername] = useState(null)
|
||||
const [website, setWebsite] = useState(null)
|
||||
const [avatar_url, setAvatarUrl] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
getProfile()
|
||||
}, [session])
|
||||
|
||||
const getProfile = async () => {
|
||||
try {
|
||||
async function getProfile() {
|
||||
setLoading(true)
|
||||
const { user } = session
|
||||
|
||||
let { data, error, status } = await supabase
|
||||
let { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.select(`username, website, avatar_url`)
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
|
||||
if (error && status !== 406) {
|
||||
throw error
|
||||
}
|
||||
|
||||
if (data) {
|
||||
if (error) {
|
||||
console.warn(error)
|
||||
} else if (data) {
|
||||
setUsername(data.username)
|
||||
setWebsite(data.website)
|
||||
setAvatarUrl(data.avatar_url)
|
||||
}
|
||||
} catch (error) {
|
||||
alert(error.message)
|
||||
} finally {
|
||||
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const updateProfile = async (e) => {
|
||||
e.preventDefault()
|
||||
getProfile()
|
||||
}, [session])
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
const { user } = session
|
||||
async function updateProfile(event) {
|
||||
event.preventDefault()
|
||||
|
||||
const updates = {
|
||||
id: user.id,
|
||||
username,
|
||||
website,
|
||||
avatar_url,
|
||||
updated_at: new Date(),
|
||||
}
|
||||
setLoading(true)
|
||||
const { user } = session
|
||||
|
||||
let { error } = await supabase.from('profiles').upsert(updates)
|
||||
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
} catch (error) {
|
||||
alert(error.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
const updates = {
|
||||
id: user.id,
|
||||
username,
|
||||
website,
|
||||
avatar_url,
|
||||
updated_at: new Date(),
|
||||
}
|
||||
|
||||
let { error } = await supabase.from('profiles').upsert(updates)
|
||||
|
||||
if (error) {
|
||||
alert(error.message)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div aria-live="polite">
|
||||
{loading ? (
|
||||
'Saving ...'
|
||||
) : (
|
||||
<form onSubmit={updateProfile} className="form-widget">
|
||||
<div>Email: {session.user.email}</div>
|
||||
<div>
|
||||
<label htmlFor="username">Name</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username || ''}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="website">Website</label>
|
||||
<input
|
||||
id="website"
|
||||
type="url"
|
||||
value={website || ''}
|
||||
onChange={(e) => setWebsite(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<button className="button primary block" disabled={loading}>
|
||||
Update profile
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
<button type="button" className="button block" onClick={() => supabase.auth.signOut()}>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={updateProfile} className="form-widget">
|
||||
<div>
|
||||
<label htmlFor="email">Email</label>
|
||||
<input id="email" type="text" value={session.user.email} disabled />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="username">Name</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
required
|
||||
value={username || ''}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="website">Website</label>
|
||||
<input
|
||||
id="website"
|
||||
type="website"
|
||||
value={website || ''}
|
||||
onChange={(e) => setWebsite(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button className="button block primary" type="submit" disabled={loading}>
|
||||
{loading ? 'Loading ...' : 'Update'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button className="button block" type="button" onClick={() => supabase.auth.signOut()}>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default Account
|
||||
```
|
||||
|
||||
### Launch!
|
||||
|
||||
Now that we have all the components in place, let's update `src/App.js`:
|
||||
Now that we have all the components in place, let's update `src/App.jsx`:
|
||||
|
||||
```jsx title=src/App.js
|
||||
import './index.css'
|
||||
```jsx title=src/App.jsx
|
||||
import './App.css'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { supabase } from './supabaseClient'
|
||||
import Auth from './Auth'
|
||||
import Account from './Account'
|
||||
|
||||
export default function App() {
|
||||
function App() {
|
||||
const [session, setSession] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -265,15 +252,17 @@ export default function App() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
```
|
||||
|
||||
Once that's done, run this in a terminal window:
|
||||
|
||||
```bash
|
||||
npm start
|
||||
npm run dev
|
||||
```
|
||||
|
||||
And then open the browser to [localhost:3000](http://localhost:3000) and you should see the completed app.
|
||||
And then open the browser to [localhost:5173](http://localhost:5173) and you should see the completed app.
|
||||
|
||||

|
||||
|
||||
@@ -285,9 +274,9 @@ Every Supabase project is configured with [Storage](/docs/guides/storage) for ma
|
||||
|
||||
Let's create an avatar for the user so that they can upload a profile photo. We can start by creating a new component:
|
||||
|
||||
Create and edit `src/Avatar.js`:
|
||||
Create and edit `src/Avatar.jsx`:
|
||||
|
||||
```jsx title=src/Avatar.js
|
||||
```jsx title=src/Avatar.jsx
|
||||
import { useEffect, useState } from 'react'
|
||||
import { supabase } from './supabaseClient'
|
||||
|
||||
@@ -299,7 +288,7 @@ export default function Avatar({ url, size, onUpload }) {
|
||||
if (url) downloadImage(url)
|
||||
}, [url])
|
||||
|
||||
const downloadImage = async (path) => {
|
||||
async function downloadImage(path) {
|
||||
try {
|
||||
const { data, error } = await supabase.storage.from('avatars').download(path)
|
||||
if (error) {
|
||||
@@ -312,7 +301,7 @@ export default function Avatar({ url, size, onUpload }) {
|
||||
}
|
||||
}
|
||||
|
||||
const uploadAvatar = async (event) => {
|
||||
async function uploadAvatar(event) {
|
||||
try {
|
||||
setUploading(true)
|
||||
|
||||
@@ -331,7 +320,7 @@ export default function Avatar({ url, size, onUpload }) {
|
||||
throw uploadError
|
||||
}
|
||||
|
||||
onUpload(filePath)
|
||||
onUpload(event, filePath)
|
||||
} catch (error) {
|
||||
alert(error.message)
|
||||
} finally {
|
||||
@@ -340,31 +329,33 @@ export default function Avatar({ url, size, onUpload }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: size }} aria-live="polite">
|
||||
<img
|
||||
src={avatarUrl ? avatarUrl : `https://place-hold.it/${size}x${size}`}
|
||||
alt={avatarUrl ? 'Avatar' : 'No image'}
|
||||
className="avatar image"
|
||||
style={{ height: size, width: size }}
|
||||
/>
|
||||
{uploading ? (
|
||||
'Uploading...'
|
||||
<div>
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt="Avatar"
|
||||
className="avatar image"
|
||||
style={{ height: size, width: size }}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<label className="button primary block" htmlFor="single">
|
||||
Upload an avatar
|
||||
</label>
|
||||
<div className="visually-hidden">
|
||||
<input
|
||||
type="file"
|
||||
id="single"
|
||||
accept="image/*"
|
||||
onChange={uploadAvatar}
|
||||
disabled={uploading}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
<div className="avatar no-image" style={{ height: size, width: size }} />
|
||||
)}
|
||||
<div style={{ width: size }}>
|
||||
<label className="button primary block" htmlFor="single">
|
||||
{uploading ? 'Uploading ...' : 'Upload'}
|
||||
</label>
|
||||
<input
|
||||
style={{
|
||||
visibility: 'hidden',
|
||||
position: 'absolute',
|
||||
}}
|
||||
type="file"
|
||||
id="single"
|
||||
accept="image/*"
|
||||
onChange={uploadAvatar}
|
||||
disabled={uploading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -372,9 +363,9 @@ export default function Avatar({ url, size, onUpload }) {
|
||||
|
||||
### Add the new widget
|
||||
|
||||
And then we can add the widget to the Account page at `src/Account.js`:
|
||||
And then we can add the widget to the Account page at `src/Account.jsx`:
|
||||
|
||||
```jsx title=src/Account.js
|
||||
```jsx title=src/Account.jsx
|
||||
// Import the new component
|
||||
import Avatar from './Avatar'
|
||||
|
||||
@@ -386,9 +377,9 @@ return (
|
||||
<Avatar
|
||||
url={avatar_url}
|
||||
size={150}
|
||||
onUpload={(url) => {
|
||||
onUpload={(event, url) => {
|
||||
setAvatarUrl(url)
|
||||
updateProfile({ username, website, avatar_url: url })
|
||||
updateProfile(event)
|
||||
}}
|
||||
/>
|
||||
{/* ... */}
|
||||
|
||||
@@ -175,9 +175,9 @@ Update your `src/routes/+layout.svelte`:
|
||||
import '../styles.css';
|
||||
import { invalidate } from '$app/navigation'
|
||||
import { onMount } from 'svelte'
|
||||
import type { PageData } from './$types'
|
||||
import type { LayoutData } from './$types'
|
||||
|
||||
export let data: PageData
|
||||
export let data: LayoutData
|
||||
|
||||
$: ({ supabase } = data)
|
||||
|
||||
@@ -560,7 +560,7 @@ And then we can add the widget to the Account page:
|
||||
|
||||
```svelte title=src/routes/account/+page.svelte
|
||||
<!-- src/routes/account/+page.svelte -->
|
||||
<script>
|
||||
<script lang="ts">
|
||||
// Import the new component
|
||||
import Avatar from './Avatar.svelte'
|
||||
</script>
|
||||
|
||||
@@ -43,8 +43,8 @@ Remove the default columns
|
||||
|
||||
Create two new columns:
|
||||
|
||||
- `user_id` as `varchar` as primary key
|
||||
- `email` as `varchar`
|
||||
- `user_id` as `text` as primary key
|
||||
- `email` as `text`
|
||||
|
||||
Click `Save` to create the new table.
|
||||
|
||||
|
||||
@@ -20,11 +20,11 @@ If you haven't already, create a new project in Supabase and create a table call
|
||||
|
||||
- id (int8)
|
||||
- created_at (timestamptz)
|
||||
- make (varchar)
|
||||
- model (varchar)
|
||||
- make (text)
|
||||
- model (text)
|
||||
- year (int8)
|
||||
- rating (int2)
|
||||
- author (varchar)
|
||||
- author (text)
|
||||
|
||||
Manually enter a couple of rows of data, so that we have something to read from the DB.
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import Layout from '~/layouts/DefaultGuideLayout'
|
||||
|
||||
export const meta = {
|
||||
title: 'Spend cap',
|
||||
description: 'Learn about the cost control of Supabase.',
|
||||
}
|
||||
|
||||
Supabase offers a "Spend Cap" on each project to manage your usage and costs.
|
||||
|
||||
This Spend Cap determines whether your project can exceed the free quota allowance of any [line item](#what-can-i-be-charged-for) in a given billing cycle.
|
||||
If you exceed the free quota allowance of a line item, and the Spend Cap is **"off"**, then you will be billed for any additional usage on that line item.
|
||||
|
||||
## What additional usage can I be charged for?
|
||||
|
||||
Supabase charges for certain usage line items in your project. Those line items include:
|
||||
|
||||
- Database Size
|
||||
- Storage Size
|
||||
- Egress
|
||||
- Asset transformations
|
||||
- Realtime concurrent peak connections
|
||||
- Realtime messages
|
||||
- Edge function invocations
|
||||
- Number of Edge functions
|
||||
|
||||
You can view the pricing, and free quota allowance of each line item on the [pricing page](/pricing).
|
||||
|
||||
You can monitor your usage and costs using the [Usage section of your project](https://app.supabase.com/project/_/settings/billing/usage) in the dashboard.
|
||||
This will show you how much you have spent so far in the current billing cycle, as well as an estimate of your expected total cost for the cycle based on your current usage.
|
||||
|
||||
## How can I turn the Spend Cap on or off?
|
||||
|
||||
To set up a Spend Cap in your project, navigate to the [Subscription section](https://app.supabase.com/project/_/settings/billing/subscription) of your project.
|
||||
From there, you can click on "Change subscription" to select the plan you want to use and, at the same time, turn on or off the Spend Cap.
|
||||
|
||||
## What happens when the Spend Cap is on?
|
||||
|
||||
Once you use all your free quota allowance for a [line item](#what-can-i-be-charged-for), your project will not allow any further usage on that line item until the next billing cycle begins.
|
||||
|
||||
This can help you to avoid unexpected costs and manage your expenses more effectively.
|
||||
|
||||
## What happens when Spend Cap is off?
|
||||
|
||||
Your project will continue to operate after using all the free quota allowance for each [line item](#what-can-i-be-charged-for).
|
||||
This additional usage will be charged based on the line item's cost per unit outlined on the [pricing page](/pricing).
|
||||
|
||||
<Admonition type="note">
|
||||
When your Spend Cap is **off**, we recommend monitoring your consumption and costs using the [Usage
|
||||
section of your project](https://app.supabase.com/project/_/settings/billing/usage) in the
|
||||
dashboard.
|
||||
</Admonition>
|
||||
|
||||
export const Page = ({ children }) => <Layout meta={meta} children={children} />
|
||||
|
||||
export default Page
|
||||
@@ -64,7 +64,7 @@ Build a basic Todo List with Supabase and your favorite frontend framework:
|
||||
- [Svelte Todo List.](https://github.com/supabase/supabase/tree/master/examples/todo-list/sveltejs-todo-list)
|
||||
- [Vue 3 Todo List (Typescript).](https://github.com/supabase/examples/tree/main/supabase-js-v1/todo-list/vue3-ts-todo-list)
|
||||
- [Angular Todo List.](https://github.com/supabase/examples/tree/main/supabase-js-v1/todo-list/angular-todo-list)
|
||||
- [Nuxt 3 Todo List.](https://github.com/nuxt-community/supabase-module/tree/main/demo)
|
||||
- [Nuxt 3 Todo List.](https://github.com/nuxt-modules/supabase/tree/main/demo)
|
||||
|
||||
### Auth examples
|
||||
|
||||
@@ -92,8 +92,8 @@ Build a basic Todo List with Supabase and your favorite frontend framework:
|
||||
- `useSupabase`: Supabase React Hooks. [GitHub](https://github.com/gbibeaul/use-supabase)
|
||||
- `vue-supabase`: Supabase Vue wrapper. [GitHub](https://github.com/supabase/vue-supabase)
|
||||
- `vue-3-supabase`: Supabase Vue 3 wrapper. [GitHub](https://github.com/DidoMarchet/vue-3-supabase)
|
||||
- `nuxt-supabase`: Supabase Nuxt wrapper. [GitHub](https://github.com/supabase/nuxt-supabase)
|
||||
- `@nuxtjs/supabase`: Supabase Nuxt 3 module. [GitHub](https://github.com/nuxt-community/supabase-module)
|
||||
- `nuxt-supabase`: Supabase Nuxt wrapper. [GitHub](https://github.com/supabase-community/nuxt-supabase)
|
||||
- `@nuxtjs/supabase`: Supabase Nuxt 3 module. [GitHub](https://github.com/nuxt-modules/supabase)
|
||||
- `react-supabase`: Supabase React Hooks. [Docs](https://react-supabase.vercel.app) [GitHub](https://github.com/tmm/react-supabase)
|
||||
- ` @triniwiz/nativescript-supabase`: Supabase NativeScript. [GitHub](https://github.com/triniwiz/nativescript-plugins/tree/master/packages/nativescript-supabase)
|
||||
|
||||
@@ -133,10 +133,11 @@ Build a basic Todo List with Supabase and your favorite frontend framework:
|
||||
- Supabase + Nuxt3 (Nuxt Starter Kit) [GitHub](https://github.com/one-aalam/nuxt-starter-kit)
|
||||
- Supabase + Remix (Remix Starter Kit) [GitHub](https://github.com/one-aalam/remix-starter-kit)
|
||||
- Supabase + Angular (Angular Starter Kit) [GitHub](https://github.com/one-aalam/ng-starter-kit)
|
||||
- Supabase + Nuxt3 + nuxtjs/supabase [Github](https://github.com/nuxt-community/supabase-module/tree/main/demo)
|
||||
- Supabase + Nuxt3 + nuxtjs/supabase [Github](https://github.com/nuxt-modules/supabase/tree/main/demo)
|
||||
- Supabase + Ory Kratos & Ory Oathkeeper [Github](https://github.com/ory/examples/tree/master/kratos-keto-oathkeeper-supabase)
|
||||
- Supabase + Ory Cloud [Github](https://github.com/ory/examples/tree/master/supabase-ory-cloud)
|
||||
- Supabase Auth with React Native + Next.js (Monorepo) [Github](https://github.com/mateoguzmana/react-native-next-supabase-auth-monorepo)
|
||||
- Supabase Auth with Nuxt3 [Github](https://github.com/zackha/supaAuth)
|
||||
|
||||
### Blog Posts
|
||||
|
||||
|
||||
@@ -7,9 +7,7 @@ export const meta = {
|
||||
sidebar_label: 'Quickstart',
|
||||
}
|
||||
|
||||
This guide shows the basic functionality of Supabase Storage. Find a full [example application on GitHub](https://github.com/supabase/supabase/tree/master/examples/user-management/nextjs-ts-user-management) or deploy it with Vercel for a preview:
|
||||
|
||||
[](https://vercel.com/new/git/external?repository-url=https%3A%2F%2Fgithub.com%2Fsupabase%2Fsupabase%2Ftree%2Fmaster%2Fexamples%2Fuser-management%2Fnextjs-ts-user-management&project-name=supabase-user-management&repository-name=supabase-user-management&demo-title=Supabase%20User%20Management&demo-description=An%20example%20web%20app%20using%20Supabase%20and%20Next.js&demo-url=https%3A%2F%2Fsupabase-nextjs-ts-user-management.vercel.app&demo-image=https%3A%2F%2Fi.imgur.com%2FZ3HkQqe.png&integration-ids=oac_jUduyjQgOyzev1fjrW83NYOv&external-id=nextjs-user-management)
|
||||
This guide shows the basic functionality of Supabase Storage. Find a full [example application on GitHub](https://github.com/supabase/supabase/tree/master/examples/user-management/nextjs-ts-user-management) or deploy it with [Vercel for a preview](https://vercel.com/new/git/external?repository-url=https%3A%2F%2Fgithub.com%2Fsupabase%2Fsupabase%2Ftree%2Fmaster%2Fexamples%2Fuser-management%2Fnextjs-ts-user-management&project-name=supabase-user-management&repository-name=supabase-user-management&demo-title=Supabase%20User%20Management&demo-description=An%20example%20web%20app%20using%20Supabase%20and%20Next.js&demo-url=https%3A%2F%2Fsupabase-nextjs-ts-user-management.vercel.app&demo-image=https%3A%2F%2Fi.imgur.com%2FZ3HkQqe.png&integration-ids=oac_jUduyjQgOyzev1fjrW83NYOv&external-id=nextjs-user-management).
|
||||
|
||||
<Admonition type="note">
|
||||
|
||||
|
||||
@@ -2,67 +2,73 @@
|
||||
import specFile from '~/../../spec/gotrue_v1_config.yaml' assert { type: 'yml' }
|
||||
import { Parameter } from '~/lib/refGenerator/refTypes'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import Head from 'next/head'
|
||||
|
||||
// Parameters are grouped on the page by tag
|
||||
const TAGS = ['general']
|
||||
|
||||
export default function Config() {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex my-16">
|
||||
<div className="w-full prose">
|
||||
<h1 className="text-4xl mb-16">{specFile.info.title} Configuration</h1>
|
||||
<ReactMarkdown>{specFile.info.description}</ReactMarkdown>
|
||||
<div>
|
||||
{TAGS.map((tag) =>
|
||||
specFile.parameters
|
||||
.filter((param: Parameter) => param.tags[0] === tag)
|
||||
.map((parameter: Parameter, index) => (
|
||||
<div>
|
||||
{index === 0 && <h2 className="text-xl capitalize">{tag}</h2>}
|
||||
<div className="mt-8">
|
||||
<div>
|
||||
<h2 className="text-xl font-medium text-scale-1200 font-mono">
|
||||
<span className="mr-2">$</span>
|
||||
{parameter.title}
|
||||
</h2>
|
||||
<div className="grid" id={parameter.id}>
|
||||
<div className="border-b pb-8" key={parameter.id}>
|
||||
<div className=" mb-16">
|
||||
<p className="mb-4 scroll-mt-16 mt-0 text-scale-1100 text-base">
|
||||
<ReactMarkdown>{parameter.description}</ReactMarkdown>
|
||||
</p>
|
||||
<div className="grid gap-2">
|
||||
<div className="flex gap-2">
|
||||
Required: <code>{parameter.required.toString()}</code>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
Default:
|
||||
<code>
|
||||
{parameter.default ? parameter.default.toString() : 'None'}
|
||||
</code>
|
||||
<>
|
||||
<Head>
|
||||
<title>Auth config</title>
|
||||
</Head>
|
||||
<div>
|
||||
<div className="flex my-16">
|
||||
<div className="w-full prose">
|
||||
<h1 className="text-4xl mb-16">{specFile.info.title} Configuration</h1>
|
||||
<ReactMarkdown>{specFile.info.description}</ReactMarkdown>
|
||||
<div>
|
||||
{TAGS.map((tag) =>
|
||||
specFile.parameters
|
||||
.filter((param: Parameter) => param.tags[0] === tag)
|
||||
.map((parameter: Parameter, index) => (
|
||||
<div>
|
||||
{index === 0 && <h2 className="text-xl capitalize">{tag}</h2>}
|
||||
<div className="mt-8">
|
||||
<div>
|
||||
<h2 className="text-xl font-medium text-scale-1200 font-mono">
|
||||
<span className="mr-2">$</span>
|
||||
{parameter.title}
|
||||
</h2>
|
||||
<div className="grid" id={parameter.id}>
|
||||
<div className="border-b pb-8" key={parameter.id}>
|
||||
<div className=" mb-16">
|
||||
<p className="mb-4 scroll-mt-16 mt-0 text-scale-1100 text-base">
|
||||
<ReactMarkdown>{parameter.description}</ReactMarkdown>
|
||||
</p>
|
||||
<div className="grid gap-2">
|
||||
<div className="flex gap-2">
|
||||
Required: <code>{parameter.required.toString()}</code>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
Default:
|
||||
<code>
|
||||
{parameter.default ? parameter.default.toString() : 'None'}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{parameter.links &&
|
||||
parameter.links.map((link) => (
|
||||
<div>
|
||||
<h3>See also:</h3>
|
||||
<li>
|
||||
<a href={link.link}>{link.name}</a>
|
||||
</li>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{parameter.links &&
|
||||
parameter.links.map((link) => (
|
||||
<div>
|
||||
<h3>See also:</h3>
|
||||
<li>
|
||||
<a href={link.link}>{link.name}</a>
|
||||
</li>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { Parameter } from '~/lib/refGenerator/refTypes'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import GuidesTableOfContents from '~/components/GuidesTableOfContents'
|
||||
import { Heading } from '~/components/CustomHTMLElements'
|
||||
import Head from 'next/head'
|
||||
|
||||
// Parameters are grouped on the page by tag
|
||||
const TAGS = ['general', 'auth', 'api', 'database', 'dashboard', 'local', 'edge-functions']
|
||||
@@ -21,69 +22,74 @@ const tocList = TAGS.map((tag) =>
|
||||
|
||||
export default function Config() {
|
||||
return (
|
||||
<div className="grid grid-cols-12 relative gap-4">
|
||||
<div className="relative col-span-12 md:col-span-9 transition-all ease-out duration-100">
|
||||
<div className="w-full prose">
|
||||
<h1 className="">CLI configuration</h1>
|
||||
<div className="max-w-xs w-32 h-[1px] bg-gradient-to-r from-brand-800 to-brand-900 my-8"></div>
|
||||
<ReactMarkdown>{specFile.info.description}</ReactMarkdown>
|
||||
<div>
|
||||
{TAGS.map((tag) =>
|
||||
specFile.parameters
|
||||
.filter((param: Parameter) => param.tags[0] === tag)
|
||||
.map((parameter: Parameter, index) => (
|
||||
<div>
|
||||
{index === 0 && <h2 className="text-xl capitalize">{tag}</h2>}
|
||||
<div className="mt-8">
|
||||
<div>
|
||||
<Heading tag="h2" parseAnchors={false} customAnchor={parameter.id}>
|
||||
<code>{parameter.title}</code>
|
||||
</Heading>
|
||||
<div className="grid">
|
||||
<div className="border-b pb-8" key={parameter.id}>
|
||||
<div className=" mb-16">
|
||||
<p className="mb-4 scroll-mt-16 mt-0 text-scale-1100 text-base">
|
||||
<ReactMarkdown>{parameter.description}</ReactMarkdown>
|
||||
</p>
|
||||
<div className="grid gap-2">
|
||||
<div className="flex gap-2">
|
||||
Required: <code>{parameter.required.toString()}</code>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
Default:
|
||||
<code>
|
||||
{parameter.default ? parameter.default.toString() : 'None'}
|
||||
</code>
|
||||
<>
|
||||
<Head>
|
||||
<title>Supabase CLI config</title>
|
||||
</Head>
|
||||
<div className="grid grid-cols-12 relative gap-4">
|
||||
<div className="relative col-span-12 md:col-span-9 transition-all ease-out duration-100">
|
||||
<div className="w-full prose">
|
||||
<h1 className="">CLI configuration</h1>
|
||||
<div className="max-w-xs w-32 h-[1px] bg-gradient-to-r from-brand-800 to-brand-900 my-8"></div>
|
||||
<ReactMarkdown>{specFile.info.description}</ReactMarkdown>
|
||||
<div>
|
||||
{TAGS.map((tag) =>
|
||||
specFile.parameters
|
||||
.filter((param: Parameter) => param.tags[0] === tag)
|
||||
.map((parameter: Parameter, index) => (
|
||||
<div>
|
||||
{index === 0 && <h2 className="text-xl capitalize">{tag}</h2>}
|
||||
<div className="mt-8">
|
||||
<div>
|
||||
<Heading tag="h2" parseAnchors={false} customAnchor={parameter.id}>
|
||||
<code>{parameter.title}</code>
|
||||
</Heading>
|
||||
<div className="grid">
|
||||
<div className="border-b pb-8" key={parameter.id}>
|
||||
<div className=" mb-16">
|
||||
<p className="mb-4 scroll-mt-16 mt-0 text-scale-1100 text-base">
|
||||
<ReactMarkdown>{parameter.description}</ReactMarkdown>
|
||||
</p>
|
||||
<div className="grid gap-2">
|
||||
<div className="flex gap-2">
|
||||
Required: <code>{parameter.required.toString()}</code>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
Default:
|
||||
<code>
|
||||
{parameter.default ? parameter.default.toString() : 'None'}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{parameter.links &&
|
||||
parameter.links.map((link) => (
|
||||
<div>
|
||||
<h3>See also:</h3>
|
||||
<li>
|
||||
<a href={link.link}>{link.name}</a>
|
||||
</li>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{parameter.links &&
|
||||
parameter.links.map((link) => (
|
||||
<div>
|
||||
<h3>See also:</h3>
|
||||
<li>
|
||||
<a href={link.link}>{link.name}</a>
|
||||
</li>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:col-span-3">
|
||||
<div className="sticky top-20 border-l">
|
||||
<span className="block font-mono text-xs uppercase text-scale-1200 pl-5 mb-4">
|
||||
On this page
|
||||
</span>
|
||||
<GuidesTableOfContents list={tocList} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:col-span-3">
|
||||
<div className="sticky top-20 border-l">
|
||||
<span className="block font-mono text-xs uppercase text-scale-1200 pl-5 mb-4">
|
||||
On this page
|
||||
</span>
|
||||
<GuidesTableOfContents list={tocList} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+20
-31
@@ -25,12 +25,11 @@
|
||||
{
|
||||
"title": "Database size",
|
||||
"tooltips": {
|
||||
"main": "Billing is based on the average database size in GB throughout the billing period.",
|
||||
"pro": "8 GB included, then $0.125 per GB"
|
||||
"main": "Billing is based on the average database size in GB throughout the billing period."
|
||||
},
|
||||
"tiers": {
|
||||
"free": "500 MB",
|
||||
"pro": "8 GB",
|
||||
"pro": "8 GB, then $0.125 per GB",
|
||||
"enterprise": "Unlimited"
|
||||
}
|
||||
},
|
||||
@@ -72,12 +71,11 @@
|
||||
{
|
||||
"title": "Database egress",
|
||||
"tooltips": {
|
||||
"main": "Billing is based on the total sum of outgoing traffic of your database in GB throughout your billing period. Only download operations are counted towards the limit",
|
||||
"pro": "50GB included per month, then $0.09 per GB"
|
||||
"main": "Billing is based on the total sum of outgoing traffic of your database in GB throughout your billing period. Only download operations are counted towards the limit"
|
||||
},
|
||||
"tiers": {
|
||||
"free": "2GB",
|
||||
"pro": "50GB",
|
||||
"pro": "50 GB, then $0.09 per GB",
|
||||
"enterprise": "Unlimited"
|
||||
}
|
||||
}
|
||||
@@ -99,12 +97,11 @@
|
||||
{
|
||||
"title": "MAUs",
|
||||
"tooltips": {
|
||||
"main": "Distinct users requesting your API throughout the billing period.",
|
||||
"pro": "100,000 included, then $0.00325 per MAU"
|
||||
"main": "Distinct users requesting your API throughout the billing period."
|
||||
},
|
||||
"tiers": {
|
||||
"free": "50,000",
|
||||
"pro": "100,000",
|
||||
"pro": "100,000, then $0.00325 per MAU",
|
||||
"enterprise": "Unlimited"
|
||||
}
|
||||
},
|
||||
@@ -175,24 +172,22 @@
|
||||
{
|
||||
"title": "Storage",
|
||||
"tooltips": {
|
||||
"pro": "100 GB included, then $0.021 per GB",
|
||||
"main": "The sum of all objects' size in your storage buckets."
|
||||
},
|
||||
"tiers": {
|
||||
"free": "1 GB",
|
||||
"pro": "100 GB",
|
||||
"pro": "100 GB, then $0.021 per GB",
|
||||
"enterprise": "Unlimited"
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "Storage egress",
|
||||
"tooltips": {
|
||||
"main": "Total sum of outgoing traffic (egress) from your storage buckets, only download operations are counted.",
|
||||
"pro": "200 GB included, then $0.09 per GB"
|
||||
"main": "Total sum of outgoing traffic (egress) from your storage buckets, only download operations are counted."
|
||||
},
|
||||
"tiers": {
|
||||
"free": "2 GB",
|
||||
"pro": "200 GB",
|
||||
"pro": "200 GB, then $0.09 per GB",
|
||||
"enterprise": "Unlimited"
|
||||
}
|
||||
},
|
||||
@@ -216,12 +211,11 @@
|
||||
{
|
||||
"title": "Asset transformations",
|
||||
"tooltips": {
|
||||
"main": "Distinct count of all images that were transformed in the billing period, ignoring any transformations.",
|
||||
"pro": "Unlimited for 100 origin images. $5 per 1000 origin images after the first 100"
|
||||
"main": "Distinct count of all images that were transformed in the billing period, ignoring any transformations."
|
||||
},
|
||||
"tiers": {
|
||||
"free": false,
|
||||
"pro": "Unlimited for 100 origin images"
|
||||
"pro": "100 origin images, then $5 per 1000 origin images"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -233,13 +227,12 @@
|
||||
{
|
||||
"title": "Invocations",
|
||||
"tooltips": {
|
||||
"main": "Billing is based on the sum of all invocations, independent of response status, throughout your billing period.",
|
||||
"pro": "2 Million per month, then $2 per 1 million invocations"
|
||||
"main": "Billing is based on the sum of all invocations, independent of response status, throughout your billing period."
|
||||
},
|
||||
"main": "Billing is based on the sum of all invocations, independent of response status, throughout your billing period.",
|
||||
"tiers": {
|
||||
"free": "500K/month",
|
||||
"pro": "2 Million/month",
|
||||
"pro": "2 Million, then $2 per 1 Million",
|
||||
"enterprise": "Unlimited"
|
||||
}
|
||||
},
|
||||
@@ -254,12 +247,11 @@
|
||||
{
|
||||
"title": "Number of functions",
|
||||
"tooltips": {
|
||||
"main": "Billing is based on the maximum amount of functions at any point in time throughout your billing period.",
|
||||
"pro": "100, then $10 per additional 100 functions"
|
||||
"main": "Billing is based on the maximum amount of functions at any point in time throughout your billing period."
|
||||
},
|
||||
"tiers": {
|
||||
"free": "10",
|
||||
"pro": "100",
|
||||
"pro": "100, then $10 per additional 100",
|
||||
"enterprise": "Unlimited"
|
||||
}
|
||||
}
|
||||
@@ -280,25 +272,22 @@
|
||||
{
|
||||
"title": "Concurrent Peak Connections",
|
||||
"tooltips": {
|
||||
"main": "We only charge for successful connections - not connection attempts!",
|
||||
"pro": "Up to 500, then $10 per 1000"
|
||||
"main": "We only charge for successful connections - not connection attempts!"
|
||||
},
|
||||
"tiers": {
|
||||
"free": "200",
|
||||
"pro": "500",
|
||||
"pro": "500, then $10 per 1000",
|
||||
"enterprise": "Unlimited concurrent connections and volume discount"
|
||||
}
|
||||
},
|
||||
{
|
||||
"title": "Messages Per Month",
|
||||
"tooltips": {
|
||||
"main": "Billing is based on the total sum of Realtime messages throughout your billing period.",
|
||||
"free": "2 Million (including database)",
|
||||
"pro": "5 Million, then $2.50 per million"
|
||||
"main": "Billing is based on the total sum of Realtime messages throughout your billing period."
|
||||
},
|
||||
"tiers": {
|
||||
"free": "2 Million",
|
||||
"pro": "5 Million",
|
||||
"pro": "5 Million, then $2.50 per Million",
|
||||
"enterprise": "Volume discounts on messages"
|
||||
}
|
||||
},
|
||||
@@ -426,7 +415,7 @@
|
||||
"tiers": {
|
||||
"free": false,
|
||||
"pro": "$10 per domain per month per project add on",
|
||||
"enterprise": "1 included, additional $10/domain/month"
|
||||
"enterprise": "1, additional $10/domain/month"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -52,7 +52,7 @@ export default [
|
||||
img: 'multiplayer-game.svg',
|
||||
title: 'Multiplayer games',
|
||||
description:
|
||||
"Keep track of how long a player has been playing, who's turn it is and even share to other clients what the pllayer is doing.",
|
||||
"Keep track of how long a player has been playing, who's turn it is and even share to other clients what the player is doing.",
|
||||
},
|
||||
{
|
||||
img: 'form-presence.svg',
|
||||
|
||||
@@ -6,53 +6,15 @@ import CodeBlock from '~/components/CodeBlock/CodeBlock'
|
||||
import ImageGrid from '~/components/ImageGrid'
|
||||
import DefaultLayout from '~/components/Layouts/Default'
|
||||
import Quote from '~/components/Quote'
|
||||
import mdxComponents from '~/lib/mdx/mdxComponents'
|
||||
|
||||
const ignoreClass = 'ignore-on-export'
|
||||
|
||||
function getComponents(type: any) {
|
||||
const components = {
|
||||
CodeBlock,
|
||||
Quote,
|
||||
Avatar,
|
||||
code: (props: any) => {
|
||||
if (props.className !== ignoreClass) {
|
||||
return <CodeBlock {...props} />
|
||||
} else {
|
||||
return <code {...props} />
|
||||
}
|
||||
},
|
||||
ImageGrid,
|
||||
img: (props: any) => {
|
||||
if (props.className !== ignoreClass) {
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
'next-image--dynamic-fill',
|
||||
type === 'blog' && 'to-scale-400 from-scale-500 rounded-lg border bg-gradient-to-r',
|
||||
].join(' ')}
|
||||
>
|
||||
<Image
|
||||
{...props}
|
||||
className={['next-image--dynamic-fill', type === 'blog' && 'rounded-md border'].join(
|
||||
' '
|
||||
)}
|
||||
layout="fill"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <img {...props} />
|
||||
},
|
||||
}
|
||||
|
||||
return components
|
||||
}
|
||||
|
||||
export default function ContentsLayout({ context }: any) {
|
||||
return (
|
||||
<DefaultLayout>
|
||||
<NextSeo {...context.meta} />
|
||||
<MDXProvider components={getComponents(context.type)}>
|
||||
<MDXProvider components={mdxComponents()}>
|
||||
<div className="prose max-w-none">{context.children}</div>
|
||||
</MDXProvider>
|
||||
</DefaultLayout>
|
||||
|
||||
@@ -3,6 +3,14 @@ module.exports = [
|
||||
source: '/:path*',
|
||||
destination: `/:path*`,
|
||||
},
|
||||
{
|
||||
source: '/dashboard',
|
||||
destination: `${process.env.NEXT_PUBLIC_STUDIO_URL}`,
|
||||
},
|
||||
{
|
||||
source: '/dashboard/:path*',
|
||||
destination: `${process.env.NEXT_PUBLIC_STUDIO_URL}/:path*`,
|
||||
},
|
||||
{
|
||||
source: '/docs',
|
||||
destination: `${process.env.NEXT_PUBLIC_DOCS_URL}`,
|
||||
|
||||
@@ -128,11 +128,14 @@ In addition to internal security reviews, we use various tools to scan our code
|
||||
|
||||
As a database company, being SOC2 compliant is important when handling sensitive customer data. Access more of Supabase’s security information by requesting for our latest SOC2 documents below.
|
||||
|
||||
<Link passHref href="https://security.supabase.com">
|
||||
<Button type="default" size="medium" as="a" className="no-underline" target="_blank">
|
||||
Get SOC2 documents
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="not-prose">
|
||||
<Link passHref href="https://forms.supabase.com/soc2">
|
||||
<Button size="medium" type="default" as="a" target="_blank">
|
||||
Request SOC2 documents
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -211,7 +211,7 @@ services:
|
||||
# Comment out everything below this point if you are using an external Postgres database
|
||||
db:
|
||||
container_name: supabase-db
|
||||
image: supabase/postgres:15.1.0.42-rc2
|
||||
image: supabase/postgres:15.1.0.54-rc0
|
||||
healthcheck:
|
||||
test: pg_isready -U postgres -h localhost
|
||||
interval: 5s
|
||||
@@ -236,8 +236,10 @@ services:
|
||||
PGDATABASE: ${POSTGRES_DB}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
volumes:
|
||||
- ./volumes/db/realtime.sql:/docker-entrypoint-initdb.d/realtime.sql:Z
|
||||
- ./volumes/db/roles.sql:/docker-entrypoint-initdb.d/roles.sql:Z
|
||||
# Must be superuser to enable pg_net extension
|
||||
- ./volumes/db/webhooks.sql:/etc/postgresql.schema.sql:Z
|
||||
- ./volumes/db/realtime.sql:/docker-entrypoint-initdb.d/migrations/99-realtime.sql:Z
|
||||
# Must be superuser to create event trigger
|
||||
- ./volumes/db/webhooks.sql:/docker-entrypoint-initdb.d/init-scripts/98-webhooks.sql:Z
|
||||
# Must be superuser to alter reserved role
|
||||
- ./volumes/db/roles.sql:/docker-entrypoint-initdb.d/init-scripts/99-roles.sql:Z
|
||||
# PGDATA directory is persisted between restarts
|
||||
- ./volumes/db/data:/var/lib/postgresql/data:Z
|
||||
@@ -4,4 +4,5 @@
|
||||
ALTER USER authenticator WITH PASSWORD :'pgpass';
|
||||
ALTER USER pgbouncer WITH PASSWORD :'pgpass';
|
||||
ALTER USER supabase_auth_admin WITH PASSWORD :'pgpass';
|
||||
ALTER USER supabase_functions_admin WITH PASSWORD :'pgpass';
|
||||
ALTER USER supabase_storage_admin WITH PASSWORD :'pgpass';
|
||||
@@ -2,11 +2,11 @@ BEGIN;
|
||||
-- Create pg_net extension
|
||||
CREATE EXTENSION IF NOT EXISTS pg_net SCHEMA extensions;
|
||||
-- Create supabase_functions schema
|
||||
CREATE SCHEMA if not exists supabase_functions;
|
||||
GRANT USAGE ON SCHEMA supabase_functions TO postgres;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON TABLES TO postgres;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON FUNCTIONS TO postgres;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON SEQUENCES TO postgres;
|
||||
CREATE SCHEMA supabase_functions AUTHORIZATION supabase_admin;
|
||||
GRANT USAGE ON SCHEMA supabase_functions TO postgres, anon, authenticated, service_role;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON TABLES TO postgres, anon, authenticated, service_role;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON FUNCTIONS TO postgres, anon, authenticated, service_role;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON SEQUENCES TO postgres, anon, authenticated, service_role;
|
||||
-- supabase_functions.migrations definition
|
||||
CREATE TABLE supabase_functions.migrations (
|
||||
version text PRIMARY KEY,
|
||||
@@ -41,29 +41,29 @@ BEGIN;
|
||||
IF url IS NULL OR url = 'null' THEN
|
||||
RAISE EXCEPTION 'url argument is missing';
|
||||
END IF;
|
||||
|
||||
|
||||
IF method IS NULL OR method = 'null' THEN
|
||||
RAISE EXCEPTION 'method argument is missing';
|
||||
END IF;
|
||||
|
||||
|
||||
IF TG_ARGV[2] IS NULL OR TG_ARGV[2] = 'null' THEN
|
||||
headers = '{"Content-Type": "application/json"}'::jsonb;
|
||||
ELSE
|
||||
headers = TG_ARGV[2]::jsonb;
|
||||
END IF;
|
||||
|
||||
|
||||
IF TG_ARGV[3] IS NULL OR TG_ARGV[3] = 'null' THEN
|
||||
params = '{}'::jsonb;
|
||||
ELSE
|
||||
params = TG_ARGV[3]::jsonb;
|
||||
END IF;
|
||||
|
||||
|
||||
IF TG_ARGV[4] IS NULL OR TG_ARGV[4] = 'null' THEN
|
||||
timeout_ms = 1000;
|
||||
ELSE
|
||||
timeout_ms = TG_ARGV[4]::integer;
|
||||
END IF;
|
||||
|
||||
|
||||
CASE
|
||||
WHEN method = 'GET' THEN
|
||||
SELECT http_get INTO request_id FROM net.http_get(
|
||||
@@ -74,13 +74,13 @@ BEGIN;
|
||||
);
|
||||
WHEN method = 'POST' THEN
|
||||
payload = jsonb_build_object(
|
||||
'old_record', OLD,
|
||||
'record', NEW,
|
||||
'old_record', OLD,
|
||||
'record', NEW,
|
||||
'type', TG_OP,
|
||||
'table', TG_TABLE_NAME,
|
||||
'schema', TG_TABLE_SCHEMA
|
||||
);
|
||||
|
||||
|
||||
SELECT http_post INTO request_id FROM net.http_post(
|
||||
url,
|
||||
payload,
|
||||
@@ -91,12 +91,12 @@ BEGIN;
|
||||
ELSE
|
||||
RAISE EXCEPTION 'method argument % is invalid', method;
|
||||
END CASE;
|
||||
|
||||
|
||||
INSERT INTO supabase_functions.hooks
|
||||
(hook_table_id, hook_name, request_id)
|
||||
VALUES
|
||||
(TG_RELID, TG_NAME, request_id);
|
||||
|
||||
|
||||
RETURN NEW;
|
||||
END
|
||||
$function$;
|
||||
@@ -148,19 +148,15 @@ BEGIN;
|
||||
WHERE extname = 'pg_net'
|
||||
)
|
||||
THEN
|
||||
GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres;
|
||||
GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role;
|
||||
ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER;
|
||||
ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER;
|
||||
ALTER function net.http_collect_response(request_id bigint, async boolean) SECURITY DEFINER;
|
||||
ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net;
|
||||
ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net;
|
||||
ALTER function net.http_collect_response(request_id bigint, async boolean) SET search_path = net;
|
||||
REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC;
|
||||
REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC;
|
||||
REVOKE ALL ON FUNCTION net.http_collect_response(request_id bigint, async boolean) FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres;
|
||||
GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres;
|
||||
GRANT EXECUTE ON FUNCTION net.http_collect_response(request_id bigint, async boolean) TO supabase_functions_admin, postgres;
|
||||
GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role;
|
||||
GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
@@ -178,19 +174,15 @@ BEGIN;
|
||||
WHERE ext.extname = 'pg_net'
|
||||
)
|
||||
THEN
|
||||
GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres;
|
||||
GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role;
|
||||
ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER;
|
||||
ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER;
|
||||
ALTER function net.http_collect_response(request_id bigint, async boolean) SECURITY DEFINER;
|
||||
ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net;
|
||||
ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net;
|
||||
ALTER function net.http_collect_response(request_id bigint, async boolean) SET search_path = net;
|
||||
REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC;
|
||||
REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC;
|
||||
REVOKE ALL ON FUNCTION net.http_collect_response(request_id bigint, async boolean) FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres;
|
||||
GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres;
|
||||
GRANT EXECUTE ON FUNCTION net.http_collect_response(request_id bigint, async boolean) TO supabase_functions_admin, postgres;
|
||||
GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role;
|
||||
GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role;
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
@@ -212,5 +204,5 @@ BEGIN;
|
||||
ALTER function supabase_functions.http_request() SECURITY DEFINER;
|
||||
ALTER function supabase_functions.http_request() SET search_path = supabase_functions;
|
||||
REVOKE ALL ON FUNCTION supabase_functions.http_request() FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION supabase_functions.http_request() TO postgres;
|
||||
GRANT EXECUTE ON FUNCTION supabase_functions.http_request() TO postgres, anon, authenticated, service_role;
|
||||
COMMIT;
|
||||
@@ -3,7 +3,6 @@
|
||||
Generate Open Graph images with Deno and Supabase Edge Functions, no framework needed. This is a fork of the awesome [@vercel/og](https://www.npmjs.com/package/@vercel/og), ported to run on Deno.
|
||||
|
||||
- Docs: https://deno.land/x/og_edge@0.0.2
|
||||
- Demo: https://zdtdtxajzydjqzuktnqx.functions.supabase.co/opengraph
|
||||
|
||||
## Run locally
|
||||
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+1697
-37598
File diff suppressed because it is too large.
Load diff
@@ -1,39 +1,22 @@
|
||||
{
|
||||
"name": "react-user-management",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@supabase/supabase-js": "^2.0.4",
|
||||
"@testing-library/jest-dom": "^5.12.0",
|
||||
"@testing-library/react": "^11.2.6",
|
||||
"@testing-library/user-event": "^12.8.3",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-scripts": "4.0.3",
|
||||
"web-vitals": "^1.1.2"
|
||||
},
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
"dependencies": {
|
||||
"@supabase/supabase-js": "^2.11.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.27",
|
||||
"@types/react-dom": "^18.0.10",
|
||||
"@vitejs/plugin-react": "^3.1.0",
|
||||
"vite": "^4.1.0"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.8 KiB |
@@ -1,43 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Web site created using create-react-app"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>React App</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
+36
-46
@@ -10,66 +10,59 @@ export default function Account({ session }) {
|
||||
|
||||
useEffect(() => {
|
||||
async function getProfile() {
|
||||
try {
|
||||
setLoading(true)
|
||||
const { user } = session
|
||||
setLoading(true)
|
||||
const { user } = session
|
||||
|
||||
let { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.select(`username, website, avatar_url`)
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
let { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.select(`username, website, avatar_url`)
|
||||
.eq('id', user.id)
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
console.warn(error)
|
||||
} else if (data) {
|
||||
setUsername(data.username)
|
||||
setWebsite(data.website)
|
||||
setAvatarUrl(data.avatar_url)
|
||||
} catch (error) {
|
||||
console.warn(error.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
getProfile()
|
||||
}, [session])
|
||||
|
||||
async function updateProfile({ username, website, avatar_url }) {
|
||||
try {
|
||||
setLoading(true)
|
||||
const { user } = session
|
||||
async function updateProfile(event) {
|
||||
event.preventDefault()
|
||||
|
||||
const updates = {
|
||||
id: user.id,
|
||||
username,
|
||||
website,
|
||||
avatar_url,
|
||||
updated_at: new Date(),
|
||||
}
|
||||
setLoading(true)
|
||||
const { user } = session
|
||||
|
||||
let { error } = await supabase.from('profiles').upsert(updates)
|
||||
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
} catch (error) {
|
||||
alert(error.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
const updates = {
|
||||
id: user.id,
|
||||
username,
|
||||
website,
|
||||
avatar_url,
|
||||
updated_at: new Date(),
|
||||
}
|
||||
|
||||
let { error } = await supabase.from('profiles').upsert(updates)
|
||||
|
||||
if (error) {
|
||||
alert(error.message)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="form-widget">
|
||||
<form onSubmit={updateProfile} className="form-widget">
|
||||
<Avatar
|
||||
url={avatar_url}
|
||||
size={150}
|
||||
onUpload={(url) => {
|
||||
onUpload={(event, url) => {
|
||||
setAvatarUrl(url)
|
||||
updateProfile({ username, website, avatar_url: url })
|
||||
updateProfile(event)
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
@@ -81,6 +74,7 @@ export default function Account({ session }) {
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
required
|
||||
value={username || ''}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
@@ -96,20 +90,16 @@ export default function Account({ session }) {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
className="button block primary"
|
||||
onClick={() => updateProfile({ username, website, avatar_url })}
|
||||
disabled={loading}
|
||||
>
|
||||
<button className="button block primary" type="submit" disabled={loading}>
|
||||
{loading ? 'Loading ...' : 'Update'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button className="button block" onClick={() => supabase.auth.signOut()}>
|
||||
<button className="button block" type="button" onClick={() => supabase.auth.signOut()}>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
+4
-2
@@ -1,10 +1,10 @@
|
||||
import './index.css'
|
||||
import { useState, useEffect } from 'react'
|
||||
import './App.css'
|
||||
import { supabase } from './supabaseClient'
|
||||
import Auth from './Auth'
|
||||
import Account from './Account'
|
||||
|
||||
export default function Home() {
|
||||
function App() {
|
||||
const [session, setSession] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -23,3 +23,5 @@ export default function Home() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -1,8 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import App from './App'
|
||||
|
||||
test('renders learn react link', () => {
|
||||
render(<App />)
|
||||
const linkElement = screen.getByText(/learn react/i)
|
||||
expect(linkElement).toBeInTheDocument()
|
||||
})
|
||||
@@ -1,50 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { supabase } from './supabaseClient'
|
||||
|
||||
export default function Auth() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [email, setEmail] = useState('')
|
||||
|
||||
const handleLogin = async (email) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const { error } = await supabase.auth.signInWithOtp({ email })
|
||||
if (error) throw error
|
||||
alert('Check your email for the login link!')
|
||||
} catch (error) {
|
||||
alert(error.error_description || error.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="row flex flex-center">
|
||||
<div className="col-6 form-widget">
|
||||
<h1 className="header">Supabase + React</h1>
|
||||
<p className="description">Sign in via magic link with your email below</p>
|
||||
<div>
|
||||
<input
|
||||
className="inputField"
|
||||
type="email"
|
||||
placeholder="Your email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleLogin(email)
|
||||
}}
|
||||
className={'button block'}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? <span>Loading</span> : <span>Send magic link</span>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useState } from 'react'
|
||||
import { supabase } from './supabaseClient'
|
||||
|
||||
export default function Auth() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [email, setEmail] = useState('')
|
||||
|
||||
const handleLogin = async (event) => {
|
||||
event.preventDefault()
|
||||
|
||||
setLoading(true)
|
||||
const { error } = await supabase.auth.signInWithOtp({ email })
|
||||
|
||||
if (error) {
|
||||
alert(error.error_description || error.message)
|
||||
} else {
|
||||
alert('Check your email for the login link!')
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="row flex flex-center">
|
||||
<div className="col-6 form-widget">
|
||||
<h1 className="header">Supabase + React</h1>
|
||||
<p className="description">Sign in via magic link with your email below</p>
|
||||
<form className="form-widget" onSubmit={handleLogin}>
|
||||
<div>
|
||||
<input
|
||||
className="inputField"
|
||||
type="email"
|
||||
placeholder="Your email"
|
||||
value={email}
|
||||
required={true}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<button className={'button block'} disabled={loading}>
|
||||
{loading ? <span>Loading</span> : <span>Send magic link</span>}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -41,7 +41,7 @@ export default function Avatar({ url, size, onUpload }) {
|
||||
throw uploadError
|
||||
}
|
||||
|
||||
onUpload(filePath)
|
||||
onUpload(event, filePath)
|
||||
} catch (error) {
|
||||
alert(error.message)
|
||||
} finally {
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -1,17 +0,0 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
import reportWebVitals from './reportWebVitals'
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
document.getElementById('root')
|
||||
)
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
// to log results (for example: reportWebVitals(console.log))
|
||||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||
reportWebVitals()
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -1,13 +0,0 @@
|
||||
const reportWebVitals = (onPerfEntry) => {
|
||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
getCLS(onPerfEntry)
|
||||
getFID(onPerfEntry)
|
||||
getFCP(onPerfEntry)
|
||||
getLCP(onPerfEntry)
|
||||
getTTFB(onPerfEntry)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default reportWebVitals
|
||||
@@ -1,5 +0,0 @@
|
||||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom'
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
const supabaseUrl = process.env.REACT_APP_SUPABASE_URL
|
||||
const supabaseAnonKey = process.env.REACT_APP_SUPABASE_ANON_KEY
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
|
||||
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY
|
||||
|
||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey)
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
@@ -126,10 +126,16 @@ export function Separator() {
|
||||
return <div className={__styles.separator}></div>
|
||||
}
|
||||
|
||||
export function Content({ children }: { children: React.ReactNode }) {
|
||||
export function Content({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
let __styles = styleHandler('sidepanel')
|
||||
|
||||
return <div className={__styles.content}>{children}</div>
|
||||
return <div className={[__styles.content, className].join(' ').trim()}>{children}</div>
|
||||
}
|
||||
|
||||
SidePanel.Content = Content
|
||||
|
||||
+24
-24
@@ -2,7 +2,7 @@ openref: 0.1
|
||||
|
||||
info:
|
||||
id: reference/csharp
|
||||
title: Getting started
|
||||
title: Supabase C# Client
|
||||
description: |
|
||||
|
||||
Supabase C#.
|
||||
@@ -293,9 +293,9 @@ functions:
|
||||
isSpotlight: true
|
||||
code: |
|
||||
```c#
|
||||
var attrs = new UserAttributes
|
||||
{
|
||||
Data = new Dictionary<string, string> { {"example", "data" } }
|
||||
var attrs = new UserAttributes
|
||||
{
|
||||
Data = new Dictionary<string, string> { {"example", "data" } }
|
||||
};
|
||||
var response = await supabase.Auth.Update(attrs);
|
||||
```
|
||||
@@ -516,10 +516,10 @@ functions:
|
||||
public int CountryId { get; set; }
|
||||
}
|
||||
|
||||
var model = new City
|
||||
{
|
||||
Name = "The Shire",
|
||||
CountryId = 554
|
||||
var model = new City
|
||||
{
|
||||
Name = "The Shire",
|
||||
CountryId = 554
|
||||
};
|
||||
|
||||
await supabase.From<City>().Insert(model);
|
||||
@@ -603,10 +603,10 @@ functions:
|
||||
isSpotlight: true
|
||||
code: |
|
||||
```c#
|
||||
var model = new City
|
||||
{
|
||||
Id = 554,
|
||||
Name = "Middle Earth"
|
||||
var model = new City
|
||||
{
|
||||
Id = 554,
|
||||
Name = "Middle Earth"
|
||||
};
|
||||
|
||||
await supabase.From<City>().Upsert(model);
|
||||
@@ -620,10 +620,10 @@ functions:
|
||||
isSpotlight: true
|
||||
code: |
|
||||
```c#
|
||||
var model = new City
|
||||
{
|
||||
Id = 554,
|
||||
Name = "Middle Earth"
|
||||
var model = new City
|
||||
{
|
||||
Id = 554,
|
||||
Name = "Middle Earth"
|
||||
};
|
||||
|
||||
await supabase
|
||||
@@ -637,10 +637,10 @@ functions:
|
||||
Allowed values for count option are `exact`, `planned` and `estimated`.
|
||||
code: |
|
||||
```c#
|
||||
var model = new City
|
||||
{
|
||||
Id = 554,
|
||||
Name = "Middle Earth"
|
||||
var model = new City
|
||||
{
|
||||
Id = 554,
|
||||
Name = "Middle Earth"
|
||||
};
|
||||
|
||||
await supabase
|
||||
@@ -702,7 +702,7 @@ functions:
|
||||
isSpotlight: true
|
||||
code: |
|
||||
```c#
|
||||
class CursorBroadcast : BaseBroadcast
|
||||
class CursorBroadcast : BaseBroadcast
|
||||
{
|
||||
[JsonProperty("cursorX")]
|
||||
public int CursorX {get; set;}
|
||||
@@ -714,7 +714,7 @@ functions:
|
||||
var channel = supabase.Realtime.Channel("any");
|
||||
var broadcast = channel.Register<CursorBroadcast>();
|
||||
|
||||
broadcast.OnBroadcast += (sender, _) =>
|
||||
broadcast.OnBroadcast += (sender, _) =>
|
||||
{
|
||||
var data = broadcast.Current();
|
||||
};
|
||||
@@ -729,7 +729,7 @@ functions:
|
||||
isSpotlight: true
|
||||
code: |
|
||||
```c#
|
||||
class UserPresence : BasePresence
|
||||
class UserPresence : BasePresence
|
||||
{
|
||||
[JsonProperty("cursorX")]
|
||||
public bool IsTyping {get; set;}
|
||||
@@ -742,7 +742,7 @@ functions:
|
||||
var presenceKey = Guid.NewGuid().ToString();
|
||||
var presence = channel.Register<UserPresence>(presenceKey);
|
||||
|
||||
presence.OnSync += (sender, _) =>
|
||||
presence.OnSync += (sender, _) =>
|
||||
{
|
||||
var state = presence.CurrentState;
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ openref: 0.1
|
||||
|
||||
info:
|
||||
id: reference/dart
|
||||
title: Getting started
|
||||
title: Supabase Dart Client
|
||||
description: |
|
||||
|
||||
Supabase Dart.
|
||||
|
||||
@@ -2,7 +2,7 @@ openref: 0.1
|
||||
|
||||
info:
|
||||
id: reference/dart
|
||||
title: Getting started
|
||||
title: Supabase Dart Client
|
||||
description: |
|
||||
|
||||
Supabase Dart.
|
||||
@@ -39,7 +39,7 @@ functions:
|
||||
runApp(MyApp());
|
||||
}
|
||||
|
||||
// Get a reference your Supabase client
|
||||
// Get a reference your Supabase client
|
||||
final supabase = Supabase.instance.client;
|
||||
```
|
||||
- id: for-other-dart-projects
|
||||
|
||||
@@ -2,7 +2,7 @@ openref: 0.1
|
||||
|
||||
info:
|
||||
id: reference/supabase-js
|
||||
title: Supabase Client
|
||||
title: Supabase Javascript Client
|
||||
description: |
|
||||
|
||||
Supabase JavaScript.
|
||||
@@ -61,7 +61,7 @@ functions:
|
||||
```
|
||||
description: |
|
||||
By default the API server points to the `public` schema. You can enable other database schemas within the Dashboard.
|
||||
Go to `Settings > API > Schema` and add the schema which you want to expose to the API. You also need to grant `USAGE` on your new schema with the grants you desire, such as `SELECT, INSERT, UPDATE, DELETE`.
|
||||
Go to `Settings > API > Schema` and add the schema which you want to expose to the API. You also need to grant `USAGE` on your new schema with the grants you desire, such as `SELECT, INSERT, UPDATE, DELETE`.
|
||||
|
||||
Note: each client connection can only access a single schema, so the code above can access the `other_schema` schema but cannot access the `public` schema.
|
||||
- id: custom-fetch-implementation
|
||||
@@ -567,7 +567,7 @@ functions:
|
||||
notes: |
|
||||
Sends a password reset request to an email address.
|
||||
- When the user clicks the reset link in the email they are redirected back to your application.
|
||||
You can configure the URL that the user is redirected to via the `redirectTo` param.
|
||||
You can configure the URL that the user is redirected to via the `redirectTo` param.
|
||||
See [redirect URLs and wildcards](/docs/guides/auth/overview#redirect-urls-and-wildcards) to add additional redirect URLs to your project.
|
||||
- After the user has been redirected successfully, prompt them for a new password and call `updateUser()`:
|
||||
```js
|
||||
|
||||
+9
-11
@@ -2,7 +2,7 @@ openref: 0.1
|
||||
|
||||
info:
|
||||
id: reference/supabase-js
|
||||
title: Supabase Client
|
||||
title: Supabase Javascript Client
|
||||
description: |
|
||||
|
||||
Supabase JavaScript.
|
||||
@@ -812,9 +812,7 @@ functions:
|
||||
const { data, error } = await supabase.auth.admin.generateLink({
|
||||
type: 'signup',
|
||||
email: 'email@example.com',
|
||||
options: {
|
||||
'password': 'secret'
|
||||
}
|
||||
password: 'secret'
|
||||
})
|
||||
```
|
||||
- id: generate-an-invite-link
|
||||
@@ -1320,21 +1318,21 @@ functions:
|
||||
sql: |
|
||||
```sql
|
||||
create table countries (
|
||||
"id" "uuid" primary key default "extensions"."uuid_generate_v4"() not null,
|
||||
"id" "uuid" primary key default "extensions"."uuid_generate_v4"() not null,
|
||||
"name" text
|
||||
);
|
||||
|
||||
create table cities (
|
||||
"id" "uuid" primary key default "extensions"."uuid_generate_v4"() not null,
|
||||
"name" text,
|
||||
"id" "uuid" primary key default "extensions"."uuid_generate_v4"() not null,
|
||||
"name" text,
|
||||
"country_id" "uuid" references public.countries on delete cascade
|
||||
);
|
||||
|
||||
|
||||
with country as (
|
||||
insert into countries (name)
|
||||
insert into countries (name)
|
||||
values ('united kingdom') returning id
|
||||
)
|
||||
insert into cities (name, country_id) values
|
||||
insert into cities (name, country_id) values
|
||||
('London', (select id from country)),
|
||||
('Manchester', (select id from country)),
|
||||
('Liverpool', (select id from country)),
|
||||
@@ -3889,7 +3887,7 @@ functions:
|
||||
}
|
||||
```
|
||||
description: |
|
||||
You can use an [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) to abort requests.
|
||||
You can use an [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController) to abort requests.
|
||||
Note that `status` and `statusText` don't mean anything for aborted requests as the request wasn't fulfilled.
|
||||
hideCodeBlock: true
|
||||
isSpotlight: true
|
||||
|
||||
@@ -2,7 +2,7 @@ openref: 0.1
|
||||
|
||||
info:
|
||||
id: reference/supabase-py
|
||||
title: Supabase Client
|
||||
title: Supabase Python Client
|
||||
description: |
|
||||
|
||||
Supabase Python(Community).
|
||||
@@ -519,21 +519,21 @@ functions:
|
||||
sql: |
|
||||
```sql
|
||||
create table countries (
|
||||
"id" "uuid" primary key default "extensions"."uuid_generate_v4"() not null,
|
||||
"id" "uuid" primary key default "extensions"."uuid_generate_v4"() not null,
|
||||
"name" text
|
||||
);
|
||||
|
||||
create table cities (
|
||||
"id" "uuid" primary key default "extensions"."uuid_generate_v4"() not null,
|
||||
"name" text,
|
||||
"id" "uuid" primary key default "extensions"."uuid_generate_v4"() not null,
|
||||
"name" text,
|
||||
"country_id" "uuid" references public.countries on delete cascade
|
||||
);
|
||||
|
||||
with country as (
|
||||
insert into countries (name)
|
||||
insert into countries (name)
|
||||
values ('united kingdom') returning id
|
||||
)
|
||||
insert into cities (name, country_id) values
|
||||
insert into cities (name, country_id) values
|
||||
('London', (select id from country)),
|
||||
('Manchester', (select id from country)),
|
||||
('Liverpool', (select id from country)),
|
||||
@@ -543,7 +543,7 @@ functions:
|
||||
```
|
||||
APIResponse(data=[{'id': 1, 'name': 'Germany', 'cities': [{'count': 1}]},
|
||||
{'id': 2, 'name': 'Indonesia', 'cities': [{'count': 1}]}],
|
||||
count=None)
|
||||
count=None)
|
||||
```
|
||||
description: |
|
||||
You can get the number of rows in a related table by using the
|
||||
|
||||
@@ -198,7 +198,13 @@ const SupabaseGridLayout = forwardRef<SupabaseGridRef, SupabaseGridProps>((props
|
||||
onAddColumn={editable ? props.onAddColumn : undefined}
|
||||
headerActions={headerActions}
|
||||
/>
|
||||
<Grid ref={gridRef} {...gridProps} rows={data?.rows ?? []} updateRow={props.updateTableRow} />
|
||||
<Grid
|
||||
ref={gridRef}
|
||||
{...gridProps}
|
||||
rows={data?.rows ?? []}
|
||||
updateRow={props.updateTableRow}
|
||||
onEditForeignKeyColumnValue={props.onEditForeignKeyColumnValue}
|
||||
/>
|
||||
<Footer isLoading={isLoading || isRefetching} />
|
||||
<Shortcuts gridRef={gridRef} />
|
||||
{mounted &&
|
||||
|
||||
@@ -215,9 +215,12 @@ export function parseSupaTable(
|
||||
isEncrypted: encryptedColumns.includes(column.name),
|
||||
enum: column.enums,
|
||||
comment: column.comment,
|
||||
targetTableSchema: null,
|
||||
targetTableName: null,
|
||||
targetColumnName: null,
|
||||
foreignKey: {
|
||||
targetTableSchema: null,
|
||||
targetTableName: null,
|
||||
targetColumnName: null,
|
||||
deletionAction: undefined,
|
||||
},
|
||||
}
|
||||
const primaryKey = primaryKeys.find((pk) => pk.name == column.name)
|
||||
temp.isPrimaryKey = !!primaryKey
|
||||
@@ -230,9 +233,10 @@ export function parseSupaTable(
|
||||
)
|
||||
})
|
||||
if (relationship) {
|
||||
temp.targetTableSchema = relationship.target_table_schema
|
||||
temp.targetTableName = relationship.target_table_name
|
||||
temp.targetColumnName = relationship.target_column_name
|
||||
temp.foreignKey.targetTableSchema = relationship.target_table_schema
|
||||
temp.foreignKey.targetTableName = relationship.target_table_name
|
||||
temp.foreignKey.targetColumnName = relationship.target_column_name
|
||||
temp.foreignKey.deletionAction = relationship.deletion_action
|
||||
}
|
||||
return temp
|
||||
})
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import React, { FC, useState } from 'react'
|
||||
import AwesomeDebouncePromise from 'awesome-debounce-promise'
|
||||
import { Button, Input, IconChevronDown, IconSearch } from 'ui'
|
||||
import { DropdownControl } from '../../common'
|
||||
import { FilterOperatorOptions } from '../../header/filter'
|
||||
import { Filter, FilterOperator } from '../../../types'
|
||||
|
||||
interface FilterProps {
|
||||
defaultColumnName?: string
|
||||
defaultValue?: string
|
||||
foreignColumnNames: string[]
|
||||
onChange: (value: Filter) => void
|
||||
}
|
||||
|
||||
export const FilterHeader: FC<FilterProps> = ({
|
||||
defaultColumnName,
|
||||
defaultValue,
|
||||
foreignColumnNames,
|
||||
onChange,
|
||||
}) => {
|
||||
const [columnName, setColumnName] = useState(
|
||||
defaultColumnName
|
||||
? defaultColumnName
|
||||
: foreignColumnNames.length > 0
|
||||
? foreignColumnNames[0]
|
||||
: ''
|
||||
)
|
||||
const [condition, setCondition] = useState(FilterOperatorOptions[0].value)
|
||||
const [filterText, setFilterText] = useState(defaultValue ?? '')
|
||||
|
||||
const columnOptions =
|
||||
foreignColumnNames.map((x) => {
|
||||
return { value: x, label: x }
|
||||
}) || []
|
||||
|
||||
function triggerOnChange(columnName: string, condition: string, filterText: string) {
|
||||
onFilterChangeDebounced(
|
||||
{
|
||||
column: columnName,
|
||||
operator: condition as FilterOperator,
|
||||
value: filterText,
|
||||
},
|
||||
onChange
|
||||
)
|
||||
}
|
||||
|
||||
function onColumnChange(value: string | number) {
|
||||
const str = value + ''
|
||||
setColumnName(str)
|
||||
triggerOnChange(str, condition, filterText)
|
||||
}
|
||||
|
||||
function onConditionChange(value: string | number) {
|
||||
const str = value + ''
|
||||
setCondition(str)
|
||||
triggerOnChange(columnName, str, filterText)
|
||||
}
|
||||
|
||||
function onFilterChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const value = event.target.value
|
||||
setFilterText(value)
|
||||
triggerOnChange(columnName, condition, value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="foreign-table-modal__filter">
|
||||
<DropdownControl
|
||||
side="bottom"
|
||||
align="start"
|
||||
options={columnOptions}
|
||||
onSelect={onColumnChange}
|
||||
>
|
||||
<Button as="span" type="outline" iconRight={<IconChevronDown />}>
|
||||
<span className="foreign-table-modal__filter__trigger-content">
|
||||
<span className="foreign-table-modal__filter__trigger-content__label">Column</span>
|
||||
<span className="foreign-table-modal__filter__trigger-content__name">{columnName}</span>
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownControl>
|
||||
<DropdownControl
|
||||
side="bottom"
|
||||
align="start"
|
||||
options={FilterOperatorOptions}
|
||||
onSelect={onConditionChange}
|
||||
>
|
||||
<Button as="span" type="outline" iconRight={<IconChevronDown />}>
|
||||
<span className="foreign-table-modal__filter__trigger-content">
|
||||
<span className="foreign-table-modal__filter__trigger-content__label">Filter</span>
|
||||
<span className="foreign-table-modal__filter__trigger-content__name">{condition}</span>
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownControl>
|
||||
<Input
|
||||
size="tiny"
|
||||
className="foreign-table-modal__filter__search-input"
|
||||
placeholder="Find a record"
|
||||
value={filterText}
|
||||
onChange={onFilterChange}
|
||||
icon={<IconSearch size="small" />}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const onFilterChangeDebounced = AwesomeDebouncePromise(
|
||||
(filter: Filter, onChange: (value: Filter) => void) => {
|
||||
onChange(filter)
|
||||
},
|
||||
500
|
||||
)
|
||||
@@ -1,43 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import { Menu } from 'ui'
|
||||
import { Dictionary } from '../../../types'
|
||||
|
||||
interface RowItemProps {
|
||||
item: Dictionary<any>
|
||||
onSelect: (item: Dictionary<any>) => void
|
||||
columnNames: string[]
|
||||
}
|
||||
|
||||
export const RowItem: React.FC<RowItemProps> = ({ item, onSelect, columnNames }) => {
|
||||
return (
|
||||
<div className="foreign-table-modal__row-item">
|
||||
<Menu.Item onClick={() => onSelect(item)} style={{ minWidth: 'min-content' }}>
|
||||
<div className="foreign-table-modal__row-item__inner">
|
||||
{columnNames.map((key, j) => {
|
||||
//
|
||||
// limit to 5 attributes
|
||||
//
|
||||
// this could be improved so the user could pick which attributes to display
|
||||
// @mildtomato
|
||||
if (j > 5) return null
|
||||
|
||||
return (
|
||||
<div className="foreign-table-modal__row-item__inner__key-item" key={`item-${j}`}>
|
||||
<p className="foreign-table-modal__row-item__inner__key-item__key text-sm text-scale-1000">
|
||||
{key}
|
||||
</p>
|
||||
<p className="text-sm font-bold">
|
||||
{item[key]
|
||||
? typeof item[key] === 'object'
|
||||
? JSON.stringify(item[key])
|
||||
: item[key]
|
||||
: '[null]'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import { Button, Modal, Menu, IconExternalLink, IconTrash } from 'ui'
|
||||
|
||||
import { RowItem } from './RowItem'
|
||||
import { FilterHeader } from './FilterHeader'
|
||||
import Query from 'components/grid/query'
|
||||
import { useTrackedState } from 'components/grid/store'
|
||||
import { Dictionary, Filter } from 'components/grid/types'
|
||||
|
||||
type ForeignTableModalProps = {
|
||||
columnName?: string
|
||||
defaultValue?: string
|
||||
onChange: (value: any | null) => void
|
||||
}
|
||||
|
||||
export const ForeignTableModal: React.FC<ForeignTableModalProps> = ({
|
||||
columnName,
|
||||
defaultValue,
|
||||
onChange,
|
||||
}) => {
|
||||
const [visible, setVisible] = React.useState(false)
|
||||
const [foreignColumnNames, setForeignColumnNames] = React.useState<string[]>([])
|
||||
const [rows, setRows] = React.useState<Dictionary<any>[] | null>(null)
|
||||
const state = useTrackedState()
|
||||
const columnDefinition = state.table?.columns.find((x) => x.name == columnName)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!visible) return
|
||||
|
||||
fetchColumns()
|
||||
|
||||
if (defaultValue && columnDefinition) {
|
||||
fetchData({
|
||||
column: columnDefinition.targetColumnName!,
|
||||
operator: '=',
|
||||
value: defaultValue,
|
||||
})
|
||||
} else {
|
||||
fetchData()
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
async function fetchColumns() {
|
||||
if (
|
||||
!state.metaService ||
|
||||
!columnDefinition?.targetTableName ||
|
||||
!columnDefinition?.targetTableSchema
|
||||
)
|
||||
return
|
||||
|
||||
const { data } = await state.metaService.fetchColumns(
|
||||
columnDefinition?.targetTableName,
|
||||
columnDefinition?.targetTableSchema
|
||||
)
|
||||
if (data && data.length > 0) {
|
||||
const columnNames = data.map((x) => x.name)
|
||||
setForeignColumnNames(columnNames)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchData(filter?: Filter) {
|
||||
if (!state.onSqlQuery || !columnDefinition?.targetTableName) return
|
||||
|
||||
const query = new Query()
|
||||
let queryChains = query
|
||||
.from(columnDefinition?.targetTableName, columnDefinition?.targetTableSchema ?? undefined)
|
||||
.select()
|
||||
|
||||
if (filter && filter.value && filter.value != '') {
|
||||
const { column, operator, value } = filter
|
||||
queryChains = queryChains.filter(column, operator, value)
|
||||
}
|
||||
|
||||
// TODO: How to let users know that filter result limit at 20 results
|
||||
// should we allow a higher value?
|
||||
const sql = queryChains.range(0, 20).toSql()
|
||||
const { data, error } = await state.onSqlQuery(sql)
|
||||
if (error) {
|
||||
setRows(null)
|
||||
} else {
|
||||
setRows(data)
|
||||
}
|
||||
}
|
||||
|
||||
function toggle(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) {
|
||||
if (event && event.stopPropagation) event.stopPropagation()
|
||||
setVisible(!visible)
|
||||
}
|
||||
|
||||
function onItemSelect(item: Dictionary<any>) {
|
||||
if (item && columnDefinition && columnDefinition.targetColumnName) {
|
||||
const value = item[columnDefinition.targetColumnName]
|
||||
onChange(value)
|
||||
}
|
||||
|
||||
setVisible(false)
|
||||
}
|
||||
|
||||
function renderRows() {
|
||||
if (!rows) return null
|
||||
const temp = rows.map((x, i) => {
|
||||
return (
|
||||
<RowItem
|
||||
key={`menu-${i}`}
|
||||
item={x}
|
||||
onSelect={onItemSelect}
|
||||
columnNames={foreignColumnNames}
|
||||
/>
|
||||
)
|
||||
})
|
||||
return <Menu className="foreign-table-modal__menu">{temp}</Menu>
|
||||
}
|
||||
|
||||
function onFilterChange(filter: Filter) {
|
||||
if (filter && filter.value && filter.value != '') fetchData(filter)
|
||||
}
|
||||
|
||||
function onClearValueClick() {
|
||||
onChange(null)
|
||||
setVisible(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="default"
|
||||
onClick={toggle}
|
||||
icon={<IconExternalLink />}
|
||||
style={{ padding: '3px' }}
|
||||
/>
|
||||
<Modal
|
||||
visible={visible}
|
||||
onCancel={toggle}
|
||||
closable
|
||||
customFooter={
|
||||
<Button block danger icon={<IconTrash />} onClick={onClearValueClick}>
|
||||
Clear value
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Modal.Content>
|
||||
<FilterHeader
|
||||
defaultColumnName={columnDefinition?.targetColumnName ?? undefined}
|
||||
defaultValue={defaultValue}
|
||||
foreignColumnNames={foreignColumnNames}
|
||||
onChange={onFilterChange}
|
||||
/>
|
||||
</Modal.Content>
|
||||
<Modal.Separator />
|
||||
<Modal.Content>
|
||||
<div className="foreign-table-modal__content-container">
|
||||
<div
|
||||
className="foreign-table-modal__content-container__inner__overflow"
|
||||
style={{ minHeight: '21rem', maxHeight: '20rem' }}
|
||||
>
|
||||
{renderRows()}
|
||||
</div>
|
||||
</div>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,30 +1,46 @@
|
||||
import * as React from 'react'
|
||||
import { Select } from 'ui'
|
||||
import { EditorProps } from '@supabase/react-data-grid'
|
||||
import { useTrackedState } from 'components/grid/store'
|
||||
|
||||
interface Props<TRow, TSummaryRow = unknown> extends EditorProps<TRow, TSummaryRow> {
|
||||
isNullable?: boolean
|
||||
}
|
||||
|
||||
export function BooleanEditor<TRow, TSummaryRow = unknown>({
|
||||
row,
|
||||
column,
|
||||
isNullable,
|
||||
onRowChange,
|
||||
onClose,
|
||||
}: EditorProps<TRow, TSummaryRow>) {
|
||||
function onChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
onRowChange({ ...row, [column.key]: event.target.checked })
|
||||
}
|
||||
}: Props<TRow, TSummaryRow>) {
|
||||
const state = useTrackedState()
|
||||
const gridColumn = state.gridColumns.find((x) => x.name == column.key)
|
||||
const value = row[column.key as keyof TRow] as unknown as string
|
||||
|
||||
function onBlur() {
|
||||
onClose(true)
|
||||
const onBlur = () => onClose(false)
|
||||
const onChange = (event: any) => {
|
||||
const value = event.target.value
|
||||
if (value === 'null') {
|
||||
onRowChange({ ...row, [column.key]: null }, true)
|
||||
} else {
|
||||
onRowChange({ ...row, [column.key]: value === 'true' }, true)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sb-grid-checkbox-editor">
|
||||
<input
|
||||
className="sb-grid-checkbox-editor__input"
|
||||
checked={row[column.key as keyof TRow] as unknown as boolean}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
type="checkbox"
|
||||
style={{ margin: 'auto' }}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
autoFocus
|
||||
id="boolean-editor"
|
||||
name="boolean-editor"
|
||||
size="small"
|
||||
onBlur={onBlur}
|
||||
onChange={onChange}
|
||||
defaultValue={value === null ? 'null' : value.toString()}
|
||||
style={{ width: `${gridColumn?.width || column.width}px` }}
|
||||
>
|
||||
<Select.Option value="true">TRUE</Select.Option>
|
||||
<Select.Option value="false">FALSE</Select.Option>
|
||||
{isNullable && <Select.Option value="null">NULL</Select.Option>}
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import { EditorProps } from '@supabase/react-data-grid'
|
||||
import { Button, IconX } from 'ui'
|
||||
|
||||
export function NullableBooleanEditor<TRow, TSummaryRow = unknown>({
|
||||
row,
|
||||
column,
|
||||
onRowChange,
|
||||
onClose,
|
||||
}: EditorProps<TRow, TSummaryRow>) {
|
||||
const value = row[column.key as keyof TRow] as unknown as boolean | null
|
||||
|
||||
useEffect(() => {
|
||||
// if value is null, set it to false on initial render
|
||||
if (value === null) {
|
||||
onRowChange({ ...row, [column.key]: false })
|
||||
}
|
||||
}, [])
|
||||
|
||||
function onChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
onRowChange({ ...row, [column.key]: event.target.checked })
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
onClose(true)
|
||||
}
|
||||
|
||||
function onClear() {
|
||||
onRowChange({ ...row, [column.key]: null }, true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sb-grid-checkbox-editor">
|
||||
<input
|
||||
className="sb-grid-checkbox-editor__input"
|
||||
checked={value === null ? false : value}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
type="checkbox"
|
||||
style={{ margin: 'auto' }}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
title="Clear"
|
||||
icon={<IconX size="tiny" strokeWidth={2} />}
|
||||
onClick={onClear}
|
||||
style={{ padding: '3px', margin: 'auto 5px auto auto' }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,13 +2,14 @@ import { useState, useCallback } from 'react'
|
||||
import { EditorProps } from '@supabase/react-data-grid'
|
||||
import { useTrackedState } from '../../store'
|
||||
import { BlockKeys, MonacoEditor, NullValue, EmptyValue } from '../common'
|
||||
import { Popover } from 'ui'
|
||||
import { Button, Popover } from 'ui'
|
||||
|
||||
export function TextEditor<TRow, TSummaryRow = unknown>({
|
||||
row,
|
||||
column,
|
||||
isNullable,
|
||||
onRowChange,
|
||||
}: EditorProps<TRow, TSummaryRow>) {
|
||||
}: EditorProps<TRow, TSummaryRow> & { isNullable?: boolean }) {
|
||||
const state = useTrackedState()
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(true)
|
||||
const gridColumn = state.gridColumns.find((x) => x.name == column.key)
|
||||
@@ -59,6 +60,13 @@ export function TextEditor<TRow, TSummaryRow = unknown>({
|
||||
<p className="text-xs text-scale-1100">Cancel changes</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{isNullable && (
|
||||
<Button type="default" size="tiny" onClick={() => saveChanges(null)}>
|
||||
Set to NULL
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</BlockKeys>
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ export * from './BooleanEditor'
|
||||
export * from './DateEditor'
|
||||
export * from './DateTimeEditor'
|
||||
export * from './JsonEditor'
|
||||
export * from './NullableBooleanEditor'
|
||||
export * from './NumberEditor'
|
||||
export * from './SelectEditor'
|
||||
export * from './TextEditor'
|
||||
|
||||
@@ -6,5 +6,5 @@ import { NullValue } from '../common'
|
||||
export const BooleanFormatter = (p: React.PropsWithChildren<FormatterProps<SupaRow, unknown>>) => {
|
||||
const value = p.row[p.column.key] as boolean | null
|
||||
if (value === null) return <NullValue />
|
||||
return <>{value ? 'true' : 'false'}</>
|
||||
return <>{value ? 'TRUE' : 'FALSE'}</>
|
||||
}
|
||||
@@ -1,43 +1,70 @@
|
||||
import * as React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { PropsWithChildren } from 'react'
|
||||
import { FormatterProps } from '@supabase/react-data-grid'
|
||||
import { SupaRow } from '../../types'
|
||||
import { NullValue } from '../common'
|
||||
import { ForeignTableModal } from '../common/ForeignTableModal'
|
||||
import { useDispatch, useTrackedState } from '../../store'
|
||||
import { deepClone } from '../../utils'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { Button, IconArrowRight } from 'ui'
|
||||
import { useParams, useStore } from 'hooks'
|
||||
|
||||
export const ForeignKeyFormatter = (
|
||||
p: React.PropsWithChildren<FormatterProps<SupaRow, unknown>>
|
||||
) => {
|
||||
const state = useTrackedState()
|
||||
const dispatch = useDispatch()
|
||||
const value = p.row[p.column.key]
|
||||
export const ForeignKeyFormatter = (props: PropsWithChildren<FormatterProps<SupaRow, unknown>>) => {
|
||||
const { ref, id } = useParams()
|
||||
const { meta } = useStore()
|
||||
|
||||
function onRowChange(_value: any | null) {
|
||||
const rowData = deepClone(p.row)
|
||||
rowData[p.column.key] = _value
|
||||
const { row, column } = props
|
||||
const selectedTable = meta.tables.byId(id as string)
|
||||
const relationship = (selectedTable?.relationships ?? []).find(
|
||||
(r) =>
|
||||
r.source_schema === selectedTable?.schema &&
|
||||
r.source_table_name === selectedTable?.name &&
|
||||
r.source_column_name === column.name
|
||||
)
|
||||
const targetTable = meta.tables
|
||||
.list()
|
||||
.find(
|
||||
(table) =>
|
||||
table.schema === relationship?.target_table_schema &&
|
||||
table.name === relationship.target_table_name
|
||||
)
|
||||
|
||||
const { error } = state.rowService!.update(rowData, p.row, p.column.key)
|
||||
if (error) {
|
||||
if (state.onError) state.onError(error)
|
||||
} else {
|
||||
dispatch({
|
||||
type: 'EDIT_ROW',
|
||||
payload: { row: rowData, idx: p.row.idx },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function onChange(_value: any | null) {
|
||||
onRowChange(_value)
|
||||
}
|
||||
const value = row[column.key]
|
||||
|
||||
return (
|
||||
<div className="sb-grid-foreign-key-formatter flex justify-between">
|
||||
<span className="sb-grid-foreign-key-formatter__text">
|
||||
{value === null ? <NullValue /> : value}
|
||||
</span>
|
||||
<ForeignTableModal columnName={p.column.key} defaultValue={value} onChange={onChange} />
|
||||
{relationship !== undefined && targetTable !== undefined && (
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger>
|
||||
<Link
|
||||
href={`/project/${ref}/editor/${targetTable?.id}?filter=${relationship?.target_column_name}%3Aeq%3A${value}`}
|
||||
>
|
||||
<a>
|
||||
<Button
|
||||
type="default"
|
||||
size="tiny"
|
||||
className="translate-y-[2px]"
|
||||
onClick={() => {}}
|
||||
icon={<IconArrowRight size="tiny" />}
|
||||
style={{ padding: '3px' }}
|
||||
/>
|
||||
</a>
|
||||
</Link>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
|
||||
'border border-scale-200',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="text-xs text-scale-1200">View referencing record</span>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
import * as React from 'react'
|
||||
import { IconKey, IconLink, IconLock } from 'ui'
|
||||
import { IconArrowRight, IconKey, IconLink, IconLock } from 'ui'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { useDrag, useDrop, DropTargetMonitor } from 'react-dnd'
|
||||
import { XYCoord } from 'dnd-core'
|
||||
import { useDispatch } from '../../store'
|
||||
import { ColumnHeaderProps, ColumnType, DragItem } from '../../types'
|
||||
import { ColumnHeaderProps, ColumnType, DragItem, GridForeignKey } from '../../types'
|
||||
import { ColumnMenu } from '../menu'
|
||||
import { useTrackedState } from '../../store'
|
||||
import { FOREIGN_KEY_DELETION_ACTION } from 'data/database/database-query-constants'
|
||||
import { getForeignKeyDeletionAction } from 'components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.utils'
|
||||
|
||||
export function ColumnHeader<R>({
|
||||
column,
|
||||
@@ -14,6 +16,7 @@ export function ColumnHeader<R>({
|
||||
isPrimaryKey,
|
||||
isEncrypted,
|
||||
format,
|
||||
foreignKey,
|
||||
}: ColumnHeaderProps<R>) {
|
||||
const ref = React.useRef<HTMLDivElement>(null)
|
||||
const dispatch = useDispatch()
|
||||
@@ -119,7 +122,7 @@ export function ColumnHeader<R>({
|
||||
<div ref={ref} data-handler-id={handlerId} style={{ opacity }} className="w-full">
|
||||
<div className={`sb-grid-column-header ${cursor}`}>
|
||||
<div className="sb-grid-column-header__inner">
|
||||
{renderColumnIcon(columnType)}
|
||||
{renderColumnIcon(columnType, { name: column.name as string, foreignKey })}
|
||||
{isPrimaryKey && (
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger>
|
||||
@@ -169,13 +172,45 @@ export function ColumnHeader<R>({
|
||||
)
|
||||
}
|
||||
|
||||
function renderColumnIcon(type: ColumnType) {
|
||||
function renderColumnIcon(
|
||||
type: ColumnType,
|
||||
columnMeta: { name?: string; foreignKey?: GridForeignKey }
|
||||
) {
|
||||
const { name, foreignKey } = columnMeta
|
||||
switch (type) {
|
||||
case 'foreign_key':
|
||||
return (
|
||||
<div>
|
||||
<IconLink size="tiny" strokeWidth={2} />
|
||||
</div>
|
||||
<Tooltip.Root delayDuration={0}>
|
||||
<Tooltip.Trigger>
|
||||
<IconLink size="tiny" strokeWidth={2} />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="bottom">
|
||||
<Tooltip.Arrow className="radix-tooltip-arrow" />
|
||||
<div
|
||||
className={[
|
||||
'rounded bg-scale-100 py-1 px-2 leading-none shadow',
|
||||
'border border-scale-200',
|
||||
].join(' ')}
|
||||
>
|
||||
<div>
|
||||
<p className="text-xs text-scale-1100">Foreign key relation:</p>
|
||||
<div className="flex items-center space-x-1">
|
||||
<p className="text-xs text-scale-1200">{name}</p>
|
||||
<IconArrowRight size="tiny" strokeWidth={1.5} />
|
||||
<p className="text-xs text-scale-1200">
|
||||
{foreignKey?.targetTableSchema}.{foreignKey?.targetTableName}.
|
||||
{foreignKey?.targetColumnName}
|
||||
</p>
|
||||
</div>
|
||||
{foreignKey?.deletionAction !== FOREIGN_KEY_DELETION_ACTION.NO_ACTION && (
|
||||
<p className="text-xs text-scale-1200 mt-1">
|
||||
On delete: {getForeignKeyDeletionAction(foreignKey?.deletionAction)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
|
||||
@@ -6,7 +6,7 @@ import { GridProps, SupaRow } from '../../types'
|
||||
import { useDispatch, useTrackedState } from '../../store'
|
||||
import RowRenderer from './RowRenderer'
|
||||
import AwesomeDebouncePromise from 'awesome-debounce-promise'
|
||||
import { useTableRowUpdateMutation } from 'data/table-rows/table-row-update-mutation'
|
||||
import { ForeignRowSelectorProps } from 'components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/ForeignRowSelector'
|
||||
|
||||
function rowKeyGetter(row: SupaRow) {
|
||||
return row?.idx ?? -1
|
||||
@@ -15,13 +15,27 @@ function rowKeyGetter(row: SupaRow) {
|
||||
interface IGrid extends GridProps {
|
||||
rows: any[]
|
||||
updateRow: (previousRow: any, updatedData: any) => void
|
||||
onEditForeignKeyColumnValue: (args: {
|
||||
foreignKey: NonNullable<ForeignRowSelectorProps['foreignKey']>
|
||||
row: any
|
||||
column: any
|
||||
}) => void
|
||||
}
|
||||
|
||||
// [Joshen] Just for visibility this is causing some hook errors in the browser
|
||||
export const Grid = memo(
|
||||
forwardRef<DataGridHandle, IGrid>(
|
||||
(
|
||||
{ width, height, containerClass, gridClass, rowClass, rows, updateRow },
|
||||
{
|
||||
width,
|
||||
height,
|
||||
containerClass,
|
||||
gridClass,
|
||||
rowClass,
|
||||
rows,
|
||||
updateRow,
|
||||
onEditForeignKeyColumnValue,
|
||||
},
|
||||
ref: React.Ref<DataGridHandle> | undefined
|
||||
) => {
|
||||
const dispatch = useDispatch()
|
||||
@@ -61,6 +75,34 @@ export const Grid = memo(
|
||||
})
|
||||
}
|
||||
|
||||
const table = state.table
|
||||
|
||||
function getColumnForeignKey(columnName: string) {
|
||||
const foreignKey = table?.columns.find((x) => x.name == columnName)?.foreignKey ?? {}
|
||||
|
||||
return Boolean(
|
||||
foreignKey.targetTableSchema && foreignKey.targetTableName && foreignKey.targetColumnName
|
||||
)
|
||||
? foreignKey
|
||||
: undefined
|
||||
}
|
||||
|
||||
function onRowDoubleClick(row: any, column: any) {
|
||||
const foreignKey = getColumnForeignKey(column.name)
|
||||
|
||||
if (foreignKey) {
|
||||
onEditForeignKeyColumnValue({
|
||||
foreignKey: {
|
||||
target_column_name: foreignKey.targetColumnName!,
|
||||
target_table_name: foreignKey.targetTableName!,
|
||||
target_table_schema: foreignKey.targetTableSchema!,
|
||||
},
|
||||
row,
|
||||
column,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!columnHeaders || columnHeaders.length == 0) {
|
||||
return (
|
||||
<div
|
||||
@@ -93,6 +135,7 @@ export const Grid = memo(
|
||||
onRowsChange={onRowsChange}
|
||||
onSelectedCellChange={onSelectedCellChange}
|
||||
onSelectedRowsChange={onSelectedRowsChange}
|
||||
onRowDoubleClick={onRowDoubleClick}
|
||||
className={gridClass}
|
||||
rowClass={rowClass}
|
||||
style={{ height: '100%' }}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useState, ReactNode } from 'react'
|
||||
import { Button, IconDownload, IconX, IconTrash, Dropdown, IconChevronDown } from 'ui'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
|
||||
import { checkPermissions, useStore } from 'hooks'
|
||||
import { checkPermissions, useStore, useUrlState } from 'hooks'
|
||||
import FilterDropdown from './filter'
|
||||
import SortPopover from './sort'
|
||||
import RefreshButton from './RefreshButton'
|
||||
@@ -61,12 +61,16 @@ const DefaultHeader = ({ table, onAddColumn, onAddRow }: DefaultHeaderProps) =>
|
||||
// [Joshen] Using this logic to block both column and row creation/update/delete
|
||||
const canCreateColumns = checkPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'columns')
|
||||
|
||||
const [{ filter: filters, sort: sorts }, setParams] = useUrlState({
|
||||
arrayKeys: ['sort', 'filter'],
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<RefreshButton table={table} />
|
||||
<FilterDropdown />
|
||||
<SortPopover />
|
||||
<FilterDropdown table={table} filters={filters as string[]} setParams={setParams} />
|
||||
<SortPopover table={table} sorts={sorts as string[]} setParams={setParams} />
|
||||
</div>
|
||||
{canAddNew && (
|
||||
<>
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import { FC, useState, useMemo, useCallback } from 'react'
|
||||
import { useState, useMemo, useCallback } from 'react'
|
||||
import { isEqual } from 'lodash'
|
||||
import { Button, IconPlus, IconFilter, Popover } from 'ui'
|
||||
import update from 'immutability-helper'
|
||||
import { useUrlState } from 'hooks'
|
||||
|
||||
import FilterRow from './FilterRow'
|
||||
import { useTrackedState } from 'components/grid/store'
|
||||
import { formatFilterURLParams } from 'components/grid/SupabaseGrid.utils'
|
||||
import { Filter } from 'components/grid/types'
|
||||
import { Filter, SupaTable } from 'components/grid/types'
|
||||
import { FilterOperatorOptions } from './Filter.constants'
|
||||
|
||||
const FilterPopover: FC = () => {
|
||||
const [{ filter: filters }]: any = useUrlState({ arrayKeys: ['filter'] })
|
||||
export interface FilterPopoverProps {
|
||||
table: SupaTable
|
||||
filters: string[]
|
||||
setParams: ReturnType<typeof useUrlState>[1]
|
||||
}
|
||||
|
||||
const FilterPopover = ({ table, filters, setParams }: FilterPopoverProps) => {
|
||||
const btnText =
|
||||
(filters || []).length > 0
|
||||
? `Filtered by ${filters.length} rule${filters.length > 1 ? 's' : ''}`
|
||||
@@ -22,7 +26,7 @@ const FilterPopover: FC = () => {
|
||||
size="large"
|
||||
align="start"
|
||||
className="sb-grid-filter-popover"
|
||||
overlay={<FilterOverlay />}
|
||||
overlay={<FilterOverlay table={table} filters={filters} setParams={setParams} />}
|
||||
>
|
||||
<Button
|
||||
as="span"
|
||||
@@ -38,12 +42,12 @@ const FilterPopover: FC = () => {
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export default FilterPopover
|
||||
|
||||
const FilterOverlay: FC = () => {
|
||||
const state = useTrackedState()
|
||||
export interface FilterOverlayProps extends FilterPopoverProps {}
|
||||
|
||||
const [{ filter: filtersFromUrl }, setParams] = useUrlState({ arrayKeys: ['filter'] })
|
||||
const FilterOverlay = ({ table, filters: filtersFromUrl, setParams }: FilterOverlayProps) => {
|
||||
const initialFilters = useMemo(
|
||||
() => formatFilterURLParams((filtersFromUrl as string[]) ?? []),
|
||||
[filtersFromUrl]
|
||||
@@ -51,7 +55,7 @@ const FilterOverlay: FC = () => {
|
||||
const [filters, setFilters] = useState<Filter[]>(initialFilters)
|
||||
|
||||
function onAddFilter() {
|
||||
const column = state.table?.columns[0]?.name
|
||||
const column = table.columns[0]?.name
|
||||
|
||||
if (column) {
|
||||
setFilters([
|
||||
@@ -104,6 +108,7 @@ const FilterOverlay: FC = () => {
|
||||
{filters.map((filter, index) => (
|
||||
<FilterRow
|
||||
key={`filter-${filter.column}-${[index]}`}
|
||||
table={table}
|
||||
filter={filter}
|
||||
filterIdx={index}
|
||||
onChange={onChangeFilter}
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
import { FC, memo } from 'react'
|
||||
import { memo } from 'react'
|
||||
import { Button, Input, IconChevronDown, IconX } from 'ui'
|
||||
|
||||
import { Filter, FilterOperator } from 'components/grid/types'
|
||||
import { Filter, FilterOperator, SupaTable } from 'components/grid/types'
|
||||
import { DropdownControl } from 'components/grid/components/common'
|
||||
import { useTrackedState } from 'components/grid/store'
|
||||
import { FilterOperatorOptions } from './Filter.constants'
|
||||
|
||||
interface Props {
|
||||
export interface FilterRowProps {
|
||||
table: SupaTable
|
||||
filterIdx: number
|
||||
filter: Filter
|
||||
onChange: (index: number, filter: Filter) => void
|
||||
onDelete: (index: number) => void
|
||||
}
|
||||
|
||||
const FilterRow: FC<Props> = ({ filter, filterIdx, onChange, onDelete }) => {
|
||||
const state = useTrackedState()
|
||||
|
||||
const column = state.table?.columns.find((x) => x.name === filter.column)
|
||||
const FilterRow = ({ table, filter, filterIdx, onChange, onDelete }: FilterRowProps) => {
|
||||
const column = table.columns.find((x) => x.name === filter.column)
|
||||
const columnOptions =
|
||||
state.table?.columns?.map((x) => {
|
||||
table.columns?.map((x) => {
|
||||
return { value: x.name, label: x.name, postLabel: x.dataType }
|
||||
}) || []
|
||||
|
||||
|
||||
@@ -1,24 +1,33 @@
|
||||
import React, { FC, useState, useMemo, useCallback } from 'react'
|
||||
import React, { useState, useMemo, useCallback } from 'react'
|
||||
import { isEqual } from 'lodash'
|
||||
import update from 'immutability-helper'
|
||||
import { Button, IconList, IconChevronDown, Popover } from 'ui'
|
||||
import { useUrlState } from 'hooks'
|
||||
|
||||
import SortRow from './SortRow'
|
||||
import { useTrackedState } from 'components/grid/store'
|
||||
import { DropdownControl } from 'components/grid/components/common'
|
||||
import { Sort } from 'components/grid/types'
|
||||
import { Sort, SupaTable } from 'components/grid/types'
|
||||
import { formatSortURLParams } from 'components/grid/SupabaseGrid.utils'
|
||||
|
||||
const SortPopover: FC = () => {
|
||||
const [{ sort: sorts }]: any = useUrlState({ arrayKeys: ['sort'] })
|
||||
export interface SortPopoverProps {
|
||||
table: SupaTable
|
||||
sorts: string[]
|
||||
setParams: ReturnType<typeof useUrlState>[1]
|
||||
}
|
||||
|
||||
const SortPopover = ({ table, sorts, setParams }: SortPopoverProps) => {
|
||||
const btnText =
|
||||
(sorts || []).length > 0
|
||||
? `Sorted by ${sorts.length} rule${sorts.length > 1 ? 's' : ''}`
|
||||
: 'Sort'
|
||||
|
||||
return (
|
||||
<Popover size="large" align="start" className="sb-grid-sort-popover" overlay={<SortOverlay />}>
|
||||
<Popover
|
||||
size="large"
|
||||
align="start"
|
||||
className="sb-grid-sort-popover"
|
||||
overlay={<SortOverlay table={table} sorts={sorts} setParams={setParams} />}
|
||||
>
|
||||
<Button
|
||||
as="span"
|
||||
type={(sorts || []).length > 0 ? 'link' : 'text'}
|
||||
@@ -33,19 +42,19 @@ const SortPopover: FC = () => {
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export default SortPopover
|
||||
|
||||
const SortOverlay: FC = () => {
|
||||
const state = useTrackedState()
|
||||
export interface SortOverlayProps extends SortPopoverProps {}
|
||||
|
||||
const [{ sort: sortsFromUrl }, setParams] = useUrlState({ arrayKeys: ['sort'] })
|
||||
const SortOverlay = ({ table, sorts: sortsFromUrl, setParams }: SortOverlayProps) => {
|
||||
const initialSorts = useMemo(
|
||||
() => formatSortURLParams((sortsFromUrl as string[]) ?? []),
|
||||
[sortsFromUrl]
|
||||
)
|
||||
const [sorts, setSorts] = useState<Sort[]>(initialSorts)
|
||||
|
||||
const columns = state?.table?.columns!.filter((x) => {
|
||||
const columns = table.columns!.filter((x) => {
|
||||
const found = sorts.find((y) => y.column == x.name)
|
||||
return !found
|
||||
})
|
||||
@@ -100,6 +109,7 @@ const SortOverlay: FC = () => {
|
||||
{sorts.map((sort, index) => (
|
||||
<SortRow
|
||||
key={sort.column}
|
||||
table={table}
|
||||
index={index}
|
||||
columnName={sort.column}
|
||||
sort={sort}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { FC, useRef, memo } from 'react'
|
||||
import { useRef, memo } from 'react'
|
||||
import { Button, IconMenu, Toggle, IconX } from 'ui'
|
||||
import { XYCoord } from 'dnd-core'
|
||||
import { useDrag, useDrop, DropTargetMonitor } from 'react-dnd'
|
||||
|
||||
import { DragItem, Sort } from 'components/grid/types'
|
||||
import { useTrackedState } from 'components/grid/store'
|
||||
import { DragItem, Sort, SupaTable } from 'components/grid/types'
|
||||
|
||||
type SortRowProps = {
|
||||
export interface SortRowProps {
|
||||
table: SupaTable
|
||||
index: number
|
||||
columnName: string
|
||||
sort: Sort
|
||||
@@ -15,10 +15,8 @@ type SortRowProps = {
|
||||
onDrag: (dragIndex: number, hoverIndex: number) => void
|
||||
}
|
||||
|
||||
const SortRow: FC<SortRowProps> = ({ index, columnName, sort, onDelete, onToggle, onDrag }) => {
|
||||
const state = useTrackedState()
|
||||
|
||||
const column = state?.table?.columns.find((x) => x.name === columnName)
|
||||
const SortRow = ({ table, index, columnName, sort, onDelete, onToggle, onDrag }: SortRowProps) => {
|
||||
const column = table.columns.find((x) => x.name === columnName)
|
||||
if (!column) return null
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -55,9 +55,17 @@ export type ColumnType =
|
||||
| 'time'
|
||||
| 'unknown'
|
||||
|
||||
export interface GridForeignKey {
|
||||
targetTableSchema?: string | null
|
||||
targetTableName?: string | null
|
||||
targetColumnName?: string | null
|
||||
deletionAction?: string
|
||||
}
|
||||
|
||||
export interface ColumnHeaderProps<R> extends HeaderRendererProps<R> {
|
||||
columnType: ColumnType
|
||||
isPrimaryKey: boolean | undefined
|
||||
isEncrypted: boolean | undefined
|
||||
format: string
|
||||
foreignKey?: GridForeignKey
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ForeignRowSelectorProps } from 'components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/ForeignRowSelector'
|
||||
import React from 'react'
|
||||
import { Dictionary } from './base'
|
||||
import { SupaRow, SupaTable } from './table'
|
||||
@@ -75,6 +76,11 @@ export interface SupabaseGridProps {
|
||||
refreshDocs: () => void
|
||||
onExpandJSONEditor: (column: string, row: SupaRow) => void
|
||||
updateTableRow: (previousRow: any, updatedData: any) => void
|
||||
onEditForeignKeyColumnValue: (args: {
|
||||
foreignKey: NonNullable<ForeignRowSelectorProps['foreignKey']>
|
||||
row: any
|
||||
column: any
|
||||
}) => void
|
||||
}
|
||||
|
||||
export interface SupabaseGridRef {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Dictionary } from './base'
|
||||
import { Dictionary, GridForeignKey } from './base'
|
||||
|
||||
export interface SupaColumn {
|
||||
readonly dataType: string
|
||||
@@ -13,9 +13,7 @@ export interface SupaColumn {
|
||||
readonly isNullable?: boolean
|
||||
readonly isUpdatable?: boolean
|
||||
readonly isEncrypted?: boolean
|
||||
readonly targetTableSchema?: string | null
|
||||
readonly targetTableName?: string | null
|
||||
readonly targetColumnName?: string | null
|
||||
readonly foreignKey?: GridForeignKey
|
||||
position: number
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
DateTimeEditor,
|
||||
DateTimeWithTimezoneEditor,
|
||||
JsonEditor,
|
||||
NullableBooleanEditor,
|
||||
NumberEditor,
|
||||
SelectEditor,
|
||||
TextEditor,
|
||||
@@ -34,7 +33,7 @@ import {
|
||||
ForeignKeyFormatter,
|
||||
} from 'components/grid/components/formatter'
|
||||
|
||||
const ESTIMATED_CHARACTER_PIXEL_WIDTH = 9
|
||||
export const ESTIMATED_CHARACTER_PIXEL_WIDTH = 9
|
||||
|
||||
export function getGridColumns(
|
||||
table: SupaTable,
|
||||
@@ -47,7 +46,6 @@ export function getGridColumns(
|
||||
): any[] {
|
||||
const columns = table.columns.map((x, idx) => {
|
||||
const columnType = getColumnType(x)
|
||||
|
||||
const columnDefaultWidth = getColumnDefaultWidth(x)
|
||||
const columnWidthBasedOnName =
|
||||
(x.name.length + x.format.length) * ESTIMATED_CHARACTER_PIXEL_WIDTH
|
||||
@@ -75,6 +73,7 @@ export function getGridColumns(
|
||||
isPrimaryKey={x.isPrimaryKey}
|
||||
isEncrypted={x.isEncrypted}
|
||||
format={x.format}
|
||||
foreignKey={x.foreignKey}
|
||||
/>
|
||||
),
|
||||
editor: options?.editable
|
||||
@@ -105,7 +104,7 @@ function getColumnEditor(
|
||||
|
||||
switch (columnType) {
|
||||
case 'boolean': {
|
||||
return columnDefinition.isNullable ? NullableBooleanEditor : BooleanEditor
|
||||
return (p: any) => <BooleanEditor {...p} isNullable={columnDefinition.isNullable} />
|
||||
}
|
||||
case 'date': {
|
||||
return DateEditor
|
||||
@@ -130,7 +129,7 @@ function getColumnEditor(
|
||||
return NumberEditor
|
||||
}
|
||||
case 'text': {
|
||||
return TextEditor
|
||||
return (p: any) => <TextEditor {...p} isNullable={columnDefinition.isNullable} />
|
||||
}
|
||||
default: {
|
||||
return undefined
|
||||
@@ -180,7 +179,7 @@ function getColumnType(columnDef: SupaColumn): ColumnType {
|
||||
} else return 'unknown'
|
||||
}
|
||||
|
||||
function getColumnDefaultWidth(columnDef: SupaColumn): number {
|
||||
export function getColumnDefaultWidth(columnDef: SupaColumn): number {
|
||||
if (isNumericalColumn(columnDef.dataType)) {
|
||||
return 120
|
||||
} else if (
|
||||
|
||||
@@ -65,6 +65,6 @@ export function isEnumColumn(type: string) {
|
||||
}
|
||||
|
||||
export function isForeignKeyColumn(columnDef: SupaColumn) {
|
||||
const { targetTableSchema, targetTableName, targetColumnName } = columnDef
|
||||
const { targetTableSchema, targetTableName, targetColumnName } = columnDef?.foreignKey ?? {}
|
||||
return !!targetTableSchema && !!targetTableName && !!targetColumnName
|
||||
}
|
||||
@@ -12,7 +12,7 @@ const baseDomainRegex =
|
||||
|
||||
// iOS deep linking scheme https://benoitpasquier.com/deep-linking-url-scheme-ios/
|
||||
const appRegex =
|
||||
/^[a-z0-9]+([.][a-z0-9]+)*:\/(\/[-a-z0-9._~!$&'()*+,;=:@%]+)+(?:\.[a-z]+)*(?::\d+)?(?![^<]*(?:<\/\w+>|\/?>))(.*)?\/?(.)*?$/i
|
||||
/^[a-z0-9-]+([.][a-z0-9]+)*:\/(\/[-a-z0-9._~!$&'()*+,;=:@%]+)+(?:\.[a-z]+)*(?::\d+)?(?![^<]*(?:<\/\w+>|\/?>))(.*)?\/?(.)*?$/i
|
||||
|
||||
// Regex from https://stackoverflow.com/a/18696953/4807782
|
||||
const localhostRegex = /^(?:^|\s)((https?:\/\/)?(?:localhost|[\w-]+(?:\.[\w-]+)+)(:\d+)?(\/\S*)?)/i
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FC, useRef, useState } from 'react'
|
||||
import { FC, useState } from 'react'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { Listbox, IconLoader, Button, IconPlus, IconAlertCircle, IconCreditCard } from 'ui'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
@@ -9,7 +9,7 @@ import { SubscriptionPreview } from '../Billing.types'
|
||||
import { getProductPrice, validateSubscriptionUpdatePayload } from '../Billing.utils'
|
||||
import PaymentTotal from './PaymentTotal'
|
||||
import InformationBox from 'components/ui/InformationBox'
|
||||
import { SubscriptionAddon } from '../AddOns/AddOns.types'
|
||||
import { AddonPrice, SubscriptionAddon } from '../AddOns/AddOns.types'
|
||||
import { getPITRDays } from './PaymentSummaryPanel.utils'
|
||||
import ConfirmPaymentModal from './ConfirmPaymentModal'
|
||||
import { StripeSubscription } from '../Subscription/Subscription.types'
|
||||
@@ -88,6 +88,21 @@ const PaymentSummaryPanel: FC<Props> = ({
|
||||
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false)
|
||||
|
||||
const selectedPlanCost =
|
||||
selectedPlan.prices.find(
|
||||
(price: AddonPrice) => price.id === selectedPlan.metadata.default_price_id
|
||||
)?.unit_amount ?? 0
|
||||
const totalSelectedAddonCost = Object.keys(selectedAddons)
|
||||
.map((productName) => {
|
||||
const product = (selectedAddons as any)[productName]
|
||||
const price = product.prices.find(
|
||||
(price: AddonPrice) => price.id === product.metadata.default_price_id
|
||||
)
|
||||
return price
|
||||
})
|
||||
.reduce((a, b) => a + b.unit_amount, 0)
|
||||
const totalMonthlyCost = selectedPlanCost + totalSelectedAddonCost
|
||||
|
||||
const currentPITRDays =
|
||||
currentAddons.pitrDuration !== undefined ? getPITRDays(currentAddons.pitrDuration) : 0
|
||||
const selectedPITRDays =
|
||||
@@ -308,6 +323,7 @@ const PaymentSummaryPanel: FC<Props> = ({
|
||||
<div className="w-full h-px bg-scale-600" />
|
||||
|
||||
<PaymentTotal
|
||||
totalMonthlyCost={totalMonthlyCost / 100}
|
||||
subscriptionPreview={subscriptionPreview}
|
||||
isRefreshingPreview={isRefreshingPreview}
|
||||
isSpendCapEnabled={isSpendCapEnabled}
|
||||
|
||||
@@ -5,18 +5,20 @@ import { SubscriptionPreview } from '../Billing.types'
|
||||
import CostBreakdownModal from './CostBreakdownModal'
|
||||
|
||||
interface Props {
|
||||
totalMonthlyCost: number
|
||||
subscriptionPreview?: SubscriptionPreview
|
||||
isRefreshingPreview: boolean
|
||||
isSpendCapEnabled: boolean
|
||||
}
|
||||
|
||||
const PaymentTotal: FC<Props> = ({
|
||||
totalMonthlyCost,
|
||||
subscriptionPreview,
|
||||
isRefreshingPreview,
|
||||
isSpendCapEnabled,
|
||||
}) => {
|
||||
const hasChanges = subscriptionPreview?.has_changes ?? false
|
||||
const totalMonthlyCost = (subscriptionPreview?.base_amount_due_next_billing_cycle ?? 0) / 100
|
||||
// const totalMonthlyCost = (subscriptionPreview?.base_amount_due_next_billing_cycle ?? 0) / 100
|
||||
const amountDueImmediately = (subscriptionPreview?.amount_due_immediately ?? 0) / 100
|
||||
const billingDate = new Date((subscriptionPreview?.bill_on ?? 0) * 1000)
|
||||
const isBillingToday =
|
||||
|
||||
@@ -10,6 +10,7 @@ import { makeRandomString } from 'lib/helpers'
|
||||
import ProjectCard from './ProjectCard'
|
||||
import ShimmeringCard from './ShimmeringCard'
|
||||
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
||||
import { useOverdueInvoicesQuery } from 'data/invoices/invoices-query'
|
||||
|
||||
interface Props {
|
||||
rewriteHref?: (projectRef: string) => string
|
||||
@@ -20,6 +21,8 @@ const ProjectList: FC<Props> = ({ rewriteHref }) => {
|
||||
const { organizations, projects } = app
|
||||
const { isLoading: isLoadingProjects } = projects
|
||||
|
||||
const { data: allOverdueInvoices } = useOverdueInvoicesQuery()
|
||||
|
||||
const isLoadingPermissions = IS_PLATFORM ? (ui?.permissions ?? []).length === 0 : false
|
||||
|
||||
return (
|
||||
@@ -32,9 +35,24 @@ const ProjectList: FC<Props> = ({ rewriteHref }) => {
|
||||
const isEmpty = sortedProjects?.length == 0
|
||||
const canReadProjects = checkPermissions(PermissionAction.READ, 'projects', undefined, id)
|
||||
|
||||
const overdueInvoices = (allOverdueInvoices || []).filter((it) => it.organization_id === id)
|
||||
|
||||
return (
|
||||
<div className="space-y-3" key={makeRandomString(5)}>
|
||||
<h4 className="text-lg">{name}</h4>
|
||||
<div className="flex space-x-4 items-center">
|
||||
<h4 className="text-lg">{name}</h4>
|
||||
|
||||
{!!overdueInvoices.length && (
|
||||
<div>
|
||||
<Link href={`/org/${slug}/invoices`}>
|
||||
<a>
|
||||
<Button type="danger">Outstanding Invoices</Button>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoadingPermissions || isLoadingProjects ? (
|
||||
<ul className="mx-auto grid grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
|
||||
<ShimmeringCard />
|
||||
|
||||
@@ -45,7 +45,7 @@ const ForgotPasswordForm = () => {
|
||||
ui.setNotification({
|
||||
id: toastId,
|
||||
category: 'success',
|
||||
message: `Password reset email sent successfully! Please check your email`,
|
||||
message: `If you registered using your email and password, you will receive a password reset email.`,
|
||||
})
|
||||
|
||||
await router.push('/sign-in')
|
||||
|
||||
@@ -63,7 +63,7 @@ const ActionBar: FC<ActionBarProps> = ({
|
||||
// Does not require applyFunction() callback
|
||||
<Button
|
||||
size="small"
|
||||
disabled={disableApply}
|
||||
disabled={loading || disableApply}
|
||||
loading={loading}
|
||||
htmlType="submit"
|
||||
form={formId}
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@ const ColumnDefaultValue: FC<Props> = ({
|
||||
description="Can either be a literal or an expression (e.g uuid_generate_v4())"
|
||||
placeholder={
|
||||
typeof columnFields.defaultValue === 'string' && columnFields.defaultValue.length === 0
|
||||
? 'Empty string'
|
||||
? 'EMPTY'
|
||||
: 'NULL'
|
||||
}
|
||||
value={columnFields?.defaultValue ?? ''}
|
||||
|
||||
+52
-34
@@ -1,12 +1,11 @@
|
||||
import Link from 'next/link'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { isUndefined, isEmpty } from 'lodash'
|
||||
import { isEmpty } from 'lodash'
|
||||
import { Dictionary } from 'components/grid'
|
||||
import { Checkbox, SidePanel, Input, Button, IconExternalLink, Toggle } from 'ui'
|
||||
import type {
|
||||
PostgresColumn,
|
||||
PostgresExtension,
|
||||
PostgresRelationship,
|
||||
PostgresTable,
|
||||
PostgresType,
|
||||
} from '@supabase/postgres-meta'
|
||||
@@ -27,19 +26,26 @@ import {
|
||||
generateUpdateColumnPayload,
|
||||
} from './ColumnEditor.utils'
|
||||
import { TEXT_TYPES } from '../SidePanelEditor.constants'
|
||||
import { ColumnField, CreateColumnPayload, UpdateColumnPayload } from '../SidePanelEditor.types'
|
||||
import {
|
||||
ColumnField,
|
||||
CreateColumnPayload,
|
||||
ExtendedPostgresRelationship,
|
||||
UpdateColumnPayload,
|
||||
} from '../SidePanelEditor.types'
|
||||
import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms'
|
||||
import { EncryptionKeySelector } from 'components/interfaces/Settings/Vault'
|
||||
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { useForeignKeyConstraintsQuery } from 'data/database/foreign-key-constraints-query'
|
||||
|
||||
interface Props {
|
||||
column?: PostgresColumn
|
||||
selectedTable: PostgresTable
|
||||
tables: PostgresTable[]
|
||||
visible: boolean
|
||||
closePanel: () => void
|
||||
saveChanges: (
|
||||
payload: CreateColumnPayload | UpdateColumnPayload,
|
||||
foreignKey: Partial<PostgresRelationship> | undefined,
|
||||
foreignKey: ExtendedPostgresRelationship | undefined,
|
||||
isNewRecord: boolean,
|
||||
configuration: {
|
||||
columnId: string | undefined
|
||||
@@ -55,7 +61,6 @@ interface Props {
|
||||
const ColumnEditor: FC<Props> = ({
|
||||
column,
|
||||
selectedTable,
|
||||
tables = [],
|
||||
visible = false,
|
||||
closePanel = () => {},
|
||||
saveChanges = () => {},
|
||||
@@ -63,31 +68,41 @@ const ColumnEditor: FC<Props> = ({
|
||||
}) => {
|
||||
const { ref } = useParams()
|
||||
const { meta, vault } = useStore()
|
||||
const { project } = useProjectContext()
|
||||
const isTCEEnabled = useFlag('transparentColumnEncryption')
|
||||
|
||||
const isNewRecord = isUndefined(column)
|
||||
const originalForeignKey = column ? getColumnForeignKey(column, selectedTable) : undefined
|
||||
const [errors, setErrors] = useState<Dictionary<any>>({})
|
||||
const [columnFields, setColumnFields] = useState<ColumnField>()
|
||||
const [isEditingRelation, setIsEditingRelation] = useState<boolean>(false)
|
||||
|
||||
const { data } = useForeignKeyConstraintsQuery({
|
||||
projectRef: project?.ref,
|
||||
connectionString: project?.connectionString,
|
||||
schema: selectedTable?.schema,
|
||||
})
|
||||
const foreignKeyMeta = data || []
|
||||
|
||||
const keys = vault.listKeys()
|
||||
const enumTypes = meta.types.list(
|
||||
(type: PostgresType) => !meta.excludedSchemas.includes(type.schema)
|
||||
)
|
||||
|
||||
const [errors, setErrors] = useState<Dictionary<any>>({})
|
||||
const [columnFields, setColumnFields] = useState<ColumnField>()
|
||||
const [isEditingRelation, setIsEditingRelation] = useState<boolean>(false)
|
||||
|
||||
const [pgsodiumExtension] = meta.extensions.list(
|
||||
(ext: PostgresExtension) => ext.name.toLowerCase() === 'pgsodium'
|
||||
)
|
||||
const isPgSodiumInstalled = pgsodiumExtension?.installed_version !== null
|
||||
|
||||
const isNewRecord = column === undefined
|
||||
const originalForeignKey = column
|
||||
? getColumnForeignKey(column, selectedTable, foreignKeyMeta)
|
||||
: undefined
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setErrors({})
|
||||
const columnFields = isNewRecord
|
||||
? { ...generateColumnField(), keyId: keys.length > 0 ? keys[0].id : 'create-new' }
|
||||
: generateColumnFieldFromPostgresColumn(column!, selectedTable)
|
||||
: generateColumnFieldFromPostgresColumn(column!, selectedTable, foreignKeyMeta)
|
||||
setColumnFields(columnFields)
|
||||
}
|
||||
}, [visible])
|
||||
@@ -111,23 +126,27 @@ const ColumnEditor: FC<Props> = ({
|
||||
setErrors(updatedErrors)
|
||||
}
|
||||
|
||||
const saveColumnForeignKey = (
|
||||
foreignKeyConfiguration: { table: PostgresTable; column: PostgresColumn } | undefined
|
||||
) => {
|
||||
const saveColumnForeignKey = (foreignKeyConfiguration?: {
|
||||
table: PostgresTable
|
||||
column: PostgresColumn
|
||||
deletionAction: string
|
||||
}) => {
|
||||
onUpdateField({
|
||||
foreignKey: !isUndefined(foreignKeyConfiguration)
|
||||
? {
|
||||
id: 0,
|
||||
constraint_name: '',
|
||||
source_schema: selectedTable.schema,
|
||||
source_table_name: selectedTable.name,
|
||||
source_column_name: columnFields?.name || column?.name || '',
|
||||
target_table_schema: foreignKeyConfiguration.table.schema,
|
||||
target_table_name: foreignKeyConfiguration.table.name,
|
||||
target_column_name: foreignKeyConfiguration.column.name,
|
||||
}
|
||||
: undefined,
|
||||
...(!isUndefined(foreignKeyConfiguration) && {
|
||||
foreignKey:
|
||||
foreignKeyConfiguration !== undefined
|
||||
? {
|
||||
id: 0,
|
||||
constraint_name: '',
|
||||
source_schema: selectedTable.schema,
|
||||
source_table_name: selectedTable.name,
|
||||
source_column_name: columnFields?.name || column?.name || '',
|
||||
target_table_schema: foreignKeyConfiguration.table.schema,
|
||||
target_table_name: foreignKeyConfiguration.table.name,
|
||||
target_column_name: foreignKeyConfiguration.column.name,
|
||||
deletion_action: foreignKeyConfiguration.deletionAction,
|
||||
}
|
||||
: undefined,
|
||||
...(foreignKeyConfiguration !== undefined && {
|
||||
format: foreignKeyConfiguration.column.format,
|
||||
defaultValue: null,
|
||||
}),
|
||||
@@ -180,9 +199,7 @@ const ColumnEditor: FC<Props> = ({
|
||||
}
|
||||
onInteractOutside={(event) => {
|
||||
const isToast = (event.target as Element)?.closest('#toast')
|
||||
if (isToast) {
|
||||
event.preventDefault()
|
||||
}
|
||||
if (isToast) event.preventDefault()
|
||||
}}
|
||||
>
|
||||
<FormSection header={<FormSectionLabel className="lg:!col-span-4">General</FormSectionLabel>}>
|
||||
@@ -218,6 +235,7 @@ const ColumnEditor: FC<Props> = ({
|
||||
originalForeignKey={originalForeignKey}
|
||||
onSelectEditRelation={() => setIsEditingRelation(true)}
|
||||
onSelectRemoveRelation={() => onUpdateField({ foreignKey: undefined })}
|
||||
onSelectCancelRemoveRelation={() => onUpdateField({ foreignKey: originalForeignKey })}
|
||||
/>
|
||||
</div>
|
||||
</FormSectionContent>
|
||||
@@ -254,10 +272,10 @@ const ColumnEditor: FC<Props> = ({
|
||||
layout="vertical"
|
||||
enumTypes={enumTypes}
|
||||
error={errors.format}
|
||||
disabled={!isUndefined(columnFields?.foreignKey)}
|
||||
disabled={columnFields?.foreignKey !== undefined}
|
||||
onOptionSelect={(format: string) => onUpdateField({ format, defaultValue: null })}
|
||||
/>
|
||||
{isUndefined(columnFields.foreignKey) && (
|
||||
{columnFields.foreignKey === undefined && (
|
||||
<div className="space-y-4">
|
||||
{columnFields.format.includes('int') && (
|
||||
<div className="w-full">
|
||||
|
||||
+44
-8
@@ -8,7 +8,14 @@ import type {
|
||||
} from '@supabase/postgres-meta'
|
||||
|
||||
import { uuidv4 } from 'lib/helpers'
|
||||
import { ColumnField, CreateColumnPayload, UpdateColumnPayload } from '../SidePanelEditor.types'
|
||||
import {
|
||||
ColumnField,
|
||||
CreateColumnPayload,
|
||||
ExtendedPostgresRelationship,
|
||||
UpdateColumnPayload,
|
||||
} from '../SidePanelEditor.types'
|
||||
import { FOREIGN_KEY_DELETION_ACTION } from 'data/database/database-query-constants'
|
||||
import { ForeignKeyConstraint } from 'data/database/foreign-key-constraints-query'
|
||||
|
||||
const isSQLExpression = (input: string) => {
|
||||
if (['CURRENT_DATE'].includes(input)) return true
|
||||
@@ -49,12 +56,13 @@ export const generateColumnField = (field: any = {}): ColumnField => {
|
||||
|
||||
export const generateColumnFieldFromPostgresColumn = (
|
||||
column: PostgresColumn,
|
||||
table: PostgresTable
|
||||
table: PostgresTable,
|
||||
foreignKeys: ForeignKeyConstraint[]
|
||||
): ColumnField => {
|
||||
const { primary_keys } = table
|
||||
// @ts-ignore
|
||||
const primaryKeyColumns = primary_keys.map((key) => key.name)
|
||||
const foreignKey = getColumnForeignKey(column, table)
|
||||
const foreignKey = getColumnForeignKey(column, table, foreignKeys)
|
||||
const isArray = column?.data_type === 'ARRAY'
|
||||
|
||||
return {
|
||||
@@ -175,8 +183,8 @@ export const validateFields = (field: ColumnField) => {
|
||||
}
|
||||
|
||||
export const getForeignKeyUIState = (
|
||||
originalConfig: PostgresRelationship | undefined,
|
||||
updatedConfig: PostgresRelationship | undefined
|
||||
originalConfig: ExtendedPostgresRelationship | undefined,
|
||||
updatedConfig: ExtendedPostgresRelationship | undefined
|
||||
): 'Info' | 'Add' | 'Remove' | 'Update' => {
|
||||
if (isUndefined(originalConfig) && !isUndefined(updatedConfig)) {
|
||||
return 'Add'
|
||||
@@ -189,7 +197,8 @@ export const getForeignKeyUIState = (
|
||||
if (
|
||||
!isEqual(originalConfig?.target_table_schema, updatedConfig?.target_table_schema) ||
|
||||
!isEqual(originalConfig?.target_table_name, updatedConfig?.target_table_name) ||
|
||||
!isEqual(originalConfig?.target_column_name, updatedConfig?.target_column_name)
|
||||
!isEqual(originalConfig?.target_column_name, updatedConfig?.target_column_name) ||
|
||||
originalConfig?.deletion_action !== updatedConfig?.deletion_action
|
||||
) {
|
||||
return 'Update'
|
||||
}
|
||||
@@ -197,15 +206,27 @@ export const getForeignKeyUIState = (
|
||||
return 'Info'
|
||||
}
|
||||
|
||||
export const getColumnForeignKey = (column: PostgresColumn, table: PostgresTable) => {
|
||||
export const getColumnForeignKey = (
|
||||
column: PostgresColumn,
|
||||
table: PostgresTable,
|
||||
foreignKeys: ForeignKeyConstraint[]
|
||||
) => {
|
||||
const { relationships } = table
|
||||
return find(relationships, (relationship) => {
|
||||
const foreignKey = find(relationships, (relationship) => {
|
||||
return (
|
||||
relationship.source_schema === column.schema &&
|
||||
relationship.source_table_name === column.table &&
|
||||
relationship.source_column_name === column.name
|
||||
)
|
||||
})
|
||||
if (foreignKey === undefined) return foreignKey
|
||||
else {
|
||||
const foreignKeyMeta = foreignKeys.find((fk) => fk.id === foreignKey.id)
|
||||
return {
|
||||
...foreignKey,
|
||||
deletion_action: foreignKeyMeta?.deletion_action ?? FOREIGN_KEY_DELETION_ACTION.NO_ACTION,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Assumes arrayString is a stringified array (e.g "[1, 2, 3]")
|
||||
@@ -213,3 +234,18 @@ const formatArrayToPostgresArray = (arrayString: string) => {
|
||||
if (!arrayString) return null
|
||||
return arrayString.replaceAll('[', '{').replaceAll(']', '}')
|
||||
}
|
||||
|
||||
export const getForeignKeyDeletionAction = (deletionAction?: string) => {
|
||||
switch (deletionAction) {
|
||||
case FOREIGN_KEY_DELETION_ACTION.CASCADE:
|
||||
return 'Cascade'
|
||||
case FOREIGN_KEY_DELETION_ACTION.RESTRICT:
|
||||
return 'Restrict'
|
||||
case FOREIGN_KEY_DELETION_ACTION.SET_DEFAULT:
|
||||
return 'Set default'
|
||||
case FOREIGN_KEY_DELETION_ACTION.SET_NULL:
|
||||
return 'Set NULL'
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
Loaded 100 of 135 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user