From 67d4fed40dc489c3a9d65f0df5d164be64cdaded Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Fri, 14 Aug 2026 11:29:39 +0700 Subject: [PATCH 01/11] Joshenlim/fe 4157 explorer migrate results component into explorer (#49066) ## Context Related to Notebooks/Explorers - this one's just shifting files from the SQLEditor into more generic folders from a file organization POV, such that files under the Explorer folder have no dependency on files within the SQLEditor folder Mainly - UtilityTabResults.utils: `getSqlErrorLines` - Moved into `data/sql/utils.ts` - SQLEditor.utils: `applyAutoLimit`, `getSqlErrorLines`, `trimTrailingSemicolons` - Moved into `data/sql/utils.ts` - SQLEditor/UtilityPanel: `ResultCell`, `Results`, `CellDetailPanel` - Moved into `components/ui/DataGridResults` - Also shifted corresponding tests over here - Also addressed some `any` type casts ## To test - Just need to ensure that the SQL Editor still works as expected ## Summary by CodeRabbit * **New Features** * Standardized query results across the Studio with a shared data grid. * Improved result-table formatting, column sizing, clipboard handling, and large-value display. * Added safer automatic row limits for eligible SQL queries. * Centralized SQL error display and formatting utilities. * **Refactor** * Improved type safety for query rows and cell values. * **Tests** * Added comprehensive coverage for result-grid and SQL utility behavior. --- .../components/grid/SupabaseGrid.utils.ts | 2 +- .../interfaces/Explorer/QueryEditor.tsx | 2 +- .../interfaces/Explorer/QueryResultTable.tsx | 6 +- .../Reports/ReportBlock/ReportBlock.tsx | 2 +- .../SQLEditor/SQLEditor.utils.test.ts | 162 ------------ .../interfaces/SQLEditor/SQLEditor.utils.ts | 69 +---- .../UtilityPanel/Results.utils.test.ts | 132 ---------- .../SQLEditor/UtilityPanel/Results.utils.ts | 39 --- .../UtilityPanel/UtilityTabResults.tsx | 6 +- .../UtilityTabResults.utils.test.ts | 80 ------ .../UtilityPanel/UtilityTabResults.utils.ts | 18 -- .../DataGridResults}/CellDetailPanel.tsx | 2 +- .../DataGridResults/DataGridResults.utils.ts | 40 +++ .../DataGridResults}/ResultCell.tsx | 2 +- .../__tests__/DataGridResults.test.tsx} | 39 ++- .../__tests__/DataGridResults.utils.test.ts | 138 ++++++++++ .../__tests__}/ResultCell.test.tsx | 2 +- .../DataGridResults/index.tsx} | 19 +- .../components/ui/EditorPanel/EditorPanel.tsx | 10 +- .../components/ui/QueryBlock/QueryBlock.tsx | 4 +- apps/studio/data/sql/__tests__/utils.test.ts | 241 ++++++++++++++++++ apps/studio/data/sql/utils.ts | 80 ++++++ 22 files changed, 560 insertions(+), 535 deletions(-) delete mode 100644 apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabResults.utils.test.ts delete mode 100644 apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabResults.utils.ts rename apps/studio/components/{interfaces/SQLEditor/UtilityPanel => ui/DataGridResults}/CellDetailPanel.tsx (99%) create mode 100644 apps/studio/components/ui/DataGridResults/DataGridResults.utils.ts rename apps/studio/components/{interfaces/SQLEditor/UtilityPanel => ui/DataGridResults}/ResultCell.tsx (95%) rename apps/studio/{tests/components/SQLEditor/Results.test.tsx => components/ui/DataGridResults/__tests__/DataGridResults.test.tsx} (54%) create mode 100644 apps/studio/components/ui/DataGridResults/__tests__/DataGridResults.utils.test.ts rename apps/studio/{tests/components/SQLEditor => components/ui/DataGridResults/__tests__}/ResultCell.test.tsx (96%) rename apps/studio/components/{interfaces/SQLEditor/UtilityPanel/Results.tsx => ui/DataGridResults/index.tsx} (89%) create mode 100644 apps/studio/data/sql/__tests__/utils.test.ts create mode 100644 apps/studio/data/sql/utils.ts diff --git a/apps/studio/components/grid/SupabaseGrid.utils.ts b/apps/studio/components/grid/SupabaseGrid.utils.ts index 2818e55edbc45..43b8ef31d1a4e 100644 --- a/apps/studio/components/grid/SupabaseGrid.utils.ts +++ b/apps/studio/components/grid/SupabaseGrid.utils.ts @@ -283,7 +283,7 @@ export function useSyncTableEditorStateFromLocalStorageWithUrl({ }, [urlParams, table, projectRef]) } -export const handleCellKeyDown = ( +export const handleCellKeyDown = = SupaRow>( args: CellKeyDownArgs, event: CellKeyboardEvent, context?: { diff --git a/apps/studio/components/interfaces/Explorer/QueryEditor.tsx b/apps/studio/components/interfaces/Explorer/QueryEditor.tsx index 8c79ef0077794..1a134b9dd7ce2 100644 --- a/apps/studio/components/interfaces/Explorer/QueryEditor.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryEditor.tsx @@ -24,7 +24,6 @@ import { DisplaySettingsButton } from './QueryCell/DisplaySettingsButton' import { QueryResultChart } from './QueryCell/QueryResultChart' import { QueryResultTable } from './QueryResultTable' import { type QueryDisplay, type QueryResult } from './types' -import { applyAutoLimit } from '@/components/interfaces/SQLEditor/SQLEditor.utils' import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor' import { isValidConnString } from '@/data/fetchers' import { useExecuteLogsSqlMutation } from '@/data/logs/execute-logs-sql-mutation' @@ -36,6 +35,7 @@ import { } from '@/data/query-sources/query-source-registry' import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query' import { useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation' +import { applyAutoLimit } from '@/data/sql/utils' import { useLatest } from '@/hooks/misc/useLatest' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' diff --git a/apps/studio/components/interfaces/Explorer/QueryResultTable.tsx b/apps/studio/components/interfaces/Explorer/QueryResultTable.tsx index ba88cb1d27424..abf405d0e7827 100644 --- a/apps/studio/components/interfaces/Explorer/QueryResultTable.tsx +++ b/apps/studio/components/interfaces/Explorer/QueryResultTable.tsx @@ -4,13 +4,13 @@ import { parseAsBoolean, useQueryState } from 'nuqs' import { Button, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' import { subscriptionHasHipaaAddon } from '../Billing/Subscription/Subscription.utils' -import { Results } from '../SQLEditor/UtilityPanel/Results' -import { getSqlErrorLines } from '../SQLEditor/UtilityPanel/UtilityTabResults.utils' import { type QueryResult } from './types' import { AiAssistantDropdown } from '@/components/ui/AiAssistantDropdown' import CopyButton from '@/components/ui/CopyButton' +import { DataGridResults } from '@/components/ui/DataGridResults' import { InlineLink, InlineLinkClassName } from '@/components/ui/InlineLink' import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query' +import { getSqlErrorLines } from '@/data/sql/utils' import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-query' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { DOCS_URL } from '@/lib/constants' @@ -186,5 +186,5 @@ const QueryError = ({ // [Joshen] Eventually migrate the Results component here from SQL Editor const QueryResults = ({ rows }: { rows: NonNullable }) => { - return + return } diff --git a/apps/studio/components/interfaces/Reports/ReportBlock/ReportBlock.tsx b/apps/studio/components/interfaces/Reports/ReportBlock/ReportBlock.tsx index 2fea6cc8265ac..93d00b32219ee 100644 --- a/apps/studio/components/interfaces/Reports/ReportBlock/ReportBlock.tsx +++ b/apps/studio/components/interfaces/Reports/ReportBlock/ReportBlock.tsx @@ -5,7 +5,6 @@ import { X } from 'lucide-react' import { useEffect, useState } from 'react' import { toast } from 'sonner' -import { applyAutoLimit } from '../../SQLEditor/SQLEditor.utils' import { BURSTABLE_IO_METRIC_KEYS, DEPRECATED_REPORTS } from '../Reports.constants' import { ChartBlock } from './ChartBlock' import { DeprecatedChartBlock } from './DeprecatedChartBlock' @@ -20,6 +19,7 @@ import { useContentIdQuery } from '@/data/content/content-id-query' import { usePrimaryDatabase } from '@/data/read-replicas/replicas-query' import { executeSql } from '@/data/sql/execute-sql-mutation' import { sqlKeys } from '@/data/sql/keys' +import { applyAutoLimit } from '@/data/sql/utils' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector' import type { Dashboards, SqlSnippets } from '@/types' diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts index d981a2da927dd..5689288a9ac9c 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts @@ -7,7 +7,6 @@ import { DiffType, type IStandaloneCodeEditor } from './SQLEditor.types' import { analyzeQueryIssues, appendEnableRLSStatements, - applyAutoLimit, assembleCompletionDiff, buildCompletionRequestBody, buildDebugChatArgs, @@ -30,7 +29,6 @@ import { resolveDiffKeyAction, shouldAutoGenerateTitle, sqlSourceToDialect, - trimTrailingSemicolons, } from './SQLEditor.utils' import type { DatabaseEventTrigger } from '@/data/database-event-triggers/database-event-triggers-query' import type { Database } from '@/data/read-replicas/replicas-query' @@ -49,166 +47,6 @@ const buildTrigger = (overrides: Partial = {}): DatabaseEv ...overrides, }) -describe('SQLEditor.utils.ts:trimTrailingSemicolons', () => { - test('removes a single trailing semicolon', () => { - const sql = safeSql`select * from countries;` - expect(trimTrailingSemicolons(sql)).toBe('select * from countries') - }) - test('removes multiple trailing semicolons', () => { - const sql = safeSql`select * from countries;;;;;;;` - expect(trimTrailingSemicolons(sql)).toBe('select * from countries') - }) - test('leaves a fragment with no trailing semicolon unchanged', () => { - const sql = safeSql`select * from countries` - expect(trimTrailingSemicolons(sql)).toBe('select * from countries') - }) - test('does not touch semicolons that are not trailing', () => { - const sql = safeSql`select 1; select 2` - expect(trimTrailingSemicolons(sql)).toBe('select 1; select 2') - }) -}) - -describe('SQLEditor.utils.ts:applyAutoLimit', () => { - test('Should return false if limit passed is <= 0', () => { - const sql = safeSql`select * from countries;` - const limit = -1 - const { appendAutoLimit } = applyAutoLimit(sql, limit) - expect(appendAutoLimit).toBe(false) - }) - test('Should return true if limit passed is > 0', () => { - const sql = safeSql`select * from countries;` - const limit = 100 - const { appendAutoLimit } = applyAutoLimit(sql, limit) - expect(appendAutoLimit).toBe(true) - }) - test('Should return false if query already has a limit', () => { - const sql = safeSql`select * from countries limit 10;` - const limit = 100 - const { appendAutoLimit } = applyAutoLimit(sql, limit) - expect(appendAutoLimit).toBe(false) - }) - test('Should return false if query already has a limit (check for case-insensitiveness)', () => { - const sql = safeSql`SELECT * FROM countries LIMIT 10;` - const limit = 100 - const { appendAutoLimit } = applyAutoLimit(sql, limit) - expect(appendAutoLimit).toBe(false) - }) - test('Should return false if query already has a limit with whitespace before the semi colon', () => { - const sql = safeSql`select * from countries limit 10 ;` - const limit = 100 - const { appendAutoLimit } = applyAutoLimit(sql, limit) - expect(appendAutoLimit).toBe(false) - }) - test('Should return false if query already has a limit and offset', () => { - const sql = safeSql`select * from countries limit 10 offset 0;` - const limit = 100 - const { appendAutoLimit } = applyAutoLimit(sql, limit) - expect(appendAutoLimit).toBe(false) - }) - test('Should return false if query already has a limit and offset with whitespace before the semi colon', () => { - const sql = safeSql`select * from countries limit 10 offset 0 ;` - const limit = 100 - const { appendAutoLimit } = applyAutoLimit(sql, limit) - expect(appendAutoLimit).toBe(false) - }) - test('Should return false if query already has a limit and offset (flip order of limit and offset)', () => { - const sql = safeSql`select * from countries offset 0 limit 1;` - const limit = 100 - const { appendAutoLimit } = applyAutoLimit(sql, limit) - expect(appendAutoLimit).toBe(false) - }) - test('Should return false if query already has a limit, even if no value provided for limit', () => { - const sql = safeSql`select * from countries limit` - const limit = 100 - const { appendAutoLimit } = applyAutoLimit(sql, limit) - expect(appendAutoLimit).toBe(false) - }) - test('Should return false if query uses `FETCH FIRST` instead of limit ', () => { - const sql = safeSql`select * from countries FETCH FIRST 5 rows only` - const limit = 100 - const { appendAutoLimit } = applyAutoLimit(sql, limit) - expect(appendAutoLimit).toBe(false) - }) - test('Should return false if query uses `fetch first` instead of limit ', () => { - const sql = safeSql`select * from countries fetch first 5 rows only` - const limit = 100 - const { appendAutoLimit } = applyAutoLimit(sql, limit) - expect(appendAutoLimit).toBe(false) - }) - test('Should return false if query uses `fetch first` (with random spaces) instead of limit ', () => { - const sql = safeSql`select * from countries FETCH FIRST 5 rows only` - const limit = 100 - const { appendAutoLimit } = applyAutoLimit(sql, limit) - expect(appendAutoLimit).toBe(false) - }) - test('Should return false if query is not a select statement', () => { - const sql = safeSql`create table test (id int8 primary key, name varchar);` - const limit = 100 - const { appendAutoLimit } = applyAutoLimit(sql, limit) - expect(appendAutoLimit).toBe(false) - }) - test('Should return false if there are multiple queries I', () => { - const sql1 = safeSql`select * from countries; -select * from cities;` - const limit = 100 - const { appendAutoLimit } = applyAutoLimit(sql1, limit) - expect(appendAutoLimit).toBe(false) - }) - test('Should return false if there are multiple queries II', () => { - const sql1 = safeSql`select * from countries; -select * from cities` - const limit = 100 - const { appendAutoLimit } = applyAutoLimit(sql1, limit) - expect(appendAutoLimit).toBe(false) - }) - // [Joshen] Opting to just avoid appending in this case to prevent making the logic overly complex atm - test('Should return false if query has with a comment I', () => { - const sql = safeSql`-- This is a comment -select * from cities` - const limit = 100 - const { appendAutoLimit } = applyAutoLimit(sql, limit) - expect(appendAutoLimit).toBe(false) - }) - test('Should return false if query has with a comment II', () => { - const sql = safeSql`select * from cities --- This is a comment` - const limit = 100 - const { appendAutoLimit } = applyAutoLimit(sql, limit) - expect(appendAutoLimit).toBe(false) - }) - - // [Joshen] These will just need to test the cases when appendAutoLimit returns true then - test('Should add the limit param properly if query ends without a semi colon', () => { - const sql = safeSql`select * from countries` - const limit = 100 - const { sql: formattedSql } = applyAutoLimit(sql, limit) - expect(formattedSql).toBe('select * from countries limit 100;') - }) - test('Should add the limit param properly if query ends with a semi colon', () => { - const sql = safeSql`select * from countries;` - const limit = 100 - const { sql: formattedSql } = applyAutoLimit(sql, limit) - expect(formattedSql).toBe('select * from countries limit 100;') - }) - test('Should add the limit param properly if query ends with multiple semi colon', () => { - const sql = safeSql`select * from countries;;;;;;;` - const limit = 100 - const { sql: formattedSql } = applyAutoLimit(sql, limit) - expect(formattedSql).toBe('select * from countries limit 100;') - }) - test('Should not append a limit if query already has one with whitespace before the semi colon', () => { - const sql = safeSql`select * from countries limit 10 ;` - const limit = 100 - const { sql: formattedSql } = applyAutoLimit(sql, limit) - expect(formattedSql).toBe('select * from countries limit 10 ;') - }) - test('returns the SafeSqlFragment result unchanged when no limit is appended', () => { - const sql = safeSql`select * from countries limit 10;` - const { sql: formattedSql } = applyAutoLimit(sql, 100) - expect(formattedSql).toBe(sql) - }) -}) - describe('SQLEditor.utils.ts:shouldAutoGenerateTitle', () => { test('returns true when AI is enabled, the name is still the placeholder, and on platform', () => { expect( diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts index 188c8c0fdcef3..040442d614731 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts @@ -1,10 +1,4 @@ -import { - literal, - safeSql, - untrustedSql, - type SafeSqlFragment, - type UntrustedSqlFragment, -} from '@supabase/pg-meta' +import { untrustedSql, type SafeSqlFragment, type UntrustedSqlFragment } from '@supabase/pg-meta' import { TABLE_EVENT_ACTIONS } from 'common/telemetry-constants' import { isLogsSource, sqlSourceToFenceLanguage, type SqlSnippetSource } from './querySource' @@ -26,6 +20,7 @@ import type { SnippetWithContent } from '@/data/content/sql-folders-query' import type { DatabaseEventTrigger } from '@/data/database-event-triggers/database-event-triggers-query' import { untrustedLogSql, type UntrustedLogSqlFragment } from '@/data/logs/safe-analytics-sql' import type { Database } from '@/data/read-replicas/replicas-query' +import { applyAutoLimit } from '@/data/sql/utils' import { generateUuid } from '@/lib/api/snippets.browser' import { removeCommentsFromSql } from '@/lib/helpers' import { wrapWithRoleImpersonation } from '@/lib/role-impersonation' @@ -384,66 +379,6 @@ export const compareAsNewSnippet = (sqlDiff: ContentDiff) => { } } -/** - * Removes trailing `;` characters from a safe SQL fragment. Only ever removes - * existing terminators — never adds text — so the result is exactly as safe - * as the input; the brand carries over intentionally. This is the one place - * in the file allowed to reassert `SafeSqlFragment` on a derived string — - * every other function composes new fragments through `safeSql`/`literal`. - */ -export function trimTrailingSemicolons(sql: SafeSqlFragment): SafeSqlFragment { - return sql.replace(/;+\s*$/, '') as SafeSqlFragment -} - -// [Joshen] Just FYI as well the checks here on whether to append limit is quite restricted -// This is to prevent dashboard from accidentally appending limit to the end of a query -// thats not supposed to have any, since there's too many cases to cover. -// We can however look into making this logic better in the future -// i.e It's harder to append the limit param, than just leaving the query as it is -// Otherwise we'd need a full on parser to do this properly -// -// Only accepts `SafeSqlFragment`: this decides whether to build (and builds) -// a new SQL fragment that gets executed, so every caller — including ones -// that only want the `appendAutoLimit` flag for a display hint — must already -// hold safe SQL. Composes the ` limit N;` suffix through `safeSql`/`literal` -// rather than gluing raw template-literal text onto the fragment and casting -// the result, so the only new content this function ever stamps safe is an -// internally-generated integer literal, never arbitrary concatenated text. -export function applyAutoLimit( - sql: SafeSqlFragment, - limit: number = 0 -): { sql: SafeSqlFragment; appendAutoLimit: boolean } { - // Remove lines and whitespaces to use for checking - const cleanedSql = sql.trim().replaceAll('\n', ' ').replaceAll(/\s+/g, ' ') - - // Check how many queries - const regMatch = cleanedSql.matchAll(/[a-zA-Z]*[0-9]*[;]+/g) - const queries = new Array(...regMatch) - const indexSemiColon = cleanedSql.lastIndexOf(';') - const hasComments = cleanedSql.includes('--') - const hasMultipleQueries = - queries.length > 1 || (indexSemiColon > 0 && indexSemiColon !== cleanedSql.length - 1) - - // Check if need to auto limit rows - const appendAutoLimit = - limit > 0 && - !hasComments && - !hasMultipleQueries && - cleanedSql.toLowerCase().startsWith('select') && - !cleanedSql.toLowerCase().match(/fetch\s+first/i) && - !cleanedSql.match(/limit$/i) && - !cleanedSql.match(/limit;$/i) && - !cleanedSql.match(/limit [0-9]* offset [0-9]*\s*[;]?$/i) && - !cleanedSql.match(/limit [0-9]*\s*[;]?$/i) - - if (!appendAutoLimit) return { sql, appendAutoLimit: false } - - const core = cleanedSql.endsWith(';') ? trimTrailingSemicolons(sql) : sql - const suffixed = safeSql`${core} limit ${literal(limit)};` - - return { sql: suffixed, appendAutoLimit: true } -} - /** * Resolves the SQL to act on from the editor: the current selection if there is * one, otherwise the full editor contents, falling back to the snippet's stored diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.utils.test.ts b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.utils.test.ts index 88224d9076478..f1fb5d068fa8d 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.utils.test.ts +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.utils.test.ts @@ -1,100 +1,14 @@ import { describe, expect, it } from 'vitest' import { - calculateResultColumnWidth, convertResultsToCSV, convertResultsToJSON, convertResultsToMarkdown, - formatCellValue, - formatClipboardValue, formatResults, getResultsHeaders, - isLargeValue, } from './Results.utils' describe('Results.utils', () => { - describe('calculateResultColumnWidth', () => { - it('uses the minimum width when the column name and values are short', () => { - expect(calculateResultColumnWidth('id', [{ id: 1 }])).toBe(100) - }) - - it('accounts for a column name that is longer than its values', () => { - expect(calculateResultColumnWidth('source_campaign_id', [{ source_campaign_id: null }])).toBe( - 148.5 - ) - }) - - it('accounts for a value that is longer than the column name', () => { - expect(calculateResultColumnWidth('name', [{ name: 'a'.repeat(20) }])).toBe(165) - }) - - it('accounts for the formatted JSON representation of an object value', () => { - expect( - calculateResultColumnWidth('metadata', [{ metadata: { campaign: 'a'.repeat(20) } }]) - ).toBe(288.75) - }) - - it('accounts for the formatted JSON representation of an array value', () => { - expect(calculateResultColumnWidth('tags', [{ tags: ['a'.repeat(10), 'b'.repeat(10)] }])).toBe( - 222.75 - ) - }) - - it('caps the width when the column name exceeds the maximum', () => { - expect(calculateResultColumnWidth('a'.repeat(100), [])).toBe(500) - }) - - it('caps the width when a value exceeds the maximum', () => { - expect(calculateResultColumnWidth('value', [{ value: 'a'.repeat(100) }])).toBe(500) - }) - - it('uses the minimum width when there are no rows', () => { - expect(calculateResultColumnWidth('id', [])).toBe(100) - }) - }) - - describe('formatClipboardValue', () => { - it('returns empty string for null', () => { - expect(formatClipboardValue(null)).toBe('') - }) - - it('stringifies objects', () => { - expect(formatClipboardValue({ a: 1 })).toBe('{"a":1}') - }) - - it('stringifies arrays', () => { - expect(formatClipboardValue([1, 2])).toBe('[1,2]') - }) - - it('converts primitives to string', () => { - expect(formatClipboardValue('hello')).toBe('hello') - expect(formatClipboardValue(42)).toBe('42') - expect(formatClipboardValue(false)).toBe('false') - }) - }) - - describe('formatCellValue', () => { - it('returns NULL for null', () => { - expect(formatCellValue(null)).toBe('NULL') - }) - - it('returns strings as-is', () => { - expect(formatCellValue('hello')).toBe('hello') - }) - - it('stringifies objects', () => { - expect(formatCellValue({ a: 1 })).toBe('{"a":1}') - }) - - it('stringifies numbers', () => { - expect(formatCellValue(42)).toBe('42') - }) - - it('stringifies booleans', () => { - expect(formatCellValue(true)).toBe('true') - }) - }) - describe('formatResults', () => { it('should stringify object values', () => { const results = [{ id: 1, data: { nested: true } }] @@ -188,52 +102,6 @@ describe('Results.utils', () => { }) }) - describe('isLargeValue', () => { - it('returns false for null', () => { - expect(isLargeValue(null)).toBe(false) - }) - - it('returns false for undefined', () => { - expect(isLargeValue(undefined)).toBe(false) - }) - - it('returns false for an empty string', () => { - expect(isLargeValue('')).toBe(false) - }) - - it('returns false for a short string under the threshold', () => { - expect(isLargeValue('hello')).toBe(false) - }) - - it('returns false for a string at the 60-char boundary', () => { - expect(isLargeValue('a'.repeat(60))).toBe(false) - }) - - it('returns true for a string just over the 60-char threshold', () => { - expect(isLargeValue('a'.repeat(61))).toBe(true) - }) - - it('returns true for a short string containing a newline', () => { - expect(isLargeValue('hello\nworld')).toBe(true) - }) - - it('returns true for an object', () => { - expect(isLargeValue({ a: 1 })).toBe(true) - }) - - it('returns true for an array', () => { - expect(isLargeValue([1, 2, 3])).toBe(true) - }) - - it('returns false for a number', () => { - expect(isLargeValue(42)).toBe(false) - }) - - it('returns false for a boolean', () => { - expect(isLargeValue(true)).toBe(false) - }) - }) - describe('convertResultsToCSV', () => { it('should return undefined for empty results', () => { expect(convertResultsToCSV([])).toBeUndefined() diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.utils.ts b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.utils.ts index fb03dc9faf259..3791f6c9c614c 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.utils.ts +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.utils.ts @@ -3,45 +3,6 @@ import Papa from 'papaparse' type ResultRow = Record -const ESTIMATED_CHARACTER_WIDTH = 8.25 -export const RESULT_COLUMN_MIN_WIDTH = 100 -const MAX_COLUMN_WIDTH = 500 - -export function calculateResultColumnWidth(columnName: string, rows: readonly ResultRow[]) { - const maxContentLength = rows.reduce( - (maxLength, row) => Math.max(maxLength, (formatCellValue(row[columnName]) ?? '').length), - columnName.length - ) - - return Math.min( - Math.max(maxContentLength * ESTIMATED_CHARACTER_WIDTH, RESULT_COLUMN_MIN_WIDTH), - MAX_COLUMN_WIDTH - ) -} - -export function formatClipboardValue(value: unknown) { - if (value === null) return '' - if (typeof value == 'object' || Array.isArray(value)) { - return JSON.stringify(value) - } - return String(value) -} - -export function formatCellValue(value: unknown) { - if (value === null) return 'NULL' - if (typeof value === 'string') return value - return JSON.stringify(value) -} - -const LARGE_VALUE_CHAR_THRESHOLD = 60 - -export function isLargeValue(value: unknown) { - if (value === null || value === undefined) return false - if (typeof value === 'object') return true - const str = String(value) - return str.length > LARGE_VALUE_CHAR_THRESHOLD || str.includes('\n') -} - export function formatResults( results: ResultRow[] ): Record[] { diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabResults.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabResults.tsx index 441af7bbca32e..e2ebd2b42638b 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabResults.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabResults.tsx @@ -4,13 +4,13 @@ import { parseAsBoolean, useQueryState } from 'nuqs' import { forwardRef } from 'react' import { Button, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' -import { Results } from './Results' -import { getSqlErrorLines } from './UtilityTabResults.utils' import { subscriptionHasHipaaAddon } from '@/components/interfaces/Billing/Subscription/Subscription.utils' import { AiAssistantDropdown } from '@/components/ui/AiAssistantDropdown' import CopyButton from '@/components/ui/CopyButton' +import { DataGridResults } from '@/components/ui/DataGridResults' import { InlineLink, InlineLinkClassName } from '@/components/ui/InlineLink' import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query' +import { getSqlErrorLines } from '@/data/sql/utils' import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-query' import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization' import { DOCS_URL } from '@/lib/constants' @@ -185,7 +185,7 @@ export const UtilityTabResults = forwardRef + return } ) diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabResults.utils.test.ts b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabResults.utils.test.ts deleted file mode 100644 index 779e4a635c57a..0000000000000 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabResults.utils.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { getSqlErrorLines } from './UtilityTabResults.utils' - -describe('getSqlErrorLines', () => { - it('returns formattedError lines when present', () => { - const lines = getSqlErrorLines({ - message: 'permission denied for table users', - formattedError: - 'ERROR: 42501: permission denied for table users\n' + - 'HINT: To grant access to anon on a specific table:\n' + - ' GRANT SELECT ON TABLE public.users TO anon;', - }) - - expect(lines).toEqual([ - 'ERROR: 42501: permission denied for table users', - 'HINT: To grant access to anon on a specific table:', - ' GRANT SELECT ON TABLE public.users TO anon;', - ]) - }) - - it('strips empty lines from formattedError', () => { - const lines = getSqlErrorLines({ - formattedError: 'ERROR: boom\n\nHINT: retry\n', - }) - - expect(lines).toEqual(['ERROR: boom', 'HINT: retry']) - }) - - it('falls back to message lines when formattedError is missing and message is multi-line', () => { - const lines = getSqlErrorLines({ - message: - 'ERROR: 42501: permission denied for table users\n' + - 'HINT: To grant access to anon on a specific table:\n' + - ' GRANT SELECT ON TABLE public.users TO anon;', - }) - - expect(lines).toEqual([ - 'ERROR: 42501: permission denied for table users', - 'HINT: To grant access to anon on a specific table:', - ' GRANT SELECT ON TABLE public.users TO anon;', - ]) - }) - - it('returns empty array for a single-line message so callers render the fallback', () => { - const lines = getSqlErrorLines({ message: 'permission denied for table users' }) - expect(lines).toEqual([]) - }) - - it('returns empty array when both fields are missing', () => { - expect(getSqlErrorLines({})).toEqual([]) - }) - - it('returns empty array when message is an empty string', () => { - expect(getSqlErrorLines({ message: '' })).toEqual([]) - }) - - it('returns empty array when message only contains whitespace newlines', () => { - // Only empty segments after filtering — treated as single-line - expect(getSqlErrorLines({ message: '\n\n' })).toEqual([]) - }) - - it('prefers formattedError even when message is also multi-line', () => { - const lines = getSqlErrorLines({ - message: 'message line 1\nmessage line 2', - formattedError: 'formatted line 1\nformatted line 2', - }) - - expect(lines).toEqual(['formatted line 1', 'formatted line 2']) - }) - - it('falls through to message when formattedError is empty string', () => { - const lines = getSqlErrorLines({ - message: 'ERROR: line 1\nHINT: line 2', - formattedError: '', - }) - - expect(lines).toEqual(['ERROR: line 1', 'HINT: line 2']) - }) -}) diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabResults.utils.ts b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabResults.utils.ts deleted file mode 100644 index 583568ec02a50..0000000000000 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityTabResults.utils.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Pick which lines to render for a SQL editor error. - * - * pg-meta returns `formattedError` with multi-line ERROR/HINT/LINE output from Postgres. - * Historically only `message` was reliably populated end-to-end, which is why the UI also - * falls back to splitting `message` on newlines — e.g. the enhanced permission-denied HINT - * added by supabase/postgres#2084 arrives in the message body on some paths. - * - * Returns an empty array when the error is single-line (message only) — callers fall back to - * a plain "Error: {message}" rendering in that case. - */ -export function getSqlErrorLines(error: { message?: string; formattedError?: string }): string[] { - const formattedLines = (error.formattedError?.split('\n') ?? []).filter((x) => x.length > 0) - if (formattedLines.length > 0) return formattedLines - - const messageLines = (error.message?.split('\n') ?? []).filter((x) => x.length > 0) - return messageLines.length > 1 ? messageLines : [] -} diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/CellDetailPanel.tsx b/apps/studio/components/ui/DataGridResults/CellDetailPanel.tsx similarity index 99% rename from apps/studio/components/interfaces/SQLEditor/UtilityPanel/CellDetailPanel.tsx rename to apps/studio/components/ui/DataGridResults/CellDetailPanel.tsx index 1ef52e82351c7..46be38eb901b8 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/CellDetailPanel.tsx +++ b/apps/studio/components/ui/DataGridResults/CellDetailPanel.tsx @@ -8,7 +8,7 @@ import { TwoOptionToggle } from '@/components/ui/TwoOptionToggle' interface CellDetailPanelProps { column: string - value: any + value: unknown visible: boolean onClose: () => void } diff --git a/apps/studio/components/ui/DataGridResults/DataGridResults.utils.ts b/apps/studio/components/ui/DataGridResults/DataGridResults.utils.ts new file mode 100644 index 0000000000000..6aec73d2549a0 --- /dev/null +++ b/apps/studio/components/ui/DataGridResults/DataGridResults.utils.ts @@ -0,0 +1,40 @@ +export type ResultRow = Record + +const ESTIMATED_CHARACTER_WIDTH = 8.25 +export const RESULT_COLUMN_MIN_WIDTH = 100 +const MAX_COLUMN_WIDTH = 500 + +export function calculateResultColumnWidth(columnName: string, rows: readonly ResultRow[]) { + const maxContentLength = rows.reduce( + (maxLength, row) => Math.max(maxLength, (formatCellValue(row[columnName]) ?? '').length), + columnName.length + ) + + return Math.min( + Math.max(maxContentLength * ESTIMATED_CHARACTER_WIDTH, RESULT_COLUMN_MIN_WIDTH), + MAX_COLUMN_WIDTH + ) +} + +export function formatClipboardValue(value: unknown) { + if (value === null) return '' + if (typeof value == 'object' || Array.isArray(value)) { + return JSON.stringify(value) + } + return String(value) +} + +export function formatCellValue(value: unknown) { + if (value === null) return 'NULL' + if (typeof value === 'string') return value + return JSON.stringify(value) +} + +const LARGE_VALUE_CHAR_THRESHOLD = 60 + +export function isLargeValue(value: unknown) { + if (value === null || value === undefined) return false + if (typeof value === 'object') return true + const str = String(value) + return str.length > LARGE_VALUE_CHAR_THRESHOLD || str.includes('\n') +} diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/ResultCell.tsx b/apps/studio/components/ui/DataGridResults/ResultCell.tsx similarity index 95% rename from apps/studio/components/interfaces/SQLEditor/UtilityPanel/ResultCell.tsx rename to apps/studio/components/ui/DataGridResults/ResultCell.tsx index 6e89f2884d691..1fd909239fdfb 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/ResultCell.tsx +++ b/apps/studio/components/ui/DataGridResults/ResultCell.tsx @@ -1,7 +1,7 @@ import { Expand } from 'lucide-react' import { Button, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' -import { formatCellValue, isLargeValue } from './Results.utils' +import { formatCellValue, isLargeValue } from './DataGridResults.utils' interface ResultCellProps { column: string diff --git a/apps/studio/tests/components/SQLEditor/Results.test.tsx b/apps/studio/components/ui/DataGridResults/__tests__/DataGridResults.test.tsx similarity index 54% rename from apps/studio/tests/components/SQLEditor/Results.test.tsx rename to apps/studio/components/ui/DataGridResults/__tests__/DataGridResults.test.tsx index 9fb21a98dfe69..d089d53605059 100644 --- a/apps/studio/tests/components/SQLEditor/Results.test.tsx +++ b/apps/studio/components/ui/DataGridResults/__tests__/DataGridResults.test.tsx @@ -1,7 +1,10 @@ import { screen } from '@testing-library/react' +import { type ComponentProps } from 'react' +import { type CalculatedColumn } from 'react-data-grid' import { expect, test, vi } from 'vitest' -import { Results } from '@/components/interfaces/SQLEditor/UtilityPanel/Results' +import { type ResultRow } from '../DataGridResults.utils' +import { DataGridResults as Results } from '../index' import { customRender as render } from '@/tests/lib/custom-render' let contextMenuMountCount = 0 @@ -10,7 +13,7 @@ vi.mock('ui', async () => { const actual = await vi.importActual('ui') return { ...actual, - ContextMenu: (props: any) => { + ContextMenu: (props: ComponentProps) => { contextMenuMountCount++ return }, @@ -18,20 +21,40 @@ vi.mock('ui', async () => { }) vi.mock('react-data-grid', () => ({ - default: ({ columns, rows }: any) => ( + default: ({ + columns, + rows, + }: { + columns: CalculatedColumn[] + rows: readonly ResultRow[] + }) => (
- {columns.map((col: any, colIdx: number) => ( + {columns.map((col, colIdx) => (
- {col.renderHeaderCell ? col.renderHeaderCell({}) : col.name} + {col.renderHeaderCell + ? col.renderHeaderCell({ + column: col, + sortDirection: undefined, + priority: undefined, + tabIndex: -1, + }) + : col.name}
))}
- {rows.map((row: any, rowIdx: number) => ( + {rows.map((row, rowIdx) => (
- {columns.map((col: any, colIdx: number) => ( + {columns.map((col, colIdx) => (
- {col.renderCell?.({ row, rowIdx, isCellSelected: false })} + {col.renderCell?.({ + column: col, + row, + rowIdx, + isCellEditable: false, + tabIndex: -1, + onRowChange: () => {}, + })}
))}
diff --git a/apps/studio/components/ui/DataGridResults/__tests__/DataGridResults.utils.test.ts b/apps/studio/components/ui/DataGridResults/__tests__/DataGridResults.utils.test.ts new file mode 100644 index 0000000000000..ddbfd2a16b845 --- /dev/null +++ b/apps/studio/components/ui/DataGridResults/__tests__/DataGridResults.utils.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest' + +import { + calculateResultColumnWidth, + formatCellValue, + formatClipboardValue, + isLargeValue, +} from '../DataGridResults.utils' + +describe('Results.utils', () => { + describe('calculateResultColumnWidth', () => { + it('uses the minimum width when the column name and values are short', () => { + expect(calculateResultColumnWidth('id', [{ id: 1 }])).toBe(100) + }) + + it('accounts for a column name that is longer than its values', () => { + expect(calculateResultColumnWidth('source_campaign_id', [{ source_campaign_id: null }])).toBe( + 148.5 + ) + }) + + it('accounts for a value that is longer than the column name', () => { + expect(calculateResultColumnWidth('name', [{ name: 'a'.repeat(20) }])).toBe(165) + }) + + it('accounts for the formatted JSON representation of an object value', () => { + expect( + calculateResultColumnWidth('metadata', [{ metadata: { campaign: 'a'.repeat(20) } }]) + ).toBe(288.75) + }) + + it('accounts for the formatted JSON representation of an array value', () => { + expect(calculateResultColumnWidth('tags', [{ tags: ['a'.repeat(10), 'b'.repeat(10)] }])).toBe( + 222.75 + ) + }) + + it('caps the width when the column name exceeds the maximum', () => { + expect(calculateResultColumnWidth('a'.repeat(100), [])).toBe(500) + }) + + it('caps the width when a value exceeds the maximum', () => { + expect(calculateResultColumnWidth('value', [{ value: 'a'.repeat(100) }])).toBe(500) + }) + + it('uses the minimum width when there are no rows', () => { + expect(calculateResultColumnWidth('id', [])).toBe(100) + }) + }) + + describe('formatClipboardValue', () => { + it('returns empty string for null', () => { + expect(formatClipboardValue(null)).toBe('') + }) + + it('stringifies objects', () => { + expect(formatClipboardValue({ a: 1 })).toBe('{"a":1}') + }) + + it('stringifies arrays', () => { + expect(formatClipboardValue([1, 2])).toBe('[1,2]') + }) + + it('converts primitives to string', () => { + expect(formatClipboardValue('hello')).toBe('hello') + expect(formatClipboardValue(42)).toBe('42') + expect(formatClipboardValue(false)).toBe('false') + }) + }) + + describe('formatCellValue', () => { + it('returns NULL for null', () => { + expect(formatCellValue(null)).toBe('NULL') + }) + + it('returns strings as-is', () => { + expect(formatCellValue('hello')).toBe('hello') + }) + + it('stringifies objects', () => { + expect(formatCellValue({ a: 1 })).toBe('{"a":1}') + }) + + it('stringifies numbers', () => { + expect(formatCellValue(42)).toBe('42') + }) + + it('stringifies booleans', () => { + expect(formatCellValue(true)).toBe('true') + }) + }) + + describe('isLargeValue', () => { + it('returns false for null', () => { + expect(isLargeValue(null)).toBe(false) + }) + + it('returns false for undefined', () => { + expect(isLargeValue(undefined)).toBe(false) + }) + + it('returns false for an empty string', () => { + expect(isLargeValue('')).toBe(false) + }) + + it('returns false for a short string under the threshold', () => { + expect(isLargeValue('hello')).toBe(false) + }) + + it('returns false for a string at the 60-char boundary', () => { + expect(isLargeValue('a'.repeat(60))).toBe(false) + }) + + it('returns true for a string just over the 60-char threshold', () => { + expect(isLargeValue('a'.repeat(61))).toBe(true) + }) + + it('returns true for a short string containing a newline', () => { + expect(isLargeValue('hello\nworld')).toBe(true) + }) + + it('returns true for an object', () => { + expect(isLargeValue({ a: 1 })).toBe(true) + }) + + it('returns true for an array', () => { + expect(isLargeValue([1, 2, 3])).toBe(true) + }) + + it('returns false for a number', () => { + expect(isLargeValue(42)).toBe(false) + }) + + it('returns false for a boolean', () => { + expect(isLargeValue(true)).toBe(false) + }) + }) +}) diff --git a/apps/studio/tests/components/SQLEditor/ResultCell.test.tsx b/apps/studio/components/ui/DataGridResults/__tests__/ResultCell.test.tsx similarity index 96% rename from apps/studio/tests/components/SQLEditor/ResultCell.test.tsx rename to apps/studio/components/ui/DataGridResults/__tests__/ResultCell.test.tsx index 353c9b858775c..39031a5120939 100644 --- a/apps/studio/tests/components/SQLEditor/ResultCell.test.tsx +++ b/apps/studio/components/ui/DataGridResults/__tests__/ResultCell.test.tsx @@ -2,7 +2,7 @@ import { fireEvent, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { expect, test, vi } from 'vitest' -import { ResultCell } from '@/components/interfaces/SQLEditor/UtilityPanel/ResultCell' +import { ResultCell } from '../ResultCell' import { customRender as render } from '@/tests/lib/custom-render' const noop = () => {} diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.tsx b/apps/studio/components/ui/DataGridResults/index.tsx similarity index 89% rename from apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.tsx rename to apps/studio/components/ui/DataGridResults/index.tsx index ec9087f524efa..6aa117000f47e 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.tsx +++ b/apps/studio/components/ui/DataGridResults/index.tsx @@ -1,6 +1,6 @@ import { Copy, Expand } from 'lucide-react' import { useCallback, useMemo, useRef, useState } from 'react' -import DataGrid, { CalculatedColumn } from 'react-data-grid' +import DataGrid, { CalculatedColumn, RenderCellProps } from 'react-data-grid' import { ContextMenu, ContextMenuContent, @@ -10,20 +10,21 @@ import { } from 'ui' import { CellDetailPanel } from './CellDetailPanel' -import { ResultCell } from './ResultCell' import { calculateResultColumnWidth, formatClipboardValue, RESULT_COLUMN_MIN_WIDTH, -} from './Results.utils' + type ResultRow, +} from './DataGridResults.utils' +import { ResultCell } from './ResultCell' import { handleCellKeyDown } from '@/components/grid/SupabaseGrid.utils' -export const Results = ({ rows }: { rows: readonly any[] }) => { - const [expandedCell, setExpandedCell] = useState<{ column: string; value: any } | null>(null) - const contextMenuCellRef = useRef<{ column: string; value: any } | null>(null) +export const DataGridResults = ({ rows }: { rows: readonly ResultRow[] }) => { + const [expandedCell, setExpandedCell] = useState<{ column: string; value: unknown } | null>(null) + const contextMenuCellRef = useRef<{ column: string; value: unknown } | null>(null) const triggerRef = useRef(null) - const handleContextMenu = useCallback((e: React.MouseEvent, column: string, value: any) => { + const handleContextMenu = useCallback((e: React.MouseEvent, column: string, value: unknown) => { contextMenuCellRef.current = { column, value } if (triggerRef.current) { @@ -45,7 +46,7 @@ export const Results = ({ rows }: { rows: readonly any[] }) => { return
{name}
} - const columns: CalculatedColumn[] = useMemo( + const columns: CalculatedColumn[] = useMemo( () => Object.keys(rows?.[0] ?? []).map((key, idx) => { return { @@ -62,7 +63,7 @@ export const Results = ({ rows }: { rows: readonly any[] }) => { frozen: false, sortable: false, isLastFrozenColumn: false, - renderCell: ({ row }: { row: any }) => ( + renderCell: ({ row }: RenderCellProps) => ( { > {showResults && (
- +
)}
diff --git a/apps/studio/components/ui/QueryBlock/QueryBlock.tsx b/apps/studio/components/ui/QueryBlock/QueryBlock.tsx index 97907d4edb3ac..8fdbec57e03f1 100644 --- a/apps/studio/components/ui/QueryBlock/QueryBlock.tsx +++ b/apps/studio/components/ui/QueryBlock/QueryBlock.tsx @@ -9,6 +9,7 @@ import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { ButtonTooltip } from '../ButtonTooltip' import { CHART_COLORS } from '../Charts/Charts.constants' import { PortalChartTooltip } from '../Charts/PortalChartTooltip' +import { DataGridResults } from '../DataGridResults' import { SqlWarningAdmonition } from '../SqlWarningAdmonition' import { BlockViewConfiguration } from './BlockViewConfiguration' import { EditQueryButton } from './EditQueryButton' @@ -21,7 +22,6 @@ import { } from './QueryBlock.utils' import { ReportBlockContainer } from '@/components/interfaces/Reports/ReportBlock/ReportBlockContainer' import { ChartConfig } from '@/components/interfaces/SQLEditor/UtilityPanel/ChartConfig' -import { Results } from '@/components/interfaces/SQLEditor/UtilityPanel/Results' export const DEFAULT_CHART_CONFIG: ChartConfig = { type: 'bar', @@ -391,7 +391,7 @@ export const QueryBlock = ({ 'flex flex-col flex-1 w-full overflow-auto overscroll-contain relative max-h-64' )} > - + {autoLimit && (

Limited to only 100 rows diff --git a/apps/studio/data/sql/__tests__/utils.test.ts b/apps/studio/data/sql/__tests__/utils.test.ts new file mode 100644 index 0000000000000..2a7e0714cf83c --- /dev/null +++ b/apps/studio/data/sql/__tests__/utils.test.ts @@ -0,0 +1,241 @@ +import { safeSql } from '@supabase/pg-meta' +import { describe, expect, it, test } from 'vitest' + +import { applyAutoLimit, getSqlErrorLines, trimTrailingSemicolons } from '../utils' + +describe('getSqlErrorLines', () => { + it('returns formattedError lines when present', () => { + const lines = getSqlErrorLines({ + message: 'permission denied for table users', + formattedError: + 'ERROR: 42501: permission denied for table users\n' + + 'HINT: To grant access to anon on a specific table:\n' + + ' GRANT SELECT ON TABLE public.users TO anon;', + }) + + expect(lines).toEqual([ + 'ERROR: 42501: permission denied for table users', + 'HINT: To grant access to anon on a specific table:', + ' GRANT SELECT ON TABLE public.users TO anon;', + ]) + }) + + it('strips empty lines from formattedError', () => { + const lines = getSqlErrorLines({ + formattedError: 'ERROR: boom\n\nHINT: retry\n', + }) + + expect(lines).toEqual(['ERROR: boom', 'HINT: retry']) + }) + + it('falls back to message lines when formattedError is missing and message is multi-line', () => { + const lines = getSqlErrorLines({ + message: + 'ERROR: 42501: permission denied for table users\n' + + 'HINT: To grant access to anon on a specific table:\n' + + ' GRANT SELECT ON TABLE public.users TO anon;', + }) + + expect(lines).toEqual([ + 'ERROR: 42501: permission denied for table users', + 'HINT: To grant access to anon on a specific table:', + ' GRANT SELECT ON TABLE public.users TO anon;', + ]) + }) + + it('returns empty array for a single-line message so callers render the fallback', () => { + const lines = getSqlErrorLines({ message: 'permission denied for table users' }) + expect(lines).toEqual([]) + }) + + it('returns empty array when both fields are missing', () => { + expect(getSqlErrorLines({})).toEqual([]) + }) + + it('returns empty array when message is an empty string', () => { + expect(getSqlErrorLines({ message: '' })).toEqual([]) + }) + + it('returns empty array when message only contains whitespace newlines', () => { + // Only empty segments after filtering — treated as single-line + expect(getSqlErrorLines({ message: '\n\n' })).toEqual([]) + }) + + it('prefers formattedError even when message is also multi-line', () => { + const lines = getSqlErrorLines({ + message: 'message line 1\nmessage line 2', + formattedError: 'formatted line 1\nformatted line 2', + }) + + expect(lines).toEqual(['formatted line 1', 'formatted line 2']) + }) + + it('falls through to message when formattedError is empty string', () => { + const lines = getSqlErrorLines({ + message: 'ERROR: line 1\nHINT: line 2', + formattedError: '', + }) + + expect(lines).toEqual(['ERROR: line 1', 'HINT: line 2']) + }) +}) + +describe('trimTrailingSemicolons', () => { + test('removes a single trailing semicolon', () => { + const sql = safeSql`select * from countries;` + expect(trimTrailingSemicolons(sql)).toBe('select * from countries') + }) + test('removes multiple trailing semicolons', () => { + const sql = safeSql`select * from countries;;;;;;;` + expect(trimTrailingSemicolons(sql)).toBe('select * from countries') + }) + test('leaves a fragment with no trailing semicolon unchanged', () => { + const sql = safeSql`select * from countries` + expect(trimTrailingSemicolons(sql)).toBe('select * from countries') + }) + test('does not touch semicolons that are not trailing', () => { + const sql = safeSql`select 1; select 2` + expect(trimTrailingSemicolons(sql)).toBe('select 1; select 2') + }) +}) + +describe('applyAutoLimit', () => { + test('Should return false if limit passed is <= 0', () => { + const sql = safeSql`select * from countries;` + const limit = -1 + const { appendAutoLimit } = applyAutoLimit(sql, limit) + expect(appendAutoLimit).toBe(false) + }) + test('Should return true if limit passed is > 0', () => { + const sql = safeSql`select * from countries;` + const limit = 100 + const { appendAutoLimit } = applyAutoLimit(sql, limit) + expect(appendAutoLimit).toBe(true) + }) + test('Should return false if query already has a limit', () => { + const sql = safeSql`select * from countries limit 10;` + const limit = 100 + const { appendAutoLimit } = applyAutoLimit(sql, limit) + expect(appendAutoLimit).toBe(false) + }) + test('Should return false if query already has a limit (check for case-insensitiveness)', () => { + const sql = safeSql`SELECT * FROM countries LIMIT 10;` + const limit = 100 + const { appendAutoLimit } = applyAutoLimit(sql, limit) + expect(appendAutoLimit).toBe(false) + }) + test('Should return false if query already has a limit with whitespace before the semi colon', () => { + const sql = safeSql`select * from countries limit 10 ;` + const limit = 100 + const { appendAutoLimit } = applyAutoLimit(sql, limit) + expect(appendAutoLimit).toBe(false) + }) + test('Should return false if query already has a limit and offset', () => { + const sql = safeSql`select * from countries limit 10 offset 0;` + const limit = 100 + const { appendAutoLimit } = applyAutoLimit(sql, limit) + expect(appendAutoLimit).toBe(false) + }) + test('Should return false if query already has a limit and offset with whitespace before the semi colon', () => { + const sql = safeSql`select * from countries limit 10 offset 0 ;` + const limit = 100 + const { appendAutoLimit } = applyAutoLimit(sql, limit) + expect(appendAutoLimit).toBe(false) + }) + test('Should return false if query already has a limit and offset (flip order of limit and offset)', () => { + const sql = safeSql`select * from countries offset 0 limit 1;` + const limit = 100 + const { appendAutoLimit } = applyAutoLimit(sql, limit) + expect(appendAutoLimit).toBe(false) + }) + test('Should return false if query already has a limit, even if no value provided for limit', () => { + const sql = safeSql`select * from countries limit` + const limit = 100 + const { appendAutoLimit } = applyAutoLimit(sql, limit) + expect(appendAutoLimit).toBe(false) + }) + test('Should return false if query uses `FETCH FIRST` instead of limit ', () => { + const sql = safeSql`select * from countries FETCH FIRST 5 rows only` + const limit = 100 + const { appendAutoLimit } = applyAutoLimit(sql, limit) + expect(appendAutoLimit).toBe(false) + }) + test('Should return false if query uses `fetch first` instead of limit ', () => { + const sql = safeSql`select * from countries fetch first 5 rows only` + const limit = 100 + const { appendAutoLimit } = applyAutoLimit(sql, limit) + expect(appendAutoLimit).toBe(false) + }) + test('Should return false if query uses `fetch first` (with random spaces) instead of limit ', () => { + const sql = safeSql`select * from countries FETCH FIRST 5 rows only` + const limit = 100 + const { appendAutoLimit } = applyAutoLimit(sql, limit) + expect(appendAutoLimit).toBe(false) + }) + test('Should return false if query is not a select statement', () => { + const sql = safeSql`create table test (id int8 primary key, name varchar);` + const limit = 100 + const { appendAutoLimit } = applyAutoLimit(sql, limit) + expect(appendAutoLimit).toBe(false) + }) + test('Should return false if there are multiple queries I', () => { + const sql1 = safeSql`select * from countries; +select * from cities;` + const limit = 100 + const { appendAutoLimit } = applyAutoLimit(sql1, limit) + expect(appendAutoLimit).toBe(false) + }) + test('Should return false if there are multiple queries II', () => { + const sql1 = safeSql`select * from countries; +select * from cities` + const limit = 100 + const { appendAutoLimit } = applyAutoLimit(sql1, limit) + expect(appendAutoLimit).toBe(false) + }) + // [Joshen] Opting to just avoid appending in this case to prevent making the logic overly complex atm + test('Should return false if query has with a comment I', () => { + const sql = safeSql`-- This is a comment +select * from cities` + const limit = 100 + const { appendAutoLimit } = applyAutoLimit(sql, limit) + expect(appendAutoLimit).toBe(false) + }) + test('Should return false if query has with a comment II', () => { + const sql = safeSql`select * from cities +-- This is a comment` + const limit = 100 + const { appendAutoLimit } = applyAutoLimit(sql, limit) + expect(appendAutoLimit).toBe(false) + }) + + // [Joshen] These will just need to test the cases when appendAutoLimit returns true then + test('Should add the limit param properly if query ends without a semi colon', () => { + const sql = safeSql`select * from countries` + const limit = 100 + const { sql: formattedSql } = applyAutoLimit(sql, limit) + expect(formattedSql).toBe('select * from countries limit 100;') + }) + test('Should add the limit param properly if query ends with a semi colon', () => { + const sql = safeSql`select * from countries;` + const limit = 100 + const { sql: formattedSql } = applyAutoLimit(sql, limit) + expect(formattedSql).toBe('select * from countries limit 100;') + }) + test('Should add the limit param properly if query ends with multiple semi colon', () => { + const sql = safeSql`select * from countries;;;;;;;` + const limit = 100 + const { sql: formattedSql } = applyAutoLimit(sql, limit) + expect(formattedSql).toBe('select * from countries limit 100;') + }) + test('Should not append a limit if query already has one with whitespace before the semi colon', () => { + const sql = safeSql`select * from countries limit 10 ;` + const limit = 100 + const { sql: formattedSql } = applyAutoLimit(sql, limit) + expect(formattedSql).toBe('select * from countries limit 10 ;') + }) + test('returns the SafeSqlFragment result unchanged when no limit is appended', () => { + const sql = safeSql`select * from countries limit 10;` + const { sql: formattedSql } = applyAutoLimit(sql, 100) + expect(formattedSql).toBe(sql) + }) +}) diff --git a/apps/studio/data/sql/utils.ts b/apps/studio/data/sql/utils.ts new file mode 100644 index 0000000000000..1f23256fd1ce9 --- /dev/null +++ b/apps/studio/data/sql/utils.ts @@ -0,0 +1,80 @@ +import { literal, safeSql, type SafeSqlFragment } from '@supabase/pg-meta' + +/** + * Pick which lines to render for a SQL editor error. + * + * pg-meta returns `formattedError` with multi-line ERROR/HINT/LINE output from Postgres. + * Historically only `message` was reliably populated end-to-end, which is why the UI also + * falls back to splitting `message` on newlines — e.g. the enhanced permission-denied HINT + * added by supabase/postgres#2084 arrives in the message body on some paths. + * + * Returns an empty array when the error is single-line (message only) — callers fall back to + * a plain "Error: {message}" rendering in that case. + */ +export function getSqlErrorLines(error: { message?: string; formattedError?: string }): string[] { + const formattedLines = (error.formattedError?.split('\n') ?? []).filter((x) => x.length > 0) + if (formattedLines.length > 0) return formattedLines + + const messageLines = (error.message?.split('\n') ?? []).filter((x) => x.length > 0) + return messageLines.length > 1 ? messageLines : [] +} + +/** + * Removes trailing `;` characters from a safe SQL fragment. Only ever removes + * existing terminators — never adds text — so the result is exactly as safe + * as the input; the brand carries over intentionally. This is the one place + * in the file allowed to reassert `SafeSqlFragment` on a derived string — + * every other function composes new fragments through `safeSql`/`literal`. + */ +export function trimTrailingSemicolons(sql: SafeSqlFragment): SafeSqlFragment { + return sql.replace(/;+\s*$/, '') as SafeSqlFragment +} + +// [Joshen] Just FYI as well the checks here on whether to append limit is quite restricted +// This is to prevent dashboard from accidentally appending limit to the end of a query +// thats not supposed to have any, since there's too many cases to cover. +// We can however look into making this logic better in the future +// i.e It's harder to append the limit param, than just leaving the query as it is +// Otherwise we'd need a full on parser to do this properly +// +// Only accepts `SafeSqlFragment`: this decides whether to build (and builds) +// a new SQL fragment that gets executed, so every caller — including ones +// that only want the `appendAutoLimit` flag for a display hint — must already +// hold safe SQL. Composes the ` limit N;` suffix through `safeSql`/`literal` +// rather than gluing raw template-literal text onto the fragment and casting +// the result, so the only new content this function ever stamps safe is an +// internally-generated integer literal, never arbitrary concatenated text. +export function applyAutoLimit( + sql: SafeSqlFragment, + limit: number = 0 +): { sql: SafeSqlFragment; appendAutoLimit: boolean } { + // Remove lines and whitespaces to use for checking + const cleanedSql = sql.trim().replaceAll('\n', ' ').replaceAll(/\s+/g, ' ') + + // Check how many queries + const regMatch = cleanedSql.matchAll(/[a-zA-Z]*[0-9]*[;]+/g) + const queries = new Array(...regMatch) + const indexSemiColon = cleanedSql.lastIndexOf(';') + const hasComments = cleanedSql.includes('--') + const hasMultipleQueries = + queries.length > 1 || (indexSemiColon > 0 && indexSemiColon !== cleanedSql.length - 1) + + // Check if need to auto limit rows + const appendAutoLimit = + limit > 0 && + !hasComments && + !hasMultipleQueries && + cleanedSql.toLowerCase().startsWith('select') && + !cleanedSql.toLowerCase().match(/fetch\s+first/i) && + !cleanedSql.match(/limit$/i) && + !cleanedSql.match(/limit;$/i) && + !cleanedSql.match(/limit [0-9]* offset [0-9]*\s*[;]?$/i) && + !cleanedSql.match(/limit [0-9]*\s*[;]?$/i) + + if (!appendAutoLimit) return { sql, appendAutoLimit: false } + + const core = cleanedSql.endsWith(';') ? trimTrailingSemicolons(sql) : sql + const suffixed = safeSql`${core} limit ${literal(limit)};` + + return { sql: suffixed, appendAutoLimit: true } +} From 2c8cc7ec8ac855aec6a7de94eb8a5fc145613623 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:21:24 +1000 Subject: [PATCH 02/11] feat(studio): relocate read replica modules under Infrastructure (#49043) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What kind of change does this PR introduce? Feature. Stack 1 of 5 for [PIPE-1007](https://linear.app/supabase/issue/PIPE-1007/move-read-replicas-out-of-replication-into-infrastructure). ## What is the current behavior? Read replica UI lives under Database / Replication. ## What is the new behavior? Moves replica form, row, and modals into Settings/Infrastructure and extracts `REPLICA_STATUS` plus path helpers. URLs and user-facing behaviour are unchanged. ## Additional context Keep `infrastructure:read_replicas` off in prod until the full stack lands. Stack: this PR → [#49044](https://github.com/supabase/supabase/pull/49044) → [#49045](https://github.com/supabase/supabase/pull/49045) → [#49046](https://github.com/supabase/supabase/pull/49046) → [#48921](https://github.com/supabase/supabase/pull/48921) ## To test Open [Database / Replication](https://studio-staging-git-danny-pipe-1007-01-relocate-701014-supabase.vercel.app/dashboard/project/_/database/replication?destinationType=Read+Replica). Confirm add replica still works as today. No new Infrastructure section yet. ## Summary by CodeRabbit - **New Features** - Added navigation for viewing a specific read replica and starting the add-replica setup flow. - Improved read-replica setup validation, including unsupported regions and invalid PostgreSQL versions. - **Bug Fixes** - Corrected default region and success messaging behavior. - Prevented incomplete pricing labels and clarified eligibility warnings. - Improved display handling for localized pricing values. - **Tests** - Added coverage for read-replica navigation paths and fallback behavior. --- .../DestinationPanel/DestinationPanel.tsx | 2 +- .../Database/Replication/Destinations.tsx | 2 +- .../Replication/Replication.constants.ts | 13 ++------ .../Replication/ReplicationDiagram/Nodes.tsx | 2 +- .../Infrastructure.utils.test.ts | 32 ++++++++++++++++++- .../Infrastructure/Infrastructure.utils.ts | 6 ++++ .../InfrastructureConfiguration/Edge.tsx | 2 +- .../InstanceConfiguration.tsx | 2 +- .../InstanceNode.tsx | 2 +- .../InfrastructureConfiguration/MapView.tsx | 2 +- .../DropReplicaConfirmationModal.tsx | 2 +- .../ReadReplicas/ReadReplicaDetails.tsx | 2 +- .../ReadReplicaEligibilityWarnings.tsx | 2 ++ .../ReadReplicaPricingDialog.tsx | 4 ++- .../ReadReplicas}/ReadReplicaForm/index.tsx | 7 ++-- .../useCheckEligibilityDeployReplica.ts | 12 +++---- .../ReadReplicaForm/useGetReplicaCost.ts | 12 +++++-- .../ReadReplicas/ReadReplicaRow.tsx | 2 +- .../ReadReplicas/ReadReplicas.constants.ts | 11 +++++++ .../ReadReplicas/ReadReplicas.utils.ts | 2 +- .../RestartReplicaConfirmationModal.tsx | 2 +- .../replication/replica/[replicaId].tsx | 10 +++--- .../ReadReplicaEligibilityWarnings.test.tsx | 6 ++-- 23 files changed, 93 insertions(+), 46 deletions(-) rename apps/studio/components/interfaces/{Database/Replication => Settings/Infrastructure}/ReadReplicas/DropReplicaConfirmationModal.tsx (98%) rename apps/studio/components/interfaces/{Database/Replication => Settings/Infrastructure}/ReadReplicas/ReadReplicaDetails.tsx (99%) rename apps/studio/components/interfaces/{Database/Replication/DestinationPanel => Settings/Infrastructure/ReadReplicas}/ReadReplicaForm/ReadReplicaEligibilityWarnings.tsx (99%) rename apps/studio/components/interfaces/{Database/Replication/DestinationPanel => Settings/Infrastructure/ReadReplicas}/ReadReplicaForm/ReadReplicaPricingDialog.tsx (97%) rename apps/studio/components/interfaces/{Database/Replication/DestinationPanel => Settings/Infrastructure/ReadReplicas}/ReadReplicaForm/index.tsx (97%) rename apps/studio/components/interfaces/{Database/Replication/DestinationPanel => Settings/Infrastructure/ReadReplicas}/ReadReplicaForm/useCheckEligibilityDeployReplica.ts (88%) rename apps/studio/components/interfaces/{Database/Replication/DestinationPanel => Settings/Infrastructure/ReadReplicas}/ReadReplicaForm/useGetReplicaCost.ts (85%) rename apps/studio/components/interfaces/{Database/Replication => Settings/Infrastructure}/ReadReplicas/ReadReplicaRow.tsx (98%) create mode 100644 apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicas.constants.ts rename apps/studio/components/interfaces/{Database/Replication => Settings/Infrastructure}/ReadReplicas/ReadReplicas.utils.ts (96%) rename apps/studio/components/interfaces/{Database/Replication => Settings/Infrastructure}/ReadReplicas/RestartReplicaConfirmationModal.tsx (98%) rename apps/studio/tests/components/{Database/Replication => Settings/Infrastructure}/ReadReplicaEligibilityWarnings.test.tsx (91%) diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.tsx b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.tsx index dcc00c7a296db..e58c8df34bc12 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.tsx +++ b/apps/studio/components/interfaces/Database/Replication/DestinationPanel/DestinationPanel.tsx @@ -24,7 +24,7 @@ import { useIsETLPrivateAlpha } from '../useIsETLPrivateAlpha' import { DestinationForm } from './DestinationForm' import { DestinationType } from './DestinationPanel.types' import { DestinationTypeSelection } from './DestinationTypeSelection' -import { ReadReplicaForm } from './ReadReplicaForm' +import { ReadReplicaForm } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicaForm' import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' import { DocsButton } from '@/components/ui/DocsButton' import { useReplicationDestinationsQuery } from '@/data/replication/destinations-query' diff --git a/apps/studio/components/interfaces/Database/Replication/Destinations.tsx b/apps/studio/components/interfaces/Database/Replication/Destinations.tsx index ea4482fff97fc..82ea355177425 100644 --- a/apps/studio/components/interfaces/Database/Replication/Destinations.tsx +++ b/apps/studio/components/interfaces/Database/Replication/Destinations.tsx @@ -30,7 +30,6 @@ import { DestinationType } from './DestinationPanel/DestinationPanel.types' import { DestinationRow } from './DestinationRow' import { DisablePipelinesDialog } from './DisablePipelinesDialog' import { EnablePipelinesModal } from './EnablePipelinesCallout' -import { ReadReplicaRow } from './ReadReplicas/ReadReplicaRow' import { REPLICA_STATUS } from './Replication.constants' import { useIsETLBigQueryPrivateAlpha, @@ -39,6 +38,7 @@ import { useIsETLIcebergPrivateAlpha, useIsETLSnowflakePrivateAlpha, } from './useIsETLPrivateAlpha' +import { ReadReplicaRow } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicaRow' import { AlertError } from '@/components/ui/AlertError' import { DocsButton } from '@/components/ui/DocsButton' import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip' diff --git a/apps/studio/components/interfaces/Database/Replication/Replication.constants.ts b/apps/studio/components/interfaces/Database/Replication/Replication.constants.ts index ddefacbe243d3..2663b675fa33a 100644 --- a/apps/studio/components/interfaces/Database/Replication/Replication.constants.ts +++ b/apps/studio/components/interfaces/Database/Replication/Replication.constants.ts @@ -1,7 +1,3 @@ -import { components } from 'api-types' - -import { PROJECT_STATUS } from '@/lib/constants' - export const STATUS_REFRESH_FREQUENCY_MS: number = 10000 // 10 seconds export enum PipelineStatusName { @@ -13,10 +9,5 @@ export enum PipelineStatusName { UNKNOWN = 'unknown', } -export const REPLICA_STATUS: { - [key: string]: components['schemas']['DatabaseStatusResponse']['status'] -} = { - ...PROJECT_STATUS, - INIT_READ_REPLICA: 'INIT_READ_REPLICA', - INIT_READ_REPLICA_FAILED: 'INIT_READ_REPLICA_FAILED', -} +/** @deprecated Import from Settings/Infrastructure/ReadReplicas/ReadReplicas.constants */ +export { REPLICA_STATUS } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicas.constants' diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/Nodes.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/Nodes.tsx index 7e3f1e2e371d9..b4d1afe51a395 100644 --- a/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/Nodes.tsx +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/Nodes.tsx @@ -6,9 +6,9 @@ import { cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' import { DestinationIcon } from '../DestinationIcon' import { getStatusName } from '../Pipeline.utils' -import { getStatusLabel } from '../ReadReplicas/ReadReplicas.utils' import { STATUS_REFRESH_FREQUENCY_MS } from '../Replication.constants' import { getReplicationDestinationType } from './Nodes.utils' +import { getStatusLabel } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicas.utils' import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query' import { formatDatabaseID } from '@/data/read-replicas/replicas.utils' import { useReplicationDestinationsQuery } from '@/data/replication/destinations-query' diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/Infrastructure.utils.test.ts b/apps/studio/components/interfaces/Settings/Infrastructure/Infrastructure.utils.test.ts index 21cea9fd3153e..805bda1931801 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/Infrastructure.utils.test.ts +++ b/apps/studio/components/interfaces/Settings/Infrastructure/Infrastructure.utils.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest' -import { getInfrastructurePath } from './Infrastructure.utils' +import { + getAddReadReplicaPath, + getInfrastructurePath, + getReadReplicaPath, +} from './Infrastructure.utils' describe('getInfrastructurePath', () => { it('builds the path for a project ref', () => { @@ -13,3 +17,29 @@ describe('getInfrastructurePath', () => { expect(getInfrastructurePath()).toBe('/project/_/settings/infrastructure') }) }) + +describe('getReadReplicaPath', () => { + it('builds the replica detail path', () => { + expect(getReadReplicaPath('project-ref', 'replica-1')).toBe( + '/project/project-ref/settings/infrastructure/replica/replica-1' + ) + }) + + it('falls back to the default project placeholder', () => { + expect(getReadReplicaPath(undefined, 'replica-1')).toBe( + '/project/_/settings/infrastructure/replica/replica-1' + ) + }) +}) + +describe('getAddReadReplicaPath', () => { + it('opens the add-replica sheet on infrastructure', () => { + expect(getAddReadReplicaPath('project-ref')).toBe( + '/project/project-ref/settings/infrastructure?addReplica=true' + ) + }) + + it('falls back to the default project placeholder', () => { + expect(getAddReadReplicaPath()).toBe('/project/_/settings/infrastructure?addReplica=true') + }) +}) diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/Infrastructure.utils.ts b/apps/studio/components/interfaces/Settings/Infrastructure/Infrastructure.utils.ts index 1d816d3151c42..fd2a56000dda7 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/Infrastructure.utils.ts +++ b/apps/studio/components/interfaces/Settings/Infrastructure/Infrastructure.utils.ts @@ -1,2 +1,8 @@ export const getInfrastructurePath = (projectRef?: string) => `/project/${projectRef ?? '_'}/settings/infrastructure` + +export const getReadReplicaPath = (projectRef: string | undefined, replicaId: string) => + `/project/${projectRef ?? '_'}/settings/infrastructure/replica/${replicaId}` + +export const getAddReadReplicaPath = (projectRef?: string) => + `/project/${projectRef ?? '_'}/settings/infrastructure?addReplica=true` diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/Edge.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/Edge.tsx index e260871c7638a..993f2cac35db5 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/Edge.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/Edge.tsx @@ -4,7 +4,7 @@ import { Loader2 } from 'lucide-react' import { Tooltip, TooltipContent, TooltipTrigger } from 'ui' import { EdgeData } from './InstanceConfiguration.constants' -import { REPLICA_STATUS } from '@/components/interfaces/Database/Replication/Replication.constants' +import { REPLICA_STATUS } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicas.constants' import { useReplicationLagQuery } from '@/data/read-replicas/replica-lag-query' import { formatDatabaseID } from '@/data/read-replicas/replicas.utils' diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx index 5123d172de06e..c49a7416010bc 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx @@ -21,7 +21,7 @@ import { SmoothstepEdge } from './Edge' import { addRegionNodes, generateNodes, getDagreGraphLayout } from './InstanceConfiguration.utils' import { LoadBalancerNode, PrimaryNode, RegionNode, ReplicaNode } from './InstanceNode' import MapView from './MapView' -import { REPLICA_STATUS } from '@/components/interfaces/Database/Replication/Replication.constants' +import { REPLICA_STATUS } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicas.constants' import { AlertError } from '@/components/ui/AlertError' import { useLoadBalancersQuery } from '@/data/read-replicas/load-balancers-query' import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query' diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceNode.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceNode.tsx index 725260ff620b7..89fec06b3acdd 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceNode.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceNode.tsx @@ -33,7 +33,7 @@ import { } from './InstanceConfiguration.constants' import { formatSeconds } from './InstanceConfiguration.utils' import { metricColor } from './InstanceNode.utils' -import { REPLICA_STATUS } from '@/components/interfaces/Database/Replication/Replication.constants' +import { REPLICA_STATUS } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicas.constants' import { SparkBar } from '@/components/ui/SparkBar' import { DatabaseInitEstimations, diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/MapView.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/MapView.tsx index b551b4a707da5..7894593313db4 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/MapView.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/MapView.tsx @@ -27,7 +27,7 @@ import { TimestampInfo } from 'ui-patterns/TimestampInfo' import { AVAILABLE_REPLICA_REGIONS } from './InstanceConfiguration.constants' import GeographyData from './MapData.json' -import { REPLICA_STATUS } from '@/components/interfaces/Database/Replication/Replication.constants' +import { REPLICA_STATUS } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicas.constants' import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query' import { formatDatabaseID } from '@/data/read-replicas/replicas.utils' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' diff --git a/apps/studio/components/interfaces/Database/Replication/ReadReplicas/DropReplicaConfirmationModal.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/DropReplicaConfirmationModal.tsx similarity index 98% rename from apps/studio/components/interfaces/Database/Replication/ReadReplicas/DropReplicaConfirmationModal.tsx rename to apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/DropReplicaConfirmationModal.tsx index 40be7d2e1f60b..91f7c2fb97fea 100644 --- a/apps/studio/components/interfaces/Database/Replication/ReadReplicas/DropReplicaConfirmationModal.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/DropReplicaConfirmationModal.tsx @@ -3,7 +3,7 @@ import { useParams } from 'common' import { toast } from 'sonner' import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal' -import { REPLICA_STATUS } from '../Replication.constants' +import { REPLICA_STATUS } from './ReadReplicas.constants' import { InlineLink } from '@/components/ui/InlineLink' import { replicaKeys } from '@/data/read-replicas/keys' import { useReadReplicaRemoveMutation } from '@/data/read-replicas/replica-remove-mutation' diff --git a/apps/studio/components/interfaces/Database/Replication/ReadReplicas/ReadReplicaDetails.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicaDetails.tsx similarity index 99% rename from apps/studio/components/interfaces/Database/Replication/ReadReplicas/ReadReplicaDetails.tsx rename to apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicaDetails.tsx index 15d9a3916ae2a..0890b12176224 100644 --- a/apps/studio/components/interfaces/Database/Replication/ReadReplicas/ReadReplicaDetails.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicaDetails.tsx @@ -17,7 +17,7 @@ import { Input } from 'ui-patterns/DataInputs/Input' import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' -import { REPLICA_STATUS } from '../Replication.constants' +import { REPLICA_STATUS } from './ReadReplicas.constants' import { REPORT_DATERANGE_HELPER_LABELS } from '@/components/interfaces/Reports/Reports.constants' import { ScaffoldContainer, ScaffoldSection } from '@/components/layouts/Scaffold' import { useInfraMonitoringAttributesQuery } from '@/data/analytics/infra-monitoring-query' diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/ReadReplicaForm/ReadReplicaEligibilityWarnings.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicaForm/ReadReplicaEligibilityWarnings.tsx similarity index 99% rename from apps/studio/components/interfaces/Database/Replication/DestinationPanel/ReadReplicaForm/ReadReplicaEligibilityWarnings.tsx rename to apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicaForm/ReadReplicaEligibilityWarnings.tsx index f029406a6027a..e64bda607cfea 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/ReadReplicaForm/ReadReplicaEligibilityWarnings.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicaForm/ReadReplicaEligibilityWarnings.tsx @@ -244,4 +244,6 @@ export const ReadReplicaEligibilityWarnings = () => { ) } + + return null } diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/ReadReplicaForm/ReadReplicaPricingDialog.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicaForm/ReadReplicaPricingDialog.tsx similarity index 97% rename from apps/studio/components/interfaces/Database/Replication/DestinationPanel/ReadReplicaForm/ReadReplicaPricingDialog.tsx rename to apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicaForm/ReadReplicaPricingDialog.tsx index ca4ed2aa46e20..bfc10a6f65677 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/ReadReplicaForm/ReadReplicaPricingDialog.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicaForm/ReadReplicaPricingDialog.tsx @@ -99,7 +99,9 @@ export const ReadReplicaPricingDialog = () => { Throughput {throughput.label} - {throughput.cost} + + {throughput.cost} + )} diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/ReadReplicaForm/index.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicaForm/index.tsx similarity index 97% rename from apps/studio/components/interfaces/Database/Replication/DestinationPanel/ReadReplicaForm/index.tsx rename to apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicaForm/index.tsx index 0d43130e4cef3..394560a46211e 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/ReadReplicaForm/index.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicaForm/index.tsx @@ -43,7 +43,7 @@ export const ReadReplicaForm = ({ const [defaultRegion] = Object.entries(AWS_REGIONS).find( ([_, name]) => name === AWS_REGIONS_DEFAULT - ) ?? ['ap-southeast-1'] + ) ?? ['SOUTHEAST_ASIA'] const { can: canDeployReplica } = useCheckEligibilityDeployReplica() const [selectedRegion, setSelectedRegion] = useState(defaultRegion) @@ -61,7 +61,7 @@ export const ReadReplicaForm = ({ const { mutate: setUpReplica, isPending: isSettingUp } = useReadReplicaSetUpMutation({ onSuccess: () => { const region = AVAILABLE_REPLICA_REGIONS.find((r) => r.key === selectedRegion)?.name - toast.success(`Spinning up new replica in ${region ?? ' Unknown'}...`) + toast.success(`Spinning up new replica in ${region ?? 'Unknown'}...`) onSuccess?.() onClose() }, @@ -75,8 +75,9 @@ export const ReadReplicaForm = ({ : AVAILABLE_REPLICA_REGIONS const onSubmit = async () => { - const regionKey = AWS_REGIONS[selectedRegion as AWS_REGIONS_KEYS].code if (!projectRef) return console.error('Project is required') + + const regionKey = AWS_REGIONS[selectedRegion as AWS_REGIONS_KEYS]?.code if (!regionKey) return toast.error('Unable to deploy replica: Unsupported region selected') const primary = data?.find((db) => db.identifier === projectRef) diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/ReadReplicaForm/useCheckEligibilityDeployReplica.ts b/apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicaForm/useCheckEligibilityDeployReplica.ts similarity index 88% rename from apps/studio/components/interfaces/Database/Replication/DestinationPanel/ReadReplicaForm/useCheckEligibilityDeployReplica.ts rename to apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicaForm/useCheckEligibilityDeployReplica.ts index 42bce7874b556..6e8e7311314b6 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/ReadReplicaForm/useCheckEligibilityDeployReplica.ts +++ b/apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicaForm/useCheckEligibilityDeployReplica.ts @@ -1,5 +1,4 @@ import { useParams } from 'common' -import { useMemo } from 'react' import { useOverdueInvoicesQuery } from '@/data/invoices/invoices-overdue-query' import { @@ -20,10 +19,7 @@ export const useCheckEligibilityDeployReplica = () => { const { hasAccess: hasReadReplicaAccess } = useCheckEntitlements('instances.read_replicas') const isAWSProvider = project?.cloud_provider === 'AWS' const isWalgEnabled = project?.is_physical_backups_enabled - const isNotOnHigherPlan = useMemo( - () => !['team', 'enterprise', 'platform'].includes(org?.plan.id ?? ''), - [org] - ) + const isNotOnHigherPlan = !['team', 'enterprise', 'platform'].includes(org?.plan.id ?? '') const isProWithSpendCapEnabled = org?.plan.id === 'pro' && !org.usage_billing_enabled const { data: allOverdueInvoices } = useOverdueInvoicesQuery({ @@ -48,12 +44,14 @@ export const useCheckEligibilityDeployReplica = () => { const isReachedMaxReplicas = (databases ?? []).filter((db) => db.identifier !== projectRef).length >= maxNumberOfReplicas - const currentPgVersion = Number( + const parsedPgVersion = Number( (project?.dbVersion ?? '').split('supabase-postgres-')[1]?.split('.')[0] ) + const currentPgVersion = Number.isNaN(parsedPgVersion) ? undefined : parsedPgVersion const canDeployReplica = !isReachedMaxReplicas && + currentPgVersion !== undefined && currentPgVersion >= 15 && isAWSProvider && hasReadReplicaAccess && @@ -68,7 +66,7 @@ export const useCheckEligibilityDeployReplica = () => { hasOverdueInvoices, isAWSProvider, isAwsK8s, - isPgVersionBelow15: currentPgVersion < 15, + isPgVersionBelow15: currentPgVersion === undefined || currentPgVersion < 15, isBelowSmallCompute, isWalgNotEnabled: !isWalgEnabled, isProWithSpendCapEnabled, diff --git a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/ReadReplicaForm/useGetReplicaCost.ts b/apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicaForm/useGetReplicaCost.ts similarity index 85% rename from apps/studio/components/interfaces/Database/Replication/DestinationPanel/ReadReplicaForm/useGetReplicaCost.ts rename to apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicaForm/useGetReplicaCost.ts index 01905ca1e2f7f..610b3b7c72fa3 100644 --- a/apps/studio/components/interfaces/Database/Replication/DestinationPanel/ReadReplicaForm/useGetReplicaCost.ts +++ b/apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicaForm/useGetReplicaCost.ts @@ -65,15 +65,21 @@ export const useGetReplicaCost = () => { }, disk: { type, - label: `${((size_gb ?? 0) * 1.25).toLocaleString()} GB (${type})`, + label: + size_gb !== undefined && size_gb !== null && type !== undefined && type !== null + ? `${(size_gb * 1.25).toLocaleString()} GB (${type})` + : undefined, cost: formatCurrency(additionalCostDiskSize), }, iops: { - label: `${iops?.toLocaleString()} IOPS`, + label: iops !== undefined && iops !== null ? `${iops.toLocaleString()} IOPS` : undefined, cost: formatCurrency(+additionalCostIOPS), }, throughput: { - label: `${throughput_mbps?.toLocaleString()} MB/s`, + label: + throughput_mbps !== undefined && throughput_mbps !== null + ? `${throughput_mbps.toLocaleString()} MB/s` + : undefined, cost: formatCurrency(+additionalCostThroughput), }, } diff --git a/apps/studio/components/interfaces/Database/Replication/ReadReplicas/ReadReplicaRow.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicaRow.tsx similarity index 98% rename from apps/studio/components/interfaces/Database/Replication/ReadReplicas/ReadReplicaRow.tsx rename to apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicaRow.tsx index 51ee5787cdb61..5895abb13d877 100644 --- a/apps/studio/components/interfaces/Database/Replication/ReadReplicas/ReadReplicaRow.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicaRow.tsx @@ -20,8 +20,8 @@ import { } from 'ui' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' -import { REPLICA_STATUS } from '../Replication.constants' import { DropReplicaConfirmationModal } from './DropReplicaConfirmationModal' +import { REPLICA_STATUS } from './ReadReplicas.constants' import { getIsInTransition, getStatusLabel } from './ReadReplicas.utils' import { RestartReplicaConfirmationModal } from './RestartReplicaConfirmationModal' import { useReplicationLagQuery } from '@/data/read-replicas/replica-lag-query' diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicas.constants.ts b/apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicas.constants.ts new file mode 100644 index 0000000000000..50f8d4f6e7f40 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicas.constants.ts @@ -0,0 +1,11 @@ +import { components } from 'api-types' + +import { PROJECT_STATUS } from '@/lib/constants' + +export const REPLICA_STATUS: { + [key: string]: components['schemas']['DatabaseStatusResponse']['status'] +} = { + ...PROJECT_STATUS, + INIT_READ_REPLICA: 'INIT_READ_REPLICA', + INIT_READ_REPLICA_FAILED: 'INIT_READ_REPLICA_FAILED', +} diff --git a/apps/studio/components/interfaces/Database/Replication/ReadReplicas/ReadReplicas.utils.ts b/apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicas.utils.ts similarity index 96% rename from apps/studio/components/interfaces/Database/Replication/ReadReplicas/ReadReplicas.utils.ts rename to apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicas.utils.ts index 1ecd153801673..2c47ebd6facf7 100644 --- a/apps/studio/components/interfaces/Database/Replication/ReadReplicas/ReadReplicas.utils.ts +++ b/apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicas.utils.ts @@ -1,4 +1,4 @@ -import { REPLICA_STATUS } from '../Replication.constants' +import { REPLICA_STATUS } from './ReadReplicas.constants' import { ReplicaInitializationStatus } from '@/data/read-replicas/replicas-status-query' export const getIsInTransition = ({ diff --git a/apps/studio/components/interfaces/Database/Replication/ReadReplicas/RestartReplicaConfirmationModal.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/RestartReplicaConfirmationModal.tsx similarity index 98% rename from apps/studio/components/interfaces/Database/Replication/ReadReplicas/RestartReplicaConfirmationModal.tsx rename to apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/RestartReplicaConfirmationModal.tsx index c8a6c2c57863f..bac0a3d85e13c 100644 --- a/apps/studio/components/interfaces/Database/Replication/ReadReplicas/RestartReplicaConfirmationModal.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/ReadReplicas/RestartReplicaConfirmationModal.tsx @@ -3,7 +3,7 @@ import { useParams } from 'common' import { toast } from 'sonner' import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' -import { REPLICA_STATUS } from '../Replication.constants' +import { REPLICA_STATUS } from './ReadReplicas.constants' import { useProjectRestartMutation } from '@/data/projects/project-restart-mutation' import { replicaKeys } from '@/data/read-replicas/keys' import { Database } from '@/data/read-replicas/replicas-query' diff --git a/apps/studio/pages/project/[ref]/database/replication/replica/[replicaId].tsx b/apps/studio/pages/project/[ref]/database/replication/replica/[replicaId].tsx index ffabcbeed2064..e129249b1e3c4 100644 --- a/apps/studio/pages/project/[ref]/database/replication/replica/[replicaId].tsx +++ b/apps/studio/pages/project/[ref]/database/replication/replica/[replicaId].tsx @@ -8,14 +8,14 @@ import { AWS_REGIONS } from 'shared-data' import { Badge, Button } from 'ui' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' -import { DropReplicaConfirmationModal } from '@/components/interfaces/Database/Replication/ReadReplicas/DropReplicaConfirmationModal' -import { ReadReplicaDetails } from '@/components/interfaces/Database/Replication/ReadReplicas/ReadReplicaDetails' +import { DropReplicaConfirmationModal } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/DropReplicaConfirmationModal' +import { ReadReplicaDetails } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicaDetails' +import { REPLICA_STATUS } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicas.constants' import { getIsInTransition, getStatusLabel, -} from '@/components/interfaces/Database/Replication/ReadReplicas/ReadReplicas.utils' -import { RestartReplicaConfirmationModal } from '@/components/interfaces/Database/Replication/ReadReplicas/RestartReplicaConfirmationModal' -import { REPLICA_STATUS } from '@/components/interfaces/Database/Replication/Replication.constants' +} from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicas.utils' +import { RestartReplicaConfirmationModal } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/RestartReplicaConfirmationModal' import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout' import { DefaultLayout } from '@/components/layouts/DefaultLayout' import { PageLayout } from '@/components/layouts/PageLayout/PageLayout' diff --git a/apps/studio/tests/components/Database/Replication/ReadReplicaEligibilityWarnings.test.tsx b/apps/studio/tests/components/Settings/Infrastructure/ReadReplicaEligibilityWarnings.test.tsx similarity index 91% rename from apps/studio/tests/components/Database/Replication/ReadReplicaEligibilityWarnings.test.tsx rename to apps/studio/tests/components/Settings/Infrastructure/ReadReplicaEligibilityWarnings.test.tsx index 10bce0f7d1ca0..c9471145d670e 100644 --- a/apps/studio/tests/components/Database/Replication/ReadReplicaEligibilityWarnings.test.tsx +++ b/apps/studio/tests/components/Settings/Infrastructure/ReadReplicaEligibilityWarnings.test.tsx @@ -1,13 +1,13 @@ import { screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' -import { ReadReplicaEligibilityWarnings } from '@/components/interfaces/Database/Replication/DestinationPanel/ReadReplicaForm/ReadReplicaEligibilityWarnings' -import { useCheckEligibilityDeployReplica } from '@/components/interfaces/Database/Replication/DestinationPanel/ReadReplicaForm/useCheckEligibilityDeployReplica' +import { ReadReplicaEligibilityWarnings } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicaForm/ReadReplicaEligibilityWarnings' +import { useCheckEligibilityDeployReplica } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicaForm/useCheckEligibilityDeployReplica' import { READ_REPLICAS_MAX_COUNT } from '@/data/read-replicas/replicas-query' import { customRender } from '@/tests/lib/custom-render' vi.mock( - '@/components/interfaces/Database/Replication/DestinationPanel/ReadReplicaForm/useCheckEligibilityDeployReplica' + '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicaForm/useCheckEligibilityDeployReplica' ) vi.mock('@/data/projects/project-detail-query', () => ({ useProjectDetailQuery: () => ({ data: undefined, isSuccess: false }), From 39c39602157229c6acc776d715a9cfe421fffc43 Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Fri, 14 Aug 2026 16:16:54 +1000 Subject: [PATCH 03/11] fix(studio): harden Explorer query persistence (#49039) image ## Summary - debounce draft persistence and flush pending edits when the page exits - validate untrusted local storage with Zod, recover from malformed entries, and retain the 50 most recently updated drafts - harden route restoration, recent-item routing, close confirmation, and local cleanup - disable execution while project or replica data is resolving and fail closed for missing replicas - use an HTTP-safe UUID generator for self-hosted Studio - adopt the upstream Explorer toolbar title API - expand component and state coverage for persistence and execution behavior ## To test 1. Open Explorer, select **Run SQL**, then enter SQL and rename the query. 2. Reload the page and confirm the draft is restored; close its tab and confirm it is discarded after the prompt. ## Why This layer makes local-only Explorer drafts resilient to rapid edits, reloads, stale browser data, and tab lifecycle edge cases. ## Impact Queries remain local-only, and closing their tabs discards them after confirmation. Save-as-notebook functionality remains intentionally out of scope. ## Validation - fresh non-incremental Studio TypeScript check - 68 Vitest tests pass across Explorer, query sources, tabs layout, and query/tab state - Studio ESLint ratchet and Prettier check both clean ## Summary by CodeRabbit - **New Features** - Explorer query drafts now save automatically and restore reliably across navigation, tab closures, page exits, and visibility changes. - Recent query items now open directly to their associated Explorer query. - Logs time-range selections are handled consistently, including custom ranges and preset matching. - **Bug Fixes** - Prevented stale query loading states when switching between queries. - Improved handling of invalid or outdated saved drafts. - Limited saved drafts to the 50 most recently updated queries. --- .../Explorer/ExplorerQuerySourceMenu.test.tsx | 126 ++++++++++++ .../Explorer/ExplorerQuerySourceMenu.tsx | 41 ++-- .../ExplorerQueryTabCoordinator.test.tsx | 30 +++ .../Explorer/ExplorerQueryTabCoordinator.tsx | 27 ++- .../interfaces/Explorer/QueryTab.test.tsx | 180 ++++++++++++++++++ .../interfaces/Explorer/QueryTab.tsx | 13 +- .../LogsTimeRangeSubMenu.test.tsx | 28 ++- .../components/layouts/Tabs/RecentItems.tsx | 2 + apps/studio/state/explorer-query.test.ts | 112 ++++++++++- apps/studio/state/explorer-query.ts | 104 +++++++--- apps/studio/state/tabs.test.ts | 53 ++++++ 11 files changed, 643 insertions(+), 73 deletions(-) create mode 100644 apps/studio/components/interfaces/Explorer/ExplorerQuerySourceMenu.test.tsx create mode 100644 apps/studio/components/interfaces/Explorer/ExplorerQueryTabCoordinator.test.tsx create mode 100644 apps/studio/components/interfaces/Explorer/QueryTab.test.tsx diff --git a/apps/studio/components/interfaces/Explorer/ExplorerQuerySourceMenu.test.tsx b/apps/studio/components/interfaces/Explorer/ExplorerQuerySourceMenu.test.tsx new file mode 100644 index 0000000000000..18f0e01c17cb2 --- /dev/null +++ b/apps/studio/components/interfaces/Explorer/ExplorerQuerySourceMenu.test.tsx @@ -0,0 +1,126 @@ +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { FeatureFlagContext } from 'common' +import { mockAnimationsApi } from 'jsdom-testing-mocks' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { ExplorerQuerySourceMenu } from './ExplorerQuerySourceMenu' +import { customRender } from '@/tests/lib/custom-render' +import { addAPIMock } from '@/tests/lib/msw' + +mockAnimationsApi() + +beforeEach(() => { + addAPIMock({ + method: 'get', + path: '/platform/projects/:ref', + response: { + id: 1, + ref: 'default', + organization_id: 1, + name: 'Test Project', + status: 'ACTIVE_HEALTHY', + cloud_provider: 'AWS', + region: 'us-east-1', + db_host: 'db.default.supabase.co', + restUrl: 'https://default.supabase.co/rest/v1/', + inserted_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + subscription_id: 'sub_123', + is_branch_enabled: false, + is_physical_backups_enabled: false, + high_availability: false, + integration_source: null, + connectionString: 'postgresql://postgres@localhost:5432/postgres', + is_hibernating: false, + }, + }) +}) + +describe('ExplorerQuerySourceMenu', () => { + const renderWithFlags = ( + source: Parameters[0]['source'], + flags: Record + ) => + customRender( + + + + ) + + it('emits a complete default binding when the query changes source', async () => { + const onSourceChange = vi.fn() + + customRender( + + ) + + await userEvent.click(screen.getByRole('button', { name: 'Query source: Logs' })) + await userEvent.click(screen.getByText('Database')) + + expect(onSourceChange).toHaveBeenCalledWith({ + id: 'database', + type: 'database', + parameters: {}, + }) + }) + + it('emits the selected log time range as source parameters', async () => { + const onSourceChange = vi.fn() + + customRender( + + ) + + await userEvent.click(screen.getByRole('button', { name: 'Query source: Logs' })) + await userEvent.hover(screen.getByText('Time range')) + await userEvent.click(await screen.findByText('Last 3 hours')) + + expect(onSourceChange).toHaveBeenCalledWith({ + id: 'logs', + type: 'logs', + parameters: { time_range: { type: 'relative', amount: 3, unit: 'hour' } }, + }) + }) + + it('does not offer logs when source flags are disabled for a database query', async () => { + renderWithFlags( + { id: 'database', type: 'database', parameters: {} }, + { sqlEditorLogsSource: false, otelLegacyLogs: false } + ) + + await userEvent.click(screen.getByRole('button', { name: 'Query source: Database' })) + + expect(screen.queryByText('Logs')).not.toBeInTheDocument() + }) + + it('keeps logs available when an existing query already uses it', async () => { + renderWithFlags( + { + id: 'logs', + type: 'logs', + parameters: { time_range: { type: 'relative', amount: 1, unit: 'hour' } }, + }, + { sqlEditorLogsSource: false, otelLegacyLogs: false } + ) + + await userEvent.click(screen.getByRole('button', { name: 'Query source: Logs' })) + + expect(screen.getAllByText('Logs')).toHaveLength(2) + expect(screen.getByText('Database')).toBeInTheDocument() + }) +}) diff --git a/apps/studio/components/interfaces/Explorer/ExplorerQuerySourceMenu.tsx b/apps/studio/components/interfaces/Explorer/ExplorerQuerySourceMenu.tsx index 89102a59e4b51..eaccc9a72570c 100644 --- a/apps/studio/components/interfaces/Explorer/ExplorerQuerySourceMenu.tsx +++ b/apps/studio/components/interfaces/Explorer/ExplorerQuerySourceMenu.tsx @@ -1,7 +1,5 @@ import { useFlag, useParams } from 'common' -import dayjs from 'dayjs' import { Check, ChevronDown } from 'lucide-react' -import { useState } from 'react' import { Button, DropdownMenu, @@ -15,7 +13,7 @@ import { DatabaseParametersSubMenu } from '@/components/interfaces/QuerySources/ import { LogsCustomRangeDialog } from '@/components/interfaces/QuerySources/LogsCustomRangeDialog' import { LogsTimeRangeSubMenu } from '@/components/interfaces/QuerySources/LogsTimeRangeSubMenu' import { QuerySourceIcon } from '@/components/interfaces/QuerySources/QuerySourceIcon' -import { maybeShowUpgradePromptIfNotEntitled } from '@/components/interfaces/Settings/Logs/Logs.utils' +import { useLogsCustomRange } from '@/components/interfaces/QuerySources/useLogsCustomRange' import UpgradePrompt from '@/components/interfaces/Settings/Logs/UpgradePrompt' import { createDefaultCellSource, @@ -23,7 +21,6 @@ import { QUERY_SOURCES, type CellSource, } from '@/data/query-sources/query-source-registry' -import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' export type ExplorerQuerySourceMenuProps = { source: CellSource @@ -42,10 +39,16 @@ export const ExplorerQuerySourceMenu = ({ const { ref } = useParams() const isLogsSourceEnabled = useFlag('sqlEditorLogsSource') const isOtelLogsEnabled = useFlag('otelLegacyLogs') - const [isCustomRangeOpen, setIsCustomRangeOpen] = useState(false) - const [showUpgradePrompt, setShowUpgradePrompt] = useState(false) - const { getEntitlementNumericValue } = useCheckEntitlements('log.retention_days') - const entitledToLogDays = getEntitlementNumericValue() + const { + isCustomRangeOpen, + setIsCustomRangeOpen, + showUpgradePrompt, + setShowUpgradePrompt, + handleApplyCustomRange, + } = useLogsCustomRange({ + onRangeChange: (timeRange) => + onSourceChange({ id: 'logs', type: 'logs', parameters: { time_range: timeRange } }), + }) const availableSources = QUERY_SOURCES.filter( (candidate) => @@ -54,26 +57,6 @@ export const ExplorerQuerySourceMenu = ({ source.type === 'logs' ) - const applyCustomRange = ({ from, to }: { from: Date; to: Date }) => { - const fromIso = dayjs(from).startOf('day').toISOString() - if (maybeShowUpgradePromptIfNotEntitled(fromIso, entitledToLogDays)) { - setShowUpgradePrompt(true) - return - } - - onSourceChange({ - id: 'logs', - type: 'logs', - parameters: { - time_range: { - type: 'absolute', - from: fromIso, - to: dayjs(to).endOf('day').toISOString(), - }, - }, - }) - } - return ( <> @@ -143,7 +126,7 @@ export const ExplorerQuerySourceMenu = ({ diff --git a/apps/studio/components/interfaces/Explorer/ExplorerQueryTabCoordinator.test.tsx b/apps/studio/components/interfaces/Explorer/ExplorerQueryTabCoordinator.test.tsx new file mode 100644 index 0000000000000..916214c59e023 --- /dev/null +++ b/apps/studio/components/interfaces/Explorer/ExplorerQueryTabCoordinator.test.tsx @@ -0,0 +1,30 @@ +import { LOCAL_STORAGE_KEYS } from 'common' +import { afterEach, describe, expect, it } from 'vitest' + +import { ExplorerQueryTabCoordinator } from './ExplorerQueryTabCoordinator' +import { explorerQueryState } from '@/state/explorer-query' +import { createTabsState, TabsStateContext } from '@/state/tabs' +import { customRender } from '@/tests/lib/custom-render' + +const QUERY_ID = 'pagehide-query' + +afterEach(() => { + explorerQueryState.removeDraft({ id: QUERY_ID, projectRef: 'default' }) +}) + +describe('ExplorerQueryTabCoordinator', () => { + it('flushes pending query edits when the page is hidden', () => { + const key = LOCAL_STORAGE_KEYS.EXPLORER_QUERY_DRAFTS('default') + explorerQueryState.createDraft({ id: QUERY_ID, projectRef: 'default' }) + explorerQueryState.updateDraft({ id: QUERY_ID, sql: 'select 1' }) + + customRender( + + + + ) + window.dispatchEvent(new Event('pagehide')) + + expect(JSON.parse(localStorage.getItem(key) ?? '{}')[QUERY_ID].sql).toBe('select 1') + }) +}) diff --git a/apps/studio/components/interfaces/Explorer/ExplorerQueryTabCoordinator.tsx b/apps/studio/components/interfaces/Explorer/ExplorerQueryTabCoordinator.tsx index 14e8e58284106..51848b4f8625d 100644 --- a/apps/studio/components/interfaces/Explorer/ExplorerQueryTabCoordinator.tsx +++ b/apps/studio/components/interfaces/Explorer/ExplorerQueryTabCoordinator.tsx @@ -15,12 +15,14 @@ export const ExplorerQueryTabCoordinator = () => { useEffect(() => { return tabs.registerTabTypeHandler('query', { confirmClose: (queryTabs) => { - const populatedDraftCount = queryTabs.filter((tab) => { + for (const tab of queryTabs) { const queryId = tab.metadata?.queryId - if (!ref || !queryId) return false - - explorerQueryState.restoreDraft({ id: queryId, projectRef: ref }) + if (ref && queryId) explorerQueryState.restoreDraft({ id: queryId, projectRef: ref }) + } + const populatedDraftCount = queryTabs.filter((tab) => { + const queryId = tab.metadata?.queryId + if (!queryId) return false return explorerQueryState.drafts[queryId]?.uncheckedSql.trim().length > 0 }).length @@ -41,5 +43,22 @@ export const ExplorerQueryTabCoordinator = () => { }) }, [ref, tabs]) + useEffect(() => { + if (!ref) return + + const flushProjectDrafts = () => explorerQueryState.flushPendingPersistence({ projectRef: ref }) + const handleVisibilityChange = () => { + if (document.visibilityState === 'hidden') flushProjectDrafts() + } + + window.addEventListener('pagehide', flushProjectDrafts) + document.addEventListener('visibilitychange', handleVisibilityChange) + + return () => { + window.removeEventListener('pagehide', flushProjectDrafts) + document.removeEventListener('visibilitychange', handleVisibilityChange) + } + }, [ref]) + return null } diff --git a/apps/studio/components/interfaces/Explorer/QueryTab.test.tsx b/apps/studio/components/interfaces/Explorer/QueryTab.test.tsx new file mode 100644 index 0000000000000..b732645d8a50e --- /dev/null +++ b/apps/studio/components/interfaces/Explorer/QueryTab.test.tsx @@ -0,0 +1,180 @@ +import { act, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { HttpResponse } from 'msw' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { QueryTab } from './QueryTab' +import type { ReadReplicasData } from '@/data/read-replicas/replicas-query' +import { explorerQueryState } from '@/state/explorer-query' +import { createTabsState, TabsStateContext } from '@/state/tabs' +import { customRender } from '@/tests/lib/custom-render' +import { addAPIMock } from '@/tests/lib/msw' +import { setupSqlEditorMocks } from '@/tests/lib/sql-editor-test-utils' + +const testContext = vi.hoisted(() => ({ + flags: { otelLegacyLogs: true } as Record, + params: { ref: 'default', id: 'query-test' } as { ref?: string; id?: string }, +})) + +vi.mock('common', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + IS_PLATFORM: true, + useParams: () => testContext.params, + useFlag: (flag: string) => testContext.flags[flag] ?? false, + } +}) + +vi.mock('@/components/ui/CodeEditor/CodeEditor', () => ({ + CodeEditor: ({ value }: { value: string }) => ( +