Merge pull request #13948 from supabase/master

Prod deploy dashboard
This commit is contained in:
Joshen Lim authored and GitHub committed 2023-04-25 14:25:45 +08:00
commit 745efc91a7
211 files changed
+3131 -1962

No files matched your search

+3 -1
View File
@@ -96,4 +96,6 @@ On the Next.JS side of things, these work almost exactly the same as the client
#### Search
Search is handled through Algolia. When the site is built, a [search script](https://github.com/supabase/supabase/blob/master/apps/docs/scripts/build-search.ts) runs through all of the types of content, generating search objects that are sent to Algolia to index.
Search is handled using a Supabase instance. During CI, [a script](https://github.com/supabase/supabase/blob/master/apps/docs/scripts/search/generate-embeddings.ts) aggregates all content sources (eg. guides, reference docs, etc), indexes them using OpenAI embeddings, and stores them in a Supabase database.
At runtime, an [Edge Function](https://github.com/supabase/supabase/blob/master/supabase/functions) is executed that performs a similarity search between the user's query and the above content sources using [`pgvector`](https://github.com/pgvector/pgvector) embeddings.
@@ -45,7 +45,7 @@ export const gettingstarted = {
items: [
{ name: 'Features', url: '/guides/getting-started/features' },
{ name: 'Architecture', url: '/guides/getting-started/architecture' },
// { name: 'Local Development', url: '/guides/cli/local-development' }, // Disabling this until we actually move the doc into this section
{ name: 'Local Development', url: '/guides/getting-started/local-development' },
{
name: 'Framework Quickstarts',
items: [
@@ -141,7 +141,6 @@ export const cli = {
title: 'CLI',
items: [
{ name: 'Overview', url: '/guides/cli' },
{ name: 'Local Development', url: '/guides/cli/local-development' },
{ name: 'Managing Environments', url: '/guides/cli/managing-environments' },
],
}
@@ -688,7 +687,6 @@ export const supabase_cli = {
url: '/guides/cli',
items: [
{ name: 'Overview', url: '/guides/cli' },
{ name: 'Local Development', url: '/guides/cli/local-development' },
{ name: 'Managing Environments', url: '/guides/cli/managing-environments' },
{
name: 'Reference',
+6
View File
@@ -8,6 +8,7 @@ import { memo, useEffect } from 'react'
import Footer from '~/components/Navigation/Footer'
import { menuState, useMenuLevelId, useMenuMobileOpen } from '~/hooks/useMenuState'
import Head from 'next/head'
import { Announcement, AnnouncementCountdown } from 'ui'
const levelsData = {
home: {
@@ -324,6 +325,11 @@ const SiteLayout = ({ children }) => {
<title>Supabase Docs</title>
</Head>
<main>
<div>
<Announcement>
<AnnouncementCountdown />
</Announcement>
</div>
<div className="flex flex-row h-screen">
<NavContainer />
<Container>
+2 -2
View File
@@ -9,9 +9,9 @@
"start": "next start",
"lint": "next lint",
"build:sitemap": "node ./internals/generate-sitemap.mjs",
"embeddings": "tsx scripts/generate-embeddings.ts",
"embeddings": "tsx scripts/search/generate-embeddings.ts",
"embeddings:refresh": "npm run embeddings -- --refresh",
"postbuild": "ts-node ./scripts/build-search.ts && node ./internals/generate-sitemap.mjs",
"postbuild": "node ./internals/generate-sitemap.mjs",
"generate:all": "npm-run-all --parallel gen:api gen:cli gen:gotrue gen:storage gen:supabase-dart:v0 gen:supabase-dart:v1 gen:supabase-csharp:v0 gen:supabase-js:v1 gen:supabase-js:v2 gen:realtime",
"gen:api": "npm-run-all gen:api:usage",
"gen:api:usage": "ts-node ./generator/index.ts gen --type api --url https://api.supabase.com --input ../../spec/transforms/api_v0_openapi_deparsed.json --output ./docs/reference/api/generated/usage.mdx",
@@ -127,7 +127,7 @@ GET https://[REF].supabase.co/rest/v1/countries?select=id,name,cities(id,name)
## Many-to-many joins
The Serverless APIs will detect many-to-many joins. For example, if you have a database which stored teams of users (where each user could below to many teams):
The Serverless APIs will detect many-to-many joins. For example, if you have a database which stored teams of users (where each user could belong to many teams):
```sql
create table
+1 -1
View File
@@ -118,7 +118,7 @@ brew upgrade supabase
- [Supabase CLI Reference](/docs/reference/cli/introduction)
- [Supabase CLI Configuration](/docs/reference/cli/config)
- [Local Development](/docs/guides/cli/local-development)
- [Local Development](/docs/guides/getting-started/local-development)
- [Managing Environments](/docs/guides/cli/managing-environments)
export const Page = ({ children }) => <Layout meta={meta} children={children} />
@@ -133,7 +133,7 @@ edge build supabase_functions
supabase functions deploy dart_edge
```
You will be asked to provide the project reference of your Supabase instance to which you want to deploy the function. You can [link your local project to your Supabase instance](https://supabase.com/docs/guides/cli/local-development#link-your-project) to avoid having to provide the Supabase reference every time you deploy.
You will be asked to provide the project reference of your Supabase instance to which you want to deploy the function. You can [link your local project to your Supabase instance](https://supabase.com/docs/guides/getting-started/local-development#link-your-project) to avoid having to provide the Supabase reference every time you deploy.
export const Page = ({ children }) => <Layout meta={meta} children={children} />
@@ -25,7 +25,7 @@ Follow the steps to prepare your Supabase project on your local machine.
- Install the Supabase CLI. [Docs](/docs/guides/cli).
- Login to the CLI using the command: `supabase login`. [Docs](/docs/reference/cli/usage#supabase-login).
- Initialize Supabase inside your project using the command: `supabase init`. [Docs](/docs/guides/cli/local-development#getting-started).
- Initialize Supabase inside your project using the command: `supabase init`. [Docs](/docs/guides/getting-started/local-development#getting-started).
- Link to your Remote Project using the command `supabase link --project-ref your-project-ref`. [Docs](/docs/reference/cli/usage#supabase-link).
- Optional: Setup your environment: Follow [this setup guide](https://deno.land/manual/getting_started/setup_your_environment) to integrate the Deno language server with your editor.
+1 -1
View File
@@ -286,7 +286,7 @@ export const resources = [
{
title: 'Local Development',
hasLightIcon: true,
href: '/guides/cli/local-development',
href: '/guides/getting-started/local-development',
description: 'Use the Supabase CLI to develop locally and collaborate between teams.',
},
]
@@ -44,7 +44,7 @@ Encrypt sensitive data and store secrets using our Postgres extension, Supabase
### Database migrations
Develop locally and push your changes to your production database using migrations. [Docs](/docs/guides/cli/local-development#database-migrations)
Develop locally and push your changes to your production database using migrations. [Docs](/docs/guides/getting-started/local-development#database-migrations)
<br />
@@ -359,6 +359,10 @@ create table "public"."cities" (
```
Alternately, you can view your table definitions directly from the Table Editor:
![SQL Definition](/docs/img/guides/cli/sql-definitions.png)
You can then copy this SQL into a new migration file, and run `supabase db reset` to apply the changes.
The last step is deploying these changes to a live Supabase project.
@@ -407,6 +411,19 @@ If your project uses Edge Functions, you can deploy these using [`functions depl
supabase functions deploy <function_name>
```
### Use Auth locally
To use Auth locally, update your project's `supabase/config.toml` file that gets created after running `supabase init`. Add any providers you want, and set enabled to `true`
```bash config.toml
[auth.external.github]
enabled = true
client_id = ""
secret = ""
```
For these changes to take effect, you need to run `supabase stop` and `supabase start` again.
### Enabling Local Logging
Local logs rely on the Supabase Analytics Server. This can be enabled via the CLI configuration, and requires a Google Cloud project and BigQuery access.
@@ -424,9 +441,9 @@ Requirements:
Once you have these 3 items, follow these steps:
1. Update your project's `config.toml` and fill in the following:
First, update your project's `supabase/config.toml`:
```
```bash
[analytics]
enabled = true
gcp_project_number = "123456"
@@ -434,9 +451,9 @@ gcp_project_id = "my-project-id"
gcp_jwt_path = "supabase/gcloud.json"
```
2. Place your service account key (a JSON file) at the corresponding path and ensure that it is correctly named.
Next, place your service account key (a JSON file) at the corresponding path and ensure that it is correctly named.
3. Start your local stack using `supabase start`
Last, start your local stack using `supabase start`
This will switch the logging drivers and will direct logs to the Analytics server. You will be able to view and query your logs via the Studio Logs Explorer and Logs UI.
@@ -68,7 +68,7 @@ export const meta = {
<StepHikeCompact.Code>
```yaml pubspec.yaml
supabase_flutter: ^1.2.2
supabase_flutter: ^1.6.2
```
</StepHikeCompact.Code>
@@ -126,6 +126,7 @@ export default MyApp
Wrap your `pages/_app.tsx` component with the `SessionContextProvider` component:
```jsx lines=2,8 title=pages/_app.tsx
import '../styles/globals.css';
import { useState } from 'react'
import { createBrowserSupabaseClient } from '@supabase/auth-helpers-nextjs'
import { SessionContextProvider, Session } from '@supabase/auth-helpers-react'
@@ -38,7 +38,7 @@ Let's dive into how we actually set up and link these two platforms to create a
## Create a Supabase Project
As mentioned, while you can [deploy Supabase locally](/docs/guides/cli/local-development). For the purpose of this guide, we'll use Supabase Cloud:
As mentioned, while you can [deploy Supabase locally](/docs/guides/getting-started/local-development). For the purpose of this guide, we'll use Supabase Cloud:
1. Create a **Supabase** account by signing in with GitHub.
2. Give your organization a name (this can be changed later).
@@ -24,6 +24,10 @@ The video below illustrates how to get connected. Or you can read the steps belo
></iframe>
</div>
<Admonition type="note">
PolyScale provides caching for TCP connections and GraphQL. Support for caching with the Supabase client is coming soon.
</Admonition>
## Step 0: Create a PolyScale account
If you do not already have a PolyScale account, you can create an account [here](https://app.polyscale.ai/signup). PolyScale offers a free tier and no credit card is required.
@@ -23,6 +23,10 @@ Daily Backups and PITR are mutually exclusive. If your project opts into using P
All Pro and Enterprise tier Supabase projects are backed up automatically on a daily basis. In terms of Recovery Point Objective (RPO), Daily Backups would be suitable for projects willing to lose up to 24 hours worth of data if disaster hits at the most inopportune time. If a lower RPO is required, enabling Point-in-Time Recovery should be considered.
<Admonition type="note">
For security purposes, passwords for custom roles are not stored in daily backups, and will not be found in downloadable files. As such, if you are restoring from a daily backup and are using custom roles, you will need to set their passwords once more following a completed restoration.
</Admonition>
### Backup Process [#daily-backups-process]
The PostgreSQL utility [pg_dumpall](https://www.postgresql.org/docs/current/app-pg-dumpall.html) is used to perform daily backups. An SQL file is generated, zipped up, and sent to our storage servers for safe keeping.
@@ -29,7 +29,7 @@ This example assumes your Supabase project is `foobarbaz` with a corresponding A
To get started:
1. [Install](/docs/guides/resources/supabase-cli) the Supabase CLI 1.11.3+.
1. [Log in](/docs/guides/resources/supabase-cli/local-development#log-in-to-the-supabase-cli) to your Supabase account using the CLI.
1. [Log in](/docs/guides/resources/supabase-getting-started/local-development#log-in-to-the-supabase-cli) to your Supabase account using the CLI.
### Configure a CNAME
@@ -98,7 +98,7 @@ Vanity Subdomains allow you to present a basic branded experience, compared to c
To get started:
1. [Install](/docs/guides/resources/supabase-cli) the Supabase CLI 1.22.0+.
1. [Log in](/docs/guides/resources/supabase-cli/local-development#log-in-to-the-supabase-cli) to your Supabase account using the CLI.
1. [Log in](/docs/guides/resources/supabase-getting-started/local-development#log-in-to-the-supabase-cli) to your Supabase account using the CLI.
1. Ensure that you have [Owner or Admin permissions](/docs/guides/platform/access-control#manage-team-members) for the project you'd like to set up a vanity subdomain for.
1. Ensure that your project has a paid subscription (Pro/Pay as you go/Enterprise tier) in the [Billing page of the Dashboard](https://app.supabase.com/project/_/settings/billing/subscription).
@@ -79,7 +79,9 @@ const NEW_PROJECT_SERVICE_KEY = 'new-project-service-key-yyy'
;(async () => {
const oldSupabaseRestClient = createClient(OLD_PROJECT_URL, OLD_PROJECT_SERVICE_KEY, {
schema: 'storage',
db: {
schema: 'storage'
},
})
const oldSupabaseClient = createClient(OLD_PROJECT_URL, OLD_PROJECT_SERVICE_KEY)
const newSupabaseClient = createClient(NEW_PROJECT_URL, NEW_PROJECT_SERVICE_KEY)
@@ -17,7 +17,7 @@ Each Supabase project comes with configurable restrictions on the IP ranges that
To get started:
1. [Install](/docs/guides/cli) the Supabase CLI 1.22.0+.
1. [Log in](/docs/guides/cli/local-development#log-in-to-the-supabase-cli) to your Supabase account using the CLI.
1. [Log in](/docs/guides/getting-started/local-development#log-in-to-the-supabase-cli) to your Supabase account using the CLI.
1. If your project was created before 23rd December 2022, it will need to be [upgraded to the latest Supabase version](/docs/guides/platform/migrating-and-upgrading-projects) before Network Restrictions can be used.
1. Ensure that you have [Owner or Admin permissions](/docs/guides/platform/access-control#manage-team-members) for the project that you are enabling network restrictions.
@@ -18,7 +18,7 @@ SSL enforcement only applies to connections to both Postgres and PgBouncer ("Con
To get started:
1. [Install](/docs/guides/cli) the Supabase CLI 1.37.0+.
1. [Log in](/docs/guides/cli/local-development#log-in-to-the-supabase-cli) to your Supabase account using the CLI.
1. [Log in](/docs/guides/getting-started/local-development#log-in-to-the-supabase-cli) to your Supabase account using the CLI.
1. Ensure that you have [Owner or Admin permissions](/docs/guides/platform/access-control#manage-team-members) for the project that you are enabling SSL enforcement.
## Check enforcement status
@@ -10,6 +10,7 @@ export const meta = {
Supabase allows you to subscribe to real-time changes on your database from your client application.
You can listen to database changes using the [Postgres Changes](/docs/guides/realtime/postgres-changes) extension.
The following video shows how you can enable this feature for your tables.
## Demo
@@ -22,6 +23,28 @@ You can listen to database changes using the [Postgres Changes](/docs/guides/rea
></iframe>
</div>
## Setup
You'll first need to create a `supabase_realtime` publication and add your tables (that you want to subscribe to) to the publication:
```sql
begin;
-- remove the supabase_realtime publication
drop
publication if exists supabase_realtime;
-- re-create the supabase_realtime publication with no tables
create publication supabase_realtime;
commit;
-- add a table called 'messages' to the publication
-- (update this to match your tables)
alter
publication supabase_realtime add table messages;
```
## Streaming inserts
You can use the `INSERT` event to stream all new rows.
Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

+1 -1
View File
@@ -235,7 +235,7 @@
</url>
<url>
<loc>https://supabase.com/docs/guides/cli/local-development</loc>
<loc>https://supabase.com/docs/guides/getting-started/local-development</loc>
<changefreq>weekly</changefreq>
<changefreq>0.5</changefreq>
</url>
-155
View File
@@ -1,155 +0,0 @@
import fs from 'fs'
import crypto from 'crypto'
import path from 'path'
import matter from 'gray-matter'
import dotenv from 'dotenv'
import algoliasearch from 'algoliasearch/lite'
import { isEmpty } from 'lodash'
// search objects
import { generateClientLibSearchObjects } from './files/client-libs'
import { generateAPISearchObjects } from './files/api'
import { generateCLISearchObjects } from './files/cli'
const cliObjects = generateCLISearchObjects()
const apiObjects = generateAPISearchObjects()
const clientLibSearchObjects = generateClientLibSearchObjects()
// @ts-ignore
// The properties of the searchObject that are specifically read
// by DocSearch are "type" and "hierarchy". The rest, though saved into Algolia (which we
// can potentially use to craft more nuanced search experiences) are not used by DocSearch
const ignoredFiles = [
'pages/404.mdx',
'pages/faq.mdx',
'pages/support.mdx',
'pages/oss.tsx',
'pages/_app.tsx',
'pages/_document.tsx',
'pages/[...slug].tsx',
'pages/handbook/contributing.mdx',
'pages/handbook/introduction.mdx',
'pages/handbook/supasquad.mdx',
]
async function walk(dir) {
let files = await fs.promises.readdir(dir)
//@ts-ignore
files = await Promise.all(
files.map(async (file) => {
const filePath = path.join(dir, file)
const stats = await fs.promises.stat(filePath)
if (stats.isDirectory()) return walk(filePath)
else if (stats.isFile()) return filePath
})
)
return files.reduce((all, folderContents) => all.concat(folderContents), [])
}
;(async function () {
// initialize environment variables
dotenv.config()
if (!process.env.NEXT_PUBLIC_ALGOLIA_APP_ID || !process.env.ALGOLIA_SEARCH_ADMIN_KEY) {
return console.log(
'Missing Algolia app ID / admin Key: skipping saving of Algolia search index'
)
}
console.log('Preparing docs indexing for Algolia')
try {
const indexName = process.env.NEXT_PUBLIC_ALGOLIA_INDEX_NAME
const client = algoliasearch(
process.env.NEXT_PUBLIC_ALGOLIA_APP_ID,
process.env.ALGOLIA_SEARCH_ADMIN_KEY
)
const index = client.initIndex(indexName)
const guidePages = (await walk('pages')).filter((slug) => !ignoredFiles.includes(slug))
// generate search objects for mdx guide pages
const guidePagesearchObjects = guidePages
.map((slug) => {
let id, title, description
const fileContents = fs.readFileSync(slug, 'utf8')
const { data, content } = matter(fileContents)
if (isEmpty(data)) {
// Guide pages do not have front-matter meta, unlike reference pages, have to manually extract
const metaIndex = fileContents.indexOf('export const meta = {')
if (metaIndex !== -1) {
const metaString =
fileContents
.slice(metaIndex + 20, fileContents.indexOf('}', metaIndex + 1) + 1)
.replace(/\n/g, '')
.slice(0, -2) + '}'
const meta = eval(`(${metaString})`)
id = meta.id
title = meta.title
description = meta.description
}
} else {
id = data.id
title = data.title
description = data.description
}
const url = (slug.includes('/generated') ? slug.replace('/generated', '') : slug)
.replace('docs', '')
.replace('pages', '')
.replace(/\.mdx$/, '')
const source = slug.includes('/reference') ? 'reference' : 'guide'
const object = {
// For Algolia
objectID: crypto.randomUUID(),
id,
title,
description,
url,
source,
//pageContent: content,
pageContent: '',
category: undefined,
version: undefined,
// Docsearch specific
type: 'lvl1',
hierarchy: {
lvl0: 'Guides',
lvl1: title,
lvl2: null,
lvl3: null,
lvl4: null,
lvl5: null,
lvl6: null,
},
}
return object
})
// Some of the reference generated files come with an 'index' page that we can ignore
.filter((object) => !object.url.endsWith('/index'))
.filter((object) => !object.url.endsWith('/.gitkeep'))
const combinedSearchObjects = guidePagesearchObjects.concat(
clientLibSearchObjects,
apiObjects,
cliObjects
)
//@ts-ignore
await index.clearObjects()
console.log(`Successfully cleared records from ${indexName}`)
//@ts-ignore
const algoliaResponse = await index.saveObjects(combinedSearchObjects)
//@ts-ignore
console.log(`Successfully saved ${algoliaResponse.objectIDs.length} records into ${indexName}.`)
} catch (error) {
console.log('Error:', error)
}
})()
-43
View File
@@ -1,43 +0,0 @@
import crypto from 'crypto'
import { flattenSections } from '../../lib/helpers'
import apiCommonSections from '~/../../spec/common-api-sections.json'
import specFile from '~/../../spec/transforms/api_v0_openapi_deparsed.json'
import { gen_v3 } from '../../lib/refGenerator/helpers'
// @ts-ignore
const generatedSpec = gen_v3(specFile, 'wat', { apiUrl: 'apiv0' })
const sections = flattenSections(apiCommonSections)
export function generateAPISearchObjects() {
let searchObjects = []
//@ts-ignore
sections.map((section) => {
const object = searchObjects.push({
objectID: crypto.randomUUID(),
id: section.id,
title: section.title,
// @ts-ignore
description: generatedSpec.operations.find((item) => item.operationId === section.id)
?.summary,
url: `/reference/api/${section.slug}`,
source: 'reference',
pageContent: '',
category: section.product,
version: '',
type: 'lvl2',
hierarchy: {
lvl0: 'References',
lvl1: 'Management API',
lvl2: section.title,
lvl3: null,
lvl4: null,
lvl5: null,
lvl6: null,
},
})
return object
})
return searchObjects
}
-43
View File
@@ -1,43 +0,0 @@
import crypto from 'crypto'
import fs from 'fs'
import { flattenSections } from '../../lib/helpers'
import cliCommonSections from '~/../../spec/common-cli-sections.json'
import yaml from 'js-yaml'
// @ts-ignore
const spec = yaml.load(fs.readFileSync(`../../spec/cli_v1_commands.yaml`, 'utf8'))
const commonSections = flattenSections(cliCommonSections)
export function generateCLISearchObjects() {
let searchObjects = []
//@ts-ignore
spec.commands.map((section) => {
const object = searchObjects.push({
objectID: crypto.randomUUID(),
id: section.id,
title: section.title,
// @ts-ignore
description: section.description?.substr(0, section.description.indexOf('\n')),
url: `/reference/cli/${commonSections.find((item) => item.id === section.id)?.slug}`,
source: 'reference',
pageContent: '',
category: section.product,
version: '',
type: 'lvl2',
hierarchy: {
lvl0: 'References',
lvl1: 'Supabase CLI',
lvl2: section.title,
lvl3: null,
lvl4: null,
lvl5: null,
lvl6: null,
},
})
return object
})
return searchObjects
}
-57
View File
@@ -1,57 +0,0 @@
import fs from 'fs'
import crypto from 'crypto'
import yaml from 'js-yaml'
import { flattenSections } from '../../lib/helpers'
import { nameMap } from '../helpers'
import commonLibSections from '~/../../spec/common-client-libs-sections.json'
const clientLibFiles = [
{ fileName: 'supabase_js_v2', label: 'javascript', version: 'v2', versionSlug: false },
{ fileName: 'supabase_js_v1', label: 'javascript', version: 'v1', versionSlug: true },
{ fileName: 'supabase_dart_v1', label: 'dart', version: 'v1', versionSlug: false },
{ fileName: 'supabase_dart_v0', label: 'dart', version: 'v0', versionSlug: true },
{ fileName: 'supabase_dart_v0', label: 'dart', version: 'v0', versionSlug: true },
]
const flatCommonLibSections = flattenSections(commonLibSections)
export function generateClientLibSearchObjects() {
// loop through each spec file, find the correspending entry in the common file and grab the title / description / slug
let clientLibSearchObjects = []
clientLibFiles.map((file) => {
const specs = yaml.load(fs.readFileSync(`../../spec/${file.fileName}.yml`, 'utf8'))
//take each function id, find it in the commonLibSections file and return { id, title, slug, description, }
//@ts-ignore
specs.functions.map((fn) => {
const item = flatCommonLibSections.find((section) => section.id === fn.id)
if (item) {
const object = clientLibSearchObjects.push({
objectID: crypto.randomUUID(),
id: item.id,
title: item.title,
description: item.title,
url: `/reference/${file.label}/${file.versionSlug ? file.version + '/' : ''}${item.slug}`,
source: 'reference',
pageContent: '',
category: item.product,
version: file.version,
type: 'lvl2',
hierarchy: {
lvl0: 'References',
lvl1: `${nameMap[file.label]} ${file.version}`,
lvl2: item.title,
lvl3: file.version,
lvl4: null,
lvl5: null,
lvl6: null,
},
})
return object
}
})
})
return clientLibSearchObjects
}
-659
View File
@@ -1,659 +0,0 @@
import { createClient } from '@supabase/supabase-js'
import { createHash } from 'crypto'
import dotenv from 'dotenv'
import { ObjectExpression } from 'estree'
import { readdir, readFile, stat } from 'fs/promises'
import GithubSlugger from 'github-slugger'
import yaml from 'js-yaml'
import { Content, Root } from 'mdast'
import { fromMarkdown } from 'mdast-util-from-markdown'
import { mdxFromMarkdown, MdxjsEsm } from 'mdast-util-mdx'
import { toMarkdown } from 'mdast-util-to-markdown'
import { toString } from 'mdast-util-to-string'
import { mdxjs } from 'micromark-extension-mdxjs'
import 'openai'
import { Configuration, OpenAIApi } from 'openai'
import { OpenAPIV3 } from 'openapi-types'
import { basename, dirname, join } from 'path'
import { u } from 'unist-builder'
import { filter } from 'unist-util-filter'
import { inspect } from 'util'
import { ICommonFunc, IFunctionDefinition, ISpec } from '../components/reference/Reference.types'
import { CliCommand, CliSpec } from '../generator/types/CliSpec'
import { flattenSections } from '../lib/helpers'
import { enrichedOperation, gen_v3 } from '../lib/refGenerator/helpers'
dotenv.config()
const ignoredFiles = ['pages/404.mdx']
/**
* Extracts ES literals from an `estree` `ObjectExpression`
* into a plain JavaScript object.
*/
function getObjectFromExpression(node: ObjectExpression) {
return node.properties.reduce<
Record<string, string | number | bigint | true | RegExp | undefined>
>((object, property) => {
if (property.type !== 'Property') {
return object
}
const key = (property.key.type === 'Identifier' && property.key.name) || undefined
const value = (property.value.type === 'Literal' && property.value.value) || undefined
if (!key) {
return object
}
return {
...object,
[key]: value,
}
}, {})
}
/**
* Extracts the `meta` ESM export from the MDX file.
*
* This info is akin to frontmatter.
*/
function extractMetaExport(mdxTree: Root) {
const metaExportNode = mdxTree.children.find((node): node is MdxjsEsm => {
return (
node.type === 'mdxjsEsm' &&
node.data?.estree?.body[0]?.type === 'ExportNamedDeclaration' &&
node.data.estree.body[0].declaration?.type === 'VariableDeclaration' &&
node.data.estree.body[0].declaration.declarations[0]?.id.type === 'Identifier' &&
node.data.estree.body[0].declaration.declarations[0].id.name === 'meta'
)
})
if (!metaExportNode) {
return undefined
}
const objectExpression =
(metaExportNode.data?.estree?.body[0]?.type === 'ExportNamedDeclaration' &&
metaExportNode.data.estree.body[0].declaration?.type === 'VariableDeclaration' &&
metaExportNode.data.estree.body[0].declaration.declarations[0]?.id.type === 'Identifier' &&
metaExportNode.data.estree.body[0].declaration.declarations[0].id.name === 'meta' &&
metaExportNode.data.estree.body[0].declaration.declarations[0].init?.type ===
'ObjectExpression' &&
metaExportNode.data.estree.body[0].declaration.declarations[0].init) ||
undefined
if (!objectExpression) {
return undefined
}
return getObjectFromExpression(objectExpression)
}
/**
* Splits a `mdast` tree into multiple trees based on
* a predicate function. Will include the splitting node
* at the beginning of each tree.
*
* Useful to split a markdown file into smaller sections.
*/
function splitTreeBy(tree: Root, predicate: (node: Content) => boolean) {
return tree.children.reduce<Root[]>((trees, node) => {
const [lastTree] = trees.slice(-1)
if (!lastTree || predicate(node)) {
const tree: Root = u('root', [node])
return trees.concat(tree)
}
lastTree.children.push(node)
return trees
}, [])
}
type Meta = ReturnType<typeof extractMetaExport>
type Section = {
content: string
heading?: string
slug?: string
}
type ProcessedMdx = {
checksum: string
meta: Meta
sections: Section[]
}
/**
* Processes MDX content for search indexing.
* It extracts metadata, strips it of all JSX,
* and splits it into sub-sections based on criteria.
*/
function processMdxForSearch(content: string): ProcessedMdx {
const checksum = createHash('sha256').update(content).digest('base64')
const mdxTree = fromMarkdown(content, {
extensions: [mdxjs()],
mdastExtensions: [mdxFromMarkdown()],
})
const meta = extractMetaExport(mdxTree)
// Remove all MDX elements from markdown
const mdTree = filter(
mdxTree,
(node) =>
![
'mdxjsEsm',
'mdxJsxFlowElement',
'mdxJsxTextElement',
'mdxFlowExpression',
'mdxTextExpression',
].includes(node.type)
)
if (!mdTree) {
return {
checksum,
meta,
sections: [],
}
}
const sectionTrees = splitTreeBy(mdTree, (node) => node.type === 'heading')
const slugger = new GithubSlugger()
const sections = sectionTrees.map((tree) => {
const [firstNode] = tree.children
const heading = firstNode.type === 'heading' ? toString(firstNode) : undefined
const slug = heading ? slugger.slug(heading) : undefined
return {
content: toMarkdown(tree),
heading,
slug,
}
})
return {
checksum,
meta,
sections,
}
}
type WalkEntry = {
path: string
parentPath?: string
}
async function walk(dir: string, parentPath?: string): Promise<WalkEntry[]> {
const immediateFiles = await readdir(dir)
const recursiveFiles = await Promise.all(
immediateFiles.map(async (file) => {
const path = join(dir, file)
const stats = await stat(path)
if (stats.isDirectory()) {
// Keep track of document hierarchy (if this dir has corresponding doc file)
const docPath = `${basename(path)}.mdx`
return walk(
path,
immediateFiles.includes(docPath) ? join(dirname(path), docPath) : parentPath
)
} else if (stats.isFile()) {
return [
{
path: path,
parentPath,
},
]
} else {
return []
}
})
)
const flattenedFiles = recursiveFiles.reduce(
(all, folderContents) => all.concat(folderContents),
[]
)
return flattenedFiles.sort((a, b) => a.path.localeCompare(b.path))
}
abstract class BaseEmbeddingSource {
checksum?: string
meta?: Meta
sections?: Section[]
constructor(public source: string, public path: string, public parentPath?: string) {}
abstract load(): Promise<{ checksum: string; meta?: Meta; sections: Section[] }>
}
class MarkdownEmbeddingSource extends BaseEmbeddingSource {
type: 'markdown' = 'markdown'
constructor(source: string, public filePath: string, public parentFilePath?: string) {
const path = filePath.replace(/^pages/, '').replace(/\.mdx?$/, '')
const parentPath = parentFilePath?.replace(/^pages/, '').replace(/\.mdx?$/, '')
super(source, path, parentPath)
}
async load() {
const contents = await readFile(this.filePath, 'utf8')
const { checksum, meta, sections } = processMdxForSearch(contents)
this.checksum = checksum
this.meta = meta
this.sections = sections
return {
checksum,
meta,
sections,
}
}
}
abstract class ReferenceEmbeddingSource<SpecSection> extends BaseEmbeddingSource {
type: 'reference' = 'reference'
constructor(
source: string,
path: string,
public meta: Meta,
public specFilePath: string,
public sectionsFilePath: string
) {
super(source, path)
}
async load() {
const specContents = await readFile(this.specFilePath, 'utf8')
const refSectionsContents = await readFile(this.sectionsFilePath, 'utf8')
const refSections: ICommonFunc[] = JSON.parse(refSectionsContents)
const flattenedRefSections = flattenSections(refSections)
const checksum = createHash('sha256')
.update(specContents + refSectionsContents)
.digest('base64')
const specSections = this.getSpecSections(specContents)
const sections = flattenedRefSections
.map((refSection) => {
const specSection = this.matchSpecSection(specSections, refSection.id)
if (!specSection) {
return
}
return {
heading: refSection.title,
slug: refSection.slug,
content: `${this.meta.title} for ${refSection.title}:\n${this.formatSection(
specSection,
refSection
)}`,
}
})
.filter((section) => !!section)
this.checksum = checksum
this.sections = sections
return {
checksum,
sections,
meta: this.meta,
}
}
abstract getSpecSections(specContents: string): SpecSection[]
abstract matchSpecSection(specSections: SpecSection[], id: string): SpecSection
abstract formatSection(specSection: SpecSection, refSection: ICommonFunc): string
}
class OpenApiEmbeddingSource extends ReferenceEmbeddingSource<enrichedOperation> {
getSpecSections(specContents: string): enrichedOperation[] {
const spec: OpenAPIV3.Document<{}> = JSON.parse(specContents)
const generatedSpec = gen_v3(spec, '', {
apiUrl: 'apiv0',
})
return generatedSpec.operations
}
matchSpecSection(operations: enrichedOperation[], id: string): enrichedOperation {
return operations.find((operation) => operation.operationId === id)
}
formatSection(specOperation: enrichedOperation) {
const { summary, description, operation, path, tags } = specOperation
return JSON.stringify({
summary,
description,
operation,
path,
tags,
})
}
}
class ClientLibEmbeddingSource extends ReferenceEmbeddingSource<IFunctionDefinition> {
getSpecSections(specContents: string): IFunctionDefinition[] {
const spec = yaml.load(specContents) as ISpec
return spec.functions
}
matchSpecSection(functionDefinitions: IFunctionDefinition[], id: string): IFunctionDefinition {
return functionDefinitions.find((functionDefinition) => functionDefinition.id === id)
}
formatSection(functionDefinition: IFunctionDefinition, refSection: ICommonFunc): string {
const { title } = refSection
const { description, title: functionName } = functionDefinition
return JSON.stringify({
title,
description,
functionName,
})
}
}
class CliEmbeddingSource extends ReferenceEmbeddingSource<CliCommand> {
getSpecSections(specContents: string): CliCommand[] {
const spec = yaml.load(specContents) as CliSpec
return spec.commands
}
matchSpecSection(cliCommands: CliCommand[], id: string): CliCommand {
return cliCommands.find((cliCommand) => cliCommand.id === id)
}
formatSection(cliCommand: CliCommand): string {
const { summary, description, usage } = cliCommand
return JSON.stringify({
summary,
description,
usage,
})
}
}
type EmbeddingSource =
| MarkdownEmbeddingSource
| OpenApiEmbeddingSource
| ClientLibEmbeddingSource
| CliEmbeddingSource
async function generateEmbeddings() {
// TODO: use better CLI lib like yargs
const args = process.argv.slice(2)
const shouldRefresh = args.includes('--refresh')
if (
!process.env.NEXT_PUBLIC_SUPABASE_URL ||
!process.env.SUPABASE_SERVICE_ROLE_KEY ||
!process.env.OPENAI_KEY
) {
return console.log(
'Environment variables NEXT_PUBLIC_SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, and OPENAI_KEY are required: skipping embeddings generation'
)
}
const supabaseClient = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.SUPABASE_SERVICE_ROLE_KEY,
{
auth: {
persistSession: false,
autoRefreshToken: false,
},
}
)
const embeddingSources: EmbeddingSource[] = [
new OpenApiEmbeddingSource(
'api',
'/reference/api',
{ title: 'Management API Reference' },
'../../spec/transforms/api_v0_openapi_deparsed.json',
'../../spec/common-api-sections.json'
),
new ClientLibEmbeddingSource(
'js-lib',
'/reference/javascript',
{ title: 'JavaScript Reference' },
'../../spec/supabase_js_v2.yml',
'../../spec/common-client-libs-sections.json'
),
new ClientLibEmbeddingSource(
'dart-lib',
'/reference/dart',
{ title: 'Dart Reference' },
'../../spec/supabase_dart_v1.yml',
'../../spec/common-client-libs-sections.json'
),
new ClientLibEmbeddingSource(
'python-lib',
'/reference/python',
{ title: 'Python Reference' },
'../../spec/supabase_py_v2.yml',
'../../spec/common-client-libs-sections.json'
),
new ClientLibEmbeddingSource(
'csharp-lib',
'/reference/csharp',
{ title: 'C# Reference' },
'../../spec/supabase_csharp_v0.yml',
'../../spec/common-client-libs-sections.json'
),
new CliEmbeddingSource(
'cli',
'/reference/cli',
{ title: 'CLI Reference' },
'../../spec/cli_v1_commands.yaml',
'../../spec/common-cli-sections.json'
),
...(await walk('pages'))
.filter(({ path }) => /\.mdx?$/.test(path))
.filter(({ path }) => !ignoredFiles.includes(path))
.map((entry) => new MarkdownEmbeddingSource('guide', entry.path)),
]
console.log(`Discovered ${embeddingSources.length} pages`)
if (!shouldRefresh) {
console.log('Checking which pages are new or have changed')
} else {
console.log('Refresh flag set, re-generating all pages')
}
for (const embeddingSource of embeddingSources) {
const { type, source, path, parentPath } = embeddingSource
try {
const { checksum, meta, sections } = await embeddingSource.load()
// Check for existing page in DB and compare checksums
const { error: fetchPageError, data: existingPage } = await supabaseClient
.from('page')
.select('id, path, checksum, parentPage:parent_page_id(id, path)')
.filter('path', 'eq', path)
.limit(1)
.maybeSingle()
if (fetchPageError) {
throw fetchPageError
}
type Singular<T> = T extends any[] ? undefined : T
// We use checksum to determine if this page & its sections need to be regenerated
if (!shouldRefresh && existingPage?.checksum === checksum) {
const existingParentPage = existingPage?.parentPage as Singular<
typeof existingPage.parentPage
>
// If parent page changed, update it
if (existingParentPage?.path !== parentPath) {
console.log(`[${path}] Parent page has changed. Updating to '${parentPath}'...`)
const { error: fetchParentPageError, data: parentPage } = await supabaseClient
.from('page')
.select()
.filter('path', 'eq', parentPath)
.limit(1)
.maybeSingle()
if (fetchParentPageError) {
throw fetchParentPageError
}
const { error: updatePageError } = await supabaseClient
.from('page')
.update({ parent_page_id: parentPage?.id })
.filter('id', 'eq', existingPage.id)
if (updatePageError) {
throw updatePageError
}
}
continue
}
if (existingPage) {
if (!shouldRefresh) {
console.log(
`[${path}] Docs have changed, removing old page sections and their embeddings`
)
} else {
console.log(`[${path}] Refresh flag set, removing old page sections and their embeddings`)
}
const { error: deletePageSectionError } = await supabaseClient
.from('page_section')
.delete()
.filter('page_id', 'eq', existingPage.id)
if (deletePageSectionError) {
throw deletePageSectionError
}
}
const { error: fetchParentPageError, data: parentPage } = await supabaseClient
.from('page')
.select()
.filter('path', 'eq', parentPath)
.limit(1)
.maybeSingle()
if (fetchParentPageError) {
throw fetchParentPageError
}
// Create/update page record. Intentionally clear checksum until we
// have successfully generated all page sections.
const { error: upsertPageError, data: page } = await supabaseClient
.from('page')
.upsert(
{
checksum: null,
path,
type,
source,
meta,
parent_page_id: parentPage?.id,
},
{ onConflict: 'path' }
)
.select()
.limit(1)
.single()
if (upsertPageError) {
throw upsertPageError
}
console.log(`[${path}] Adding ${sections.length} page sections (with embeddings)`)
for (const { slug, heading, content } of sections) {
// OpenAI recommends replacing newlines with spaces for best results (specific to embeddings)
const input = content.replace(/\n/g, ' ')
try {
const configuration = new Configuration({ apiKey: process.env.OPENAI_KEY })
const openai = new OpenAIApi(configuration)
const embeddingResponse = await openai.createEmbedding({
model: 'text-embedding-ada-002',
input,
})
if (embeddingResponse.status !== 200) {
throw new Error(inspect(embeddingResponse.data, false, 2))
}
const [responseData] = embeddingResponse.data.data
const { error: insertPageSectionError, data: pageSection } = await supabaseClient
.from('page_section')
.insert({
page_id: page.id,
slug,
heading,
content,
token_count: embeddingResponse.data.usage.total_tokens,
embedding: responseData.embedding,
})
.select()
.limit(1)
.single()
if (insertPageSectionError) {
throw insertPageSectionError
}
} catch (err) {
// TODO: decide how to better handle failed embeddings
console.error(
`Failed to generate embeddings for '${path}' page section starting with '${input.slice(
0,
40
)}...'`
)
throw err
}
}
// Set page checksum so that we know this page was stored successfully
const { error: updatePageError } = await supabaseClient
.from('page')
.update({ checksum })
.filter('id', 'eq', page.id)
if (updatePageError) {
throw updatePageError
}
} catch (err) {
console.error(
`Page '${path}' or one/multiple of its page sections failed to store properly. Page has been marked with null checksum to indicate that it needs to be re-generated.`
)
console.error(err)
}
}
console.log('Embedding generation complete')
}
async function main() {
await generateEmbeddings()
}
main().catch((err) => console.error(err))
-9
View File
@@ -1,9 +0,0 @@
export const nameMap = {
api: 'Management API',
cli: 'Supabase CLI',
auth: 'Auth Server',
storage: 'Storage Server',
postgres: 'Postgres',
dart: 'Supabase Flutter Library',
javascript: 'Supabase JavaScript Library',
}
@@ -0,0 +1,225 @@
import { createClient } from '@supabase/supabase-js'
import dotenv from 'dotenv'
import 'openai'
import { Configuration, OpenAIApi } from 'openai'
import { inspect } from 'util'
import { fetchSources } from './sources'
dotenv.config()
async function generateEmbeddings() {
// TODO: use better CLI lib like yargs
const args = process.argv.slice(2)
const shouldRefresh = args.includes('--refresh')
const requiredEnvVars = ['NEXT_PUBLIC_SUPABASE_URL', 'SUPABASE_SERVICE_ROLE_KEY', 'OPENAI_KEY']
if (requiredEnvVars.some((name) => !process.env[name])) {
throw new Error(
`Environment variables ${requiredEnvVars.join(
', '
)} are required: skipping embeddings generation`
)
}
const supabaseClient = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.SUPABASE_SERVICE_ROLE_KEY,
{
auth: {
persistSession: false,
autoRefreshToken: false,
},
}
)
const embeddingSources = await fetchSources()
console.log(`Discovered ${embeddingSources.length} pages`)
if (!shouldRefresh) {
console.log('Checking which pages are new or have changed')
} else {
console.log('Refresh flag set, re-generating all pages')
}
for (const embeddingSource of embeddingSources) {
const { type, source, path, parentPath } = embeddingSource
try {
const { checksum, meta, sections } = await embeddingSource.load()
// Check for existing page in DB and compare checksums
const { error: fetchPageError, data: existingPage } = await supabaseClient
.from('page')
.select('id, path, checksum, parentPage:parent_page_id(id, path)')
.filter('path', 'eq', path)
.limit(1)
.maybeSingle()
if (fetchPageError) {
throw fetchPageError
}
type Singular<T> = T extends any[] ? undefined : T
// We use checksum to determine if this page & its sections need to be regenerated
if (!shouldRefresh && existingPage?.checksum === checksum) {
const existingParentPage = existingPage?.parentPage as Singular<
typeof existingPage.parentPage
>
// If parent page changed, update it
if (existingParentPage?.path !== parentPath) {
console.log(`[${path}] Parent page has changed. Updating to '${parentPath}'...`)
const { error: fetchParentPageError, data: parentPage } = await supabaseClient
.from('page')
.select()
.filter('path', 'eq', parentPath)
.limit(1)
.maybeSingle()
if (fetchParentPageError) {
throw fetchParentPageError
}
const { error: updatePageError } = await supabaseClient
.from('page')
.update({ parent_page_id: parentPage?.id })
.filter('id', 'eq', existingPage.id)
if (updatePageError) {
throw updatePageError
}
}
continue
}
if (existingPage) {
if (!shouldRefresh) {
console.log(
`[${path}] Docs have changed, removing old page sections and their embeddings`
)
} else {
console.log(`[${path}] Refresh flag set, removing old page sections and their embeddings`)
}
const { error: deletePageSectionError } = await supabaseClient
.from('page_section')
.delete()
.filter('page_id', 'eq', existingPage.id)
if (deletePageSectionError) {
throw deletePageSectionError
}
}
const { error: fetchParentPageError, data: parentPage } = await supabaseClient
.from('page')
.select()
.filter('path', 'eq', parentPath)
.limit(1)
.maybeSingle()
if (fetchParentPageError) {
throw fetchParentPageError
}
// Create/update page record. Intentionally clear checksum until we
// have successfully generated all page sections.
const { error: upsertPageError, data: page } = await supabaseClient
.from('page')
.upsert(
{
checksum: null,
path,
type,
source,
meta,
parent_page_id: parentPage?.id,
},
{ onConflict: 'path' }
)
.select()
.limit(1)
.single()
if (upsertPageError) {
throw upsertPageError
}
console.log(`[${path}] Adding ${sections.length} page sections (with embeddings)`)
for (const { slug, heading, content } of sections) {
// OpenAI recommends replacing newlines with spaces for best results (specific to embeddings)
const input = content.replace(/\n/g, ' ')
try {
const configuration = new Configuration({ apiKey: process.env.OPENAI_KEY })
const openai = new OpenAIApi(configuration)
const embeddingResponse = await openai.createEmbedding({
model: 'text-embedding-ada-002',
input,
})
if (embeddingResponse.status !== 200) {
throw new Error(inspect(embeddingResponse.data, false, 2))
}
const [responseData] = embeddingResponse.data.data
const { error: insertPageSectionError, data: pageSection } = await supabaseClient
.from('page_section')
.insert({
page_id: page.id,
slug,
heading,
content,
token_count: embeddingResponse.data.usage.total_tokens,
embedding: responseData.embedding,
})
.select()
.limit(1)
.single()
if (insertPageSectionError) {
throw insertPageSectionError
}
} catch (err) {
// TODO: decide how to better handle failed embeddings
console.error(
`Failed to generate embeddings for '${path}' page section starting with '${input.slice(
0,
40
)}...'`
)
throw err
}
}
// Set page checksum so that we know this page was stored successfully
const { error: updatePageError } = await supabaseClient
.from('page')
.update({ checksum })
.filter('id', 'eq', page.id)
if (updatePageError) {
throw updatePageError
}
} catch (err) {
console.error(
`Page '${path}' or one/multiple of its page sections failed to store properly. Page has been marked with null checksum to indicate that it needs to be re-generated.`
)
console.error(err)
}
}
console.log('Embedding generation complete')
}
async function main() {
await generateEmbeddings()
}
main().catch((err) => console.error(err))
+20
View File
@@ -0,0 +1,20 @@
export type Json = Record<
string,
string | number | boolean | null | Json[] | { [key: string]: Json }
>
export type Section = {
content: string
heading?: string
slug?: string
}
export abstract class BaseSource {
checksum?: string
meta?: Json
sections?: Section[]
constructor(public source: string, public path: string, public parentPath?: string) {}
abstract load(): Promise<{ checksum: string; meta?: Json; sections: Section[] }>
}
+85
View File
@@ -0,0 +1,85 @@
import { MarkdownSource } from './markdown'
import {
CliReferenceSource,
ClientLibReferenceSource,
OpenApiReferenceSource,
} from './reference-doc'
import { walk } from './util'
const ignoredFiles = ['pages/404.mdx']
export type SearchSource =
| MarkdownSource
| OpenApiReferenceSource
| ClientLibReferenceSource
| CliReferenceSource
/**
* Fetches all the sources we want to index for search
*/
export async function fetchSources() {
const openApiReferenceSource = new OpenApiReferenceSource(
'api',
'/reference/api',
{ title: 'Management API Reference' },
'../../spec/transforms/api_v0_openapi_deparsed.json',
'../../spec/common-api-sections.json'
)
const jsLibReferenceSource = new ClientLibReferenceSource(
'js-lib',
'/reference/javascript',
{ title: 'JavaScript Reference' },
'../../spec/supabase_js_v2.yml',
'../../spec/common-client-libs-sections.json'
)
const dartLibReferenceSource = new ClientLibReferenceSource(
'dart-lib',
'/reference/dart',
{ title: 'Dart Reference' },
'../../spec/supabase_dart_v1.yml',
'../../spec/common-client-libs-sections.json'
)
const pythonLibReferenceSource = new ClientLibReferenceSource(
'python-lib',
'/reference/python',
{ title: 'Python Reference' },
'../../spec/supabase_py_v2.yml',
'../../spec/common-client-libs-sections.json'
)
const cSharpLibReferenceSource = new ClientLibReferenceSource(
'csharp-lib',
'/reference/csharp',
{ title: 'C# Reference' },
'../../spec/supabase_csharp_v0.yml',
'../../spec/common-client-libs-sections.json'
)
const cliReferenceSource = new CliReferenceSource(
'cli',
'/reference/cli',
{ title: 'CLI Reference' },
'../../spec/cli_v1_commands.yaml',
'../../spec/common-cli-sections.json'
)
const guideSources = (await walk('pages'))
.filter(({ path }) => /\.mdx?$/.test(path))
.filter(({ path }) => !ignoredFiles.includes(path))
.map((entry) => new MarkdownSource('guide', entry.path))
const sources: SearchSource[] = [
openApiReferenceSource,
jsLibReferenceSource,
dartLibReferenceSource,
pythonLibReferenceSource,
cSharpLibReferenceSource,
cliReferenceSource,
...guideSources,
]
return sources
}
@@ -0,0 +1,191 @@
import { createHash } from 'crypto'
import { ObjectExpression } from 'estree'
import { readFile } from 'fs/promises'
import GithubSlugger from 'github-slugger'
import { Content, Root } from 'mdast'
import { fromMarkdown } from 'mdast-util-from-markdown'
import { MdxjsEsm, mdxFromMarkdown } from 'mdast-util-mdx'
import { toMarkdown } from 'mdast-util-to-markdown'
import { toString } from 'mdast-util-to-string'
import { mdxjs } from 'micromark-extension-mdxjs'
import { u } from 'unist-builder'
import { filter } from 'unist-util-filter'
import { BaseSource, Json, Section } from './base'
/**
* Extracts ES literals from an `estree` `ObjectExpression`
* into a plain JavaScript object.
*/
export function getObjectFromExpression(node: ObjectExpression) {
return node.properties.reduce<
Record<string, string | number | bigint | true | RegExp | undefined>
>((object, property) => {
if (property.type !== 'Property') {
return object
}
const key = (property.key.type === 'Identifier' && property.key.name) || undefined
const value = (property.value.type === 'Literal' && property.value.value) || undefined
if (!key) {
return object
}
return {
...object,
[key]: value,
}
}, {})
}
/**
* Extracts the `meta` ESM export from the MDX file.
*
* This info is akin to frontmatter.
*/
export function extractMetaExport(mdxTree: Root) {
const metaExportNode = mdxTree.children.find((node): node is MdxjsEsm => {
return (
node.type === 'mdxjsEsm' &&
node.data?.estree?.body[0]?.type === 'ExportNamedDeclaration' &&
node.data.estree.body[0].declaration?.type === 'VariableDeclaration' &&
node.data.estree.body[0].declaration.declarations[0]?.id.type === 'Identifier' &&
node.data.estree.body[0].declaration.declarations[0].id.name === 'meta'
)
})
if (!metaExportNode) {
return undefined
}
const objectExpression =
(metaExportNode.data?.estree?.body[0]?.type === 'ExportNamedDeclaration' &&
metaExportNode.data.estree.body[0].declaration?.type === 'VariableDeclaration' &&
metaExportNode.data.estree.body[0].declaration.declarations[0]?.id.type === 'Identifier' &&
metaExportNode.data.estree.body[0].declaration.declarations[0].id.name === 'meta' &&
metaExportNode.data.estree.body[0].declaration.declarations[0].init?.type ===
'ObjectExpression' &&
metaExportNode.data.estree.body[0].declaration.declarations[0].init) ||
undefined
if (!objectExpression) {
return undefined
}
return getObjectFromExpression(objectExpression)
}
/**
* Splits a `mdast` tree into multiple trees based on
* a predicate function. Will include the splitting node
* at the beginning of each tree.
*
* Useful to split a markdown file into smaller sections.
*/
export function splitTreeBy(tree: Root, predicate: (node: Content) => boolean) {
return tree.children.reduce<Root[]>((trees, node) => {
const [lastTree] = trees.slice(-1)
if (!lastTree || predicate(node)) {
const tree: Root = u('root', [node])
return trees.concat(tree)
}
lastTree.children.push(node)
return trees
}, [])
}
/**
* Processes MDX content for search indexing.
* It extracts metadata, strips it of all JSX,
* and splits it into sub-sections based on criteria.
*/
export function processMdxForSearch(content: string): ProcessedMdx {
const checksum = createHash('sha256').update(content).digest('base64')
const mdxTree = fromMarkdown(content, {
extensions: [mdxjs()],
mdastExtensions: [mdxFromMarkdown()],
})
const meta = extractMetaExport(mdxTree)
const serializableMeta: Json = JSON.parse(JSON.stringify(meta))
// Remove all MDX elements from markdown
const mdTree = filter(
mdxTree,
(node) =>
![
'mdxjsEsm',
'mdxJsxFlowElement',
'mdxJsxTextElement',
'mdxFlowExpression',
'mdxTextExpression',
].includes(node.type)
)
if (!mdTree) {
return {
checksum,
meta: serializableMeta,
sections: [],
}
}
const sectionTrees = splitTreeBy(mdTree, (node) => node.type === 'heading')
const slugger = new GithubSlugger()
const sections = sectionTrees.map((tree) => {
const [firstNode] = tree.children
const heading = firstNode.type === 'heading' ? toString(firstNode) : undefined
const slug = heading ? slugger.slug(heading) : undefined
return {
content: toMarkdown(tree),
heading,
slug,
}
})
return {
checksum,
meta: serializableMeta,
sections,
}
}
export type ProcessedMdx = {
checksum: string
meta: Json
sections: Section[]
}
export class MarkdownSource extends BaseSource {
type = 'markdown' as const
constructor(source: string, public filePath: string, public parentFilePath?: string) {
const path = filePath.replace(/^pages/, '').replace(/\.mdx?$/, '')
const parentPath = parentFilePath?.replace(/^pages/, '').replace(/\.mdx?$/, '')
super(source, path, parentPath)
}
async load() {
const contents = await readFile(this.filePath, 'utf8')
const { checksum, meta, sections } = processMdxForSearch(contents)
this.checksum = checksum
this.meta = meta
this.sections = sections
return {
checksum,
meta,
sections,
}
}
}
@@ -0,0 +1,138 @@
import { createHash } from 'crypto'
import { readFile } from 'fs/promises'
import yaml from 'js-yaml'
import { OpenAPIV3 } from 'openapi-types'
import {
ICommonFunc,
IFunctionDefinition,
ISpec,
} from '../../../components/reference/Reference.types'
import { CliCommand, CliSpec } from '../../../generator/types/CliSpec'
import { flattenSections } from '../../../lib/helpers'
import { enrichedOperation, gen_v3 } from '../../../lib/refGenerator/helpers'
import { BaseSource, Json } from './base'
export abstract class ReferenceSource<SpecSection> extends BaseSource {
type = 'reference' as const
constructor(
source: string,
path: string,
public meta: Json,
public specFilePath: string,
public sectionsFilePath: string
) {
super(source, path)
}
async load() {
const specContents = await readFile(this.specFilePath, 'utf8')
const refSectionsContents = await readFile(this.sectionsFilePath, 'utf8')
const refSections: ICommonFunc[] = JSON.parse(refSectionsContents)
const flattenedRefSections = flattenSections(refSections)
const checksum = createHash('sha256')
.update(specContents + refSectionsContents)
.digest('base64')
const specSections = this.getSpecSections(specContents)
const sections = flattenedRefSections
.map((refSection) => {
const specSection = this.matchSpecSection(specSections, refSection.id)
if (!specSection) {
return
}
return {
heading: refSection.title,
slug: refSection.slug,
content: `${this.meta.title} for ${refSection.title}:\n${this.formatSection(
specSection,
refSection
)}`,
}
})
.filter((section) => !!section)
this.checksum = checksum
this.sections = sections
return {
checksum,
sections,
meta: this.meta,
}
}
abstract getSpecSections(specContents: string): SpecSection[]
abstract matchSpecSection(specSections: SpecSection[], id: string): SpecSection
abstract formatSection(specSection: SpecSection, refSection: ICommonFunc): string
}
export class OpenApiReferenceSource extends ReferenceSource<enrichedOperation> {
getSpecSections(specContents: string): enrichedOperation[] {
const spec: OpenAPIV3.Document<{}> = JSON.parse(specContents)
const generatedSpec = gen_v3(spec, '', {
apiUrl: 'apiv0',
})
return generatedSpec.operations
}
matchSpecSection(operations: enrichedOperation[], id: string): enrichedOperation {
return operations.find((operation) => operation.operationId === id)
}
formatSection(specOperation: enrichedOperation) {
const { summary, description, operation, path, tags } = specOperation
return JSON.stringify({
summary,
description,
operation,
path,
tags,
})
}
}
export class ClientLibReferenceSource extends ReferenceSource<IFunctionDefinition> {
getSpecSections(specContents: string): IFunctionDefinition[] {
const spec = yaml.load(specContents) as ISpec
return spec.functions
}
matchSpecSection(functionDefinitions: IFunctionDefinition[], id: string): IFunctionDefinition {
return functionDefinitions.find((functionDefinition) => functionDefinition.id === id)
}
formatSection(functionDefinition: IFunctionDefinition, refSection: ICommonFunc): string {
const { title } = refSection
const { description, title: functionName } = functionDefinition
return JSON.stringify({
title,
description,
functionName,
})
}
}
export class CliReferenceSource extends ReferenceSource<CliCommand> {
getSpecSections(specContents: string): CliCommand[] {
const spec = yaml.load(specContents) as CliSpec
return spec.commands
}
matchSpecSection(cliCommands: CliCommand[], id: string): CliCommand {
return cliCommands.find((cliCommand) => cliCommand.id === id)
}
formatSection(cliCommand: CliCommand): string {
const { summary, description, usage } = cliCommand
return JSON.stringify({
summary,
description,
usage,
})
}
}
+43
View File
@@ -0,0 +1,43 @@
import { readdir, stat } from 'fs/promises'
import { basename, dirname, join } from 'path'
export type WalkEntry = {
path: string
parentPath?: string
}
export async function walk(dir: string, parentPath?: string): Promise<WalkEntry[]> {
const immediateFiles = await readdir(dir)
const recursiveFiles = await Promise.all(
immediateFiles.map(async (file) => {
const path = join(dir, file)
const stats = await stat(path)
if (stats.isDirectory()) {
// Keep track of document hierarchy (if this dir has corresponding doc file)
const docPath = `${basename(path)}.mdx`
return walk(
path,
immediateFiles.includes(docPath) ? join(dirname(path), docPath) : parentPath
)
} else if (stats.isFile()) {
return [
{
path: path,
parentPath,
},
]
} else {
return []
}
})
)
const flattenedFiles = recursiveFiles.reduce(
(all, folderContents) => all.concat(folderContents),
[]
)
return flattenedFiles.sort((a, b) => a.path.localeCompare(b.path))
}
+16 -1
View File
@@ -15,7 +15,16 @@ date: '2021-12-03'
toc_depth: 3
---
Today we're open sourcing [`pg_graphql`](https://github.com/supabase/pg_graphql), a work-in-progress native PostgreSQL extension adding GraphQL support. The extension keeps schema generation, query parsing, and resolvers all neatly contained on your database server requiring no external services.
<div className="bg-gray-300 rounded-lg px-6 py-2 italic">
🆕 `pg_graphql` is now generally available and has undergone significant enhancements since this announcement. Here is what is new:
- [pg_graphql v1.0](https://supabase.com/blog/pg-graphql-v1)
- [New Features in pg_graphql v1.2](https://supabase.com/blog/whats-new-in-pg-graphql-v1-2)
</div>
Today we're open sourcing [`pg_graphql`](https://github.com/supabase/pg_graphql), a native PostgreSQL extension adding GraphQL support. The extension keeps schema generation, query parsing, and resolvers all neatly contained on your database server requiring no external services.
`pg_graphql` inspects an existing PostgreSQL schema and reflects a GraphQL schema with resolvers that are:
@@ -27,6 +36,7 @@ Today we're open sourcing [`pg_graphql`](https://github.com/supabase/pg_graphql)
Interested? You're [3 commands away](https://supabase.github.io/pg_graphql/quickstart/) from a live [GraphiQL](https://graphql-dotnet.github.io/docs/getting-started/graphiql/) demo.
## Motivation
The Supabase stack is centered around PostgreSQL as the single source of truth. All data, configuration, and security are housed in the database so any GraphQL solution needed to be equivalently SQL-centric.
@@ -190,3 +200,8 @@ Full steps to reproduce this output are available in [the docs](https://supabase
`pg_graphql` is [open source software](https://github.com/supabase/pg_graphql/). As always, Issues and PRs are welcome.
[Try `pg_graphql`](https://supabase.github.io/pg_graphql/quickstart/) today to see a live [GraphiQL](https://graphql-dotnet.github.io/docs/getting-started/graphiql/) demo.
## More pg_graphql
- [pg_graphql v1.0](https://supabase.com/blog/pg-graphql-v1)
- [New Features in pg_graphql v1.2](https://supabase.com/blog/whats-new-in-pg-graphql-v1-2)
@@ -1,6 +1,6 @@
---
title: 'GraphQL is now available in Supabase'
description: GraphQL support is now in general availability on the Supabase platform via our open source PostgreSQL extension, pg_graphql (beta).
description: GraphQL support is now in general availability on the Supabase platform via our open source PostgreSQL extension, pg_graphql.
author: oli_rice,dthyresson
image: launch-week-4/tuesday-graphql/graphql-thumb-og.png
thumb: launch-week-4/tuesday-graphql/graphql-thumb.png
@@ -11,10 +11,16 @@ date: '2022-03-29'
toc_depth: 3
---
GraphQL support is now in general availability on the Supabase platform via our [open source](https://github.com/supabase/pg_graphql/) PostgreSQL extension,
[`pg_graphql](https://supabase.github.io/pg_graphql/) (beta)`.
<div className="bg-gray-300 rounded-lg px-6 py-2 italic">
![supameme.png](/images/blog/launch-week-4/tuesday-graphql/supameme.png)
🆕 `pg_graphql` has undergone significant enhancements since this announcement. Here is what is new:
- [pg_graphql v1.0](https://supabase.com/blog/pg-graphql-v1)
- [New Features in pg_graphql v1.2](https://supabase.com/blog/whats-new-in-pg-graphql-v1-2)
</div>
GraphQL support is now in general availability on the Supabase platform via our [open source](https://github.com/supabase/pg_graphql/) PostgreSQL extension, [`pg_graphql`](https://supabase.github.io/pg_graphql/).
`pg_graphql` enables you to query existing PostgreSQL databases using GraphQL, either from within SQL or over HTTP:
@@ -119,8 +125,7 @@ An advantage to embedding GraphQL directly in the database is that we can lean o
### Authentication
The GraphQL types exposed by `pg_graphql` are filtered according to the SQL role's [INSERT/UPDATE/DELETE permissions](https://www.postgresql.org/docs/current/sql-grant.html).
At Supabase, each API request is resolved in the database using the role in the request's JWT.
The GraphQL types exposed by `pg_graphql` are filtered according to the SQL role's [INSERT/UPDATE/DELETE permissions](https://www.postgresql.org/docs/current/sql-grant.html). At Supabase, each API request is resolved in the database using the role in the request's JWT.
Anonymous users receive the `anon` role, and logged in users get the `authenticated` role. In either case, pg_graphql resolves requests according to the SQL permissions.
The [introspection schema](https://graphql.org/learn/introspection/) is similarly filtered to limit exposed types and fields to those that the user has permission to access.
@@ -134,6 +139,7 @@ Another nice side effect of making PostgreSQL do the heavy lifting is that Graph
## Performance
Each [free tier database](https://supabase.com/pricing) on the Supabase platform runs on a dedicated AWS t4g.micro instance with 2 vCPUs and 1 GB of memory.
To squeeze the most out of that limited hardware we had to make a few significant optimizations:
**GraphQL queries are always transpiled into exactly one SQL query**
@@ -195,9 +201,11 @@ Which has no duplication of data.
The difference in payload size is negligible in this case, but as the number of 1-to-many joins grows, data duplication in the SQL response grows geometrically.
**Queries are cached as [prepared statements](https://www.postgresql.org/docs/current/sql-prepare.html)**
![supameme.png](/images/blog/launch-week-4/tuesday-graphql/supameme.png)
After a GraphQL query is transpiled to SQL, it is added to the prepared statement cache so subsequent requests with the same structure (think pagination) can skip the transpilation step.
**Queries are cached as prepared statements**
After a GraphQL query is transpiled to SQL, it is added to the [prepared statement](https://www.postgresql.org/docs/current/sql-prepare.html) cache so subsequent requests with the same structure (think pagination) can skip the transpilation step.
Using prepared statements also allows PostgreSQL to skip the overhead of computing a query plan. For small, on-index, queries,
the query planning step can take several times as long as the query's execution time, so the saving is significant at scale.
@@ -221,7 +229,7 @@ As a result of these optimizations, the throughput of a “hello world” equiva
</aside>
To enable GraphQL in your Supabase instance, enable `pg_graphql` from the dashboard.
To enable GraphQL in your Supabase instance, enable `pg_graphql` from the [dashboard](https://app.supabase.com/).
<video width="99%" autoPlay="autoplay" muted playsInline controls={true}>
<source
@@ -319,3 +327,10 @@ Our first general availability release of `pg_graphql` supports:
In the near term, we plan to fully support array and json/b types. Longer term, we intend to support views and custom mutations from user defined functions.
Didn't see the feature you're interested in? [Let us know](https://github.com/supabase/pg_graphql/issues)
## More pg_graphql
- [Introducing pg_graphql: A GraphQL extension for PostgreSQL](https://supabase.com/blog/pg-graphql)
- [pg_graphql v1.0](https://supabase.com/blog/pg-graphql-v1)
- [pg_graphql v1.2](https://supabase.com/blog/whats-new-in-pg-graphql-v1-2)
+11 -11
View File
@@ -84,24 +84,24 @@ Time per request: 1.549 [ms] (mean, across all concurrent requests)
## Roadmap
<div className="bg-gray-300 rounded-lg px-6 py-2 italic">
🆕 Support for views, filtering options, and other new features were released with [pg_graphql v1.2](https://supabase.com/blog/whats-new-in-pg-graphql-v1-2)
</div>
While v1.0 is an important milestone for stability, there's plenty of room to expand the feature set. For example, some commonly requested features on the immediate roadmap are:
- Extended filtering options
- `startsWith` for the `String` type
- nestable `and`/ `or` blocks
- Support for user-defined functions
- Support for views
- Support for views
Longer term we look forward to experimenting with more ambitions features like an API for migrations, integrations with 3rd-party services through [supabase/wrappers](https://github.com/supabase/wrappers), and a scalable solution for subscriptions.
## More Launch Week 6
## More pg_graphql
- [Day 1: New Supabase Docs, built with Next.js](https://supabase.com/blog/new-supabase-docs-built-with-nextjs)
- [Day 2: Supabase Storage v2: Image resizing and Smart CDN](https://supabase.com/blog/storage-image-resizing-smart-cdn)
- [Day 3: Multi-factor Authentication via Row Level Security Enforcement](https://supabase.com/blog/mfa-auth-via-rls)
- [Day 4: Supabase Wrappers, a Postgres FDW framework written in Rust](https://supabase.com/blog/postgres-foreign-data-wrappers-rust)
- [Day 5: Supabase Vault is now in Beta](https://supabase.com/blog/vault-now-in-beta)
- [Community Day](https://supabase.com/blog/launch-week-6-community-day)
- [Point in Time Recovery is now available](https://supabase.com/blog/postgres-point-in-time-recovery)
- [Custom Domain Names are now available](https://supabase.com/blog/custom-domain-names)
- [Wrap Up: everything we shipped](https://supabase.com/blog/launch-week-6-wrap-up)
- [Introducing pg_graphql: A GraphQL extension for PostgreSQL](https://supabase.com/blog/pg-graphql)
- [GraphQL is now available in Supabase](https://supabase.com/blog/graphql-now-available)
- [pg_graphql v1.0](https://supabase.com/blog/pg-graphql-v1)
@@ -0,0 +1,203 @@
---
title: 'What''s New in pg_graphql v1.2'
description: 'New Features in the v1.2 release of pg_graphql'
launchweek: 7
tags:
- postgres
- graphql
- planetpg
date: '2023-04-21'
published_at: '2023-04-21:00:00.000-07:00'
toc_depth: 3
author: oli_rice
image: launch-week-6/pggraphql/og-pg-graphql.png
thumb: launch-week-6/pggraphql/og-pg-graphql.png
---
It's been 4 months since the [1.0.0 release of pg_graphql](https://supabase.com/blog/pg-graphql-v1).
Since then, we’ve pushed several features to improve the APIs that `pg_graphql` produces.
In this article, we’ll walk through those features and show examples of each.
### View Support
Prior to `v1.1`, `pg_graphql` would only reflect standard tables. Since then, views, materialized views, and foreign tables are now also reflected in the GraphQL schema.
For example:
```sql
create view "ProjectOwner" as
select
acc.id,
acc.name
from
account acc
join role r
on r.id = acc.role_id
where
acc.role = 'project_owner'
```
Since all entities exposed by `pg_graphql` require primary keys, we must define that constraint for the view. We do that using a comment directive:
```sql
comment on view "ProjectOwner"
is '@graphql({"primary_key_columns": ["id"]})';
```
Which yields the GraphQL type:
```graphql
type ProjectOwner implements Node {
nodeId: ID!
id: UUID!
name: String
}
```
With associated `Edge` and `Connection` types. That enables querying via:
```graphql
{
projectOwnerCollection (first: 2) {
edges {
node {
nodeId
name
}
}
}
}
```
Additionally, simple views automatically support mutation events like inserts and updates. You might use these to migrate underlying tables while maintaining backwards compatibility with previous API versions.
## Filtering
Filtering in SQL is endlessly flexible. We’ve taken two incremental steps to bring more of that flexibility to the GraphQL interface.
### `is null` and `is not null`
Handling `null` values can be tricky in both SQL and GraphQL. However, there are similarities we can take advantage of.
In `pg_graphql`, every scalar data type has its own filter type, such as `IntFilter` and `StringFilter`.
Each of these filter types now includes an `is` argument, which allows you to filter based on whether a value is null or not null.
You can do this by using `{is: NULL}` for `null` values and `{is: NOT_NULL}` for non-`null` values.
```graphql
enum FilterIs {
NULL
NOT_NULL
}
type IntFilter {
...
is: FilterIs
}
```
For example:
```graphql
{
blogCollection(filter: { name: {is: NULL}}) {
...
}
}
```
to return all `blog`s where the `name` is `null`.
### **`like`**, **`ilike`**, and **`startsWith`**
Text filtering options in `pg_graphql` have historically been restricted to equality checks. The hesitation was due to concerns about exposing a default filter that is difficult to index. The combination of [citext](https://www.postgresql.org/docs/current/citext.html) and [PGroonga available on the platform](https://supabase.com/docs/guides/database/extensions/pgroonga) solves those scalability risks and enabled us to expand the `StringFilter` with options for `like` `ilike` and `startsWith`.
```graphql
input StringFilter {
eq: String
...
startsWith: String
like: String
ilike: String
}
```
Note that `startsWith` filters should be preferred where appropriate because they can leverage simple B-Tree indexes to improve performance.
```graphql
{
generalLedgerCollection(filter: {identifierCode: {startsWith: "BX1:"}}) {
edges {
node {
nodeId
identifierCode
amount
}
}
}
}
```
### GraphQL directives `@skip` and `@include`
The GraphQL spec has evolved over time. Although the spec is clear, it is common for GraphQL servers to selectively omit some chunks of functionality. For example, some frameworks intentionally do not expose an introspection schema as a form of security through obscurity.
`pg_graphql` aims to be unopinionated and adhere exactly to the spec. The `@skip` and `@include` directives are part of the [GraphQL core specification](https://spec.graphql.org/October2021/#sec--skip) and are now functional.
The **`@skip`** directive in GraphQL is used to conditionally skip a field or fragment during query execution based on a Boolean variable. It can be used to make the query more efficient by reducing the amount of data retrieved from the server.
The **`@include`** directive is the mirror of **`@skip`** where a field or fragment is conditionally included depending on the value of a Boolean variable.
Here's an example of how the **`@skip`** directive can be used in a GraphQL query:
```graphql
query getBooks($includeDetails: Boolean!) {
booksCollection {
edges {
node {
id
title
description @skip(if: $includeDetails)
}
}
}
}
```
### User Defined Descriptions
Users can now use the [comment directive system to assign descriptions](https://supabase.github.io/pg_graphql/configuration/#description) to tables, views and columns.
```sql
create table public.book(
id int primary key,
title text not null
);
comment on table public.book
is e'@graphql({"description": "a library book"})';
comment on column public.book.title
is e'@graphql({"description": "the title of the book"})';
```
GraphQL IDEs, such as GraphiQL render those descriptions, allowing developers to provide clearer API documentation.
<Img
src="/images/blog/2023-04-19-pg_graphql-v1_2/description_directive.png"
wide={false}
alt="description comment directive"
/>
## Roadmap
The headline features we aim to launch in coming releases of `pg_graphql` include:
1. Support for user-defined functions: [GitHub issue](https://github.com/supabase/pg_graphql/issues/222)
2. Support for nested inserts: [GitHub issue](https://github.com/supabase/pg_graphql/issues/294)
3. An alternative approach to computed relationships based on SQL functions returning **`SET OF`** rather than comment directives (compatible with PostgREST)
## More pg_graphql
- [Introducing pg_graphql: A GraphQL extension for PostgreSQL](https://supabase.com/blog/pg-graphql)
- [GraphQL is now available in Supabase](https://supabase.com/blog/graphql-now-available)
- [pg_graphql v1.0](https://supabase.com/blog/pg-graphql-v1)
@@ -0,0 +1,124 @@
---
title: 'Launch Week 7 Hackathon Winners'
description: Announcing the winners of the Launch Week 7 Hackathon!
author: tyler_shukert
image: lw7-hackathon-winners/lw7-hackathon-winner.jpg
thumb: lw7-hackathon-winners/lw7-hackathon-winner.jpg
tags:
- hackathon
date: '2023-04-24'
toc_depth: 2
---
[Launch Week 7](https://supabase.com/launch-week) passed by, but we still have our favorite part left, celebrating the winners of the hackathon! We added an AI theme to our hackathon, and the results were exceptional. The quality of the apps was out of this world.
And now, without further ado, congratulations to the winners of Supabase Launch Week 7 Hackathon!
## Best Overall Project
### Winner
[Page Assist](https://github.com/n4ze3m/page-assist) - by [@n4ze3m](https://twitter.com/n4ze3m)
Revolutionize your browsing experience with PageAssist, an open-source Chrome extension that allows you to easily chat with any webpage using the power of ChatGPT API.
![Page Assist](/images/blog/lw7-hackathon-winners/01_page_assist.png)
### Runner Up
[rect](https://github.com/Vandivier/rect) - by [@JohnVandivier](https://twitter.com/JohnVandivier)
Rect takes short-form social media content as an input, and produces four kinds of educational output material. Specifically, Rect is an AI-powered assistant that will produce flash cards, quizzes, slideshows, and a chatbot assistant.
![rect](/images/blog/lw7-hackathon-winners/02_rect.png)
## Best use of AI
### Winner
[Supathreads](https://github.com/asrvd/supathreads) - by [@_asheeshh](https://twitter.com/_asheeshh)
Supathreads creates concise and professional Twitter threads from your blog posts within seconds using the power of AI.
![Supathreads](/images/blog/lw7-hackathon-winners/03supathreads.png)
### Runner Up
[ChatVox · Chat With Any Video](https://github.com/JimmyLv/ChatVox) - by [@Jimmy_JingLv](https://twitter.com/Jimmy_JingLv)
ChatVox takes the YouTube/Podcast link you enter or the local Video/Audio file (WIP) you upload on the page, then you can chat with the video to obtain information about it.
![ChatVox · Chat With Any Video](/images/blog/lw7-hackathon-winners/04_chatvox.png)
## Most fun / best easter egg
### Winner
[Replay.ai - spotlight search for your browsing history](https://github.com/KamaniBhavin/replay) - by [@bhavinkamani_](https://twitter.com/bhavinkamani_)
Replay is a Chrome extension that lets you search through all the pages in your browsing history to find the one you can't remember. It uses cosine similarity search to find the page based on the content of the page and the search query provided by the user.
![Replay.ai - spotlight search for your browsing history](/images/blog/lw7-hackathon-winners/05_replayai.png)
### Runner Up
[Groove AI - AI Generated Drum Patterns](https://github.com/danryland/groove-ai) - by [@RealDanRyland](https://twitter.com/RealDanRyland)
Groove AI generates drum patterns powered by AI and randomly plays the pattern whenever you open the page. You can modify the pattern by toggling each beat.
![Groove AI - AI Generated Drum Patterns](/images/blog/lw7-hackathon-winners/06_grooveai.png)
## Most technically impressive
### Winner
[Supa0SQL](https://github.com/Gabrielpanga/supa0sql) - by [@gpangantes](https://twitter.com/gpangantes)
Supa0SQL is a simple, easy-to-use, and fast Natural Language to Chart tool. It's meant for non-technical users to be able to quickly and easily create charts from their data without having to learn SQL.
![Supa0SQL](/images/blog/lw7-hackathon-winners/07supa0sql.png)
### Runner Up
[Schedurio - An open-source tweet Scheduler.](https://github.com/Ansh-Rathod/schedurio) - by [@anshrathodfr](https://twitter.com/anshrathodfr)
Schedurio is very helpful for Twitter content creators who are paying monthly for services like Hypefury, Feedhive, and many others, now they can use the Schedurio open-source app on their desktop by adding their own API keys. currently available for the Macos and web.
![Schedurio](/images/blog/lw7-hackathon-winners/08_schedurio.png)
## Most visually pleasing
### Winner
[Generation: Hotdog](https://github.com/laznic/generation-hotdog) - by [@laznic](https://twitter.com/laznic)
Generation: Hotdog generates hotdog images from 5 emojis. It uses both OpenAI and Stable Diffusion APIs to generate the end results. First, it'll use the selected emojis to generate a description of a hotdog via OpenAI, and then it'll also translate that text to Japanese Kanji (also via OpenAI).
![Generation: Hotdog](/images/blog/lw7-hackathon-winners/09_generation_hotdog.png)
### Runner Up
[Tune Twisters](https://github.com/JohnPhamous/tune-twisters) - by [@johnphamous](https://twitter.com/johnphamous)
Tune Twisters is a simple game where it plays a random song in reverse and you have to guess the name of the song.
![Tune Twisters](/images/blog/lw7-hackathon-winners/10_twisters.png)
## The Prizes
As promised in the [hackathon announcement article](https://supabase.com/blog/launch-week-7-hackathon) the winners of the Best overall project will be granted the extremely limited edition Supabase Launch Week 7 Keyboard!
![keyboard](/images/blog/lw7-hackathon-winners/keyboard.png)
Each member of the winners and runner-ups in other categories will receive a limited edition Launch Week 7 swag kit.
![swag](/images/blog/lw7-hackathon-winners/swag.png)
### Get Started Guides
If you're inspired to build, check out some of the latest resources:
- **[Quick Start Guides](https://supabase.com/docs/guides/getting-started)**
- **[Supabase Storage Guide](https://supabase.com/docs/guides/storage)**
- **[Edge Functions Guide](https://supabase.com/docs/guides/functions)**
+4 -4
View File
@@ -2,7 +2,7 @@ import React, { useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { Button, Badge, IconStar, IconChevronDown } from 'ui'
import { Button, Badge, Announcement, AnnouncementCountdown, IconStar, IconChevronDown } from 'ui'
import FlyOut from '~/components/UI/FlyOut'
import Transition from 'lib/Transition'
@@ -10,8 +10,7 @@ import SolutionsData from 'data/Solutions.json'
import Solutions from '~/components/Nav/Product'
import Developers from '~/components/Nav/Developers'
import Announcement from '~/components/Nav/Announcement'
import CountdownBanner from '~/components/LaunchWeek/Banners/CountdownBanner'
import ScrollProgress from '~/components/ScrollProgress'
import { useIsLoggedIn, useTheme } from 'common'
@@ -19,6 +18,7 @@ import TextLink from '../TextLink'
import Image from 'next/image'
import * as supabaseLogoWordmarkDark from 'common/assets/images/supabase-logo-wordmark--dark.png'
import * as supabaseLogoWordmarkLight from 'common/assets/images/supabase-logo-wordmark--light.png'
import * as supabaseLogoWordmarkWhite from 'common/assets/images/supabase-logo-wordmark--white.png'
const Nav = () => {
@@ -198,7 +198,7 @@ const Nav = () => {
return (
<>
<Announcement>
<CountdownBanner />
<AnnouncementCountdown />
</Announcement>
<div className="sticky top-0 z-40 transform" style={{ transform: 'translate3d(0,0,999px)' }}>
<div
+5
View File
@@ -1908,4 +1908,9 @@ module.exports = [
source: '/docs/support',
destination: '/support',
},
{
permanent: true,
source: '/docs/guides/cli/local-development',
destination: '/docs/guides/getting-started/local-development',
},
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 651 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 512 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 491 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 520 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 742 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 869 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 710 KiB

@@ -0,0 +1,39 @@
import React from 'https://esm.sh/react@18.2.0?deno-std=0.140.0'
import type { FormattedTweet } from './types.ts'
/**
* Supports plain text, images, quote tweets.
*
* Needs support for images, GIFs, and replies maybe?
* Styles use !important to override Tailwind .prose inside MDX.
*/
export default function Tweet({
id,
text,
author,
media,
created_at,
public_metrics,
referenced_tweets,
}: FormattedTweet) {
const authorUrl = `https://twitter.com/${author.username}`
const likeUrl = `https://twitter.com/intent/like?tweet_id=${id}`
const retweetUrl = `https://twitter.com/intent/retweet?tweet_id=${id}`
const replyUrl = `https://twitter.com/intent/tweet?in_reply_to=${id}`
const tweetUrl = `https://twitter.com/${author.username}/status/${id}`
const createdAt = new Date(created_at)
const formattedText = text.replace(/https:\/\/[\n\S]+/g, '').replace('&amp;', '&')
const quoteTweet = referenced_tweets && referenced_tweets.find((t) => t.type === 'quoted')
return (
<div tw="flex">
<div tw="flex flex-col md:flex-row w-full py-12 px-4 md:items-center justify-between p-8">
<h2 tw="flex flex-col text-3xl sm:text-4xl font-bold tracking-tight text-left">
<span>{formattedText}</span>
<span tw="text-gray-300">{`- @${author.username}`}</span>
</h2>
</div>
</div>
)
}
@@ -0,0 +1,61 @@
import type { TwitterApiResponse, Tweet, FormattedTweet } from './types.ts'
export const getTweets: (ids: string[]) => Promise<FormattedTweet[]> = async (ids: string[]) => {
if (ids.length === 0) {
return []
}
const queryParams = new URLSearchParams({
ids: ids.join(','),
expansions:
'author_id,attachments.media_keys,referenced_tweets.id,referenced_tweets.id.author_id',
'tweet.fields':
'attachments,author_id,public_metrics,created_at,id,in_reply_to_user_id,referenced_tweets,text',
'user.fields': 'id,name,profile_image_url,protected,url,username,verified',
'media.fields': 'duration_ms,height,media_key,preview_image_url,type,url,width,public_metrics',
})
const response = await fetch(`https://api.twitter.com/2/tweets?${queryParams}`, {
headers: {
Authorization: `Bearer ${Deno.env.get('TWITTER_API_TOKEN')}`,
},
})
const tweets: TwitterApiResponse = await response.json()
const getAuthorInfo = (author_id: string) => {
return tweets.includes.users.find((user) => user.id === author_id)
}
const getReferencedTweets = (mainTweet: Tweet) => {
return (
mainTweet?.referenced_tweets?.map((referencedTweet) => {
const fullReferencedTweet = tweets.includes.tweets.find(
(tweet) => tweet.id === referencedTweet.id
)
return {
type: referencedTweet.type,
author: getAuthorInfo(fullReferencedTweet!.author_id),
...fullReferencedTweet,
}
}) || []
)
}
return (
tweets.data.reduce<any>((allTweets, tweet) => {
const tweetWithAuthor = {
...tweet,
media:
tweet?.attachments?.media_keys.map((key: string) =>
tweets.includes.media.find((media) => media.media_key === key)
) || [],
referenced_tweets: getReferencedTweets(tweet),
author: getAuthorInfo(tweet.author_id),
}
return [tweetWithAuthor, ...allTweets]
}, []) || [] // If the Twitter API key isn't set, don't break the build
)
}
@@ -0,0 +1,93 @@
import React from 'https://esm.sh/react@18.2.0?deno-std=0.140.0'
import { ImageResponse } from 'https://deno.land/x/og_edge@0.0.4/mod.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.5.0'
import { corsHeaders } from '../_shared/cors.ts'
import { getTweets } from './getTweet.ts'
import Tweet from './Tweet.tsx'
const STORAGE_URL =
'https://obuldanrptloktxcffvn.supabase.co/storage/v1/object/public/images/tweet-to-image'
// Load custom font
const FONT_URL = `${STORAGE_URL}/CircularStd-Book.otf`
const font = fetch(new URL(FONT_URL, import.meta.url)).then((res) => res.arrayBuffer())
export async function handler(req: Request) {
const url = new URL(req.url)
const tweetId = url.searchParams.get('tweetId')
if (!tweetId)
return new Response(JSON.stringify({ error: 'missing tweetId param' }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 400,
})
try {
// Try to get image from Supabase Storage CDN.
const storageResponse = await fetch(`${STORAGE_URL}/${tweetId}.png`)
if (storageResponse.ok) return storageResponse
// Else, generate image and upload to storage.
const fontData = await font
const tweets = await getTweets([tweetId])
const tweet = tweets[0]
console.log('formattedTweets', JSON.stringify(tweets, null, 2))
const generatedImage = new ImageResponse(
(
<div
style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#1c1c1c',
color: '#EDEDED',
}}
>
<Tweet {...tweet} />
</div>
),
{
width: 1200,
height: 630,
// Supported options: 'twemoji', 'blobmoji', 'noto', 'openmoji', 'fluent', 'fluentFlat'
// Default to 'twemoji'
emoji: 'twemoji',
fonts: [
{
name: 'Circular',
data: fontData,
style: 'normal',
},
],
}
)
const supabaseAdminClient = createClient(
// Supabase API URL - env var exported by default when deployed.
Deno.env.get('SUPABASE_URL') ?? '',
// Supabase API SERVICE ROLE KEY - env var exported by default when deployed.
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
)
// Upload image to storage.
const { error } = await supabaseAdminClient.storage
.from('images')
.upload(`tweet-to-image/${tweetId}.png`, generatedImage.body!, {
contentType: 'image/png',
cacheControl: '31536000',
upsert: false,
})
if (error) throw error
return generatedImage
} catch (error) {
return new Response(JSON.stringify({ error: error.message }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 400,
})
}
}
@@ -0,0 +1,11 @@
// Follow this setup guide to integrate the Deno language server with your editor:
// https://deno.land/manual/getting_started/setup_your_environment
// This enables autocomplete, go to definition, etc.
import { serve } from 'https://deno.land/std@0.140.0/http/server.ts'
import { handler } from './handler.tsx'
console.log(`Function "tweet-to-image" up and running!`)
serve(handler)
@@ -0,0 +1,74 @@
export type Tweet = {
edit_history_tweet_ids: string[]
text: string
referenced_tweets:
| Array<{
type: string
id: string
}>
| undefined
attachments: {
media_keys: string[]
}
id: string
created_at: string
public_metrics: {
retweet_count: number
reply_count: number
like_count: number
quote_count: number
impression_count: number
}
author_id: string
author?: User
}
export type Media = {
width: number
media_key: string
type: string
url: string
height: number
}
export type User = {
name: string
url: string
verified: boolean
profile_image_url: string
id: string
username: string
protected: boolean
}
// raw
export type TwitterApiResponse = {
data: Tweet[]
includes: {
media: Media[]
users: User[]
tweets: Tweet[]
}
}
export type FormattedTweet = {
type: string
edit_history_tweet_ids: string[]
text: string
referenced_tweets: FormattedTweet[]
attachments: {
media_keys: string[]
}
id: string
created_at: string
public_metrics: {
retweet_count: number
reply_count: number
like_count: number
quote_count: number
impression_count: number
}
author_id: string
media: Media[]
author: User
}
+371 -17
View File
@@ -5952,6 +5952,25 @@
"resolved": "https://registry.npmjs.org/@octokit/webhooks-types/-/webhooks-types-6.10.0.tgz",
"integrity": "sha512-lDNv83BeEyxxukdQ0UttiUXawk9+6DkdjjFtm2GFED+24IQhTVaoSbwV9vWWKONyGLzRmCQqZmoEWkDhkEmPlw=="
},
"node_modules/@pkgr/utils": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@pkgr/utils/-/utils-2.3.1.tgz",
"integrity": "sha512-wfzX8kc1PMyUILA+1Z/EqoE4UCXGy0iRGMhPwdfae1+f0OXlLqCk+By+aMzgJBzR9AzS4CDizioG6Ss1gvAFJw==",
"dependencies": {
"cross-spawn": "^7.0.3",
"is-glob": "^4.0.3",
"open": "^8.4.0",
"picocolors": "^1.0.0",
"tiny-glob": "^0.2.9",
"tslib": "^2.4.0"
},
"engines": {
"node": "^12.20.0 || ^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/unts"
}
},
"node_modules/@pmmmwh/react-refresh-webpack-plugin": {
"version": "0.5.10",
"resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.10.tgz",
@@ -15827,7 +15846,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz",
"integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==",
"dev": true,
"engines": {
"node": ">=8"
}
@@ -19020,7 +19038,6 @@
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.5.0.tgz",
"integrity": "sha512-MjhiaIWCJ1sAU4pIQ5i5OfOuHHxVo1oYeNsWTON7jxYkod8pHocXeh+SSbmu5OZZZK73B6cbJ2XADzXehLyovQ==",
"dev": true,
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
@@ -19127,6 +19144,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/globalyzer": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/globalyzer/-/globalyzer-0.1.0.tgz",
"integrity": "sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q=="
},
"node_modules/globby": {
"version": "12.2.0",
"resolved": "https://registry.npmjs.org/globby/-/globby-12.2.0.tgz",
@@ -19157,6 +19179,11 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/globrex": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz",
"integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="
},
"node_modules/goober": {
"version": "2.1.12",
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.12.tgz",
@@ -20885,7 +20912,6 @@
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
"integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
"dev": true,
"bin": {
"is-docker": "cli.js"
},
@@ -21301,7 +21327,6 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
"integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
"dev": true,
"dependencies": {
"is-docker": "^2.0.0"
},
@@ -28220,7 +28245,6 @@
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz",
"integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==",
"dev": true,
"dependencies": {
"define-lazy-prop": "^2.0.0",
"is-docker": "^2.1.1",
@@ -35342,6 +35366,21 @@
"integrity": "sha512-AsS729u2RHUfEra9xJrE39peJcc2stq2+poBXX8bcM08Y6g9j/i/PUzwNQqkaJde7Ntg1TO7bSREbR5sdosQ+g==",
"dev": true
},
"node_modules/synckit": {
"version": "0.8.5",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.5.tgz",
"integrity": "sha512-L1dapNV6vu2s/4Sputv8xGsCdAVlb5nRDMFU/E27D44l5U6cw1g0dGd45uLc+OXjNMmF4ntiMdCimzcjFKQI8Q==",
"dependencies": {
"@pkgr/utils": "^2.3.1",
"tslib": "^2.5.0"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/unts"
}
},
"node_modules/tabbable": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-4.0.0.tgz",
@@ -35798,6 +35837,15 @@
"resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz",
"integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q=="
},
"node_modules/tiny-glob": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz",
"integrity": "sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==",
"dependencies": {
"globalyzer": "0.1.0",
"globrex": "^0.1.2"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz",
@@ -39108,6 +39156,7 @@
"config": "*",
"configcat-js": "^7.0.0",
"dayjs": "^1.11.0",
"eslint-config-next": "^13.3.0",
"file-saver": "^2.0.5",
"generate-password": "^1.7.0",
"graphql": "^16.6.0",
@@ -39122,7 +39171,6 @@
"mobx": "^6.3.2",
"mobx-react-lite": "^3.2.0",
"next": "12.3.2",
"next-compose-plugins": "^2.2.1",
"next-transpile-modules": "^9.0.0",
"openapi-types": "^9.1.0",
"p-queue": "^6.6.2",
@@ -39221,6 +39269,14 @@
"webpack-bundle-analyzer": "4.3.0"
}
},
"studio/node_modules/@next/eslint-plugin-next": {
"version": "13.3.0",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-13.3.0.tgz",
"integrity": "sha512-wuGN5qSEjSgcq9fVkH0Y/qIPFjnZtW3ZPwfjJOn7l/rrf6y8J24h/lo61kwqunTyzZJm/ETGfGVU9PUs8cnzEA==",
"dependencies": {
"glob": "7.1.7"
}
},
"studio/node_modules/@tailwindcss/typography": {
"version": "0.5.8",
"dev": true,
@@ -39475,6 +39531,132 @@
"d3-time": "1 - 2"
}
},
"studio/node_modules/doctrine": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
"integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==",
"dependencies": {
"esutils": "^2.0.2"
},
"engines": {
"node": ">=0.10.0"
}
},
"studio/node_modules/eslint-config-next": {
"version": "13.3.0",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-13.3.0.tgz",
"integrity": "sha512-6YEwmFBX0VjBd3ODGW9df0Is0FLaRFdMN8eAahQG9CN6LjQ28J8AFr19ngxqMSg7Qv6Uca/3VeeBosJh1bzu0w==",
"dependencies": {
"@next/eslint-plugin-next": "13.3.0",
"@rushstack/eslint-patch": "^1.1.3",
"@typescript-eslint/parser": "^5.42.0",
"eslint-import-resolver-node": "^0.3.6",
"eslint-import-resolver-typescript": "^3.5.2",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-react": "^7.31.7",
"eslint-plugin-react-hooks": "^4.5.0"
},
"peerDependencies": {
"eslint": "^7.23.0 || ^8.0.0",
"typescript": ">=3.3.1"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"studio/node_modules/eslint-import-resolver-typescript": {
"version": "3.5.5",
"resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.5.5.tgz",
"integrity": "sha512-TdJqPHs2lW5J9Zpe17DZNQuDnox4xo2o+0tE7Pggain9Rbc19ik8kFtXdxZ250FVx2kF4vlt2RSf4qlUpG7bhw==",
"dependencies": {
"debug": "^4.3.4",
"enhanced-resolve": "^5.12.0",
"eslint-module-utils": "^2.7.4",
"get-tsconfig": "^4.5.0",
"globby": "^13.1.3",
"is-core-module": "^2.11.0",
"is-glob": "^4.0.3",
"synckit": "^0.8.5"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/unts/projects/eslint-import-resolver-ts"
},
"peerDependencies": {
"eslint": "*",
"eslint-plugin-import": "*"
}
},
"studio/node_modules/eslint-plugin-react": {
"version": "7.32.2",
"resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.32.2.tgz",
"integrity": "sha512-t2fBMa+XzonrrNkyVirzKlvn5RXzzPwRHtMvLAtVZrt8oxgnTQaYbU6SXTOO1mwQgp1y5+toMSKInnzGr0Knqg==",
"dependencies": {
"array-includes": "^3.1.6",
"array.prototype.flatmap": "^1.3.1",
"array.prototype.tosorted": "^1.1.1",
"doctrine": "^2.1.0",
"estraverse": "^5.3.0",
"jsx-ast-utils": "^2.4.1 || ^3.0.0",
"minimatch": "^3.1.2",
"object.entries": "^1.1.6",
"object.fromentries": "^2.0.6",
"object.hasown": "^1.1.2",
"object.values": "^1.1.6",
"prop-types": "^15.8.1",
"resolve": "^2.0.0-next.4",
"semver": "^6.3.0",
"string.prototype.matchall": "^4.0.8"
},
"engines": {
"node": ">=4"
},
"peerDependencies": {
"eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8"
}
},
"studio/node_modules/glob": {
"version": "7.1.7",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz",
"integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.0.4",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"studio/node_modules/globby": {
"version": "13.1.4",
"resolved": "https://registry.npmjs.org/globby/-/globby-13.1.4.tgz",
"integrity": "sha512-iui/IiiW+QrJ1X1hKH5qwlMQyv34wJAYwH1vrf8b9kBA4sNiif3gKsMHa+BrdnOpEudWjpotfa7LrTzB1ERS/g==",
"dependencies": {
"dir-glob": "^3.0.1",
"fast-glob": "^3.2.11",
"ignore": "^5.2.0",
"merge2": "^1.4.1",
"slash": "^4.0.0"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"studio/node_modules/internmap": {
"version": "1.0.1",
"license": "ISC"
@@ -39542,6 +39724,33 @@
"react-dom": "^16.0.0 || ^17.0.0"
}
},
"studio/node_modules/resolve": {
"version": "2.0.0-next.4",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.4.tgz",
"integrity": "sha512-iMDbmAWtfU+MHpxt/I5iWI7cY6YVEZUQ3MBgPQ++XD1PELuJHIl82xBmObyP2KyQmkNB2dsqF7seoQQiAn5yDQ==",
"dependencies": {
"is-core-module": "^2.9.0",
"path-parse": "^1.0.7",
"supports-preserve-symlinks-flag": "^1.0.0"
},
"bin": {
"resolve": "bin/resolve"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"studio/node_modules/slash": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz",
"integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"studio/node_modules/sql-formatter": {
"version": "12.2.0",
"resolved": "https://registry.npmjs.org/sql-formatter/-/sql-formatter-12.2.0.tgz",
@@ -39556,7 +39765,7 @@
},
"studio/node_modules/typescript": {
"version": "4.8.4",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -43408,6 +43617,19 @@
"resolved": "https://registry.npmjs.org/@octokit/webhooks-types/-/webhooks-types-6.10.0.tgz",
"integrity": "sha512-lDNv83BeEyxxukdQ0UttiUXawk9+6DkdjjFtm2GFED+24IQhTVaoSbwV9vWWKONyGLzRmCQqZmoEWkDhkEmPlw=="
},
"@pkgr/utils": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@pkgr/utils/-/utils-2.3.1.tgz",
"integrity": "sha512-wfzX8kc1PMyUILA+1Z/EqoE4UCXGy0iRGMhPwdfae1+f0OXlLqCk+By+aMzgJBzR9AzS4CDizioG6Ss1gvAFJw==",
"requires": {
"cross-spawn": "^7.0.3",
"is-glob": "^4.0.3",
"open": "^8.4.0",
"picocolors": "^1.0.0",
"tiny-glob": "^0.2.9",
"tslib": "^2.4.0"
}
},
"@pmmmwh/react-refresh-webpack-plugin": {
"version": "0.5.10",
"resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.10.tgz",
@@ -51064,8 +51286,7 @@
"define-lazy-prop": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz",
"integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==",
"dev": true
"integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="
},
"define-properties": {
"version": "1.2.0",
@@ -53971,8 +54192,7 @@
"get-tsconfig": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.5.0.tgz",
"integrity": "sha512-MjhiaIWCJ1sAU4pIQ5i5OfOuHHxVo1oYeNsWTON7jxYkod8pHocXeh+SSbmu5OZZZK73B6cbJ2XADzXehLyovQ==",
"dev": true
"integrity": "sha512-MjhiaIWCJ1sAU4pIQ5i5OfOuHHxVo1oYeNsWTON7jxYkod8pHocXeh+SSbmu5OZZZK73B6cbJ2XADzXehLyovQ=="
},
"get-value": {
"version": "2.0.6",
@@ -54046,6 +54266,11 @@
"define-properties": "^1.1.3"
}
},
"globalyzer": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/globalyzer/-/globalyzer-0.1.0.tgz",
"integrity": "sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q=="
},
"globby": {
"version": "12.2.0",
"resolved": "https://registry.npmjs.org/globby/-/globby-12.2.0.tgz",
@@ -54066,6 +54291,11 @@
}
}
},
"globrex": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz",
"integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="
},
"goober": {
"version": "2.1.12",
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.12.tgz",
@@ -55352,8 +55582,7 @@
"is-docker": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
"integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
"dev": true
"integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="
},
"is-dom": {
"version": "1.1.0",
@@ -55630,7 +55859,6 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
"integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
"dev": true,
"requires": {
"is-docker": "^2.0.0"
}
@@ -60911,7 +61139,6 @@
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz",
"integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==",
"dev": true,
"requires": {
"define-lazy-prop": "^2.0.0",
"is-docker": "^2.1.1",
@@ -66146,6 +66373,7 @@
"config": "*",
"configcat-js": "^7.0.0",
"dayjs": "^1.11.0",
"eslint-config-next": "*",
"file-saver": "^2.0.5",
"generate-password": "^1.7.0",
"graphql": "^16.6.0",
@@ -66162,7 +66390,6 @@
"mobx": "^6.3.2",
"mobx-react-lite": "^3.2.0",
"next": "12.3.2",
"next-compose-plugins": "^2.2.1",
"next-transpile-modules": "^9.0.0",
"openapi-types": "^9.1.0",
"p-queue": "^6.6.2",
@@ -66217,6 +66444,14 @@
"webpack-bundle-analyzer": "4.3.0"
}
},
"@next/eslint-plugin-next": {
"version": "13.3.0",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-13.3.0.tgz",
"integrity": "sha512-wuGN5qSEjSgcq9fVkH0Y/qIPFjnZtW3ZPwfjJOn7l/rrf6y8J24h/lo61kwqunTyzZJm/ETGfGVU9PUs8cnzEA==",
"requires": {
"glob": "7.1.7"
}
},
"@tailwindcss/typography": {
"version": "0.5.8",
"dev": true,
@@ -66378,6 +66613,92 @@
"d3-time": "1 - 2"
}
},
"doctrine": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
"integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==",
"requires": {
"esutils": "^2.0.2"
}
},
"eslint-config-next": {
"version": "13.3.0",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-13.3.0.tgz",
"integrity": "sha512-6YEwmFBX0VjBd3ODGW9df0Is0FLaRFdMN8eAahQG9CN6LjQ28J8AFr19ngxqMSg7Qv6Uca/3VeeBosJh1bzu0w==",
"requires": {
"@next/eslint-plugin-next": "13.3.0",
"@rushstack/eslint-patch": "^1.1.3",
"@typescript-eslint/parser": "^5.42.0",
"eslint-import-resolver-node": "^0.3.6",
"eslint-import-resolver-typescript": "^3.5.2",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-react": "^7.31.7",
"eslint-plugin-react-hooks": "^4.5.0"
}
},
"eslint-import-resolver-typescript": {
"version": "3.5.5",
"resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.5.5.tgz",
"integrity": "sha512-TdJqPHs2lW5J9Zpe17DZNQuDnox4xo2o+0tE7Pggain9Rbc19ik8kFtXdxZ250FVx2kF4vlt2RSf4qlUpG7bhw==",
"requires": {
"debug": "^4.3.4",
"enhanced-resolve": "^5.12.0",
"eslint-module-utils": "^2.7.4",
"get-tsconfig": "^4.5.0",
"globby": "^13.1.3",
"is-core-module": "^2.11.0",
"is-glob": "^4.0.3",
"synckit": "^0.8.5"
}
},
"eslint-plugin-react": {
"version": "7.32.2",
"resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.32.2.tgz",
"integrity": "sha512-t2fBMa+XzonrrNkyVirzKlvn5RXzzPwRHtMvLAtVZrt8oxgnTQaYbU6SXTOO1mwQgp1y5+toMSKInnzGr0Knqg==",
"requires": {
"array-includes": "^3.1.6",
"array.prototype.flatmap": "^1.3.1",
"array.prototype.tosorted": "^1.1.1",
"doctrine": "^2.1.0",
"estraverse": "^5.3.0",
"jsx-ast-utils": "^2.4.1 || ^3.0.0",
"minimatch": "^3.1.2",
"object.entries": "^1.1.6",
"object.fromentries": "^2.0.6",
"object.hasown": "^1.1.2",
"object.values": "^1.1.6",
"prop-types": "^15.8.1",
"resolve": "^2.0.0-next.4",
"semver": "^6.3.0",
"string.prototype.matchall": "^4.0.8"
}
},
"glob": {
"version": "7.1.7",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz",
"integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==",
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.0.4",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
}
},
"globby": {
"version": "13.1.4",
"resolved": "https://registry.npmjs.org/globby/-/globby-13.1.4.tgz",
"integrity": "sha512-iui/IiiW+QrJ1X1hKH5qwlMQyv34wJAYwH1vrf8b9kBA4sNiif3gKsMHa+BrdnOpEudWjpotfa7LrTzB1ERS/g==",
"requires": {
"dir-glob": "^3.0.1",
"fast-glob": "^3.2.11",
"ignore": "^5.2.0",
"merge2": "^1.4.1",
"slash": "^4.0.0"
}
},
"internmap": {
"version": "1.0.1"
},
@@ -66429,6 +66750,21 @@
"reduce-css-calc": "^2.1.8"
}
},
"resolve": {
"version": "2.0.0-next.4",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.4.tgz",
"integrity": "sha512-iMDbmAWtfU+MHpxt/I5iWI7cY6YVEZUQ3MBgPQ++XD1PELuJHIl82xBmObyP2KyQmkNB2dsqF7seoQQiAn5yDQ==",
"requires": {
"is-core-module": "^2.9.0",
"path-parse": "^1.0.7",
"supports-preserve-symlinks-flag": "^1.0.0"
}
},
"slash": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz",
"integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew=="
},
"sql-formatter": {
"version": "12.2.0",
"resolved": "https://registry.npmjs.org/sql-formatter/-/sql-formatter-12.2.0.tgz",
@@ -66440,7 +66776,7 @@
},
"typescript": {
"version": "4.8.4",
"dev": true
"devOptional": true
},
"uuid": {
"version": "8.3.2",
@@ -66809,6 +67145,15 @@
"integrity": "sha512-AsS729u2RHUfEra9xJrE39peJcc2stq2+poBXX8bcM08Y6g9j/i/PUzwNQqkaJde7Ntg1TO7bSREbR5sdosQ+g==",
"dev": true
},
"synckit": {
"version": "0.8.5",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.5.tgz",
"integrity": "sha512-L1dapNV6vu2s/4Sputv8xGsCdAVlb5nRDMFU/E27D44l5U6cw1g0dGd45uLc+OXjNMmF4ntiMdCimzcjFKQI8Q==",
"requires": {
"@pkgr/utils": "^2.3.1",
"tslib": "^2.5.0"
}
},
"tabbable": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-4.0.0.tgz",
@@ -67155,6 +67500,15 @@
"resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz",
"integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q=="
},
"tiny-glob": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz",
"integrity": "sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==",
"requires": {
"globalyzer": "0.1.0",
"globrex": "^0.1.2"
}
},
"tiny-invariant": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz",
+17
View File
@@ -60,6 +60,23 @@ export * from './src/components/Form'
// CMD+K
export * from './src/components/Command'
// layout
// banners
export * from './src/layout/banners'
// config
// export { default as Config } from './../ui.config'
// ARCHIVE
// export * from './src/components/Textarea'
// AUTH
// export * from './src/components/Auth'
// ICONS
// export icons
export * from './src/components/Icon/icons/IconActivity'
+4 -5
View File
@@ -3,8 +3,7 @@ import { useFormik, FormikConfig } from 'formik'
import { FormContextProvider } from './FormContext'
// interface Props extends FormikProps<any>, Partial FormikConfig<any> {
interface Props
extends Omit<FormikConfig<any>, 'validateOnMount' | 'validateOnChange'> {
interface Props extends Omit<FormikConfig<any>, 'validateOnMount' | 'validateOnChange'> {
children: any
handleIsSubmitting?: any
handleIsValidating?: any
@@ -69,8 +68,8 @@ export default function Form({ validate, ...props }: Props) {
handleBlur={formik.handleBlur}
touched={formik.touched}
fieldLevelValidation={handleFieldLevelValidation}
// children={rest.children}
children={props.children({
>
{props.children({
/** map of field names to specific error for that field */
errors: formik.errors, // errors,
// /** map of field names to whether the field has been touched */
@@ -90,7 +89,7 @@ export default function Form({ validate, ...props }: Props) {
/** Resets the form with custom values */
resetForm: formik.resetForm,
})}
/>
</FormContextProvider>
</form>
)
}
@@ -102,6 +102,9 @@ function Listbox({
function handleResize() {
// Set window width/height to state
// [Joshen] Note this causes some style conflicts if there are multiple listboxes
// rendered on the same page. All listbox option widths will be that of the latest
// listbox component that got rendered, rather than following its parent
document.documentElement.style.setProperty(
'--width-listbox',
`${optionsWidth ? optionsWidth : triggerRef.current?.offsetWidth}px`
@@ -1,15 +1,18 @@
import React from 'react'
import Link from 'next/link'
import Countdown from 'react-countdown'
import _announcement from '~/data/Announcement.json'
import { AnnouncementProps } from '../../Nav/Announcement'
import { useRouter } from 'next/router'
import { PropsWithChildren } from 'react'
import Countdown from 'react-countdown'
import { AnnouncementProps } from './Announcement'
import _announcement from './data/Announcement.json'
interface CountdownStepProps {
value: string | number
unit: string
}
interface CountdownButtonProps {
dest: string
}
const announcement = _announcement as AnnouncementProps
function CountdownStep({ value, unit }: CountdownStepProps) {
@@ -23,6 +26,17 @@ function CountdownStep({ value, unit }: CountdownStepProps) {
)
}
function CountdownButton({ children, dest }: PropsWithChildren<CountdownButtonProps>) {
return (
<a
href={dest}
className="bg-white text-xs px-1.5 md:px-2.5 py-1 rounded-full text-[#9E44EF] shadow-none hover:shadow-mg cursor-pointer"
>
{children}
</a>
)
}
function CountdownBanner() {
const { pathname } = useRouter()
const isLaunchWeekPage = pathname === '/launch-week'
@@ -35,11 +49,7 @@ function CountdownBanner() {
<div className="w-full flex gap-3 md:gap-6 items-center justify-center">
<p>Supabase Launch Week 7</p>
<div>
<Link href="/launch-week">
<a className="bg-white text-xs px-1.5 md:px-2.5 py-1 rounded-full text-[#9E44EF] shadow-none hover:shadow-mg cursor-pointer">
Explore
</a>
</Link>
<CountdownButton dest="/launch-week">Explore</CountdownButton>
</div>
</div>
)
@@ -63,11 +73,7 @@ function CountdownBanner() {
</div>
{!isLaunchWeekPage && (
<div className="hidden md:block">
<Link href="/launch-week">
<a className="bg-white text-xs px-1.5 md:px-2.5 py-1 rounded-full text-[#9E44EF] shadow-none hover:shadow-mg cursor-pointer">
Get your ticket
</a>
</Link>
<CountdownButton dest="/launch-week">Get your ticket</CountdownButton>
</div>
)}
</div>
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react'
import { useEffect, useState } from 'react'
import _announcement from '~/data/Announcement.json'
import _announcement from './data/Announcement.json'
import { IconX } from 'ui'
import { useRouter } from 'next/router'
import { PropsWithChildren } from 'react'
@@ -51,16 +51,15 @@ const Announcement = ({
return setHidden(true)
}
function handleLink() {
router.push(announcement.link)
}
// Always show if on LW section
if (!isLaunchWeekSection && hidden) {
return null
} else {
return (
<div onClick={handleLink} className={['relative w-full cursor-pointer', className].join(' ')}>
<div
onClick={() => window.location.assign(announcement.link)}
className={['relative w-full cursor-pointer', className].join(' ')}
>
{!isLaunchWeekSection && (
<div
className="absolute right-4 flex h-full items-center opacity-50 transition-opacity hover:opacity-100"
@@ -2,6 +2,6 @@
"show": true,
"text": "Supabase Launch Week 7",
"launchDate": "2023-04-10T07:00:00.000-07:00",
"link": "/launch-week",
"link": "https://supabase.com/launch-week",
"badge": "Get your ticket"
}
+2
View File
@@ -0,0 +1,2 @@
export { default as Announcement } from './Announcement'
export { default as AnnouncementCountdown } from './Announcement.Countdown'
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "next/core-web-vitals",
"rules": {
"@next/next/no-img-element": "off",
"react/no-unescaped-entities": "off",
"react/display-name": "warn"
}
}
@@ -32,6 +32,8 @@ export const SelectColumn: CalculatedColumn<any, any> = {
)
},
formatter: (props: FormatterProps<SupaRow>) => {
// [Alaister] formatter is actually a valid React component, so we can use hooks here
// eslint-disable-next-line react-hooks/rules-of-hooks
const [isRowSelected, onRowSelectionChange] = useRowSelection()
return (
<SelectCellFormatter
@@ -49,6 +51,8 @@ export const SelectColumn: CalculatedColumn<any, any> = {
)
},
groupFormatter: (props: GroupFormatterProps<SupaRow>) => {
// [Alaister] groupFormatter is actually a valid React component, so we can use hooks here
// eslint-disable-next-line react-hooks/rules-of-hooks
const [isRowSelected, onRowSelectionChange] = useRowSelection()
return (
<SelectCellFormatter
@@ -58,12 +58,11 @@ export default function RLSBannerWarning() {
size="medium"
onSelectCancel={() => setIsOpen(false)}
onSelectConfirm={handleDismissWarning}
children={
<Modal.Content>
<RLSDisableModalContent />
</Modal.Content>
}
/>
>
<Modal.Content>
<RLSDisableModalContent />
</Modal.Content>
</ConfirmationModal>
</div>
) : (
<></>
@@ -17,7 +17,6 @@ export interface SortRowProps {
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)
@@ -95,6 +94,8 @@ const SortRow = ({ table, index, columnName, sort, onDelete, onToggle, onDrag }:
const opacity = isDragging ? 0 : 1
drag(drop(ref))
if (!column) return null
return (
<div
className="flex items-center gap-3 px-3"
@@ -7,6 +7,9 @@ import { useDispatch } from 'components/grid/store'
export class SqlRowService implements IRowService {
protected query = new Query()
// [Alaister]: This store is going to be removed in the near future
// and I really don't want to fix this "hook inside class" craziness 🤯
// eslint-disable-next-line react-hooks/rules-of-hooks
protected dispatch = useDispatch()
constructor(
@@ -89,14 +89,13 @@ const AccessTokenList = observer(() => {
onSelectConfirm={() => {
if (token) onDeleteToken(token.id)
}}
children={
<Modal.Content>
<p className="py-4 text-sm text-scale-1100">
{`This action cannot be undone. Are you sure you want to delete "${token?.name}" token?`}
</p>
</Modal.Content>
}
/>
>
<Modal.Content>
<p className="py-4 text-sm text-scale-1100">
{`This action cannot be undone. Are you sure you want to delete "${token?.name}" token?`}
</p>
</Modal.Content>
</ConfirmationModal>
</>
)
})
@@ -115,7 +115,7 @@ const NewAccessTokenButton = observer(({ onCreateToken }: NewAccessTokenButtonPr
</p>
<div className="mt-4">
<Link href="https://api.supabase.com/api/v0">
<a target="_blank">
<a target="_blank" rel="noreferrer">
<Button type="default" icon={<IconExternalLink strokeWidth={1.5} />}>
Experimental API documentation
</Button>
@@ -10,7 +10,7 @@ const AppBannerWrapper: FC = ({ children }) => {
<div className="min-h-full flex flex-col">
{ongoingIncident && (
<Link href="https://status.supabase.com">
<a target="_blank">
<a target="_blank" rel="noreferrer">
<div className="flex cursor-pointer items-center justify-center space-x-2 bg-green-900 py-3 text-scale-400 transition hover:bg-green-1000 dark:text-scale-1200">
<p className="text-sm font-medium">
We are currently investigating a technical issue, follow status.supabase.com for
@@ -12,10 +12,10 @@ interface Props {
}
const FormField: FC<Props> = ({ name, properties, formValues, disabled = false }) => {
if (properties.show && formValues[properties.show.key] !== properties.show.matches) return <></>
const [hidden, setHidden] = useState(!!properties.isSecret)
if (properties.show && formValues[properties.show.key] !== properties.show.matches) return null
switch (properties.type) {
case 'string':
return (
@@ -15,7 +15,11 @@ const EmailTemplates = observer(() => {
/>
<div className="mb-6">
<Button type="default" icon={<IconExternalLink size={14} strokeWidth={1.5} />}>
<a target="_blank" href="https://supabase.com/docs/guides/auth/auth-email-templates">
<a
target="_blank"
rel="noreferrer"
href="https://supabase.com/docs/guides/auth/auth-email-templates"
>
Documentation
</a>
</Button>
@@ -64,6 +64,9 @@ const TemplateEditor: FC<Props> = ({ template }) => {
return (
<Form id={formId} className="!border-t-0" initialValues={INITIAL_VALUES} onSubmit={onSubmit}>
{({ isSubmitting, resetForm, values, initialValues }: any) => {
// [Alaister] although this "technically" is breaking the rules of React hooks
// it won't error because the hooks are always rendered in the same order
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
// Form is reset once remote data is loaded in store
resetForm({
@@ -85,7 +88,7 @@ const TemplateEditor: FC<Props> = ({ template }) => {
const property = properties[x]
if (property.type === 'string') {
return (
<div className="space-y-3">
<div key={x} className="space-y-3">
<label className="col-span-12 text-sm text-scale-1200 lg:col-span-5">
{property.title}
</label>
@@ -9,8 +9,14 @@ interface Props {
const PolicyEditorFooter: FC<Props> = ({ showTemplates, onViewTemplates, onReviewPolicy }) => (
<div className="flex justify-between items-center border-t px-6 py-4 dark:border-dark">
<a href='https://supabase.com/docs/learn/auth-deep-dive/auth-policies' target="_blank">
<Button type='link' icon={<IconExternalLink size={14} strokeWidth={1.5} />}>Documentation</Button>
<a
href="https://supabase.com/docs/learn/auth-deep-dive/auth-policies"
target="_blank"
rel="noreferrer"
>
<Button type="link" icon={<IconExternalLink size={14} strokeWidth={1.5} />}>
Documentation
</Button>
</a>
<div className="flex w-full items-center justify-end gap-2">
{showTemplates && (
@@ -61,6 +61,7 @@ const PolicySelection: FC<Props> = ({
Not sure what policies are? Check out our resources{' '}
<a
target="_blank"
rel="noreferrer"
className="text-brand-900 transition-colors hover:text-brand-1200"
href="https://supabase.com/docs/guides/auth#policies"
>
@@ -125,6 +125,9 @@ const SmtpForm = () => {
const isValidSmtpConfig = isSmtpEnabled(values)
const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues)
// [Alaister] although this "technically" is breaking the rules of React hooks
// it won't error because the hooks are always rendered in the same order
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (isLoaded) {
const formValues = generateFormValues(config)
@@ -37,7 +37,7 @@ const ComputeSizeSelection: FC<Props> = ({
</p>
</div>
<Link href="https://supabase.com/docs/guides/platform/compute-add-ons">
<a target="_blank">
<a target="_blank" rel="noreferrer">
<Button type="default" icon={<IconExternalLink strokeWidth={1.5} />}>
About compute add-ons
</Button>
@@ -31,7 +31,11 @@ export const USAGE_BASED_PRODUCTS = [
tooltip: (
<span>
Billing is based on the average daily database size in GB throughout the billing period.{' '}
<a href="https://supabase.com/docs/guides/platform/database-usage" target="_blank">
<a
href="https://supabase.com/docs/guides/platform/database-usage"
target="_blank"
rel="noreferrer"
>
Docs
</a>
</span>
@@ -131,7 +131,7 @@ const Invoices: FC<Props> = ({ projectRef }) => {
<div className="flex items-center justify-end space-x-2">
{[InvoiceStatus.UNCOLLECTIBLE, InvoiceStatus.OPEN].includes(x.status) && (
<Link href={`https://redirect.revops.supabase.com/pay-invoice/${x.id}`}>
<a target="_blank">
<a target="_blank" rel="noreferrer">
<Button>Pay Now</Button>
</a>
</Link>
@@ -45,7 +45,7 @@ const PlanSelection: FC<Props> = ({ visible, tiers, currentPlan, onSelectPlan })
</div>
<div className="flex justify-center items-center mt-4">
<Link href="https://supabase.com/pricing">
<a target="_blank">
<a target="_blank" rel="noreferrer">
<Button
type="link"
icon={<IconExternalLink size={14} strokeWidth={1.5} />}
@@ -15,7 +15,7 @@ interface Props {
const PlanCard: FC<Props> = ({ plan, currentPlan, onSelectPlan }) => {
// Team tier is enabled when the flag is turned on OR the user is already on the team tier (manually assigned by us)
const teamTierEnabled =
currentPlan?.supabase_prod_id === PRICING_TIER_PRODUCT_IDS.TEAM || useFlag('teamTier')
useFlag('teamTier') || currentPlan?.supabase_prod_id === PRICING_TIER_PRODUCT_IDS.TEAM
const planMeta = PRICING_META[plan.id]
const isEnterprise = plan.id === 'Enterprise'
@@ -134,9 +134,7 @@ const PlanCard: FC<Props> = ({ plan, currentPlan, onSelectPlan }) => {
{planMeta.name === 'Pro' && currentPlan?.prod_id === STRIPE_PRODUCT_IDS.PAYG && (
<div>
<p className="text-sm text-scale-1100">
Spend cap is currently disabled
</p>
<p className="text-sm text-scale-1100">Spend cap is currently disabled</p>
<p className="text-xs text-scale-1000">
You will be charged for usage beyond the plan limits
</p>{' '}
@@ -129,7 +129,7 @@ const SpendCapModal: FC<Props> = ({ visible, onHide }) => {
<p>
See{' '}
<Link href="https://supabase.com/pricing" passHref>
<a className="text-brand-900" target="_blank">
<a className="text-brand-900" target="_blank" rel="noreferrer">
<span>pricing page</span>
</a>
</Link>{' '}
@@ -150,7 +150,12 @@ const Subscription: FC<Props> = ({ showProjectName = false }) => {
<div className="px-6 pb-4 mt-2">
<p className="text-sm text-scale-1100">
See our{' '}
<a href="https://supabase.com/pricing" target="_blank" className="underline">
<a
href="https://supabase.com/pricing"
target="_blank"
rel="noreferrer"
className="underline"
>
pricing
</a>{' '}
for a more detailed analysis of what Supabase has on offer.
@@ -52,7 +52,7 @@ const TeamUpgrade: FC<Props> = ({
// Team tier is enabled when the flag is turned on OR the user is already on the team tier (manually assigned by us)
const userIsOnTeamTier =
currentSubscription?.tier?.supabase_prod_id === PRICING_TIER_PRODUCT_IDS.TEAM
const teamTierEnabled = userIsOnTeamTier || useFlag('teamTier')
const teamTierEnabled = useFlag('teamTier') || userIsOnTeamTier
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
const captchaRef = useRef<HCaptcha>(null)
@@ -451,15 +451,14 @@ const CreateFunction: FC<CreateFunctionProps> = ({ func, visible, setVisible })
setIsClosingPanel(false)
setVisible(!visible)
}}
children={
<Modal.Content>
<p className="py-4 text-sm text-scale-1100">
There are unsaved changes. Are you sure you want to close the panel? Your changes
will be lost.
</p>
</Modal.Content>
}
/>
>
<Modal.Content>
<p className="py-4 text-sm text-scale-1100">
There are unsaved changes. Are you sure you want to close the panel? Your changes will
be lost.
</p>
</Modal.Content>
</ConfirmationModal>
</SidePanel>
</>
)
@@ -63,7 +63,6 @@ const FunctionsList: FC<Props> = ({
disabled={!canCreateFunctions}
disabledMessage="You need additional permissions to create functions"
>
<AlphaPreview />
<p className="text-sm text-scale-1100">
PostgreSQL functions, also known as stored procedures, is a set of SQL and procedural
commands such as declarations, assignments, loops, flow-of-control, etc.
@@ -1,6 +1,6 @@
import Image from 'next/image'
import { useState, useRef, useEffect } from 'react'
import { PostgresTrigger } from '@supabase/postgres-meta'
import { useState, useRef, useEffect, MutableRefObject } from 'react'
import { PostgresTable, PostgresTrigger } from '@supabase/postgres-meta'
import { Button, SidePanel, Form, Input, Listbox, Checkbox, Radio, Badge, Modal } from 'ui'
import { useStore } from 'hooks'
@@ -13,7 +13,10 @@ import ConfirmationModal from 'components/ui/ConfirmationModal'
import { FormSection, FormSectionLabel, FormSectionContent } from 'components/ui/Forms'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query'
import {
EdgeFunctionsResponse,
useEdgeFunctionsQuery,
} from 'data/edge-functions/edge-functions-query'
import { useDatabaseTriggerCreateMutation } from 'data/database-triggers/database-trigger-create-mutation'
import { useDatabaseTriggerUpdateMutation } from 'data/database-triggers/database-trigger-update-transaction-mutation'
@@ -29,7 +32,7 @@ const EditHookPanel = ({ visible, selectedHook, onClose }: EditHookPanelProps) =
// [Joshen] Need to change to use RQ once Alaister's PR goes in
const { ref } = useParams()
const { meta, ui } = useStore()
const submitRef: any = useRef()
const submitRef = useRef<any>(null)
const [isEdited, setIsEdited] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [isClosingPanel, setIsClosingPanel] = useState(false)
@@ -278,181 +281,26 @@ const EditHookPanel = ({ visible, selectedHook, onClose }: EditHookPanelProps) =
>
<Form validateOnBlur initialValues={initialValues} onSubmit={onSubmit} validate={validate}>
{({ values, resetForm, errors }: any) => {
useEffect(() => {
if (values.function_type === 'http_request') {
if (selectedHook !== undefined) {
const [url, method] = selectedHook.function_args
const updatedValues = { ...values, http_url: url, http_method: method }
resetForm({ values: updatedValues, initialValues: updatedValues })
} else {
const updatedValues = { ...values, http_url: '' }
resetForm({ values: updatedValues, initialValues: updatedValues })
}
} else if (values.function_type === 'supabase_function') {
const fnSlug = (functions ?? [])[0]?.slug
const defaultFunctionUrl = `https://${ref}.functions.supabase.${restUrlTld}/${fnSlug}`
const updatedValues = {
...values,
http_url: isEdgeFunction(values.http_url) ? values.http_url : defaultFunctionUrl,
}
resetForm({ values: updatedValues, initialValues: updatedValues })
}
}, [values.function_type])
return (
<div>
<FormSection
header={<FormSectionLabel className="lg:!col-span-4">General</FormSectionLabel>}
>
<FormSectionContent loading={false} className="lg:!col-span-8">
<Input
id="name"
name="name"
label="Name"
descriptionText="Do not use spaces/whitespaces"
/>
</FormSectionContent>
</FormSection>
<SidePanel.Separator />
<FormSection
header={
<FormSectionLabel
className="lg:!col-span-4"
description={
<p className="text-sm text-scale-1000">
Select which table and events will trigger your webhook
</p>
}
>
Conditions to fire webhook
</FormSectionLabel>
}
>
<FormSectionContent loading={false} className="lg:!col-span-8">
<Listbox
size="medium"
id="table_id"
name="table_id"
label="Table"
descriptionText="This is the table the trigger will watch for changes. You can only select 1 table for a trigger."
>
<Listbox.Option
key={'table-no-selection'}
id={'table-no-selection'}
label={'---'}
value={'no-selection'}
>
---
</Listbox.Option>
{tables.map((table) => (
<Listbox.Option
key={table.id}
id={table.id.toString()}
value={table.id}
label={table.name}
>
<div className="flex items-center space-x-2">
<p className="text-scale-1000">{table.schema}</p>
<p className="text-scale-1200">{table.name}</p>
</div>
</Listbox.Option>
))}
</Listbox>
<Checkbox.Group
id="events"
name="events"
label="Events"
error={eventsError}
descriptionText="These are the events that are watched by the webhook, only the events selected above will fire the webhook on the table you've selected."
>
{HOOK_EVENTS.map((event) => (
<Checkbox
key={event.value}
value={event.value}
label={event.label}
description={event.description}
checked={events.includes(event.value)}
onChange={() => onUpdateSelectedEvents(event.value)}
/>
))}
</Checkbox.Group>
</FormSectionContent>
</FormSection>
<SidePanel.Separator />
<FormSection
header={
<FormSectionLabel className="lg:!col-span-4">
Webhook configuration
</FormSectionLabel>
}
>
<FormSectionContent loading={false} className="lg:!col-span-8">
<Radio.Group
id="function_type"
name="function_type"
label="Type of webhook"
type="cards"
>
{AVAILABLE_WEBHOOK_TYPES.map((webhook) => (
<Radio
key={webhook.value}
id={webhook.value}
value={webhook.value}
label=""
beforeLabel={
<div className="flex items-center space-x-5">
<Image src={webhook.icon} layout="fixed" width="32" height="32" />
<div className="flex-col space-y-0">
<div className="flex space-x-2">
<p className="text-scale-1200">{webhook.label}</p>
</div>
<p className="text-scale-1000">{webhook.description}</p>
</div>
</div>
}
/>
))}
</Radio.Group>
</FormSectionContent>
</FormSection>
<SidePanel.Separator />
<HTTPRequestFields
type={values.function_type}
errors={errors}
httpHeaders={httpHeaders}
httpParameters={httpParameters}
onAddHeader={(header?: any) => {
if (header) setHttpHeaders(httpHeaders.concat(header))
else setHttpHeaders(httpHeaders.concat({ id: uuidv4(), name: '', value: '' }))
}}
onUpdateHeader={(idx, property, value) =>
setHttpHeaders(
httpHeaders.map((header, i) => {
if (idx === i) return { ...header, [property]: value }
else return header
})
)
}
onRemoveHeader={(idx) => setHttpHeaders(httpHeaders.filter((_, i) => idx !== i))}
onAddParameter={() =>
setHttpParameters(httpParameters.concat({ id: uuidv4(), name: '', value: '' }))
}
onUpdateParameter={(idx, property, value) =>
setHttpParameters(
httpParameters.map((param, i) => {
if (idx === i) return { ...param, [property]: value }
else return param
})
)
}
onRemoveParameter={(idx) =>
setHttpParameters(httpParameters.filter((_, i) => idx !== i))
}
/>
<button ref={submitRef} type="submit" className="hidden" />
</div>
<FormContents
values={values}
resetForm={resetForm}
errors={errors}
projectRef={ref}
restUrlTld={restUrlTld}
functions={functions}
isEdgeFunction={isEdgeFunction}
tables={tables}
events={events}
eventsError={eventsError}
onUpdateSelectedEvents={onUpdateSelectedEvents}
httpHeaders={httpHeaders}
httpParameters={httpParameters}
setHttpHeaders={setHttpHeaders}
setHttpParameters={setHttpParameters}
submitRef={submitRef}
selectedHook={selectedHook}
/>
)
}}
</Form>
@@ -467,17 +315,222 @@ const EditHookPanel = ({ visible, selectedHook, onClose }: EditHookPanelProps) =
setIsEdited(false)
onClose()
}}
children={
<Modal.Content>
<p className="py-4 text-sm text-scale-1100">
There are unsaved changes. Are you sure you want to close the panel? Your changes will
be lost.
</p>
</Modal.Content>
}
/>
>
<Modal.Content>
<p className="py-4 text-sm text-scale-1100">
There are unsaved changes. Are you sure you want to close the panel? Your changes will
be lost.
</p>
</Modal.Content>
</ConfirmationModal>
</>
)
}
export default EditHookPanel
interface FormContentsProps {
values: any
resetForm: any
errors: any
projectRef?: string
restUrlTld?: string
selectedHook?: PostgresTrigger
functions: EdgeFunctionsResponse[] | undefined
isEdgeFunction: (url: string) => boolean
tables: PostgresTable[]
events: string[]
eventsError?: string
onUpdateSelectedEvents: (event: string) => void
httpHeaders: HTTPArgument[]
httpParameters: HTTPArgument[]
setHttpHeaders: (arr: HTTPArgument[]) => void
setHttpParameters: (arr: HTTPArgument[]) => void
submitRef: MutableRefObject<any>
}
const FormContents = ({
values,
resetForm,
errors,
projectRef,
restUrlTld,
selectedHook,
functions,
isEdgeFunction,
tables,
events,
eventsError,
onUpdateSelectedEvents,
httpHeaders,
httpParameters,
setHttpHeaders,
setHttpParameters,
submitRef,
}: FormContentsProps) => {
useEffect(() => {
if (values.function_type === 'http_request') {
if (selectedHook !== undefined) {
const [url, method] = selectedHook.function_args
const updatedValues = { ...values, http_url: url, http_method: method }
resetForm({ values: updatedValues, initialValues: updatedValues })
} else {
const updatedValues = { ...values, http_url: '' }
resetForm({ values: updatedValues, initialValues: updatedValues })
}
} else if (values.function_type === 'supabase_function') {
const fnSlug = (functions ?? [])[0]?.slug
const defaultFunctionUrl = `https://${projectRef}.functions.supabase.${restUrlTld}/${fnSlug}`
const updatedValues = {
...values,
http_url: isEdgeFunction(values.http_url) ? values.http_url : defaultFunctionUrl,
}
resetForm({ values: updatedValues, initialValues: updatedValues })
}
}, [values.function_type])
return (
<div>
<FormSection header={<FormSectionLabel className="lg:!col-span-4">General</FormSectionLabel>}>
<FormSectionContent loading={false} className="lg:!col-span-8">
<Input
id="name"
name="name"
label="Name"
descriptionText="Do not use spaces/whitespaces"
/>
</FormSectionContent>
</FormSection>
<SidePanel.Separator />
<FormSection
header={
<FormSectionLabel
className="lg:!col-span-4"
description={
<p className="text-sm text-scale-1000">
Select which table and events will trigger your webhook
</p>
}
>
Conditions to fire webhook
</FormSectionLabel>
}
>
<FormSectionContent loading={false} className="lg:!col-span-8">
<Listbox
size="medium"
id="table_id"
name="table_id"
label="Table"
descriptionText="This is the table the trigger will watch for changes. You can only select 1 table for a trigger."
>
<Listbox.Option
key={'table-no-selection'}
id={'table-no-selection'}
label={'---'}
value={'no-selection'}
>
---
</Listbox.Option>
{tables.map((table) => (
<Listbox.Option
key={table.id}
id={table.id.toString()}
value={table.id}
label={table.name}
>
<div className="flex items-center space-x-2">
<p className="text-scale-1000">{table.schema}</p>
<p className="text-scale-1200">{table.name}</p>
</div>
</Listbox.Option>
))}
</Listbox>
<Checkbox.Group
id="events"
name="events"
label="Events"
error={eventsError}
descriptionText="These are the events that are watched by the webhook, only the events selected above will fire the webhook on the table you've selected."
>
{HOOK_EVENTS.map((event) => (
<Checkbox
key={event.value}
value={event.value}
label={event.label}
description={event.description}
checked={events.includes(event.value)}
onChange={() => onUpdateSelectedEvents(event.value)}
/>
))}
</Checkbox.Group>
</FormSectionContent>
</FormSection>
<SidePanel.Separator />
<FormSection
header={
<FormSectionLabel className="lg:!col-span-4">Webhook configuration</FormSectionLabel>
}
>
<FormSectionContent loading={false} className="lg:!col-span-8">
<Radio.Group id="function_type" name="function_type" label="Type of webhook" type="cards">
{AVAILABLE_WEBHOOK_TYPES.map((webhook) => (
<Radio
key={webhook.value}
id={webhook.value}
value={webhook.value}
label=""
beforeLabel={
<div className="flex items-center space-x-5">
<Image src={webhook.icon} layout="fixed" width="32" height="32" />
<div className="flex-col space-y-0">
<div className="flex space-x-2">
<p className="text-scale-1200">{webhook.label}</p>
</div>
<p className="text-scale-1000">{webhook.description}</p>
</div>
</div>
}
/>
))}
</Radio.Group>
</FormSectionContent>
</FormSection>
<SidePanel.Separator />
<HTTPRequestFields
type={values.function_type}
errors={errors}
httpHeaders={httpHeaders}
httpParameters={httpParameters}
onAddHeader={(header?: any) => {
if (header) setHttpHeaders(httpHeaders.concat(header))
else setHttpHeaders(httpHeaders.concat({ id: uuidv4(), name: '', value: '' }))
}}
onUpdateHeader={(idx, property, value) =>
setHttpHeaders(
httpHeaders.map((header, i) => {
if (idx === i) return { ...header, [property]: value }
else return header
})
)
}
onRemoveHeader={(idx) => setHttpHeaders(httpHeaders.filter((_, i) => idx !== i))}
onAddParameter={() =>
setHttpParameters(httpParameters.concat({ id: uuidv4(), name: '', value: '' }))
}
onUpdateParameter={(idx, property, value) =>
setHttpParameters(
httpParameters.map((param, i) => {
if (idx === i) return { ...param, [property]: value }
else return param
})
)
}
onRemoveParameter={(idx) => setHttpParameters(httpParameters.filter((_, i) => idx !== i))}
/>
<button ref={submitRef} type="submit" className="hidden" />
</div>
)
}
@@ -1,4 +1,4 @@
import { BASE_PATH } from 'lib/constants'
import { BASE_PATH, IS_PLATFORM } from 'lib/constants'
export const ENABLED_MODE_OPTIONS = [
{ label: 'Origin', value: 'ORIGIN', description: 'This is the default behaviour' },
@@ -40,10 +40,14 @@ export const AVAILABLE_WEBHOOK_TYPES = [
label: 'HTTP Request',
description: 'Send an HTTP request to any URL.',
},
{
value: 'supabase_function',
icon: `${BASE_PATH}/img/function-providers/supabase-severless-function.png`,
label: 'Supabase Edge Functions',
description: 'Choose a Supabase edge function to run.',
},
...(IS_PLATFORM
? [
{
value: 'supabase_function',
icon: `${BASE_PATH}/img/function-providers/supabase-severless-function.png`,
label: 'Supabase Edge Functions',
description: 'Choose a Supabase edge function to run.',
},
]
: []),
]
@@ -93,7 +93,7 @@ const HooksList = ({
/>
<div className="flex items-center space-x-2">
<Link href="https://supabase.com/docs/guides/database/webhooks">
<a target="_blank">
<a target="_blank" rel="noreferrer">
<Button type="default" icon={<IconExternalLink strokeWidth={1.5} />}>
Documentation
</Button>
@@ -1,4 +1,4 @@
import { FC, createContext, useContext, useState } from 'react'
import { createContext, useContext, useState } from 'react'
import { useRouter } from 'next/router'
import { observer, useLocalObservable } from 'mobx-react-lite'
import { uniqBy, map as lodashMap } from 'lodash'
@@ -24,19 +24,19 @@ class ChooseFunctionFormStore implements IChooseFunctionFormStore {
const ChooseFunctionFormContext = createContext<IChooseFunctionFormStore | null>(null)
type ChooseFunctionFormProps = {
export interface ChooseFunctionFormProps {
triggerFunctions: Dictionary<any>[]
visible: boolean
onChange: (id: number) => void
setVisible: (value: boolean) => void
}
const ChooseFunctionForm: FC<ChooseFunctionFormProps> = ({
const ChooseFunctionForm = ({
triggerFunctions,
visible,
onChange,
setVisible,
}) => {
}: ChooseFunctionFormProps) => {
const _localState = useLocalObservable(() => new ChooseFunctionFormStore())
_localState.triggerFunctions = triggerFunctions as any
@@ -55,25 +55,27 @@ const ChooseFunctionForm: FC<ChooseFunctionFormProps> = ({
onCancel={() => setVisible(!visible)}
className="hooks-sidepanel"
>
{hasPublicSchemaFunctions ? (
<ChooseFunctionFormContext.Provider value={_localState}>
<div className="space-y-6">
<NoticeBox />
{_localState.functionSchemas.map((schema: string) => (
<SchemaFunctionGroup key={schema} schema={schema} selectFunction={selectFunction} />
))}
</div>
</ChooseFunctionFormContext.Provider>
) : (
<NoFunctionsState />
)}
<div className="py-6">
{hasPublicSchemaFunctions ? (
<ChooseFunctionFormContext.Provider value={_localState}>
<div className="space-y-6">
<NoticeBox />
{_localState.functionSchemas.map((schema: string) => (
<SchemaFunctionGroup key={schema} schema={schema} selectFunction={selectFunction} />
))}
</div>
</ChooseFunctionFormContext.Provider>
) : (
<NoFunctionsState />
)}
</div>
</SidePanel>
)
}
export default observer(ChooseFunctionForm)
const NoticeBox: FC = ({}) => {
const NoticeBox = ({}) => {
const router = useRouter()
const { ref } = router.query
return (
@@ -84,7 +86,7 @@ const NoticeBox: FC = ({}) => {
description={`You can make functions by using the Database Functions`}
button={
<Button
type="secondary"
type="default"
onClick={() => {
router.push(`/project/${ref}/database/functions`)
}}
@@ -97,7 +99,7 @@ const NoticeBox: FC = ({}) => {
)
}
const NoFunctionsState: FC = ({}) => {
const NoFunctionsState = ({}) => {
// for the empty 'no tables' state link
const router = useRouter()
const { ref } = router.query
@@ -117,12 +119,12 @@ const NoFunctionsState: FC = ({}) => {
)
}
type SchemaFunctionGroupProps = {
export interface SchemaFunctionGroupProps {
schema: string
selectFunction: (id: number) => void
}
const SchemaFunctionGroup: FC<SchemaFunctionGroupProps> = observer(({ schema, selectFunction }) => {
const SchemaFunctionGroup = observer(({ schema, selectFunction }: SchemaFunctionGroupProps) => {
const _pageState = useContext(ChooseFunctionFormContext)
const _functions = _pageState!.triggerFunctions.filter((x) => x.schema == schema)
return (
@@ -146,14 +148,14 @@ const SchemaFunctionGroup: FC<SchemaFunctionGroupProps> = observer(({ schema, se
)
})
type FunctionProps = {
export interface FunctionProps {
id: number
completeStatement: string
name: string
onClick: (id: number) => void
}
const Function: FC<FunctionProps> = ({ id, completeStatement, name, onClick }) => {
const Function = ({ id, completeStatement, name, onClick }: FunctionProps) => {
const [visible, setVisible] = useState(false)
return (
<div
@@ -165,7 +167,7 @@ const Function: FC<FunctionProps> = ({ id, completeStatement, name, onClick }) =
<div className="flex items-center justify-center rounded bg-scale-1200 p-1 text-scale-100 ">
<IconTerminal strokeWidth={2} size={14} />
</div>
<h5 className="mb-0">{name}</h5>
<p className="mb-0 text-sm">{name}</p>
</div>
<Button
type="text"
@@ -174,7 +176,7 @@ const Function: FC<FunctionProps> = ({ id, completeStatement, name, onClick }) =
setVisible(!visible)
}}
icon={
<IconChevronDown className={visible ? 'rotate-0 transform' : 'rotate-180 transform'} />
<IconChevronDown className={visible ? 'rotate-180 transform' : 'rotate-0 transform'} />
}
>
{visible ? 'Hide definition' : 'View definition'}
@@ -24,10 +24,10 @@ const SchemaTable: FC<Props> = ({ filterString, schema, editTrigger, deleteTrigg
className="table-fixed px-6"
head={
<>
<Table.th key="name" className="space-x-4">
<Table.th key="name" className="w-[25%] space-x-4">
Name
</Table.th>
<Table.th key="table" className="hidden lg:table-cell">
<Table.th key="table" className="hidden w-[13%] lg:table-cell">
Table
</Table.th>
<Table.th key="function" className="hidden xl:table-cell">
@@ -37,16 +37,16 @@ const TriggerList: FC<Props> = ({ filterString, schema, editTrigger, deleteTrigg
<>
{_triggers.map((x: any) => (
<Table.tr key={x.id}>
<Table.td className="space-x-2 break-all">
<p>{x.name}</p>
<Table.td className="space-x-2">
<p title={x.name} className="truncate">
{x.name}
</p>
</Table.td>
<Table.td className="hidden lg:table-cell break-all">
<p>{x.table}</p>
<p title={x.table}>{x.table}</p>
</Table.td>
<Table.td className="hidden space-x-2 xl:table-cell">
<div className="flex flex-col">
<p>{x.function_name}</p>
</div>
<p title={x.function_name}>{x.function_name}</p>
</Table.td>
<Table.td className="hidden xl:table-cell">
<div className="flex space-x-2">
@@ -143,7 +143,7 @@ const CreateWrapper = () => {
<h3 className="mb-2 text-xl text-scale-1200">Create a {wrapperMeta?.label} Wrapper</h3>
<div className="flex items-center space-x-2">
<Link href="https://supabase.github.io/wrappers/stripe/">
<a target="_blank">
<a target="_blank" rel="noreferrer">
<Button type="default" icon={<IconExternalLink strokeWidth={1.5} />}>
Documentation
</Button>
@@ -229,7 +229,10 @@ const CreateWrapper = () => {
) : (
<div className="space-y-2">
{newTables.map((table, i) => (
<div className="flex items-center justify-between px-4 py-2 border rounded-md border-scale-600">
<div
key={`${table.schema_name}.${table.table_name}`}
className="flex items-center justify-between px-4 py-2 border rounded-md border-scale-600"
>
<div>
<p className="text-sm">
{table.schema_name}.{table.table_name}
Loaded 100 of 211 files, more files were not shown because too many files have changed in this diff. Show more