diff --git a/apps/docs/app/api/graphql/__snapshots__/route.test.ts.snap b/apps/docs/app/api/graphql/__snapshots__/route.test.ts.snap new file mode 100644 index 00000000000..92aa9a51c06 --- /dev/null +++ b/apps/docs/app/api/graphql/__snapshots__/route.test.ts.snap @@ -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! +}" +`; diff --git a/apps/docs/app/api/graphql/route.test.ts b/apps/docs/app/api/graphql/route.test.ts new file mode 100644 index 00000000000..f77a823659e --- /dev/null +++ b/apps/docs/app/api/graphql/route.test.ts @@ -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() + }) +}) diff --git a/apps/docs/app/api/graphql/route.ts b/apps/docs/app/api/graphql/route.ts new file mode 100644 index 00000000000..c786b1c92fa --- /dev/null +++ b/apps/docs/app/api/graphql/route.ts @@ -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 { + 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 { + 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 { + 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' }], + }) + } + } +} diff --git a/apps/docs/app/api/graphql/validators.test.ts b/apps/docs/app/api/graphql/validators.test.ts new file mode 100644 index 00000000000..0659623848d --- /dev/null +++ b/apps/docs/app/api/graphql/validators.test.ts @@ -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) + }) +}) diff --git a/apps/docs/app/api/graphql/validators.ts b/apps/docs/app/api/graphql/validators.ts new file mode 100644 index 00000000000..f4ca8e68df9 --- /dev/null +++ b/apps/docs/app/api/graphql/validators.ts @@ -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() + + 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, + 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, 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) +} diff --git a/apps/docs/app/api/utils.ts b/apps/docs/app/api/utils.ts new file mode 100644 index 00000000000..12e23bb605e --- /dev/null +++ b/apps/docs/app/api/utils.ts @@ -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) +} diff --git a/apps/docs/app/graphiql/page.tsx b/apps/docs/app/graphiql/page.tsx new file mode 100644 index 00000000000..6443554bbbd --- /dev/null +++ b/apps/docs/app/graphiql/page.tsx @@ -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, 'fetcher'>) { + const fetcher = createGraphiQLFetcher({ + url: 'http://localhost:3001/docs/api/graphql', + }) + return + } + }, + { + loading: () =>
Loading GraphiQL...
, + ssr: false, + } +) + +export default function GraphiQLPage() { + if (!IS_DEV) { + notFound() + } + + return +} diff --git a/apps/docs/app/guides/database/extensions/wrappers/[[...slug]]/page.tsx b/apps/docs/app/guides/database/extensions/wrappers/[[...slug]]/page.tsx index 0bbbee54595..8028d6c5625 100644 --- a/apps/docs/app/guides/database/extensions/wrappers/[[...slug]]/page.tsx +++ b/apps/docs/app/guides/database/extensions/wrappers/[[...slug]]/page.tsx @@ -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(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 } diff --git a/apps/docs/components/Feedback/Feedback.tsx b/apps/docs/components/Feedback/Feedback.tsx index 16771ca6e5b..e2aa841aa43 100644 --- a/apps/docs/components/Feedback/Feedback.tsx +++ b/apps/docs/components/Feedback/Feedback.tsx @@ -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() diff --git a/apps/docs/components/Feedback/Feedback.utils.ts b/apps/docs/components/Feedback/Feedback.utils.ts index cf9ff4c989a..2fa626ce14d 100644 --- a/apps/docs/components/Feedback/Feedback.utils.ts +++ b/apps/docs/components/Feedback/Feedback.utils.ts @@ -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 } diff --git a/apps/docs/content/guides/auth/quickstarts/nextjs.mdx b/apps/docs/content/guides/auth/quickstarts/nextjs.mdx index 90b0bec7d26..69e6a29ef44 100644 --- a/apps/docs/content/guides/auth/quickstarts/nextjs.mdx +++ b/apps/docs/content/guides/auth/quickstarts/nextjs.mdx @@ -52,7 +52,7 @@ hideToc: true - 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). diff --git a/apps/docs/content/guides/auth/social-login/auth-apple.mdx b/apps/docs/content/guides/auth/social-login/auth-apple.mdx index 0316b186396..aa2faa122e1 100644 --- a/apps/docs/content/guides/auth/social-login/auth-apple.mdx +++ b/apps/docs/content/guides/auth/social-login/auth-apple.mdx @@ -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" /> diff --git a/apps/docs/content/guides/database/extensions/pg_plan_filter.mdx b/apps/docs/content/guides/database/extensions/pg_plan_filter.mdx index fe490755a0b..17e8782a234 100644 --- a/apps/docs/content/guides/database/extensions/pg_plan_filter.mdx +++ b/apps/docs/content/guides/database/extensions/pg_plan_filter.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; diff --git a/apps/docs/content/guides/local-development/declarative-database-schemas.mdx b/apps/docs/content/guides/local-development/declarative-database-schemas.mdx index 26eccaca0a3..37b2b085342 100644 --- a/apps/docs/content/guides/local-development/declarative-database-schemas.mdx +++ b/apps/docs/content/guides/local-development/declarative-database-schemas.mdx @@ -42,6 +42,12 @@ create table "employees" ( + + +Make sure your local database is stopped before diffing your schema. + + + @@ -64,12 +70,6 @@ supabase db diff -f create_employees_table - - -Make sure your local database is stopped before diffing your schema. - - - diff --git a/apps/docs/lib/octokit.ts b/apps/docs/lib/octokit.ts index bfe5a0f1b88..edad471ae79 100644 --- a/apps/docs/lib/octokit.ts +++ b/apps/docs/lib/octokit.ts @@ -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 diff --git a/apps/docs/package.json b/apps/docs/package.json index 4208f151170..b145e54337f 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -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", diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index ef470c4a57e..d69b658b8c8 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -32,6 +32,7 @@ David A. Ventimiglia Deji I Div Arora Divit D +Eduardo Gurgel Egor Romanov Eleftheria Trivyzaki Etienne Stalmans diff --git a/apps/docs/resources/rootSchema.ts b/apps/docs/resources/rootSchema.ts new file mode 100644 index 00000000000..05c5d6d0f1b --- /dev/null +++ b/apps/docs/resources/rootSchema.ts @@ -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, + }, + }), +}) diff --git a/apps/studio/components/grid/SupabaseGrid.tsx b/apps/studio/components/grid/SupabaseGrid.tsx index d24332ae8f5..d6dfe395600 100644 --- a/apps/studio/components/grid/SupabaseGrid.tsx +++ b/apps/studio/components/grid/SupabaseGrid.tsx @@ -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(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 (
-
+
{children || ( <> diff --git a/apps/studio/components/grid/components/header/Header.tsx b/apps/studio/components/grid/components/header/Header.tsx index f88b172e15f..55847f98d3e 100644 --- a/apps/studio/components/grid/components/header/Header.tsx +++ b/apps/studio/components/grid/components/header/Header.tsx @@ -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 (
{customHeader ? ( - <>{customHeader} + customHeader + ) : snap.selectedRows.size > 0 ? ( + ) : ( - <> - {snap.selectedRows.size > 0 ? ( - - ) : ( - - )} - + )}
@@ -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 (
- - + +
{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({ diff --git a/apps/studio/components/grid/components/header/filter/FilterPopover.tsx b/apps/studio/components/grid/components/header/filter/FilterPopover.tsx index ccc6b91295c..cab1b6e51b1 100644 --- a/apps/studio/components/grid/components/header/filter/FilterPopover.tsx +++ b/apps/studio/components/grid/components/header/filter/FilterPopover.tsx @@ -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 ( - - - - - - - - - ) -} - -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(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) { - if (event.key === 'Enter') onSelectApplyFilters() - } - - return ( -
-
- {filters.map((filter, index) => ( - - ))} - {filters.length == 0 && ( -
-
No filters applied to this view
-

Add a column below to filter the view

-
- )} -
- -
- - -
-
+ ) } diff --git a/apps/studio/components/grid/components/header/filter/FilterPopoverPrimitive.tsx b/apps/studio/components/grid/components/header/filter/FilterPopoverPrimitive.tsx new file mode 100644 index 00000000000..8891ff8dcef --- /dev/null +++ b/apps/studio/components/grid/components/header/filter/FilterPopoverPrimitive.tsx @@ -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(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) { + if (event.key === 'Enter') onSelectApplyFilters() + } + + return ( + + + + + +
+
+ {localFilters.map((filter, index) => ( + + ))} + {localFilters.length == 0 && ( +
+
No filters applied to this view
+

+ Add a column below to filter the view +

+
+ )} +
+ +
+ + +
+
+
+
+ ) +} diff --git a/apps/studio/components/grid/components/header/sort/SortPopover.tsx b/apps/studio/components/grid/components/header/sort/SortPopover.tsx index cbd90ecb274..f7cc7e20400 100644 --- a/apps/studio/components/grid/components/header/sort/SortPopover.tsx +++ b/apps/studio/components/grid/components/header/sort/SortPopover.tsx @@ -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 ( - - - - - - - - - ) -} - -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(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 ( -
- {sorts.map((sort, index) => ( - - ))} - {sorts.length === 0 && ( -
-
No sorts applied to this view
-

Add a column below to sort the view

-
- )} - - -
- {columns && columns.length > 0 ? ( - - - - ) : ( -

All columns have been added

- )} -
- -
-
-
- ) + return } diff --git a/apps/studio/components/grid/components/header/sort/SortPopoverPrimitive.tsx b/apps/studio/components/grid/components/header/sort/SortPopoverPrimitive.tsx new file mode 100644 index 00000000000..7cba3696a96 --- /dev/null +++ b/apps/studio/components/grid/components/header/sort/SortPopoverPrimitive.tsx @@ -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(sorts) + + // Track the last props we received for comparison + const lastSortsRef = useRef(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 = ( +
+ {localSorts.map((sort, index) => ( + + ))} + {localSorts.length === 0 && ( +
+
No sorts applied to this view
+

Add a column below to sort the view

+
+ )} + + +
+ {dropdownOptions && dropdownOptions.length > 0 ? ( + + + + ) : ( +

All columns have been added

+ )} +
+ +
+
+
+ ) + + return ( + + + + + + {content} + + + ) +} diff --git a/apps/studio/components/grid/components/header/sort/index.ts b/apps/studio/components/grid/components/header/sort/index.ts deleted file mode 100644 index 8f4094016a6..00000000000 --- a/apps/studio/components/grid/components/header/sort/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as SortPopover } from './SortPopover' diff --git a/apps/studio/components/grid/hooks/README.md b/apps/studio/components/grid/hooks/README.md new file mode 100644 index 00000000000..559e08b58dc --- /dev/null +++ b/apps/studio/components/grid/hooks/README.md @@ -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 ( + <> + + + + + {/* 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 diff --git a/apps/studio/components/grid/hooks/useSaveTableEditorState.ts b/apps/studio/components/grid/hooks/useSaveTableEditorState.ts new file mode 100644 index 00000000000..9157100956c --- /dev/null +++ b/apps/studio/components/grid/hooks/useSaveTableEditorState.ts @@ -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 } +} diff --git a/apps/studio/components/grid/hooks/useTableFilter.ts b/apps/studio/components/grid/hooks/useTableFilter.ts new file mode 100644 index 00000000000..0181bbce9ca --- /dev/null +++ b/apps/studio/components/grid/hooks/useTableFilter.ts @@ -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 + } +} diff --git a/apps/studio/components/grid/hooks/useTableSort.ts b/apps/studio/components/grid/hooks/useTableSort.ts new file mode 100644 index 00000000000..9cbd1c70a7a --- /dev/null +++ b/apps/studio/components/grid/hooks/useTableSort.ts @@ -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, + } +} diff --git a/apps/studio/components/interfaces/Account/Preferences/ThemeSettings.tsx b/apps/studio/components/interfaces/Account/Preferences/ThemeSettings.tsx index 73ae2129770..b05c8c5cf28 100644 --- a/apps/studio/components/interfaces/Account/Preferences/ThemeSettings.tsx +++ b/apps/studio/components/interfaces/Account/Preferences/ThemeSettings.tsx @@ -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) => ( } /> - +

Show entity types

diff --git a/apps/studio/components/interfaces/ProjectCreation/PostgresVersionSelector.tsx b/apps/studio/components/interfaces/ProjectCreation/PostgresVersionSelector.tsx index dd5bb1a399c..cfdcb09cfee 100644 --- a/apps/studio/components/interfaces/ProjectCreation/PostgresVersionSelector.tsx +++ b/apps/studio/components/interfaces/ProjectCreation/PostgresVersionSelector.tsx @@ -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 ( diff --git a/apps/studio/components/interfaces/Settings/Addons/Addons.tsx b/apps/studio/components/interfaces/Settings/Addons/Addons.tsx index 16cc9ddf870..9d426018192 100644 --- a/apps/studio/components/interfaces/Settings/Addons/Addons.tsx +++ b/apps/studio/components/interfaces/Settings/Addons/Addons.tsx @@ -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 = () => { - ) : isOrioleDb ? ( + ) : isOrioleDbInAws ? ( { 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 = () => { ), isOrioleDb && ( - <> - - - - OrioleDB - - - - This project uses OrioleDB - - - + + + + OrioleDB + + + + This project uses OrioleDB + + ), isOnLatestVersion && ( diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.DatePickers.tsx b/apps/studio/components/interfaces/Settings/Logs/Logs.DatePickers.tsx index 63889f0df5d..a113cd1539f 100644 --- a/apps/studio/components/interfaces/Settings/Logs/Logs.DatePickers.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.DatePickers.tsx @@ -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

{ - 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')) > diff --git a/apps/studio/components/interfaces/Settings/Logs/LogsFilterPopover.tsx b/apps/studio/components/interfaces/Settings/Logs/LogsFilterPopover.tsx index 6ddc6bf66f5..0bb61c5628f 100644 --- a/apps/studio/components/interfaces/Settings/Logs/LogsFilterPopover.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/LogsFilterPopover.tsx @@ -69,7 +69,7 @@ const LogsFilterPopover = ({ {options.label} - +

{ e.preventDefault() diff --git a/apps/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx b/apps/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx index 9948cca9e04..a09f04964ad 100644 --- a/apps/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/LogsQueryPanel.tsx @@ -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 { diff --git a/apps/studio/components/interfaces/TableGridEditor/DeleteConfirmationDialogs.tsx b/apps/studio/components/interfaces/TableGridEditor/DeleteConfirmationDialogs.tsx index 62bb1fd0627..361f79ea9fe 100644 --- a/apps/studio/components/interfaces/TableGridEditor/DeleteConfirmationDialogs.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/DeleteConfirmationDialogs.tsx @@ -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(), }) diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/ForeignRowSelector.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/ForeignRowSelector.tsx index 221e6bfa353..92eab845314 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/ForeignRowSelector.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/RowEditor/ForeignRowSelector/ForeignRowSelector.tsx @@ -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 = ({
- - +
diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx index 5b9799e38ea..af57fb9decb 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx @@ -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 diff --git a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx index df3d071c0cb..2ad8c0c54ca 100644 --- a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx @@ -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} /> +
) diff --git a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LocalVersionPopover.tsx b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LocalVersionPopover.tsx index 1fdd245e201..6adaa0abf2b 100644 --- a/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LocalVersionPopover.tsx +++ b/apps/studio/components/layouts/ProjectLayout/LayoutHeader/LocalVersionPopover.tsx @@ -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 && (

Available version:

-

{data.latest}

+

{latestCliVersion}

)}
diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx index 52b458484e9..15b3be04104 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx @@ -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'>( diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx index 31a02e6c9fe..cb26ba3cd0c 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SQLEditorNav.tsx @@ -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( @@ -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 ( <> diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SearchList.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SearchList.tsx index 3a7305a4bcc..9c74944dbc9 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SearchList.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorNavV2/SearchList.tsx @@ -29,7 +29,7 @@ export const SearchList = ({ search }: SearchListProps) => { { keepPreviousData: true } ) - const { data: count, isLoading: isLoadingCount } = useContentCountQuery( + const { data: count } = useContentCountQuery( { projectRef, cumulative: true, diff --git a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx index 1474176cd1a..3c4ea8c51bb 100644 --- a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx @@ -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('') @@ -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 ( <>
diff --git a/apps/studio/components/layouts/Tabs/Tabs.utils.ts b/apps/studio/components/layouts/Tabs/Tabs.utils.ts index e9636ab5177..9fc1e1225c3 100644 --- a/apps/studio/components/layouts/Tabs/Tabs.utils.ts +++ b/apps/studio/components/layouts/Tabs/Tabs.utils.ts @@ -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 }) + }) } diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/ConfirmDeleteModal.tsx b/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/ConfirmDeleteModal.tsx index 6f041a1de0b..1aefdb6456d 100644 --- a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/ConfirmDeleteModal.tsx +++ b/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/ConfirmDeleteModal.tsx @@ -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 ( - {title}} + title={{title}} size="medium" onCancel={onSelectCancel} - customFooter={ -
- - -
- } - > - - - {description} - - -
+ onConfirm={onConfirmDelete} + variant="destructive" + alert={{ + base: { variant: 'destructive' }, + title: 'This action cannot be undone', + description, + }} + /> ) } diff --git a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/StorageExplorer.tsx b/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/StorageExplorer.tsx index a6d131379d2..020eacb45ab 100644 --- a/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/StorageExplorer.tsx +++ b/apps/studio/components/to-be-cleaned/Storage/StorageExplorer/StorageExplorer.tsx @@ -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' diff --git a/apps/studio/components/ui/Charts/ComposedChart.tsx b/apps/studio/components/ui/Charts/ComposedChart.tsx index 7ebfa5db0f6..1e6c62bbf1f 100644 --- a/apps/studio/components/ui/Charts/ComposedChart.tsx +++ b/apps/studio/components/ui/Charts/ComposedChart.tsx @@ -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 extends CommonChartProps { attributes: MultiAttribute[] diff --git a/apps/studio/components/ui/Charts/ComposedChart.utils.tsx b/apps/studio/components/ui/Charts/ComposedChart.utils.tsx index 3a6e83663bc..bacb440bf9b 100644 --- a/apps/studio/components/ui/Charts/ComposedChart.utils.tsx +++ b/apps/studio/components/ui/Charts/ComposedChart.utils.tsx @@ -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 } diff --git a/apps/studio/components/ui/FilterPopover.tsx b/apps/studio/components/ui/FilterPopover.tsx index d3eed31644a..186d4c3c652 100644 --- a/apps/studio/components/ui/FilterPopover.tsx +++ b/apps/studio/components/ui/FilterPopover.tsx @@ -83,7 +83,7 @@ export const FilterPopover = >({
- +
{title ?? `Select ${name.toLowerCase()}`} diff --git a/apps/studio/components/ui/QueryBlock/QueryBlock.tsx b/apps/studio/components/ui/QueryBlock/QueryBlock.tsx index 9424b39c1cc..b2d5fa639c4 100644 --- a/apps/studio/components/ui/QueryBlock/QueryBlock.tsx +++ b/apps/studio/components/ui/QueryBlock/QueryBlock.tsx @@ -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() const [queryResult, setQueryResult] = useState(results) + const [focusDataIndex, setFocusDataIndex] = useState() const formattedQueryResult = useMemo(() => { // Make sure Y axis values are numbers @@ -379,10 +381,9 @@ export const QueryBlock = ({

Select columns for the X and Y axes

) : ( -
+
{ + if (e.activeTooltipIndex !== focusDataIndex) { + setFocusDataIndex(e.activeTooltipIndex) + } + }} + onMouseLeave={() => setFocusDataIndex(undefined)} > - + - } /> - + } /> + + {chartData?.map((_: any, index: number) => ( + + ))} +
diff --git a/apps/studio/components/ui/SchemaSelector.tsx b/apps/studio/components/ui/SchemaSelector.tsx index df05ba0ceba..7a0293ca219 100644 --- a/apps/studio/components/ui/SchemaSelector.tsx +++ b/apps/studio/components/ui/SchemaSelector.tsx @@ -123,9 +123,10 @@ const SchemaSelector = ({ diff --git a/apps/studio/data/database/backups-query.ts b/apps/studio/data/database/backups-query.ts index 626ffef968d..7cef5a9462a 100644 --- a/apps/studio/data/database/backups-query.ts +++ b/apps/studio/data/database/backups-query.ts @@ -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 = ( { enabled = true, ...options }: UseQueryOptions = {} ) => { // [Joshen] Check for specifically false to account for project not loaded yet - const isOrioleDb = useIsOrioleDb() + const isOrioleDbInAws = useIsOrioleDbInAws() return useQuery( databaseKeys.backups(projectRef), ({ signal }) => getBackups({ projectRef }, signal), - { enabled: enabled && isOrioleDb === false && typeof projectRef !== 'undefined', ...options } + { + enabled: enabled && !isOrioleDbInAws && typeof projectRef !== 'undefined', + ...options, + } ) } diff --git a/apps/studio/data/sql/execute-sql-mutation.ts b/apps/studio/data/sql/execute-sql-mutation.ts index 01df31c8bc1..b5ddceaf427 100644 --- a/apps/studio/data/sql/execute-sql-mutation.ts +++ b/apps/studio/data/sql/execute-sql-mutation.ts @@ -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 -} diff --git a/apps/studio/data/table-editor/keys.ts b/apps/studio/data/table-editor/keys.ts index 6ce556c9754..851eefd9838 100644 --- a/apps/studio/data/table-editor/keys.ts +++ b/apps/studio/data/table-editor/keys.ts @@ -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), } diff --git a/apps/studio/data/table-editor/table-editor-types.ts b/apps/studio/data/table-editor/table-editor-types.ts index 1014acac8cd..b793f43111d 100644 --- a/apps/studio/data/table-editor/table-editor-types.ts +++ b/apps/studio/data/table-editor/table-editor-types.ts @@ -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, + } +} diff --git a/apps/studio/data/table-rows/table-row-delete-all-mutation.ts b/apps/studio/data/table-rows/table-row-delete-all-mutation.ts index 0fc55394fb1..bfd96398af4 100644 --- a/apps/studio/data/table-rows/table-row-delete-all-mutation.ts +++ b/apps/studio/data/table-rows/table-row-delete-all-mutation.ts @@ -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 } diff --git a/apps/studio/data/table-rows/table-rows-query.ts b/apps/studio/data/table-rows/table-rows-query.ts index 56825ec17d7..1241ab9e4cf 100644 --- a/apps/studio/data/table-rows/table-rows-query.ts +++ b/apps/studio/data/table-rows/table-rows-query.ts @@ -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'}` + ) } } diff --git a/apps/studio/data/table-rows/utils.ts b/apps/studio/data/table-rows/utils.ts index 1cd131be63a..bb6e78a811f 100644 --- a/apps/studio/data/table-rows/utils.ts +++ b/apps/studio/data/table-rows/utils.ts @@ -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) diff --git a/apps/studio/hooks/misc/useSelectedProject.ts b/apps/studio/hooks/misc/useSelectedProject.ts index 7fb9b5d60e8..c973f22e083 100644 --- a/apps/studio/hooks/misc/useSelectedProject.ts +++ b/apps/studio/hooks/misc/useSelectedProject.ts @@ -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 +} diff --git a/apps/studio/pages/api/cli-release-version.ts b/apps/studio/pages/api/cli-release-version.ts index 0c67461cb35..7c6ce96b38a 100644 --- a/apps/studio/pages/api/cli-release-version.ts +++ b/apps/studio/pages/api/cli-release-version.ts @@ -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 { diff --git a/apps/studio/pages/project/[ref]/database/backups/pitr.tsx b/apps/studio/pages/project/[ref]/database/backups/pitr.tsx index 166c05189bb..275ae658474 100644 --- a/apps/studio/pages/project/[ref]/database/backups/pitr.tsx +++ b/apps/studio/pages/project/[ref]/database/backups/pitr.tsx @@ -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 } - if (isOrioleDb) { + if (isOrioleDbInAws) { return ( { 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 = () => { - {isOrioleDb ? ( + {isOrioleDbInAws ? ( { - - + {project?.ref !== undefined && selectedTable !== undefined && isTableLike(selectedTable) && ( + + + + + )} ) } diff --git a/apps/studio/pages/project/[ref]/database/tables/index.tsx b/apps/studio/pages/project/[ref]/database/tables/index.tsx index 512e29931cf..c56310f5d36 100644 --- a/apps/studio/pages/project/[ref]/database/tables/index.tsx +++ b/apps/studio/pages/project/[ref]/database/tables/index.tsx @@ -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() + const [selectedTableToEdit, setSelectedTableToEdit] = useState() return ( <> @@ -24,15 +28,15 @@ const DatabaseTables: NextPageWithLayout = () => { { - 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 = () => { - - + {projectRef !== undefined && + selectedTableToEdit !== undefined && + isTableLike(selectedTableToEdit) && ( + + + + )} + + ) } diff --git a/apps/studio/pages/project/[ref]/editor/[id].tsx b/apps/studio/pages/project/[ref]/editor/[id].tsx index c1f16f54964..da2077944bf 100644 --- a/apps/studio/pages/project/[ref]/editor/[id].tsx +++ b/apps/studio/pages/project/[ref]/editor/[id].tsx @@ -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 ( - <> - - - - ) + return } TableEditorPage.getLayout = (page) => ( diff --git a/apps/studio/pages/project/[ref]/editor/index.tsx b/apps/studio/pages/project/[ref]/editor/index.tsx index 2f3bebfa86f..e1a129c3808 100644 --- a/apps/studio/pages/project/[ref]/editor/index.tsx +++ b/apps/studio/pages/project/[ref]/editor/index.tsx @@ -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]) diff --git a/apps/studio/state/table-editor-table.tsx b/apps/studio/state/table-editor-table.tsx index ea62ab755d0..60c133eecea 100644 --- a/apps/studio/state/table-editor-table.tsx +++ b/apps/studio/state/table-editor-table.tsx @@ -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 & { width?: number }).width = width + if (state.gridColumns[index]) { + ;(state.gridColumns[index] as CalculatedColumn & { width?: number }).width = width + } }, freezeColumn: (columnKey: string) => { const index = state.gridColumns.findIndex((x) => x.key === columnKey) - ;(state.gridColumns[index] as CalculatedColumn & { frozen?: boolean }).frozen = true + if (state.gridColumns[index]) { + ;(state.gridColumns[index] as CalculatedColumn & { frozen?: boolean }).frozen = + true + } }, unfreezeColumn: (columnKey: string) => { const index = state.gridColumns.findIndex((x) => x.key === columnKey) - ;(state.gridColumns[index] as CalculatedColumn & { frozen?: boolean }).frozen = - false + if (state.gridColumns[index]) { + ;(state.gridColumns[index] as CalculatedColumn & { frozen?: boolean }).frozen = + false + } }, updateColumnIdx: (columnKey: string, columnIdx: number) => { const index = state.gridColumns.findIndex((x) => x.key === columnKey) - ;(state.gridColumns[index] as CalculatedColumn & { idx?: number }).idx = columnIdx - + if (state.gridColumns[index]) { + ;(state.gridColumns[index] as CalculatedColumn & { idx?: number }).idx = columnIdx + } state.gridColumns.sort((a, b) => a.idx - b.idx) }, diff --git a/apps/studio/state/table-editor.tsx b/apps/studio/state/table-editor.tsx index 6230e27716d..be940f4135e 100644 --- a/apps/studio/state/table-editor.tsx +++ b/apps/studio/state/table-editor.tsx @@ -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' diff --git a/apps/studio/tests/pages/projects/Logs.Datepickers.test.tsx b/apps/studio/tests/pages/projects/Logs.Datepickers.test.tsx index c4ea241af56..82ecd7f9384 100644 --- a/apps/studio/tests/pages/projects/Logs.Datepickers.test.tsx +++ b/apps/studio/tests/pages/projects/Logs.Datepickers.test.tsx @@ -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(), }) }) diff --git a/apps/ui-library/package.json b/apps/ui-library/package.json index 8157c99e4e7..8523c160c0f 100644 --- a/apps/ui-library/package.json +++ b/apps/ui-library/package.json @@ -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", diff --git a/apps/ui-library/public/img/supabase-og-image.png b/apps/ui-library/public/img/supabase-og-image.png index c2f4d0ea806..cba27d69859 100644 Binary files a/apps/ui-library/public/img/supabase-og-image.png and b/apps/ui-library/public/img/supabase-og-image.png differ diff --git a/apps/www/_customers/bree.mdx b/apps/www/_customers/bree.mdx index 411ea8e9e03..8e70581f263 100644 --- a/apps/www/_customers/bree.mdx +++ b/apps/www/_customers/bree.mdx @@ -30,7 +30,7 @@ region: 'North America' supabase_products: ['database', 'auth', 'functions', 'vector'] --- - + 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. @@ -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. - + 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. @@ -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. - + 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. @@ -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 - + 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. @@ -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. - + After migrating to Supabase, we saw immediate lift in developer velocity. Engineers can iterate on features much faster by writing Postgres SQL queries. 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. - + It already has double-digit latency improvements. With further optimization, some functions were 10x faster. @@ -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 - + 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. diff --git a/apps/www/_customers/euka.mdx b/apps/www/_customers/euka.mdx new file mode 100644 index 00000000000..7a8f2b4728a --- /dev/null +++ b/apps/www/_customers/euka.mdx @@ -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'] +--- + + + Supabase gave us the tools we needed to move fast, scale quickly, and stay flexible as we grow — + all without the heavy lifting. + + +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. + + + It just worked. Supabase gave us a zero-to-one experience that let us build quickly without + worrying about the backend. + + +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. + + + Setting up the database and storage was one of the easiest parts of building our MVP. + + +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. + + + With Supabase, we saw an immediate lift in developer velocity. Engineers can iterate on features + much faster by writing Postgres SQL queries. + + +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. + + + Honestly, it’s a no-brainer these days. If you’re starting something new and need structured data, + Supabase is the obvious choice. + + +## Ready to build and scale with Supabase? + +Start your journey today at [www.supabase.com](/) diff --git a/apps/www/components/logos.tsx b/apps/www/components/logos.tsx index 12f0e8b9440..b2ef09c79c7 100644 --- a/apps/www/components/logos.tsx +++ b/apps/www/components/logos.tsx @@ -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', diff --git a/apps/www/data/CustomerStories.ts b/apps/www/data/CustomerStories.ts index cdca1608ba2..93350249549 100644 --- a/apps/www/data/CustomerStories.ts +++ b/apps/www/data/CustomerStories.ts @@ -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", diff --git a/apps/www/public/customers-rss.xml b/apps/www/public/customers-rss.xml index fb6ea467bda..48b0f075937 100644 --- a/apps/www/public/customers-rss.xml +++ b/apps/www/public/customers-rss.xml @@ -5,9 +5,16 @@ https://supabase.com Latest news from Supabase en - Tue, 08 Apr 2025 00:00:00 -0700 + Tue, 29 Apr 2025 00:00:00 -0700 + https://supabase.com/customers/euka + Euka used Supabase to unlock faster growth + https://supabase.com/customers/euka + 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. + Tue, 29 Apr 2025 00:00:00 -0700 + + https://supabase.com/customers/bree Bree's Migration to Supabase from Fauna https://supabase.com/customers/bree diff --git a/apps/www/public/images/blog/avatars/kevin-wang-euka.jpeg b/apps/www/public/images/blog/avatars/kevin-wang-euka.jpeg new file mode 100644 index 00000000000..365a2020ba2 Binary files /dev/null and b/apps/www/public/images/blog/avatars/kevin-wang-euka.jpeg differ diff --git a/apps/www/public/images/customers/logos/euka.png b/apps/www/public/images/customers/logos/euka.png new file mode 100644 index 00000000000..d60da3dc2a6 Binary files /dev/null and b/apps/www/public/images/customers/logos/euka.png differ diff --git a/apps/www/public/images/customers/logos/light/euka.png b/apps/www/public/images/customers/logos/light/euka.png new file mode 100644 index 00000000000..6de3722bdb4 Binary files /dev/null and b/apps/www/public/images/customers/logos/light/euka.png differ diff --git a/apps/www/public/images/logos/publicity/krea.svg b/apps/www/public/images/logos/publicity/krea.svg deleted file mode 100644 index 76079718c47..00000000000 --- a/apps/www/public/images/logos/publicity/krea.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/packages/common/telemetry.tsx b/packages/common/telemetry.tsx index 29278df8cfa..51e9295aee1 100644 --- a/packages/common/telemetry.tsx +++ b/packages/common/telemetry.tsx @@ -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, { diff --git a/packages/ui-patterns/admonition.tsx b/packages/ui-patterns/admonition.tsx index 5c546acbedd..6c046091cc0 100644 --- a/packages/ui-patterns/admonition.tsx +++ b/packages/ui-patterns/admonition.tsx @@ -103,7 +103,6 @@ export const Admonition = forwardRef< {...props} className={cn( 'mb-2', - '[&_a]:underline', admonitionSVG({ type: typeMapped }), admonitionBase({ type: typeMapped }), props.className diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e1c0b3aeaa3..0aa189bb6db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/supabase/migrations/20250423133137_improve_vector_search.sql b/supabase/migrations/20250423133137_improve_vector_search.sql new file mode 100644 index 00000000000..8563164e5bd --- /dev/null +++ b/supabase/migrations/20250423133137_improve_vector_search.sql @@ -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); +$$;