mirror of
https://github.com/supabase/supabase.git
synced 2026-10-15 06:09:08 +03:00
Merge branch 'master' into feature/assistant-mcp
This commit is contained in:
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!
|
||||
}"
|
||||
`;
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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()
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -32,6 +32,7 @@ David A. Ventimiglia
|
||||
Deji I
|
||||
Div Arora
|
||||
Divit D
|
||||
Eduardo Gurgel
|
||||
Egor Romanov
|
||||
Eleftheria Trivyzaki
|
||||
Etienne Stalmans
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
})
|
||||
@@ -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'
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
+17
-19
@@ -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>
|
||||
|
||||
|
||||
+1
@@ -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 })
|
||||
})
|
||||
}
|
||||
+13
-21
@@ -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 }
|
||||
@@ -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>
|
||||
|
||||
@@ -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_>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,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,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(),
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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 |
@@ -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>
|
||||
|
||||
@@ -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](/)
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'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 |
@@ -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, {
|
||||
|
||||
@@ -103,7 +103,6 @@ export const Admonition = forwardRef<
|
||||
{...props}
|
||||
className={cn(
|
||||
'mb-2',
|
||||
'[&_a]:underline',
|
||||
admonitionSVG({ type: typeMapped }),
|
||||
admonitionBase({ type: typeMapped }),
|
||||
props.className
|
||||
|
||||
Generated
+194
-4
@@ -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);
|
||||
$$;
|
||||
Reference in new issue
Block a user