Merge branch 'master' into feature/assistant-mcp

This commit is contained in:
Saxon Fletcher committed 2025-05-02 09:35:13 +10:00
commit 3bd9880282
87 files changed
+2336 -871

No files matched your search

@@ -0,0 +1,12 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`/api/graphql schema snapshot > should match snapshot 1`] = `
"schema {
query: RootQueryType
}
type RootQueryType {
"""Get the GraphQL schema for this endpoint"""
schema: String!
}"
`;
+84
View File
@@ -0,0 +1,84 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { POST } from './route'
describe('/api/graphql basic error statuses', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(console, 'error').mockImplementation(() => {})
})
it('should return error if request body is not valid JSON', async () => {
const request = new Request('http://localhost/api/graphql', {
method: 'POST',
body: 'not json',
})
const response = await POST(request)
const json = await response.json()
expect(json.errors[0].message).toBe('Invalid request: Request body must be valid JSON')
})
it('should return error if request body is missing required fields', async () => {
const request = new Request('http://localhost/api/graphql', {
method: 'POST',
body: JSON.stringify({ variables: {} }),
})
const response = await POST(request)
const json = await response.json()
expect(json.errors[0].message).toContain(
'Invalid request: Request body must be valid GraphQL request object'
)
})
it('should return error if query is not a string', async () => {
const request = new Request('http://localhost/api/graphql', {
method: 'POST',
body: JSON.stringify({ query: 123 }),
})
const response = await POST(request)
const json = await response.json()
expect(json.errors[0].message).toContain(
'Invalid request: Request body must be valid GraphQL request object'
)
})
it('should return error for internal server errors', async () => {
const request = new Request('http://localhost/api/graphql', {
method: 'POST',
body: JSON.stringify({ query: 'query { hello }' }),
})
vi.spyOn(request, 'json').mockImplementation(() => {
throw new Error('Unexpected error')
})
const response = await POST(request)
const json = await response.json()
expect(json.errors[0].message).toBe('Internal Server Error')
})
})
describe('/api/graphql schema snapshot', () => {
it('should match snapshot', async () => {
const schemaQuery = `
query {
schema
}
`
const request = new Request('http://localhost/api/graphql', {
method: 'POST',
body: JSON.stringify({ query: schemaQuery }),
})
const response = await POST(request)
const json = await response.json()
expect(json.errors).toBeUndefined()
const {
data: { schema },
} = json
expect(schema).toMatchSnapshot()
})
})
+105
View File
@@ -0,0 +1,105 @@
import { type DocumentNode, graphql, GraphQLError, parse, specifiedRules, validate } from 'graphql'
import { createComplexityLimitRule } from 'graphql-validation-complexity'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { ApiError, convertZodToInvalidRequestError, InvalidRequestError } from '~/app/api/utils'
import { BASE_PATH, IS_DEV } from '~/lib/constants'
import { rootGraphQLSchema } from '~/resources/rootSchema'
import { createQueryDepthLimiter } from './validators'
export const runtime = 'edge'
const MAX_DEPTH = 9
const validationRules = [
...specifiedRules,
createQueryDepthLimiter(MAX_DEPTH),
createComplexityLimitRule(1500, {
scalarCost: 1,
objectCost: 2,
listFactor: 10,
}),
]
function isDevGraphiQL(request: Request) {
const origin = request.headers.get('Origin')
const referrer = request.headers.get('Referer')
return (
IS_DEV &&
origin.startsWith('http://localhost') &&
referrer === `${origin}${BASE_PATH ?? ''}/graphiql`
)
}
const graphQLRequestSchema = z.object({
query: z.string(),
variables: z.record(z.any()).optional(),
operationName: z.string().optional(),
})
async function handleGraphQLRequest(request: Request): Promise<NextResponse> {
const body = await request.json().catch((error) => {
throw new InvalidRequestError('Request body must be valid JSON', error)
})
const parsedBody = graphQLRequestSchema.safeParse(body)
if (!parsedBody.success) {
throw convertZodToInvalidRequestError(
parsedBody.error,
'Request body must be valid GraphQL request object'
)
}
const { query, variables, operationName } = parsedBody.data
const validationErrors = validateGraphQLRequest(query, isDevGraphiQL(request))
if (validationErrors.length > 0) {
return NextResponse.json({
errors: validationErrors.map((error) => ({
message: error.message,
locations: error.locations,
path: error.path,
})),
})
}
const result = await graphql({
schema: rootGraphQLSchema,
contextValue: { request },
source: query,
variableValues: variables,
operationName,
})
return NextResponse.json(result)
}
function validateGraphQLRequest(query: string, isDevGraphiQL = false): ReadonlyArray<GraphQLError> {
let documentAST: DocumentNode
try {
documentAST = parse(query)
} catch (error: unknown) {
if (error instanceof GraphQLError) {
return [error]
} else {
throw error
}
}
const rules = isDevGraphiQL ? specifiedRules : validationRules
return validate(rootGraphQLSchema, documentAST, rules)
}
export async function POST(request: Request): Promise<NextResponse> {
try {
return await handleGraphQLRequest(request)
} catch (error: unknown) {
console.error(error)
if (error instanceof ApiError) {
return NextResponse.json({
errors: [{ message: error.isPrivate() ? 'Internal Server Error' : error.message }],
})
} else {
return NextResponse.json({
errors: [{ message: 'Internal Server Error' }],
})
}
}
}
@@ -0,0 +1,247 @@
import { buildSchema, parse, validate } from 'graphql'
import { describe, expect, test } from 'vitest'
import { createQueryDepthLimiter } from './validators'
describe('createQueryDepthLimiter', () => {
// Create a test schema
const schema = buildSchema(`
type Post {
id: ID!
title: String
content: String
author: User
comments: [Comment]
}
type User {
id: ID!
name: String
email: String
posts: [Post]
friends: [User]
}
type Comment {
id: ID!
text: String
author: User
post: Post
replies: [Comment]
}
type Query {
post(id: ID!): Post
user(id: ID!): User
posts: [Post]
users: [User]
}
`)
test('should not report error for queries under max depth', () => {
const query = `
query {
posts {
title
author {
name
}
}
}
`
const queryAST = parse(query)
const maxDepth = 3
const errors = validate(schema, queryAST, [createQueryDepthLimiter(maxDepth)])
expect(errors).toHaveLength(0)
})
test('should report error for queries exceeding max depth', () => {
const query = `
query {
posts {
title
author {
name
posts {
title
}
}
}
}
`
const queryAST = parse(query)
const maxDepth = 3
const errors = validate(schema, queryAST, [createQueryDepthLimiter(maxDepth)])
expect(errors.length).toBeGreaterThan(0)
expect(errors[0].message).toContain('Query exceeds maximum depth of 3')
})
test('should ignore __typename fields when calculating depth', () => {
const query = `
query {
posts {
__typename
title
author {
__typename
name
posts {
__typename
}
}
}
}
`
const queryAST = parse(query)
const maxDepth = 3
const errors = validate(schema, queryAST, [createQueryDepthLimiter(maxDepth)])
expect(errors).toHaveLength(0)
})
test('should handle fragments correctly', () => {
const query = `
fragment UserDetails on User {
id
name
posts {
title
}
}
query {
users {
...UserDetails
}
}
`
const queryAST = parse(query)
const maxDepth = 3
const errors = validate(schema, queryAST, [createQueryDepthLimiter(maxDepth)])
expect(errors).toHaveLength(0)
})
test('should report error for fragments exceeding max depth', () => {
const query = `
fragment UserDetails on User {
id
name
posts {
title
author {
name
}
}
}
query {
users {
...UserDetails
}
}
`
const queryAST = parse(query)
const maxDepth = 3
const errors = validate(schema, queryAST, [createQueryDepthLimiter(maxDepth)])
expect(errors.length).toBeGreaterThan(0)
expect(errors[0].message).toContain(
'Fragment spread "UserDetails" causes query to exceed maximum depth'
)
})
test('should report error for fragment spreads causing excessive depth', () => {
const query = `
fragment PostDetails on Post {
title
content
}
fragment UserDetails on User {
name
posts {
...PostDetails
}
}
query {
users {
friends {
...UserDetails
}
}
}
`
const queryAST = parse(query)
const maxDepth = 3
const errors = validate(schema, queryAST, [createQueryDepthLimiter(maxDepth)])
expect(errors.length).toBeGreaterThan(0)
})
test('should handle nested fragments correctly', () => {
const query = `
fragment CommentDetails on Comment {
text
author {
name
}
}
fragment PostDetails on Post {
title
comments {
...CommentDetails
}
}
query {
posts {
...PostDetails
}
}
`
const queryAST = parse(query)
const maxDepth = 3
const errors = validate(schema, queryAST, [createQueryDepthLimiter(maxDepth)])
expect(errors.length).toBeGreaterThan(0)
})
test('should handle inline fragments', () => {
const query = `
query {
posts {
... on Post {
title
author {
name
}
}
}
}
`
const queryAST = parse(query)
const maxDepth = 3
const errors = validate(schema, queryAST, [createQueryDepthLimiter(maxDepth)])
expect(errors).toHaveLength(0)
})
test('should detect circular fragment references', () => {
const query = `
fragment UserPosts on User {
posts {
author {
...UserPosts
}
}
}
query {
users {
...UserPosts
}
}
`
const queryAST = parse(query)
const maxDepth = 5
// This should not go into infinite recursion
const errors = validate(schema, queryAST, [createQueryDepthLimiter(maxDepth)])
expect(errors.length).toBeGreaterThan(0)
})
})
+157
View File
@@ -0,0 +1,157 @@
import {
type FragmentDefinitionNode,
GraphQLError,
type SelectionNode,
type ValidationContext,
type ValidationRule,
} from 'graphql'
/**
* Creates a validator that limits the depth of a GraphQL query.
*
* @param maxDepth The maximum allowed depth
* @returns A validation rule that enforces depth limits
*/
export function createQueryDepthLimiter(maxDepth: number): ValidationRule {
const fragmentDepthMap = new Map<string, number>()
return function limitQueryDepth(context) {
return {
Document: {
leave() {
fragmentDepthMap.clear()
},
},
Field: {
enter(node, _key, _parent, path, ancestors) {
// Skip __typename and introspection fields
if (node.name.value === '__typename' || node.name.value.startsWith('__')) {
return
}
// Calculate the depth by counting the ancestors that are field nodes
const fieldAncestors = ancestors.filter(
(ancestor) => ancestor && 'kind' in ancestor && ancestor.kind === 'Field'
)
const depth = fieldAncestors.length + 1
// Check if this field exceeds the maximum maxDepth
if (depth > maxDepth) {
const pathStr = path.join('.')
context.reportError(
new GraphQLError(
`Query exceeds maximum depth of ${maxDepth}. Got depth ${depth} for ${pathStr}.`,
{ nodes: node }
)
)
}
},
},
FragmentDefinition: {
enter(node) {
calculateFragmentDepth(node, context, 0, fragmentDepthMap, maxDepth)
const depth = fragmentDepthMap.get(node.name.value)!
if (depth > maxDepth) {
context.reportError(
new GraphQLError(
`Fragment "${node.name.value}" exceeds maximum depth of ${maxDepth}.`,
{ nodes: node }
)
)
}
},
},
FragmentSpread: {
enter(node, _key, _parent, _path, ancestors) {
const fragmentName = node.name.value
const fragmentDepth = fragmentDepthMap.get(fragmentName) || 0
const fieldAncestors = ancestors.filter(
(ancestor) => ancestor && 'kind' in ancestor && ancestor.kind === 'Field'
)
const currentDepth = fieldAncestors.length
const totalDepth = currentDepth + fragmentDepth
if (totalDepth > maxDepth) {
context.reportError(
new GraphQLError(
`Fragment spread "${fragmentName}" causes query to exceed maximum depth of ${maxDepth}.`,
{ nodes: node }
)
)
}
},
},
}
}
}
/**
* Helper function to calculate the depth of a fragment
*/
function calculateFragmentDepth(
node: FragmentDefinitionNode,
context: ValidationContext,
currentDepth: number,
visitedFragments: Map<string, number>,
maxDepth: number
) {
const fragmentName = node.name.value
if (visitedFragments.has(fragmentName)) {
return
}
let maxFragmentDepth = 0
// Process all selections in the fragment
function processSelections(selections: ReadonlyArray<SelectionNode>, depth: number) {
for (const selection of selections) {
if (selection.kind === 'Field') {
// Skip __typename and introspection fields
if (selection.name.value === '__typename' || selection.name.value.startsWith('__')) {
continue
}
const newDepth = depth + 1
if (newDepth > maxFragmentDepth) {
maxFragmentDepth = newDepth
}
if (maxFragmentDepth > maxDepth) {
// Do not recurse anymore because we might go on indefinitely
continue
}
// If there are nested selections, process them
if (selection.selectionSet) {
processSelections(selection.selectionSet.selections, newDepth)
}
} else if (selection.kind === 'FragmentSpread') {
// Process fragment spreads
const spreadName = selection.name.value
const fragment = context.getFragment(spreadName)
if (!fragment) continue
const fragmentName = fragment.name.value
if (!visitedFragments.has(fragmentName)) {
calculateFragmentDepth(fragment, context, depth, visitedFragments, maxDepth)
}
const fragmentDepth = visitedFragments.get(fragmentName)!
const totalDepth = fragmentDepth + depth
if (totalDepth > maxFragmentDepth) {
maxFragmentDepth = totalDepth
}
} else if (selection.kind === 'InlineFragment' && selection.selectionSet) {
// Process inline fragments
processSelections(selection.selectionSet.selections, depth)
}
}
}
if (node.selectionSet) {
processSelections(node.selectionSet.selections, currentDepth)
}
visitedFragments.set(fragmentName, maxFragmentDepth)
}
+40
View File
@@ -0,0 +1,40 @@
import { z, type ZodError } from 'zod'
export class ApiError extends Error {
constructor(message: string) {
super(message)
}
isPrivate() {
return true
}
statusCode() {
return 500
}
}
export class InvalidRequestError extends ApiError {
constructor(message: string, source?: unknown) {
super(`Invalid request: ${message}`)
}
isPrivate() {
return false
}
statusCode() {
return 400
}
}
export function convertZodToInvalidRequestError(
error: ZodError,
prelude?: string
): InvalidRequestError {
const issue = error.issues[0]
const pathStr = issue.path.join('.')
const message = `${prelude ? `${prelude}: ` : ''}${issue.message} at key "${pathStr}"`
return new InvalidRequestError(message, error)
}
+33
View File
@@ -0,0 +1,33 @@
'use client'
import dynamic from 'next/dynamic'
import { notFound } from 'next/navigation'
import { type ComponentProps } from 'react'
import { IS_DEV } from '~/lib/constants'
const LazyGraphiQL = dynamic(
async () => {
const { GraphiQL: GraphiQLPrimitive } = await import('graphiql')
const { createGraphiQLFetcher } = await import('@graphiql/toolkit')
await import('graphiql/graphiql.css')
return function GraphiQL(props: Omit<ComponentProps<typeof GraphiQLPrimitive>, 'fetcher'>) {
const fetcher = createGraphiQLFetcher({
url: 'http://localhost:3001/docs/api/graphql',
})
return <GraphiQLPrimitive fetcher={fetcher} {...props} />
}
},
{
loading: () => <div className="p-6">Loading GraphiQL...</div>,
ssr: false,
}
)
export default function GraphiQLPage() {
if (!IS_DEV) {
notFound()
}
return <LazyGraphiQL className="!h-[calc(100vh-var(--header-height))]" />
}
@@ -4,29 +4,97 @@ import { readFile } from 'node:fs/promises'
import { join, relative } from 'node:path'
import rehypeSlug from 'rehype-slug'
import emoji from 'remark-emoji'
import { GuideTemplate, newEditLink } from '~/features/docs/GuidesMdx.template'
import {
genGuideMeta,
genGuidesStaticParams,
removeRedundantH1,
} from '~/features/docs/GuidesMdx.utils'
import { GuideTemplate, newEditLink } from '~/features/docs/GuidesMdx.template'
import { REVALIDATION_TAGS } from '~/features/helpers.fetch'
import { GUIDES_DIRECTORY, isValidGuideFrontmatter } from '~/lib/docs'
import { UrlTransformFunction, linkTransform } from '~/lib/mdx/plugins/rehypeLinkTransform'
import remarkMkDocsAdmonition from '~/lib/mdx/plugins/remarkAdmonition'
import { removeTitle } from '~/lib/mdx/plugins/remarkRemoveTitle'
import remarkPyMdownTabs from '~/lib/mdx/plugins/remarkTabs'
import { REVALIDATION_TAGS } from '~/features/helpers.fetch'
import { octokit } from '~/lib/octokit'
export const dynamicParams = false
// We fetch these docs at build time from an external repo
const org = 'supabase'
const repo = 'wrappers'
const branch = 'main'
const docsDir = 'docs/catalog'
const externalSite = 'https://supabase.github.io/wrappers'
type TagQueryResponse = {
repository: {
refs: {
nodes:
| {
name: string
}[]
| null
pageInfo: {
hasNextPage: boolean
endCursor: string | null
}
}
}
}
const tagQuery = `
query TagQuery($owner: String!, $name: String!, $after: String) {
repository(owner: $owner, name: $name) {
refs(
refPrefix: "refs/tags/",
orderBy: {
field: TAG_COMMIT_DATE,
direction: DESC
},
first: 5,
after: $after
) {
nodes {
name
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
`
async function getLatestRelease(after: string | null = null) {
try {
const {
repository: {
refs: {
nodes,
pageInfo: { hasNextPage, endCursor },
},
},
} = await octokit().graphql<TagQueryResponse>(tagQuery, {
owner: org,
name: repo,
after,
request: {
fetch: (url: RequestInfo | URL, options?: RequestInit) =>
fetch(url, { ...options, next: { tags: [REVALIDATION_TAGS.WRAPPERS] } }),
},
})
return (
nodes?.find((node) => node?.name?.match(/^docs_v\d+\.\d+\.\d+/))?.name ??
(hasNextPage && endCursor ? await getLatestRelease(endCursor) : null)
)
} catch (error) {
console.error(`Error fetching release tags for wrappers federated pages: ${error}`)
return null
}
}
// Each external docs page is mapped to a local page
const pageMap = [
{
@@ -193,8 +261,14 @@ const getContent = async (params: Params) => {
isExternal = true
let remoteFile: string
;({ remoteFile, meta } = federatedPage)
const repoPath = `${org}/${repo}/${branch}/${docsDir}/${remoteFile}`
editLink = `${org}/${repo}/blob/${branch}/${docsDir}/${remoteFile}`
const tag = await getLatestRelease()
if (!tag) {
throw new Error('No latest release found for federated wrappers pages')
}
const repoPath = `${org}/${repo}/${tag}/${docsDir}/${remoteFile}`
editLink = `${org}/${repo}/blob/${tag}/${docsDir}/${remoteFile}`
const response = await fetch(`https://raw.githubusercontent.com/${repoPath}`, {
next: { tags: [REVALIDATION_TAGS.WRAPPERS] },
@@ -259,4 +333,4 @@ const generateStaticParams = async () => {
const generateMetadata = genGuideMeta(getContent)
export default WrappersDocs
export { generateStaticParams, generateMetadata }
export { generateMetadata, generateStaticParams }
+2 -2
View File
@@ -18,7 +18,7 @@ import { Button, cn } from 'ui'
import { IS_PLATFORM } from '~/lib/constants'
import { useSendFeedbackMutation } from '~/lib/fetch/feedback'
import { useSendTelemetryEvent } from '~/lib/telemetry'
import { getNotionTeam, getSanitizedTabParams } from './Feedback.utils'
import { getLinearTeam, getSanitizedTabParams } from './Feedback.utils'
import { type FeedbackFields, FeedbackModal } from './FeedbackModal'
const FeedbackButton = forwardRef<
@@ -134,7 +134,7 @@ function Feedback({ className }: { className?: string }) {
title,
// @ts-expect-error -- can't click this button without having a state.response
isHelpful: state.response === 'yes',
team: getNotionTeam(pathname),
team: getLinearTeam(pathname),
})
setModalOpen(false)
refocusButton()
+12 -12
View File
@@ -3,8 +3,8 @@ import { pick } from 'lodash'
/**
* Gets the Notion team to send feedback to based on the pathname.
*/
const getNotionTeam = (pathname: string) => {
const DEFAULT_TEAM = 'team-docs'
const getLinearTeam = (pathname: string) => {
const DEFAULT_TEAM = 'Docs'
// Pathname has format `/guides/(team)/**`
const pathParts = pathname.split('/')
@@ -13,26 +13,26 @@ const getNotionTeam = (pathname: string) => {
switch (pathParts[2]) {
case 'database':
return 'team-postgres'
return 'Postgres'
case 'auth':
return 'team-auth'
return 'Auth'
case 'storage':
return 'team-storage'
return 'Storage'
case 'functions':
return 'team-functions'
return 'Functions'
case 'realtime':
return 'team-realtime'
return 'Realtime'
case 'ai':
return 'team-ai'
return 'AI'
case 'local-development':
case 'self-hosting':
case 'deployment':
return 'team-dev-workflows'
return 'Dev Workflows'
case 'integrations':
return 'team-api'
return 'API'
case 'platform':
case 'monitoring-troubleshooting':
return 'team-infra'
return 'Infra'
default:
return DEFAULT_TEAM
}
@@ -51,4 +51,4 @@ const getSanitizedTabParams = () => {
return pick(Object.fromEntries(searchParams.entries()), queryGroups)
}
export { getNotionTeam, getSanitizedTabParams }
export { getLinearTeam, getSanitizedTabParams }
@@ -52,7 +52,7 @@ hideToc: true
<StepHikeCompact.Step step={3}>
<StepHikeCompact.Details title="Declare Supabase Environment Variables">
Rename `.env.local.example` to `.env.local` and populate with [your project's URL and Anon Key](https://supabase.com/dashboard/project/_/settings/api).
Rename `.env.example` to `.env.local` and populate with [your project's URL and Anon Key](https://supabase.com/dashboard/project/_/settings/api).
</StepHikeCompact.Details>
@@ -14,7 +14,7 @@ To support Sign in with Apple, you need to configure the [Apple provider in the
There are three general ways to use Sign in with Apple, depending on the application you're trying to build:
- Sign in on the web or in web-based apps
- Using an OAuth flow initiated by Supabase Auth using the [Sign in with Apple REST API](https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api).
- Using an OAuth flow initiated by Supabase Auth using the [Sign in with Apple REST API](https://developer.apple.com/documentation/signinwithapplerestapi).
- Using [Sign in with Apple JS](https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js) directly in the browser, usually suitable for websites.
- Sign in natively inside iOS, macOS, watchOS or tvOS apps using [Apple's Authentication Services](https://developer.apple.com/documentation/authenticationservices)
@@ -35,7 +35,7 @@ When developing with Expo, you can test Sign in with Apple via the Expo Go app,
Sign in with Apple's OAuth flow is designed for web or browser based sign in methods. It can be used on web-based apps as well as websites, though some users can benefit by using Sign in with Apple JS directly.
Behind the scenes, Supabase Auth uses the [REST APIs](https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api) provided by Apple.
Behind the scenes, Supabase Auth uses the [REST APIs](https://developer.apple.com/documentation/signinwithapplerestapi) provided by Apple.
<$Partial path="create_client_snippet.mdx" />
@@ -8,19 +8,9 @@ description: 'Block queries over a total cost limit'
## Enable the extension
`pg_plan_filter` can be enabled on a per connection basis:
The extension is already enabled by default via `shared_preload_libraries` setting.
{/* prettier-ignore */}
```sql
load 'plan_filter';
```
or for all connections:
{/* prettier-ignore */}
```sql
alter database some_db set session_preload_libraries = 'plan_filter';
```
You can follow the instructions below.
## API
@@ -66,7 +56,6 @@ Now we can choose a `statement_cost_filter` value between the total cost for the
{/* prettier-ignore */}
```sql
load 'plan_filter';
set plan_filter.statement_cost_limit = 50; -- between 2.49 and 135.0
select * from book where id = 1;
@@ -42,6 +42,12 @@ create table "employees" (
</StepHikeCompact.Step>
</StepHikeCompact>
<Admonition type="note">
Make sure your local database is stopped before diffing your schema.
</Admonition>
<StepHikeCompact>
<StepHikeCompact.Step step={2}>
@@ -64,12 +70,6 @@ supabase db diff -f create_employees_table
</StepHikeCompact.Step>
</StepHikeCompact>
<Admonition type="note">
Make sure your local database is stopped before diffing your schema.
</Admonition>
<StepHikeCompact>
<StepHikeCompact.Step step={3}>
+1 -1
View File
@@ -8,7 +8,7 @@ import { fetchRevalidatePerDay } from '~/features/helpers.fetch'
let octokitInstance: Octokit
function octokit() {
export function octokit() {
if (!octokitInstance) {
// https://github.com/gr2m/universal-github-app-jwt?tab=readme-ov-file#converting-pkcs1-to-pkcs8
const privateKeyPkcs8 = crypto
+5
View File
@@ -55,6 +55,8 @@
"eslint-config-supabase": "workspace:*",
"framer-motion": "^11.0.3",
"github-slugger": "^2.0.0",
"graphql": "^16.10.0",
"graphql-validation-complexity": "^0.4.2",
"gray-matter": "^4.0.3",
"hast-util-has-property": "^2.0.1",
"hast-util-to-html": "^9.0.0",
@@ -111,9 +113,11 @@
},
"devDependencies": {
"@aws-sdk/client-secrets-manager": "^3.410.0",
"@graphiql/toolkit": "^0.9.1",
"@supabase/supa-mdx-lint": "0.2.6-alpha",
"@types/common-tags": "^1.8.4",
"@types/estree": "1.0.5",
"@types/graphql-validation-complexity": "^0.4.4",
"@types/hast": "^2.3.4",
"@types/jsrsasign": "^10.5.12",
"@types/mdast": "^3.0.0",
@@ -130,6 +134,7 @@
"dotenv": "^16.0.3",
"ejs": "^3.1.10",
"globby": "^13.2.2",
"graphiql": "^3.8.3",
"openapi-types": "^12.1.3",
"shiki": "^3.2.1",
"simple-git": "^3.24.0",
+1
View File
@@ -32,6 +32,7 @@ David A. Ventimiglia
Deji I
Div Arora
Divit D
Eduardo Gurgel
Egor Romanov
Eleftheria Trivyzaki
Etienne Stalmans
+31
View File
@@ -0,0 +1,31 @@
import {
GraphQLNonNull,
GraphQLObjectType,
GraphQLSchema,
GraphQLString,
printSchema,
} from 'graphql'
const GRAPHQL_FIELD_INTROSPECT = 'schema'
async function resolveIntrospect() {
const schema = printSchema(rootGraphQLSchema)
return schema
}
const introspectRoot = {
[GRAPHQL_FIELD_INTROSPECT]: {
description: 'Get the GraphQL schema for this endpoint',
type: new GraphQLNonNull(GraphQLString),
resolve: resolveIntrospect,
},
}
export const rootGraphQLSchema = new GraphQLSchema({
query: new GraphQLObjectType({
name: 'RootQueryType',
fields: {
...introspectRoot,
},
}),
})
+17 -50
View File
@@ -1,4 +1,4 @@
import { PropsWithChildren, useCallback, useEffect, useRef, useState } from 'react'
import { PropsWithChildren, useEffect, useRef, useState } from 'react'
import { DataGridHandle } from 'react-data-grid'
import { DndProvider } from 'react-dnd'
import { HTML5Backend } from 'react-dnd-html5-backend'
@@ -7,24 +7,21 @@ import { createPortal } from 'react-dom'
import { useParams } from 'common'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useTableRowsQuery } from 'data/table-rows/table-rows-query'
import { useTableEditorFiltersSort } from 'hooks/misc/useTableEditorFiltersSort'
import { RoleImpersonationState } from 'lib/role-impersonation'
import { EMPTY_ARR } from 'lib/void'
import { useRoleImpersonationStateSnapshot } from 'state/role-impersonation-state'
import { useTableEditorStateSnapshot } from 'state/table-editor'
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
import {
filtersToUrlParams,
formatFilterURLParams,
formatSortURLParams,
saveTableEditorStateToLocalStorage,
} from './SupabaseGrid.utils'
import { Shortcuts } from './components/common/Shortcuts'
import Footer from './components/footer/Footer'
import { Grid } from './components/grid/Grid'
import Header, { HeaderProps } from './components/header/Header'
import { RowContextMenu } from './components/menu'
import { Filter, GridProps } from './types'
import { GridProps } from './types'
import { useTableFilter } from './hooks/useTableFilter'
import { useTableSort } from './hooks/useTableSort'
export const SupabaseGrid = ({
customHeader,
@@ -39,43 +36,15 @@ export const SupabaseGrid = ({
const tableId = _id ? Number(_id) : undefined
const { project } = useProjectContext()
const tableEditorSnap = useTableEditorStateSnapshot()
const snap = useTableEditorTableStateSnapshot()
const gridRef = useRef<DataGridHandle>(null)
const [mounted, setMounted] = useState(false)
const { filters: filter, sorts: sort, setParams } = useTableEditorFiltersSort()
const sorts = formatSortURLParams(snap.table.name, sort as string[] | undefined)
const filters = formatFilterURLParams(filter as string[])
const onApplyFilters = useCallback(
(appliedFilters: Filter[]) => {
snap.setEnforceExactCount(false)
// Reset page to 1 when filters change
snap.setPage(1)
const filters = filtersToUrlParams(appliedFilters)
setParams((prevParams) => {
return {
...prevParams,
filter: filters,
}
})
if (project?.ref) {
saveTableEditorStateToLocalStorage({
projectRef: project.ref,
tableName: snap.table.name,
schema: snap.table.schema,
filters: filters,
})
}
},
[project?.ref, snap.table.name, snap.table.schema]
)
const { filters, onApplyFilters } = useTableFilter()
const { sorts, onApplySorts } = useTableSort()
const roleImpersonationState = useRoleImpersonationStateSnapshot()
@@ -93,15 +62,13 @@ export const SupabaseGrid = ({
{
keepPreviousData: true,
retryDelay: (retryAttempt, error: any) => {
if (error && error.message?.includes('does not exist')) {
setParams((prevParams) => {
return {
...prevParams,
...{ sort: undefined },
}
})
}
if (retryAttempt > 3) {
const doesNotExistError = error && error.message?.includes('does not exist')
const tooManyRequestsError = error.message?.includes('Too Many Requests')
const vaultError = error.message?.includes('query vault failed')
if (doesNotExistError) onApplySorts([])
if (retryAttempt > 3 || doesNotExistError || tooManyRequestsError || vaultError) {
return Infinity
}
return 5000
@@ -118,7 +85,7 @@ export const SupabaseGrid = ({
return (
<DndProvider backend={HTML5Backend} context={window}>
<div className="sb-grid h-full flex flex-col">
<Header sorts={sorts} filters={filters} customHeader={customHeader} />
<Header customHeader={customHeader} />
{children || (
<>
@@ -3,16 +3,12 @@ import saveAs from 'file-saver'
import { ArrowUp, ChevronDown, FileText, Trash } from 'lucide-react'
import Link from 'next/link'
import Papa from 'papaparse'
import { ReactNode, useCallback, useState } from 'react'
import { ReactNode, useState } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
import {
filtersToUrlParams,
saveTableEditorStateToLocalStorage,
sortsToUrlParams,
} from 'components/grid/SupabaseGrid.utils'
import type { Filter, Sort } from 'components/grid/types'
import { useTableFilter } from 'components/grid/hooks/useTableFilter'
import { useTableSort } from 'components/grid/hooks/useTableSort'
import GridHeaderActions from 'components/interfaces/TableGridEditor/GridHeaderActions'
import { formatTableRowsToSQL } from 'components/interfaces/TableGridEditor/TableEntity.utils'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
@@ -22,7 +18,6 @@ import { fetchAllTableRows, useTableRowsQuery } from 'data/table-rows/table-rows
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useTableEditorFiltersSort } from 'hooks/misc/useTableEditorFiltersSort'
import { RoleImpersonationState } from 'lib/role-impersonation'
import {
useRoleImpersonationStateSnapshot,
@@ -40,8 +35,8 @@ import {
Separator,
SonnerProgress,
} from 'ui'
import FilterPopover from './filter/FilterPopover'
import { SortPopover } from './sort'
import { FilterPopover } from './filter/FilterPopover'
import { SortPopover } from './sort/SortPopover'
// [Joshen] CSV exports require this guard as a fail-safe if the table is
// just too large for a browser to keep all the rows in memory before
// exporting. Either that or export as multiple CSV sheets with max n rows each
@@ -58,27 +53,21 @@ export const MAX_EXPORT_ROW_COUNT_MESSAGE = (
)
export type HeaderProps = {
sorts: Sort[]
filters: Filter[]
customHeader: ReactNode
}
const Header = ({ sorts, filters, customHeader }: HeaderProps) => {
const Header = ({ customHeader }: HeaderProps) => {
const snap = useTableEditorTableStateSnapshot()
return (
<div>
<div className="flex h-10 items-center justify-between bg-dash-sidebar dark:bg-surface-100 px-1.5 py-1.5 gap-2 overflow-x-auto ">
{customHeader ? (
<>{customHeader}</>
customHeader
) : snap.selectedRows.size > 0 ? (
<RowHeader />
) : (
<>
{snap.selectedRows.size > 0 ? (
<RowHeader sorts={sorts} filters={filters} />
) : (
<DefaultHeader />
)}
</>
<DefaultHeader />
)}
<GridHeaderActions table={snap.originalTable} />
</div>
@@ -93,6 +82,8 @@ const DefaultHeader = () => {
const tableEditorSnap = useTableEditorStateSnapshot()
const snap = useTableEditorTableStateSnapshot()
const org = useSelectedOrganization()
const canCreateColumns = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'columns')
const { mutate: sendEvent } = useSendEventMutation()
const onAddRow =
snap.editable && (snap.table.columns ?? []).length > 0 ? tableEditorSnap.onAddRow : undefined
@@ -101,68 +92,11 @@ const DefaultHeader = () => {
const canAddNew = onAddRow !== undefined || onAddColumn !== undefined
// [Joshen] Using this logic to block both column and row creation/update/delete
const canCreateColumns = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'columns')
const { filters, sorts, setParams } = useTableEditorFiltersSort()
const onApplyFilters = useCallback(
(appliedFilters: Filter[]) => {
snap.setEnforceExactCount(false)
// Reset page to 1 when filters change
snap.setPage(1)
const newFilters = filtersToUrlParams(appliedFilters)
setParams((prevParams) => {
return {
filter: newFilters,
sort: prevParams.sort,
}
})
if (projectRef) {
saveTableEditorStateToLocalStorage({
projectRef,
tableName: snap.table.name,
schema: snap.table.schema,
filters: newFilters,
})
}
},
[projectRef, snap.table.name, snap.table.schema, setParams]
)
const onApplySorts = useCallback(
(appliedSorts: Sort[]) => {
const newSorts = sortsToUrlParams(appliedSorts)
setParams((prevParams) => {
return {
filter: prevParams.filter,
sort: newSorts,
}
})
if (projectRef) {
saveTableEditorStateToLocalStorage({
projectRef,
tableName: snap.table.name,
schema: snap.table.schema,
sorts: newSorts,
})
}
},
[projectRef, snap.table.name, snap.table.schema, setParams]
)
const { mutate: sendEvent } = useSendEventMutation()
return (
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<FilterPopover filters={filters} onApplyFilters={onApplyFilters} />
<SortPopover sorts={sorts} onApplySorts={onApplySorts} />
<FilterPopover />
<SortPopover />
</div>
{canAddNew && (
<>
@@ -284,11 +218,7 @@ const DefaultHeader = () => {
)
}
type RowHeaderProps = {
sorts: Sort[]
filters: Filter[]
}
const RowHeader = ({ sorts, filters }: RowHeaderProps) => {
const RowHeader = () => {
const { project } = useProjectContext()
const tableEditorSnap = useTableEditorStateSnapshot()
const snap = useTableEditorTableStateSnapshot()
@@ -296,6 +226,9 @@ const RowHeader = ({ sorts, filters }: RowHeaderProps) => {
const roleImpersonationState = useRoleImpersonationStateSnapshot()
const isImpersonatingRole = roleImpersonationState.role !== undefined
const { filters } = useTableFilter()
const { sorts } = useTableSort()
const [isExporting, setIsExporting] = useState(false)
const { data } = useTableRowsQuery({
@@ -1,145 +1,22 @@
import update from 'immutability-helper'
import { isEqual } from 'lodash'
import { FilterIcon, Plus } from 'lucide-react'
import { KeyboardEvent, useCallback, useMemo, useState } from 'react'
import { useMemo } from 'react'
import { useTableFilter } from 'components/grid/hooks/useTableFilter'
import { formatFilterURLParams } from 'components/grid/SupabaseGrid.utils'
import type { Filter } from 'components/grid/types'
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
import {
Button,
PopoverContent_Shadcn_,
PopoverSeparator_Shadcn_,
PopoverTrigger_Shadcn_,
Popover_Shadcn_,
} from 'ui'
import FilterRow from './FilterRow'
import { FilterPopoverPrimitive } from './FilterPopoverPrimitive'
export interface FilterPopoverProps {
filters: string[]
portal?: boolean
onApplyFilters: (filters: Filter[]) => void
}
const FilterPopover = ({ filters, portal = true, onApplyFilters }: FilterPopoverProps) => {
const [open, setOpen] = useState(false)
export const FilterPopover = ({ portal = true }: FilterPopoverProps) => {
const { urlFilters, onApplyFilters } = useTableFilter()
const btnText =
(filters || []).length > 0
? `Filtered by ${filters.length} rule${filters.length > 1 ? 's' : ''}`
: 'Filter'
// Convert string[] to Filter[]
const filters = useMemo(() => {
return formatFilterURLParams(urlFilters ?? [])
}, [urlFilters])
return (
<Popover_Shadcn_ open={open} onOpenChange={setOpen} modal={false}>
<PopoverTrigger_Shadcn_ asChild>
<Button type={(filters || []).length > 0 ? 'link' : 'text'} icon={<FilterIcon />}>
{btnText}
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ className="p-0 w-96" side="bottom" align="start" portal={portal}>
<FilterOverlay filters={filters} onApplyFilters={onApplyFilters} />
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
)
}
export default FilterPopover
interface FilterOverlayProps {
filters: string[]
onApplyFilters: (filter: Filter[]) => void
}
const FilterOverlay = ({ filters: filtersFromUrl, onApplyFilters }: FilterOverlayProps) => {
const snap = useTableEditorTableStateSnapshot()
const initialFilters = useMemo(
() => formatFilterURLParams((filtersFromUrl as string[]) ?? []),
[filtersFromUrl]
)
const [filters, setFilters] = useState<Filter[]>(initialFilters)
function onAddFilter() {
const column = snap.table.columns[0]?.name
if (column) {
setFilters([
...filters,
{
column,
operator: '=',
value: '',
},
])
}
}
const onChangeFilter = useCallback((index: number, filter: Filter) => {
setFilters((currentFilters) =>
update(currentFilters, {
[index]: {
$set: filter,
},
})
)
}, [])
const onDeleteFilter = useCallback((index: number) => {
setFilters((currentFilters) =>
update(currentFilters, {
$splice: [[index, 1]],
})
)
}, [])
const onSelectApplyFilters = () => {
// [Joshen] Trim empty spaces in input for only UUID type columns
const formattedFilters = filters.map((f) => {
const column = snap.table.columns.find((c) => c.name === f.column)
if (column?.format === 'uuid') return { ...f, value: f.value.trim() }
else return f
})
setFilters(formattedFilters)
onApplyFilters(formattedFilters)
}
function handleEnterKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (event.key === 'Enter') onSelectApplyFilters()
}
return (
<div className="space-y-2 py-2">
<div className="space-y-2">
{filters.map((filter, index) => (
<FilterRow
key={`filter-${filter.column}-${[index]}`}
filter={filter}
filterIdx={index}
onChange={onChangeFilter}
onDelete={onDeleteFilter}
onKeyDown={handleEnterKeyDown}
/>
))}
{filters.length == 0 && (
<div className="space-y-1 px-3">
<h5 className="text-sm text-foreground-light">No filters applied to this view</h5>
<p className="text-xs text-foreground-lighter">Add a column below to filter the view</p>
</div>
)}
</div>
<PopoverSeparator_Shadcn_ />
<div className="px-3 flex flex-row justify-between">
<Button icon={<Plus />} type="text" onClick={onAddFilter}>
Add filter
</Button>
<Button
disabled={isEqual(filters, initialFilters)}
type="default"
onClick={() => onSelectApplyFilters()}
>
Apply filter
</Button>
</div>
</div>
<FilterPopoverPrimitive portal={portal} filters={filters} onApplyFilters={onApplyFilters} />
)
}
@@ -0,0 +1,136 @@
import { isEqual } from 'lodash'
import { Filter as FilterIcon, Plus } from 'lucide-react'
import { KeyboardEvent, useCallback, useMemo, useState } from 'react'
import type { Filter } from 'components/grid/types'
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
import {
Button,
PopoverContent_Shadcn_,
PopoverSeparator_Shadcn_,
PopoverTrigger_Shadcn_,
Popover_Shadcn_,
} from 'ui'
import FilterRow from './FilterRow'
export interface FilterPopoverPrimitiveProps {
buttonText?: string
filters: Filter[]
onApplyFilters: (filters: Filter[]) => void
portal?: boolean
}
export const FilterPopoverPrimitive = ({
buttonText,
filters,
onApplyFilters,
portal = true,
}: FilterPopoverPrimitiveProps) => {
const [open, setOpen] = useState(false)
const snap = useTableEditorTableStateSnapshot()
// Internal state management
const [localFilters, setLocalFilters] = useState<Filter[]>(filters)
// Update local state when filters prop changes
useMemo(() => {
setLocalFilters(filters)
}, [filters])
const displayButtonText =
buttonText ??
(filters.length > 0
? `Filtered by ${filters.length} rule${filters.length > 1 ? 's' : ''}`
: 'Filter')
const onAddFilter = () => {
const column = snap.table.columns[0]?.name
if (column) {
setLocalFilters([
...localFilters,
{
column,
operator: '=',
value: '',
},
])
}
}
const onChangeFilter = useCallback((index: number, filter: Filter) => {
setLocalFilters((currentFilters) => [
...currentFilters.slice(0, index),
filter,
...currentFilters.slice(index + 1),
])
}, [])
const onDeleteFilter = useCallback((index: number) => {
setLocalFilters((currentFilters) => [
...currentFilters.slice(0, index),
...currentFilters.slice(index + 1),
])
}, [])
const onSelectApplyFilters = () => {
// [Joshen] Trim empty spaces in input for only UUID type columns
const formattedFilters = localFilters.map((f) => {
const column = snap.table.columns.find((c) => c.name === f.column)
if (column?.format === 'uuid') return { ...f, value: f.value.trim() }
else return f
})
setLocalFilters(formattedFilters)
onApplyFilters(formattedFilters)
}
function handleEnterKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (event.key === 'Enter') onSelectApplyFilters()
}
return (
<Popover_Shadcn_ open={open} onOpenChange={setOpen} modal={false}>
<PopoverTrigger_Shadcn_ asChild>
<Button type={filters.length > 0 ? 'link' : 'text'} icon={<FilterIcon />}>
{displayButtonText}
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ className="p-0 w-96" side="bottom" align="start" portal={portal}>
<div className="space-y-2 py-2">
<div className="space-y-2">
{localFilters.map((filter, index) => (
<FilterRow
key={`filter-${filter.column}-${[index]}`}
filter={filter}
filterIdx={index}
onChange={onChangeFilter}
onDelete={onDeleteFilter}
onKeyDown={handleEnterKeyDown}
/>
))}
{localFilters.length == 0 && (
<div className="space-y-1 px-3">
<h5 className="text-sm text-foreground-light">No filters applied to this view</h5>
<p className="text-xs text-foreground-lighter">
Add a column below to filter the view
</p>
</div>
)}
</div>
<PopoverSeparator_Shadcn_ />
<div className="px-3 flex flex-row justify-between">
<Button icon={<Plus />} type="text" onClick={onAddFilter}>
Add filter
</Button>
<Button
disabled={isEqual(localFilters, filters)}
type="default"
onClick={() => onSelectApplyFilters()}
>
Apply filter
</Button>
</div>
</div>
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
)
}
@@ -1,163 +1,23 @@
import update from 'immutability-helper'
import { isEqual } from 'lodash'
import { ChevronDown, List } from 'lucide-react'
import { useCallback, useMemo, useState } from 'react'
import { useMemo } from 'react'
import { formatSortURLParams } from 'components/grid/SupabaseGrid.utils'
import { DropdownControl } from 'components/grid/components/common/DropdownControl'
import type { Sort } from 'components/grid/types'
import { useTableSort } from 'components/grid/hooks/useTableSort'
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
import {
Button,
PopoverContent_Shadcn_,
PopoverSeparator_Shadcn_,
PopoverTrigger_Shadcn_,
Popover_Shadcn_,
} from 'ui'
import SortRow from './SortRow'
import { SortPopoverPrimitive } from './SortPopoverPrimitive'
export interface SortPopoverProps {
sorts: string[]
portal?: boolean
onApplySorts: (sorts: Sort[]) => void
}
const SortPopover = ({ sorts, portal = true, onApplySorts }: SortPopoverProps) => {
const [open, setOpen] = useState(false)
export const SortPopover = ({ portal = true }: SortPopoverProps) => {
const { urlSorts, onApplySorts } = useTableSort()
const tableState = useTableEditorTableStateSnapshot()
const tableName = tableState?.table?.name || ''
const btnText =
(sorts || []).length > 0
? `Sorted by ${sorts.length} rule${sorts.length > 1 ? 's' : ''}`
: 'Sort'
// Convert string[] to Sort[]
const sorts = useMemo(() => {
return tableName && urlSorts ? formatSortURLParams(tableName, urlSorts) : []
}, [tableName, urlSorts])
return (
<Popover_Shadcn_ modal={false} open={open} onOpenChange={setOpen}>
<PopoverTrigger_Shadcn_ asChild>
<Button type={(sorts || []).length > 0 ? 'link' : 'text'} icon={<List />}>
{btnText}
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ className="p-0 w-96" side="bottom" align="start" portal={portal}>
<SortOverlay sorts={sorts} onApplySorts={onApplySorts} />
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
)
}
export default SortPopover
export interface SortOverlayProps {
sorts: string[]
onApplySorts: (sorts: Sort[]) => void
}
const SortOverlay = ({ sorts: sortsFromUrl, onApplySorts }: SortOverlayProps) => {
const snap = useTableEditorTableStateSnapshot()
const initialSorts = useMemo(
() => formatSortURLParams(snap.table.name, sortsFromUrl ?? []),
[snap.table.name, sortsFromUrl]
)
const [sorts, setSorts] = useState<Sort[]>(initialSorts)
const columns = snap.table.columns.filter((x) => {
// exclude json/jsonb columns from sorting. Sorting by json fields in PG is only possible if you provide key from
// the JSON object.
if (x.dataType === 'json' || x.dataType === 'jsonb') {
return false
}
const found = sorts.find((y) => y.column == x.name)
return !found
})
const dropdownOptions =
columns?.map((x) => {
return { value: x.name, label: x.name }
}) || []
function onAddSort(columnName: string | number) {
setSorts([...sorts, { table: snap.table.name, column: columnName as string, ascending: true }])
}
const onDeleteSort = useCallback((column: string) => {
setSorts((currentSorts) => currentSorts.filter((sort) => sort.column !== column))
}, [])
const onToggleSort = useCallback((column: string, ascending: boolean) => {
setSorts((currentSorts) => {
const idx = currentSorts.findIndex((x) => x.column === column)
return update(currentSorts, {
[idx]: {
$merge: { ascending },
},
})
})
}, [])
const onDragSort = useCallback((dragIndex: number, hoverIndex: number) => {
setSorts((currentSort) =>
update(currentSort, {
$splice: [
[dragIndex, 1],
[hoverIndex, 0, currentSort[dragIndex]],
],
})
)
}, [])
return (
<div className="space-y-2 py-2">
{sorts.map((sort, index) => (
<SortRow
key={sort.column}
index={index}
columnName={sort.column}
sort={sort}
onDelete={onDeleteSort}
onToggle={onToggleSort}
onDrag={onDragSort}
/>
))}
{sorts.length === 0 && (
<div className="space-y-1 px-3">
<h5 className="text-sm text-foreground-light">No sorts applied to this view</h5>
<p className="text-xs text-foreground-lighter">Add a column below to sort the view</p>
</div>
)}
<PopoverSeparator_Shadcn_ />
<div className="px-3 flex flex-row justify-between">
{columns && columns.length > 0 ? (
<DropdownControl
options={dropdownOptions}
onSelect={onAddSort}
side="bottom"
align="start"
>
<Button
asChild
type="text"
iconRight={<ChevronDown size="14" className="text-foreground-light" />}
className="sb-grid-dropdown__item-trigger"
data-testid="table-editor-pick-column-to-sort-button"
>
<span>Pick {sorts.length > 1 ? 'another' : 'a'} column to sort by</span>
</Button>
</DropdownControl>
) : (
<p className="text-sm text-foreground-light">All columns have been added</p>
)}
<div className="flex items-center">
<Button
disabled={isEqual(sorts, initialSorts)}
type="default"
onClick={() => onApplySorts(sorts)}
>
Apply sorting
</Button>
</div>
</div>
</div>
)
return <SortPopoverPrimitive portal={portal} sorts={sorts} onApplySorts={onApplySorts} />
}
@@ -0,0 +1,236 @@
import { isEqual } from 'lodash'
import { ChevronDown, List } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { Sort } from 'components/grid/types'
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
import {
Button,
PopoverContent_Shadcn_,
PopoverSeparator_Shadcn_,
PopoverTrigger_Shadcn_,
Popover_Shadcn_,
} from 'ui'
import { DropdownControl } from '../../common/DropdownControl'
import SortRow from './SortRow'
export interface SortPopoverPrimitiveProps {
buttonText?: string
sorts: Sort[]
onApplySorts: (sorts: Sort[]) => void
portal?: boolean
}
/**
* SortPopoverPrimitive - A component for sorting table columns
*
* This component maintains a draft state of sorts while editing, then applies
* them to the parent component when "Apply" is clicked.
*
* To avoid issues with drag-and-drop reconciliation, we use a special sync mechanism
* that properly detects external vs. internal updates.
*/
export const SortPopoverPrimitive = ({
buttonText,
sorts,
onApplySorts,
portal = true,
}: SortPopoverPrimitiveProps) => {
const [open, setOpen] = useState(false)
const snap = useTableEditorTableStateSnapshot()
// Local state for draft sorts
const [localSorts, setLocalSorts] = useState<Sort[]>(sorts)
// Track the last props we received for comparison
const lastSortsRef = useRef<Sort[]>(sorts)
// Track if we're in the middle of applying our own changes
const isApplyingRef = useRef(false)
// Sync with props when they change, but in a smarter way
useEffect(() => {
// If we're in the middle of applying changes, don't sync from props
if (isApplyingRef.current) {
isApplyingRef.current = false
return
}
// If the props changed unexpectedly (not due to our own actions)
// then we should update our local state
if (!isEqual(sorts, lastSortsRef.current)) {
setLocalSorts(sorts)
lastSortsRef.current = sorts
}
}, [sorts])
// Fix: Use localSorts for button text, not sorts
const displayButtonText =
buttonText ??
(localSorts.length > 0
? `Sorted by ${localSorts.length} rule${localSorts.length > 1 ? 's' : ''}`
: 'Sort')
// Filter available columns to exclude columns already in sorts
const columns = useMemo(() => {
if (!snap?.table?.columns) return []
return snap.table.columns.filter((x) => {
if (x.dataType === 'json' || x.dataType === 'jsonb') return false
const found = localSorts.find((y) => y.column == x.name)
return !found
})
}, [snap?.table?.columns, localSorts])
// Format the columns for the dropdown
const dropdownOptions = useMemo(() => {
return columns?.map((x) => ({ value: x.name, label: x.name })) || []
}, [columns])
// Add a new sort
const onAddSort = (columnName: string | number) => {
const currentTableName = snap.table?.name
if (currentTableName) {
setLocalSorts([
...localSorts,
{ table: currentTableName, column: columnName as string, ascending: true },
])
}
}
// Remove a sort by column name
const onDeleteSort = useCallback((column: string) => {
setLocalSorts((currentSorts) => currentSorts.filter((sort) => sort.column !== column))
}, [])
// Toggle ascending/descending for a column
const onToggleSort = useCallback((column: string, ascending: boolean) => {
setLocalSorts((currentSorts) => {
const index = currentSorts.findIndex((x) => x.column === column)
if (index === -1) return currentSorts
const updatedSort = { ...currentSorts[index], ascending }
return [...currentSorts.slice(0, index), updatedSort, ...currentSorts.slice(index + 1)]
})
}, [])
// Handle drag-and-drop reordering
const onDragSort = useCallback((dragIndex: number, hoverIndex: number) => {
setLocalSorts((currentSort) => {
if (
dragIndex < 0 ||
dragIndex >= currentSort.length ||
hoverIndex < 0 ||
hoverIndex >= currentSort.length
) {
return currentSort
}
const itemToMove = currentSort[dragIndex]
const remainingItems = [
...currentSort.slice(0, dragIndex),
...currentSort.slice(dragIndex + 1),
]
return [
...remainingItems.slice(0, hoverIndex),
itemToMove,
...remainingItems.slice(hoverIndex),
]
})
}, [])
// Fix: Compare for meaningful changes (only column order and ascending)
const hasChanges = useMemo(() => {
if (localSorts.length !== sorts.length) return true
// Compare each sort by relevant properties
return localSorts.some((localSort, index) => {
const propSort = sorts[index]
return (
!propSort ||
localSort.column !== propSort.column ||
localSort.ascending !== propSort.ascending
)
})
}, [localSorts, sorts])
// Apply the sorts to the parent component
const onSelectApplySorts = () => {
// Mark that we're applying our changes to prevent re-syncing
isApplyingRef.current = true
// Update our last sorts ref to the current local state
lastSortsRef.current = [...localSorts]
// Create deep copies to avoid reference issues
const sortsCopy = localSorts.map((sort) => ({ ...sort }))
// Apply the sorts
onApplySorts(sortsCopy)
}
// Generate stable keys for SortRow components to avoid reconciliation issues
const getSortRowKey = (sort: Sort, index: number) => {
return `sort-${sort.table}-${sort.column}-${index}`
}
const content = (
<div className="space-y-2 py-2">
{localSorts.map((sort, index) => (
<SortRow
key={getSortRowKey(sort, index)}
index={index}
columnName={sort.column}
sort={sort}
onDelete={onDeleteSort}
onToggle={onToggleSort}
onDrag={onDragSort}
/>
))}
{localSorts.length === 0 && (
<div className="space-y-1 px-3">
<h5 className="text-sm text-foreground-light">No sorts applied to this view</h5>
<p className="text-xs text-foreground-lighter">Add a column below to sort the view</p>
</div>
)}
<PopoverSeparator_Shadcn_ />
<div className="px-3 flex flex-row justify-between">
{dropdownOptions && dropdownOptions.length > 0 ? (
<DropdownControl
options={dropdownOptions}
onSelect={onAddSort}
side="bottom"
align="start"
>
<Button
asChild
type="text"
iconRight={<ChevronDown size="14" className="text-foreground-light" />}
className="sb-grid-dropdown__item-trigger"
data-testid="table-editor-pick-column-to-sort-button"
>
<span>Pick {localSorts.length > 1 ? 'another' : 'a'} column to sort by</span>
</Button>
</DropdownControl>
) : (
<p className="text-sm text-foreground-light">All columns have been added</p>
)}
<div className="flex items-center">
<Button disabled={!hasChanges} type="default" onClick={onSelectApplySorts}>
Apply sorting
</Button>
</div>
</div>
</div>
)
return (
<Popover_Shadcn_ modal={false} open={open} onOpenChange={setOpen}>
<PopoverTrigger_Shadcn_ asChild>
<Button type={localSorts.length > 0 ? 'link' : 'text'} icon={<List />}>
{displayButtonText}
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ className="p-0 w-96" side="bottom" align="start" portal={portal}>
{content}
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
)
}
@@ -1 +0,0 @@
export { default as SortPopover } from './SortPopover'
+101
View File
@@ -0,0 +1,101 @@
# Table Filtering and Sorting Developer notes
## Overview
The table filtering and sorting system uses a URL-based state persistence pattern combined with custom hooks that abstract the implementation details from consuming components. This architecture provides several benefits:
- **Persistent state**: Filters and sorts are stored in URL parameters, enabling bookmarking and sharing
- **Separation of concerns**: Logic is separated from UI components
- **Draft-then-apply pattern**: UI components maintain draft state until explicitly applied
## Core Hooks
### `useTableFilter`
```typescript
// Returns: { filters, urlFilters, onApplyFilters }
```
The `useTableFilter` hook manages filter state with these responsibilities:
- Retrieves raw filter parameters from URL
- Formats filter parameters into usable Filter[] objects
- Provides a callback to apply new filters
- Persists changes to URL parameters
- Triggers side effects through `saveFiltersAndTriggerSideEffects`
Key design aspects:
- No direct snapshot interaction, keeping it focused solely on filter management
- Uses URL parameters as the source of truth
- Forwards filter changes to URL and triggers application-specific side effects
### `useTableSort`
```typescript
// Returns: { sorts, urlSorts, onApplySorts }
```
The `useTableSort` hook manages sort state with these responsibilities:
- Retrieves raw filter parameters from URL
- Formats sort parameters into usable Sort[] objects (needs table name)
- Provides a callback to apply new sorts
- Persists changes to URL parameters
- Triggers side effects through `saveSortsAndTriggerSideEffects`
Key design aspects:
- Handles applying table name to sort objects
- Maintains URL parameters as source of truth
- Forwards sort changes to URL and triggers application-specific side effects
## Component Implementation
### FilterPopoverPrimitive and SortPopoverPrimitive
These components follow a "draft and apply" pattern:
1. **Local state management**: Both components maintain a local state copy of filters/sorts
2. **Edit operations**: Changes like adding, modifying, or deleting are made to the local state
3. **Apply operations**: Only when the user clicks "Apply" are the changes committed via the callback
4. **Synchronization**: Local state is synchronized with props when external changes occur
## Data Flow
1. URL parameters store the raw filter/sort state
2. Hooks read and format these parameters into usable objects
3. UI components receive formatted objects and callbacks
4. Components maintain draft state for editing
5. When "Apply" is clicked, callbacks update URL parameters
6. Side effects are triggered via dedicated save hooks
## Component Usage
Components using these hooks should follow this pattern:
```tsx
function TableComponent() {
// Get filter data and callbacks
const { filters, onApplyFilters } = useTableFilter()
// Get sort data and callbacks
const { sorts, onApplySorts } = useTableSort()
return (
<>
<FilterPopoverPrimitive filters={filters} onApplyFilters={onApplyFilters} />
<SortPopoverPrimitive sorts={sorts} onApplySorts={onApplySorts} />
{/* Table rendering with filters and sorts applied */}
</>
)
}
```
## Implementation Notes
- Filter and sort parameters are stored in URL using specific formats
- Conversion utilities (`formatFilterURLParams`, `formatSortURLParams`, etc.) handle translation between URL strings and typed objects
- Side effect hooks manage database persistence and related operations
@@ -0,0 +1,58 @@
import { useCallback } from 'react'
import { saveTableEditorStateToLocalStorage } from 'components/grid/SupabaseGrid.utils'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
/**
* Hook for saving state and triggering side effects.
*/
export function useSaveTableEditorState() {
const { project } = useProjectContext()
const snap = useTableEditorTableStateSnapshot()
const saveDataAndTriggerSideEffects = useCallback(
(dataToSave: { filters?: string[]; sorts?: string[] }) => {
const projectRef = project?.ref
if (!projectRef) {
return console.warn(
'[useSaveTableEditorState] ProjectRef missing, cannot save or trigger side effects.'
)
}
try {
snap.setPage(1)
snap.setEnforceExactCount(false)
const tableName = snap.table?.name
const schema = snap.table?.schema
if (tableName) {
saveTableEditorStateToLocalStorage({
projectRef,
tableName,
schema,
...dataToSave,
})
} else {
console.warn('[useSaveTableEditorState] Table name missing, skipping localStorage save.')
}
} catch (error) {
console.error('[useSaveTableEditorState] Error during interaction with snapshot:', error)
}
},
[snap, project]
)
const saveFiltersAndTriggerSideEffects = useCallback(
(urlFilters: string[]) => saveDataAndTriggerSideEffects({ filters: urlFilters }),
[saveDataAndTriggerSideEffects]
)
const saveSortsAndTriggerSideEffects = useCallback(
(urlSorts: string[]) => saveDataAndTriggerSideEffects({ sorts: urlSorts }),
[saveDataAndTriggerSideEffects]
)
return { saveFiltersAndTriggerSideEffects, saveSortsAndTriggerSideEffects }
}
@@ -0,0 +1,32 @@
import { useCallback } from 'react'
import { filtersToUrlParams, formatFilterURLParams } from 'components/grid/SupabaseGrid.utils'
import type { Filter } from 'components/grid/types'
import { useTableEditorFiltersSort } from 'hooks/misc/useTableEditorFiltersSort'
import { useSaveTableEditorState } from './useSaveTableEditorState'
/**
* Hook for managing table filter URL parameters and saving.
* NO direct snapshot interaction.
*/
export function useTableFilter() {
const { filters: urlFilters, setParams } = useTableEditorFiltersSort()
const { saveFiltersAndTriggerSideEffects } = useSaveTableEditorState()
const filters = formatFilterURLParams(urlFilters)
const onApplyFilters = useCallback(
(appliedFilters: Filter[]) => {
const newUrlFilters = filtersToUrlParams(appliedFilters)
setParams((prevParams) => ({ ...prevParams, filter: newUrlFilters }))
saveFiltersAndTriggerSideEffects(newUrlFilters)
},
[setParams, saveFiltersAndTriggerSideEffects]
)
return {
filters, // Formatted Filter[] object array
urlFilters, // Raw string[] from URL
onApplyFilters, // Callback to apply changes
}
}
@@ -0,0 +1,50 @@
import { useCallback, useMemo } from 'react'
import { formatSortURLParams, sortsToUrlParams } from 'components/grid/SupabaseGrid.utils'
import type { Sort } from 'components/grid/types'
import { useTableEditorFiltersSort } from 'hooks/misc/useTableEditorFiltersSort'
import { useTableEditorTableStateSnapshot } from 'state/table-editor-table'
import { useSaveTableEditorState } from './useSaveTableEditorState'
/**
* Hook for managing table sort URL parameters and saving.
* Uses snapshot ONLY to get table name for formatting/mapping.
* Uses useSaveTableEditorState for saving and side effects.
* Does NOT format initial sorts (needs table name externally).
* Does NOT interact with snapshot directly.
*/
export function useTableSort() {
const { sorts: urlSorts, setParams } = useTableEditorFiltersSort()
const snap = useTableEditorTableStateSnapshot()
const { saveSortsAndTriggerSideEffects } = useSaveTableEditorState()
const tableName = useMemo(() => snap.table?.name || '', [snap])
const sorts = useMemo(() => {
return formatSortURLParams(tableName, urlSorts)
}, [tableName, urlSorts])
const onApplySorts = useCallback(
(appliedSorts: Sort[]) => {
if (!tableName) {
return console.warn(
'[useTableSort] Table name missing in callback, cannot apply sort correctly.'
)
}
const sortsWithTable = appliedSorts.map((sort) => ({ ...sort, table: tableName }))
const newUrlSorts = sortsToUrlParams(sortsWithTable)
setParams((prevParams) => ({ ...prevParams, sort: newUrlSorts }))
saveSortsAndTriggerSideEffects(newUrlSorts)
},
[snap, setParams, saveSortsAndTriggerSideEffects]
)
return {
sorts,
urlSorts,
onApplySorts,
}
}
@@ -2,6 +2,7 @@ import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react'
import SVG from 'react-inlinesvg'
import { DEFAULT_SIDEBAR_BEHAVIOR } from 'components/interfaces/Sidebar'
import Panel from 'components/ui/Panel'
import { useLocalStorageQuery } from 'hooks/misc/useLocalStorage'
import { BASE_PATH, LOCAL_STORAGE_KEYS } from 'lib/constants'
@@ -19,7 +20,6 @@ import {
Theme,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { DEFAULT_SIDEBAR_BEHAVIOR } from 'components/interfaces/Sidebar'
export const ThemeSettings = () => {
const [mounted, setMounted] = useState(false)
@@ -51,7 +51,7 @@ export const ThemeSettings = () => {
>
{singleThemes.map((theme: Theme) => (
<RadioGroupLargeItem_Shadcn_
className="grow p-3"
className="p-3"
key={theme.value}
value={theme.value}
label={theme.name}
@@ -221,7 +221,7 @@ const TableList = ({
icon={<Filter />}
/>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ className="p-0 w-56" side="bottom" align="center">
<PopoverContent_Shadcn_ className="p-0 w-56" side="bottom" align="center" portal={true}>
<div className="px-3 pt-3 pb-2 flex flex-col gap-y-2">
<p className="text-xs">Show entity types</p>
<div className="flex flex-col">
@@ -76,7 +76,7 @@ export const PostgresVersionSelector = ({
const defaultValue = gaVersion ? formatValue(gaVersion) : formatValue(availableVersions[0])
form.setValue('postgresVersionSelection', defaultValue)
}
}, [isSuccess, form])
}, [isSuccess, availableVersions, form])
return (
<FormItemLayout label="Postgres Version" layout={layout}>
@@ -32,7 +32,7 @@ import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-que
import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query'
import type { ProjectAddonVariantMeta } from 'data/subscriptions/types'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useIsOrioleDb, useProjectByRef } from 'hooks/misc/useSelectedProject'
import { useIsOrioleDb, useIsOrioleDbInAws, useProjectByRef } from 'hooks/misc/useSelectedProject'
import { useFlag } from 'hooks/ui/useFlag'
import { getCloudProviderArchitecture } from 'lib/cloudprovider-utils'
import { BASE_PATH, INSTANCE_MICRO_SPECS, INSTANCE_NANO_SPECS } from 'lib/constants'
@@ -54,6 +54,7 @@ const Addons = () => {
const isBranch = parentProject !== undefined
const isProjectActive = useIsProjectActive()
const isOrioleDb = useIsOrioleDb()
const isOrioleDbInAws = useIsOrioleDbInAws()
const { data: settings } = useProjectSettingsV2Query({ projectRef })
const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: selectedOrg?.slug })
@@ -486,7 +487,7 @@ const Addons = () => {
</Button>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
) : isOrioleDb ? (
) : isOrioleDbInAws ? (
<ButtonTooltip
disabled
type="default"
@@ -13,6 +13,7 @@ import { useProjectUpgradeEligibilityQuery } from 'data/config/project-upgrade-e
import { useProjectServiceVersionsQuery } from 'data/projects/project-service-versions'
import { useReadReplicasQuery } from 'data/read-replicas/replicas-query'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useIsOrioleDb } from 'hooks/misc/useSelectedProject'
import {
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
@@ -62,7 +63,7 @@ const InfrastructureInfo = () => {
current_app_version_release_channel && current_app_version_release_channel !== 'ga'
? current_app_version_release_channel
: undefined
const isOrioleDb = (current_app_version ?? '').includes('orioledb')
const isOrioleDb = useIsOrioleDb()
const latestPgVersion = (latest_app_version ?? '').split('supabase-postgres-')[1]
const isInactive = project?.status === 'INACTIVE'
@@ -147,18 +148,16 @@ const InfrastructureInfo = () => {
</Tooltip>
),
isOrioleDb && (
<>
<Tooltip>
<TooltipTrigger>
<Badge variant="default" className="mr-1">
OrioleDB
</Badge>
</TooltipTrigger>
<TooltipContent side="bottom" className="w-44 text-center">
This project uses OrioleDB
</TooltipContent>
</Tooltip>
</>
<Tooltip>
<TooltipTrigger>
<Badge variant="default" className="mr-1">
OrioleDB
</Badge>
</TooltipTrigger>
<TooltipContent side="bottom" className="w-44 text-center">
This project uses OrioleDB
</TooltipContent>
</Tooltip>
),
isOnLatestVersion && (
<Tooltip>
@@ -1,16 +1,16 @@
/* eslint-disable react-hooks/exhaustive-deps */
import DatePicker from 'react-datepicker'
import dayjs from 'dayjs'
import { useEffect, useState } from 'react'
import { Button, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_, Popover_Shadcn_, cn } from 'ui'
import type { DatetimeHelper } from './Logs.types'
import { ChevronLeft, ChevronRight, Clock, XIcon } from 'lucide-react'
import TimeSplitInput from 'components/ui/DatePicker/TimeSplitInput'
import { RadioGroup, RadioGroupItem } from '@ui/components/shadcn/ui/radio-group'
import { PropsWithChildren, useEffect, useState } from 'react'
import DatePicker from 'react-datepicker'
import { Label } from '@ui/components/shadcn/ui/label'
import { RadioGroup, RadioGroupItem } from '@ui/components/shadcn/ui/radio-group'
import TimeSplitInput from 'components/ui/DatePicker/TimeSplitInput'
import { Button, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_, Popover_Shadcn_, cn } from 'ui'
import { LOGS_LARGE_DATE_RANGE_DAYS_THRESHOLD } from './Logs.constants'
import { PropsWithChildren } from 'react'
import type { DatetimeHelper } from './Logs.types'
import { copyToClipboard } from 'lib/helpers'
export type DatePickerValue = {
to: string
@@ -176,12 +176,13 @@ export const LogsDatePicker = ({ onSubmit, helpers, value }: PropsWithChildren<P
fromDate.setHours(+startTime.HH, +startTime.mm, +startTime.ss)
toDate.setHours(+endTime.HH, +endTime.mm, +endTime.ss)
navigator.clipboard.writeText(
copyToClipboard(
JSON.stringify({
from: fromDate.toISOString(),
to: toDate.toISOString(),
})
)
setCopied(true)
}
@@ -194,13 +195,15 @@ export const LogsDatePicker = ({ onSubmit, helpers, value }: PropsWithChildren<P
}, [pasted])
useEffect(() => {
document.addEventListener('paste', handlePaste)
document.addEventListener('copy', handleCopy)
if (open) {
document.addEventListener('paste', handlePaste)
document.addEventListener('copy', handleCopy)
}
return () => {
document.removeEventListener('paste', handlePaste)
document.removeEventListener('copy', handleCopy)
}
}, [startDate, endDate])
}, [open, startDate, endDate])
const isLargeRange =
Math.abs(dayjs(startDate).diff(dayjs(endDate), 'days')) >
@@ -69,7 +69,7 @@ const LogsFilterPopover = ({
<span>{options.label}</span>
</Button>
</PopoverTrigger>
<PopoverContent align={align} className="p-0 w-60">
<PopoverContent align={align} className="p-0 w-60" portal={true}>
<form
onSubmit={(e) => {
e.preventDefault()
@@ -1,8 +1,10 @@
import Link from 'next/link'
import React, { ReactNode, useState } from 'react'
import { ReactNode, useState } from 'react'
import { Popover, PopoverContent, PopoverTrigger } from '@ui/components/shadcn/ui/popover'
import { IS_PLATFORM } from 'common'
import Table from 'components/to-be-cleaned/Table'
import dayjs from 'dayjs'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useFlag } from 'hooks/ui/useFlag'
import { copyToClipboard } from 'lib/helpers'
@@ -26,11 +28,9 @@ import {
LOGS_SOURCE_DESCRIPTION,
LogsTableName,
} from './Logs.constants'
import { LogsDatePicker, DatePickerValue } from './Logs.DatePickers'
import { DatePickerValue, LogsDatePicker } from './Logs.DatePickers'
import { LogsWarning, LogTemplate, WarehouseCollection } from './Logs.types'
import { WarehouseQueryTemplate } from './Warehouse.utils'
import { Popover, PopoverContent, PopoverTrigger } from '@ui/components/shadcn/ui/popover'
import dayjs from 'dayjs'
export type SourceType = 'logs' | 'warehouse'
export interface LogsQueryPanelProps {
@@ -1,106 +1,50 @@
import type { PostgresTable } from '@supabase/postgres-meta'
import { ExternalLink } from 'lucide-react'
import Link from 'next/link'
import { toast } from 'sonner'
import {
formatFilterURLParams,
saveTableEditorStateToLocalStorageDebounced,
} from 'components/grid/SupabaseGrid.utils'
import { useTableFilter } from 'components/grid/hooks/useTableFilter'
import type { SupaRow } from 'components/grid/types'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useDatabaseColumnDeleteMutation } from 'data/database-columns/database-column-delete-mutation'
import { Entity } from 'data/table-editor/table-editor-types'
import { TableLike } from 'data/table-editor/table-editor-types'
import { useTableRowDeleteAllMutation } from 'data/table-rows/table-row-delete-all-mutation'
import { useTableRowDeleteMutation } from 'data/table-rows/table-row-delete-mutation'
import { useTableRowTruncateMutation } from 'data/table-rows/table-row-truncate-mutation'
import { useTableDeleteMutation } from 'data/tables/table-delete-mutation'
import { TablesData, useGetTables } from 'data/tables/tables-query'
import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState'
import { noop } from 'lib/void'
import { useGetImpersonatedRoleState } from 'state/role-impersonation-state'
import { useTableEditorStateSnapshot } from 'state/table-editor'
import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button, Checkbox } from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { useTableEditorFiltersSort } from 'hooks/misc/useTableEditorFiltersSort'
export type DeleteConfirmationDialogsProps = {
selectedTable?: Entity | PostgresTable
onAfterDeleteTable?: (tables: TablesData) => void
selectedTable?: TableLike
onTableDeleted?: () => void
}
const DeleteConfirmationDialogs = ({
selectedTable,
onAfterDeleteTable = noop,
onTableDeleted,
}: DeleteConfirmationDialogsProps) => {
const { project } = useProjectContext()
const snap = useTableEditorStateSnapshot()
const { selectedSchema } = useQuerySchemaState()
const { filters: filter, setParams } = useTableEditorFiltersSort()
const filters = formatFilterURLParams(filter as string[])
const getTables = useGetTables({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const { filters, onApplyFilters } = useTableFilter()
const removeDeletedColumnFromFiltersAndSorts = ({
ref,
tableName,
schema,
columnName,
}: {
ref: string
tableName: string
schema: string
ref?: string
tableName?: string
schema?: string
columnName: string
}) => {
setParams((prevParams) => {
const existingFilters = (prevParams?.filter ?? []) as string[]
const existingSorts = (prevParams?.sort ?? []) as string[]
const newFiltersAndSorts = {
filter: existingFilters.filter((filter: string) => {
const [column] = filter.split(':')
if (column !== columnName) return filter
}),
sort: existingSorts.filter((sort: string) => {
const [column] = sort.split(':')
if (column !== columnName) return sort
}),
}
// Overwrite local storage without the deleted column
saveTableEditorStateToLocalStorageDebounced({
projectRef: ref,
tableName,
schema,
filters: newFiltersAndSorts.filter,
sorts: newFiltersAndSorts.sort,
})
return {
...prevParams,
...newFiltersAndSorts,
}
})
onApplyFilters(filters.filter((filter) => filter.column !== columnName))
}
const { mutate: deleteColumn } = useDatabaseColumnDeleteMutation({
onSuccess: () => {
if (!(snap.confirmationDialog?.type === 'column')) return
const selectedColumnToDelete = snap.confirmationDialog.column
if (!project?.ref) return
if (!selectedTable?.name) return
removeDeletedColumnFromFiltersAndSorts({
ref: project?.ref,
tableName: selectedTable?.name,
schema: selectedColumnToDelete.schema,
columnName: selectedColumnToDelete.name,
})
removeDeletedColumnFromFiltersAndSorts({ columnName: selectedColumnToDelete.name })
toast.success(`Successfully deleted column "${selectedColumnToDelete.name}"`)
},
onError: (error) => {
@@ -114,9 +58,8 @@ const DeleteConfirmationDialogs = ({
})
const { mutate: deleteTable } = useTableDeleteMutation({
onSuccess: async () => {
const tables = await getTables(selectedSchema)
onAfterDeleteTable(tables)
toast.success(`Successfully deleted table "${selectedTable?.name}"`)
onTableDeleted?.()
},
onError: (error) => {
toast.error(`Failed to delete ${selectedTable?.name}: ${error.message}`)
@@ -231,13 +174,13 @@ const DeleteConfirmationDialogs = ({
truncateRows({
projectRef: project.ref,
connectionString: project.connectionString,
table: selectedTable as any,
table: selectedTable,
})
} else {
deleteAllRows({
projectRef: project.ref,
connectionString: project.connectionString,
table: selectedTable as any,
table: selectedTable,
filters,
roleImpersonationState: getImpersonatedRoleState(),
})
@@ -246,7 +189,7 @@ const DeleteConfirmationDialogs = ({
deleteRows({
projectRef: project.ref,
connectionString: project.connectionString,
table: selectedTable as any,
table: selectedTable,
rows: selectedRowsToDelete as SupaRow[],
roleImpersonationState: getImpersonatedRoleState(),
})
@@ -4,15 +4,9 @@ import { DndProvider } from 'react-dnd'
import { HTML5Backend } from 'react-dnd-html5-backend'
import { useParams } from 'common'
import {
filtersToUrlParams,
formatFilterURLParams,
formatSortURLParams,
sortsToUrlParams,
} from 'components/grid/SupabaseGrid.utils'
import RefreshButton from 'components/grid/components/header/RefreshButton'
import FilterPopover from 'components/grid/components/header/filter/FilterPopover'
import { SortPopover } from 'components/grid/components/header/sort'
import { FilterPopoverPrimitive } from 'components/grid/components/header/filter/FilterPopoverPrimitive'
import { SortPopoverPrimitive } from 'components/grid/components/header/sort/SortPopoverPrimitive'
import type { Filter } from 'components/grid/types'
import { Sort } from 'components/grid/types'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
@@ -65,9 +59,9 @@ const ForeignRowSelector = ({
id: tableId,
})
const [{ sort: sorts, filter: filters }, setParams] = useState<{
filter: string[]
sort: string[]
const [{ sort: sorts, filter: filters }, setFiltersAndSorts] = useState<{
filter: Filter[]
sort: Sort[]
}>({ filter: [], sort: [] })
const onApplyFilters = (appliedFilters: Filter[]) => {
@@ -76,19 +70,19 @@ const ForeignRowSelector = ({
setPage(1)
}
setParams((prevParams) => {
setFiltersAndSorts((prevParams) => {
return {
...prevParams,
filter: filtersToUrlParams(appliedFilters),
filter: appliedFilters,
}
})
}
const onApplySorts = (appliedSorts: Sort[]) => {
setParams((prevParams) => {
setFiltersAndSorts((prevParams) => {
return {
...prevParams,
sort: sortsToUrlParams(appliedSorts),
sort: appliedSorts,
}
})
}
@@ -103,8 +97,8 @@ const ForeignRowSelector = ({
projectRef: project?.ref,
connectionString: project?.connectionString,
tableId: table?.id,
sorts: formatSortURLParams(table?.name || '', sorts),
filters: formatFilterURLParams(filters),
sorts,
filters,
page,
limit: rowsPerPage,
roleImpersonationState: roleImpersonationState as RoleImpersonationState,
@@ -160,13 +154,17 @@ const ForeignRowSelector = ({
<div className="flex items-center justify-between my-2 mx-3">
<div className="flex items-center">
<RefreshButton tableId={table?.id} isRefetching={isRefetching} />
<FilterPopover
<FilterPopoverPrimitive
portal={false}
filters={filters}
onApplyFilters={onApplyFilters}
/>
<DndProvider backend={HTML5Backend} context={window}>
<SortPopover portal={false} sorts={sorts} onApplySorts={onApplySorts} />
<SortPopoverPrimitive
portal={false}
sorts={sorts}
onApplySorts={onApplySorts}
/>
</DndProvider>
</div>
@@ -764,6 +764,7 @@ export const updateTable = async ({
queryClient.invalidateQueries(databaseKeys.foreignKeyConstraints(projectRef, table.schema)),
queryClient.invalidateQueries(databaseKeys.tableDefinition(projectRef, table.id)),
queryClient.invalidateQueries(entityTypeKeys.list(projectRef)),
queryClient.invalidateQueries(tableKeys.list(projectRef, table.schema, true)),
])
// We need to invalidate tableRowsAndCount after tableEditor
@@ -13,16 +13,19 @@ import {
isTableLike,
isView,
} from 'data/table-editor/table-editor-types'
import { useGetTables } from 'data/tables/tables-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState'
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
import { useUrlState } from 'hooks/ui/useUrlState'
import { PROTECTED_SCHEMAS } from 'lib/constants/schemas'
import { useAppStateSnapshot } from 'state/app-state'
import { TableEditorTableStateContextProvider } from 'state/table-editor-table'
import { handleTabClose, makeActiveTabPermanent, useTabsStore } from 'state/tabs'
import { createTabId, handleTabClose, makeActiveTabPermanent, useTabsStore } from 'state/tabs'
import { Button } from 'ui'
import { Admonition, GenericSkeletonLoader } from 'ui-patterns'
import { useIsTableEditorTabsEnabled } from '../App/FeaturePreview/FeaturePreviewContext'
import DeleteConfirmationDialogs from './DeleteConfirmationDialogs'
import SidePanelEditor from './SidePanelEditor/SidePanelEditor'
import TableDefinition from './TableDefinition'
@@ -40,8 +43,10 @@ export const TableGridEditor = ({
const project = useSelectedProject()
const appSnap = useAppStateSnapshot()
const { ref: projectRef, id } = useParams()
const tabs = useTabsStore(projectRef)
const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled()
const { selectedSchema } = useQuerySchemaState()
useLoadTableEditorStateFromLocalStorageIntoUrl({
projectRef,
@@ -55,6 +60,17 @@ export const TableGridEditor = ({
const isReadOnly = !canEditTables && !canEditColumns
const tabId = !!id ? tabs.openTabs.find((x) => x.endsWith(id)) : undefined
const getTables = useGetTables({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const onClearDashboardHistory = () => {
if (projectRef && editor) {
appSnap.setDashboardHistory(projectRef, editor === 'table' ? 'editor' : editor, undefined)
}
}
const onTableCreated = useCallback(
(table: { id: number }) => {
router.push(`/project/${projectRef}/editor/${table.id}`)
@@ -62,11 +78,21 @@ export const TableGridEditor = ({
[projectRef, router]
)
const onClearDashboardHistory = () => {
if (projectRef && editor) {
appSnap.setDashboardHistory(projectRef, editor === 'table' ? 'editor' : editor, undefined)
const onTableDeleted = useCallback(async () => {
// For simplicity for now, we just open the first table within the same schema
if (isTableEditorTabsEnabled && selectedTable) {
// Close tab
const tabId = createTabId(selectedTable.entity_type, { id: selectedTable.id })
handleTabClose({ ref: projectRef, id: tabId, router, editor, onClearDashboardHistory })
} else {
const tables = await getTables(selectedSchema)
if (tables.length > 0) {
router.push(`/project/${projectRef}/editor/${tables[0].id}`)
} else {
router.push(`/project/${projectRef}/editor`)
}
}
}
}, [getTables, isTableEditorTabsEnabled, projectRef, router, selectedSchema])
// NOTE: DO NOT PUT HOOKS AFTER THIS LINE
if (isLoadingSelectedTable || !projectRef) {
@@ -161,6 +187,10 @@ export const TableGridEditor = ({
selectedTable={isTableLike(selectedTable) ? selectedTable : undefined}
onTableCreated={onTableCreated}
/>
<DeleteConfirmationDialogs
selectedTable={isTableLike(selectedTable) ? selectedTable : undefined}
onTableDeleted={onTableDeleted}
/>
</TableEditorTableStateContextProvider>
</div>
)
@@ -28,15 +28,18 @@ import { getSemver, semverGte, semverLte } from './LocalVersionPopover.utils'
export const LocalVersionPopover = () => {
const { data, isSuccess } = useCLIReleaseVersionQuery()
const currentCliVersion = data?.current
const hasLatestCLIVersion = isSuccess && !!data?.latest
const latestCliVersion = data?.latest
const hasLatestCLIVersion = isSuccess && !!latestCliVersion
const current = getSemver(data?.current)
const latest = getSemver(data?.latest)
const current = getSemver(currentCliVersion)
const latest = getSemver(latestCliVersion)
const hasUpdate =
!!current && !!latest ? data?.current !== data?.latest && semverLte(current, latest) : false
!!current && !!latest
? currentCliVersion !== latestCliVersion && semverLte(current, latest)
: false
const isBeta =
!!current && !!latest && data?.current !== data?.latest && semverGte(current, latest)
!!current && !!latest && currentCliVersion !== latestCliVersion && semverGte(current, latest)
const approximateNextRelease = !!data?.published_at
? dayjs(data?.published_at).utc().add(14, 'day').format('DD MMM YYYY')
@@ -180,7 +183,7 @@ export const LocalVersionPopover = () => {
{hasLatestCLIVersion && hasUpdate && !isBeta && (
<div className="flex flex-col gap-y-1">
<p className="text-xs">Available version:</p>
<p className="text-sm font-mono">{data.latest}</p>
<p className="text-sm font-mono">{latestCliVersion}</p>
</div>
)}
</div>
@@ -1,15 +1,16 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useDebounce } from '@uidotdev/usehooks'
import { FilePlus, FolderPlus, Plus, X } from 'lucide-react'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useLocalStorage } from 'hooks/misc/useLocalStorage'
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
import { LOCAL_STORAGE_KEYS } from 'lib/constants'
import { useProfile } from 'lib/profile'
import { FilePlus, FolderPlus, Plus, X } from 'lucide-react'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { getAppStateSnapshot } from 'state/app-state'
import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
import {
@@ -37,8 +38,8 @@ export const SQLEditorMenu = () => {
const { profile } = useProfile()
const project = useSelectedProject()
const { ref } = useParams()
const snapV2 = useSqlEditorV2StateSnapshot()
const [search, setSearch] = useState('')
const [showSearch, setShowSearch] = useState(false)
const [sort, setSort] = useLocalStorage<'name' | 'inserted_at'>(
@@ -29,7 +29,7 @@ import {
useSnippetFolders,
useSqlEditorV2StateSnapshot,
} from 'state/sql-editor-v2'
import { createTabId, getTabsStore, makeTabPermanent, removeTabs } from 'state/tabs'
import { createTabId, getTabsStore, makeTabPermanent, removeTabs, useTabsStore } from 'state/tabs'
import { SqlSnippets } from 'types'
import { Separator, TreeView } from 'ui'
import {
@@ -40,7 +40,6 @@ import {
InnerSideMenuSeparator,
} from 'ui-patterns'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { useSnapshot } from 'valtio'
import SQLEditorLoadingSnippets from './SQLEditorLoadingSnippets'
import { formatFolderResponseForTreeView, getLastItemIds, ROOT_NODE } from './SQLEditorNav.utils'
import { SQLEditorTreeViewItem } from './SQLEditorTreeViewItem'
@@ -61,7 +60,7 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
const snapV2 = useSqlEditorV2StateSnapshot()
const tabStore = getTabsStore(projectRef)
const tabs = useSnapshot(tabStore)
const tabs = useTabsStore(projectRef)
const isSQLEditorTabsEnabled = useIsSQLEditorTabsEnabled()
const [sectionVisibility, setSectionVisibility] = useLocalStorage<SectionState>(
@@ -540,11 +539,10 @@ export const SQLEditorNav = ({ sort = 'inserted_at' }: SQLEditorNavProps) => {
}, [projectRef, sharedSqlSnippetsData?.pages])
useEffect(() => {
if (projectRef && isSuccess && isSharedSqlSnippetsSuccess && isSQLEditorTabsEnabled) {
if (projectRef && isSuccess && isSQLEditorTabsEnabled) {
sqlEditorTabsCleanup({ ref: projectRef, snippets: allSnippetsInView as any })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSuccess, isSharedSqlSnippetsSuccess, allSnippetsInView])
}, [isSuccess, isSharedSqlSnippetsSuccess, allSnippetsInView, isSQLEditorTabsEnabled])
return (
<>
@@ -29,7 +29,7 @@ export const SearchList = ({ search }: SearchListProps) => {
{ keepPreviousData: true }
)
const { data: count, isLoading: isLoadingCount } = useContentCountQuery(
const { data: count } = useContentCountQuery(
{
projectRef,
cumulative: true,
@@ -40,6 +40,7 @@ import {
InnerSideBarFilters,
} from 'ui-patterns/InnerSideMenu'
import { useProjectContext } from '../ProjectLayout/ProjectContext'
import { tableEditorTabsCleanUp } from '../Tabs/Tabs.utils'
import EntityListItem from './EntityListItem'
import { TableMenuEmptyState } from './TableMenuEmptyState'
@@ -48,8 +49,8 @@ const TableEditorMenu = () => {
const id = _id ? Number(_id) : undefined
const snap = useTableEditorStateSnapshot()
const { selectedSchema, setSelectedSchema } = useQuerySchemaState()
const isMobile = useBreakpoint()
const isTableEditorTabsEnabled = useIsTableEditorTabsEnabled()
const isMobile = useBreakpoint()
const [showModal, setShowModal] = useState(false)
const [searchText, setSearchText] = useState<string>('')
@@ -114,6 +115,13 @@ const TableEditorMenu = () => {
}
}, [selectedTable?.schema])
useEffect(() => {
// Clean up tabs + recent items for any tables that might have been removed outside of the dashboard session
if (isTableEditorTabsEnabled && ref && entityTypes && !searchText) {
tableEditorTabsCleanUp({ ref, schemas: [selectedSchema], entities: entityTypes })
}
}, [entityTypes])
return (
<>
<div className="flex flex-col flex-grow gap-5 pt-5 h-full">
@@ -5,7 +5,7 @@ import {
RecentItem,
removeRecentItems,
} from 'state/recent-items'
import { createTabId, getTabsStore, removeTabs } from 'state/tabs'
import { createTabId, editorEntityTypes, getTabsStore, removeTabs, updateTab } from 'state/tabs'
export const tableEditorTabsCleanUp = ({
ref,
@@ -58,6 +58,18 @@ export const tableEditorTabsCleanUp = ({
// perform tabs cleanup
removeTabs(ref, tableEditorTabsToBeCleaned)
// [Joshen] Validate for opened tabs, if their label matches the entity's name - update label if not
// As the entity could've been renamed outside of the table editor
// e.g Using the SQL editor to rename the entity
const openTabs = tabsStore.openTabs
.map((id) => tabsStore.tabsMap[id])
.filter((tab) => editorEntityTypes['table']?.includes(tab.type))
openTabs.forEach((tab) => {
const entity = entities?.find((x) => tab.metadata?.tableId === x.id)
if (!!entity && entity.name !== tab.label) updateTab(ref, tab.id, { label: entity.name })
})
}
export const sqlEditorTabsCleanup = ({
@@ -65,7 +77,7 @@ export const sqlEditorTabsCleanup = ({
snippets,
}: {
ref: string
snippets: { id: string; type: string }[]
snippets: { id: string; type: string; name: string }[]
}) => {
// these are tabs that are static content
// these canot be removed from localstorage based on this query request
@@ -95,4 +107,16 @@ export const sqlEditorTabsCleanup = ({
? recentItems.filter((item) => !currentContentIds.includes(item.id)).map((item) => item.id)
: []
)
// [Joshen] Validate for opened tabs, if their label matches the snippet's name - update label if not
// As the snippets name could've been updated outside of the SQL Editor session
// e.g for a shared snippet, the owner could've updated the name of the snippet
const openSqlTabs = tabsStore.openTabs
.map((id) => tabsStore.tabsMap[id])
.filter((tab) => editorEntityTypes['sql']?.includes(tab.type))
openSqlTabs.forEach((tab) => {
const snippet = snippets?.find((x) => tab.metadata?.sqlId === x.id)
if (!!snippet && snippet.name !== tab.label) updateTab(ref, tab.id, { label: snippet.name })
})
}
@@ -1,6 +1,7 @@
import { noop } from 'lodash'
import { useEffect, useState } from 'react'
import { Alert, Button, Modal } from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { StorageItem } from '../Storage.types'
interface ConfirmDeleteModalProps {
@@ -10,7 +11,7 @@ interface ConfirmDeleteModalProps {
onSelectDelete: () => void
}
const ConfirmDeleteModal = ({
export const ConfirmDeleteModal = ({
visible = false,
selectedItemsToDelete = [],
onSelectCancel = noop,
@@ -42,28 +43,19 @@ const ConfirmDeleteModal = ({
}
return (
<Modal
<ConfirmationModal
visible={visible}
header={<span className="break-words">{title}</span>}
title={<span className="break-words">{title}</span>}
size="medium"
onCancel={onSelectCancel}
customFooter={
<div className="flex items-center gap-2">
<Button type="default" disabled={deleting} onClick={onSelectCancel}>
Cancel
</Button>
<Button type="danger" disabled={deleting} loading={deleting} onClick={onConfirmDelete}>
{deleting ? 'Deleting' : 'Delete'}
</Button>
</div>
}
>
<Modal.Content>
<Alert withIcon variant="danger" title={`This action cannot be undone.`}>
{description}
</Alert>
</Modal.Content>
</Modal>
onConfirm={onConfirmDelete}
variant="destructive"
alert={{
base: { variant: 'destructive' },
title: 'This action cannot be undone',
description,
}}
/>
)
}
@@ -9,7 +9,7 @@ import { IS_PLATFORM } from 'lib/constants'
import { useStorageStore } from 'localStores/storageExplorer/StorageExplorerStore'
import { useStorageExplorerStateSnapshot } from 'state/storage-explorer'
import { STORAGE_ROW_TYPES, STORAGE_VIEWS } from '../Storage.constants'
import ConfirmDeleteModal from './ConfirmDeleteModal'
import { ConfirmDeleteModal } from './ConfirmDeleteModal'
import CustomExpiryModal from './CustomExpiryModal'
import FileExplorer from './FileExplorer'
import FileExplorerHeader from './FileExplorerHeader'
@@ -1,21 +1,21 @@
'use client'
import { useState, ComponentProps, useEffect } from 'react'
import dayjs from 'dayjs'
import { useTheme } from 'next-themes'
import { ComponentProps, useEffect, useState } from 'react'
import {
Area,
Bar,
ComposedChart as RechartComposedChart,
XAxis,
YAxis,
CartesianGrid,
Line,
ComposedChart as RechartComposedChart,
ReferenceArea,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import { CategoricalChartState } from 'recharts/types/chart/types'
import { cn } from 'ui'
import { useTheme } from 'next-themes'
import ChartHeader from './ChartHeader'
import ChartHighlightActions from './ChartHighlightActions'
import {
@@ -24,17 +24,17 @@ import {
STACKED_CHART_COLORS,
updateStackedChartColors,
} from './Charts.constants'
import { Datum, CommonChartProps } from './Charts.types'
import { useChartSize, numberFormatter } from './Charts.utils'
import { ChartHighlight } from './useChartHighlight'
import NoDataPlaceholder from './NoDataPlaceholder'
import { MultiAttribute } from './ComposedChartHandler'
import { CommonChartProps, Datum } from './Charts.types'
import { numberFormatter, useChartSize } from './Charts.utils'
import {
calculateTotalChartAggregate,
CustomLabel,
CustomTooltip,
formatBytes,
} from './ComposedChart.utils'
import { MultiAttribute } from './ComposedChartHandler'
import NoDataPlaceholder from './NoDataPlaceholder'
import { ChartHighlight } from './useChartHighlight'
export interface BarChartProps<D = Datum> extends CommonChartProps<D> {
attributes: MultiAttribute[]
@@ -1,12 +1,11 @@
'use client'
import { useState } from 'react'
import dayjs from 'dayjs'
import { useState } from 'react'
import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import { DateTimeFormats } from './Charts.constants'
import { numberFormatter } from './Charts.utils'
import { MultiAttribute } from './ComposedChartHandler'
import { Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import { cn } from 'ui'
interface CustomIconProps {
color: string
@@ -232,4 +231,4 @@ const CustomLabel = ({ payload, attributes, showMaxValue, onLabelHover }: Custom
)
}
export { CustomTooltip, CustomLabel }
export { CustomLabel, CustomTooltip }
+1 -1
View File
@@ -83,7 +83,7 @@ export const FilterPopover = <T extends Record<string, any>>({
</div>
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ className={cn('p-0 w-44', className)} align="start">
<PopoverContent_Shadcn_ className={cn('p-0 w-44', className)} align="start" portal={true}>
<div className="border-b border-overlay bg-surface-200 rounded-t pb-1 px-3">
<span className="text-xs text-foreground-light">
{title ?? `Select ${name.toLowerCase()}`}
@@ -1,6 +1,6 @@
import { Code, Play } from 'lucide-react'
import { DragEvent, ReactNode, useEffect, useMemo, useState } from 'react'
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import { Bar, BarChart, CartesianGrid, Cell, Tooltip, XAxis, YAxis } from 'recharts'
import { toast } from 'sonner'
import { useParams } from 'common'
@@ -12,9 +12,10 @@ import { QueryResponseError, useExecuteSqlMutation } from 'data/sql/execute-sql-
import dayjs from 'dayjs'
import { Parameter, parseParameters } from 'lib/sql-parameters'
import { Dashboards } from 'types'
import { ChartContainer, ChartTooltip, ChartTooltipContent, cn, CodeBlock, SQL_ICON } from 'ui'
import { ChartContainer, ChartTooltipContent, cn, CodeBlock, SQL_ICON } from 'ui'
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
import { ButtonTooltip } from '../ButtonTooltip'
import { CHART_COLORS } from '../Charts/Charts.constants'
import SqlWarningAdmonition from '../SqlWarningAdmonition'
import { BlockViewConfiguration } from './BlockViewConfiguration'
import { EditQueryButton } from './EditQueryButton'
@@ -130,6 +131,7 @@ export const QueryBlock = ({
const [readOnlyError, setReadOnlyError] = useState(false)
const [queryError, setQueryError] = useState<QueryResponseError>()
const [queryResult, setQueryResult] = useState<any[] | undefined>(results)
const [focusDataIndex, setFocusDataIndex] = useState<number>()
const formattedQueryResult = useMemo(() => {
// Make sure Y axis values are numbers
@@ -379,10 +381,9 @@ export const QueryBlock = ({
<p className="text-foreground-light text-xs">Select columns for the X and Y axes</p>
</div>
) : (
<div className={cn('flex-1 w-full')}>
<div className="flex-1 w-full">
<ChartContainer
className="aspect-auto px-3 py-2"
config={{}}
style={{
height: maxHeight ? `${maxHeight}px` : undefined,
minHeight: maxHeight ? `${maxHeight}px` : undefined,
@@ -392,12 +393,18 @@ export const QueryBlock = ({
accessibilityLayer
margin={{ left: -20, right: 0, top: 10 }}
data={chartData}
onMouseMove={(e: any) => {
if (e.activeTooltipIndex !== focusDataIndex) {
setFocusDataIndex(e.activeTooltipIndex)
}
}}
onMouseLeave={() => setFocusDataIndex(undefined)}
>
<CartesianGrid vertical={false} />
<CartesianGrid vertical={false} stroke={CHART_COLORS.AXIS} />
<XAxis
tickLine
dataKey={xKey}
axisLine={false}
tickLine={{ stroke: CHART_COLORS.AXIS }}
axisLine={{ stroke: CHART_COLORS.AXIS }}
interval="preserveStartEnd"
tickMargin={4}
minTickGap={32}
@@ -406,8 +413,18 @@ export const QueryBlock = ({
}
/>
<YAxis tickLine={false} axisLine={false} tickMargin={4} />
<ChartTooltip content={<ChartTooltipContent className="w-[150px]" />} />
<Bar dataKey={yKey} fill="var(--chart-1)" radius={4} />
<Tooltip content={<ChartTooltipContent className="w-[150px]" />} />
<Bar radius={1} dataKey={yKey}>
{chartData?.map((_: any, index: number) => (
<Cell
key={`cell-${index}`}
className="transition-all duration-100"
fill="var(--chart-1)"
opacity={focusDataIndex === undefined || focusDataIndex === index ? 1 : 0.4}
enableBackground={12}
/>
))}
</Bar>
</BarChart>
</ChartContainer>
</div>
+2 -1
View File
@@ -123,9 +123,10 @@ const SchemaSelector = ({
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_
className="p-0 min-w-[200px]"
className="p-0 min-w-[200px] pointer-events-auto"
side="bottom"
align="start"
portal={true}
sameWidthAsTrigger
>
<Command_Shadcn_>
+6 -3
View File
@@ -2,7 +2,7 @@ import { useQuery, UseQueryOptions } from '@tanstack/react-query'
import type { components } from 'data/api'
import { get, handleError } from 'data/fetchers'
import { useIsOrioleDb } from 'hooks/misc/useSelectedProject'
import { useIsOrioleDbInAws } from 'hooks/misc/useSelectedProject'
import type { ResponseError } from 'types'
import { databaseKeys } from './keys'
@@ -32,11 +32,14 @@ export const useBackupsQuery = <TData = BackupsData>(
{ enabled = true, ...options }: UseQueryOptions<BackupsData, BackupsError, TData> = {}
) => {
// [Joshen] Check for specifically false to account for project not loaded yet
const isOrioleDb = useIsOrioleDb()
const isOrioleDbInAws = useIsOrioleDbInAws()
return useQuery<BackupsData, BackupsError, TData>(
databaseKeys.backups(projectRef),
({ signal }) => getBackups({ projectRef }, signal),
{ enabled: enabled && isOrioleDb === false && typeof projectRef !== 'undefined', ...options }
{
enabled: enabled && !isOrioleDbInAws && typeof projectRef !== 'undefined',
...options,
}
)
}
+15 -76
View File
@@ -1,16 +1,13 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { databaseExtensionsKeys } from 'data/database-extensions/keys'
import { databasePoliciesKeys } from 'data/database-policies/keys'
import { databaseRoleKeys } from 'data/database-roles/keys'
import { databaseTriggerKeys } from 'data/database-triggers/keys'
import { databaseKeys } from 'data/database/keys'
import { entityTypeKeys } from 'data/entity-types/keys'
import { enumeratedTypesKeys } from 'data/enumerated-types/keys'
import { tableKeys } from 'data/tables/keys'
import { executeSql, ExecuteSqlData, ExecuteSqlVariables } from './execute-sql-query'
// [Joshen] Intention is that we invalidate all database related keys whenever running a mutation related query
// So we attempt to ignore all the non-related query keys. We could probably look into grouping our query keys better
// actually to not make this too hacky here
const INVALIDATION_KEYS_IGNORE = ['branches', 'settings-v2', 'addons', 'custom-domains', 'content']
export type QueryResponseError = {
code: string
message: string
@@ -41,9 +38,16 @@ export const useExecuteSqlMutation = ({
const { contextualInvalidation, sql, projectRef } = variables
// [Joshen] Default to false for now, only used for SQL editor to dynamically invalidate
if (contextualInvalidation && projectRef) {
const invalidationKeys = inferInvalidationKeys(projectRef, sql)
await Promise.all(invalidationKeys.map((key) => queryClient.invalidateQueries(key)))
const sqlLower = sql.toLowerCase()
const isMutationSQL =
sqlLower.includes('create ') || sqlLower.includes('alter ') || sqlLower.includes('drop ')
if (contextualInvalidation && projectRef && isMutationSQL) {
const databaseRelatedKeys = queryClient
.getQueryCache()
.findAll(['projects', projectRef])
.map((x) => x.queryKey)
.filter((x) => !INVALIDATION_KEYS_IGNORE.some((a) => x.includes(a)))
await Promise.all(databaseRelatedKeys.map((key) => queryClient.invalidateQueries(key)))
}
await onSuccess?.(data, variables, context)
},
@@ -58,68 +62,3 @@ export const useExecuteSqlMutation = ({
}
)
}
// [Joshen] Can expand this further eventually, but just covering certain easy cases for now
const inferInvalidationKeys = (ref: string, sql: string) => {
const keys = []
const sqlLower = sql.toLowerCase()
if (
sqlLower.includes('create table') ||
sqlLower.includes('alter table') ||
sqlLower.includes('drop table')
) {
keys.push(entityTypeKeys.list(ref))
keys.push(tableKeys.list(ref))
}
if (
sqlLower.includes('create schema') ||
sqlLower.includes('alter schema') ||
sqlLower.includes('drop schema')
) {
keys.push(databaseKeys.schemas(ref))
}
if (
sqlLower.includes('create function') ||
sqlLower.includes('alter function') ||
sqlLower.includes('drop function')
) {
keys.push(databaseKeys.databaseFunctions(ref))
}
if (
sqlLower.includes('create trigger') ||
sqlLower.includes('alter trigger') ||
sqlLower.includes('drop trigger')
) {
keys.push(databaseTriggerKeys.list(ref))
}
if (
sqlLower.includes('create policy') ||
sqlLower.includes('alter policy') ||
sqlLower.includes('drop policy')
) {
keys.push(databasePoliciesKeys.list(ref))
}
if (
sqlLower.includes('create type') ||
sqlLower.includes('alter type') ||
sqlLower.includes('drop type')
) {
keys.push(enumeratedTypesKeys.list(ref))
}
if (
sqlLower.includes('create role') ||
sqlLower.includes('alter role') ||
sqlLower.includes('drop role')
) {
keys.push(databaseRoleKeys.databaseRoles(ref))
}
if (sqlLower.includes('create index') || sqlLower.includes('drop index')) {
keys.push(databaseKeys.indexes(ref))
}
if (sqlLower.includes('create extension') || sqlLower.includes('drop extension')) {
keys.push(databaseExtensionsKeys.list(ref))
}
return keys
}
+2 -2
View File
@@ -1,4 +1,4 @@
export const tableEditorKeys = {
tableEditor: (projectRef: string | undefined, id: number | undefined) =>
['projects', projectRef, 'table-editor', id] as const,
tableEditor: (projectRef: string | undefined, id?: number) =>
['projects', projectRef, 'table-editor', id].filter(Boolean),
}
@@ -45,6 +45,8 @@ export interface ForeignTable {
export type Entity = Table | PartitionedTable | View | MaterializedView | ForeignTable
export type TableLike = Table | PartitionedTable
export function isTable(entity?: Entity): entity is Table {
return entity?.entity_type === ENTITY_TYPE.TABLE
}
@@ -57,7 +59,7 @@ export function isPartitionedTable(entity?: Entity): entity is PartitionedTable
* Returns true if the entity is a Table or a PartitionedTable.
* Foreign tables are not considered table-like.
*/
export function isTableLike(entity?: Entity): entity is Table | PartitionedTable {
export function isTableLike(entity?: Entity): entity is TableLike {
return isTable(entity) || isPartitionedTable(entity)
}
@@ -79,3 +81,36 @@ export function isMaterializedView(entity?: Entity): entity is MaterializedView
export function isViewLike(entity?: Entity): entity is View | MaterializedView {
return isView(entity) || isMaterializedView(entity)
}
export function postgresTableToEntity(table: PostgresTable): Entity | undefined {
if (table.columns === undefined || table.relationships === undefined) {
console.error(
'Unable to convert PostgresTable to Entity type: columns and relationships must not be undefined.'
)
return undefined
}
const tableRelationships: TableRelationship[] = table.relationships.map((rel) => ({
deletion_action: 'a',
update_action: 'a',
...rel,
}))
return {
id: table.id,
schema: table.schema,
name: table.name,
comment: table.comment,
rls_enabled: table.rls_enabled,
rls_forced: table.rls_forced,
replica_identity: table.replica_identity,
bytes: table.bytes,
size: table.size,
live_rows_estimate: table.live_rows_estimate,
dead_rows_estimate: table.dead_rows_estimate,
columns: table.columns,
relationships: tableRelationships,
primary_keys: table.primary_keys,
entity_type: ENTITY_TYPE.TABLE,
}
}
@@ -2,8 +2,9 @@ import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react
import { toast } from 'sonner'
import { Query } from '@supabase/pg-meta/src/query'
import type { Filter, SupaTable } from 'components/grid/types'
import type { Filter } from 'components/grid/types'
import { executeSql } from 'data/sql/execute-sql-query'
import { Entity } from 'data/table-editor/table-editor-types'
import { RoleImpersonationState, wrapWithRoleImpersonation } from 'lib/role-impersonation'
import { isRoleImpersonationEnabled } from 'state/role-impersonation-state'
import type { ResponseError } from 'types'
@@ -13,7 +14,7 @@ import { formatFilterValue } from './utils'
export type TableRowDeleteAllVariables = {
projectRef: string
connectionString?: string
table: SupaTable
table: Entity
filters: Filter[]
roleImpersonationState?: RoleImpersonationState
}
+25 -20
View File
@@ -1,3 +1,5 @@
import { Query } from '@supabase/pg-meta/src/query'
import { getTableRowsSql } from '@supabase/pg-meta/src/query/table-row-query'
import {
useQuery,
useQueryClient,
@@ -5,8 +7,6 @@ import {
type UseQueryOptions,
} from '@tanstack/react-query'
import { Query } from '@supabase/pg-meta/src/query'
import { getTableRowsSql } from '@supabase/pg-meta/src/query/table-row-query'
import { IS_PLATFORM } from 'common'
import { parseSupaTable } from 'components/grid/SupabaseGrid.utils'
import { Filter, Sort, SupaRow, SupaTable } from 'components/grid/types'
@@ -142,9 +142,9 @@ export const fetchAllTableRows = async ({
)
try {
const { result } = await executeWithRetry(async () =>
const { result } = await executeWithRetry(async () => {
executeSql({ projectRef, connectionString, sql: query })
)
})
rows.push(...result)
progressCallback?.(rows.length)
@@ -153,7 +153,7 @@ export const fetchAllTableRows = async ({
await sleep(THROTTLE_DELAY)
} catch (error) {
throw new Error(
`Error fetching table rows: ${error instanceof Error ? error.message : 'Unknown error'}`
`Error fetching all table rows: ${error instanceof Error ? error.message : 'Unknown error'}`
)
}
}
@@ -202,23 +202,28 @@ export async function getTableRows(
getTableRowsSql({ table: entity, filters, sorts, limit, page }),
roleImpersonationState
)
const { result } = await executeSql(
{
projectRef,
connectionString,
sql,
queryKey: ['table-rows', table?.id],
isRoleImpersonationEnabled: isRoleImpersonationEnabled(roleImpersonationState?.role),
},
signal
)
const rows = result.map((x: any, index: number) => {
return { idx: index, ...x }
}) as SupaRow[]
try {
const { result } = await executeSql(
{
projectRef,
connectionString,
sql,
queryKey: ['table-rows', table?.id],
isRoleImpersonationEnabled: isRoleImpersonationEnabled(roleImpersonationState?.role),
},
signal
)
return {
rows,
const rows = result.map((x: any, index: number) => {
return { idx: index, ...x }
}) as SupaRow[]
return { rows }
} catch (error) {
throw new Error(
`Error fetching table rows: ${error instanceof Error ? error.message : 'Unknown error'}`
)
}
}
+10 -2
View File
@@ -1,4 +1,4 @@
import type { Filter, ServiceError, SupaTable } from 'components/grid/types'
import type { Filter, ServiceError } from 'components/grid/types'
import { isNumericalColumn } from 'components/grid/utils/types'
import { Entity, isTableLike } from 'data/table-editor/table-editor-types'
@@ -6,7 +6,15 @@ import { Entity, isTableLike } from 'data/table-editor/table-editor-types'
* temporary fix until we implement a better filter UI
* which validate input value base on the column type
*/
export function formatFilterValue(table: SupaTable, filter: Filter) {
export function formatFilterValue(
table: {
columns: {
name: string
format: string
}[]
},
filter: Filter
) {
const column = table.columns.find((x) => x.name == filter.column)
if (column && isNumericalColumn(column.format)) {
const numberValue = Number(filter.value)
+10 -1
View File
@@ -1,7 +1,9 @@
import { useMemo } from 'react'
import { useIsLoggedIn, useParams } from 'common'
import { useProjectDetailQuery } from 'data/projects/project-detail-query'
import { ProjectInfo, useProjectsQuery } from 'data/projects/projects-query'
import { useMemo } from 'react'
import { PROVIDERS } from 'lib/constants'
export function useSelectedProject({ enabled = true } = {}) {
const { ref } = useParams()
@@ -37,3 +39,10 @@ export const useIsOrioleDb = () => {
const isOrioleDb = project?.dbVersion?.endsWith('orioledb')
return isOrioleDb
}
export const useIsOrioleDbInAws = () => {
const project = useSelectedProject()
const isOrioleDbInAws =
project?.dbVersion?.endsWith('orioledb') && project?.cloud_provider === PROVIDERS.AWS.id
return isOrioleDbInAws
}
+1 -1
View File
@@ -8,7 +8,7 @@ type GitHubRepositoryRelease = {
published_at: string
}
const current = process.env.CURRENT_CLI_VERSION
const current = `v${process.env.CURRENT_CLI_VERSION}`
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
try {
@@ -5,6 +5,7 @@ import { useParams } from 'common'
import DatabaseBackupsNav from 'components/interfaces/Database/Backups/DatabaseBackupsNav'
import { PITRNotice, PITRSelection } from 'components/interfaces/Database/Backups/PITR'
import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout'
import DefaultLayout from 'components/layouts/DefaultLayout'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
import AlertError from 'components/ui/AlertError'
@@ -16,12 +17,11 @@ import UpgradeToPro from 'components/ui/UpgradeToPro'
import { useBackupsQuery } from 'data/database/backups-query'
import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions'
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useIsOrioleDb } from 'hooks/misc/useSelectedProject'
import { useIsOrioleDbInAws } from 'hooks/misc/useSelectedProject'
import { PROJECT_STATUS } from 'lib/constants'
import type { NextPageWithLayout } from 'types'
import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_ } from 'ui'
import { Admonition } from 'ui-patterns'
import DefaultLayout from 'components/layouts/DefaultLayout'
const DatabasePhysicalBackups: NextPageWithLayout = () => {
return (
@@ -51,7 +51,7 @@ const PITR = () => {
const { ref: projectRef } = useParams()
const { project } = useProjectContext()
const organization = useSelectedOrganization()
const isOrioleDb = useIsOrioleDb()
const isOrioleDbInAws = useIsOrioleDbInAws()
const { data: backups, error, isLoading, isError, isSuccess } = useBackupsQuery({ projectRef })
const plan = organization?.plan?.id
@@ -65,7 +65,7 @@ const PITR = () => {
return <NoPermission resourceText="view PITR backups" />
}
if (isOrioleDb) {
if (isOrioleDbInAws) {
return (
<Admonition
type="default"
@@ -5,6 +5,7 @@ import { useParams } from 'common'
import { BackupsList } from 'components/interfaces/Database'
import DatabaseBackupsNav from 'components/interfaces/Database/Backups/DatabaseBackupsNav'
import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout'
import DefaultLayout from 'components/layouts/DefaultLayout'
import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
import AlertError from 'components/ui/AlertError'
import { DocsButton } from 'components/ui/DocsButton'
@@ -14,17 +15,16 @@ import NoPermission from 'components/ui/NoPermission'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { useBackupsQuery } from 'data/database/backups-query'
import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions'
import { useIsOrioleDb } from 'hooks/misc/useSelectedProject'
import { useIsOrioleDbInAws } from 'hooks/misc/useSelectedProject'
import type { NextPageWithLayout } from 'types'
import { Admonition } from 'ui-patterns'
import DefaultLayout from 'components/layouts/DefaultLayout'
const DatabaseScheduledBackups: NextPageWithLayout = () => {
const { ref: projectRef } = useParams()
const { data: backups, error, isLoading, isError, isSuccess } = useBackupsQuery({ projectRef })
const isOrioleDb = useIsOrioleDb()
const isOrioleDbInAws = useIsOrioleDbInAws()
const isPitrEnabled = backups?.pitr_enabled
const isPermissionsLoaded = usePermissionsLoaded()
@@ -39,7 +39,7 @@ const DatabaseScheduledBackups: NextPageWithLayout = () => {
<DatabaseBackupsNav active="scheduled" />
{isOrioleDb ? (
{isOrioleDbInAws ? (
<Admonition
type="default"
title="Database backups are not available for OrioleDB"
@@ -11,6 +11,7 @@ import { useTableEditorQuery } from 'data/table-editor/table-editor-query'
import { isTableLike } from 'data/table-editor/table-editor-types'
import { ChevronRight } from 'lucide-react'
import { useTableEditorStateSnapshot } from 'state/table-editor'
import { TableEditorTableStateContextProvider } from 'state/table-editor-table'
import type { NextPageWithLayout } from 'types'
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
@@ -50,11 +51,16 @@ const DatabaseTables: NextPageWithLayout = () => {
</ScaffoldSection>
</ScaffoldContainer>
<DeleteConfirmationDialogs selectedTable={selectedTable} />
<SidePanelEditor
includeColumns
selectedTable={isTableLike(selectedTable) ? selectedTable : undefined}
/>
{project?.ref !== undefined && selectedTable !== undefined && isTableLike(selectedTable) && (
<TableEditorTableStateContextProvider
key={`table-editor-table-${selectedTable.id}`}
projectRef={project?.ref}
table={selectedTable}
>
<DeleteConfirmationDialogs selectedTable={selectedTable} />
<SidePanelEditor includeColumns selectedTable={selectedTable} />
</TableEditorTableStateContextProvider>
)}
</>
)
}
@@ -1,6 +1,7 @@
import type { PostgresTable } from '@supabase/postgres-meta'
import { useState } from 'react'
import { PostgresTable } from '@supabase/postgres-meta'
import { useParams } from 'common'
import { TableList } from 'components/interfaces/Database'
import { SidePanelEditor } from 'components/interfaces/TableGridEditor'
import DeleteConfirmationDialogs from 'components/interfaces/TableGridEditor/DeleteConfirmationDialogs'
@@ -8,12 +9,15 @@ import DatabaseLayout from 'components/layouts/DatabaseLayout/DatabaseLayout'
import DefaultLayout from 'components/layouts/DefaultLayout'
import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold'
import { FormHeader } from 'components/ui/Forms/FormHeader'
import { Entity, isTableLike, postgresTableToEntity } from 'data/table-editor/table-editor-types'
import { useTableEditorStateSnapshot } from 'state/table-editor'
import { TableEditorTableStateContextProvider } from 'state/table-editor-table'
import type { NextPageWithLayout } from 'types'
const DatabaseTables: NextPageWithLayout = () => {
const { ref: projectRef } = useParams()
const snap = useTableEditorStateSnapshot()
const [selectedTableToEdit, setSelectedTableToEdit] = useState<PostgresTable>()
const [selectedTableToEdit, setSelectedTableToEdit] = useState<Entity>()
return (
<>
@@ -24,15 +28,15 @@ const DatabaseTables: NextPageWithLayout = () => {
<TableList
onAddTable={snap.onAddTable}
onEditTable={(table) => {
setSelectedTableToEdit(table)
setSelectedTableToEdit(postgresTableToEntity(table))
snap.onEditTable()
}}
onDeleteTable={(table) => {
setSelectedTableToEdit(table)
setSelectedTableToEdit(postgresTableToEntity(table))
snap.onDeleteTable()
}}
onDuplicateTable={(table) => {
setSelectedTableToEdit(table)
setSelectedTableToEdit(postgresTableToEntity(table))
snap.onDuplicateTable()
}}
/>
@@ -40,8 +44,19 @@ const DatabaseTables: NextPageWithLayout = () => {
</ScaffoldSection>
</ScaffoldContainer>
<DeleteConfirmationDialogs selectedTable={selectedTableToEdit} />
<SidePanelEditor includeColumns selectedTable={selectedTableToEdit} />
{projectRef !== undefined &&
selectedTableToEdit !== undefined &&
isTableLike(selectedTableToEdit) && (
<TableEditorTableStateContextProvider
key={`table-editor-table-${selectedTableToEdit.id}`}
projectRef={projectRef}
table={selectedTableToEdit}
>
<DeleteConfirmationDialogs selectedTable={selectedTableToEdit} />
</TableEditorTableStateContextProvider>
)}
<SidePanelEditor includeColumns selectedTable={selectedTableToEdit as PostgresTable} />
</>
)
}
@@ -1,9 +1,7 @@
import { useRouter } from 'next/router'
import { useCallback, useEffect } from 'react'
import { useEffect } from 'react'
import { useParams } from 'common'
import { useIsTableEditorTabsEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import DeleteConfirmationDialogs from 'components/interfaces/TableGridEditor/DeleteConfirmationDialogs'
import { TableGridEditor } from 'components/interfaces/TableGridEditor/TableGridEditor'
import DefaultLayout from 'components/layouts/DefaultLayout'
import { EditorBaseLayout } from 'components/layouts/editors/EditorBaseLayout'
@@ -11,7 +9,6 @@ import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectConte
import TableEditorLayout from 'components/layouts/TableEditorLayout/TableEditorLayout'
import TableEditorMenu from 'components/layouts/TableEditorLayout/TableEditorMenu'
import { useTableEditorQuery } from 'data/table-editor/table-editor-query'
import { TablesData } from 'data/tables/tables-query'
import { addTab, createTabId, getTabsStore } from 'state/tabs'
import type { NextPageWithLayout } from 'types'
@@ -27,19 +24,6 @@ const TableEditorPage: NextPageWithLayout = () => {
id,
})
const router = useRouter()
const onAfterDeleteTable = useCallback(
(tables: TablesData) => {
// For simplicity for now, we just open the first table within the same schema
if (tables.length > 0) {
router.push(`/project/${projectRef}/editor/${tables[0].id}`)
} else {
router.push(`/project/${projectRef}/editor`)
}
},
[router, projectRef]
)
/**
* Effect: Creates or updates tab when table is loaded
* Runs when:
@@ -70,15 +54,7 @@ const TableEditorPage: NextPageWithLayout = () => {
}
}, [selectedTable, id, projectRef, isTableEditorTabsEnabled])
return (
<>
<TableGridEditor isLoadingSelectedTable={isLoading} selectedTable={selectedTable} />
<DeleteConfirmationDialogs
selectedTable={selectedTable}
onAfterDeleteTable={onAfterDeleteTable}
/>
</>
)
return <TableGridEditor isLoadingSelectedTable={isLoading} selectedTable={selectedTable} />
}
TableEditorPage.getLayout = (page) => (
@@ -25,8 +25,8 @@ const TableEditorPage: NextPageWithLayout = () => {
}
useEffect(() => {
const lastOpenedTab = appSnap.dashboardHistory.editor
if (isTableEditorTabsEnabled) {
const lastOpenedTab = appSnap.dashboardHistory.editor
const lastTabId = tabStore.openTabs.find((id) =>
editorEntityTypes.table.includes(tabStore.tabsMap[id]?.type)
)
@@ -37,6 +37,8 @@ const TableEditorPage: NextPageWithLayout = () => {
const lastTab = tabStore.tabsMap[lastTabId]
if (lastTab) router.push(`/project/${projectRef}/editor/${lastTab.metadata?.tableId}`)
}
} else if (lastOpenedTab) {
router.push(`/project/${projectRef}/editor/${appSnap.dashboardHistory.editor}`)
}
}, [isTableEditorTabsEnabled])
+17 -8
View File
@@ -17,14 +17,15 @@ import { useTableEditorStateSnapshot } from './table-editor'
export const createTableEditorTableState = ({
projectRef,
table: originalTable,
editable,
editable = true,
onAddColumn,
onExpandJSONEditor,
onExpandTextEditor,
}: {
projectRef: string
table: Entity
editable: boolean
/** If set to true, render an additional "+" column to support adding a new column in the grid editor */
editable?: boolean
onAddColumn: () => void
onExpandJSONEditor: (column: string, row: SupaRow) => void
onExpandTextEditor: (column: string, row: SupaRow) => void
@@ -93,21 +94,29 @@ export const createTableEditorTableState = ({
state.gridColumns.splice(toIdx, 0, moveItem)
},
updateColumnSize: (index: number, width: number) => {
;(state.gridColumns[index] as CalculatedColumn<any, any> & { width?: number }).width = width
if (state.gridColumns[index]) {
;(state.gridColumns[index] as CalculatedColumn<any, any> & { width?: number }).width = width
}
},
freezeColumn: (columnKey: string) => {
const index = state.gridColumns.findIndex((x) => x.key === columnKey)
;(state.gridColumns[index] as CalculatedColumn<any, any> & { frozen?: boolean }).frozen = true
if (state.gridColumns[index]) {
;(state.gridColumns[index] as CalculatedColumn<any, any> & { frozen?: boolean }).frozen =
true
}
},
unfreezeColumn: (columnKey: string) => {
const index = state.gridColumns.findIndex((x) => x.key === columnKey)
;(state.gridColumns[index] as CalculatedColumn<any, any> & { frozen?: boolean }).frozen =
false
if (state.gridColumns[index]) {
;(state.gridColumns[index] as CalculatedColumn<any, any> & { frozen?: boolean }).frozen =
false
}
},
updateColumnIdx: (columnKey: string, columnIdx: number) => {
const index = state.gridColumns.findIndex((x) => x.key === columnKey)
;(state.gridColumns[index] as CalculatedColumn<any, any> & { idx?: number }).idx = columnIdx
if (state.gridColumns[index]) {
;(state.gridColumns[index] as CalculatedColumn<any, any> & { idx?: number }).idx = columnIdx
}
state.gridColumns.sort((a, b) => a.idx - b.idx)
},
-1
View File
@@ -1,7 +1,6 @@
import type { PostgresColumn } from '@supabase/postgres-meta'
import { PropsWithChildren, createContext, useContext, useRef } from 'react'
import { proxy, useSnapshot } from 'valtio'
import { proxySet } from 'valtio/utils'
import type { SupaRow } from 'components/grid/types'
import { ForeignKey } from 'components/interfaces/TableGridEditor/SidePanelEditor/ForeignKeySelector/ForeignKeySelector.types'
@@ -2,12 +2,12 @@ import { screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { LogsDatePicker } from 'components/interfaces/Settings/Logs/Logs.DatePickers'
import { PREVIEWER_DATEPICKER_HELPERS } from 'components/interfaces/Settings/Logs/Logs.constants'
import { DatetimeHelper } from 'components/interfaces/Settings/Logs/Logs.types'
import dayjs from 'dayjs'
import timezone from 'dayjs/plugin/timezone'
import utc from 'dayjs/plugin/utc'
import { expect, test, vi } from 'vitest'
import { render } from '../../helpers'
import { DatetimeHelper } from 'components/interfaces/Settings/Logs/Logs.types'
dayjs.extend(timezone)
dayjs.extend(utc)
@@ -94,17 +94,16 @@ test('datepicker onSubmit will return ISO string of selected dates', async () =>
// open the datepicker
userEvent.click(screen.getByText(/13:00/i))
// Get today's date and tomorrow's date
const today = dayjs()
const tomorrow = today.add(1, 'day')
const day15 = dayjs().date(15)
const day16 = day15.add(1, 'day')
// Find and click on today's date
const todayElement = await screen.findByText(today.format('D'))
userEvent.click(todayElement)
// Find and click on first date
const day15Element = await screen.findByText(day15.format('D'))
userEvent.click(day15Element)
// Find and click on tomorrow's date
const tomorrowElement = await screen.findByText(tomorrow.format('D'))
userEvent.click(tomorrowElement)
// Find and click on second date
const day16Element = await screen.findByText(day16.format('D'))
userEvent.click(day16Element)
await userEvent.click(await screen.findByText('Apply'))
expect(mockFn).toBeCalled()
@@ -112,8 +111,8 @@ test('datepicker onSubmit will return ISO string of selected dates', async () =>
const call = mockFn.mock.calls[0][0]
expect(call).toMatchObject({
from: dayjs().date(today.date()).hour(13).minute(0).second(0).millisecond(0).toISOString(),
to: dayjs().date(tomorrow.date()).hour(23).minute(59).second(59).millisecond(0).toISOString(),
from: dayjs().date(day15.date()).hour(13).minute(0).second(0).millisecond(0).toISOString(),
to: dayjs().date(day16.date()).hour(23).minute(59).second(59).millisecond(0).toISOString(),
})
})
+1 -1
View File
@@ -70,7 +70,7 @@
"mdast-util-toc": "^6.1.1",
"postcss": "^8",
"react-dropzone": "^14.3.8",
"react-router": "^7.4.0",
"react-router": "^7.5.2",
"rimraf": "^4.1.3",
"shadcn": "2.4.0-canary.16",
"shiki": "^1.1.7",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 191 KiB

+7 -7
View File
@@ -30,7 +30,7 @@ region: 'North America'
supabase_products: ['database', 'auth', 'functions', 'vector']
---
<Quote img="ryan-han-bree.png" caption="Ryan Han, Head of Machine Learning, Bree">
<Quote img="ryan-han-bree.png" caption="Ryan Han, Head of Engineering and AI, Bree">
We hit scalability limits with Fauna, and Supabase was a step up across the board. The developer
experience, ecosystem, support for AI. It’s the complete package.
</Quote>
@@ -41,7 +41,7 @@ supabase_products: ['database', 'auth', 'functions', 'vector']
Bree had originally built their infrastructure on Fauna, a NoSQL database with a proprietary query language (FQL). The limitations quickly surfaced.
<Quote img="ryan-han-bree.png" caption="Ryan Han, Head of Machine Learning, Bree">
<Quote img="ryan-han-bree.png" caption="Ryan Han, Head of Engineering and AI, Bree">
Fauna’s custom language, FQL, introduced friction in our development process. It took time to
learn and made it harder for our team to iterate and ship at the speed we needed.
</Quote>
@@ -54,7 +54,7 @@ And when Fauna announced its end of life, it only added urgency. Bree needed a r
Bree had long been aware of Supabase. Supabase had earned its reputation among builders and indie developers for its ease of use and instant setup. But what sold Bree was how well Supabase aligned with their long-term technical goals.
<Quote img="ryan-han-bree.png" caption="Ryan Han, Head of Machine Learning, Bree">
<Quote img="ryan-han-bree.png" caption="Ryan Han, Head of Engineering and AI, Bree">
Builders around the world use Supabase for exciting projects. It is so intuitive that developers
can start building immediately and ship production-ready features in a day.
</Quote>
@@ -65,7 +65,7 @@ After a thorough evaluation of vendors, Bree chose Supabase for several reasons:
- A tightly integrated suite of tools: [Auth](/auth), [Edge Functions](/edge-functions), [Vectors](/modules/vector), and more.
- A rich ecosystems of partners and plugins
<Quote img="ryan-han-bree.png" caption="Ryan Han, Head of Machine Learning, Bree">
<Quote img="ryan-han-bree.png" caption="Ryan Han, Head of Engineering and AI, Bree">
It’s almost the default database for AI-first development. It’s integrated with Lovable, with V0,
and with Cursor—which we use heavily. Supabase just fits.
</Quote>
@@ -94,14 +94,14 @@ Phase two, now underway, is focused on modularizing the data model, splitting la
The results of Bree’s migration to Supabase have been immediate and measurable. Engineers can ship faster, performance is up across the board, and Supabase has become a foundational part of the team’s AI-driven development workflow.
<Quote img="ryan-han-bree.png" caption="Ryan Han, Head of Machine Learning, Bree">
<Quote img="ryan-han-bree.png" caption="Ryan Han, Head of Engineering and AI, Bree">
After migrating to Supabase, we saw immediate lift in developer velocity. Engineers can iterate on
features much faster by writing Postgres SQL queries.
</Quote>
Latency improvements are notable. Bree has observed double-digit reductions in response times across core functions and expects further gains as their architecture is optimized for Supabase.
<Quote img="ryan-han-bree.png" caption="Ryan Han, Head of Machine Learning, Bree">
<Quote img="ryan-han-bree.png" caption="Ryan Han, Head of Engineering and AI, Bree">
It already has double-digit latency improvements. With further optimization, some functions were
10x faster.
</Quote>
@@ -117,7 +117,7 @@ Bree’s journey reflects the reasons developers and fast-moving teams choose Su
- **AI-native infrastructure** – Supabase supports tools like Cursor and `pgvector` out of the box, making it ideal for AI-powered development
- **Built to scale** – With high availability, backups, and a flexible Postgres foundation, Supabase is ready for growth
<Quote img="ryan-han-bree.png" caption="Ryan Han, Head of Machine Learning, Bree">
<Quote img="ryan-han-bree.png" caption="Ryan Han, Head of Engineering and AI, Bree">
Supabase puts your mind at ease. It has the best support experience I’ve ever seen. It’s a
disruptive technology to bring to any organization. It’s very worth switching to.
</Quote>
+112
View File
@@ -0,0 +1,112 @@
---
name: Euka
title: 'Euka used Supabase to unlock faster growth'
# Use meta_title to add a custom meta title. Otherwise it defaults to '{name} | Supabase Customer Stories':
# meta_title:
description: Supabase helped Euka accelerate development, simplify AI feature rollout, and scale creator marketing faster than ever. A Postgres-based platform gave them the structure, speed, and flexibility they needed to grow.
# Use meta_description to add a custom meta description. Otherwise it defaults to {description}:
meta_description: Discover how Supabase helped Euka accelerate development.
author: prashant
author_title: Prashant Sridharan
author_url: https://github.com/CoolAssPuppy
author_image_url: https://avatars.githubusercontent.com/u/914007?v=4
logo: /images/customers/logos/euka.png
logo_inverse: /images/customers/logos/light/euka.png
og_image: /images/customers/og/euka.jpg
tags:
- supabase
date: '2025-04-29'
company_url: https://www.euka.ai
stats: [{ stat: '00,000', label: Example stat }]
misc: [{ label: 'Founded', text: 'Canada' }]
about: Turning TikTok affiliates into a revenue-generating engine.
# "healthcare" | "fintech" | "ecommerce" | "education" | "gaming" | "media" | "real-estate" | "saas" | "social" | "analytics" | "ai" | "developer-tools"
industry: ['media', 'social']
# "startup" | "enterprise" | "indie_dev"
company_size: 'startup'
# "Asia" | "Europe" | "North America" | "South America" | "Africa" | "Oceania"
region: 'North America'
# "database" | "auth" | "storage" | "realtime" | "functions" | "vector"
supabase_products: ['database', 'auth', 'storage', 'functions', 'vector']
---
<Quote img="kevin-wang-euka.jpeg" caption="Kevin Wang, CTO and Co-Founder, Euka">
Supabase gave us the tools we needed to move fast, scale quickly, and stay flexible as we grow —
all without the heavy lifting.
</Quote>
Euka is reimagining how brands scale their presence on social media. Their AI-powered platform helps businesses find, manage, and scale creator partnerships, primarily on TikTok Shop. By automating the most time-consuming parts of user-generated content (UGC) campaigns, Euka allows brands to build awareness and drive sales faster than ever before.
As they expanded beyond ecommerce into SaaS and consumer software, Euka needed a backend platform that could scale with them without slowing them down.
## The challenge
Euka’s vision required a backend that could support rapid iteration, structured data analytics, seamless AI integration, and scalable storage. To succeed, Euka needed:
- A relational database that supported structured queries.
- Integrated services like authentication, storage, and AI search.
- A platform that scaled easily as their business grew.
- A developer-first experience to enable faster shipping.
## Choosing Supabase
When it came time to choose a permanent foundation for the business, Kevin recalled a side project where Supabase had impressed him with its simplicity and speed. This time, for Euka, it was not just about familiarity. It was about finding the right platform to unlock their next phase of growth.
Supabase provided everything they needed in one place:
- A fully managed Postgres database with standard SQL support.
- Built-in authentication, storage, edge functions, and vector search.
- Native AI features like pgvector for semantic search.
- An intuitive dashboard and first-class developer experience.
<Quote img="kevin-wang-euka.jpeg" caption="Kevin Wang, CTO and Co-Founder, Euka">
It just worked. Supabase gave us a zero-to-one experience that let us build quickly without
worrying about the backend.
</Quote>
Supabase allowed the team to move faster without compromising on performance or scalability. It also future-proofed their stack for AI development without needing costly rewrites later.
## How Euka built with Supabase
Euka’s architecture focuses on simplicity and rapid iteration. They designed their system to get the most out of Supabase while maintaining flexibility for future growth.
Their stack includes:
- **Frontend**: Two Next.js apps deployed on Vercel, one for brands and one for creators.
- **Backend**: Python scripts for data scraping, trend analysis, and creator search, connected directly to Supabase.
- **Storage**: Supabase Storage for managing creator profile pictures, videos, and ecommerce product images.
- **Authentication**: Supabase Auth with ongoing fine-tuning around OAuth flows.
- **AI search**: Supabase’s `pgvector` extension for trending video discovery, using cosine similarity searches.
<Quote img="kevin-wang-euka.jpeg" caption="Kevin Wang, CTO and Co-Founder, Euka">
Setting up the database and storage was one of the easiest parts of building our MVP.
</Quote>
Supabase’s dashboard also became a powerful internal tool. Engineers could debug quickly by inspecting real-time logs and queries. Non-technical teams like marketing and sales used the dashboard to run simple queries, inspect user data, and export reports without needing custom-built admin panels.
## The results
Since launching on Supabase, Euka has accelerated every part of their development lifecycle. They can build quickly and are able to deliver new AI capabilities faster than expected.
<Quote img="kevin-wang-euka.jpeg" caption="Kevin Wang, CTO and Co-Founder, Euka">
With Supabase, we saw an immediate lift in developer velocity. Engineers can iterate on features
much faster by writing Postgres SQL queries.
</Quote>
Supabase helped Euka achieve:
- Faster developer cycles by standardizing on SQL.
- Easier scaling through integrated, high-performance services.
- Simplified AI expansion using `pgvector` and seamless embedding storage.
- Greater internal collaboration through an intuitive, accessible dashboard.
Supabase gave Euka a faster way to ship, a stronger foundation for AI growth, and a platform that can evolve alongside them as they expand into new industries.
<Quote img="kevin-wang-euka.jpeg" caption="Kevin Wang, CTO and Co-Founder, Euka">
Honestly, it’s a no-brainer these days. If you’re starting something new and need structured data,
Supabase is the obvious choice.
</Quote>
## Ready to build and scale with Supabase?
Start your journey today at [www.supabase.com](/)
-5
View File
@@ -78,11 +78,6 @@ const logos = [
alt: 'humata',
name: 'humata',
},
{
image: `/images/logos/publicity/krea.svg`,
alt: 'krea',
name: 'krea',
},
{
image: `/images/logos/publicity/udio.svg`,
alt: 'udio',
+11
View File
@@ -18,6 +18,17 @@ export type CustomerStoryType = {
}
export const data: CustomerStoryType[] = [
{
type: 'Customer Story',
title: 'Euka used Supabase to unlock faster growth',
description:
'Supabase helped Euka accelerate development, simplify AI feature rollout, and scale creator marketing faster than ever.',
organization: 'Euka',
imgUrl: 'images/customers/logos/euka.png',
logo: '/images/customers/logos/euka.png',
logo_inverse: '/images/customers/logos/light/euka.png',
url: '/customers/euka',
},
{
type: 'Customer Story',
title: "Bree's Migration to Supabase from Fauna",
+8 -1
View File
@@ -5,9 +5,16 @@
<link>https://supabase.com</link>
<description>Latest news from Supabase</description>
<language>en</language>
<lastBuildDate>Tue, 08 Apr 2025 00:00:00 -0700</lastBuildDate>
<lastBuildDate>Tue, 29 Apr 2025 00:00:00 -0700</lastBuildDate>
<atom:link href="https://supabase.com/rss.xml" rel="self" type="application/rss+xml"/>
<item>
<guid>https://supabase.com/customers/euka</guid>
<title>Euka used Supabase to unlock faster growth</title>
<link>https://supabase.com/customers/euka</link>
<description>Supabase helped Euka accelerate development, simplify AI feature rollout, and scale creator marketing faster than ever. A Postgres-based platform gave them the structure, speed, and flexibility they needed to grow.</description>
<pubDate>Tue, 29 Apr 2025 00:00:00 -0700</pubDate>
</item>
<item>
<guid>https://supabase.com/customers/bree</guid>
<title>Bree&apos;s Migration to Supabase from Fauna</title>
<link>https://supabase.com/customers/bree</link>
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

@@ -1,10 +0,0 @@
<svg width="107" height="60" viewBox="0 0 107 60" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M86.6115 23.9407H90.4803L96.119 38.8606H92.2913L91.3858 36.2059H85.6237L84.7388 38.8606H80.9934L86.6115 23.9407ZM86.5909 33.3454H90.4392L88.5253 27.6656L86.5909 33.3454Z" fill="#7E7E7E"/>
<path d="M68.6029 23.9407H80.0449V27.0276H72.122V29.6412H79.6128V32.7486H72.122V35.7532H80.0449V38.8606H68.6029V23.9407Z" fill="#7E7E7E"/>
<path d="M59.4719 33.2425H57.0847V38.8606H53.5657V23.9407H59.3073C60.1304 23.9407 60.8644 23.9819 61.5092 24.0642C62.154 24.1465 62.7646 24.2906 63.3408 24.4964C63.9307 24.7022 64.4177 24.9766 64.8019 25.3196C65.1998 25.6625 65.5084 26.1084 65.728 26.6572C65.9612 27.1923 66.0778 27.8233 66.0778 28.5505C66.0778 30.5398 65.1037 31.9117 63.1556 32.6663L66.8392 38.8606H62.7028L59.4719 33.2425ZM57.0847 26.9041V30.2791H59.4102C60.3568 30.2791 61.0839 30.1625 61.5915 29.9293C62.0992 29.6823 62.353 29.2364 62.353 28.5916C62.353 27.9468 62.0923 27.5078 61.571 27.2746C61.0634 27.0276 60.3431 26.9041 59.4102 26.9041H57.0847Z" fill="#7E7E7E"/>
<path d="M42.2083 29.847L47.6206 23.9407H51.9216L46.4064 29.8881L52.2097 38.8606H47.9704L43.9369 32.5634L42.2083 34.4567V38.8606H38.6892V23.9407H42.2083V29.847Z" fill="#7E7E7E"/>
<path d="M13.5971 20.0302C15.55 19.8928 17.273 21.2525 17.5244 23.1294C17.7631 24.9123 16.5428 26.6368 14.7093 27.0405C14.2498 27.1417 13.739 27.1136 13.2527 27.1753C12.0692 27.3256 11.0558 27.8195 10.2125 28.657L10.2037 28.6615L10.1939 28.6607L10.1861 28.6548L10.1829 28.6457C10.1826 28.639 10.1843 28.6331 10.1881 28.628C10.1934 28.6204 10.196 28.6121 10.196 28.603C10.1845 27.2202 10.1809 25.546 10.1852 23.5803C10.189 21.7229 11.6565 20.1663 13.5971 20.0302Z" fill="#7E7E7E"/>
<path d="M13.8026 35.5575C11.3169 35.538 9.53506 33.0923 10.3997 30.8007C10.8766 29.5358 12.0541 28.6135 13.4497 28.4683C13.874 28.4243 14.3838 28.4356 14.7931 28.3594C16.8949 27.9688 18.5183 26.3614 18.8468 24.3148C18.9196 23.8607 18.8816 23.3194 18.9887 22.8376C19.4849 20.6078 22.0401 19.3565 24.1861 20.3646C25.3049 20.8909 26.0549 21.8844 26.2405 23.0713C26.2786 23.3147 26.291 23.6163 26.2775 23.9759C26.0342 30.4372 20.5159 35.611 13.8026 35.5575Z" fill="#7E7E7E"/>
<path d="M16.9332 36.5858C16.9219 36.5806 16.9143 36.5731 16.9102 36.5631L16.9092 36.5568L16.911 36.5507L16.915 36.5459C16.9168 36.5446 16.9188 36.5437 16.921 36.5431C19.4712 35.9594 21.6807 34.7852 23.5493 33.0202C23.5828 32.9885 23.6133 32.9909 23.6408 33.0275C23.9406 33.4222 24.2536 33.8464 24.5045 34.2516C25.5354 35.9157 26.1196 37.7182 26.2567 39.6591C26.2843 40.0504 26.2929 40.3527 26.2826 40.566C26.19 42.4519 24.6117 43.9735 22.6221 43.9849C20.8605 43.9944 19.3118 42.7926 18.9772 41.1113C18.8983 40.7134 18.9227 40.1621 18.8555 39.7416C18.6481 38.4476 18.0159 37.4005 16.9586 36.5999C16.9539 36.5959 16.9455 36.5912 16.9332 36.5858Z" fill="#7E7E7E"/>
<path d="M13.2066 43.9306C11.7105 43.6702 10.5066 42.5084 10.2388 41.0629C10.2021 40.8651 10.1838 40.5637 10.1838 40.1587C10.1835 38.5645 10.1832 36.9701 10.1829 35.3755C10.1829 35.3598 10.1888 35.3573 10.2007 35.3678C11.179 36.2375 12.2071 36.7688 13.5501 36.8573C13.5501 36.8573 13.6716 36.8615 13.9109 36.869C15.6594 36.9249 17.1405 38.0708 17.482 39.7354C17.9877 42.1967 15.7595 44.3748 13.2066 43.9306Z" fill="#7E7E7E"/>
</svg>

Before

Width:  |  Height:  |  Size: 3.2 KiB

+8 -1
View File
@@ -172,7 +172,14 @@ export function sendTelemetryEvent(API_URL: string, event: TelemetryEvent, pathn
...getSharedTelemetryData(pathname),
action: event.action,
custom_properties: 'properties' in event ? event.properties : {},
groups: 'groups' in event ? event.groups : {},
groups: 'groups' in event ? { ...event.groups } : {},
}
if (body.groups?.project === 'Unknown') {
delete body.groups.project
if (body.groups?.organization === 'Unknown') {
delete body.groups
}
}
return post(`${ensurePlatformSuffix(API_URL)}/telemetry/event`, body, {
-1
View File
@@ -103,7 +103,6 @@ export const Admonition = forwardRef<
{...props}
className={cn(
'mb-2',
'[&_a]:underline',
admonitionSVG({ type: typeMapped }),
admonitionBase({ type: typeMapped }),
props.className
+194 -4
View File
@@ -282,6 +282,12 @@ importers:
github-slugger:
specifier: ^2.0.0
version: 2.0.0
graphql:
specifier: ^16.10.0
version: 16.11.0
graphql-validation-complexity:
specifier: ^0.4.2
version: 0.4.2(graphql@16.11.0)
gray-matter:
specifier: ^4.0.3
version: 4.0.3
@@ -445,6 +451,9 @@ importers:
'@aws-sdk/client-secrets-manager':
specifier: ^3.410.0
version: 3.797.0
'@graphiql/toolkit':
specifier: ^0.9.1
version: 0.9.2(@types/node@20.17.32)(graphql-ws@5.14.1(graphql@16.11.0))(graphql@16.11.0)
'@supabase/supa-mdx-lint':
specifier: 0.2.6-alpha
version: 0.2.6-alpha
@@ -454,6 +463,9 @@ importers:
'@types/estree':
specifier: 1.0.5
version: 1.0.5
'@types/graphql-validation-complexity':
specifier: ^0.4.4
version: 0.4.4
'@types/hast':
specifier: ^2.3.4
version: 2.3.10
@@ -499,6 +511,9 @@ importers:
globby:
specifier: ^13.2.2
version: 13.2.2
graphiql:
specifier: ^3.8.3
version: 3.9.0(@codemirror/language@6.11.0)(@types/node@20.17.32)(@types/react-dom@18.3.6(@types/react@18.3.20))(@types/react@18.3.20)(graphql-ws@5.14.1(graphql@16.11.0))(graphql@16.11.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
openapi-types:
specifier: ^12.1.3
version: 12.1.3
@@ -1003,7 +1018,7 @@ importers:
version: 2.7.5(@types/node@20.17.32)(typescript@5.5.4)
next-router-mock:
specifier: ^0.9.13
version: 0.9.13(next@15.3.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.87.0))(react@18.3.1)
version: 0.9.13(next@15.3.1(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.87.0))(react@18.3.1)
postcss:
specifier: ^8.4.31
version: 8.5.3
@@ -1192,7 +1207,7 @@ importers:
specifier: ^14.3.8
version: 14.3.8(react@18.3.1)
react-router:
specifier: ^7.4.0
specifier: ^7.5.2
version: 7.5.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
rimraf:
specifier: ^4.1.3
@@ -2144,7 +2159,7 @@ importers:
version: link:../api-types
next-router-mock:
specifier: ^0.9.13
version: 0.9.13(next@15.3.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.87.0))(react@18.3.1)
version: 0.9.13(next@15.3.1(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.87.0))(react@18.3.1)
typescript:
specifier: ~5.5.0
version: 5.5.4
@@ -3319,6 +3334,22 @@ packages:
react: ^16.8.0 || ^17 || ^18
react-dom: ^16.8.0 || ^17 || ^18
'@graphiql/react@0.29.0':
resolution: {integrity: sha512-4Zd57+DeK5t1KYiqy9hnuaQhR8fnZ1CkKUkmZqINpAe0OlpbszOE661zwNGuW5NjbXgepfI89ICnh1ZVinSJvg==}
peerDependencies:
graphql: ^15.5.0 || ^16.0.0 || ^17.0.0
react: ^16.8.0 || ^17 || ^18
react-dom: ^16.8.0 || ^17 || ^18
'@graphiql/toolkit@0.11.2':
resolution: {integrity: sha512-C6O3f7KsLaoGjZHRT5jLjUmZeGUUewJtASbiAjPsJWeuONvQjLIxHnM+wjXYIVRqAVUVDtb4iadcZK0O9fzh0w==}
peerDependencies:
graphql: ^15.5.0 || ^16.0.0 || ^17.0.0
graphql-ws: '>= 4.5.0'
peerDependenciesMeta:
graphql-ws:
optional: true
'@graphiql/toolkit@0.9.2':
resolution: {integrity: sha512-QLPhwx9+E4JfGY+0iwFwa85OBjCJ7GHNypkQoz3pCOwAAlIpFBtXDOuLF1eH9Z08WNZSjRLL7VYW4Ci3hfTqxg==}
peerDependencies:
@@ -6630,6 +6661,9 @@ packages:
'@types/geojson@7946.0.16':
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
'@types/graphql-validation-complexity@0.4.4':
resolution: {integrity: sha512-/0AmF5HYHmLErXRky/be86E4hAVO2LE5ejHdy3kf1rASgzBDG3Yb1yqoHxrZz+Nk72jt2UWCfa6DSfdpHDkuzg==}
'@types/hast@2.3.10':
resolution: {integrity: sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==}
@@ -7924,6 +7958,13 @@ packages:
codemirror: ^5.65.3
graphql: ^15.5.0 || ^16.0.0 || ^17.0.0
codemirror-graphql@2.2.1:
resolution: {integrity: sha512-//E1IjSVfScGBMpLgylfbMQNQnejpeqyP+Weqh/Kl45kr0XkSUiOCImsG5OL5Crm6waFbx4H7GAAI6KFXy27IQ==}
peerDependencies:
'@codemirror/language': 6.0.0
codemirror: ^5.65.3
graphql: ^15.5.0 || ^16.0.0 || ^17.0.0
codemirror@5.65.19:
resolution: {integrity: sha512-+aFkvqhaAVr1gferNMuN8vkTSrWIFvzlMV9I2KBLCWS2WpZ2+UAkZjlMZmEuT+gcXTi6RrGQCkWq1/bDtGqhIA==}
@@ -8968,6 +9009,7 @@ packages:
resolution: {integrity: sha512-X41YRfKkVCooILai2N0aN0Q5O0imu+MbNt894QWU683A82xJJXIR01dEqxwYJ91M6IdExtyFB3GCOJb4eBMBMQ==}
peerDependencies:
eslint: '>= 5'
bundledDependencies: []
eslint-plugin-import@2.31.0:
resolution: {integrity: sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==}
@@ -9572,6 +9614,10 @@ packages:
get-tsconfig@4.10.0:
resolution: {integrity: sha512-kGzZ3LWWQcGIAmg6iWvXn0ei6WDtV26wzHRMwDSzmAbcXrTEXxHy6IehI6/4eT6VRKyMP1eF1VqwrVUmE/LR7A==}
get-value@3.0.1:
resolution: {integrity: sha512-mKZj9JLQrwMBtj5wxi6MH8Z5eSKaERpAwjg43dPtlGI1ZVEgH/qC7T8/6R2OBSUA+zzHBZgICsVJaEIV2tKTDA==}
engines: {node: '>=6.0'}
giget@2.0.0:
resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==}
hasBin: true
@@ -9659,12 +9705,30 @@ packages:
graphemer@1.4.0:
resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
graphiql@3.9.0:
resolution: {integrity: sha512-rJYFlHdBbug9+YbFSwRVvgfPjCayC7wHecZkx/qB4XLuQPFQZw/yB9pmHFtOJV6RiNBBHro3u1GdocKa3YcGRA==}
peerDependencies:
graphql: ^15.5.0 || ^16.0.0 || ^17.0.0
react: ^16.8.0 || ^17 || ^18
react-dom: ^16.8.0 || ^17 || ^18
graphql-language-service@5.3.0:
resolution: {integrity: sha512-gCQIIy7lM9CB1KPLEb+DNZLczA9zuTLEOJE2hEQZTFYInogdmMDRa6RAkvM4LL0LcgcS+3uPs6KtHlcjCqRbUg==}
hasBin: true
peerDependencies:
graphql: ^15.5.0 || ^16.0.0 || ^17.0.0-alpha.2
graphql-language-service@5.3.1:
resolution: {integrity: sha512-6u/f6rxQ6sgNfHwHYGsFiR+zYP93uFEStNO0j9WexoOVd2hQK7BPsNyBz/WsB8URHPAjoVhSKs1wkAqUrcxj1g==}
hasBin: true
peerDependencies:
graphql: ^15.5.0 || ^16.0.0 || ^17.0.0
graphql-validation-complexity@0.4.2:
resolution: {integrity: sha512-4tzmN/70a06c2JH5fvISkoLX6oBDpqK22cvr2comge3HZHtBLD3n5Sl6MnQYMVhQqKGlpZWcCgD00MnyKNzYYg==}
peerDependencies:
graphql: '>=0.9.5'
graphql-ws@5.14.1:
resolution: {integrity: sha512-aqkls1espsygP1PfkAuuLIV96IbztQ6EaADse97pw8wRIMT3+AL/OYfS8V2iCRkc0gzckitoDRGCQEdnySggiA==}
engines: {node: '>=10'}
@@ -10647,6 +10711,9 @@ packages:
linkify-it@3.0.3:
resolution: {integrity: sha512-ynTsyrFSdE5oZ/O9GEf00kPngmOfVwazR5GKDq6EYfhlpFug3J2zybX56a2PRRpc9P+FuSoGNAwjlbDs9jJBPQ==}
linkify-it@5.0.0:
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
list-item@1.1.1:
resolution: {integrity: sha512-S3D0WZ4J6hyM8o5SNKWaMYB1ALSacPZ2nHGEuCjmHZ+dc03gFeNZoNDcqfcnO4vDhTZmNrqrpYZCdXsRh22bzw==}
engines: {node: '>=0.10.0'}
@@ -10849,6 +10916,10 @@ packages:
resolution: {integrity: sha512-TchMembfxfNVpHkbtriWltGWc+m3xszaRD0CZup7GFFhzIgQqxIfn3eGj1yZpfuflzPvfkt611B2Q/Bsk1YnGg==}
hasBin: true
markdown-it@14.1.0:
resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==}
hasBin: true
markdown-link@0.1.1:
resolution: {integrity: sha512-TurLymbyLyo+kAUUAV9ggR9EPcDjP/ctlv9QAFiqUH7c+t6FlsbivPo9OKTU8xdOx9oNd2drW/Fi5RRElQbUqA==}
engines: {node: '>=0.10.0'}
@@ -11000,6 +11071,9 @@ packages:
mdurl@1.0.1:
resolution: {integrity: sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==}
mdurl@2.0.0:
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
mdx-bundler@10.1.1:
resolution: {integrity: sha512-87FtxC7miUPznwqEaAlJARinHJ6Qin9kDuG2E2BCCNEOszr62kHpqivI/IF/CmwObVSpvApVFFxN1ftM/Gykvw==}
engines: {node: '>=18', npm: '>=6'}
@@ -12662,6 +12736,10 @@ packages:
pump@3.0.2:
resolution: {integrity: sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==}
punycode.js@2.3.1:
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
engines: {node: '>=6'}
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@@ -12741,6 +12819,11 @@ packages:
react: ^16.8.5 || ^17.0.0 || ^18.0.0
react-dom: ^16.8.5 || ^17.0.0 || ^18.0.0
react-compiler-runtime@19.1.0-rc.1:
resolution: {integrity: sha512-wCt6g+cRh8g32QT18/9blfQHywGjYu+4FlEc3CW1mx3pPxYzZZl1y+VtqxRgnKKBCFLIGUYxog4j4rs5YS86hw==}
peerDependencies:
react: ^17.0.0 || ^18.0.0 || ^19.0.0 || ^0.0.0-experimental
react-contexify@5.0.0:
resolution: {integrity: sha512-2FIp7lxJ6dtfGr8EZ4uVV5p5TQjd0n2h/JU7PrejNIMiCeZWvSVPFh4lj1ZvjXosglBvP7q5JQQ8yUCdSaMSaw==}
engines: {node: '>=10'}
@@ -14508,6 +14591,9 @@ packages:
uc.micro@1.0.6:
resolution: {integrity: sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==}
uc.micro@2.1.0:
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
ufo@1.6.1:
resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==}
@@ -16845,6 +16931,45 @@ snapshots:
- '@types/react-dom'
- graphql-ws
'@graphiql/react@0.29.0(@codemirror/language@6.11.0)(@types/node@20.17.32)(@types/react-dom@18.3.6(@types/react@18.3.20))(@types/react@18.3.20)(graphql-ws@5.14.1(graphql@16.11.0))(graphql@16.11.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@graphiql/toolkit': 0.11.2(@types/node@20.17.32)(graphql-ws@5.14.1(graphql@16.11.0))(graphql@16.11.0)
'@headlessui/react': 1.7.19(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-dialog': 1.0.5(@types/react-dom@18.3.6(@types/react@18.3.20))(@types/react@18.3.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-dropdown-menu': 2.1.12(@types/react-dom@18.3.6(@types/react@18.3.20))(@types/react@18.3.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-tooltip': 1.2.4(@types/react-dom@18.3.6(@types/react@18.3.20))(@types/react@18.3.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-visually-hidden': 1.2.0(@types/react-dom@18.3.6(@types/react@18.3.20))(@types/react@18.3.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@types/codemirror': 5.60.15
clsx: 1.2.1
codemirror: 5.65.19
codemirror-graphql: 2.2.1(@codemirror/language@6.11.0)(codemirror@5.65.19)(graphql@16.11.0)
copy-to-clipboard: 3.3.3
framer-motion: 6.5.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
get-value: 3.0.1
graphql: 16.11.0
graphql-language-service: 5.3.1(graphql@16.11.0)
markdown-it: 14.1.0
react: 18.3.1
react-compiler-runtime: 19.1.0-rc.1(react@18.3.1)
react-dom: 18.3.1(react@18.3.1)
set-value: 4.1.0
transitivePeerDependencies:
- '@codemirror/language'
- '@types/node'
- '@types/react'
- '@types/react-dom'
- graphql-ws
'@graphiql/toolkit@0.11.2(@types/node@20.17.32)(graphql-ws@5.14.1(graphql@16.11.0))(graphql@16.11.0)':
dependencies:
'@n1ru4l/push-pull-async-iterable-iterator': 3.2.0
graphql: 16.11.0
meros: 1.3.0(@types/node@20.17.32)
optionalDependencies:
graphql-ws: 5.14.1(graphql@16.11.0)
transitivePeerDependencies:
- '@types/node'
'@graphiql/toolkit@0.9.2(@types/node@20.17.32)(graphql-ws@5.14.1(graphql@16.11.0))(graphql@16.11.0)':
dependencies:
'@n1ru4l/push-pull-async-iterable-iterator': 3.2.0
@@ -21209,6 +21334,10 @@ snapshots:
'@types/geojson@7946.0.16': {}
'@types/graphql-validation-complexity@0.4.4':
dependencies:
graphql: 16.11.0
'@types/hast@2.3.10':
dependencies:
'@types/unist': 2.0.11
@@ -22767,6 +22896,14 @@ snapshots:
graphql: 16.11.0
graphql-language-service: 5.3.0(graphql@16.11.0)
codemirror-graphql@2.2.1(@codemirror/language@6.11.0)(codemirror@5.65.19)(graphql@16.11.0):
dependencies:
'@codemirror/language': 6.11.0
'@types/codemirror': 0.0.90
codemirror: 5.65.19
graphql: 16.11.0
graphql-language-service: 5.3.1(graphql@16.11.0)
codemirror@5.65.19: {}
coffee-script@1.12.7: {}
@@ -24632,6 +24769,10 @@ snapshots:
dependencies:
resolve-pkg-maps: 1.0.0
get-value@3.0.1:
dependencies:
isobject: 3.0.1
giget@2.0.0:
dependencies:
citty: 0.1.6
@@ -24752,6 +24893,20 @@ snapshots:
graphemer@1.4.0: {}
graphiql@3.9.0(@codemirror/language@6.11.0)(@types/node@20.17.32)(@types/react-dom@18.3.6(@types/react@18.3.20))(@types/react@18.3.20)(graphql-ws@5.14.1(graphql@16.11.0))(graphql@16.11.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@graphiql/react': 0.29.0(@codemirror/language@6.11.0)(@types/node@20.17.32)(@types/react-dom@18.3.6(@types/react@18.3.20))(@types/react@18.3.20)(graphql-ws@5.14.1(graphql@16.11.0))(graphql@16.11.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
graphql: 16.11.0
react: 18.3.1
react-compiler-runtime: 19.1.0-rc.1(react@18.3.1)
react-dom: 18.3.1(react@18.3.1)
transitivePeerDependencies:
- '@codemirror/language'
- '@types/node'
- '@types/react'
- '@types/react-dom'
- graphql-ws
graphql-language-service@5.3.0(graphql@16.11.0):
dependencies:
debounce-promise: 3.1.2
@@ -24759,6 +24914,18 @@ snapshots:
nullthrows: 1.1.1
vscode-languageserver-types: 3.17.5
graphql-language-service@5.3.1(graphql@16.11.0):
dependencies:
debounce-promise: 3.1.2
graphql: 16.11.0
nullthrows: 1.1.1
vscode-languageserver-types: 3.17.5
graphql-validation-complexity@0.4.2(graphql@16.11.0):
dependencies:
graphql: 16.11.0
warning: 4.0.3
graphql-ws@5.14.1(graphql@16.11.0):
dependencies:
graphql: 16.11.0
@@ -25821,6 +25988,10 @@ snapshots:
dependencies:
uc.micro: 1.0.6
linkify-it@5.0.0:
dependencies:
uc.micro: 2.1.0
list-item@1.1.1:
dependencies:
expand-range: 1.8.2
@@ -26073,6 +26244,15 @@ snapshots:
mdurl: 1.0.1
uc.micro: 1.0.6
markdown-it@14.1.0:
dependencies:
argparse: 2.0.1
entities: 4.5.0
linkify-it: 5.0.0
mdurl: 2.0.0
punycode.js: 2.3.1
uc.micro: 2.1.0
markdown-link@0.1.1: {}
markdown-table@3.0.4: {}
@@ -26458,6 +26638,8 @@ snapshots:
mdurl@1.0.1: {}
mdurl@2.0.0: {}
mdx-bundler@10.1.1(acorn@8.14.1)(esbuild@0.25.3)(supports-color@8.1.1):
dependencies:
'@babel/runtime': 7.27.0
@@ -27386,7 +27568,7 @@ snapshots:
dependencies:
js-yaml-loader: 1.2.2
next-router-mock@0.9.13(next@15.3.1(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.87.0))(react@18.3.1):
next-router-mock@0.9.13(next@15.3.1(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.87.0))(react@18.3.1):
dependencies:
next: 15.3.1(@babel/core@7.26.10(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.52.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.87.0)
react: 18.3.1
@@ -28593,6 +28775,8 @@ snapshots:
end-of-stream: 1.4.4
once: 1.4.0
punycode.js@2.3.1: {}
punycode@2.3.1: {}
qs@6.14.0:
@@ -28676,6 +28860,10 @@ snapshots:
transitivePeerDependencies:
- react-native
react-compiler-runtime@19.1.0-rc.1(react@18.3.1):
dependencies:
react: 18.3.1
react-contexify@5.0.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
clsx: 1.2.1
@@ -30920,6 +31108,8 @@ snapshots:
uc.micro@1.0.6: {}
uc.micro@2.1.0: {}
ufo@1.6.1: {}
uglify-js@3.19.3:
@@ -0,0 +1,103 @@
create index if not exists
idx_page_section_embedding
on public.page_section
using hnsw (embedding vector_ip_ops);
create or replace function match_embedding(
embedding vector(1536),
match_threshold float default 0.78,
max_results int default 30
)
returns setof page_section
language plpgsql
set search_path = ''
as $$
#variable_conflict use_variable
begin
return query
select *
from public.page_section
where (page_section.embedding operator(public.<#>) embedding) <= -match_threshold
order by page_section.embedding operator(public.<#>) embedding
limit max_results;
end;
$$;
create or replace function get_full_content_url(
type text,
path text,
slug text
)
returns text
language sql
set search_path = ''
as $$
select case
when type = 'github-discussions'
then path
when type = 'partner-integration'
then concat('https://supabase.com', path)
else
concat(
'https://supabase.com/docs',
path,
case
when slug is null
then ''
else
concat('#', slug)
end
)
end;
$$;
create or replace function search_content(
embedding vector(1536),
include_full_content boolean default false,
match_threshold float default 0.78,
max_result int default 30
)
returns table (
id bigint,
page_title text,
type text,
href text,
content text,
subsections json[]
)
language sql
set search_path = ''
as $$
with matched_section as (
select
*,
row_number() over () as ranking
from public.match_embedding(
embedding,
match_threshold,
max_result
)
)
select
page.id,
meta ->> 'title' as page_title,
type,
public.get_full_content_url(type, path, null) as href,
case
when include_full_content
then page.content
else
null
end as content,
array_agg(
json_build_object(
'title', heading,
'href', public.get_full_content_url(type, path, slug),
'content', matched_section.content
)
)
from matched_section
join public.page on matched_section.page_id = page.id
group by page.id
order by min(ranking);
$$;